e.stopPropagation()}
+ className="relative z-[71] w-[420px] bg-surface/95 backdrop-blur-xl border border-line/60 rounded-2xl shadow-2xl shadow-black/50 overflow-hidden"
>
{/* Search input */}
diff --git a/canvas/src/components/__tests__/SidePanel.general.test.tsx b/canvas/src/components/__tests__/SidePanel.general.test.tsx
new file mode 100644
index 00000000..88710372
--- /dev/null
+++ b/canvas/src/components/__tests__/SidePanel.general.test.tsx
@@ -0,0 +1,390 @@
+// @vitest-environment jsdom
+/**
+ * Tests for SidePanel — general rendering and non-tab behaviors.
+ *
+ * Companion to SidePanel.tabs.test.tsx which covers tablist ARIA
+ * and localStorage width persistence.
+ *
+ * Covers:
+ * - Null when no node is selected
+ * - Null when selectedNodeId points to a missing node
+ * - Header: node name, role, tier badge
+ * - MetaPill capability summary pills
+ * - Resize handle: role=separator, aria-valuenow/min/max, aria-orientation
+ * - Resize handle: ArrowLeft/Right/Home/End keyboard nav
+ * - Needs-restart banner + Restart Now button
+ * - Current-task banner with pulsing dot
+ * - Footer shows workspace ID
+ * - Close button calls selectNode(null)
+ * - Tab switch via onClick fires setPanelTab
+ * - setSidePanelWidth called on mount
+ */
+import React from "react";
+import { render, screen, fireEvent, cleanup } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { SidePanel } from "../SidePanel";
+
+// ── Tab content stubs ───────────────────────────────────────────────────────
+vi.mock("../tabs/DetailsTab", () => ({ DetailsTab: () => null }));
+vi.mock("../tabs/SkillsTab", () => ({ SkillsTab: () => null }));
+vi.mock("../tabs/ChatTab", () => ({ ChatTab: () => null }));
+vi.mock("../tabs/ConfigTab", () => ({ ConfigTab: () => null }));
+vi.mock("../tabs/TerminalTab", () => ({ TerminalTab: () => null }));
+vi.mock("../tabs/FilesTab", () => ({ FilesTab: () => null }));
+vi.mock("../MemoryInspectorPanel", () => ({ MemoryInspectorPanel: () => null }));
+vi.mock("../tabs/TracesTab", () => ({ TracesTab: () => null }));
+vi.mock("../tabs/EventsTab", () => ({ EventsTab: () => null }));
+vi.mock("../tabs/ActivityTab", () => ({ ActivityTab: () => null }));
+vi.mock("../tabs/ScheduleTab", () => ({ ScheduleTab: () => null }));
+vi.mock("../tabs/ChannelsTab", () => ({ ChannelsTab: () => null }));
+vi.mock("../AuditTrailPanel", () => ({ AuditTrailPanel: () => null }));
+vi.mock("../StatusDot", () => ({ StatusDot: () => null }));
+vi.mock("../Tooltip", () => ({
+ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}>,
+}));
+vi.mock("@/components/Toaster", () => ({ showToast: vi.fn() }));
+
+// ── Canvas store mock — mutable so each test can reconfigure ───────────────
+const mockSetPanelTab = vi.fn();
+const mockSelectNode = vi.fn();
+const mockSetSidePanelWidth = vi.fn();
+const mockRestartWorkspace = vi.fn().mockResolvedValue(undefined);
+
+const BASE_NODE = {
+ id: "ws-1",
+ data: {
+ name: "Test Workspace",
+ status: "online" as const,
+ tier: 2,
+ role: "Engineer",
+ parentId: null,
+ needsRestart: false,
+ currentTask: null,
+ agentCard: null,
+ },
+};
+
+// Mutable store state — tests reassign fields to test different states
+let storeState = {
+ selectedNodeId: "ws-1" as string | null,
+ panelTab: "chat",
+ setPanelTab: mockSetPanelTab,
+ selectNode: mockSelectNode,
+ setSidePanelWidth: mockSetSidePanelWidth,
+ nodes: [BASE_NODE],
+ restartWorkspace: mockRestartWorkspace,
+};
+
+vi.mock("@/store/canvas", () => ({
+ useCanvasStore: Object.assign(
+ vi.fn((selector: (s: typeof storeState) => unknown) => selector(storeState)),
+ { getState: () => storeState }
+ ),
+ summarizeWorkspaceCapabilities: () => ({ runtime: "claude-code", skillCount: 3 }),
+}));
+
+beforeEach(() => {
+ mockSetPanelTab.mockReset();
+ mockSelectNode.mockReset();
+ mockSetSidePanelWidth.mockReset();
+ mockRestartWorkspace.mockReset().mockResolvedValue(undefined);
+ localStorage.clear();
+ // Reset store state to default
+ storeState = {
+ selectedNodeId: "ws-1",
+ panelTab: "chat",
+ setPanelTab: mockSetPanelTab,
+ selectNode: mockSelectNode,
+ setSidePanelWidth: mockSetSidePanelWidth,
+ nodes: [BASE_NODE],
+ restartWorkspace: mockRestartWorkspace,
+ };
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+// ─── Null guard ──────────────────────────────────────────────────────────────
+
+describe("SidePanel — null guard", () => {
+ it("returns null when selectedNodeId is null", () => {
+ storeState.selectedNodeId = null;
+ const { container } = render(
);
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("returns null when selectedNodeId does not match any node", () => {
+ storeState.selectedNodeId = "nonexistent-ws";
+ storeState.nodes = [];
+ const { container } = render(
);
+ expect(container.firstChild).toBeNull();
+ });
+});
+
+// ─── Header ─────────────────────────────────────────────────────────────────
+
+describe("SidePanel — header", () => {
+ it("shows node name in heading", () => {
+ render(
);
+ expect(screen.getByRole("heading", { name: "Test Workspace" })).toBeTruthy();
+ });
+
+ it("shows node role", () => {
+ render(
);
+ expect(screen.getByText("Engineer")).toBeTruthy();
+ });
+
+ it("shows tier badge with correct value", () => {
+ render(
);
+ // T2 appears in header badge AND meta pill — confirm at least one
+ const all = screen.getAllByText("T2");
+ expect(all.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("close button is present with aria-label", () => {
+ render(
);
+ expect(screen.getByRole("button", { name: /close workspace panel/i })).toBeTruthy();
+ });
+
+ it("close button calls selectNode(null)", () => {
+ render(
);
+ fireEvent.click(screen.getByRole("button", { name: /close workspace panel/i }));
+ expect(mockSelectNode).toHaveBeenCalledWith(null);
+ });
+});
+
+// ─── MetaPills ─────────────────────────────────────────────────────────────
+
+describe("SidePanel — meta pills", () => {
+ it("renders Tier, Runtime, Skills, and Status pills in the meta row", () => {
+ render(
);
+ // All four labels appear somewhere in the meta pills row
+ expect(screen.getByText(/tier/i)).toBeTruthy();
+ expect(screen.getByText(/runtime/i)).toBeTruthy();
+ expect(screen.getByText(/skills/i)).toBeTruthy();
+ expect(screen.getByText(/status/i)).toBeTruthy();
+ });
+
+ it("shows correct runtime value in meta pill", () => {
+ render(
);
+ expect(screen.getByText("claude-code")).toBeTruthy();
+ });
+
+ it("shows skill count in meta pill", () => {
+ render(
);
+ expect(screen.getByText("3")).toBeTruthy();
+ });
+});
+
+// ─── Resize handle ──────────────────────────────────────────────────────────
+
+describe("SidePanel — resize handle", () => {
+ it("has role=separator", () => {
+ render(
);
+ expect(screen.getByRole("separator")).toBeTruthy();
+ });
+
+ it("has aria-label='Resize workspace panel'", () => {
+ render(
);
+ expect(screen.getByRole("separator").getAttribute("aria-label")).toBe(
+ "Resize workspace panel"
+ );
+ });
+
+ it("has aria-valuenow=480 (default width)", () => {
+ render(
);
+ expect(screen.getByRole("separator").getAttribute("aria-valuenow")).toBe("480");
+ });
+
+ it("has aria-valuemin=320", () => {
+ render(
);
+ expect(screen.getByRole("separator").getAttribute("aria-valuemin")).toBe("320");
+ });
+
+ it("has aria-valuemax=800", () => {
+ render(
);
+ expect(screen.getByRole("separator").getAttribute("aria-valuemax")).toBe("800");
+ });
+
+ it("has aria-orientation=vertical", () => {
+ render(
);
+ expect(screen.getByRole("separator").getAttribute("aria-orientation")).toBe("vertical");
+ });
+
+ it("has tabIndex=0 (focusable)", () => {
+ render(
);
+ expect(screen.getByRole("separator").getAttribute("tabindex")).toBe("0");
+ });
+
+ it("ArrowLeft increases width by 16px (STEP — moves left edge rightward, widens panel)", () => {
+ render(
);
+ const sep = screen.getByRole("separator");
+ fireEvent.keyDown(sep, { key: "ArrowLeft" });
+ const panel = document.querySelector(".fixed") as HTMLElement;
+ expect(parseInt(panel.style.width, 10)).toBe(480 + 16); // widens
+ });
+
+ it("ArrowRight decreases width by 16px (STEP — moves left edge leftward, narrows panel)", () => {
+ render(
);
+ const sep = screen.getByRole("separator");
+ fireEvent.keyDown(sep, { key: "ArrowRight" });
+ const panel = document.querySelector(".fixed") as HTMLElement;
+ expect(parseInt(panel.style.width, 10)).toBe(480 - 16); // narrows
+ });
+
+ it("Home key sets width to MIN (320)", () => {
+ render(
);
+ fireEvent.keyDown(screen.getByRole("separator"), { key: "Home" });
+ const panel = document.querySelector(".fixed") as HTMLElement;
+ expect(parseInt(panel.style.width, 10)).toBe(320);
+ });
+
+ it("End key sets width to MAX (800)", () => {
+ render(
);
+ fireEvent.keyDown(screen.getByRole("separator"), { key: "End" });
+ const panel = document.querySelector(".fixed") as HTMLElement;
+ expect(parseInt(panel.style.width, 10)).toBe(800);
+ });
+
+ it("ArrowLeft persists new width to localStorage", () => {
+ render(
);
+ fireEvent.keyDown(screen.getByRole("separator"), { key: "ArrowLeft" });
+ expect(localStorage.getItem("molecule:sidepanel-width")).toBe(String(480 + 16));
+ });
+
+ it("Home persists new width to localStorage", () => {
+ render(
);
+ fireEvent.keyDown(screen.getByRole("separator"), { key: "Home" });
+ expect(localStorage.getItem("molecule:sidepanel-width")).toBe("320");
+ });
+});
+
+// ─── Needs-restart banner ────────────────────────────────────────────────────
+
+describe("SidePanel — needs-restart banner", () => {
+ it("shows banner when needsRestart=true and no currentTask", () => {
+ storeState.nodes = [{ ...BASE_NODE, data: { ...BASE_NODE.data, needsRestart: true, currentTask: null } }];
+ render(
);
+ expect(screen.getByText(/config changed/i)).toBeTruthy();
+ expect(screen.getByRole("button", { name: /restart now/i })).toBeTruthy();
+ });
+
+ it("does NOT show banner when needsRestart=false", () => {
+ render(
);
+ expect(screen.queryByText(/config changed/i)).toBeNull();
+ expect(screen.queryByRole("button", { name: /restart now/i })).toBeNull();
+ });
+
+ it("Restart Now button calls restartWorkspace(selectedNodeId)", () => {
+ storeState.nodes = [{ ...BASE_NODE, data: { ...BASE_NODE.data, needsRestart: true, currentTask: null } }];
+ render(
);
+ fireEvent.click(screen.getByRole("button", { name: /restart now/i }));
+ expect(mockRestartWorkspace).toHaveBeenCalledWith("ws-1");
+ });
+});
+
+// ─── Current-task banner ────────────────────────────────────────────────────
+
+describe("SidePanel — current-task banner", () => {
+ it("shows banner when currentTask is set", () => {
+ storeState.nodes = [{ ...BASE_NODE, data: { ...BASE_NODE.data, currentTask: "Deploying bundle..." } }];
+ render(
);
+ expect(screen.getByText("Deploying bundle...")).toBeTruthy();
+ });
+
+ it("does NOT show banner when currentTask is null", () => {
+ render(
);
+ expect(screen.queryByText(/deploying bundle/i)).toBeNull();
+ });
+});
+
+// ─── Footer ─────────────────────────────────────────────────────────────────
+
+describe("SidePanel — footer", () => {
+ it("footer shows workspace ID in monospace font", () => {
+ render(
);
+ // ws-1 appears in the footer with font-mono class
+ expect(screen.getByText("ws-1")).toBeTruthy();
+ });
+});
+
+// ─── Tab switching ─────────────────────────────────────────────────────────
+
+describe("SidePanel — tab switching", () => {
+ it("clicking Details tab calls setPanelTab('details')", () => {
+ render(
);
+ fireEvent.click(screen.getByRole("tab", { name: /details/i }));
+ expect(mockSetPanelTab).toHaveBeenCalledWith("details");
+ });
+
+ it("clicking Plugins tab calls setPanelTab('skills')", () => {
+ render(
);
+ fireEvent.click(screen.getByRole("tab", { name: /plugins/i }));
+ expect(mockSetPanelTab).toHaveBeenCalledWith("skills");
+ });
+
+ it("clicking Terminal tab calls setPanelTab('terminal')", () => {
+ render(
);
+ fireEvent.click(screen.getByRole("tab", { name: /terminal/i }));
+ expect(mockSetPanelTab).toHaveBeenCalledWith("terminal");
+ });
+});
+
+// ─── setSidePanelWidth ─────────────────────────────────────────────────────
+
+describe("SidePanel — setSidePanelWidth side-effect", () => {
+ it("calls setSidePanelWidth with 480 (default width) on mount", () => {
+ render(
);
+ expect(mockSetSidePanelWidth).toHaveBeenCalledWith(480);
+ });
+
+ it("updates setSidePanelWidth after keyboard resize", () => {
+ render(
);
+ mockSetSidePanelWidth.mockClear();
+ fireEvent.keyDown(screen.getByRole("separator"), { key: "ArrowLeft" });
+ expect(mockSetSidePanelWidth).toHaveBeenCalledWith(480 + 16);
+ });
+});
+
+// ─── Width localStorage ────────────────────────────────────────────────────
+
+describe("SidePanel — width localStorage", () => {
+ it("does not persist default width to localStorage on initial mount (only on user resize)", () => {
+ render(
);
+ // localStorage is only written by the keyboard resize handler, not on mount
+ expect(localStorage.getItem("molecule:sidepanel-width")).toBeNull();
+ });
+
+ it("reads saved width from localStorage", () => {
+ localStorage.setItem("molecule:sidepanel-width", "600");
+ const { container } = render(
);
+ const panel = container.firstChild as HTMLElement;
+ expect(panel.style.width).toBe("600px");
+ });
+
+ it("caps saved width to default when below minimum", () => {
+ localStorage.setItem("molecule:sidepanel-width", "100");
+ const { container } = render(
);
+ const panel = container.firstChild as HTMLElement;
+ expect(panel.style.width).toBe("480px");
+ });
+});
+
+// ─── Offline status ─────────────────────────────────────────────────────────
+
+describe("SidePanel — offline status", () => {
+ it("shows tier badge even when node is offline", () => {
+ storeState.nodes = [{ ...BASE_NODE, data: { ...BASE_NODE.data, status: "offline" as const } }];
+ render(
);
+ // T2 appears in both header badge and meta pill — just confirm at least one exists
+ const all = screen.getAllByText("T2");
+ expect(all.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("shows 'offline' in the Status meta pill when node is offline", () => {
+ storeState.nodes = [{ ...BASE_NODE, data: { ...BASE_NODE.data, status: "offline" as const } }];
+ render(
);
+ expect(screen.getByText("offline")).toBeTruthy();
+ });
+});
diff --git a/canvas/src/components/__tests__/TemplatePalette.test.tsx b/canvas/src/components/__tests__/TemplatePalette.test.tsx
new file mode 100644
index 00000000..7a5ffd10
--- /dev/null
+++ b/canvas/src/components/__tests__/TemplatePalette.test.tsx
@@ -0,0 +1,260 @@
+// @vitest-environment jsdom
+/**
+ * Tests for TemplatePalette — the floating sidebar drawer.
+ *
+ * Covers:
+ * - Toggle button aria-label (open / closed)
+ * - Sidebar renders when open, hides when closed
+ * - Sidebar header: "Templates" heading, subtitle
+ * - Loading state
+ * - Empty state ("No templates found")
+ * - Template cards: name, description, tier badge, skill pills
+ * - Deploy button calls deploy()
+ * - Errors swallowed → empty state shown
+ * - setTemplatePaletteOpen called on open/close
+ * - OrgTemplatesSection rendered inside sidebar
+ * - Import Agent Folder button in footer
+ * - Refresh templates button in footer
+ */
+import React from "react";
+import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// ── Hoisted mocks — vi.hoisted() so they're available when vi.mock runs ──────
+// IMPORTANT: use plain vi.fn() in the return object (NOT `const fn = vi.fn(); return { fn }`)
+const { mockDeploy, mockSetTemplatePaletteOpen, mockGet } = vi.hoisted(() => ({
+ mockDeploy: vi.fn(),
+ mockSetTemplatePaletteOpen: vi.fn(),
+ mockGet: vi.fn(),
+}));
+
+vi.mock("@/hooks/useTemplateDeploy", () => ({
+ useTemplateDeploy: () => ({
+ deploy: mockDeploy,
+ deploying: null,
+ error: null,
+ modal: null,
+ }),
+}));
+
+vi.mock("@/store/canvas", () => ({
+ useCanvasStore: vi.fn((selector: (s: { setTemplatePaletteOpen: typeof mockSetTemplatePaletteOpen }) => unknown) =>
+ selector({ setTemplatePaletteOpen: mockSetTemplatePaletteOpen })
+ ),
+}));
+
+vi.mock("@/lib/api", () => ({
+ api: { get: mockGet },
+}));
+
+vi.mock("../OrgImportPreflightModal", () => ({
+ OrgImportPreflightModal: () => null,
+}));
+
+vi.mock("../ConfirmDialog", () => ({
+ ConfirmDialog: () => null,
+}));
+
+vi.mock("../Spinner", () => ({
+ Spinner: () =>
,
+}));
+
+vi.mock("../Toaster", () => ({ showToast: vi.fn() }));
+
+// ── Component import — after all mocks ──────────────────────────────────────
+import { TemplatePalette } from "../TemplatePalette";
+
+beforeEach(() => {
+ mockDeploy.mockReset();
+ mockSetTemplatePaletteOpen.mockReset();
+ mockGet.mockReset().mockResolvedValue([]);
+});
+
+afterEach(() => {
+ cleanup();
+});
+
+// ── Helpers ──────────────────────────────────────────────────────────────────
+
+async function flush() {
+ await act(async () => { await Promise.resolve(); });
+}
+
+const MOCK_TEMPLATES = [
+ {
+ id: "tmpl-1",
+ name: "Software Engineer",
+ description: "Best for writing code",
+ tier: 1,
+ skills: ["web-search", "read-file", "write-file"],
+ },
+ {
+ id: "tmpl-2",
+ name: "Researcher",
+ description: "Deep research agent",
+ tier: 2,
+ skills: [],
+ },
+];
+
+// ─── Toggle button ─────────────────────────────────────────────────────────
+
+describe("TemplatePalette — toggle button", () => {
+ it("has aria-label='Open template palette' when closed", () => {
+ render(
);
+ expect(screen.getByRole("button", { name: /open template palette/i })).toBeTruthy();
+ });
+
+ it("has aria-label='Close template palette' when open", async () => {
+ render(
);
+ fireEvent.click(screen.getByRole("button", { name: /open template palette/i }));
+ await flush();
+ expect(screen.getByRole("button", { name: /close template palette/i })).toBeTruthy();
+ });
+
+ it("clicking toggle opens sidebar", async () => {
+ render(
);
+ fireEvent.click(screen.getByRole("button", { name: /open template palette/i }));
+ await flush();
+ expect(screen.getByRole("heading", { name: "Templates" })).toBeTruthy();
+ });
+
+ it("clicking toggle again closes sidebar", async () => {
+ render(
);
+ fireEvent.click(screen.getByRole("button", { name: /open template palette/i }));
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /close template palette/i }));
+ await flush();
+ expect(screen.queryByRole("heading", { name: "Templates" })).toBeNull();
+ });
+
+ it("calls setTemplatePaletteOpen(true) when opened", async () => {
+ render(
);
+ fireEvent.click(screen.getByRole("button", { name: /open template palette/i }));
+ await flush();
+ expect(mockSetTemplatePaletteOpen).toHaveBeenCalledWith(true);
+ });
+
+ it("calls setTemplatePaletteOpen(false) when closed", async () => {
+ render(
);
+ fireEvent.click(screen.getByRole("button", { name: /open template palette/i }));
+ await flush();
+ mockSetTemplatePaletteOpen.mockClear();
+ fireEvent.click(screen.getByRole("button", { name: /close template palette/i }));
+ await flush();
+ expect(mockSetTemplatePaletteOpen).toHaveBeenCalledWith(false);
+ });
+});
+
+// ─── Sidebar content ───────────────────────────────────────────────────────
+
+describe("TemplatePalette — sidebar", () => {
+ async function openSidebar() {
+ fireEvent.click(screen.getByRole("button", { name: /open template palette/i }));
+ await flush();
+ }
+
+ it("shows 'Templates' heading", async () => {
+ render(
);
+ await openSidebar();
+ expect(screen.getByRole("heading", { name: "Templates" })).toBeTruthy();
+ });
+
+ it("shows subtitle 'Click to deploy a workspace'", async () => {
+ render(
);
+ await openSidebar();
+ expect(screen.getByText(/click to deploy a workspace/i)).toBeTruthy();
+ });
+
+ it("shows loading state", async () => {
+ mockGet.mockReturnValue(new Promise(() => {}));
+ render(
);
+ await openSidebar();
+ expect(screen.getByTestId("spinner")).toBeTruthy();
+ expect(screen.getByText(/loading/i)).toBeTruthy();
+ });
+
+ it("shows empty state when no templates", async () => {
+ mockGet.mockResolvedValue([]);
+ render(
);
+ await openSidebar();
+ expect(screen.getByText(/no templates found/i)).toBeTruthy();
+ });
+
+ it("renders template cards", async () => {
+ mockGet.mockResolvedValue(MOCK_TEMPLATES);
+ render(
);
+ await openSidebar();
+ expect(screen.getByText("Software Engineer")).toBeTruthy();
+ expect(screen.getByText("Researcher")).toBeTruthy();
+ });
+
+ it("shows template description", async () => {
+ mockGet.mockResolvedValue(MOCK_TEMPLATES);
+ render(
);
+ await openSidebar();
+ expect(screen.getByText(/best for writing code/i)).toBeTruthy();
+ });
+
+ it("shows tier badge on template card", async () => {
+ mockGet.mockResolvedValue(MOCK_TEMPLATES);
+ render(
);
+ await openSidebar();
+ // T1 appears in tier badge
+ expect(screen.getAllByText("T1").length).toBeGreaterThanOrEqual(1);
+ });
+
+ it("shows up to 3 skill pills", async () => {
+ mockGet.mockResolvedValue(MOCK_TEMPLATES);
+ render(
);
+ await openSidebar();
+ expect(screen.getByText("web-search")).toBeTruthy();
+ expect(screen.getByText("read-file")).toBeTruthy();
+ expect(screen.getByText("write-file")).toBeTruthy();
+ });
+
+ it("shows '+N more' when more than 3 skills", async () => {
+ mockGet.mockResolvedValue([
+ { id: "tmpl-many", name: "Full Stack", description: "", tier: 1, skills: ["a", "b", "c", "d", "e"] },
+ ]);
+ render(
);
+ await openSidebar();
+ expect(screen.getByText("+2")).toBeTruthy();
+ });
+
+ it("deploy button calls deploy(t)", async () => {
+ mockGet.mockResolvedValue(MOCK_TEMPLATES);
+ render(
);
+ await openSidebar();
+ const deployBtns = screen.getAllByRole("button", { name: /software engineer/i });
+ await act(async () => { deployBtns[0].click(); });
+ expect(mockDeploy).toHaveBeenCalledWith(MOCK_TEMPLATES[0]);
+ });
+
+ it("shows empty state when api.get rejects (error is swallowed)", async () => {
+ mockGet.mockRejectedValue(new Error("server error"));
+ render(
);
+ await openSidebar();
+ await waitFor(() => {
+ expect(screen.getByText(/no templates found/i)).toBeTruthy();
+ });
+ });
+
+ it("renders OrgTemplatesSection inside sidebar", async () => {
+ render(
);
+ await openSidebar();
+ expect(document.querySelector("[data-testid='org-templates-section']")).toBeTruthy();
+ });
+
+ it("renders Import Agent Folder button in footer", async () => {
+ render(
);
+ await openSidebar();
+ expect(screen.getByRole("button", { name: /import agent folder/i })).toBeTruthy();
+ });
+
+ it("renders Refresh templates button in footer", async () => {
+ render(
);
+ await openSidebar();
+ expect(screen.getByRole("button", { name: /^refresh templates$/i })).toBeTruthy();
+ });
+});
diff --git a/canvas/src/components/canvas/__tests__/TopBar.test.tsx b/canvas/src/components/canvas/__tests__/TopBar.test.tsx
new file mode 100644
index 00000000..e2096479
--- /dev/null
+++ b/canvas/src/components/canvas/__tests__/TopBar.test.tsx
@@ -0,0 +1,97 @@
+// @vitest-environment jsdom
+/**
+ * TopBar — canvas header scaffold with logo, canvas name, New Agent button,
+ * and SettingsButton integration point.
+ *
+ * Coverage:
+ * - Renders header with logo and canvas name (default and custom)
+ * - New Agent button present and clickable
+ * - SettingsButton rendered (via mock)
+ * - Ref forwarding wired (settingsGearRef passed as ref prop)
+ *
+ * NOTE: No @testing-library/jest-dom — use DOM APIs.
+ */
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render } from "@testing-library/react";
+import React from "react";
+
+import { TopBar } from "../TopBar";
+
+vi.mock("@/components/settings/SettingsButton", () => ({
+ SettingsButton: React.forwardRef
(
+ (_props, ref) => ,
+ ),
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.restoreAllMocks();
+});
+
+// ─── Render ────────────────────────────────────────────────────────────────────
+
+describe("TopBar — render", () => {
+ it("renders the header element", () => {
+ render();
+ const header = document.querySelector("header");
+ expect(header).toBeTruthy();
+ });
+
+ it("shows default canvas name 'Canvas'", () => {
+ render();
+ expect(document.body.textContent).toContain("Canvas");
+ });
+
+ it("shows custom canvas name when provided", () => {
+ render();
+ expect(document.body.textContent).toContain("Production Canvas");
+ expect(document.body.textContent).not.toContain("Canvas\n"); // not default
+ });
+
+ it("renders New Agent button", () => {
+ render();
+ const btn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.includes("New Agent"),
+ );
+ expect(btn).toBeTruthy();
+ });
+
+ it("renders SettingsButton", () => {
+ render();
+ const settingsBtn = document.querySelector('button[aria-label="Settings"]');
+ expect(settingsBtn).toBeTruthy();
+ });
+
+ it("renders logo icon", () => {
+ render();
+ const logo = Array.from(document.querySelectorAll("span")).find(
+ (s) => s.getAttribute("aria-hidden") === "true",
+ );
+ expect(logo).toBeTruthy();
+ expect(logo?.textContent).toContain("☁");
+ });
+});
+
+// ─── Interaction ──────────────────────────────────────────────────────────────
+
+describe("TopBar — interaction", () => {
+ it("New Agent button is in the DOM and not disabled", () => {
+ render();
+ const btn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent?.includes("New Agent"),
+ );
+ expect(btn).toBeTruthy();
+ expect(btn!.getAttribute("disabled")).toBeNull();
+ });
+
+ it("renders without crashing with empty canvasName", () => {
+ render();
+ expect(document.querySelector("header")).toBeTruthy();
+ });
+
+ it("renders without crashing with long canvasName", () => {
+ const longName = "A".repeat(200);
+ render();
+ expect(document.body.textContent).toContain(longName);
+ });
+});
diff --git a/canvas/src/components/canvas/__tests__/useKeyboardShortcuts.test.tsx b/canvas/src/components/canvas/__tests__/useKeyboardShortcuts.test.tsx
index 9606180f..edffa4e2 100644
--- a/canvas/src/components/canvas/__tests__/useKeyboardShortcuts.test.tsx
+++ b/canvas/src/components/canvas/__tests__/useKeyboardShortcuts.test.tsx
@@ -101,6 +101,20 @@ describe("Esc — deselect / close context menu", () => {
fireEvent.keyDown(window, { key: "Escape" });
expect(mockStoreState.selectNode).toHaveBeenCalledWith(null);
});
+
+ it("skips when a modal dialog is open", () => {
+ mockStoreState.contextMenu = null;
+ mockStoreState.selectedNodeId = "n1";
+ renderWithProvider();
+ const dialog = document.createElement("div");
+ dialog.setAttribute("role", "dialog");
+ dialog.setAttribute("aria-modal", "true");
+ document.body.appendChild(dialog);
+ fireEvent.keyDown(window, { key: "Escape" });
+ expect(mockStoreState.clearSelection).not.toHaveBeenCalled();
+ expect(mockStoreState.selectNode).not.toHaveBeenCalled();
+ document.body.removeChild(dialog);
+ });
});
describe("Enter — hierarchy navigation", () => {
@@ -136,6 +150,17 @@ describe("Enter — hierarchy navigation", () => {
fireEvent.keyDown(window, { key: "Enter" });
expect(mockStoreState.selectNode).not.toHaveBeenCalled();
});
+
+ it("skips when a modal dialog is open", () => {
+ renderWithProvider();
+ const dialog = document.createElement("div");
+ dialog.setAttribute("role", "dialog");
+ dialog.setAttribute("aria-modal", "true");
+ document.body.appendChild(dialog);
+ fireEvent.keyDown(window, { key: "Enter" });
+ expect(mockStoreState.selectNode).not.toHaveBeenCalled();
+ document.body.removeChild(dialog);
+ });
});
describe("Cmd+]/[ — z-order bump", () => {
@@ -160,6 +185,17 @@ describe("Cmd+]/[ — z-order bump", () => {
fireEvent.keyDown(window, { key: "]", ctrlKey: true });
expect(mockStoreState.bumpZOrder).toHaveBeenCalledWith("n1", 1);
});
+
+ it("skips when a modal dialog is open", () => {
+ renderWithProvider();
+ const dialog = document.createElement("div");
+ dialog.setAttribute("role", "dialog");
+ dialog.setAttribute("aria-modal", "true");
+ document.body.appendChild(dialog);
+ fireEvent.keyDown(window, { key: "]", metaKey: true });
+ expect(mockStoreState.bumpZOrder).not.toHaveBeenCalled();
+ document.body.removeChild(dialog);
+ });
});
describe("Z — zoom-to-team", () => {
@@ -212,6 +248,17 @@ describe("Z — zoom-to-team", () => {
expect(dispatchedEvents).toHaveLength(0);
document.body.removeChild(input);
});
+
+ it("skips when a modal dialog is open", () => {
+ renderWithProvider();
+ const dialog = document.createElement("div");
+ dialog.setAttribute("role", "dialog");
+ dialog.setAttribute("aria-modal", "true");
+ document.body.appendChild(dialog);
+ fireEvent.keyDown(window, { key: "z" });
+ expect(dispatchedEvents).toHaveLength(0);
+ document.body.removeChild(dialog);
+ });
});
describe("Arrow keys — keyboard node movement", () => {
diff --git a/canvas/src/components/canvas/useKeyboardShortcuts.ts b/canvas/src/components/canvas/useKeyboardShortcuts.ts
index 2612f51c..9e44c7d7 100644
--- a/canvas/src/components/canvas/useKeyboardShortcuts.ts
+++ b/canvas/src/components/canvas/useKeyboardShortcuts.ts
@@ -13,7 +13,9 @@ function hasChildren(nodeId: string, nodes: Node[]): boolean
/**
* Canvas-wide keyboard shortcuts. All bound to the document window so
* they work regardless of focused node, except when the user is typing
- * into an input (`inInput` short-circuits handling).
+ * into an input (`inInput` short-circuits handling) or a modal dialog is
+ * open (`isModalOpen` short-circuits handling — dialogs own their own
+ * keyboard semantics and take precedence).
*
* Esc — close context menu, clear selection, deselect
* Enter — descend into selected node's first child
@@ -25,6 +27,10 @@ function hasChildren(nodeId: string, nodes: Node[]): boolean
* Cmd/Ctrl+Arrow — resize selected node (↑↓ height, ←→ width)
* Cmd/Ctrl+Shift+Arrow — resize by 2px per press (fine control)
*/
+/** Returns true when a modal dialog (role=dialog, aria-modal=true) is open. */
+const isModalOpen = () =>
+ document.querySelector('[role="dialog"][aria-modal="true"]') !== null;
+
export function useKeyboardShortcuts() {
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@@ -36,6 +42,7 @@ export function useKeyboardShortcuts() {
(e.target as HTMLElement).isContentEditable;
if (e.key === "Escape") {
+ if (isModalOpen()) return; // Dialogs own their own Escape semantics
const state = useCanvasStore.getState();
if (state.contextMenu) {
state.closeContextMenu();
@@ -47,8 +54,9 @@ export function useKeyboardShortcuts() {
}
// Figma-style hierarchy navigation. Skipped when the user is
- // typing so Enter can still submit forms.
- if (!inInput && (e.key === "Enter" || e.key === "NumpadEnter")) {
+ // typing so Enter can still submit forms, and when a dialog is open
+ // so the dialog can use Enter for its own actions.
+ if (!inInput && !isModalOpen() && (e.key === "Enter" || e.key === "NumpadEnter")) {
e.preventDefault();
const state = useCanvasStore.getState();
const id = state.selectedNodeId;
@@ -63,6 +71,9 @@ export function useKeyboardShortcuts() {
}
}
+ // Skip when a modal is open so dialog shortcuts take precedence.
+ if (isModalOpen()) return;
+
if (
!inInput &&
(e.metaKey || e.ctrlKey) &&
@@ -111,7 +122,7 @@ export function useKeyboardShortcuts() {
if (!selectedId) return;
// Skip when a modal/dialog is already open — dialogs own their own
// arrow-key semantics and shouldn't trigger canvas moves.
- if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
+ if (isModalOpen()) return;
e.preventDefault();
const step = e.shiftKey ? 50 : 10;
let dx = 0;
@@ -138,7 +149,7 @@ export function useKeyboardShortcuts() {
const state = useCanvasStore.getState();
const selectedId = state.selectedNodeId;
if (!selectedId) return;
- if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
+ if (isModalOpen()) return;
e.preventDefault();
const step = e.shiftKey ? 2 : 10;
const node = state.nodes.find((n) => n.id === selectedId);
diff --git a/canvas/src/components/mobile/__tests__/AgentCard.test.tsx b/canvas/src/components/mobile/__tests__/AgentCard.test.tsx
new file mode 100644
index 00000000..9b0dd513
--- /dev/null
+++ b/canvas/src/components/mobile/__tests__/AgentCard.test.tsx
@@ -0,0 +1,115 @@
+// @vitest-environment jsdom
+/**
+ * AgentCard — mobile agent row card.
+ *
+ * Per WCAG 2.1 AA:
+ * - Rendered as }>Runtime config,
+ );
+ expect(container.textContent).toContain("Edit");
+ expect(container.querySelector("button")).toBeTruthy();
+ });
+
+ it("renders without right slot", () => {
+ const { container } = render(Runtime config);
+ expect(container.querySelector("button")).toBeNull();
+ });
+
+ it("uses uppercase text transform", () => {
+ const { container } = render(Runtime config);
+ const div = container.querySelector("div") as HTMLDivElement;
+ expect(div.style.textTransform).toBe("uppercase");
+ });
+});
diff --git a/canvas/src/components/mobile/components.tsx b/canvas/src/components/mobile/components.tsx
index 9e1c8780..99af074b 100644
--- a/canvas/src/components/mobile/components.tsx
+++ b/canvas/src/components/mobile/components.tsx
@@ -72,8 +72,33 @@ export function TabBar({
{ id: "comms", label: "Comms", icon: "pulse" },
{ id: "me", label: "Me", icon: "user" },
];
+
+ const handleKeyDown = (e: React.KeyboardEvent, idx: number) => {
+ let nextIdx: number | null = null;
+ if (e.key === "ArrowRight" || e.key === "ArrowDown") {
+ nextIdx = (idx + 1) % tabs.length;
+ } else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
+ nextIdx = (idx - 1 + tabs.length) % tabs.length;
+ } else if (e.key === "Home") {
+ nextIdx = 0;
+ } else if (e.key === "End") {
+ nextIdx = tabs.length - 1;
+ }
+ if (nextIdx !== null) {
+ e.preventDefault();
+ onChange(tabs[nextIdx]!.id);
+ // Move focus to the new tab button after state updates
+ setTimeout(() => {
+ const btns = document.querySelectorAll('[role="tab"]');
+ (btns[nextIdx!] as HTMLButtonElement | null)?.focus();
+ }, 0);
+ }
+ };
+
return (
- {tabs.map((t) => {
+ {tabs.map((t, idx) => {
const on = active === t.id;
return (
+ {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
-
diff --git a/canvas/src/components/settings/__tests__/AddKeyForm.test.tsx b/canvas/src/components/settings/__tests__/AddKeyForm.test.tsx
new file mode 100644
index 00000000..bd5e1d79
--- /dev/null
+++ b/canvas/src/components/settings/__tests__/AddKeyForm.test.tsx
@@ -0,0 +1,340 @@
+// @vitest-environment jsdom
+/**
+ * Tests for AddKeyForm — inline form for adding a new API key.
+ *
+ * Covers:
+ * - Header + key name + value fields rendered
+ * - Key name auto-uppercased on input
+ * - Validation: UPPER_SNAKE_CASE required, duplicate name blocked
+ * - Provider hint shown for known providers (GitHub, Anthropic, OpenRouter)
+ * - Provider hint hidden for custom key names
+ * - Debounced value validation
+ * - Save button disabled when form invalid / saving
+ * - createSecret called on save with correct args
+ * - onCancel called on Cancel click
+ * - Save error shown on failure
+ * - TestConnectionButton shown when value is format-valid and provider supports it
+ */
+import React from "react";
+import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { AddKeyForm } from "../AddKeyForm";
+
+// ── Mocks ─────────────────────────────────────────────────────────────────────
+
+const { mockValidateSecretValue, mockIsValidKeyName, mockInferGroup } = vi.hoisted(() => ({
+ mockValidateSecretValue: vi.fn((value: string) => {
+ // Return error for "bad-value" to test ValidationHint display
+ if (value === "bad-value") return "Invalid format";
+ return null;
+ }),
+ mockIsValidKeyName: vi.fn((name: string) => /^[A-Z][A-Z0-9_]*$/.test(name)),
+ mockInferGroup: vi.fn((name: string) => {
+ const u = name.toUpperCase();
+ if (u.includes("GITHUB")) return "github" as const;
+ if (u.includes("ANTHROPIC")) return "anthropic" as const;
+ if (u.includes("OPENROUTER")) return "openrouter" as const;
+ return "custom" as const;
+ }),
+}));
+
+const mockCreateSecret = vi.fn();
+
+vi.mock("@/stores/secrets-store", () => ({
+ useSecretsStore: Object.assign(
+ vi.fn((selector?: (s: { createSecret: typeof mockCreateSecret }) => unknown) =>
+ selector ? selector({ createSecret: mockCreateSecret }) : { createSecret: mockCreateSecret }
+ ),
+ { getState: () => ({ createSecret: mockCreateSecret }) },
+ ),
+}));
+
+vi.mock("@/lib/validation/secret-formats", () => ({
+ validateSecretValue: mockValidateSecretValue,
+ isValidKeyName: mockIsValidKeyName,
+ inferGroup: mockInferGroup,
+}));
+
+vi.mock("@/lib/services", () => ({
+ SERVICES: {
+ github: { label: "GitHub", icon: "github", keyNames: [], docsUrl: "https://github.com", testSupported: true },
+ anthropic: { label: "Anthropic", icon: "anthropic", keyNames: [], docsUrl: "https://anthropic.com", testSupported: true },
+ openrouter: { label: "OpenRouter", icon: "openrouter", keyNames: [], docsUrl: "https://openrouter.ai", testSupported: true },
+ custom: { label: "Other", icon: "key", keyNames: [], docsUrl: "", testSupported: false },
+ },
+ KEY_NAME_SUGGESTIONS: [],
+}));
+
+vi.mock("@/components/ui/KeyValueField", () => ({
+ KeyValueField: ({ value, onChange, disabled }: { value: string; onChange: (v: string) => void; disabled?: boolean }) => (
+