From 5adc8a74d5bbad4b60d4aa5da909a8a45cb0819c Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 24 Apr 2026 15:14:59 -0700 Subject: [PATCH] feat(canvas+org): env preflight, EmptyState parity, shared useTemplateDeploy hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on #2061. Three internally-cohesive sub-features; easiest to read in order. ## 1. Org-level env preflight Server - `OrgTemplate` + `OrgWorkspace` gain `required_env: string[]` and `recommended_env: string[]` YAML fields. - `GET /org/templates` walks the tree and returns the tree-union (deduped, sorted) of both. `collectOrgEnv` dedup prefers required when the same key is declared at both tiers. - `POST /org/import` preflights against `global_secrets` WHERE `octet_length(encrypted_value) > 0` (empty-value rows used to be counted as "configured" and the per-container preflight still failed at start time). 412 Precondition Failed + `missing_env` list when required keys are absent. `force=true` bypasses with an audit log line. DB lookup failure now returns 500 (was: silent fall-through that defeated the guard). Env-var NAMES validated against `^[A-Z][A-Z0-9_]{0,127}$` so a malicious template can't ship pathological names into the UI or DB. Canvas - New `OrgImportPreflightModal`: red "Required" section (blocking) and yellow "Recommended" section (non-blocking, import stays enabled, shows live missing-count next to the Import button). - Per-key password input → `PUT /settings/secrets` → strike-through on save. Functional `setDrafts` throughout (no stale-closure clobbers on rapid successive saves). `useEffect` seed keyed on a sorted-join string signature so a parent re-render with a new array identity doesn't clobber typed inputs. - `TemplatePalette.handleImport` branches: zero env declarations → straight to import; any declarations → fetch configured global secret keys, open the modal. Tests (Go): `TestCollectOrgEnv_*` (5) cover union-across-levels, required-wins-over-recommended (including same-struct), dedup, empty, invalid-name rejection. ## 2. EmptyState parity with TemplatePalette The "Deploy your first agent" grid used to call `POST /workspaces` with no preflight while the sidebar palette ran `checkDeploySecrets` + `MissingKeysModal` first. Same template deployed two different ways → first-run users saw containers boot in `failed` state without guidance. Now both surfaces share one preflight + modal handshake. EmptyState's previous `interface Template` dropped `runtime`, `models`, and `required_env` — silently discarding exactly the fields the preflight needs. `Template` now lives in `deploy-preflight.ts` and is imported from there by both surfaces. ## 3. useTemplateDeploy hook With the preflight + modal wiring now duplicated across EmptyState + TemplatePalette + (going forward) any third surface, extracted the pattern into `canvas/src/hooks/useTemplateDeploy.tsx`: const { deploy, deploying, error, modal } = useTemplateDeploy({ canvasCoords: ..., // optional, default random onDeployed: (id) => ..., }); Closes three drift surfaces that the duplication had created: - `resolveRuntime` id→runtime fallback table (moved to `deploy-preflight.ts`). EmptyState had a narrower fallback that would have silently disagreed with the palette on any future id needing a non-identity mapping. - `checkDeploySecrets` call signature. One owner. - `MissingKeysModal` JSX wiring. One owner. Narrow try/catch around `checkDeploySecrets` so a preflight network failure clears `deploying` and surfaces via `setError` instead of stranding the button forever. `modal: ReactNode` (not a `renderModal()` function) — the previous memoization bought nothing since consumers called it inline every render. Named `MissingKeysInfo` interface for the state shape. ## 4. Viewport auto-fit user-pan gate fix During org deploy the canvas was meant to pan+zoom to follow each arriving workspace (`molecule:fit-deploying-org` event → debounced fitView). In practice the fit stayed stuck on wherever the first fit landed. Root cause: React Flow v12 fires `onMoveEnd` with a truthy `event` at the END of a programmatic `fitView` animation. The original "respect-user-pan" gate stamped `userPannedAtRef` in `onMoveEnd`, so our own fit completing looked like a user pan, and every subsequent auto-fit short-circuited for the rest of the deploy. Fix: stop trusting `onMoveEnd` for user-intent detection. Register explicit `wheel` + `pointerdown` listeners on `document` with capture phase and `target.closest('.react-flow__pane')` filter. Capture-phase immunity to `stopPropagation`; pane-filter rejects toolbar / modal / side-panel clicks (the old `window` fallback caught those). `onMoveEnd` simplified to only drive the debounced viewport save. Also: fit event dispatched on root arrivals (not just children), so the canvas centers on the just-landed root immediately instead of waiting ~2s for the first child. Animation 600ms → 400ms so successive per-arrival fits don't pile up visually. End-state fit stays at 1200ms — intentional asymmetry ("settling" vs "tracking"), documented in code. Co-Authored-By: Claude Opus 4.7 (1M context) --- canvas/src/components/EmptyState.tsx | 103 +++--- .../components/OrgImportPreflightModal.tsx | 329 ++++++++++++++++++ canvas/src/components/TemplatePalette.tsx | 218 ++++++------ .../components/canvas/useCanvasViewport.ts | 80 ++++- canvas/src/hooks/useTemplateDeploy.tsx | 170 +++++++++ canvas/src/lib/deploy-preflight.ts | 40 +++ canvas/src/store/canvas-events.ts | 11 +- workspace-server/internal/handlers/org.go | 96 ++++- .../internal/handlers/org_import.go | 100 ++++++ .../internal/handlers/org_test.go | 142 ++++++++ 10 files changed, 1113 insertions(+), 176 deletions(-) create mode 100644 canvas/src/components/OrgImportPreflightModal.tsx create mode 100644 canvas/src/hooks/useTemplateDeploy.tsx diff --git a/canvas/src/components/EmptyState.tsx b/canvas/src/components/EmptyState.tsx index bca64869..43e66665 100644 --- a/canvas/src/components/EmptyState.tsx +++ b/canvas/src/components/EmptyState.tsx @@ -1,27 +1,19 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { api } from "@/lib/api"; import { useCanvasStore } from "@/store/canvas"; import { OrgTemplatesSection } from "./TemplatePalette"; +import { type Template } from "@/lib/deploy-preflight"; +import { useTemplateDeploy } from "@/hooks/useTemplateDeploy"; import { Spinner } from "./Spinner"; import { TIER_CONFIG } from "@/lib/design-tokens"; -interface Template { - id: string; - name: string; - description: string; - tier: number; - model: string; - skills: string[]; - skill_count: number; -} - export function EmptyState() { const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(true); - const [deploying, setDeploying] = useState(null); - const [error, setError] = useState(null); + const [blankCreating, setBlankCreating] = useState(false); + const [blankError, setBlankError] = useState(null); useEffect(() => { api @@ -31,48 +23,56 @@ export function EmptyState() { .finally(() => setLoading(false)); }, []); - const deploy = async (template: Template) => { - setDeploying(template.id); - setError(null); - try { - const ws = await api.post<{ id: string }>("/workspaces", { - name: template.name, - template: template.id, - tier: template.tier, - canvas: { x: 200, y: 150 }, - }); - // Auto-select the new workspace and open chat - setTimeout(() => { - useCanvasStore.getState().selectNode(ws.id); - useCanvasStore.getState().setPanelTab("chat"); - }, 500); - } catch (e) { - setError(e instanceof Error ? e.message : "Deploy failed"); - } finally { - setDeploying(null); - } - }; + // Canvas fills in a visible "center-ish" spot on a fresh tenant so + // the user doesn't have to pan to find their new workspace. Fixed + // (200, 150) instead of the sidebar's random placement because the + // canvas is guaranteed empty when this component mounts. + const firstDeployCoords = useCallback(() => ({ x: 200, y: 150 }), []); + // After the POST succeeds, auto-select the new workspace and flip + // the panel to Chat. This is a UX flourish that only makes sense + // on first deploy (the canvas is empty so the selection can't + // surprise anyone); the sidebar intentionally skips this step. + // 500 ms delay so React Flow has a frame to render the new node + // before it receives focus. + const handleDeployed = useCallback((workspaceId: string) => { + setTimeout(() => { + useCanvasStore.getState().selectNode(workspaceId); + useCanvasStore.getState().setPanelTab("chat"); + }, 500); + }, []); + + const { deploy, deploying, error, modal } = useTemplateDeploy({ + canvasCoords: firstDeployCoords, + onDeployed: handleDeployed, + }); + + // "Create blank" bypasses templates entirely — no preflight, no + // modal, just POST /workspaces with a default name and tier. + // Deliberately NOT routed through useTemplateDeploy because it + // has no `template.id` to deploy against. const createBlank = async () => { - setDeploying("blank"); - setError(null); + setBlankCreating(true); + setBlankError(null); try { const ws = await api.post<{ id: string }>("/workspaces", { name: "My First Agent", tier: 2, - canvas: { x: 200, y: 150 }, + canvas: firstDeployCoords(), }); - setTimeout(() => { - useCanvasStore.getState().selectNode(ws.id); - useCanvasStore.getState().setPanelTab("chat"); - }, 500); + handleDeployed(ws.id); } catch (e) { - setError(e instanceof Error ? e.message : "Create failed"); + setBlankError(e instanceof Error ? e.message : "Create failed"); } finally { - setDeploying(null); + setBlankCreating(false); } }; + // Any active gesture locks every button so the user can't fire a + // second POST while the first is still in flight. + const anyDeploying = !!deploying || blankCreating; + const displayError = error ?? blankError; + return (
@@ -112,8 +112,8 @@ export function EmptyState() { {/* Org templates — instantiate a whole team in one click */} @@ -154,12 +154,17 @@ export function EmptyState() {
- {error && ( + {displayError && (
- {error} + {displayError}
)} + {/* Missing-keys preflight modal — owned by useTemplateDeploy, + shared with TemplatePalette. Rendered inline here so it + overlays this card naturally. */} + {modal} + {/* Tips */}
diff --git a/canvas/src/components/OrgImportPreflightModal.tsx b/canvas/src/components/OrgImportPreflightModal.tsx new file mode 100644 index 00000000..97123663 --- /dev/null +++ b/canvas/src/components/OrgImportPreflightModal.tsx @@ -0,0 +1,329 @@ +"use client"; + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { createSecret } from "@/lib/api/secrets"; + +interface Props { + open: boolean; + /** Display name of the org template — headline only. */ + orgName: string; + /** Total workspace count so the header can read "12 workspaces". */ + workspaceCount: number; + /** Env vars the server has declared MUST be set as global secrets. + * Import is disabled until every entry here is configured. */ + requiredEnv: string[]; + /** Env vars the server suggests — import can proceed without them, + * but the user sees them listed so they can decide. */ + recommendedEnv: string[]; + /** Names of env vars already configured globally. Used to strike + * through entries the user has already set up in another + * session. Passed in rather than queried inside the modal so the + * parent can refresh after each save without prop-driven effects. */ + configuredKeys: Set; + /** Called after a successful secret save so the parent can refresh + * `configuredKeys`. */ + onSecretSaved: () => void; + /** User clicked Import with all required envs satisfied. */ + onProceed: () => void; + /** User dismissed the modal. Import is NOT fired. */ + onCancel: () => void; +} + +interface DraftEntry { + key: string; + value: string; + saving: boolean; + error: string | null; +} + +/** + * OrgImportPreflightModal + * ----------------------- + * Two-tier env preflight before POST /org/import: + * + * - REQUIRED section (red, blocking) — every entry MUST be configured + * globally before the Import button enables. Matches the server- + * side preflight that would 412 the import anyway. + * + * - RECOMMENDED section (yellow, non-blocking) — listed so the user + * can add them if they want the full experience, but the Import + * button stays enabled regardless. + * + * Saving goes to the GLOBAL secrets endpoint (PUT /settings/secrets) + * because org-level templates deploy shared resources. Per-workspace + * overrides still work via the Config tab on an individual node + * after import. The modal does NOT enable Import the moment a key is + * typed — only after it saves successfully (so a half-entered token + * can't proceed and then fail at container-start time instead). + */ +export function OrgImportPreflightModal({ + open, + orgName, + workspaceCount, + requiredEnv, + recommendedEnv, + configuredKeys, + onSecretSaved, + onProceed, + onCancel, +}: Props) { + const [drafts, setDrafts] = useState>({}); + + // Seed a draft entry per declared key the first time the modal + // opens. Entries persist across `configuredKeys` changes so a mid- + // save recheck doesn't wipe what the user typed. + // + // Dep: dervie a STABLE string from the env-name lists rather than + // the array refs themselves. The parent computes + // `preflight.org.required_env ?? []`, which produces a fresh [] + // identity on every re-render (e.g. when refreshConfiguredKeys + // bumps state); depending on the array refs would re-fire the + // effect on every parent render and mask any future edit that + // drops the `if (!next[k])` guard as a silent input-reset bug. + const envKeysSignature = useMemo( + () => [...requiredEnv, ...recommendedEnv].sort().join("|"), + [requiredEnv, recommendedEnv], + ); + useEffect(() => { + if (!open) return; + setDrafts((prev) => { + const next = { ...prev }; + for (const k of [...requiredEnv, ...recommendedEnv]) { + if (!next[k]) { + next[k] = { key: k, value: "", saving: false, error: null }; + } + } + return next; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, envKeysSignature]); + + const missingRequired = useMemo( + () => requiredEnv.filter((k) => !configuredKeys.has(k)), + [requiredEnv, configuredKeys], + ); + const missingRecommended = useMemo( + () => recommendedEnv.filter((k) => !configuredKeys.has(k)), + [recommendedEnv, configuredKeys], + ); + const canProceed = missingRequired.length === 0; + + const saveOne = useCallback( + async (key: string) => { + // Functional setter throughout so two near-simultaneous saves + // don't have the second one's call see a stale snapshot captured + // before the first save's setState landed. Read the current + // value AND write the `saving` flag in a single transition + // rather than reading from closure-scoped `drafts`. + let startValue = ""; + setDrafts((d) => { + const current = d[key]; + if (!current || !current.value.trim()) return d; + startValue = current.value; + return { ...d, [key]: { ...current, saving: true, error: null } }; + }); + if (!startValue.trim()) return; + try { + await createSecret("global", key, startValue); + setDrafts((d) => ({ + ...d, + [key]: { ...d[key], value: "", saving: false, error: null }, + })); + // Let the parent refresh configuredKeys so the strike-through + // updates and canProceed recomputes. + onSecretSaved(); + } catch (e) { + setDrafts((d) => ({ + ...d, + [key]: { + ...d[key], + saving: false, + error: e instanceof Error ? e.message : "Save failed", + }, + })); + } + }, + [onSecretSaved], + ); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > +
+

+ Deploy {orgName} +

+

+ {workspaceCount} workspace{workspaceCount === 1 ? "" : "s"}. + Review the credentials needed before import. +

+
+ +
+ {requiredEnv.length > 0 && ( + + setDrafts((d) => ({ ...d, [key]: { ...d[key], value } })) + } + onSave={saveOne} + /> + )} + {recommendedEnv.length > 0 && ( + + setDrafts((d) => ({ ...d, [key]: { ...d[key], value } })) + } + onSave={saveOne} + /> + )} + {requiredEnv.length === 0 && recommendedEnv.length === 0 && ( +

+ No additional credentials required for this template. +

+ )} +
+ +
+ +
+ {missingRecommended.length > 0 && canProceed && ( + + {missingRecommended.length} recommended key + {missingRecommended.length === 1 ? "" : "s"} still unset + + )} + +
+
+
+
+ ); +} + +interface EnvListProps { + tone: "required" | "recommended"; + title: string; + subtitle: string; + entries: string[]; + configuredKeys: Set; + drafts: Record; + onChange: (key: string, value: string) => void; + onSave: (key: string) => void; +} + +function EnvList({ + tone, + title, + subtitle, + entries, + configuredKeys, + drafts, + onChange, + onSave, +}: EnvListProps) { + const accent = + tone === "required" + ? "border-red-800/60 bg-red-950/20" + : "border-amber-800/50 bg-amber-950/15"; + const headerColor = + tone === "required" ? "text-red-300" : "text-amber-300"; + + return ( +
+

+ {title} +

+

{subtitle}

+
    + {entries.map((k) => { + const configured = configuredKeys.has(k); + const d = drafts[k]; + return ( +
  • + + {k} + + {configured ? ( + ✓ set + ) : ( + <> + onChange(k, e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + onSave(k); + } + }} + disabled={d?.saving} + className="flex-1 px-2 py-1 rounded bg-zinc-800 border border-zinc-700 text-[11px] text-zinc-200 focus:outline-none focus:border-blue-500 disabled:opacity-50" + /> + + + )} + {d?.error && ( + + {d.error} + + )} +
  • + ); + })} +
+
+ ); +} diff --git a/canvas/src/components/TemplatePalette.tsx b/canvas/src/components/TemplatePalette.tsx index ad24c0e4..b46ee155 100644 --- a/canvas/src/components/TemplatePalette.tsx +++ b/canvas/src/components/TemplatePalette.tsx @@ -4,32 +4,36 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { api } from "@/lib/api"; import { useCanvasStore } from "@/store/canvas"; import type { WorkspaceData } from "@/store/socket"; -import { checkDeploySecrets, type PreflightResult, type ModelSpec } from "@/lib/deploy-preflight"; -import { MissingKeysModal } from "./MissingKeysModal"; +import { type Template } from "@/lib/deploy-preflight"; +import { useTemplateDeploy } from "@/hooks/useTemplateDeploy"; +import { OrgImportPreflightModal } from "./OrgImportPreflightModal"; import { ConfirmDialog } from "./ConfirmDialog"; import { Spinner } from "./Spinner"; import { showToast } from "./Toaster"; import { TIER_CONFIG } from "@/lib/design-tokens"; +import { listSecrets } from "@/lib/api/secrets"; -interface Template { - id: string; - name: string; - description: string; - tier: number; - runtime?: string; - model: string; - models?: ModelSpec[]; - /** AND-required env vars declared at runtime_config.required_env. */ - required_env?: string[]; - skills: string[]; - skill_count: number; -} - +// `Template` type and `resolveRuntime` helper now live in +// `@/lib/deploy-preflight` so EmptyState can import the same ones. Was +// redeclared here + a narrower redeclaration in EmptyState; the +// narrower one dropped `runtime`, `models`, `required_env`, which is +// exactly the data the preflight needs. See reviewer's "runtime +// fallback drift" note — single source of truth closes the drift. export interface OrgTemplate { dir: string; name: string; description: string; workspaces: number; + /** Env vars that MUST be set as global secrets before the org can + * import. Server refuses the import with 412 if any are missing; + * the canvas preflights against /secrets/list to avoid the round + * trip. Aggregated from org-level + every workspace in the tree. */ + required_env?: string[]; + /** "Nice-to-have" tier. Import proceeds without them but features + * may degrade — a channel's webhook posts get dropped, a fallback + * LLM isn't available, etc. Surfaced to the user as a non-blocking + * warning with an "add now" affordance. */ + recommended_env?: string[]; } /** Fetch the list of org templates from the platform. Returns [] on error @@ -91,6 +95,14 @@ export function OrgTemplatesSection() { const [loading, setLoading] = useState(false); const [importing, setImporting] = useState(null); const [error, setError] = useState(null); + // Preflight modal state. `preflight` is non-null when the user + // clicked Import on an org with declared required/recommended envs + // and we're waiting for them to confirm; null otherwise (direct + // import path for orgs with zero env requirements). + const [preflight, setPreflight] = useState<{ + org: OrgTemplate; + configuredKeys: Set; + } | null>(null); // Collapsed by default — org templates are multi-workspace imports // that most new users don't reach for first. Keeping them // expand-on-demand frees ~400 px of vertical space for the @@ -109,7 +121,25 @@ export function OrgTemplatesSection() { loadOrgs(); }, [loadOrgs]); - const handleImport = async (org: OrgTemplate) => { + /** Fetch the set of global secret KEYS that are already configured. + * Used to strike through already-set entries in the preflight modal + * and to decide whether the import needs the modal at all. */ + const loadConfiguredKeys = useCallback(async (): Promise> => { + try { + const secrets = await listSecrets("global"); + return new Set(secrets.map((s) => s.name)); + } catch { + // Secrets endpoint unreachable → assume nothing configured. + // The server will refuse the import with 412 and the user + // retries; safer than letting the import fly blind. + return new Set(); + } + }, []); + + /** Actually run the import. Split out so both the "no preflight + * needed" fast path and the "preflight modal approved" path can + * share the fetch + hydrate + toast sequence. */ + const doImport = useCallback(async (org: OrgTemplate) => { setImporting(org.dir); setError(null); try { @@ -149,7 +179,45 @@ export function OrgTemplatesSection() { } finally { setImporting(null); } - }; + }, []); + + /** Entry point for the Import button. Two paths: + * + * 1. No env declared by the template (required_env + recommended_env + * both empty) → fire doImport directly. Matches the pre-preflight + * behaviour for existing templates. + * + * 2. Any env declared → load the configured-keys set and open the + * preflight modal. doImport runs only when the user clicks + * Import inside the modal, which is gated to "required envs all + * configured" by the modal itself. */ + const handleImport = useCallback(async (org: OrgTemplate) => { + const hasEnvDeclarations = + (org.required_env && org.required_env.length > 0) || + (org.recommended_env && org.recommended_env.length > 0); + if (!hasEnvDeclarations) { + void doImport(org); + return; + } + // Flip the button to its "Importing…" state while the secrets + // lookup runs — on a tenant with 500+ global secrets the round + // trip can be > 200 ms and the user otherwise gets zero visual + // feedback after clicking. Cleared on modal close / error. + setImporting(org.dir); + try { + const configuredKeys = await loadConfiguredKeys(); + setPreflight({ org, configuredKeys }); + } finally { + setImporting(null); + } + }, [doImport, loadConfiguredKeys]); + + /** Called by the preflight modal after a successful key save so the + * strike-through re-renders and canProceed recomputes. */ + const refreshConfiguredKeys = useCallback(async () => { + const keys = await loadConfiguredKeys(); + setPreflight((prev) => (prev ? { ...prev, configuredKeys: keys } : prev)); + }, [loadConfiguredKeys]); return (
@@ -238,6 +306,24 @@ export function OrgTemplatesSection() { })}
)} + + {preflight && ( + { + const org = preflight.org; + setPreflight(null); + void doImport(org); + }} + onCancel={() => setPreflight(null)} + /> + )}
); } @@ -335,14 +421,6 @@ export function TemplatePalette() { const [templates, setTemplates] = useState([]); const [loading, setLoading] = useState(false); - const [creating, setCreating] = useState(null); - const [error, setError] = useState(null); - - // Missing keys modal state - const [missingKeysInfo, setMissingKeysInfo] = useState<{ - template: Template; - preflight: PreflightResult; - } | null>(null); const loadTemplates = useCallback(async () => { setLoading(true); @@ -360,65 +438,15 @@ export function TemplatePalette() { if (open) loadTemplates(); }, [open, loadTemplates]); - /** Resolve runtime from template ID (e.g., "langgraph", "claude-code-default" → "claude-code") */ - const resolveRuntime = (templateId: string): string => { - const runtimeMap: Record = { - langgraph: "langgraph", - "claude-code-default": "claude-code", - openclaw: "openclaw", - deepagents: "deepagents", - crewai: "crewai", - autogen: "autogen", - }; - return runtimeMap[templateId] ?? templateId.replace(/-default$/, ""); - }; - - /** Actually execute the deploy API call */ - const executeDeploy = useCallback(async (template: Template) => { - setCreating(template.id); - setError(null); - try { - await api.post("/workspaces", { - name: template.name, - template: template.id, - tier: template.tier, - canvas: { - x: Math.random() * 400 + 100, - y: Math.random() * 300 + 100, - }, - }); - setCreating(null); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to deploy"); - setCreating(null); - } - }, []); - - /** Pre-deploy check: validate secrets before deploying */ - const handleDeploy = async (template: Template) => { - setCreating(template.id); - setError(null); - - // Prefer the runtime the Go /templates endpoint returned verbatim — - // resolveRuntime() is a legacy id→runtime fallback for installs whose - // template summary predates the `runtime` field. - const runtime = template.runtime ?? resolveRuntime(template.id); - const preflight = await checkDeploySecrets({ - runtime, - models: template.models, - required_env: template.required_env, - }); - - if (!preflight.ok) { - // Missing keys — show the modal instead of deploying - setMissingKeysInfo({ template, preflight }); - setCreating(null); - return; - } - - // All keys present — deploy directly - await executeDeploy(template); - }; + // Preflight + POST + modal wiring moved into useTemplateDeploy so + // this component and EmptyState use one implementation. The sidebar + // uses the hook's default random canvas placement (no override) — + // an already-populated canvas shouldn't have new deploys stacking on + // a single fixed point. No post-deploy side effect either: the + // palette is operator-triggered, so auto-selecting would yank + // focus off whatever the user was already looking at. + const { deploy: handleDeploy, deploying: creating, error, modal } = + useTemplateDeploy(); return ( <> @@ -442,21 +470,9 @@ export function TemplatePalette() { - {/* Missing Keys Modal */} - { - if (missingKeysInfo) { - const template = missingKeysInfo.template; - setMissingKeysInfo(null); - executeDeploy(template); - } - }} - onCancel={() => setMissingKeysInfo(null)} - /> + {/* Missing-keys modal — rendered by the shared hook. Same + instance shape used by EmptyState. */} + {modal} {/* Sidebar */} {open && ( @@ -499,7 +515,7 @@ export function TemplatePalette() {