diff --git a/canvas/src/components/ConversationTraceModal.tsx b/canvas/src/components/ConversationTraceModal.tsx index 9b8851bc..a603b553 100644 --- a/canvas/src/components/ConversationTraceModal.tsx +++ b/canvas/src/components/ConversationTraceModal.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import * as Dialog from "@radix-ui/react-dialog"; import { api } from "@/lib/api"; import { useCanvasStore } from "@/store/canvas"; import { type ActivityEntry } from "@/types/activity"; @@ -46,7 +47,7 @@ function extractMessageText(body: Record | null): string { return ""; } -export function ConversationTraceModal({ open, workspaceId, onClose }: Props) { +export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClose }: Props) { const [entries, setEntries] = useState([]); const [loading, setLoading] = useState(false); const nodes = useCanvasStore((s) => s.nodes); @@ -83,205 +84,215 @@ export function ConversationTraceModal({ open, workspaceId, onClose }: Props) { }); }, [open, nodes]); - if (!open) return null; - const isA2A = (e: ActivityEntry) => e.activity_type === "a2a_receive" || e.activity_type === "a2a_send"; return ( -
- {/* Backdrop */} -
+ { if (!o) onClose(); }}> + + {/* Overlay replaces the old manual backdrop div */} + - {/* Modal */} -
- {/* Header */} -
-
-

- Conversation Trace -

-

- {entries.length} events across all workspaces -

-
- -
- - {/* Timeline */} -
- {loading && ( -
- Loading trace from all workspaces... + {/* Content wraps the entire centred modal panel */} + + {/* Modal panel */} +
+ {/* Header */} +
+
+ + Conversation Trace + +

+ {entries.length} events across all workspaces +

+
+ + +
- )} - {!loading && entries.length === 0 && ( -
- No activity found -
- )} + {/* Timeline */} +
+ {loading && ( +
+ Loading trace from all workspaces... +
+ )} -
- {entries.map((entry) => { - const time = new Date(entry.created_at).toLocaleTimeString(); - const wsName = resolveName(entry.workspace_id); - const sourceName = resolveName(entry.source_id); - const targetName = resolveName(entry.target_id); - const requestText = extractMessageText(entry.request_body); - const responseText = extractMessageText(entry.response_body); - const isError = entry.status === "error"; - const isSend = entry.activity_type === "a2a_send"; - const isReceive = entry.activity_type === "a2a_receive"; + {!loading && entries.length === 0 && ( +
+ No activity found +
+ )} - return ( -
- {/* Event header */} -
- {/* Timeline dot + line */} -
-
-
-
+
+ {entries.map((entry) => { + const time = new Date(entry.created_at).toLocaleTimeString(); + const wsName = resolveName(entry.workspace_id); + const sourceName = resolveName(entry.source_id); + const targetName = resolveName(entry.target_id); + const requestText = extractMessageText(entry.request_body); + const responseText = extractMessageText(entry.response_body); + const isError = entry.status === "error"; + const isSend = entry.activity_type === "a2a_send"; + const isReceive = entry.activity_type === "a2a_receive"; - {/* Content */} -
-
- - {time} - - - {isSend - ? "SEND" - : isReceive - ? "RECEIVE" - : entry.activity_type.toUpperCase()} - - {entry.duration_ms != null && entry.duration_ms > 0 && ( - - {entry.duration_ms > 1000 - ? `${Math.round(entry.duration_ms / 1000)}s` - : `${entry.duration_ms}ms`} - - )} -
+ return ( +
+ {/* Event header */} +
+ {/* Timeline dot + line */} +
+
+
+
- {/* Flow */} - {isA2A(entry) && ( -
- {isSend ? ( - - - {sourceName || wsName} - - - - {targetName} - + {/* Content */} +
+
+ + {time} - ) : ( - - - {targetName || wsName} + + {isSend + ? "SEND" + : isReceive + ? "RECEIVE" + : entry.activity_type.toUpperCase()} + + {entry.duration_ms != null && entry.duration_ms > 0 && ( + + {entry.duration_ms > 1000 + ? `${Math.round(entry.duration_ms / 1000)}s` + : `${entry.duration_ms}ms`} - {sourceName && ( - <> - - {" "}← {" "} - + )} +
+ + {/* Flow */} + {isA2A(entry) && ( +
+ {isSend ? ( + - {sourceName} + {sourceName || wsName} - + + + {targetName} + + + ) : ( + + + {targetName || wsName} + + {sourceName && ( + <> + + {" "}← {" "} + + + {sourceName} + + + )} + )} - +
+ )} + + {/* Summary */} + {entry.summary && !isA2A(entry) && ( +
+ {wsName}:{" "} + {entry.summary} +
+ )} + + {/* Error */} + {isError && entry.error_detail && ( +
+ {entry.error_detail.slice(0, 200)} +
+ )} + + {/* Message content — show request and/or response */} + {requestText && ( +
+
+ {isSend ? "Task" : "Request"} +
+
+ {requestText.slice(0, 2000)} + {requestText.length > 2000 && ( + ...({requestText.length} chars) + )} +
+
+ )} + {responseText && ( +
+
Response
+
+ {responseText.slice(0, 2000)} + {responseText.length > 2000 && ( + ...({responseText.length} chars) + )} +
+
)}
- )} - - {/* Summary */} - {entry.summary && !isA2A(entry) && ( -
- {wsName}:{" "} - {entry.summary} -
- )} - - {/* Error */} - {isError && entry.error_detail && ( -
- {entry.error_detail.slice(0, 200)} -
- )} - - {/* Message content — show request and/or response */} - {requestText && ( -
-
- {isSend ? "Task" : "Request"} -
-
- {requestText.slice(0, 2000)} - {requestText.length > 2000 && ( - ...({requestText.length} chars) - )} -
-
- )} - {responseText && ( -
-
Response
-
- {responseText.slice(0, 2000)} - {responseText.length > 2000 && ( - ...({responseText.length} chars) - )} -
-
- )} +
-
-
- ); - })} -
-
+ ); + })} +
+
- {/* Footer */} -
- -
-
-
+ {/* Footer */} +
+ + + +
+
+ + + ); } diff --git a/canvas/src/components/__tests__/ConversationTraceModal.a11y.test.tsx b/canvas/src/components/__tests__/ConversationTraceModal.a11y.test.tsx new file mode 100644 index 00000000..7983b2fe --- /dev/null +++ b/canvas/src/components/__tests__/ConversationTraceModal.a11y.test.tsx @@ -0,0 +1,158 @@ +// @vitest-environment jsdom +/** + * WCAG 2.1 / Issue M — ConversationTraceModal accessibility + * + * Migrated from custom
to Radix Dialog, which provides: + * - role="dialog" + aria-modal="true" automatically (WCAG 4.1.2) + * - aria-labelledby pointing to Dialog.Title (WCAG 1.3.1) + * - Focus trap (WCAG 2.1.2 / 2.4.3) + * - Escape key closes the dialog (WCAG 2.1.1) + * - ✕ close button has aria-label="Close conversation trace" + */ + +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +// ── Mocks must be declared before importing the component ──────────────────── + +vi.mock("@/lib/api", () => ({ + api: { + get: vi.fn().mockResolvedValue([]), + }, +})); + +vi.mock("@/store/canvas", () => ({ + useCanvasStore: (selector: (s: { nodes: unknown[] }) => unknown) => + selector({ nodes: [] }), +})); + +vi.mock("@/hooks/useWorkspaceName", () => ({ + useWorkspaceName: () => () => "Test WS", +})); + +import { ConversationTraceModal } from "../ConversationTraceModal"; + +// Helper: renders the modal in open state with a spy for onClose +function renderOpen() { + const onClose = vi.fn(); + render( + + ); + return { onClose }; +} + +// ──────────────────────────────────────────────────────────────────────────── +// Presence / absence +// ──────────────────────────────────────────────────────────────────────────── + +describe("ConversationTraceModal — dialog presence (Issue M)", () => { + it("dialog is absent when open=false", () => { + render( + + ); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("dialog is present when open=true", () => { + renderOpen(); + expect(screen.getByRole("dialog")).toBeTruthy(); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// ARIA attributes provided by Radix Dialog +// ──────────────────────────────────────────────────────────────────────────── + +describe("ConversationTraceModal — ARIA attributes (Issue M)", () => { + it("dialog element is accessible via role='dialog' with a non-empty accessible name", () => { + renderOpen(); + // Radix Dialog.Content renders role="dialog" with aria-labelledby pointing + // to Dialog.Title. Verify the role is present and the name is non-empty + // (testing-library computes the accessible name from aria-labelledby). + const dialog = screen.getByRole("dialog", { name: /conversation trace/i }); + expect(dialog).toBeTruthy(); + }); + + it("dialog has aria-labelledby pointing to 'Conversation Trace' title", () => { + renderOpen(); + const dialog = screen.getByRole("dialog"); + const labelledBy = dialog.getAttribute("aria-labelledby"); + expect(labelledBy).toBeTruthy(); + const titleEl = document.getElementById(labelledBy!); + expect(titleEl?.textContent?.trim()).toBe("Conversation Trace"); + }); + + it("dialog has data-state='open' (Radix state attribute)", () => { + renderOpen(); + const dialog = screen.getByRole("dialog"); + expect(dialog.getAttribute("data-state")).toBe("open"); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Close button accessible name +// ──────────────────────────────────────────────────────────────────────────── + +describe("ConversationTraceModal — close button (Issue M)", () => { + it("✕ close button has aria-label='Close conversation trace'", () => { + renderOpen(); + const closeBtn = screen.getByRole("button", { + name: /close conversation trace/i, + }); + expect(closeBtn).toBeTruthy(); + }); + + it("clicking ✕ button calls onClose", async () => { + const { onClose } = renderOpen(); + const closeBtn = screen.getByRole("button", { + name: /close conversation trace/i, + }); + fireEvent.click(closeBtn); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + }); + + it("footer 'Close' button also closes the dialog", async () => { + const { onClose } = renderOpen(); + const closeBtn = screen.getByRole("button", { name: /^Close$/i }); + fireEvent.click(closeBtn); + await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1)); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Escape key closes the dialog (WCAG 2.1.1 — Keyboard) +// ──────────────────────────────────────────────────────────────────────────── + +describe("ConversationTraceModal — Escape key (Issue M)", () => { + it("Escape key triggers onClose via Radix onOpenChange", async () => { + const { onClose } = renderOpen(); + // Radix Dialog automatically closes on Escape and fires onOpenChange(false) + // which our handler converts to onClose(). Dispatch on the document so + // Radix's own keydown listener picks it up. + fireEvent.keyDown(document, { key: "Escape", code: "Escape" }); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); +}); + +// ──────────────────────────────────────────────────────────────────────────── +// Empty state +// ──────────────────────────────────────────────────────────────────────────── + +describe("ConversationTraceModal — loading state (Issue M)", () => { + it("shows loading indicator when dialog opens and fetch is in progress", () => { + renderOpen(); + // After render + effects (flushed by act inside render), loading=true + // because useEffect fired setLoading(true). The loading text should + // be visible at this synchronous point. + expect(screen.getByText(/loading trace from all workspaces/i)).toBeTruthy(); + }); +});