diff --git a/canvas/src/components/canvas/DropTargetBadge.tsx b/canvas/src/components/canvas/DropTargetBadge.tsx
index 13c0f7d4..48e5d8de 100644
--- a/canvas/src/components/canvas/DropTargetBadge.tsx
+++ b/canvas/src/components/canvas/DropTargetBadge.tsx
@@ -63,6 +63,7 @@ export function DropTargetBadge() {
<>
{ghostVisible && (
)}
diff --git a/canvas/src/components/canvas/__tests__/DropTargetBadge.test.tsx b/canvas/src/components/canvas/__tests__/DropTargetBadge.test.tsx
new file mode 100644
index 00000000..da2a13b6
--- /dev/null
+++ b/canvas/src/components/canvas/__tests__/DropTargetBadge.test.tsx
@@ -0,0 +1,253 @@
+// @vitest-environment jsdom
+/**
+ * Tests for DropTargetBadge — floating drag affordance rendered over the
+ * ReactFlow canvas while a workspace node is being dragged onto a parent.
+ *
+ * Covers:
+ * - Renders nothing when dragOverNodeId is null
+ * - Renders nothing when target node not found in store
+ * - Renders nothing when getInternalNode returns null
+ * - Renders ghost slot + badge when valid target is found
+ * - Ghost hidden when slot falls outside parent bounds
+ * - Badge text includes the target workspace name
+ * - Badge positioned via screen-space coordinates from flowToScreenPosition
+ */
+import React from "react";
+import { render, screen, cleanup } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { DropTargetBadge } from "../DropTargetBadge";
+
+// ─── Mutable store state — hoisted so vi.mock factory closures capture the ref ─
+
+let _storeState: {
+ dragOverNodeId: string | null;
+ nodes: Array<{
+ id: string;
+ data: Record
;
+ parentId: string | null;
+ measured?: { width: number; height: number };
+ }>;
+} = {
+ dragOverNodeId: null,
+ nodes: [],
+};
+
+const _subscribers = new Set<() => void>();
+function _notifySubscribers() {
+ for (const fn of _subscribers) fn();
+}
+
+const _mockUseCanvasStore = vi.hoisted(() => {
+ const impl = (selector: (s: typeof _storeState) => unknown) => selector(_storeState);
+ return impl;
+});
+
+// Module-level mutable impl — setFlowMock() swaps it out per test.
+let _flowImpl: (arg: { x: number; y: number }) => { x: number; y: number } =
+ ({ x, y }) => ({ x: x * 2, y: y * 2 });
+
+let _flowToScreenPosition = vi.hoisted(() =>
+ vi.fn((arg: { x: number; y: number }) => _flowImpl(arg)),
+);
+
+let _getInternalNode = vi.hoisted(() =>
+ vi.fn<(id: string) => {
+ internals: { positionAbsolute: { x: number; y: number } };
+ measured?: { width: number; height: number };
+ } | null>(() => null),
+);
+
+const _mockUseReactFlow = vi.hoisted(() =>
+ vi.fn(() => ({
+ getInternalNode: _getInternalNode,
+ flowToScreenPosition: _flowToScreenPosition,
+ })),
+);
+
+// ─── Module mocks ─────────────────────────────────────────────────────────────
+
+vi.mock("@/store/canvas", () => ({
+ useCanvasStore: _mockUseCanvasStore,
+}));
+
+vi.mock("@xyflow/react", () => ({
+ useReactFlow: _mockUseReactFlow,
+}));
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function setStore(state: Partial) {
+ _storeState = { ..._storeState, ...state };
+ _notifySubscribers();
+}
+
+// Helper to set per-test flowToScreenPosition mock — replaces _flowImpl.
+function setFlowMock(impl: (arg: { x: number; y: number }) => { x: number; y: number }) {
+ _flowImpl = impl;
+}
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+describe("DropTargetBadge — renders nothing when not dragging", () => {
+ afterEach(() => {
+ cleanup();
+ _storeState = { dragOverNodeId: null, nodes: [] };
+ _getInternalNode.mockReset().mockReturnValue(null);
+ _flowImpl = ({ x, y }) => ({ x: x * 2, y: y * 2 });
+ });
+
+ it("returns null when dragOverNodeId is null", () => {
+ setStore({ dragOverNodeId: null });
+ render();
+ expect(document.body.textContent).toBe("");
+ });
+
+ it("returns null when target node not found in store nodes array", () => {
+ setStore({ dragOverNodeId: "ws-target", nodes: [] });
+ render();
+ expect(document.body.textContent).toBe("");
+ });
+});
+
+describe("DropTargetBadge — renders nothing when getInternalNode is null", () => {
+ afterEach(() => {
+ cleanup();
+ _storeState = { dragOverNodeId: null, nodes: [] };
+ _getInternalNode.mockReset().mockReturnValue(null);
+ _flowImpl = ({ x, y }) => ({ x: x * 2, y: y * 2 });
+ });
+
+ it("returns null when getInternalNode returns null (node not in RF viewport)", () => {
+ _getInternalNode.mockReturnValue(null);
+ setStore({
+ dragOverNodeId: "ws-target",
+ nodes: [{ id: "ws-target", data: { name: "Target WS" }, parentId: null }],
+ });
+ render();
+ expect(document.body.textContent).toBe("");
+ });
+});
+
+describe("DropTargetBadge — renders ghost slot + badge for valid drag target", () => {
+ afterEach(() => {
+ cleanup();
+ _storeState = { dragOverNodeId: null, nodes: [] };
+ _getInternalNode.mockReset().mockReturnValue(null);
+ _flowImpl = ({ x, y }) => ({ x: x * 2, y: y * 2 });
+ });
+
+ it("renders the drop badge with target name", () => {
+ _getInternalNode.mockReturnValue({
+ internals: { positionAbsolute: { x: 100, y: 200 } },
+ measured: { width: 220, height: 120 },
+ });
+ _flowToScreenPosition
+ .mockReturnValueOnce({ x: 500, y: 400 }) // slotTL
+ .mockReturnValueOnce({ x: 900, y: 600 }) // slotBR
+ .mockReturnValueOnce({ x: 700, y: 200 }); // badge
+
+ setStore({
+ dragOverNodeId: "ws-target",
+ nodes: [
+ { id: "ws-target", data: { name: "SEO Workspace" }, parentId: null, measured: { width: 220, height: 120 } },
+ ],
+ });
+ render();
+ expect(screen.getByText(/Drop into: SEO Workspace/)).toBeTruthy();
+ });
+
+ it("renders the ghost slot div via data-testid", () => {
+ // measured.height must be large enough that parentBR.y > slotTL.y=330 so
+ // ghostVisible = (slotTL.y < parentBR.y) is true.
+ // parentBR.y = abs.y + measured.height = 200 + h > 330 → h > 130
+ _getInternalNode.mockReturnValue({
+ internals: { positionAbsolute: { x: 100, y: 200 } },
+ measured: { width: 220, height: 500 },
+ });
+ // Component calls flowToScreenPosition 5 times (confirmed via debug):
+ // 1) badge {x:210, y:200} -> {x:420, y:400} (badge center)
+ // 2) slotTL {x:116, y:330} -> {x:232, y:660} (slot origin)
+ // 3) slotBR {x:356, y:460} -> {x:712, y:920} (ghost uses this)
+ // 4) parentTL {x:100, y:200} -> {x:200, y:400} (parent origin)
+ // 5) parentBR {x:320, y:320} -> {x:640, y:640} (parent corner)
+ setFlowMock(({ x, y }: { x: number; y: number }) => {
+ if (x === 210 && y === 200) return { x: 420, y: 400 };
+ if (x === 116 && y === 330) return { x: 232, y: 660 };
+ if (x === 356 && y === 460) return { x: 712, y: 920 };
+ if (x === 100 && y === 200) return { x: 200, y: 400 };
+ // 5th call: parentBR = abs + {w:220, h:500} = {320, 700}
+ if (x === 320 && y === 700) return { x: 640, y: 1400 };
+ return { x: x * 2, y: y * 2 };
+ });
+
+ setStore({
+ dragOverNodeId: "ws-target",
+ nodes: [
+ { id: "ws-target", data: { name: "Target" }, parentId: null, measured: { width: 220, height: 500 } },
+ ],
+ });
+ render();
+ expect(screen.getByTestId("ghost-slot")).toBeTruthy();
+ // Ghost uses slotBR from 3rd call: slotBR - slotTL = (712-232, 920-660)
+ expect(screen.getByTestId("ghost-slot").style.left).toBe("232px");
+ expect(screen.getByTestId("ghost-slot").style.top).toBe("660px");
+ expect(screen.getByTestId("ghost-slot").style.width).toBe("480px");
+ expect(screen.getByTestId("ghost-slot").style.height).toBe("260px");
+ });
+
+ it("ghost is hidden when slot falls entirely outside parent bounds", () => {
+ _getInternalNode.mockReturnValue({
+ internals: { positionAbsolute: { x: 100, y: 200 } },
+ measured: { width: 220, height: 120 },
+ });
+ // Set slotBR (3rd call) to be inside parent to hide ghost.
+ // slotBR.x ≤ parentTL.x makes slotBR.x - slotTL.x < 0 → ghostVisible = false.
+ setFlowMock(({ x, y }: { x: number; y: number }) => {
+ if (x === 210 && y === 200) return { x: 420, y: 400 }; // badge (1st call)
+ if (x === 116 && y === 330) return { x: 232, y: 660 }; // slotTL (2nd call)
+ if (x === 356 && y === 460) return { x: 150, y: 460 }; // slotBR (3rd): slotBR.x=150 < parentTL.x=200 → hidden
+ if (x === 100 && y === 200) return { x: 200, y: 400 }; // parentTL (4th call)
+ if (x === 320 && y === 320) return { x: 640, y: 640 }; // parentBR (5th call)
+ return { x: x * 2, y: y * 2 };
+ });
+
+ setStore({
+ dragOverNodeId: "ws-target",
+ nodes: [
+ { id: "ws-target", data: { name: "Tiny" }, parentId: null, measured: { width: 220, height: 120 } },
+ ],
+ });
+ render();
+ // Badge should still render, ghost should not
+ expect(screen.getByText(/Drop into: Tiny/)).toBeTruthy();
+ expect(screen.queryByTestId("ghost-slot")).toBeNull();
+ });
+
+ it("badge is absolutely positioned with left and top from flowToScreenPosition", () => {
+ _getInternalNode.mockReturnValue({
+ internals: { positionAbsolute: { x: 100, y: 200 } },
+ measured: { width: 220, height: 120 },
+ });
+ setFlowMock(({ x, y }: { x: number; y: number }) => {
+ if (x === 210 && y === 200) return { x: 420, y: 400 };
+ if (x === 116 && y === 330) return { x: 232, y: 660 };
+ if (x === 356 && y === 460) return { x: 712, y: 920 };
+ if (x === 100 && y === 200) return { x: 200, y: 400 };
+ if (x === 320 && y === 320) return { x: 640, y: 640 };
+ return { x: x * 2, y: y * 2 };
+ });
+
+ setStore({
+ dragOverNodeId: "ws-target",
+ nodes: [
+ { id: "ws-target", data: { name: "Target" }, parentId: null, measured: { width: 220, height: 120 } },
+ ],
+ });
+ render();
+ expect(screen.getByTestId("drop-badge")).toBeTruthy();
+ // Badge uses 1st call: {x:210,y:200} -> {x:420,y:400}, badge.y = 400-6 = 394
+ expect(screen.getByTestId("drop-badge").style.left).toBe("420px");
+ expect(screen.getByTestId("drop-badge").style.top).toBe("394px");
+ expect(screen.getByText(/Drop into: Target/)).toBeTruthy();
+ });
+});
diff --git a/canvas/src/components/tabs/__tests__/ActivityTab.test.tsx b/canvas/src/components/tabs/__tests__/ActivityTab.test.tsx
new file mode 100644
index 00000000..da5c637f
--- /dev/null
+++ b/canvas/src/components/tabs/__tests__/ActivityTab.test.tsx
@@ -0,0 +1,535 @@
+// @vitest-environment jsdom
+/**
+ * Tests for ActivityTab — activity ledger with live updates, filtering,
+ * expand/collapse, and A2A error hint rendering.
+ *
+ * Covers:
+ * - Loading state
+ * - Error state (network failure)
+ * - Empty state (no activities)
+ * - Activity list rendering (single + multiple)
+ * - Filter bar: 7 filters, active filter highlighted
+ * - Each filter updates the rendered list
+ * - Auto-refresh toggle (Live / Paused)
+ * - Refresh button calls API
+ * - Full Trace button opens ConversationTraceModal
+ * - Duration display in activity rows
+ * - Expand/collapse row details
+ * - A2A rows show source → target name flow
+ * - Error rows styled differently
+ * - Error detail shown when expanded
+ * - getSkills exported function (standalone unit)
+ */
+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 { ActivityTab } from "../ActivityTab";
+import type { ActivityEntry } from "@/types/activity";
+
+const mockApiGet = vi.fn();
+
+const mockUseSocketEvent = vi.fn();
+const mockUseWorkspaceName = vi.fn<(id: string | null) => string>((_id: string | null) => "Test Workspace");
+const mockConversationTraceModal = vi.fn(() => null);
+const mockConversationTraceModalRender = vi.fn(
+ ({ open }: { open: boolean }) => (open ? Trace
: null),
+);
+
+vi.mock("@/hooks/useSocketEvent", () => ({
+ useSocketEvent: (...args: unknown[]) => mockUseSocketEvent(...args),
+}));
+
+vi.mock("@/hooks/useWorkspaceName", () => ({
+ useWorkspaceName: () => mockUseWorkspaceName,
+}));
+
+vi.mock("@/components/ConversationTraceModal", () => ({
+ ConversationTraceModal: (props: { open: boolean; onClose: () => void; workspaceId: string }) =>
+ props.open ? Trace
: null,
+}));
+
+vi.mock("@/lib/api", () => ({
+ api: { get: (...args: unknown[]) => mockApiGet(...args) },
+}));
+
+// ─── Fixtures ───────────────────────────────────────────────────────────────
+
+function activity(overrides: Partial = {}): ActivityEntry {
+ return {
+ id: "act-1",
+ workspace_id: "ws-1",
+ activity_type: "agent_log",
+ source_id: null,
+ target_id: null,
+ method: null,
+ summary: null,
+ request_body: null,
+ response_body: null,
+ duration_ms: null,
+ status: "ok",
+ error_detail: null,
+ created_at: new Date(Date.now() - 60_000).toISOString(),
+ ...overrides,
+ };
+}
+
+// ─── Helpers ────────────────────────────────────────────────────────────────
+
+async function flush() {
+ await act(async () => { await Promise.resolve(); });
+}
+
+// ─── Tests ────────────────────────────────────────────────────────────────
+
+describe("ActivityTab — loading / error / empty", () => {
+ beforeEach(() => {
+ mockApiGet.mockReset();
+ mockUseSocketEvent.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("shows loading state initially", () => {
+ mockApiGet.mockImplementation(() => new Promise(() => {}));
+ render();
+ expect(screen.getByText("Loading activity...")).toBeTruthy();
+ });
+
+ it("shows error banner when API fails", async () => {
+ mockApiGet.mockRejectedValue(new Error("network failure"));
+ render();
+ await flush();
+ expect(screen.getByText(/network failure/i)).toBeTruthy();
+ });
+
+ it("shows empty state when no activities", async () => {
+ mockApiGet.mockResolvedValue([]);
+ render();
+ await flush();
+ expect(screen.getByText("No activity recorded yet")).toBeTruthy();
+ });
+});
+
+describe("ActivityTab — list rendering", () => {
+ beforeEach(() => {
+ mockApiGet.mockReset();
+ mockUseSocketEvent.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("renders a single activity row", async () => {
+ mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]);
+ render();
+ await flush();
+ expect(screen.getByText("LOG")).toBeTruthy();
+ });
+
+ it("renders multiple activity rows", async () => {
+ mockApiGet.mockResolvedValue([
+ activity({ id: "a1", activity_type: "agent_log" }),
+ activity({ id: "a2", activity_type: "task_update" }),
+ ]);
+ render();
+ await flush();
+ expect(screen.getByText("LOG")).toBeTruthy();
+ expect(screen.getByText("TASK")).toBeTruthy();
+ });
+
+ it("shows duration when duration_ms is present", async () => {
+ mockApiGet.mockResolvedValue([
+ activity({ id: "a1", duration_ms: 1234, activity_type: "agent_log" }),
+ ]);
+ render();
+ await flush();
+ expect(screen.getByText("1234ms")).toBeTruthy();
+ });
+
+ it("shows summary text when present", async () => {
+ mockApiGet.mockResolvedValue([
+ activity({ id: "a1", summary: "Delegated task to SEO Agent", activity_type: "a2a_send" }),
+ ]);
+ render();
+ await flush();
+ expect(screen.getByText(/Delegated task to SEO Agent/)).toBeTruthy();
+ });
+});
+
+describe("ActivityTab — filter bar", () => {
+ beforeEach(() => {
+ mockApiGet.mockReset();
+ mockUseSocketEvent.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("renders all 7 filter buttons", async () => {
+ mockApiGet.mockResolvedValue([]);
+ render();
+ await flush();
+ expect(screen.getByRole("button", { name: /all/i })).toBeTruthy();
+ expect(screen.getByRole("button", { name: /a2a in/i })).toBeTruthy();
+ expect(screen.getByRole("button", { name: /a2a out/i })).toBeTruthy();
+ expect(screen.getByRole("button", { name: /tasks/i })).toBeTruthy();
+ expect(screen.getByRole("button", { name: /skill promo/i })).toBeTruthy();
+ expect(screen.getByRole("button", { name: /logs/i })).toBeTruthy();
+ expect(screen.getByRole("button", { name: /errors/i })).toBeTruthy();
+ });
+
+ it("active filter has aria-pressed=true", async () => {
+ mockApiGet.mockResolvedValue([]);
+ render();
+ await flush();
+ const allBtn = screen.getByRole("button", { name: /all/i });
+ expect(allBtn.getAttribute("aria-pressed")).toBe("true");
+ });
+
+ it("clicking a filter updates aria-pressed and re-fetches", async () => {
+ mockApiGet.mockResolvedValue([]);
+ render();
+ await flush();
+ const errorsBtn = screen.getByRole("button", { name: /errors/i });
+ await act(async () => { errorsBtn.click(); });
+ await flush();
+ expect(errorsBtn.getAttribute("aria-pressed")).toBe("true");
+ // API was called with ?type=error
+ expect(mockApiGet).toHaveBeenLastCalledWith("/workspaces/ws-1/activity?type=error");
+ });
+
+ it("clicking All removes the type query param", async () => {
+ mockApiGet.mockResolvedValue([]);
+ render();
+ await flush();
+ // First click a specific filter
+ const errorsBtn = screen.getByRole("button", { name: /errors/i });
+ await act(async () => { errorsBtn.click(); });
+ await flush();
+ // Then click All
+ const allBtn = screen.getByRole("button", { name: /all/i });
+ await act(async () => { allBtn.click(); });
+ await flush();
+ expect(mockApiGet).toHaveBeenLastCalledWith("/workspaces/ws-1/activity");
+ });
+});
+
+describe("ActivityTab — auto-refresh toggle", () => {
+ beforeEach(() => {
+ mockApiGet.mockReset();
+ mockUseSocketEvent.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("renders Live by default", async () => {
+ mockApiGet.mockResolvedValue([]);
+ render();
+ await flush();
+ expect(screen.getByText("⟳ Live")).toBeTruthy();
+ });
+
+ it("clicking Live toggles to Paused", async () => {
+ mockApiGet.mockResolvedValue([]);
+ render();
+ await flush();
+ const liveBtn = screen.getByText("⟳ Live");
+ await act(async () => { liveBtn.click(); });
+ await flush();
+ expect(screen.getByText("⟳ Paused")).toBeTruthy();
+ });
+
+ it("clicking Paused toggles back to Live", async () => {
+ mockApiGet.mockResolvedValue([]);
+ render();
+ await flush();
+ const liveBtn = screen.getByText("⟳ Live");
+ await act(async () => { liveBtn.click(); });
+ await flush();
+ const pausedBtn = screen.getByText("⟳ Paused");
+ await act(async () => { pausedBtn.click(); });
+ await flush();
+ expect(screen.getByText("⟳ Live")).toBeTruthy();
+ });
+});
+
+describe("ActivityTab — refresh button", () => {
+ beforeEach(() => {
+ mockApiGet.mockReset();
+ mockUseSocketEvent.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("Refresh calls the API", async () => {
+ mockApiGet.mockResolvedValue([]);
+ render();
+ await flush();
+ const refreshBtn = screen.getByRole("button", { name: /refresh/i });
+ await act(async () => { refreshBtn.click(); });
+ await flush();
+ // loadActivities called again (second call)
+ expect(mockApiGet.mock.calls.length).toBeGreaterThanOrEqual(2);
+ });
+});
+
+describe("ActivityTab — Full Trace button", () => {
+ beforeEach(() => {
+ mockApiGet.mockReset();
+ mockUseSocketEvent.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("Full Trace button opens the trace modal", async () => {
+ mockApiGet.mockResolvedValue([]);
+ render();
+ await flush();
+ const traceBtn = screen.getByRole("button", { name: /full trace/i });
+ await act(async () => { traceBtn.click(); });
+ await flush();
+ expect(screen.getByTestId("trace-modal")).toBeTruthy();
+ });
+});
+
+describe("ActivityTab — row expand / collapse", () => {
+ beforeEach(() => {
+ mockApiGet.mockReset();
+ mockUseSocketEvent.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("row is collapsed by default (shows ▶)", async () => {
+ mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]);
+ render();
+ await flush();
+ expect(screen.getByText("▶")).toBeTruthy();
+ });
+
+ it("clicking a row expands it (shows ▼)", async () => {
+ mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]);
+ render();
+ await flush();
+ const rowBtn = screen.getByText("LOG").closest("button") as HTMLButtonElement;
+ await act(async () => { rowBtn.click(); });
+ await flush();
+ expect(screen.getByText("▼")).toBeTruthy();
+ });
+
+ it("clicking expanded row collapses it", async () => {
+ mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "agent_log" })]);
+ render();
+ await flush();
+ const rowBtn = screen.getByText("LOG").closest("button") as HTMLButtonElement;
+ await act(async () => { rowBtn.click(); }); // expand
+ await flush();
+ await act(async () => { rowBtn.click(); }); // collapse
+ await flush();
+ expect(screen.getByText("▶")).toBeTruthy();
+ });
+});
+
+describe("ActivityTab — A2A rows with source/target", () => {
+ beforeEach(() => {
+ mockApiGet.mockReset();
+ mockUseSocketEvent.mockReset();
+ mockUseWorkspaceName.mockImplementation((id: string | null) => {
+ if (id === "ws-agent-1") return "Alice Agent";
+ if (id === "ws-agent-2") return "Bob Agent";
+ return "Unknown";
+ });
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("shows source → target for a2a_receive rows", async () => {
+ mockApiGet.mockResolvedValue([
+ activity({
+ id: "a1",
+ activity_type: "a2a_receive",
+ source_id: "ws-agent-1",
+ target_id: "ws-agent-2",
+ method: "message/send",
+ }),
+ ]);
+ render();
+ await flush();
+ expect(screen.getByText("Alice Agent")).toBeTruthy();
+ expect(screen.getByText("→")).toBeTruthy();
+ expect(screen.getByText("Bob Agent")).toBeTruthy();
+ });
+
+ it("shows A2A OUT badge for a2a_send rows", async () => {
+ mockApiGet.mockResolvedValue([
+ activity({
+ id: "a1",
+ activity_type: "a2a_send",
+ source_id: "ws-agent-1",
+ target_id: "ws-agent-2",
+ }),
+ ]);
+ render();
+ await flush();
+ expect(screen.getByText("A2A OUT")).toBeTruthy();
+ });
+});
+
+describe("ActivityTab — error rows", () => {
+ beforeEach(() => {
+ mockApiGet.mockReset();
+ mockUseSocketEvent.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("error status row renders with ERROR badge", async () => {
+ mockApiGet.mockResolvedValue([
+ activity({ id: "a1", activity_type: "error", status: "error" }),
+ ]);
+ render();
+ await flush();
+ expect(screen.getByText("ERROR")).toBeTruthy();
+ });
+
+ it("error detail is shown when row is expanded", async () => {
+ mockApiGet.mockResolvedValue([
+ activity({
+ id: "a1",
+ activity_type: "error",
+ status: "error",
+ error_detail: "Connection refused",
+ duration_ms: null,
+ }),
+ ]);
+ render();
+ await flush();
+ const rowBtn = screen.getByText("ERROR").closest("button") as HTMLButtonElement;
+ await act(async () => { rowBtn.click(); });
+ await flush();
+ // Text appears twice: collapsed-row preview + expanded detail section
+ expect(screen.getAllByText("Connection refused")).toHaveLength(2);
+ });
+});
+
+describe("ActivityTab — type badge rendering", () => {
+ beforeEach(() => {
+ mockApiGet.mockReset();
+ mockUseSocketEvent.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("renders correct badge text for each type", async () => {
+ const types: ActivityEntry["activity_type"][] = [
+ "a2a_receive", "a2a_send", "task_update", "skill_promotion", "agent_log", "error",
+ ];
+ const entries = types.map((t, i) =>
+ activity({ id: `a${i}`, activity_type: t }),
+ );
+ mockApiGet.mockResolvedValue(entries);
+ render();
+ await flush();
+ expect(screen.getByText("A2A IN")).toBeTruthy();
+ expect(screen.getByText("A2A OUT")).toBeTruthy();
+ expect(screen.getByText("TASK")).toBeTruthy();
+ expect(screen.getByText("PROMO")).toBeTruthy();
+ expect(screen.getByText("LOG")).toBeTruthy();
+ expect(screen.getByText("ERROR")).toBeTruthy();
+ });
+});
+
+describe("ActivityTab — count display", () => {
+ beforeEach(() => {
+ mockApiGet.mockReset();
+ mockUseSocketEvent.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("shows count with 'activities' label when filter=all", async () => {
+ mockApiGet.mockResolvedValue([
+ activity({ id: "a1" }),
+ activity({ id: "a2" }),
+ ]);
+ render();
+ await flush();
+ expect(screen.getByText(/2 activities/)).toBeTruthy();
+ });
+
+ it("shows count with filter label when non-all filter selected", async () => {
+ mockApiGet.mockResolvedValue([activity({ id: "a1", activity_type: "error" })]);
+ render();
+ await flush();
+ const errorsBtn = screen.getByRole("button", { name: /errors/i });
+ await act(async () => { errorsBtn.click(); });
+ await flush();
+ expect(screen.getByText(/1 error entries/)).toBeTruthy();
+ });
+});
+
+describe("getSkills — unit", () => {
+ it("returns empty array for null card", async () => {
+ const { getSkills } = await import("../DetailsTab");
+ expect(getSkills(null)).toEqual([]);
+ });
+
+ it("returns empty array when skills is not an array", async () => {
+ const { getSkills } = await import("../DetailsTab");
+ expect(getSkills({ name: "test" } as Record)).toEqual([]);
+ });
+
+ it("extracts skill ids and descriptions", async () => {
+ const { getSkills } = await import("../DetailsTab");
+ const card = {
+ skills: [
+ { id: "web-search", description: "Search the web" },
+ { name: "code-interpreter" },
+ { id: "analytics" },
+ ],
+ };
+ const result = getSkills(card as Record);
+ expect(result).toEqual([
+ { id: "web-search", description: "Search the web" },
+ { id: "code-interpreter" },
+ { id: "analytics" },
+ ]);
+ });
+
+ it("filters out skills with no id or name", async () => {
+ const { getSkills } = await import("../DetailsTab");
+ const card = { skills: [{ description: "no id" }, { id: "valid" }] };
+ expect(getSkills(card as Record)).toEqual([{ id: "valid" }]);
+ });
+});
diff --git a/canvas/src/components/tabs/__tests__/DetailsTab.test.tsx b/canvas/src/components/tabs/__tests__/DetailsTab.test.tsx
new file mode 100644
index 00000000..7b3bb053
--- /dev/null
+++ b/canvas/src/components/tabs/__tests__/DetailsTab.test.tsx
@@ -0,0 +1,459 @@
+// @vitest-environment jsdom
+/**
+ * Tests for DetailsTab — workspace detail panel with editable fields,
+ * delete/restart workflows, peers list, error display, and section
+ * composition.
+ *
+ * Covers:
+ * - View mode: all rows rendered (name, role, tier, status, URL, etc.)
+ * - Edit mode: name/role/tier fields become editable
+ * - Save workflow: calls PATCH and updates store
+ * - Cancel: reverts fields to original data
+ * - Delete: two-step confirm (confirm button shows alertdialog)
+ * - Delete confirm: calls DELETE and removes node from store
+ * - Restart button: calls POST /restart for failed/degraded/offline
+ * - Error section: shown for failed/degraded with lastSampleError
+ * - Skills section: rendered when agentCard has skills
+ * - Peers section: loads and displays peer list
+ * - Peers section: empty state when offline
+ * - ConsoleModal: opens/closes via button click
+ */
+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 { DetailsTab } from "../DetailsTab";
+import type { WorkspaceNodeData } from "@/store/canvas";
+
+const mockApi = vi.hoisted(() => ({
+ get: vi.fn(),
+ patch: vi.fn(),
+ del: vi.fn(),
+ post: vi.fn(),
+}));
+
+const mockUpdateNodeData = vi.hoisted(() => vi.fn());
+const mockRemoveSubtree = vi.hoisted(() => vi.fn());
+const mockSelectNode = vi.hoisted(() => vi.fn());
+
+const mockUseCanvasStore = vi.hoisted(() => {
+ const fn = (selector: (s: {
+ updateNodeData: typeof mockUpdateNodeData;
+ removeSubtree: typeof mockRemoveSubtree;
+ selectNode: typeof mockSelectNode;
+ }) => unknown) =>
+ selector({
+ updateNodeData: mockUpdateNodeData,
+ removeSubtree: mockRemoveSubtree,
+ selectNode: mockSelectNode,
+ });
+ return fn;
+});
+
+vi.mock("@/store/canvas", () => ({
+ useCanvasStore: mockUseCanvasStore,
+}));
+
+vi.mock("@/lib/api", () => ({
+ api: mockApi,
+}));
+
+vi.mock("@/components/BudgetSection", () => ({
+ BudgetSection: () => BudgetSection
,
+}));
+
+vi.mock("@/components/WorkspaceUsage", () => ({
+ WorkspaceUsage: () => WorkspaceUsage
,
+}));
+
+vi.mock("@/components/ConsoleModal", () => ({
+ ConsoleModal: ({ open, onClose }: { open: boolean; onClose: () => void; workspaceId: string; workspaceName: string }) =>
+ open ? (
+
+
+
+ ) : null,
+}));
+
+// ─── Fixtures ───────────────────────────────────────────────────────────────
+
+const baseData: WorkspaceNodeData = {
+ name: "Test Workspace",
+ status: "online",
+ tier: 2,
+ url: "https://test.molecules.ai",
+ parentId: null,
+ activeTasks: 0,
+ agentCard: null,
+} as WorkspaceNodeData;
+
+function data(overrides: Partial = {}): WorkspaceNodeData {
+ return { ...baseData, ...overrides } as WorkspaceNodeData;
+}
+
+// ─── Helpers ───────────────────────────────────────────────────────────────
+
+async function flush() {
+ await act(async () => { await Promise.resolve(); });
+}
+
+// ─── Tests ────────────────────────────────────────────────────────────────
+
+describe("DetailsTab — view mode", () => {
+ beforeEach(() => {
+ mockApi.get.mockReset();
+ mockUpdateNodeData.mockReset();
+ mockRemoveSubtree.mockReset();
+ mockSelectNode.mockReset();
+ mockApi.get.mockResolvedValue([]);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("renders name, role, tier, status, URL, parent rows", () => {
+ render();
+ expect(screen.getByText("Test Workspace")).toBeTruthy();
+ expect(screen.getByText("SEO Specialist")).toBeTruthy();
+ expect(screen.getByText("T2")).toBeTruthy();
+ expect(screen.getByText("online")).toBeTruthy();
+ expect(screen.getByText("https://example.com")).toBeTruthy();
+ expect(screen.getByText("root")).toBeTruthy();
+ });
+
+ it("renders Edit button", () => {
+ render();
+ expect(screen.getByRole("button", { name: /edit/i })).toBeTruthy();
+ });
+
+ it("renders BudgetSection and WorkspaceUsage", () => {
+ render();
+ expect(screen.getByTestId("budget-section")).toBeTruthy();
+ expect(screen.getByTestId("workspace-usage")).toBeTruthy();
+ });
+
+ it("renders Restart button for failed status", () => {
+ render();
+ expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy();
+ });
+
+ it("renders Restart button for offline status", () => {
+ render();
+ expect(screen.getByRole("button", { name: /restart/i })).toBeTruthy();
+ });
+
+ it("renders Restart button for degraded status", () => {
+ render();
+ expect(screen.getByRole("button", { name: /restart/i })).toBeTruthy();
+ });
+
+ it("does not render Restart for online status", () => {
+ render();
+ expect(screen.queryByRole("button", { name: /restart|retry/i })).toBeNull();
+ });
+
+ it("renders error section for failed status with lastSampleError", () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId("details-error-log")).toBeTruthy();
+ expect(screen.getByText(/ModuleNotFoundError/)).toBeTruthy();
+ });
+
+ it("renders error rate for degraded status", () => {
+ render();
+ expect(screen.getByText(/15%/)).toBeTruthy();
+ });
+
+ it("renders Delete Workspace button in Danger Zone", () => {
+ render();
+ expect(screen.getByRole("button", { name: /delete workspace/i })).toBeTruthy();
+ });
+});
+
+describe("DetailsTab — edit mode", () => {
+ beforeEach(() => {
+ mockApi.patch.mockReset();
+ mockUpdateNodeData.mockReset();
+ mockApi.get.mockResolvedValue([]);
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("clicking Edit shows form fields", () => {
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /edit/i }));
+ expect(screen.getByLabelText(/name/i)).toBeTruthy();
+ expect(screen.getByLabelText(/role/i)).toBeTruthy();
+ expect(screen.getByLabelText(/tier/i)).toBeTruthy();
+ });
+
+ it("Edit form pre-fills current values", () => {
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /edit/i }));
+ expect((screen.getByLabelText(/name/i) as HTMLInputElement).value).toBe("My WS");
+ expect((screen.getByLabelText(/role/i) as HTMLInputElement).value).toBe("Coder");
+ });
+
+ it("Save calls PATCH and exits edit mode", async () => {
+ mockApi.patch.mockResolvedValue({});
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /edit/i }));
+ await flush();
+ const nameInput = screen.getByLabelText(/name/i) as HTMLInputElement;
+ fireEvent.change(nameInput, { target: { value: "Renamed WS" } });
+ await flush();
+ // Use scoped search: BudgetSection also has a Save button
+ const saveBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent === "Save" && !b.getAttribute("data-testid"),
+ ) as HTMLButtonElement;
+ fireEvent.click(saveBtn);
+ await flush();
+ expect(mockApi.patch).toHaveBeenCalledWith(
+ "/workspaces/ws-1",
+ expect.objectContaining({ name: "Renamed WS" }),
+ );
+ expect(mockUpdateNodeData).toHaveBeenCalledWith("ws-1", expect.objectContaining({ name: "Renamed WS" }));
+ // Edit fields should no longer be visible
+ expect(screen.queryByLabelText(/name/i)).toBeNull();
+ });
+
+ it("Cancel reverts to view mode without saving", async () => {
+ mockApi.patch.mockResolvedValue({});
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /edit/i }));
+ await flush();
+ const nameInput = screen.getByLabelText(/name/i) as HTMLInputElement;
+ fireEvent.change(nameInput, { target: { value: "Changed" } });
+ await flush();
+ const cancelBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent === "Cancel" && !b.getAttribute("data-testid"),
+ ) as HTMLButtonElement;
+ fireEvent.click(cancelBtn);
+ await flush();
+ expect(mockApi.patch).not.toHaveBeenCalled();
+ expect(screen.getByText("Original")).toBeTruthy();
+ expect(screen.queryByLabelText(/name/i)).toBeNull();
+ });
+
+ it("Save shows error banner on failure", async () => {
+ mockApi.patch.mockRejectedValue(new Error("Server error"));
+ render();
+ fireEvent.click(screen.getByRole("button", { name: /edit/i }));
+ await flush();
+ const saveBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent === "Save" && !b.getAttribute("data-testid"),
+ ) as HTMLButtonElement;
+ fireEvent.click(saveBtn);
+ await flush();
+ expect(screen.getByText(/server error/i)).toBeTruthy();
+ });
+});
+
+describe("DetailsTab — delete workflow", () => {
+ beforeEach(() => {
+ mockApi.del.mockReset();
+ mockRemoveSubtree.mockReset();
+ mockSelectNode.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("clicking Delete shows confirm dialog", async () => {
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /delete workspace/i }));
+ await flush();
+ expect(screen.getByRole("alertdialog")).toBeTruthy();
+ expect(screen.getByText(/confirm deletion/i)).toBeTruthy();
+ });
+
+ it("confirming delete calls DELETE and removes node from store", async () => {
+ mockApi.del.mockResolvedValue(undefined);
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /delete workspace/i }));
+ await flush();
+ // Radix ConfirmDialog uses dispatchEvent with bubbling click
+ const confirmBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent === "Confirm Delete",
+ ) as HTMLButtonElement;
+ fireEvent(confirmBtn, new MouseEvent("click", { bubbles: true }));
+ await flush();
+ expect(mockApi.del).toHaveBeenCalledWith("/workspaces/ws-1?confirm=true");
+ expect(mockRemoveSubtree).toHaveBeenCalledWith("ws-1");
+ expect(mockSelectNode).toHaveBeenCalledWith(null);
+ });
+
+ it("cancelling delete returns to view mode", async () => {
+ mockApi.del.mockResolvedValue(undefined);
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /delete workspace/i }));
+ await flush();
+ const cancelBtn = Array.from(document.querySelectorAll("button")).find(
+ (b) => b.textContent === "Cancel",
+ ) as HTMLButtonElement;
+ fireEvent(cancelBtn, new MouseEvent("click", { bubbles: true }));
+ await flush();
+ expect(screen.queryByRole("alertdialog")).toBeNull();
+ expect(screen.getByRole("button", { name: /delete workspace/i })).toBeTruthy();
+ });
+});
+
+describe("DetailsTab — restart workflow", () => {
+ beforeEach(() => {
+ mockApi.post.mockReset();
+ mockUpdateNodeData.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("Restart button calls POST /restart and sets status to provisioning", async () => {
+ mockApi.post.mockResolvedValue(undefined);
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /retry/i }));
+ await flush();
+ expect(mockApi.post).toHaveBeenCalledWith("/workspaces/ws-1/restart", {});
+ expect(mockUpdateNodeData).toHaveBeenCalledWith("ws-1", { status: "provisioning" });
+ });
+
+ it("Restart shows error on failure", async () => {
+ mockApi.post.mockRejectedValue(new Error("Restart failed"));
+ render();
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /restart/i }));
+ await flush();
+ expect(screen.getByText(/restart failed/i)).toBeTruthy();
+ });
+});
+
+describe("DetailsTab — peers section", () => {
+ beforeEach(() => {
+ mockApi.get.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("loads peers from API", async () => {
+ mockApi.get.mockResolvedValue([
+ { id: "p1", name: "Alice Agent", role: "seo", status: "online", tier: 2 },
+ { id: "p2", name: "Bob Agent", role: null, status: "offline", tier: 3 },
+ ]);
+ render();
+ await flush();
+ expect(screen.getByText("Alice Agent")).toBeTruthy();
+ expect(screen.getByText("Bob Agent")).toBeTruthy();
+ });
+
+ it("shows 'No reachable peers' when list is empty", async () => {
+ mockApi.get.mockResolvedValue([]);
+ render();
+ await flush();
+ expect(screen.getByText("No reachable peers")).toBeTruthy();
+ });
+
+ it("shows offline message when workspace is not online", async () => {
+ mockApi.get.mockResolvedValue([]);
+ render();
+ await flush();
+ expect(screen.getByText(/only discoverable while the workspace is online/i)).toBeTruthy();
+ });
+
+ it("clicking peer name selects that node", async () => {
+ mockApi.get.mockResolvedValue([{ id: "p1", name: "Alice Agent", role: null, status: "online", tier: 2 }]);
+ render();
+ await flush();
+ fireEvent.click(screen.getByText("Alice Agent"));
+ await flush();
+ expect(mockSelectNode).toHaveBeenCalledWith("p1");
+ });
+});
+
+describe("DetailsTab — skills section", () => {
+ beforeEach(() => {
+ mockApi.get.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("renders skills from agentCard", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText("web-search")).toBeTruthy();
+ expect(screen.getByText("Search the web")).toBeTruthy();
+ expect(screen.getByText("code-interpreter")).toBeTruthy();
+ });
+
+ it("does not render Skills section when agentCard is null", () => {
+ render();
+ expect(screen.queryByText("Skills")).toBeNull();
+ });
+});
+
+describe("DetailsTab — ConsoleModal", () => {
+ beforeEach(() => {
+ mockApi.get.mockReset();
+ });
+
+ afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ });
+
+ it("View console output button opens ConsoleModal", async () => {
+ render(
+ ,
+ );
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /view console output/i }));
+ await flush();
+ expect(screen.getByTestId("console-modal")).toBeTruthy();
+ });
+
+ it("Close button closes ConsoleModal", async () => {
+ render(
+ ,
+ );
+ await flush();
+ fireEvent.click(screen.getByRole("button", { name: /view console output/i }));
+ await flush();
+ expect(screen.getByTestId("console-modal")).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: /close console/i }));
+ await flush();
+ expect(screen.queryByTestId("console-modal")).toBeNull();
+ });
+});