refactor(agents): drop template chooser from create-agent dialog (#2615)

* refactor(agents): drop template chooser from create-agent dialog

Removes the blank-vs-template chooser, the template picker, and the
template detail step. The "Create agent" entry point now opens directly
on the form. The createAgentFromTemplate API and types remain
untouched — this only removes the UI entry.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
Co-authored-by: multica-agent <[email protected]>

* docs(squads): fix stale comment about createAgentFromTemplate

Squad-scoped create flow no longer goes through the template path;
the dialog now only calls api.createAgent then api.addSquadMember.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 <[email protected]>
Co-authored-by: multica-agent <[email protected]>
This commit is contained in:
Naiyuan Qing
2026-05-14 18:05:37 +08:00
committed by GitHub
co-authored by multica-agent Claude Opus 4.7
parent c98161b039
commit 43b9a1173c
9 changed files with 161 additions and 1163 deletions
@@ -458,7 +458,6 @@ export function AgentsPage() {
members={members}
currentUserId={currentUser?.id ?? null}
template={duplicateTemplate}
existingAgentNames={agents.map((a) => a.name)}
onClose={() => {
setShowCreate(false);
setDuplicateTemplate(null);
@@ -146,13 +146,6 @@ function renderDialog(runtimes: RuntimeDevice[], template?: Agent) {
</QueryClientProvider>
</I18nProvider>,
);
// Without a `template`, the dialog opens on the blank-vs-template
// chooser. These tests target the manual form's runtime picker, so
// advance through the chooser to the form. Duplicate mode jumps
// straight to the form and doesn't render the chooser.
if (!template) {
fireEvent.click(screen.getByText(enAgents.create_dialog.chooser.blank_title));
}
return { onCreate, onClose };
}
@@ -1,29 +1,18 @@
"use client";
import { useState } from "react";
import {
ArrowLeft,
FileText,
Globe,
Lock,
PenLine,
} from "lucide-react";
import { Globe, Lock } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { ModelDropdown } from "./model-dropdown";
import { RuntimePicker, isRuntimeUsableForUser } from "./runtime-picker";
import { TemplatePicker } from "./template-picker";
import { TemplateDetail } from "./template-detail";
import { InstructionsEditor } from "./instructions-editor";
import { SkillMultiSelect } from "./skill-multi-select";
import { AvatarPicker } from "./avatar-picker";
import { useNavigation } from "../../navigation";
import { api } from "@multica/core/api";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspacePaths } from "@multica/core/paths";
import { workspaceKeys } from "@multica/core/workspace/queries";
import type {
Agent,
AgentTemplateSummary,
AgentVisibility,
RuntimeDevice,
MemberWithUser,
@@ -40,7 +29,6 @@ import {
import { Button } from "@multica/ui/components/ui/button";
import { Input } from "@multica/ui/components/ui/input";
import { Label } from "@multica/ui/components/ui/label";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import {
AGENT_DESCRIPTION_MAX_LENGTH,
@@ -50,64 +38,12 @@ import {
import { CharCounter } from "./char-counter";
import { useT } from "../../i18n";
// State machine encoded as a discriminated union.
//
// chooser → "Start blank" or "From template" cards
// blank-form → standard manual-create form (post-chooser)
// duplicate-form → form pre-filled from a duplicated agent (entry only)
// template-picker → grid of templates — clicking a card immediately
// creates the agent (no intermediate form step);
// the user refines on the detail page if needed.
type DialogStep =
| { kind: "chooser" }
| { kind: "blank-form" }
| { kind: "duplicate-form" }
| { kind: "template-picker" }
| { kind: "template-detail"; template: AgentTemplateSummary };
/** Helper: which step kinds render the form view. Template path is now
* a one-click flow (picker → API call → close), so it never lands on
* the form — defaults are auto-populated, the user customises later
* on the detail page. */
function isFormStep(step: DialogStep): step is { kind: "blank-form" } | { kind: "duplicate-form" } {
return step.kind === "blank-form" || step.kind === "duplicate-form";
}
/**
* Per-step dialog sizing.
*
* Width is wider on info-dense steps (picker, detail, form) — `max-w-5xl`
* gives instructions and skill descriptions room to breathe without
* wrapping every line. Chooser stays at `max-w-4xl`: only two cards, more
* width just creates empty side-margin.
*
* Heights are **fixed pixel-ish values** because CSS `transition` cannot
* interpolate to/from `height: auto` (the keyword isn't a number). Both
* `h-[420px]` (chooser) and `h-[85vh]` (others) are numeric and animate
* cleanly.
*
* `transition-[height,max-width]` runs whenever the step changes, giving
* the modal a smooth grow/shrink instead of a hard size pop. !important
* is required because the Dialog primitive sets its own `duration-100`.
*/
function agentDialogContentClass(step: DialogStep): string {
return cn(
"p-0 gap-0 flex flex-col overflow-hidden",
"!top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2",
"!w-full",
"!transition-[height,max-width] !duration-300 !ease-out",
step.kind === "chooser" ? "!max-w-4xl" : "!max-w-5xl",
step.kind === "chooser" ? "!h-[420px]" : "!h-[85vh]",
);
}
export function CreateAgentDialog({
runtimes,
runtimesLoading,
members,
currentUserId,
template,
existingAgentNames,
squadId,
onClose,
onCreate,
@@ -124,19 +60,11 @@ export function CreateAgentDialog({
// Skills are copied separately by the caller after createAgent
// succeeds — they're not part of CreateAgentRequest.
template?: Agent | null;
// Names of agents already in the workspace, used to auto-dedupe the
// default name when picking a template (so the form lands on a unique
// name instead of immediately hitting the backend's 409). Optional —
// when absent, default names are used verbatim and 409 stays the
// safety net.
existingAgentNames?: readonly string[];
// When set, every successful create (manual, duplicate, or template)
// is followed by addSquadMember(squadId, agent) so the new agent
// joins this squad. The template path also skips its usual
// navigation to the agent detail page so the user stays on the
// squad. If the squad-join call fails the agent still exists and
// the dialog surfaces a warning toast — the user can add it
// manually from the Members tab.
// When set, every successful create is followed by
// addSquadMember(squadId, agent) so the new agent joins this squad.
// If the squad-join call fails the agent still exists and the dialog
// surfaces a warning toast — the user can add it manually from the
// Members tab.
squadId?: string;
onClose: () => void;
// Returns the created Agent so the dialog can run a follow-up
@@ -149,17 +77,8 @@ export function CreateAgentDialog({
const isDuplicate = !!template;
const queryClient = useQueryClient();
const wsId = useWorkspaceId();
const navigation = useNavigation();
const paths = useWorkspacePaths();
// Duplicate path comes in via the row action with a concrete agent —
// chooser would be redundant, jump straight to the form.
const [step, setStep] = useState<DialogStep>(
isDuplicate ? { kind: "duplicate-form" } : { kind: "chooser" },
);
// Name defaults: duplicate uses "<original> copy", template uses the
// template's name verbatim. Manual-create starts blank.
// Name defaults: duplicate uses "<original> copy". Manual-create starts blank.
const [name, setName] = useState(
template ? `${template.name}${t(($) => $.create_dialog.duplicate_copy_suffix)}` : "",
);
@@ -168,18 +87,12 @@ export function CreateAgentDialog({
template?.visibility ?? "workspace",
);
const [model, setModel] = useState(template?.model ?? "");
// Optional fields exposed through the collapsible sections at the bottom
// of the form. Each initialises from the duplicate template (if any);
// template-flow defaults are populated when the user picks one (see
// goToTemplateForm). Selected skills are stored as IDs only — the
// workspace skill list is fetched on demand by SkillMultiSelect.
const [instructions, setInstructions] = useState(template?.instructions ?? "");
const [avatarUrl, setAvatarUrl] = useState<string | null>(template?.avatar_url ?? null);
const [selectedSkillIds, setSelectedSkillIds] = useState<Set<string>>(
() => new Set(template?.skills.map((s) => s.id) ?? []),
);
const [creating, setCreating] = useState(false);
const [failedURLs, setFailedURLs] = useState<string[] | null>(null);
// Duplicate-mode pre-fill: clone lands on the source agent's runtime so
// the user doesn't have to re-pick. Skipped when that runtime is now
@@ -205,18 +118,6 @@ export function CreateAgentDialog({
selectedRuntime != null &&
!isRuntimeUsableForUser(selectedRuntime, currentUserId);
// Transition helpers. Each centralises the form-field initialisation
// for its target step, so transitions can't leave stale state behind.
const goToBlankForm = () => {
setName("");
setDescription("");
setInstructions("");
setAvatarUrl(null);
setSelectedSkillIds(new Set());
setStep({ kind: "blank-form" });
};
// Shared squad-join follow-up. Returns nothing — the caller has
// already shown its create-success toast; we only need to surface a
// warning when the agent landed but the squad-join failed. Cache
@@ -247,106 +148,12 @@ export function CreateAgentDialog({
}
};
// Template path is one-click — picker card click goes straight to the
// API. Defaults: name auto-deduped, runtime = first usable one,
// visibility = workspace. User refines on the detail page if needed.
// On 422 with failed_urls the user stays on the template-detail step
// and the banner there reports the bad URLs; on any other error we
// surface a toast and reset the spinner so they can retry.
const quickCreateFromTemplate = async (tmpl: AgentTemplateSummary) => {
if (!selectedRuntime || selectedRuntimeLocked) {
toast.error(t(($) => $.create_dialog.no_runtime_toast));
return;
}
const taken = new Set(existingAgentNames ?? []);
let candidate = tmpl.name;
let n = 2;
while (taken.has(candidate)) {
candidate = `${tmpl.name} ${n}`;
n++;
}
setFailedURLs(null);
setCreating(true);
try {
const resp = await api.createAgentFromTemplate({
template_slug: tmpl.slug,
name: candidate,
runtime_id: selectedRuntime.id,
model: model.trim() || undefined,
visibility: "workspace",
});
if (wsId) {
queryClient.invalidateQueries({ queryKey: workspaceKeys.agents(wsId) });
queryClient.invalidateQueries({ queryKey: workspaceKeys.skills(wsId) });
}
if (resp.reused_skill_ids.length > 0) {
toast.success(
t(($) => $.create_dialog.template_created_with_reuse_toast, {
name: candidate,
count: resp.reused_skill_ids.length,
}),
);
} else {
toast.success(
t(($) => $.create_dialog.template_created_toast, { name: candidate }),
);
}
// Squad context: attach the freshly created agent to the squad
// before closing — and skip the agent-detail navigation so the
// user lands back on the squad page where they triggered the
// flow. Failures here are non-fatal; the agent exists and can be
// added manually if the join call 4xxs.
if (squadId && resp.agent.id) {
await attachToSquad(resp.agent.id, candidate);
}
onClose();
// Land on the new agent's detail page so the user can verify or
// customise instructions / skills / avatar — matches the navigation
// behaviour of the manual create path in agents-page.tsx. When the
// response failed schema parsing (`agent.id === ""`, see schema
// fallback) we skip the navigation: the agent was created server-
// side, the list-invalidation above will surface it, and a push
// to `/agents/` would land on a broken detail page. Squad-context
// entry also skips the push — the user wants to stay on Members.
if (resp.agent.id && !squadId) {
navigation.push(paths.agentDetail(resp.agent.id));
}
} catch (err) {
const body = extractFailureBody(err);
if (body?.failed_urls?.length) {
setFailedURLs(body.failed_urls);
} else {
toast.error(
err instanceof Error ? err.message : t(($) => $.create_dialog.create_failed_toast),
);
}
setCreating(false);
}
};
const handleSubmit = async () => {
if (!name.trim() || !selectedRuntime || selectedRuntimeLocked) return;
setFailedURLs(null);
setCreating(true);
// Template path goes through quickCreateFromTemplate directly from
// the picker — no form step. So handleSubmit only fires for blank /
// duplicate flows below.
// Scratch / duplicate path: hits the manual POST /api/agents through
// the caller's onCreate callback, then we run a follow-up
// setAgentSkills here for any skills the user selected in the form.
// Duplicate path already had this two-phase behaviour in
// agents-page.tsx; we extend it by sending whichever skill IDs the
// user explicitly chose in the dialog (overriding duplicate's
// copy-source skills if both apply).
try {
const trimmedInstructions = instructions.trim();
// Manual fields take precedence over duplicate source for
// instructions / avatar / skills — duplicate fills the defaults at
// mount time, then anything the user edits overrides those defaults
// naturally because we read from state.
const data: CreateAgentRequest = {
name: name.trim(),
description: description.trim(),
@@ -412,345 +219,176 @@ export function CreateAgentDialog({
}
};
// Header title — narrowed by step.kind, so reads on `step.template` are
// type-safe and there's no fallback string compilation needed.
const headerTitle = (() => {
switch (step.kind) {
case "chooser":
return t(($) => $.create_dialog.title_create);
case "blank-form":
return t(($) => $.create_dialog.title_create);
case "duplicate-form":
return t(($) => $.create_dialog.title_duplicate);
case "template-picker":
return t(($) => $.create_dialog.template_picker.title);
case "template-detail":
return step.template.name;
}
})();
// Back navigation — each kind maps to one previous kind. Duplicate-form
// and chooser have no back (duplicate enters from a row action, chooser
// is the root).
const handleBack = () => {
switch (step.kind) {
case "template-picker":
setStep({ kind: "chooser" });
return;
case "template-detail":
setStep({ kind: "template-picker" });
return;
case "blank-form":
setStep({ kind: "chooser" });
return;
case "chooser":
case "duplicate-form":
return;
}
};
const showBackButton =
step.kind === "template-picker" ||
step.kind === "template-detail" ||
step.kind === "blank-form";
const headerTitle = isDuplicate
? t(($) => $.create_dialog.title_duplicate)
: t(($) => $.create_dialog.title_create);
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent className={agentDialogContentClass(step)}>
<DialogContent className="p-0 gap-0 flex flex-col overflow-hidden !top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2 !w-full !max-w-5xl !h-[85vh]">
<DialogHeader className="border-b px-5 py-3 space-y-0">
<div className="flex items-center gap-2">
{showBackButton && (
<button
type="button"
onClick={handleBack}
className="rounded-md p-1 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={t(($) => $.create_dialog.back_aria)}
>
<ArrowLeft className="h-4 w-4" />
</button>
)}
<DialogTitle className="text-base font-semibold">{headerTitle}</DialogTitle>
</div>
{step.kind === "chooser" && (
<DialogDescription className="mt-1 text-xs">
{t(($) => $.create_dialog.description_create)}
</DialogDescription>
)}
{step.kind === "duplicate-form" && template && (
<DialogTitle className="text-base font-semibold">{headerTitle}</DialogTitle>
{isDuplicate && template && (
<DialogDescription className="mt-1 text-xs">
{t(($) => $.create_dialog.description_duplicate, { name: template.name })}
</DialogDescription>
)}
{!isDuplicate && (
<DialogDescription className="mt-1 text-xs">
{t(($) => $.create_dialog.description_create)}
</DialogDescription>
)}
</DialogHeader>
{/* ---------- Step 1: Chooser (two large cards, vertically centred) ---------- */}
{step.kind === "chooser" && (
<div className="flex flex-1 items-center justify-center p-8">
<div className="grid w-full max-w-3xl grid-cols-2 gap-4">
<button
type="button"
onClick={goToBlankForm}
className="group flex flex-col items-center justify-center gap-3 rounded-xl border bg-card p-8 text-center transition-colors hover:border-primary/40 hover:bg-accent/40"
>
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-muted text-muted-foreground group-hover:bg-accent group-hover:text-foreground">
<PenLine className="h-7 w-7" />
</div>
<div className="flex-1 overflow-y-auto p-5">
<div className="space-y-4 min-w-0">
{/* Identity row: avatar (left) + name & description stack
(right). The avatar visually anchors the identity of
what the user is creating; pairing it with the Name
field reads as "this is the agent's face + name",
same shape as detail-page header so the affordance is
instantly familiar. */}
<div className="flex items-start gap-4">
<AvatarPicker value={avatarUrl} onChange={setAvatarUrl} size={64} />
<div className="flex-1 min-w-0 space-y-3">
<div>
<div className="text-base font-semibold">
{t(($) => $.create_dialog.chooser.blank_title)}
</div>
<div className="mt-1 text-sm text-muted-foreground">
{t(($) => $.create_dialog.chooser.blank_desc)}
</div>
</div>
</button>
<button
type="button"
onClick={() => setStep({ kind: "template-picker" })}
className="group flex flex-col items-center justify-center gap-3 rounded-xl border bg-card p-8 text-center transition-colors hover:border-primary/40 hover:bg-accent/40"
>
<div className="flex h-14 w-14 items-center justify-center rounded-xl bg-primary/10 text-primary">
<FileText className="h-7 w-7" />
</div>
<div>
<div className="text-base font-semibold">
{t(($) => $.create_dialog.chooser.template_title)}
</div>
<div className="mt-1 text-sm text-muted-foreground">
{t(($) => $.create_dialog.chooser.template_desc)}
</div>
</div>
</button>
</div>
</div>
)}
{/* ---------- Step 2: Template Picker — click a card opens the
detail preview so the user can review instructions / skills
before committing. ---------- */}
{step.kind === "template-picker" && (
<TemplatePicker
onSelect={(tmpl) => setStep({ kind: "template-detail", template: tmpl })}
/>
)}
{/* ---------- Step 3: Template Detail — read-only preview with a
"Use this template" button. Clicking Use fires the one-shot
create with default settings (name auto-deduped, first usable
runtime, private visibility); the user customises further on
the agent detail page if needed. ---------- */}
{step.kind === "template-detail" && (
<TemplateDetail
template={step.template}
onUse={quickCreateFromTemplate}
creating={creating}
failedURLs={failedURLs}
runtimes={runtimes}
runtimesLoading={runtimesLoading}
members={members}
currentUserId={currentUserId}
selectedRuntimeId={selectedRuntimeId}
onRuntimeSelect={setSelectedRuntimeId}
selectedRuntime={selectedRuntime}
model={model}
onModelChange={setModel}
useDisabled={!selectedRuntime || selectedRuntimeLocked}
/>
)}
{/* ---------- Form step (blank / duplicate / template) ---------- */}
{isFormStep(step) && (
<>
<div className="flex-1 overflow-y-auto p-5">
<div className="space-y-4 min-w-0">
{/* failedURLs banner lives in TemplateDetail — it's the
only step that can trigger a 422, and rendering the
error there keeps the user where the action was. */}
{/* Identity row: avatar (left) + name & description stack
(right). The avatar visually anchors the identity of
what the user is creating; pairing it with the Name
field reads as "this is the agent's face + name",
same shape as detail-page header so the affordance is
instantly familiar. */}
<div className="flex items-start gap-4">
<AvatarPicker value={avatarUrl} onChange={setAvatarUrl} size={64} />
<div className="flex-1 min-w-0 space-y-3">
<div>
<Label className="text-xs text-muted-foreground">{t(($) => $.create_dialog.name_label)}</Label>
<Input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t(($) => $.create_dialog.name_placeholder)}
className="mt-1"
onKeyDown={(e) => {
if (isImeComposing(e)) return;
if (e.key === "Enter") handleSubmit();
}}
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">{t(($) => $.create_dialog.description_label)}</Label>
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t(($) => $.create_dialog.description_placeholder)}
maxLength={AGENT_DESCRIPTION_MAX_LENGTH}
className="mt-1"
/>
<div className="mt-1">
<CharCounter
length={[...description].length}
max={AGENT_DESCRIPTION_MAX_LENGTH}
/>
</div>
</div>
</div>
<Label className="text-xs text-muted-foreground">{t(($) => $.create_dialog.name_label)}</Label>
<Input
autoFocus
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t(($) => $.create_dialog.name_placeholder)}
className="mt-1"
onKeyDown={(e) => {
if (isImeComposing(e)) return;
if (e.key === "Enter") handleSubmit();
}}
/>
</div>
<div>
<Label className="text-xs text-muted-foreground">{t(($) => $.create_dialog.visibility_label)}</Label>
<div className="mt-1.5 flex gap-2">
<button
type="button"
onClick={() => setVisibility("workspace")}
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
visibility === "workspace"
? "border-primary bg-primary/5"
: "border-border hover:bg-muted"
}`}
>
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">{VISIBILITY_LABEL.workspace}</div>
<div className="text-xs text-muted-foreground">
{VISIBILITY_DESCRIPTION.workspace}
</div>
</div>
</button>
<button
type="button"
onClick={() => setVisibility("private")}
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
visibility === "private"
? "border-primary bg-primary/5"
: "border-border hover:bg-muted"
}`}
>
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">{VISIBILITY_LABEL.private}</div>
<div className="text-xs text-muted-foreground">
{VISIBILITY_DESCRIPTION.private}
</div>
</div>
</button>
<Label className="text-xs text-muted-foreground">{t(($) => $.create_dialog.description_label)}</Label>
<Input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t(($) => $.create_dialog.description_placeholder)}
maxLength={AGENT_DESCRIPTION_MAX_LENGTH}
className="mt-1"
/>
<div className="mt-1">
<CharCounter
length={[...description].length}
max={AGENT_DESCRIPTION_MAX_LENGTH}
/>
</div>
</div>
<RuntimePicker
runtimes={runtimes}
runtimesLoading={runtimesLoading}
members={members}
currentUserId={currentUserId}
selectedRuntimeId={selectedRuntimeId}
onSelect={setSelectedRuntimeId}
/>
<ModelDropdown
runtimeId={selectedRuntime?.id ?? null}
runtimeOnline={selectedRuntime?.status === "online"}
value={model}
onChange={setModel}
disabled={!selectedRuntime}
/>
{/* --- Optional sections (instructions / skills) ---
Collapsed by default so quick-create stays fast.
Duplicate pre-fills everything from the source agent. */}
<InstructionsEditor
value={instructions}
onChange={setInstructions}
placeholder={
step.kind === "duplicate-form"
? t(($) => $.create_dialog.instructions.placeholder_duplicate)
: t(($) => $.create_dialog.instructions.placeholder_blank)
}
/>
<SkillMultiSelect
selectedIds={selectedSkillIds}
onChange={setSelectedSkillIds}
// We deliberately don't filter by `template.skills` here:
// the template's skill IDs live in skills.sh / GitHub
// space (source_url), while the picker shows workspace
// skill UUIDs — they're incomparable. If the user does
// tick a workspace skill that happens to match a
// template skill by name, the server's AddAgentSkill
// uses ON CONFLICT DO NOTHING so the duplicate is a
// silent no-op.
/>
{/* Avatar moved up next to Name — see the identity row at
the top of the form. */}
</div>
</div>
{/* Inline footer instead of <DialogFooter>: the shipped
DialogFooter applies `-mx-4 -mb-4` assuming a padded
DialogContent (default `p-4`). Our DialogContent uses
`p-0`, so those negative margins push the footer outside
the dialog. A plain flex row anchored by `border-t` keeps
the visual rhythm without the overflow bug. */}
<div className="flex items-center justify-end gap-2 border-t bg-background px-5 py-3">
<Button variant="ghost" onClick={onClose}>
{t(($) => $.create_dialog.cancel)}
</Button>
<Button
onClick={handleSubmit}
disabled={
creating || !name.trim() || !selectedRuntime || selectedRuntimeLocked
}
title={
selectedRuntimeLocked
? t(($) => $.create_dialog.runtime_private_locked_tooltip)
: undefined
}
>
{creating ? t(($) => $.create_dialog.creating) : t(($) => $.create_dialog.create)}
</Button>
<div>
<Label className="text-xs text-muted-foreground">{t(($) => $.create_dialog.visibility_label)}</Label>
<div className="mt-1.5 flex gap-2">
<button
type="button"
onClick={() => setVisibility("workspace")}
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
visibility === "workspace"
? "border-primary bg-primary/5"
: "border-border hover:bg-muted"
}`}
>
<Globe className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">{VISIBILITY_LABEL.workspace}</div>
<div className="text-xs text-muted-foreground">
{VISIBILITY_DESCRIPTION.workspace}
</div>
</div>
</button>
<button
type="button"
onClick={() => setVisibility("private")}
className={`flex flex-1 items-center gap-2 rounded-lg border px-3 py-2.5 text-sm transition-colors ${
visibility === "private"
? "border-primary bg-primary/5"
: "border-border hover:bg-muted"
}`}
>
<Lock className="h-4 w-4 shrink-0 text-muted-foreground" />
<div className="text-left">
<div className="font-medium">{VISIBILITY_LABEL.private}</div>
<div className="text-xs text-muted-foreground">
{VISIBILITY_DESCRIPTION.private}
</div>
</div>
</button>
</div>
</div>
</>
)}
<RuntimePicker
runtimes={runtimes}
runtimesLoading={runtimesLoading}
members={members}
currentUserId={currentUserId}
selectedRuntimeId={selectedRuntimeId}
onSelect={setSelectedRuntimeId}
/>
<ModelDropdown
runtimeId={selectedRuntime?.id ?? null}
runtimeOnline={selectedRuntime?.status === "online"}
value={model}
onChange={setModel}
disabled={!selectedRuntime}
/>
{/* --- Optional sections (instructions / skills) ---
Collapsed by default so quick-create stays fast.
Duplicate pre-fills everything from the source agent. */}
<InstructionsEditor
value={instructions}
onChange={setInstructions}
placeholder={
isDuplicate
? t(($) => $.create_dialog.instructions.placeholder_duplicate)
: t(($) => $.create_dialog.instructions.placeholder_blank)
}
/>
<SkillMultiSelect
selectedIds={selectedSkillIds}
onChange={setSelectedSkillIds}
/>
</div>
</div>
{/* Inline footer instead of <DialogFooter>: the shipped
DialogFooter applies `-mx-4 -mb-4` assuming a padded
DialogContent (default `p-4`). Our DialogContent uses
`p-0`, so those negative margins push the footer outside
the dialog. A plain flex row anchored by `border-t` keeps
the visual rhythm without the overflow bug. */}
<div className="flex items-center justify-end gap-2 border-t bg-background px-5 py-3">
<Button variant="ghost" onClick={onClose}>
{t(($) => $.create_dialog.cancel)}
</Button>
<Button
onClick={handleSubmit}
disabled={
creating || !name.trim() || !selectedRuntime || selectedRuntimeLocked
}
title={
selectedRuntimeLocked
? t(($) => $.create_dialog.runtime_private_locked_tooltip)
: undefined
}
>
{creating ? t(($) => $.create_dialog.creating) : t(($) => $.create_dialog.create)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}
// extractFailureBody recovers the structured 422 payload from the fetch
// error our APIClient throws. The client wraps non-2xx responses as a thrown
// Error whose message is the JSON body; this helper parses it so we can
// surface specific failed URLs instead of just "request failed".
function extractFailureBody(err: unknown): { error?: string; failed_urls?: string[] } | null {
if (!(err instanceof Error)) return null;
// The fetch helper attaches the body as a stringified JSON on the error
// message (or as `body` on the error object). Try both shapes.
const maybeBody = (err as Error & { body?: unknown }).body;
if (maybeBody && typeof maybeBody === "object") {
return maybeBody as { error?: string; failed_urls?: string[] };
}
try {
const parsed = JSON.parse(err.message);
if (parsed && typeof parsed === "object") {
return parsed as { error?: string; failed_urls?: string[] };
}
} catch {
// not JSON — fall through
}
return null;
}
@@ -15,10 +15,6 @@ import { useT } from "../../i18n";
export type RuntimeFilter = "mine" | "all";
// Pulled out of create-agent-dialog so the template-detail step can reuse the
// same picker without duplicating the ~90-line popover. Internal UI state
// (popover open, filter toggle) lives here; selection lives in the parent
// because both the form and the template-create call read selectedRuntime.
export function RuntimePicker({
runtimes,
runtimesLoading,
@@ -1,230 +0,0 @@
"use client";
import { Check, ChevronRight, Loader2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { agentTemplateDetailOptions } from "@multica/core/agents/queries";
import type {
AgentTemplateSummary,
MemberWithUser,
RuntimeDevice,
} from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
import { getAccentClass, getTemplateIcon } from "./template-picker";
import { ModelDropdown } from "./model-dropdown";
import { RuntimePicker } from "./runtime-picker";
interface TemplateDetailProps {
template: AgentTemplateSummary;
/** Fired when the user clicks "Use this template" — the dialog calls
* the create API and navigates to the new agent. */
onUse: (template: AgentTemplateSummary) => void;
/** True while the parent's create request is in flight; we disable the
* Use button so the user can't double-click. */
creating?: boolean;
/** Upstream URLs the server reported as unreachable on the most recent
* create attempt. Surfaces an inline error banner so the user knows
* *why* Create didn't navigate. The detail step is the only place
* this banner can render — `quickCreateFromTemplate` fires from here
* and never advances to a different step on failure. */
failedURLs?: readonly string[] | null;
// Runtime/model state lives in the parent so `quickCreateFromTemplate`
// can read the latest selection without lifting child state up at click
// time.
runtimes: RuntimeDevice[];
runtimesLoading?: boolean;
members: MemberWithUser[];
currentUserId: string | null;
selectedRuntimeId: string;
onRuntimeSelect: (id: string) => void;
selectedRuntime: RuntimeDevice | null;
model: string;
onModelChange: (model: string) => void;
useDisabled?: boolean;
}
/**
* Step 3 of the create-agent flow: a read-only preview of the picked
* template — instructions, skill list with cached descriptions, and a
* "Use this template" CTA at the bottom. Clicking Use kicks off a
* one-shot create with default settings (no form step in between).
*
* Instructions come from the lazy-fetched detail endpoint (the picker
* only carries the summary). Cached through TanStack Query keyed by
* slug with `staleTime: Infinity`, so navigating back and forth between
* picker and detail doesn't re-fetch. Visual rhythm matches the picker
* card so the transition feels seamless.
*/
export function TemplateDetail({
template,
onUse,
creating = false,
failedURLs,
runtimes,
runtimesLoading,
members,
currentUserId,
selectedRuntimeId,
onRuntimeSelect,
selectedRuntime,
model,
onModelChange,
useDisabled,
}: TemplateDetailProps) {
const { t } = useT("agents");
const { data: detail, isLoading, error } = useQuery(
agentTemplateDetailOptions(template.slug),
);
const Icon = getTemplateIcon(template.icon);
const accentClass = getAccentClass(template.accent);
return (
<>
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-3xl p-6">
{/* failedURLs banner — sits above the header so it's the first
thing the user sees after the spinner clears on a 422. */}
{failedURLs && failedURLs.length > 0 && (
<div className="mb-5 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm">
<div className="font-medium text-destructive">
{t(($) => $.create_dialog.template_failure.title)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t(($) => $.create_dialog.template_failure.body)}
</div>
<ul className="mt-2 space-y-0.5 text-xs">
{failedURLs.map((u) => (
<li key={u} className="break-all font-mono">
{u}
</li>
))}
</ul>
</div>
)}
{/* Header: icon + name + description. Same rhythm as the picker
card so the user reads the transition as "the same item,
expanded". */}
<div className="flex items-start gap-3">
<div className={cn("flex h-12 w-12 shrink-0 items-center justify-center rounded-lg", accentClass)}>
<Icon className="h-6 w-6" />
</div>
<div className="min-w-0 flex-1">
<h2 className="text-lg font-semibold">{template.name}</h2>
<p className="mt-0.5 text-sm text-muted-foreground">{template.description}</p>
{template.category ? (
<div className="mt-2 inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground">
{template.category}
</div>
) : null}
</div>
</div>
{/* Runtime + Model — picked here so the user knows where the
new agent will run and which model it uses. Defaults seeded
by the parent (first usable runtime, empty model = runtime
default); user can override before clicking Use. Two columns
with `items-stretch` keep both pickers visually balanced. */}
<section className="mt-6 grid grid-cols-1 items-stretch gap-4 sm:grid-cols-2">
<RuntimePicker
runtimes={runtimes}
runtimesLoading={runtimesLoading}
members={members}
currentUserId={currentUserId}
selectedRuntimeId={selectedRuntimeId}
onSelect={onRuntimeSelect}
/>
<ModelDropdown
runtimeId={selectedRuntime?.id ?? null}
runtimeOnline={selectedRuntime?.status === "online"}
value={model}
onChange={onModelChange}
disabled={!selectedRuntime}
/>
</section>
{/* Skill list — hidden entirely for prompt-only templates. Most of
the catalog is 0-skill; a section reading "Includes 0 skills"
with an empty list would just be visual noise. The Instructions
section below carries the agent's identity for these. */}
{template.skills.length > 0 ? (
<section className="mt-6">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t(($) => $.create_dialog.template_detail.skill_count, {
count: template.skills.length,
})}
</h3>
<ul className="mt-3 space-y-2">
{template.skills.map((s) => (
<li
key={s.source_url}
className="rounded-lg border bg-card px-3 py-2.5"
>
<div className="flex items-center gap-2">
<Check className="h-4 w-4 text-success" />
<span className="font-mono text-xs font-medium">{s.cached_name}</span>
</div>
{s.cached_description ? (
<p className="mt-1 ml-6 text-xs text-muted-foreground">
{s.cached_description}
</p>
) : null}
</li>
))}
</ul>
</section>
) : null}
{/* Instructions — lazy fetch + loading/error states */}
<section className="mt-6">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t(($) => $.create_dialog.template_detail.instructions_label)}
</h3>
<div className="mt-3 rounded-lg border bg-muted/30 px-4 py-3">
{isLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
{t(($) => $.create_dialog.template_detail.instructions_loading)}
</div>
) : error ? (
<div className="text-xs text-destructive">
{error instanceof Error
? error.message
: t(($) => $.create_dialog.template_detail.load_failed)}
</div>
) : (
<pre className="max-h-60 overflow-y-auto whitespace-pre-wrap text-xs leading-relaxed text-foreground/80">
{detail?.instructions ?? ""}
</pre>
)}
</div>
</section>
</div>
</div>
{/* Sticky CTA footer — click Use kicks off the create API call;
parent shows a creating spinner and navigates on success. */}
<div className="flex items-center justify-end gap-2 border-t bg-background px-5 py-3">
<Button
onClick={() => onUse(template)}
disabled={creating || useDisabled}
className="gap-1.5"
>
{creating ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
{t(($) => $.create_dialog.template_detail.creating)}
</>
) : (
<>
{t(($) => $.create_dialog.template_detail.use)}
<ChevronRight className="h-4 w-4" />
</>
)}
</Button>
</div>
</>
);
}
@@ -1,328 +0,0 @@
"use client";
import { useMemo, useState } from "react";
import {
AlertTriangle,
AlignLeft,
BookOpen,
Brush,
Briefcase,
Bug,
ChevronRight,
ClipboardList,
FileText,
FlaskConical,
GitCommit,
GitPullRequest,
GraduationCap,
Highlighter,
Languages,
LayoutDashboard,
Lightbulb,
ListChecks,
Loader2,
Megaphone,
MessageSquare,
Microscope,
Palette,
PenLine,
Presentation,
Scale,
Search,
Sparkles,
Target,
Type,
UserRound,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { agentTemplateListOptions } from "@multica/core/agents/queries";
import type { AgentTemplateSummary } from "@multica/core/types";
import { Button } from "@multica/ui/components/ui/button";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../../i18n";
interface TemplatePickerProps {
/** Fired when a template card is clicked. The dialog advances to the
* detail step (which shows instructions + skills + Use button). */
onSelect: (template: AgentTemplateSummary) => void;
}
/**
* Step 2 of the create-agent flow: a 2-column grid of template cards,
* grouped by `category`. Clicking a card moves to the detail step.
*
* Templates are a static catalog (workspace-independent, only changes on
* server deploy), so the catalog is loaded through TanStack Query with
* `staleTime: Infinity` — re-opening the picker hits the cache instantly
* and there's no per-mount refetch.
*
* Icons and accent colors come from the template JSON itself (`icon` is a
* lucide-react name, `accent` is a Multica semantic token). Resolved
* through static maps (ICONS / ACCENTS) so Tailwind can JIT-scan every
* class variant — dynamic `bg-${accent}/10` strings would silently not
* generate.
*/
export function TemplatePicker({ onSelect }: TemplatePickerProps) {
const { t } = useT("agents");
const { data: templates = [], isLoading, error } = useQuery(
agentTemplateListOptions(),
);
// `null` = "All" (default). When a specific category is selected, the
// grid renders flat (no section headers) — the active pill already
// tells the user what they're looking at, so headers would be noise.
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
// Group by category. Templates without a category fall into the
// localised "Other" bucket so they still render. Preserves the load
// order within each group for deterministic UI (matches the
// alphabetic-by-filename order the loader uses on the server).
const otherCategory = t(($) => $.create_dialog.template_picker.other_category);
const groups = useMemo(() => {
const byCategory = new Map<string, AgentTemplateSummary[]>();
for (const tmpl of templates) {
const key = tmpl.category?.trim() ? tmpl.category : otherCategory;
if (!byCategory.has(key)) byCategory.set(key, []);
byCategory.get(key)!.push(tmpl);
}
return Array.from(byCategory.entries());
}, [templates, otherCategory]);
// Templates currently visible given the filter. When "All" is active
// we show every template (grouped by category below); otherwise we
// only show the matching category.
const visibleTemplates = useMemo(() => {
if (selectedCategory === null) return templates;
return templates.filter(
(tmpl) =>
(tmpl.category?.trim() ? tmpl.category : otherCategory) ===
selectedCategory,
);
}, [templates, selectedCategory, otherCategory]);
if (isLoading) {
return (
<div className="flex flex-1 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<div className="flex flex-1 items-center justify-center p-6">
<div className="text-sm text-destructive">
{error instanceof Error
? error.message
: t(($) => $.create_dialog.template_picker.load_failed)}
</div>
</div>
);
}
if (templates.length === 0) {
return (
<div className="flex flex-1 items-center justify-center p-6">
<div className="text-sm text-muted-foreground">
{t(($) => $.create_dialog.template_picker.empty)}
</div>
</div>
);
}
return (
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-5xl space-y-4 p-6">
{/* Category filter — mirrors the IssuesHeader scope pattern
(Button variant="outline" + active-class swap). `flex-wrap`
so the 8 pills (All + 7 categories) degrade gracefully on
narrow widths. Counts are inlined into the label rather than
shown as a separate badge because we want the pill row to
stay one-line-tall per pill. */}
<div className="flex flex-wrap items-center gap-1">
<FilterPill
label={`${t(($) => $.create_dialog.template_picker.filter_all)} (${templates.length})`}
active={selectedCategory === null}
onClick={() => setSelectedCategory(null)}
/>
{groups.map(([category, tmpls]) => (
<FilterPill
key={category}
label={`${category} (${tmpls.length})`}
active={selectedCategory === category}
onClick={() => setSelectedCategory(category)}
/>
))}
</div>
{/* Grid — grouped with sticky headers when "All" is active;
flat when a single category is filtered (the active pill
already tells the user what they're looking at). */}
{selectedCategory === null ? (
<div className="space-y-6">
{groups.map(([category, tmpls]) => (
<section key={category}>
<h2 className="sticky top-0 z-10 -mx-6 border-b bg-background px-6 py-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{category}
</h2>
<div className="grid grid-cols-1 gap-3 pt-3 md:grid-cols-2">
{tmpls.map((tmpl) => (
<TemplateCard
key={tmpl.slug}
template={tmpl}
onClick={() => onSelect(tmpl)}
/>
))}
</div>
</section>
))}
</div>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
{visibleTemplates.map((tmpl) => (
<TemplateCard
key={tmpl.slug}
template={tmpl}
onClick={() => onSelect(tmpl)}
/>
))}
</div>
)}
</div>
</div>
);
}
/** Single filter pill. Visual matches IssuesHeader's scope toggle
* (Button outline + bg-accent on active) so the catalog feels
* consistent with the rest of the app's filter affordances. */
function FilterPill({
label,
active,
onClick,
}: {
label: string;
active: boolean;
onClick: () => void;
}) {
return (
<Button
type="button"
variant="outline"
size="sm"
className={cn(
"h-7 text-xs",
active
? "bg-accent text-accent-foreground hover:bg-accent/80"
: "text-muted-foreground",
)}
onClick={onClick}
>
{label}
</Button>
);
}
interface TemplateCardProps {
template: AgentTemplateSummary;
onClick: () => void;
}
function TemplateCard({ template, onClick }: TemplateCardProps) {
const { t } = useT("agents");
const Icon = ICONS[template.icon ?? ""] ?? FileText;
const accentClass = ACCENTS[template.accent ?? ""] ?? ACCENTS.muted;
return (
<button
type="button"
onClick={onClick}
className="group flex items-start gap-3 rounded-lg border bg-card p-4 text-left transition-colors hover:border-primary/40 hover:bg-accent/40"
>
<div
className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-lg",
accentClass,
)}
>
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<span className="truncate text-sm font-semibold">{template.name}</span>
<ChevronRight className="ml-auto h-4 w-4 shrink-0 text-muted-foreground/40 transition-transform group-hover:translate-x-0.5 group-hover:text-muted-foreground" />
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">
{template.description}
</p>
<div className="mt-2.5 inline-flex items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{template.skills.length === 0
? t(($) => $.create_dialog.template_card.prompt_only)
: t(($) => $.create_dialog.template_card.skills, {
count: template.skills.length,
})}
</div>
</div>
</button>
);
}
// --- Static maps so Tailwind's JIT scanner picks up every variant ---
/** Lucide icon name → component. Add new entries when shipping templates
* that use icons not yet listed here. Unknown names fall back to FileText. */
const ICONS: Record<string, LucideIcon> = {
AlertTriangle,
AlignLeft,
BookOpen,
Briefcase,
Brush,
Bug,
ClipboardList,
FileText,
FlaskConical,
GitCommit,
GitPullRequest,
GraduationCap,
Highlighter,
Languages,
LayoutDashboard,
Lightbulb,
ListChecks,
Megaphone,
MessageSquare,
Microscope,
Palette,
PenLine,
Presentation,
Scale,
Search,
Sparkles,
Target,
Type,
UserRound,
};
/** Semantic accent → Tailwind class string. The class strings are written
* out verbatim so JIT scans them; dynamic `bg-${name}/10` would not be
* generated. Mirrors the conventions in runtime-columns.tsx /
* usage-section.tsx (existing uses of these tokens). */
const DEFAULT_ACCENT = "bg-muted text-muted-foreground";
const ACCENTS: Record<string, string> = {
info: "bg-info/10 text-info",
success: "bg-success/10 text-success",
warning: "bg-warning/10 text-warning",
primary: "bg-primary/10 text-primary",
secondary: "bg-secondary text-secondary-foreground",
muted: DEFAULT_ACCENT,
};
/** Exposed for the detail / form steps so they can render the same icon
* badge as the picker card. Keeps visual continuity across steps. */
export function getTemplateIcon(iconName: string | undefined): LucideIcon {
return ICONS[iconName ?? ""] ?? FileText;
}
export function getAccentClass(accent: string | undefined): string {
return ACCENTS[accent ?? ""] ?? DEFAULT_ACCENT;
}
-36
View File
@@ -218,44 +218,8 @@
"creating": "Creating...",
"cancel": "Cancel",
"create_failed_toast": "Failed to create agent",
"back_aria": "Back",
"no_runtime_toast": "No runtime available. Add a runtime first, then try again.",
"template_created_toast": "Agent \"{{name}}\" created",
"template_created_with_reuse_toast_one": "Agent \"{{name}}\" created. Reused {{count}} existing skill.",
"template_created_with_reuse_toast_other": "Agent \"{{name}}\" created. Reused {{count}} existing skills.",
"skill_attach_failed_toast": "Agent created, but failed to attach skills: {{error}}",
"squad_join_failed_toast": "Agent \"{{name}}\" was created but couldn't join the squad: {{error}}. You can add it manually from Members.",
"chooser": {
"blank_title": "Start blank",
"blank_desc": "Write everything yourself.",
"template_title": "From template",
"template_desc": "Pre-configured agents with curated skills."
},
"template_picker": {
"title": "Pick a template",
"empty": "No templates available yet.",
"load_failed": "Failed to load templates",
"other_category": "Other",
"filter_all": "All"
},
"template_card": {
"skills_one": "{{count}} skill",
"skills_other": "{{count}} skills",
"prompt_only": "Prompt only"
},
"template_detail": {
"load_failed": "Failed to load template",
"skill_count_one": "Includes {{count}} skill",
"skill_count_other": "Includes {{count}} skills",
"instructions_label": "Instructions",
"instructions_loading": "Loading instructions…",
"use": "Use this template",
"creating": "Creating…"
},
"template_failure": {
"title": "Some template skills are unavailable",
"body": "The template can't be applied because one or more upstream URLs returned an error. No changes were written. Ping the template maintainer with the URLs below."
},
"instructions": {
"label": "Instructions",
"placeholder_blank": "Click to write instructions…",
@@ -214,41 +214,8 @@
"creating": "创建中...",
"cancel": "取消",
"create_failed_toast": "创建智能体失败",
"back_aria": "返回",
"no_runtime_toast": "暂无可用运行时。请先添加运行时再试。",
"template_created_toast": "智能体「{{name}}」已创建",
"template_created_with_reuse_toast_other": "智能体「{{name}}」已创建。复用了 {{count}} 个现有 skill。",
"skill_attach_failed_toast": "智能体已创建,但 skill 关联失败:{{error}}",
"squad_join_failed_toast": "智能体「{{name}}」已创建,但加入小队失败:{{error}}。你可以在 Members 标签里手动添加。",
"chooser": {
"blank_title": "从零开始",
"blank_desc": "全部由你来填写。",
"template_title": "使用模板",
"template_desc": "预配置指令并精选 skill 的智能体。"
},
"template_picker": {
"title": "选择模板",
"empty": "暂无可用模板。",
"load_failed": "加载模板失败",
"other_category": "其他",
"filter_all": "全部"
},
"template_card": {
"skills_other": "{{count}} 个 skill",
"prompt_only": "纯指令"
},
"template_detail": {
"load_failed": "加载模板失败",
"skill_count_other": "包含 {{count}} 个 skill",
"instructions_label": "指令",
"instructions_loading": "加载指令中…",
"use": "使用该模板",
"creating": "创建中…"
},
"template_failure": {
"title": "部分模板 skill 不可用",
"body": "因部分上游 URL 返回错误,模板无法应用。未写入任何变更。请将下方 URL 反馈给模板维护者。"
},
"instructions": {
"label": "指令",
"placeholder_blank": "点击撰写指令…",
@@ -252,18 +252,17 @@ export function SquadDetailPage() {
)}
{/* Squad-scoped create flow: same dialog as the Agents page but
with squadId set, so the dialog runs addSquadMember after
createAgent / createAgentFromTemplate and skips the
agent-detail navigation. Only mounted for workspace
owner/admin since AddSquadMember is owner/admin-gated
server-side; for everyone else the trigger never renders. */}
with squadId set, so the dialog runs api.addSquadMember after
api.createAgent and skips the agent-detail navigation. Only
mounted for workspace owner/admin since AddSquadMember is
owner/admin-gated server-side; for everyone else the trigger
never renders. */}
{showCreateAgent && isWorkspaceAdmin && (
<CreateAgentDialog
runtimes={runtimes}
runtimesLoading={runtimesLoading}
members={wsMembers}
currentUserId={currentUser?.id ?? null}
existingAgentNames={agents.map((a: Agent) => a.name)}
squadId={squadId}
onClose={() => setShowCreateAgent(false)}
onCreate={handleCreateAgent}