From e1496936e9a2c82cc383185cd4b3c8307b8a86c3 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Fri, 1 May 2026 12:45:20 -0700 Subject: [PATCH] feat(canvas): dynamic provider dropdown in CreateWorkspaceDialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the data-driven pattern PR #2454 set in ConfigTab: read runtime_config.providers from /templates and filter the modal's provider so an operator can only pick a +// provider the template actually supports. +interface TemplateSpec { + id: string; + name?: string; + runtime?: string; + providers?: string[]; +} + interface HermesProvider { id: string; label: string; @@ -55,6 +68,13 @@ export function CreateWorkspaceButton() { const [creating, setCreating] = useState(false); const [error, setError] = useState(null); const [workspaces, setWorkspaces] = useState([]); + // Templates fetched from /api/templates — drives the dynamic provider + // filter below. Same data source ConfigTab uses (PR #2454). When the + // selected template declares `runtime_config.providers` in its + // config.yaml, the modal surfaces only those providers in the + // . Better to over-show than to lock the user out. + return filtered.length > 0 ? filtered : HERMES_PROVIDERS; + }, [selectedTemplateSpec]); + + // If the currently-selected provider is filtered out by a template + // change, snap back to the first available. Without this, the + // hermesProvider state could refer to a provider not in the dropdown + // — confusing UI + the API key field's envVar would be wrong. + useEffect(() => { + if (!isHermes) return; + if (availableProviders.length === 0) return; + if (!availableProviders.some((p) => p.id === hermesProvider)) { + setHermesProvider(availableProviders[0].id); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [availableProviders, isHermes]); + // Auto-fill hermesModel with the provider's defaultModel whenever the // provider changes, but only if the user hasn't already typed their own // slug. Prevents the empty-model → "auto" → Anthropic-default 401 trap. @@ -163,6 +229,10 @@ export function CreateWorkspaceButton() { .get("/workspaces") .then((ws) => setWorkspaces(ws)) .catch(() => {}); + api + .get("/templates") + .then((rows) => setTemplateSpecs(Array.isArray(rows) ? rows : [])) + .catch(() => { /* keep empty — HERMES_PROVIDERS fallback below */ }); // defaultTier is stable for the session (derived from window.location), // safe to omit from deps. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -405,7 +475,7 @@ export function CreateWorkspaceButton() { aria-label="Hermes provider" className="w-full bg-zinc-800/60 border border-zinc-700/50 rounded-lg px-3 py-2 text-sm text-zinc-100 focus:outline-none focus:border-violet-500/60 focus:ring-1 focus:ring-violet-500/20 transition-colors" > - {HERMES_PROVIDERS.map((p) => ( + {availableProviders.map((p) => ( diff --git a/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx b/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx index dd207743..4d441436 100644 --- a/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx +++ b/canvas/src/components/__tests__/CreateWorkspaceDialog.test.tsx @@ -190,6 +190,91 @@ describe("CreateWorkspaceDialog — Hermes provider picker", () => { expect(ids).toContain("hermes"); }); + // Pins the dynamic-providers behavior: when the matched template's + // /templates row declares `providers`, the dropdown filters to that + // subset instead of showing the full HERMES_PROVIDERS catalog. Same + // data source ConfigTab uses (PR #2454) — keeps the modal and the + // settings tab honest about which providers a template supports. + it("hermes provider dropdown filters to template-declared providers when /templates ships them", async () => { + // Per-URL mock: /workspaces returns the existing fixture, /templates + // returns a hermes row that only allows anthropic + minimax + openai. + mockGet.mockImplementation(async (url: string) => { + if (url === "/templates") { + return [ + { id: "hermes", name: "Hermes", runtime: "hermes", providers: ["anthropic", "minimax", "openai"] }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return SAMPLE_WORKSPACES as any; + }); + + await openDialog(); + await setTemplate("hermes"); + await waitFor(() => + expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() + ); + const providerSelect = document.getElementById("hermes-provider-select") as HTMLSelectElement; + // Filtered list arrives async after /templates fetch resolves — + // keep waiting until the dropdown shrinks below the full catalog. + await waitFor(() => expect(providerSelect.options.length).toBe(3)); + const ids = Array.from(providerSelect.options).map((o) => o.value); + expect(ids).toEqual(expect.arrayContaining(["anthropic", "minimax", "openai"])); + expect(ids).not.toContain("gemini"); + expect(ids).not.toContain("deepseek"); + }); + + // Back-compat: a template that hasn't migrated to runtime_config.providers + // (older templates, self-hosted setups without /templates server) keeps + // showing the full provider catalog. Operators picking from those + // templates can't be locked out of providers we know hermes supports. + it("hermes provider dropdown falls back to all providers when template declares no providers list", async () => { + mockGet.mockImplementation(async (url: string) => { + if (url === "/templates") { + // No `providers` field — empty/missing → fall back to full catalog. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [{ id: "hermes", name: "Hermes", runtime: "hermes" }] as any; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return SAMPLE_WORKSPACES as any; + }); + + await openDialog(); + await setTemplate("hermes"); + await waitFor(() => + expect(document.querySelector("[data-testid='hermes-provider-section']")).toBeTruthy() + ); + const providerSelect = document.getElementById("hermes-provider-select") as HTMLSelectElement; + expect(providerSelect.options.length).toBe(HERMES_PROVIDERS.length); + }); + + // Defensive: a template's declared list with NO matches against our + // static catalog (e.g. a brand-new provider id we don't have label/ + // envVar metadata for yet) must not render an empty