From e708f794fbf147ebeccf7c65d22dcaf84a73db66 Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Wed, 13 May 2026 18:41:12 +0800 Subject: [PATCH] feat(agents): require runtime + model in template detail before Use The one-click template flow used to auto-pick the first usable runtime and forward an empty model, so users couldn't tell which machine the new agent would land on or which model it would talk to. The Use CTA now stays disabled until the user explicitly picks both, surfaced as a two-column row directly above the skill list (runtime on the left, model on the right). When the runtime reports no per-agent model support, the model field auto-clears and is no longer required. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../agents/components/create-agent-dialog.tsx | 23 +- .../agents/components/template-detail.tsx | 263 ++++++++++++++++-- packages/views/locales/en/agents.json | 1 - packages/views/locales/zh-Hans/agents.json | 1 - 4 files changed, 255 insertions(+), 33 deletions(-) diff --git a/packages/views/agents/components/create-agent-dialog.tsx b/packages/views/agents/components/create-agent-dialog.tsx index 62a9a33a9..406c31e20 100644 --- a/packages/views/agents/components/create-agent-dialog.tsx +++ b/packages/views/agents/components/create-agent-dialog.tsx @@ -274,17 +274,17 @@ export function CreateAgentDialog({ setStep({ kind: "blank-form" }); }; - // 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. + // Template path runs from the detail step — the user picks runtime + // and model there (Use stays disabled until both are set), so this + // helper trusts the options passed in and skips the form-level runtime + // state entirely. Defaults: name auto-deduped, visibility = workspace. // 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 quickCreateFromTemplate = async ( + tmpl: AgentTemplateSummary, + options: { runtimeId: string; model: string }, + ) => { const taken = new Set(existingAgentNames ?? []); let candidate = tmpl.name; let n = 2; @@ -299,7 +299,8 @@ export function CreateAgentDialog({ const resp = await api.createAgentFromTemplate({ template_slug: tmpl.slug, name: candidate, - runtime_id: selectedRuntime.id, + runtime_id: options.runtimeId, + model: options.model || undefined, visibility: "workspace", }); if (wsId) { @@ -552,6 +553,10 @@ export function CreateAgentDialog({ {step.kind === "template-detail" && ( void; + /** Workspace runtimes — used to populate the runtime picker. */ + runtimes: RuntimeDevice[]; + runtimesLoading?: boolean; + /** Members of the workspace, used to label runtime owners. */ + members: MemberWithUser[]; + /** Current user id, used to grey-out private runtimes owned by others. */ + currentUserId: string | null; + /** Fired when the user clicks "Use this template". The dialog calls the + * create API with the runtime + model the user picked here. */ + onUse: (template: AgentTemplateSummary, options: TemplateDetailUseOptions) => 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. */ + * *why* Create didn't navigate. */ failedURLs?: readonly string[] | null; } /** * 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. + * template — runtime + model picker, instructions, skill list, and a + * "Use this template" CTA. The CTA stays disabled until the user picks + * a runtime *and* a model (or the runtime explicitly doesn't support + * per-agent model selection, in which case model is auto-cleared and + * not required). */ export function TemplateDetail({ template, + runtimes, + runtimesLoading, + members, + currentUserId, onUse, creating = false, failedURLs, @@ -51,6 +83,64 @@ export function TemplateDetail({ const Icon = getTemplateIcon(template.icon); const accentClass = getAccentClass(template.accent); + // Runtime + model state — local to this step so the form path's own + // selection is untouched. User must pick both before Use is enabled. + const [selectedRuntimeId, setSelectedRuntimeId] = useState(""); + const [model, setModel] = useState(""); + const [runtimeOpen, setRuntimeOpen] = useState(false); + + const isRuntimeDisabledForUser = (r: RuntimeDevice): boolean => { + if (!currentUserId) return false; + if (r.owner_id === currentUserId) return false; + return r.visibility !== "public"; + }; + + const sortedRuntimes = useMemo(() => { + return [...runtimes].sort((a, b) => { + const aMine = a.owner_id === currentUserId; + const bMine = b.owner_id === currentUserId; + if (aMine && !bMine) return -1; + if (!aMine && bMine) return 1; + const aDisabled = isRuntimeDisabledForUser(a); + const bDisabled = isRuntimeDisabledForUser(b); + if (!aDisabled && bDisabled) return -1; + if (aDisabled && !bDisabled) return 1; + return 0; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [runtimes, currentUserId]); + + const selectedRuntime = + runtimes.find((r) => r.id === selectedRuntimeId) ?? null; + const selectedRuntimeLocked = + selectedRuntime != null && isRuntimeDisabledForUser(selectedRuntime); + + const getOwnerMember = (ownerId: string | null) => { + if (!ownerId) return null; + return members.find((m) => m.user_id === ownerId) ?? null; + }; + + // Query the selected runtime's model catalog so we can tell whether the + // runtime supports per-agent model selection at all. Cached by TanStack + // Query so ModelDropdown's own subscription reuses the same data. + const modelsQuery = useQuery( + runtimeModelsOptions( + selectedRuntime?.status === "online" ? selectedRuntime.id : null, + ), + ); + const modelSupported = modelsQuery.data?.supported ?? true; + + // Use CTA is enabled only when: + // - a runtime is picked and not locked + // - either the runtime doesn't support per-agent model selection + // (model is irrelevant), or the user picked a non-empty model. + const modelSatisfied = !modelSupported || model.trim() !== ""; + const canUse = + !creating && + !!selectedRuntime && + !selectedRuntimeLocked && + modelSatisfied; + return ( <>
@@ -93,7 +183,129 @@ export function TemplateDetail({
- {/* Skill list — always visible (summary has cached descriptions) */} + {/* Runtime + model selectors — required before Use is enabled. + Two-column grid so they read as a single configuration row. */} +
+
+ + + + {runtimesLoading ? ( + + ) : selectedRuntime ? ( + + ) : ( + + )} +
+
+ + {runtimesLoading + ? t(($) => $.create_dialog.runtime_loading) + : selectedRuntime?.name ?? t(($) => $.create_dialog.runtime_none)} + + {selectedRuntime?.runtime_mode === "cloud" && ( + + {t(($) => $.create_dialog.runtime_cloud_badge)} + + )} +
+
+ {selectedRuntime + ? getOwnerMember(selectedRuntime.owner_id)?.name ?? selectedRuntime.device_info + : t(($) => $.create_dialog.runtime_register_first)} +
+
+ +
+ + {sortedRuntimes.map((device) => { + const ownerMember = getOwnerMember(device.owner_id); + const disabled = isRuntimeDisabledForUser(device); + const disabledTitle = disabled + ? t(($) => $.create_dialog.runtime_private_locked_tooltip) + : undefined; + return ( + + ); + })} + +
+
+ + +
+ + {/* Skill list */}

{t(($) => $.create_dialog.template_detail.skill_count, { @@ -147,12 +359,19 @@ export function TemplateDetail({ - {/* Sticky CTA footer — click Use kicks off the create API call; - parent shows a creating spinner and navigates on success. */} + {/* Sticky CTA footer */}