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:
Molecule AI · core-uiux 2026-05-16 12:40:01 +00:00
parent 44da3e3591
commit de81a8bbea
3 changed files with 409 additions and 0 deletions

View 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>
);
}

View File

@ -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 />

View 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();
});
});
});