From 6ba24c30f2cc8d46192c550637971a19a6bc70bc Mon Sep 17 00:00:00 2001 From: claude-ceo-assistant Date: Mon, 25 May 2026 07:55:05 -0700 Subject: [PATCH] fix(canvas): split runtime and workspace template selectors --- .../src/components/CreateWorkspaceDialog.tsx | 127 ++++++++++++++---- .../__tests__/CreateWorkspaceDialog.test.tsx | 62 ++++++--- 2 files changed, 145 insertions(+), 44 deletions(-) diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index a07f937dc..69a5b6f94 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -49,6 +49,15 @@ const DEFAULT_LLM_MODELS: SelectorModel[] = [ { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", required_env: ["ANTHROPIC_API_KEY"] }, { id: "sonnet", name: "Claude Sonnet", required_env: ["CLAUDE_CODE_OAUTH_TOKEN"] }, ]; +const DEFAULT_PLATFORM_MODEL = DEFAULT_LLM_MODELS[0]; +const DEFAULT_RUNTIME = "claude-code"; +const RUNTIME_OPTIONS = [ + { value: "claude-code", label: "Claude Code" }, + { value: "codex", label: "OpenAI Codex CLI" }, + { value: "hermes", label: "Hermes" }, + { value: "openclaw", label: "OpenClaw" }, +]; +const BASE_RUNTIME_TEMPLATE_IDS = new Set(["claude-code-default", "codex", "hermes", "openclaw"]); const DEFAULT_HEADLESS_INSTANCE_TYPE = "t3.medium"; const DEFAULT_HEADLESS_ROOT_GB = 30; const DEFAULT_DISPLAY_INSTANCE_TYPE = "t3.xlarge"; @@ -83,6 +92,7 @@ export function CreateWorkspaceButton() { const [open, setOpen] = useState(false); const [name, setName] = useState(""); const [role, setRole] = useState(""); + const [runtime, setRuntime] = useState(DEFAULT_RUNTIME); const [template, setTemplate] = useState(""); const [parentId, setParentId] = useState(""); const [budgetLimit, setBudgetLimit] = useState(""); @@ -181,17 +191,59 @@ export function CreateWorkspaceButton() { [] ); - // Resolve the selected template's spec from the /templates response. - // The user picks a runtime/template preset from a dropdown; the value - // remains the template id because that is the backend create contract. + const handleRuntimeChange = useCallback((nextRuntime: string) => { + setRuntime(nextRuntime); + setTemplate(""); + setHermesProvider("anthropic"); + setHermesApiKey(""); + setHermesModel(""); + setLLMSelection({ providerId: "platform|", model: DEFAULT_PLATFORM_MODEL.id, envVars: [] }); + setLLMSecret(""); + }, []); + + // Resolve the selected workspace template from /templates. Runtime is + // deliberately separate: "SEO Agent" is a workspace template, not a + // runtime, so it must never appear in the runtime selector. const selectedTemplateSpec = useMemo(() => { if (!template) return null; return templateSpecs.find((s) => s.id === template) ?? null; }, [template, templateSpecs]); - const isHermes = (selectedTemplateSpec?.runtime ?? "").trim().toLowerCase() === "hermes"; + const selectedRuntimeTemplateSpec = useMemo(() => ( + templateSpecs.find((s) => s.id === runtime && BASE_RUNTIME_TEMPLATE_IDS.has(s.id)) ?? null + ), [runtime, templateSpecs]); + const isHermes = runtime === "hermes"; + const visibleTemplateSpecs = useMemo( + () => templateSpecs.filter((spec) => { + if (BASE_RUNTIME_TEMPLATE_IDS.has(spec.id)) return false; + const specRuntime = (spec.runtime ?? DEFAULT_RUNTIME).trim().toLowerCase(); + return specRuntime === runtime; + }), + [runtime, templateSpecs], + ); const llmModels = useMemo( - () => selectedTemplateSpec?.models?.length ? selectedTemplateSpec.models : DEFAULT_LLM_MODELS, - [selectedTemplateSpec], + () => { + if (!selectedTemplateSpec?.models?.length) return DEFAULT_LLM_MODELS; + if (isHermes) { + return selectedTemplateSpec.models; + } + if (selectedTemplateSpec.models.some((model) => model.provider === "platform")) { + return selectedTemplateSpec.models; + } + const templateDefault = selectedTemplateSpec.model?.trim(); + const defaultModelSpec = templateDefault + ? selectedTemplateSpec.models.find((model) => model.id === templateDefault) + : undefined; + return [ + { + id: templateDefault || DEFAULT_PLATFORM_MODEL.id, + name: defaultModelSpec?.name ?? DEFAULT_PLATFORM_MODEL.name, + provider: "platform", + required_env: [], + }, + ...selectedTemplateSpec.models, + ]; + }, + [isHermes, selectedTemplateSpec], ); const llmCatalog = useMemo(() => buildProviderCatalog(llmModels), [llmModels]); const selectedLLMProvider = useMemo( @@ -204,7 +256,7 @@ export function CreateWorkspaceButton() { // templates that haven't migrated to the explicit `providers:` field // (and self-hosted setups without /templates) keep working unchanged. const availableProviders = useMemo(() => { - const declared = selectedTemplateSpec?.providers; + const declared = selectedTemplateSpec?.providers ?? selectedRuntimeTemplateSpec?.providers; if (!declared || declared.length === 0) return HERMES_PROVIDERS; const allowed = new Set(declared.map((p) => p.toLowerCase())); const filtered = HERMES_PROVIDERS.filter((p) => allowed.has(p.id.toLowerCase())); @@ -213,7 +265,7 @@ export function CreateWorkspaceButton() { // metadata for yet), fall back to the full list rather than render // an empty setTemplate(e.target.value)} - className="w-full bg-surface-card/60 border border-line/50 rounded-lg px-3 py-2 text-sm text-ink focus:outline-none focus:border-accent/60 focus:ring-1 focus:ring-accent/20 transition-colors" - > - - {templateSpecs.map((spec) => ( - - ))} - +
+
+ + +
+
+ + +
)} @@ -673,7 +744,7 @@ export function CreateWorkspaceButton() { - {/* Hermes provider configuration — shown only when template === "hermes" */} + {/* Hermes provider configuration — shown only for the Hermes runtime. */} {isHermes && (
{ }); expect(body.model).toBe("moonshot/kimi-k2.6"); expect(body.llm_provider).toBe("platform"); + expect(body.runtime).toBe("claude-code"); expect(body.secrets).toBeUndefined(); }); + it("keeps runtime and workspace template as separate selectors", async () => { + await openDialog(); + + const runtimeSelect = screen.getByLabelText("Runtime") as HTMLSelectElement; + const runtimeTexts = Array.from(runtimeSelect.options).map((o) => o.text.trim()); + expect(runtimeTexts).toEqual([ + "Claude Code", + "OpenAI Codex CLI", + "Hermes", + "OpenClaw", + ]); + expect(runtimeTexts).not.toContain("SEO Agent"); + + await waitFor(() => { + const templateSelect = screen.getByLabelText("Workspace Template") as HTMLSelectElement; + const templateTexts = Array.from(templateSelect.options).map((o) => o.text.trim()); + expect(templateTexts).toContain("SEO Agent"); + expect(templateTexts).not.toContain("Hermes"); + }); + }); + it("does not send managed compute for external agents", async () => { await openDialog(); fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), { @@ -278,9 +307,9 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => { expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeNull(); }); - it("shows hermes provider section when template is 'hermes'", async () => { + it("shows hermes provider section when runtime is 'hermes'", async () => { await openDialog(); - await setTemplate("hermes"); + await setRuntime("hermes"); await waitFor(() => expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() ); @@ -288,7 +317,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => { it("shows hermes provider section for the Hermes runtime preset", async () => { await openDialog(); - await setTemplate("hermes"); + await setRuntime("hermes"); await waitFor(() => expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() ); @@ -296,7 +325,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => { it("hermes provider dropdown defaults to 'anthropic'", async () => { await openDialog(); - await setTemplate("hermes"); + await setRuntime("hermes"); await waitFor(() => expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() ); @@ -307,7 +336,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => { it("hermes provider dropdown lists all 15 providers", async () => { await openDialog(); - await setTemplate("hermes"); + await setRuntime("hermes"); await waitFor(() => expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() ); @@ -341,7 +370,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => { }); await openDialog(); - await setTemplate("hermes"); + await setRuntime("hermes"); await waitFor(() => expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() ); @@ -371,7 +400,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => { }); await openDialog(); - await setTemplate("hermes"); + await setRuntime("hermes"); await waitFor(() => expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() ); @@ -397,7 +426,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => { }); await openDialog(); - await setTemplate("hermes"); + await setRuntime("hermes"); await waitFor(() => expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() ); @@ -408,7 +437,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => { it("hermes API key field is a password input (masked)", async () => { await openDialog(); - await setTemplate("hermes"); + await setRuntime("hermes"); await waitFor(() => expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() ); @@ -422,7 +451,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => { fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), { target: { value: "Hermes Agent" }, }); - await setTemplate("hermes"); + await setRuntime("hermes"); await waitFor(() => expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() ); @@ -443,7 +472,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => { fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), { target: { value: "Hermes Agent" }, }); - await setTemplate("hermes"); + await setRuntime("hermes"); await waitFor(() => expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() ); @@ -458,7 +487,8 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => { await waitFor(() => expect(mockPost).toHaveBeenCalled()); const body = mockPost.mock.calls[0][1] as Record; expect(body.secrets).toEqual({ ANTHROPIC_API_KEY: "sk-test-anthropic-key" }); - expect(body.template).toBe("hermes"); + expect(body.runtime).toBe("hermes"); + expect(body.template).toBeUndefined(); }); it("uses the correct env var when a non-default provider is selected", async () => { @@ -466,7 +496,7 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => { fireEvent.change(screen.getByPlaceholderText("e.g. SEO Agent"), { target: { value: "Hermes OpenAI" }, }); - await setTemplate("hermes"); + await setRuntime("hermes"); await waitFor(() => expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() ); @@ -503,13 +533,13 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => { it("hides hermes section and resets state when template is cleared", async () => { await openDialog(); - await setTemplate("hermes"); + await setRuntime("hermes"); await waitFor(() => expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() ); - // Clear template - await setTemplate(""); + // Switch back to a non-Hermes runtime. + await setRuntime("claude-code"); await waitFor(() => expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeNull() ); -- 2.52.0