feat(canvas): add BroadcastBanner for real-time agent broadcasts
Renders a dismissible sky-colored banner when another workspace broadcasts a BROADCAST_MESSAGE WebSocket event. One banner per sender; deduplication keeps only the latest from each sender; auto-dismisses after 10 s. WCAG 2.1 AA compliance: - role="status" + aria-live="polite" on container - aria-hidden="true" on decorative emoji - aria-label on dismiss button with specific broadcast content - focus-visible:ring-2 on dismiss button (WCAG 2.4.7) Tests: 13 passing (empty state, render, WCAG, auto-dismiss, deduplication). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
44da3e3591
commit
de81a8bbea
133
canvas/src/components/BroadcastBanner.tsx
Normal file
133
canvas/src/components/BroadcastBanner.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { subscribeSocketEvents } from "@/store/socket-events";
|
||||
import type { WSMessage } from "@/store/socket";
|
||||
|
||||
interface BroadcastEntry {
|
||||
id: string;
|
||||
sender: string;
|
||||
senderId: string;
|
||||
message: string;
|
||||
receivedAt: number;
|
||||
}
|
||||
|
||||
interface BroadcastPayload {
|
||||
message: string;
|
||||
sender_id: string;
|
||||
sender: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* BroadcastBanner
|
||||
* Displays real-time broadcast messages from agent workspaces.
|
||||
*
|
||||
* A workspace with `broadcast_enabled=true` can send a message to every
|
||||
* other workspace in the same org. The platform emits a BROADCAST_MESSAGE
|
||||
* WebSocket event to each recipient; the canvas shows a dismissible
|
||||
* banner so the human operator sees what their agent just broadcast.
|
||||
*
|
||||
* WCAG 2.1 compliance:
|
||||
* - role="status" + aria-live="polite" — announcements don't interrupt
|
||||
* current speech; polite is correct for non-critical notifications.
|
||||
* - aria-atomic="true" — screen readers announce the full message.
|
||||
* - Dismiss button: aria-label with specific broadcast content.
|
||||
* - focus-visible ring on dismiss button.
|
||||
* - Auto-dismiss after 10s so stale banners don't accumulate.
|
||||
* - Keyboard: dismiss via Escape key (listened on document).
|
||||
*/
|
||||
export function BroadcastBanner() {
|
||||
const [entries, setEntries] = useState<BroadcastEntry[]>([]);
|
||||
const timeoutRefs = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
|
||||
const dismiss = useCallback((id: string) => {
|
||||
setEntries((prev) => prev.filter((e) => e.id !== id));
|
||||
const timer = timeoutRefs.current.get(id);
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
timeoutRefs.current.delete(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const _unsubscribe = subscribeSocketEvents((msg: WSMessage) => {
|
||||
if (msg.event !== "BROADCAST_MESSAGE") return;
|
||||
const payload = msg.payload as BroadcastPayload;
|
||||
if (!payload.message || !payload.sender) return;
|
||||
|
||||
const entry: BroadcastEntry = {
|
||||
id: `${payload.sender_id}-${msg.timestamp}-${Date.now()}`,
|
||||
sender: payload.sender,
|
||||
senderId: payload.sender_id,
|
||||
message: payload.message,
|
||||
receivedAt: Date.now(),
|
||||
};
|
||||
|
||||
setEntries((prev) => {
|
||||
// Prevent duplicates from reconnect-bursts — keep only the latest
|
||||
// entry per sender.
|
||||
const filtered = prev.filter((e) => e.senderId !== entry.senderId);
|
||||
return [...filtered, entry];
|
||||
});
|
||||
|
||||
// Auto-dismiss after 10 seconds.
|
||||
const timer = setTimeout(() => {
|
||||
dismiss(entry.id);
|
||||
}, 10_000);
|
||||
timeoutRefs.current.set(entry.id, timer);
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Guard: unsubscribe may be a vi.fn() stub in test mocks. Safety check
|
||||
// prevents "unsubscribe is not a function" when vi.resetModules() clears
|
||||
// hoisted refs between test cases.
|
||||
if (typeof _unsubscribe === "function") _unsubscribe();
|
||||
// Clear all pending timers on unmount.
|
||||
for (const timer of timeoutRefs.current.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timeoutRefs.current.clear();
|
||||
};
|
||||
}, [dismiss]);
|
||||
|
||||
if (entries.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="false"
|
||||
aria-label="Broadcast messages"
|
||||
className="fixed top-16 left-1/2 -translate-x-1/2 z-30 flex flex-col gap-2 items-center"
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="bg-sky-950/90 backdrop-blur-md border border-sky-700/50 rounded-xl px-5 py-3 shadow-2xl shadow-black/40 max-w-md animate-in slide-in-from-top duration-300"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-8 h-8 rounded-lg bg-sky-800/40 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<span aria-hidden="true" className="text-sky-400 text-lg">📣</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-sky-300 font-semibold">
|
||||
{entry.sender}
|
||||
</div>
|
||||
<div className="text-sm text-sky-100 mt-0.5 break-words">
|
||||
{entry.message}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => dismiss(entry.id)}
|
||||
aria-label={`Dismiss broadcast from ${entry.sender}: ${entry.message}`}
|
||||
className="shrink-0 w-6 h-6 flex items-center justify-center rounded text-sky-400 hover:text-sky-200 hover:bg-sky-800/60 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-400 focus-visible:ring-offset-1 focus-visible:ring-offset-sky-950"
|
||||
>
|
||||
<span aria-hidden="true">✕</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -21,6 +21,7 @@ import { CreateWorkspaceButton } from "./CreateWorkspaceDialog";
|
||||
import { ContextMenu } from "./ContextMenu";
|
||||
import { TemplatePalette } from "./TemplatePalette";
|
||||
import { ApprovalBanner } from "./ApprovalBanner";
|
||||
import { BroadcastBanner } from "./BroadcastBanner";
|
||||
import { BundleDropZone } from "./BundleDropZone";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { OnboardingWizard } from "./OnboardingWizard";
|
||||
@ -367,6 +368,7 @@ function CanvasInner() {
|
||||
<OnboardingWizard />
|
||||
<Toolbar />
|
||||
<ApprovalBanner />
|
||||
<BroadcastBanner />
|
||||
<BundleDropZone />
|
||||
<TemplatePalette />
|
||||
<SidePanel />
|
||||
|
||||
274
canvas/src/components/__tests__/BroadcastBanner.test.tsx
Normal file
274
canvas/src/components/__tests__/BroadcastBanner.test.tsx
Normal file
@ -0,0 +1,274 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* WCAG 2.1 AA accessibility + functional tests for BroadcastBanner.
|
||||
*
|
||||
* Pattern matches ActivityTab.test.tsx — uses the real subscribeSocketEvents
|
||||
* bus (no module mock) so the component's useEffect registers its listener
|
||||
* normally. Tests call emitSocketEvent to fire fake events into the bus,
|
||||
* which delivers to all registered listeners including the component's.
|
||||
*
|
||||
* _resetSocketEventListenersForTests() clears the listeners Set between tests
|
||||
* so each case starts clean.
|
||||
*/
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent, cleanup, waitFor, act } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import {
|
||||
emitSocketEvent,
|
||||
_resetSocketEventListenersForTests,
|
||||
} from "@/store/socket-events";
|
||||
import type { WSMessage } from "@/store/socket";
|
||||
import { BroadcastBanner } from "../BroadcastBanner";
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const broadcastMsg = (
|
||||
sender = "Test Agent",
|
||||
senderId = "ws-agent-1",
|
||||
message = "All agents: please check your memory for stale data.",
|
||||
): WSMessage => ({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-recipient-1",
|
||||
timestamp: new Date().toISOString(),
|
||||
payload: {
|
||||
message,
|
||||
sender_id: senderId,
|
||||
sender,
|
||||
} as unknown as Record<string, unknown>,
|
||||
});
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("BroadcastBanner — empty state", () => {
|
||||
beforeEach(() => {
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
it("renders nothing when no BROADCAST_MESSAGE events have been received", () => {
|
||||
render(<BroadcastBanner />);
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BroadcastBanner — renders banner on BROADCAST_MESSAGE", () => {
|
||||
beforeEach(() => {
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
it("shows a status banner when a BROADCAST_MESSAGE is received", async () => {
|
||||
render(<BroadcastBanner />);
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
emitSocketEvent(broadcastMsg());
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("status")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays the sender name", async () => {
|
||||
render(<BroadcastBanner />);
|
||||
act(() => {
|
||||
emitSocketEvent(broadcastMsg("PM Agent"));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("PM Agent")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays the broadcast message", async () => {
|
||||
render(<BroadcastBanner />);
|
||||
act(() => {
|
||||
emitSocketEvent(broadcastMsg("PM Agent", "ws-pm", "Sprint review in 30 minutes."));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Sprint review in 30 minutes.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("BroadcastBanner — WCAG 1.1.1 Non-text Content", () => {
|
||||
beforeEach(() => {
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
it("broadcast emoji is aria-hidden=true", async () => {
|
||||
render(<BroadcastBanner />);
|
||||
act(() => {
|
||||
emitSocketEvent(broadcastMsg());
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("📣")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText("📣").getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("BroadcastBanner — WCAG 4.1.2 Name, Role, Value", () => {
|
||||
beforeEach(() => {
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
it("container has role=status", async () => {
|
||||
render(<BroadcastBanner />);
|
||||
act(() => {
|
||||
emitSocketEvent(broadcastMsg());
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("status")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("container has aria-live=polite", async () => {
|
||||
render(<BroadcastBanner />);
|
||||
act(() => {
|
||||
emitSocketEvent(broadcastMsg());
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("status").getAttribute("aria-live")).toBe("polite");
|
||||
});
|
||||
});
|
||||
|
||||
it("dismiss button has aria-label describing the broadcast", async () => {
|
||||
render(<BroadcastBanner />);
|
||||
act(() => {
|
||||
emitSocketEvent(broadcastMsg("PM Agent", "ws-pm", "Sprint review in 30 minutes."));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole("button", { name: /dismiss broadcast from pm agent/i }),
|
||||
).toBeTruthy();
|
||||
});
|
||||
const btn = screen.getByRole("button", { name: /dismiss broadcast from pm agent/i });
|
||||
expect(btn.getAttribute("aria-label")).toContain("Sprint review in 30 minutes.");
|
||||
});
|
||||
|
||||
it("dismiss button has focus-visible ring class", async () => {
|
||||
render(<BroadcastBanner />);
|
||||
act(() => {
|
||||
emitSocketEvent(broadcastMsg());
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("button", { name: /dismiss broadcast/i })).toBeTruthy();
|
||||
});
|
||||
const btn = screen.getByRole("button", { name: /dismiss broadcast/i });
|
||||
// Component uses focus-visible:ring-2 for keyboard focus indication (WCAG 2.4.7).
|
||||
expect(btn.classList.contains("focus-visible:ring-2")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("BroadcastBanner — auto-dismiss", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
_resetSocketEventListenersForTests();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("banner auto-dismisses after 10 seconds", async () => {
|
||||
render(<BroadcastBanner />);
|
||||
act(() => {
|
||||
emitSocketEvent(broadcastMsg());
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("status")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Advance 10 seconds — the setTimeout fires.
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it("banner disappears immediately on dismiss button click", async () => {
|
||||
render(<BroadcastBanner />);
|
||||
act(() => {
|
||||
emitSocketEvent(broadcastMsg());
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("status")).toBeTruthy();
|
||||
});
|
||||
|
||||
const dismissBtn = screen.getByRole("button", { name: /dismiss broadcast/i });
|
||||
fireEvent.click(dismissBtn);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole("status")).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("BroadcastBanner — deduplication", () => {
|
||||
beforeEach(() => {
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
_resetSocketEventListenersForTests();
|
||||
});
|
||||
|
||||
it("shows one banner when the same sender sends multiple messages rapidly", async () => {
|
||||
render(<BroadcastBanner />);
|
||||
act(() => {
|
||||
emitSocketEvent(broadcastMsg("PM Agent", "ws-pm", "First message."));
|
||||
emitSocketEvent(broadcastMsg("PM Agent", "ws-pm", "Second message."));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// Only one banner per sender — the second replaces the first.
|
||||
expect(screen.getAllByRole("status")).toHaveLength(1);
|
||||
expect(screen.getByText("Second message.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows separate banners for different senders", async () => {
|
||||
render(<BroadcastBanner />);
|
||||
act(() => {
|
||||
emitSocketEvent(broadcastMsg("PM Agent", "ws-pm", "PM message."));
|
||||
emitSocketEvent(broadcastMsg("Research Lead", "ws-rl", "Research message."));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
// The outer container has role="status" (1); each child banner does not.
|
||||
// Verify both senders appear as text instead.
|
||||
expect(screen.getByText("PM Agent")).toBeTruthy();
|
||||
expect(screen.getByText("Research Lead")).toBeTruthy();
|
||||
expect(screen.getByText("PM message.")).toBeTruthy();
|
||||
expect(screen.getByText("Research message.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user