diff --git a/canvas/src/components/__tests__/Toolbar.test.tsx b/canvas/src/components/__tests__/Toolbar.test.tsx
new file mode 100644
index 00000000..60e74c7c
--- /dev/null
+++ b/canvas/src/components/__tests__/Toolbar.test.tsx
@@ -0,0 +1,291 @@
+// @vitest-environment jsdom
+/**
+ * Toolbar tests.
+ *
+ * Covers:
+ * - Renders with 0 workspaces
+ * - Shows online/offline/failed/provisioning status pills when nodes exist
+ * - WebSocket status pill: connected → "Live"
+ * - WebSocket status pill: connecting → "Reconnecting"
+ * - WebSocket status pill: disconnected → "Offline"
+ * - Stop All button visible when activeTasks > 0
+ * - Restart Pending button visible when needsRestart nodes exist
+ * - Help button opens the help popover
+ * - Help popover closes on Escape or pointer-outside
+ * - KeyboardShortcutsDialog opens via ? shortcut (when not in input)
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, cleanup } from "@testing-library/react";
+import React from "react";
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+// Reset store state between tests so mutations don't leak.
+beforeEach(() => {
+ defaultStore.nodes = [];
+ defaultStore.wsStatus = "connected";
+ defaultStore.showA2AEdges = false;
+ defaultStore.selectedNodeId = null;
+ mockSetShowA2AEdges.mockClear();
+ mockSetPanelTab.mockClear();
+ mockSetSearchOpen.mockClear();
+ mockUpdateNodeData.mockClear();
+});
+
+// ── Mock targets ───────────────────────────────────────────────────────────────
+
+vi.mock("@/components/Toaster", () => ({
+ showToast: vi.fn(),
+}));
+
+vi.mock("@/components/ConfirmDialog", () => ({
+ ConfirmDialog: () => null,
+}));
+
+vi.mock("@/components/settings/SettingsButton", () => ({
+ SettingsButton: () => null,
+}));
+
+vi.mock("@/components/settings/SettingsPanel", () => ({
+ settingsGearRef: { current: null },
+}));
+
+vi.mock("@/components/ThemeToggle", () => ({
+ ThemeToggle: () => null,
+}));
+
+vi.mock("@/components/KeyboardShortcutsDialog", () => ({
+ KeyboardShortcutsDialog: ({ open }: { open: boolean; onClose: () => void }) =>
+ open ?
Shortcuts
: null,
+}));
+
+vi.mock("@/lib/design-tokens", () => ({
+ statusDotClass: (status: string) => {
+ const map: Record = {
+ online: "bg-emerald-400",
+ offline: "bg-zinc-500",
+ paused: "bg-indigo-400",
+ degraded: "bg-amber-400",
+ failed: "bg-red-400",
+ provisioning: "bg-sky-400",
+ };
+ return map[status] ?? "bg-zinc-500";
+ },
+}));
+
+vi.mock("@/lib/api", () => ({
+ api: {
+ post: vi.fn(() => Promise.resolve()),
+ },
+}));
+
+// ── Store mocks ────────────────────────────────────────────────────────────────
+
+const mockSetShowA2AEdges = vi.fn();
+const mockSetPanelTab = vi.fn();
+const mockSetSearchOpen = vi.fn();
+const mockUpdateNodeData = vi.fn();
+
+const makeNodes = (
+ statuses: Array<"online" | "offline" | "failed" | "provisioning">,
+ activeTasks: number[] = [],
+ needsRestart: boolean[] = [],
+ parentIds: (string | null)[] = []
+) => {
+ return statuses.map((status, i) => ({
+ id: `ws-${i}`,
+ data: {
+ name: `Workspace ${i}`,
+ role: "agent",
+ tier: 1,
+ status,
+ parentId: parentIds[i] ?? null,
+ activeTasks: activeTasks[i] ?? 0,
+ needsRestart: needsRestart[i] ?? false,
+ },
+ }));
+};
+
+// Nodes must be React Flow nodes (id + data), but Toolbar only reads data fields.
+// makeNodes returns { id, data: { activeTasks, needsRestart, ... } }.
+const toStoreNodes = (nodes: ReturnType) =>
+ nodes.map((n) => ({ id: n.id, data: n.data }));
+
+const defaultStore = {
+ nodes: [] as ReturnType,
+ wsStatus: "connected" as "connected" | "connecting" | "disconnected",
+ showA2AEdges: false,
+ selectedNodeId: null as string | null,
+ sidePanelWidth: 480,
+ setShowA2AEdges: mockSetShowA2AEdges,
+ setPanelTab: mockSetPanelTab,
+ setSearchOpen: mockSetSearchOpen,
+ updateNodeData: mockUpdateNodeData,
+ selectedNodeIds: new Set(),
+ clearSelection: vi.fn(),
+ batchRestart: vi.fn(() => Promise.resolve()),
+ batchPause: vi.fn(() => Promise.resolve()),
+ batchDelete: vi.fn(() => Promise.resolve()),
+};
+
+vi.mock("@/store/canvas", () => ({
+ useCanvasStore: vi.fn((selector: (s: typeof defaultStore) => unknown) =>
+ selector(defaultStore)
+ ),
+}));
+
+// ── Component under test ───────────────────────────────────────────────────────
+import { Toolbar } from "../Toolbar";
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe("Toolbar — workspace count display", () => {
+ it("shows '0 workspaces' when the canvas is empty", () => {
+ render();
+ expect(screen.getByText(/0 workspaces?/)).toBeTruthy();
+ });
+
+ it("shows 'N workspaces' when nodes exist", () => {
+ defaultStore.nodes = toStoreNodes(makeNodes(["online", "online"]));
+ render();
+ expect(screen.getByText(/2 workspaces?/)).toBeTruthy();
+ });
+});
+
+describe("Toolbar — status pills", () => {
+ it("shows the online pill when nodes are online", () => {
+ defaultStore.nodes = toStoreNodes(makeNodes(["online"]));
+ render();
+ // StatusPill uses aria-label
+ expect(screen.getByLabelText(/1 online/i)).toBeTruthy();
+ });
+
+ it("shows the offline pill only when offline nodes exist", () => {
+ defaultStore.nodes = toStoreNodes(makeNodes(["offline"]));
+ render();
+ expect(screen.getByLabelText(/1 offline/i)).toBeTruthy();
+ });
+
+ it("shows the failed pill when failed nodes exist", () => {
+ defaultStore.nodes = toStoreNodes(makeNodes(["failed"]));
+ render();
+ expect(screen.getByLabelText(/1 failed/i)).toBeTruthy();
+ });
+
+ it("shows the provisioning pill when provisioning nodes exist", () => {
+ defaultStore.nodes = toStoreNodes(makeNodes(["provisioning"]));
+ render();
+ expect(screen.getByLabelText(/1 starting/i)).toBeTruthy();
+ });
+
+ it("suppresses offline pill when no offline nodes", () => {
+ defaultStore.nodes = toStoreNodes(makeNodes(["online", "online"]));
+ render();
+ expect(screen.queryByLabelText(/offline/i)).toBeNull();
+ });
+});
+
+describe("Toolbar — WebSocket status pill", () => {
+ it('shows "Live" when connected', () => {
+ defaultStore.wsStatus = "connected";
+ render();
+ expect(screen.getByText("Live")).toBeTruthy();
+ });
+
+ it('shows "Reconnecting" when connecting', () => {
+ defaultStore.wsStatus = "connecting";
+ render();
+ expect(screen.getByText("Reconnecting")).toBeTruthy();
+ });
+
+ it('shows "Offline" when disconnected', () => {
+ defaultStore.wsStatus = "disconnected";
+ render();
+ expect(screen.getByText("Offline")).toBeTruthy();
+ });
+});
+
+describe("Toolbar — Stop All", () => {
+ it("is hidden when no active tasks", () => {
+ defaultStore.nodes = toStoreNodes(makeNodes(["online"], [0]));
+ render();
+ expect(screen.queryByRole("button", { name: /Stop All/i })).toBeNull();
+ });
+
+ it("is visible when active tasks > 0", () => {
+ defaultStore.nodes = toStoreNodes(makeNodes(["online", "online"], [2, 2]));
+ render();
+ // aria-label: "Stop all running tasks (2)"
+ expect(screen.getByRole("button", { name: /stop all running tasks/i })).toBeTruthy();
+ });
+});
+
+describe("Toolbar — Restart Pending", () => {
+ it("is hidden when no nodes need restart", () => {
+ defaultStore.nodes = toStoreNodes(makeNodes(["online"], [], [false]));
+ render();
+ expect(screen.queryByRole("button", { name: /Restart Pending/i })).toBeNull();
+ });
+
+ it("is visible when nodes need restart", () => {
+ defaultStore.nodes = toStoreNodes(makeNodes(["online"], [], [true]));
+ render();
+ // aria-label: "Restart 1 workspaces pending config or secret changes"
+ expect(screen.getByRole("button", { name: /restart 1 workspace/i })).toBeTruthy();
+ });
+});
+
+describe("Toolbar — Help popover", () => {
+ it("opens when help button is clicked", () => {
+ render();
+ const helpBtn = screen.getByRole("button", { name: /open shortcuts and tips/i });
+ fireEvent.click(helpBtn);
+ expect(screen.getByRole("dialog")).toBeTruthy();
+ });
+
+ it("closes when close button is clicked", () => {
+ render();
+ const helpBtn = screen.getByRole("button", { name: /open shortcuts and tips/i });
+ fireEvent.click(helpBtn);
+ expect(screen.getByRole("dialog")).toBeTruthy();
+ const closeBtn = screen.getByRole("button", { name: /close help dialog/i });
+ fireEvent.click(closeBtn);
+ expect(screen.queryByRole("dialog")).toBeNull();
+ });
+});
+
+describe("Toolbar — A2A edges toggle", () => {
+ it("calls setShowA2AEdges on click", () => {
+ defaultStore.showA2AEdges = false;
+ render();
+ const toggle = screen.getByRole("button", { name: /show a2a edges/i });
+ fireEvent.click(toggle);
+ expect(mockSetShowA2AEdges).toHaveBeenCalledWith(true);
+ });
+});
+
+describe("Toolbar — ? shortcut opens shortcuts dialog", () => {
+ it("opens KeyboardShortcutsDialog when ? is pressed outside an input", () => {
+ render();
+ expect(screen.queryByTestId("shortcuts-dialog")).toBeNull();
+ fireEvent.keyDown(window, { key: "?" });
+ expect(screen.getByTestId("shortcuts-dialog")).toBeTruthy();
+ });
+
+ it("does not fire ? shortcut when focus is in an input", () => {
+ render(
+
+
+
+
+ );
+ const input = screen.getByTestId("test-input");
+ fireEvent.focus(input);
+ // Fire on the input element so e.target.tagName === "INPUT" is true
+ fireEvent.keyDown(input, { key: "?" });
+ expect(screen.queryByTestId("shortcuts-dialog")).toBeNull();
+ });
+});