Merge pull request #887 from Molecule-AI/fix/canvas-a11y-conversation-trace-modal

fix(canvas): a11y — migrate ConversationTraceModal to Radix Dialog with aria-label
This commit is contained in:
molecule-ai[bot] 2026-04-18 01:19:57 +00:00 committed by GitHub
commit 913bcd5c18
2 changed files with 350 additions and 181 deletions

View File

@ -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<string, unknown> | null): string {
return "";
}
export function ConversationTraceModal({ open, workspaceId, onClose }: Props) {
export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClose }: Props) {
const [entries, setEntries] = useState<ActivityEntry[]>([]);
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 (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={onClose} />
<Dialog.Root open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
<Dialog.Portal>
{/* Overlay replaces the old manual backdrop div */}
<Dialog.Overlay className="fixed inset-0 z-[59] bg-black/70 backdrop-blur-sm" />
{/* Modal */}
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl max-w-[700px] w-full mx-4 max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800">
<div>
<h3 className="text-sm font-semibold text-zinc-100">
Conversation Trace
</h3>
<p className="text-[10px] text-zinc-500 mt-0.5">
{entries.length} events across all workspaces
</p>
</div>
<button
onClick={onClose}
className="text-zinc-500 hover:text-zinc-300 text-lg px-2"
>
</button>
</div>
{/* Timeline */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{loading && (
<div className="text-xs text-zinc-500 text-center py-8">
Loading trace from all workspaces...
{/* Content wraps the entire centred modal panel */}
<Dialog.Content
className="fixed inset-0 z-[60] flex items-center justify-center p-4"
aria-label="Conversation trace"
aria-describedby={undefined}
>
{/* Modal panel */}
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl max-w-[700px] w-full max-h-[85vh] flex flex-col overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-3 border-b border-zinc-800">
<div>
<Dialog.Title className="text-sm font-semibold text-zinc-100">
Conversation Trace
</Dialog.Title>
<p className="text-[10px] text-zinc-500 mt-0.5">
{entries.length} events across all workspaces
</p>
</div>
<Dialog.Close asChild>
<button
aria-label="Close conversation trace"
className="text-zinc-500 hover:text-zinc-300 text-lg px-2"
>
</button>
</Dialog.Close>
</div>
)}
{!loading && entries.length === 0 && (
<div className="text-xs text-zinc-500 text-center py-8">
No activity found
</div>
)}
{/* Timeline */}
<div className="flex-1 overflow-y-auto px-5 py-4">
{loading && (
<div className="text-xs text-zinc-500 text-center py-8">
Loading trace from all workspaces...
</div>
)}
<div className="space-y-1">
{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 && (
<div className="text-xs text-zinc-500 text-center py-8">
No activity found
</div>
)}
return (
<div key={entry.id} className="group">
{/* Event header */}
<div className="flex items-start gap-3">
{/* Timeline dot + line */}
<div className="flex flex-col items-center pt-1.5">
<div
className={`w-2.5 h-2.5 rounded-full shrink-0 ${
isError
? "bg-red-500"
: isSend
? "bg-cyan-500"
: isReceive
? "bg-blue-500"
: "bg-zinc-600"
}`}
/>
<div className="w-px flex-1 bg-zinc-800 min-h-[8px]" />
</div>
<div className="space-y-1">
{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 */}
<div className="flex-1 pb-3 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[9px] text-zinc-400 font-mono">
{time}
</span>
<span
className={`text-[9px] font-semibold px-1.5 py-0.5 rounded ${
isError
? "bg-red-950/50 text-red-400"
: isSend
? "bg-cyan-950/50 text-cyan-400"
: isReceive
? "bg-blue-950/50 text-blue-400"
: "bg-zinc-800 text-zinc-400"
}`}
>
{isSend
? "SEND"
: isReceive
? "RECEIVE"
: entry.activity_type.toUpperCase()}
</span>
{entry.duration_ms != null && entry.duration_ms > 0 && (
<span className="text-[9px] text-zinc-400">
{entry.duration_ms > 1000
? `${Math.round(entry.duration_ms / 1000)}s`
: `${entry.duration_ms}ms`}
</span>
)}
</div>
return (
<div key={entry.id} className="group">
{/* Event header */}
<div className="flex items-start gap-3">
{/* Timeline dot + line */}
<div className="flex flex-col items-center pt-1.5">
<div
className={`w-2.5 h-2.5 rounded-full shrink-0 ${
isError
? "bg-red-500"
: isSend
? "bg-cyan-500"
: isReceive
? "bg-blue-500"
: "bg-zinc-600"
}`}
/>
<div className="w-px flex-1 bg-zinc-800 min-h-[8px]" />
</div>
{/* Flow */}
{isA2A(entry) && (
<div className="text-[11px] mt-1">
{isSend ? (
<span>
<span className="text-cyan-400 font-medium">
{sourceName || wsName}
</span>
<span className="text-zinc-400"> </span>
<span className="text-blue-400 font-medium">
{targetName}
</span>
{/* Content */}
<div className="flex-1 pb-3 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[9px] text-zinc-400 font-mono">
{time}
</span>
) : (
<span>
<span className="text-blue-400 font-medium">
{targetName || wsName}
<span
className={`text-[9px] font-semibold px-1.5 py-0.5 rounded ${
isError
? "bg-red-950/50 text-red-400"
: isSend
? "bg-cyan-950/50 text-cyan-400"
: isReceive
? "bg-blue-950/50 text-blue-400"
: "bg-zinc-800 text-zinc-400"
}`}
>
{isSend
? "SEND"
: isReceive
? "RECEIVE"
: entry.activity_type.toUpperCase()}
</span>
{entry.duration_ms != null && entry.duration_ms > 0 && (
<span className="text-[9px] text-zinc-400">
{entry.duration_ms > 1000
? `${Math.round(entry.duration_ms / 1000)}s`
: `${entry.duration_ms}ms`}
</span>
{sourceName && (
<>
<span className="text-zinc-400">
{" "} {" "}
</span>
)}
</div>
{/* Flow */}
{isA2A(entry) && (
<div className="text-[11px] mt-1">
{isSend ? (
<span>
<span className="text-cyan-400 font-medium">
{sourceName}
{sourceName || wsName}
</span>
</>
<span className="text-zinc-400"> </span>
<span className="text-blue-400 font-medium">
{targetName}
</span>
</span>
) : (
<span>
<span className="text-blue-400 font-medium">
{targetName || wsName}
</span>
{sourceName && (
<>
<span className="text-zinc-400">
{" "} {" "}
</span>
<span className="text-cyan-400 font-medium">
{sourceName}
</span>
</>
)}
</span>
)}
</span>
</div>
)}
{/* Summary */}
{entry.summary && !isA2A(entry) && (
<div className="text-[10px] text-zinc-400 mt-1">
<span className="text-zinc-300 font-medium">{wsName}:</span>{" "}
{entry.summary}
</div>
)}
{/* Error */}
{isError && entry.error_detail && (
<div className="text-[10px] text-red-400/80 mt-1 truncate">
{entry.error_detail.slice(0, 200)}
</div>
)}
{/* Message content — show request and/or response */}
{requestText && (
<div className="mt-1.5 bg-zinc-950/60 border border-zinc-800/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
<div className="text-[8px] text-zinc-500 uppercase mb-1">
{isSend ? "Task" : "Request"}
</div>
<div className="text-[10px] text-zinc-300 whitespace-pre-wrap break-words leading-relaxed">
{requestText.slice(0, 2000)}
{requestText.length > 2000 && (
<span className="text-zinc-400"> ...({requestText.length} chars)</span>
)}
</div>
</div>
)}
{responseText && (
<div className="mt-1 bg-zinc-950/60 border border-emerald-900/30 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
<div className="text-[8px] text-emerald-500/60 uppercase mb-1">Response</div>
<div className="text-[10px] text-zinc-300 whitespace-pre-wrap break-words leading-relaxed">
{responseText.slice(0, 2000)}
{responseText.length > 2000 && (
<span className="text-zinc-400"> ...({responseText.length} chars)</span>
)}
</div>
</div>
)}
</div>
)}
{/* Summary */}
{entry.summary && !isA2A(entry) && (
<div className="text-[10px] text-zinc-400 mt-1">
<span className="text-zinc-300 font-medium">{wsName}:</span>{" "}
{entry.summary}
</div>
)}
{/* Error */}
{isError && entry.error_detail && (
<div className="text-[10px] text-red-400/80 mt-1 truncate">
{entry.error_detail.slice(0, 200)}
</div>
)}
{/* Message content — show request and/or response */}
{requestText && (
<div className="mt-1.5 bg-zinc-950/60 border border-zinc-800/50 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
<div className="text-[8px] text-zinc-500 uppercase mb-1">
{isSend ? "Task" : "Request"}
</div>
<div className="text-[10px] text-zinc-300 whitespace-pre-wrap break-words leading-relaxed">
{requestText.slice(0, 2000)}
{requestText.length > 2000 && (
<span className="text-zinc-400"> ...({requestText.length} chars)</span>
)}
</div>
</div>
)}
{responseText && (
<div className="mt-1 bg-zinc-950/60 border border-emerald-900/30 rounded-lg px-3 py-2 max-h-32 overflow-y-auto">
<div className="text-[8px] text-emerald-500/60 uppercase mb-1">Response</div>
<div className="text-[10px] text-zinc-300 whitespace-pre-wrap break-words leading-relaxed">
{responseText.slice(0, 2000)}
{responseText.length > 2000 && (
<span className="text-zinc-400"> ...({responseText.length} chars)</span>
)}
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex justify-end">
<button
onClick={onClose}
className="px-4 py-1.5 text-[12px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition-colors"
>
Close
</button>
</div>
</div>
</div>
{/* Footer */}
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex justify-end">
<Dialog.Close asChild>
<button
className="px-4 py-1.5 text-[12px] bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg transition-colors"
>
Close
</button>
</Dialog.Close>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@ -0,0 +1,158 @@
// @vitest-environment jsdom
/**
* WCAG 2.1 / Issue M ConversationTraceModal accessibility
*
* Migrated from custom <div> 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(
<ConversationTraceModal
open={true}
workspaceId="ws-1"
onClose={onClose}
/>
);
return { onClose };
}
// ────────────────────────────────────────────────────────────────────────────
// Presence / absence
// ────────────────────────────────────────────────────────────────────────────
describe("ConversationTraceModal — dialog presence (Issue M)", () => {
it("dialog is absent when open=false", () => {
render(
<ConversationTraceModal open={false} workspaceId="ws-1" onClose={vi.fn()} />
);
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();
});
});