mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-10 18:36:45 +08:00
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:
co-authored by
multica-agent
Claude Opus 4.7
parent
c98161b039
commit
43b9a1173c
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user