diff --git a/packages/views/agents/components/agents-page.tsx b/packages/views/agents/components/agents-page.tsx index 9889cd3a9..a81f56d05 100644 --- a/packages/views/agents/components/agents-page.tsx +++ b/packages/views/agents/components/agents-page.tsx @@ -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); diff --git a/packages/views/agents/components/create-agent-dialog.test.tsx b/packages/views/agents/components/create-agent-dialog.test.tsx index 7b03b0c08..479514a01 100644 --- a/packages/views/agents/components/create-agent-dialog.test.tsx +++ b/packages/views/agents/components/create-agent-dialog.test.tsx @@ -146,13 +146,6 @@ function renderDialog(runtimes: RuntimeDevice[], template?: Agent) { , ); - // 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 }; } diff --git a/packages/views/agents/components/create-agent-dialog.tsx b/packages/views/agents/components/create-agent-dialog.tsx index 0f948f505..39fb59ea2 100644 --- a/packages/views/agents/components/create-agent-dialog.tsx +++ b/packages/views/agents/components/create-agent-dialog.tsx @@ -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( - isDuplicate ? { kind: "duplicate-form" } : { kind: "chooser" }, - ); - - // Name defaults: duplicate uses " copy", template uses the - // template's name verbatim. Manual-create starts blank. + // Name defaults: duplicate uses " 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(template?.avatar_url ?? null); const [selectedSkillIds, setSelectedSkillIds] = useState>( () => new Set(template?.skills.map((s) => s.id) ?? []), ); const [creating, setCreating] = useState(false); - const [failedURLs, setFailedURLs] = useState(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 ( { if (!v) onClose(); }}> - + -
- {showBackButton && ( - - )} - {headerTitle} -
- {step.kind === "chooser" && ( - - {t(($) => $.create_dialog.description_create)} - - )} - {step.kind === "duplicate-form" && template && ( + {headerTitle} + {isDuplicate && template && ( {t(($) => $.create_dialog.description_duplicate, { name: template.name })} )} + {!isDuplicate && ( + + {t(($) => $.create_dialog.description_create)} + + )}
- {/* ---------- Step 1: Chooser (two large cards, vertically centred) ---------- */} - {step.kind === "chooser" && ( -
-
- - - -
-
- )} - - {/* ---------- Step 2: Template Picker — click a card opens the - detail preview so the user can review instructions / skills - before committing. ---------- */} - {step.kind === "template-picker" && ( - 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" && ( - - )} - - {/* ---------- Form step (blank / duplicate / template) ---------- */} - {isFormStep(step) && ( - <> -
-
- {/* 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. */} -
- -
-
- - setName(e.target.value)} - placeholder={t(($) => $.create_dialog.name_placeholder)} - className="mt-1" - onKeyDown={(e) => { - if (isImeComposing(e)) return; - if (e.key === "Enter") handleSubmit(); - }} - /> -
- -
- - setDescription(e.target.value)} - placeholder={t(($) => $.create_dialog.description_placeholder)} - maxLength={AGENT_DESCRIPTION_MAX_LENGTH} - className="mt-1" - /> -
- -
-
-
+ + setName(e.target.value)} + placeholder={t(($) => $.create_dialog.name_placeholder)} + className="mt-1" + onKeyDown={(e) => { + if (isImeComposing(e)) return; + if (e.key === "Enter") handleSubmit(); + }} + />
- -
- - + + setDescription(e.target.value)} + placeholder={t(($) => $.create_dialog.description_placeholder)} + maxLength={AGENT_DESCRIPTION_MAX_LENGTH} + className="mt-1" + /> +
+
- - - - - - {/* --- Optional sections (instructions / skills) --- - Collapsed by default so quick-create stays fast. - Duplicate pre-fills everything from the source agent. */} - $.create_dialog.instructions.placeholder_duplicate) - : t(($) => $.create_dialog.instructions.placeholder_blank) - } - /> - - - - {/* Avatar moved up next to Name — see the identity row at - the top of the form. */}
- {/* Inline footer instead of : 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. */} -
- - +
+ +
+ + +
- - )} + + + + + + {/* --- Optional sections (instructions / skills) --- + Collapsed by default so quick-create stays fast. + Duplicate pre-fills everything from the source agent. */} + $.create_dialog.instructions.placeholder_duplicate) + : t(($) => $.create_dialog.instructions.placeholder_blank) + } + /> + + +
+
+ + {/* Inline footer instead of : 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. */} +
+ + +
); } - -// 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; -} diff --git a/packages/views/agents/components/runtime-picker.tsx b/packages/views/agents/components/runtime-picker.tsx index 3ca4e7adc..623a13eb5 100644 --- a/packages/views/agents/components/runtime-picker.tsx +++ b/packages/views/agents/components/runtime-picker.tsx @@ -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, diff --git a/packages/views/agents/components/template-detail.tsx b/packages/views/agents/components/template-detail.tsx deleted file mode 100644 index 3abb0f992..000000000 --- a/packages/views/agents/components/template-detail.tsx +++ /dev/null @@ -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 ( - <> -
-
- {/* 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 && ( -
-
- {t(($) => $.create_dialog.template_failure.title)} -
-
- {t(($) => $.create_dialog.template_failure.body)} -
-
    - {failedURLs.map((u) => ( -
  • - {u} -
  • - ))} -
-
- )} - - {/* Header: icon + name + description. Same rhythm as the picker - card so the user reads the transition as "the same item, - expanded". */} -
-
- -
-
-

{template.name}

-

{template.description}

- {template.category ? ( -
- {template.category} -
- ) : null} -
-
- - {/* 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. */} -
- - -
- - {/* 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 ? ( -
-

- {t(($) => $.create_dialog.template_detail.skill_count, { - count: template.skills.length, - })} -

-
    - {template.skills.map((s) => ( -
  • -
    - - {s.cached_name} -
    - {s.cached_description ? ( -

    - {s.cached_description} -

    - ) : null} -
  • - ))} -
-
- ) : null} - - {/* Instructions — lazy fetch + loading/error states */} -
-

- {t(($) => $.create_dialog.template_detail.instructions_label)} -

-
- {isLoading ? ( -
- - {t(($) => $.create_dialog.template_detail.instructions_loading)} -
- ) : error ? ( -
- {error instanceof Error - ? error.message - : t(($) => $.create_dialog.template_detail.load_failed)} -
- ) : ( -
-                  {detail?.instructions ?? ""}
-                
- )} -
-
-
-
- - {/* Sticky CTA footer — click Use kicks off the create API call; - parent shows a creating spinner and navigates on success. */} -
- -
- - ); -} diff --git a/packages/views/agents/components/template-picker.tsx b/packages/views/agents/components/template-picker.tsx deleted file mode 100644 index b94c4ecf0..000000000 --- a/packages/views/agents/components/template-picker.tsx +++ /dev/null @@ -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(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(); - 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 ( -
- -
- ); - } - if (error) { - return ( -
-
- {error instanceof Error - ? error.message - : t(($) => $.create_dialog.template_picker.load_failed)} -
-
- ); - } - if (templates.length === 0) { - return ( -
-
- {t(($) => $.create_dialog.template_picker.empty)} -
-
- ); - } - - return ( -
-
- {/* 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. */} -
- $.create_dialog.template_picker.filter_all)} (${templates.length})`} - active={selectedCategory === null} - onClick={() => setSelectedCategory(null)} - /> - {groups.map(([category, tmpls]) => ( - setSelectedCategory(category)} - /> - ))} -
- - {/* 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 ? ( -
- {groups.map(([category, tmpls]) => ( -
-

- {category} -

-
- {tmpls.map((tmpl) => ( - onSelect(tmpl)} - /> - ))} -
-
- ))} -
- ) : ( -
- {visibleTemplates.map((tmpl) => ( - onSelect(tmpl)} - /> - ))} -
- )} -
-
- ); -} - -/** 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 ( - - ); -} - -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 ( - - ); -} - -// --- 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 = { - 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 = { - 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; -} diff --git a/packages/views/locales/en/agents.json b/packages/views/locales/en/agents.json index 2d82ec036..d32bab021 100644 --- a/packages/views/locales/en/agents.json +++ b/packages/views/locales/en/agents.json @@ -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…", diff --git a/packages/views/locales/zh-Hans/agents.json b/packages/views/locales/zh-Hans/agents.json index 00dad7469..e208a8efe 100644 --- a/packages/views/locales/zh-Hans/agents.json +++ b/packages/views/locales/zh-Hans/agents.json @@ -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": "点击撰写指令…", diff --git a/packages/views/squads/components/squad-detail-page.tsx b/packages/views/squads/components/squad-detail-page.tsx index 1fd1a50b8..48d97a946 100644 --- a/packages/views/squads/components/squad-detail-page.tsx +++ b/packages/views/squads/components/squad-detail-page.tsx @@ -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 && ( a.name)} squadId={squadId} onClose={() => setShowCreateAgent(false)} onCreate={handleCreateAgent}