From 1e1df77042deeda3e413f6148c3d675a19d4e1c5 Mon Sep 17 00:00:00 2001 From: claude-ceo-assistant Date: Mon, 25 May 2026 03:32:49 -0700 Subject: [PATCH] Show SEO template instead of runtime defaults --- canvas/src/components/EmptyState.tsx | 4 +- canvas/src/components/TemplatePalette.tsx | 4 +- .../components/__tests__/EmptyState.test.tsx | 47 +++++++++++++------ .../__tests__/TemplatePalette.test.tsx | 17 +++++++ .../preflight-resolveRuntime.test.ts | 14 +++++- canvas/src/lib/deploy-preflight.ts | 11 +++++ manifest.json | 3 +- 7 files changed, 79 insertions(+), 21 deletions(-) diff --git a/canvas/src/components/EmptyState.tsx b/canvas/src/components/EmptyState.tsx index bbc8e779f..532f54413 100644 --- a/canvas/src/components/EmptyState.tsx +++ b/canvas/src/components/EmptyState.tsx @@ -4,7 +4,7 @@ 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 { isUserVisibleWorkspaceTemplate, type Template } from "@/lib/deploy-preflight"; import { useTemplateDeploy } from "@/hooks/useTemplateDeploy"; import { Spinner } from "./Spinner"; import { TIER_CONFIG } from "@/lib/design-tokens"; @@ -18,7 +18,7 @@ export function EmptyState() { useEffect(() => { api .get("/templates") - .then((t) => setTemplates(t)) + .then((t) => setTemplates(t.filter(isUserVisibleWorkspaceTemplate))) .catch(() => setTemplates([])) .finally(() => setLoading(false)); }, []); diff --git a/canvas/src/components/TemplatePalette.tsx b/canvas/src/components/TemplatePalette.tsx index c41be7643..88a95a17c 100644 --- a/canvas/src/components/TemplatePalette.tsx +++ b/canvas/src/components/TemplatePalette.tsx @@ -5,7 +5,7 @@ import { flushSync } from "react-dom"; import { api } from "@/lib/api"; import { useCanvasStore } from "@/store/canvas"; import type { WorkspaceData } from "@/store/socket"; -import { type Template } from "@/lib/deploy-preflight"; +import { isUserVisibleWorkspaceTemplate, type Template } from "@/lib/deploy-preflight"; import { useTemplateDeploy } from "@/hooks/useTemplateDeploy"; import { OrgImportPreflightModal, @@ -446,7 +446,7 @@ export function TemplatePalette() { setLoading(true); try { const data = await api.get("/templates"); - setTemplates(data); + setTemplates(data.filter(isUserVisibleWorkspaceTemplate)); } catch { setTemplates([]); } finally { diff --git a/canvas/src/components/__tests__/EmptyState.test.tsx b/canvas/src/components/__tests__/EmptyState.test.tsx index 10d0ebdb0..c3659d8d7 100644 --- a/canvas/src/components/__tests__/EmptyState.test.tsx +++ b/canvas/src/components/__tests__/EmptyState.test.tsx @@ -96,12 +96,12 @@ vi.mock("@/lib/design-tokens", () => ({ // ─── Fixtures ───────────────────────────────────────────────────────────────── const TEMPLATE = { - id: "tpl-1", - name: "Claude Code Agent", - description: "A general-purpose coding assistant", + id: "seo-agent", + name: "SEO Agent", + description: "SEO workspace template", tier: 2, skill_count: 3, - model: "claude-opus-4-5", + model: "MiniMax-M2.7", }; function template(overrides: Partial = {}): typeof TEMPLATE { @@ -159,7 +159,7 @@ describe("EmptyState — loading", () => { it("does not render template buttons while loading", async () => { renderEmpty(); await flush(); - expect(screen.queryByText("Claude Code Agent")).toBeNull(); + expect(screen.queryByText("SEO Agent")).toBeNull(); }); }); @@ -183,8 +183,8 @@ describe("EmptyState — templates", () => { it("renders template buttons with name and description", async () => { renderEmpty(); await flush(); - expect(screen.getByText("Claude Code Agent")).toBeTruthy(); - expect(screen.getByText("A general-purpose coding assistant")).toBeTruthy(); + expect(screen.getByText("SEO Agent")).toBeTruthy(); + expect(screen.getByText("SEO workspace template")).toBeTruthy(); }); it("renders tier badge and skill count", async () => { @@ -198,25 +198,42 @@ describe("EmptyState — templates", () => { it("renders model name when present", async () => { renderEmpty(); await flush(); - expect(screen.getByText(/claude-opus/i)).toBeTruthy(); + expect(screen.getByText(/MiniMax-M2.7/i)).toBeTruthy(); }); it("calls deploy with the template on click", async () => { renderEmpty(); await flush(); - fireEvent.click(screen.getByText("Claude Code Agent")); + fireEvent.click(screen.getByText("SEO Agent")); expect(_deploy.deployFn).toHaveBeenCalledWith(template()); }); + it("hides runtime-default templates from the product template grid", async () => { + mockApiGet.mockResolvedValue([ + template({ id: "claude-code-default", name: "Claude Code Agent" }), + template({ id: "codex", name: "OpenAI Codex CLI" }), + template({ id: "hermes", name: "Hermes Agent" }), + template({ id: "openclaw", name: "OpenClaw Agent" }), + template(), + ]); + renderEmpty(); + await flush(); + expect(screen.getByText("SEO Agent")).toBeTruthy(); + expect(screen.queryByText("Claude Code Agent")).toBeNull(); + expect(screen.queryByText("OpenAI Codex CLI")).toBeNull(); + expect(screen.queryByText("Hermes Agent")).toBeNull(); + expect(screen.queryByText("OpenClaw Agent")).toBeNull(); + }); + it("shows 'Deploying...' on the button of the template being deployed", async () => { - _deploy.deploying = "tpl-1"; + _deploy.deploying = "seo-agent"; renderEmpty(); await flush(); expect(screen.getByText("Deploying...")).toBeTruthy(); }); it("disables the template button of the deploying template", async () => { - _deploy.deploying = "tpl-1"; + _deploy.deploying = "seo-agent"; renderEmpty(); await flush(); const btn = screen.getByText("Deploying...").closest("button") as HTMLButtonElement; @@ -224,7 +241,7 @@ describe("EmptyState — templates", () => { }); it("disables 'create blank' while a template is deploying", async () => { - _deploy.deploying = "tpl-1"; + _deploy.deploying = "seo-agent"; renderEmpty(); await flush(); expect(screen.getByRole("button", { name: "+ Create blank workspace" }).disabled).toBe(true); @@ -245,7 +262,7 @@ describe("EmptyState — fetch failure / empty templates", () => { it("does not render template grid when GET /templates returns []", async () => { renderEmpty(); await flush(); - expect(screen.queryByText("Claude Code Agent")).toBeNull(); + expect(screen.queryByText("SEO Agent")).toBeNull(); }); it("renders 'create blank' button when templates list is empty", async () => { @@ -258,7 +275,7 @@ describe("EmptyState — fetch failure / empty templates", () => { mockApiGet.mockReset().mockRejectedValue(new Error("Network failure")); renderEmpty(); await flush(); - expect(screen.queryByText("Claude Code Agent")).toBeNull(); + expect(screen.queryByText("SEO Agent")).toBeNull(); }); }); @@ -316,7 +333,7 @@ describe("EmptyState — create blank", () => { await flush(); fireEvent.click(screen.getByRole("button", { name: "+ Create blank workspace" })); await act(async () => { await Promise.resolve(); }); - expect((screen.getByText("Claude Code Agent").closest("button") as HTMLButtonElement).disabled).toBe(true); + expect((screen.getByText("SEO Agent").closest("button") as HTMLButtonElement).disabled).toBe(true); }); it("shows error banner when POST /workspaces fails", async () => { diff --git a/canvas/src/components/__tests__/TemplatePalette.test.tsx b/canvas/src/components/__tests__/TemplatePalette.test.tsx index 7a5ffd10a..525ab53bf 100644 --- a/canvas/src/components/__tests__/TemplatePalette.test.tsx +++ b/canvas/src/components/__tests__/TemplatePalette.test.tsx @@ -189,6 +189,23 @@ describe("TemplatePalette — sidebar", () => { expect(screen.getByText("Researcher")).toBeTruthy(); }); + it("hides runtime-default templates from the deployable product template list", async () => { + mockGet.mockResolvedValue([ + { id: "claude-code-default", name: "Claude Code Agent", description: "", tier: 4, skills: [] }, + { id: "codex", name: "OpenAI Codex CLI", description: "", tier: 4, skills: [] }, + { id: "hermes", name: "Hermes Agent", description: "", tier: 4, skills: [] }, + { id: "openclaw", name: "OpenClaw Agent", description: "", tier: 4, skills: [] }, + { id: "seo-agent", name: "SEO Agent", description: "SEO workspace template", tier: 4, skills: ["seo"] }, + ]); + render(); + await openSidebar(); + expect(screen.getByText("SEO Agent")).toBeTruthy(); + expect(screen.queryByText("Claude Code Agent")).toBeNull(); + expect(screen.queryByText("OpenAI Codex CLI")).toBeNull(); + expect(screen.queryByText("Hermes Agent")).toBeNull(); + expect(screen.queryByText("OpenClaw Agent")).toBeNull(); + }); + it("shows template description", async () => { mockGet.mockResolvedValue(MOCK_TEMPLATES); render(); diff --git a/canvas/src/lib/__tests__/preflight-resolveRuntime.test.ts b/canvas/src/lib/__tests__/preflight-resolveRuntime.test.ts index 7eeefb023..45e6eaef2 100644 --- a/canvas/src/lib/__tests__/preflight-resolveRuntime.test.ts +++ b/canvas/src/lib/__tests__/preflight-resolveRuntime.test.ts @@ -8,7 +8,7 @@ * count bounded. */ import { describe, it, expect } from "vitest"; -import { resolveRuntime } from "../deploy-preflight"; +import { isUserVisibleWorkspaceTemplate, resolveRuntime } from "../deploy-preflight"; describe("resolveRuntime", () => { describe("explicit runtime-map entries", () => { @@ -64,3 +64,15 @@ describe("resolveRuntime", () => { }); }); }); + +describe("isUserVisibleWorkspaceTemplate", () => { + it("hides runtime-default templates from product template surfaces", () => { + for (const id of ["claude-code-default", "codex", "hermes", "openclaw"]) { + expect(isUserVisibleWorkspaceTemplate({ id })).toBe(false); + } + }); + + it("keeps product templates visible", () => { + expect(isUserVisibleWorkspaceTemplate({ id: "seo-agent" })).toBe(true); + }); +}); diff --git a/canvas/src/lib/deploy-preflight.ts b/canvas/src/lib/deploy-preflight.ts index 90f64892a..2ad701192 100644 --- a/canvas/src/lib/deploy-preflight.ts +++ b/canvas/src/lib/deploy-preflight.ts @@ -49,6 +49,17 @@ export interface Template extends TemplateLike { skill_count: number; } +const RUNTIME_DEFAULT_TEMPLATE_IDS = new Set([ + "claude-code-default", + "codex", + "hermes", + "openclaw", +]); + +export function isUserVisibleWorkspaceTemplate(template: Pick): boolean { + return !RUNTIME_DEFAULT_TEMPLATE_IDS.has(template.id); +} + /** Map from a template id to the runtime name the per-workspace * preflight expects. Used only when the server's `/templates` * response predates the `runtime` field on the summary (legacy diff --git a/manifest.json b/manifest.json index 0c3510d99..81204ffd2 100644 --- a/manifest.json +++ b/manifest.json @@ -28,7 +28,8 @@ {"name": "claude-code-default", "repo": "molecule-ai/molecule-ai-workspace-template-claude-code", "ref": "main"}, {"name": "hermes", "repo": "molecule-ai/molecule-ai-workspace-template-hermes", "ref": "main"}, {"name": "openclaw", "repo": "molecule-ai/molecule-ai-workspace-template-openclaw", "ref": "main"}, - {"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"} + {"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"}, + {"name": "seo-agent", "repo": "molecule-ai/molecule-ai-workspace-template-seo-agent", "ref": "main"} ], "org_templates": [ {"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-org-template-molecule-dev", "ref": "main"}, -- 2.52.0