Merge pull request 'fix(canvas): split runtime and workspace template selectors' (#1850) from fix/create-dialog-platform-defaults into main
ci-arm64-advisory / fast-checks (push) Waiting to run
CI / Detect changes (push) Waiting to run
CI / Python Lint & Test (push) Waiting to run
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (push) Successful in 9s
publish-canvas-image / Build & push canvas image (push) Successful in 2m38s
publish-workspace-server-image / build-and-push (push) Successful in 3m15s
publish-workspace-server-image / Production auto-deploy (push) Failing after 30m20s
CI / all-required (push) Failing after 40m26s
Block internal-flavored paths / Block forbidden paths (push) Successful in 10s
E2E API Smoke Test / detect-changes (push) Successful in 15s
E2E Chat / detect-changes (push) Successful in 11s
Handlers Postgres Integration / detect-changes (push) Successful in 9s
E2E Staging Canvas (Playwright) / detect-changes (push) Successful in 19s
Harness Replays / detect-changes (push) Successful in 14s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (push) Successful in 12s
Secret scan / Scan diff for credential-shaped strings (push) Successful in 9s
Lint no tenant GITEA or GITHUB token write / Scan for repo-host token write into tenant workspace surface (push) Successful in 11s
E2E API Smoke Test / E2E API Smoke Test (push) Successful in 3s
Harness Replays / Harness Replays (push) Successful in 5s
Handlers Postgres Integration / Handlers Postgres Integration (push) Failing after 1m49s
E2E Chat / E2E Chat (push) Successful in 3m29s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (push) Successful in 7m24s
gate-check-v3 / gate-check (push) Successful in 34s
Sweep stale Cloudflare DNS records / Sweep CF orphans (push) Successful in 16s
ci-required-drift / drift (push) Successful in 1m53s
Sweep stale AWS Secrets Manager secrets / Sweep AWS Secrets Manager (push) Successful in 9s
Sweep stale Cloudflare Tunnels / Sweep CF tunnels (push) Successful in 14s
main-red-watchdog / watchdog (push) Successful in 1m4s
Staging SaaS smoke (every 30 min) / Staging SaaS smoke (push) Successful in 5m48s
Continuous synthetic E2E (staging) / Synthetic E2E against staging (push) Successful in 6m46s
CI / Platform (Go) (push) Has been cancelled
CI / Canvas (Next.js) (push) Has been cancelled
CI / Shellcheck (E2E scripts) (push) Has been cancelled
CI / Canvas Deploy Reminder (push) Has been cancelled

This commit was merged in pull request #1850.
This commit is contained in:
2026-05-25 14:56:39 +00:00
2 changed files with 145 additions and 44 deletions
+99 -28
View File
@@ -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<TemplateSpec | null>(() => {
if (!template) return null;
return templateSpecs.find((s) => s.id === template) ?? null;
}, [template, templateSpecs]);
const isHermes = (selectedTemplateSpec?.runtime ?? "").trim().toLowerCase() === "hermes";
const selectedRuntimeTemplateSpec = useMemo<TemplateSpec | null>(() => (
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<HermesProvider[]>(() => {
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 <select>. Better to over-show than to lock the user out.
return filtered.length > 0 ? filtered : HERMES_PROVIDERS;
}, [selectedTemplateSpec]);
}, [selectedRuntimeTemplateSpec, selectedTemplateSpec]);
// If the currently-selected provider is filtered out by a template
// change, snap back to the first available. Without this, the
@@ -267,6 +319,7 @@ export function CreateWorkspaceButton() {
setName("");
setRole("");
setTier(defaultTier);
setRuntime(DEFAULT_RUNTIME);
setTemplate("");
setParentId("");
setBudgetLimit("");
@@ -378,7 +431,7 @@ export function CreateWorkspaceButton() {
// Runtime=external flips the backend into awaiting-agent mode:
// no container provisioning, token minted, connection payload
// returned in the response for the modal below.
...(isExternal ? { runtime: externalRuntime } : {}),
...(isExternal ? { runtime: externalRuntime } : { runtime }),
...(!isExternal && isHermes && provider
? {
secrets: { [provider.envVar]: hermesApiKey.trim() },
@@ -496,24 +549,42 @@ export function CreateWorkspaceButton() {
)}
{!isExternal && (
<div>
<label htmlFor="runtime-template-select" className="text-[11px] text-ink-mid block mb-1">
Runtime
</label>
<select
id="runtime-template-select"
value={template}
onChange={(e) => 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"
>
<option value="">Claude Code (blank workspace)</option>
{templateSpecs.map((spec) => (
<option key={spec.id} value={spec.id}>
{spec.name || spec.id}
{spec.runtime ? ` (${spec.runtime})` : ""}
</option>
))}
</select>
<div className="space-y-3">
<div>
<label htmlFor="runtime-select" className="text-[11px] text-ink-mid block mb-1">
Runtime
</label>
<select
id="runtime-select"
value={runtime}
onChange={(e) => handleRuntimeChange(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"
>
{RUNTIME_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div>
<label htmlFor="workspace-template-select" className="text-[11px] text-ink-mid block mb-1">
Workspace Template
</label>
<select
id="workspace-template-select"
value={template}
onChange={(e) => 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"
>
<option value="">Blank workspace</option>
{visibleTemplateSpecs.map((spec) => (
<option key={spec.id} value={spec.id}>
{spec.name || spec.id}
</option>
))}
</select>
</div>
</div>
)}
@@ -673,7 +744,7 @@ export function CreateWorkspaceButton() {
</div>
</div>
{/* Hermes provider configuration — shown only when template === "hermes" */}
{/* Hermes provider configuration — shown only for the Hermes runtime. */}
{isHermes && (
<div
className="mt-4 rounded-xl border border-violet-700/40 bg-violet-950/20 p-4 space-y-3"
@@ -65,6 +65,13 @@ async function openDialog() {
}
async function setTemplate(value: string) {
fireEvent.change(
screen.getByLabelText("Workspace Template"),
{ target: { value } }
);
}
async function setRuntime(value: string) {
fireEvent.change(
screen.getByLabelText("Runtime"),
{ target: { value } }
@@ -165,9 +172,31 @@ describe("CreateWorkspaceDialog", () => {
});
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<string, unknown>;
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()
);