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 */}