diff --git a/frontend/app/lab/announcer-showcase.tsx b/frontend/app/lab/announcer-showcase.tsx new file mode 100644 index 00000000..79bc4a3e --- /dev/null +++ b/frontend/app/lab/announcer-showcase.tsx @@ -0,0 +1,103 @@ +"use client"; + +// Lab playground for the announcer (#350): fire each event and see what it +// speaks. Turn on VoiceOver or NVDA to actually hear it. Real failures are +// silent here on purpose - the error banner handles those. + +import { useRef, useState } from "react"; +import { Button } from "@/components/base/buttons/button"; +import { type AgentActivity, agentAnnouncement } from "@/components/chat/agent-announcement"; +import { AgentAnnouncer } from "@/components/chat/agent-announcer"; + +const IDLE_ACTIVITY: AgentActivity = { + running: false, + outcome: null, + approvalId: null, + questionId: null, + pendingApprovalIds: [], +}; + +export function AnnouncerShowcase() { + const [activity, setActivity] = useState({ ...IDLE_ACTIVITY, running: true }); + const [spoken, setSpoken] = useState([]); + const nextApproval = useRef(0); + + const apply = (next: AgentActivity) => { + const message = agentAnnouncement(activity, next); + if (message) setSpoken((log) => [message, ...log].slice(0, 6)); + setActivity(next); + }; + + return ( +
+ +
+ + + + + +
+
+

+ Announced (newest first) - a real failure is intentionally silent here, the error banner + owns it: +

+ {spoken.length > 0 ? ( +
    + {spoken.map((line, index) => ( +
  • + {line} +
  • + ))} +
+ ) : ( +

Nothing announced yet.

+ )} +
+
+ ); +} diff --git a/frontend/app/lab/component-lab.tsx b/frontend/app/lab/component-lab.tsx index c18cb589..e0a086a3 100644 --- a/frontend/app/lab/component-lab.tsx +++ b/frontend/app/lab/component-lab.tsx @@ -55,6 +55,7 @@ import * as Modal from "@/components/base/modal/modal"; import { ARTIFACT_CAPABILITY_ROWS } from "./artifact-capability-matrix"; import { BeautifulUiExtras } from "./beautiful-ui-extras"; import { BeuiAgentShowcase } from "./beui-agent-showcase"; +import { AnnouncerShowcase } from "./announcer-showcase"; import { ComposerRetrievalShowcase } from "./composer-retrieval-showcase"; import { ModelPickerShowcase } from "./model-picker-showcase"; import { ProductTourShowcase } from "./product-tour-showcase"; @@ -506,6 +507,10 @@ export function ComponentLab() { +
+ +
+
{ test("error renders the actionable error line (the submission-error state)", () => { const html = render({ error: "Could not send your decision - try again" }); expect(html).toContain("Could not send your decision - try again"); + // The error line is a live alert so a screen reader hears it at once (#350). + expect(html).toContain('role="alert"'); }); test("note and chip render when supplied; reason slot renders its content", () => { diff --git a/frontend/components/ai/approval-card.tsx b/frontend/components/ai/approval-card.tsx index a25a9fc5..5ead493b 100644 --- a/frontend/components/ai/approval-card.tsx +++ b/frontend/components/ai/approval-card.tsx @@ -156,7 +156,12 @@ export function ApprovalCard({ {body} {note &&

{note}

} - {error &&

{error}

} + {/* Both lanes send their error here, so role="alert" reads it out once (#350). */} + {error && ( +

+ {error} +

+ )} {reason} diff --git a/frontend/components/chat/agent-announcement.test.ts b/frontend/components/chat/agent-announcement.test.ts new file mode 100644 index 00000000..985540d1 --- /dev/null +++ b/frontend/components/chat/agent-announcement.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "bun:test"; +import { type AgentActivity, agentAnnouncement } from "./agent-announcement"; + +const IDLE: AgentActivity = { + running: false, + outcome: null, + approvalId: null, + questionId: null, + pendingApprovalIds: [], +}; + +const running = (over: Partial = {}): AgentActivity => ({ + ...IDLE, + running: true, + ...over, +}); + +describe("agentAnnouncement", () => { + test("the first snapshot establishes a baseline and announces nothing", () => { + // Opening a thread whose agent already replied must not read a stale event. + expect(agentAnnouncement(null, { ...IDLE, outcome: "replied" })).toBeNull(); + expect(agentAnnouncement(null, { ...IDLE, approvalId: "a1" })).toBeNull(); + }); + + test("a finished reply is announced once, on the running to settled edge", () => { + const before = running(); + const after: AgentActivity = { ...IDLE, outcome: "replied" }; + expect(agentAnnouncement(before, after)).toBe("The agent replied."); + // The same settled snapshot repeated is not a new event. + expect(agentAnnouncement(after, after)).toBeNull(); + }); + + test("a user stop is announced, distinct from a reply", () => { + expect(agentAnnouncement(running(), { ...IDLE, outcome: "stopped" })).toBe( + "The agent stopped.", + ); + }); + + test("a real failure is left to the error banner (never announced here)", () => { + // A real failure comes in as outcome null, so we say nothing here. + expect(agentAnnouncement(running(), { ...IDLE, outcome: null })).toBeNull(); + }); + + test("a new native approval is announced when it appears", () => { + expect(agentAnnouncement(running(), running({ approvalId: "ap1" }))).toBe( + "The agent needs your approval.", + ); + }); + + test("a resolved approval is not re-announced", () => { + const waiting = running({ approvalId: "ap1" }); + expect(agentAnnouncement(waiting, running())).toBeNull(); + }); + + test("a different approval replacing the last one counts as a new event", () => { + const first = running({ approvalId: "ap1" }); + expect(agentAnnouncement(first, running({ approvalId: "ap2" }))).toBe( + "The agent needs your approval.", + ); + }); + + test("a newly pending gateway approval is announced", () => { + const before = running({ pendingApprovalIds: ["g1"] }); + expect(agentAnnouncement(before, running({ pendingApprovalIds: ["g1", "g2"] }))).toBe( + "The agent needs your approval.", + ); + // A pending approval already present at baseline is not a fresh event. + expect(agentAnnouncement(before, before)).toBeNull(); + }); + + test("a new native question is announced", () => { + expect(agentAnnouncement(running(), running({ questionId: "q1" }))).toBe( + "The agent has a question.", + ); + }); + + test("a request the person must act on wins over a settled outcome", () => { + // Both edges in one transition: the blocking request is what to hear. + const before = running(); + const after: AgentActivity = { ...IDLE, outcome: "replied", approvalId: "ap1" }; + expect(agentAnnouncement(before, after)).toBe("The agent needs your approval."); + }); + + test("nothing new produces no announcement", () => { + expect(agentAnnouncement(running(), running())).toBeNull(); + expect(agentAnnouncement(IDLE, IDLE)).toBeNull(); + }); +}); diff --git a/frontend/components/chat/agent-announcement.ts b/frontend/components/chat/agent-announcement.ts new file mode 100644 index 00000000..8a49d960 --- /dev/null +++ b/frontend/components/chat/agent-announcement.ts @@ -0,0 +1,64 @@ +// #350: picks what a screen reader should hear - a reply finishing, a user +// stop, or a new approval/question. Pure so we can unit-test it without a DOM. +// Running and real failures are left out on purpose: the running footer and the +// error banner already announce those. + +/** completed = "replied", a user cancel = "stopped". A real failure is left to + * the error banner, so it isn't here. */ +export type TurnOutcome = "replied" | "stopped"; + +export interface AgentActivity { + readonly running: boolean; + /** Newest settled turn's outcome, or null while running or on a real failure. */ + readonly outcome: TurnOutcome | null; + /** Waiting native approval / question ids, null when none waits. */ + readonly approvalId: string | null; + readonly questionId: string | null; + /** Gateway (tool) approvals currently waiting. */ + readonly pendingApprovalIds: readonly string[]; +} + +const MESSAGES = { + replied: "The agent replied.", + stopped: "The agent stopped.", + approval: "The agent needs your approval.", + question: "The agent has a question.", +} as const; + +/** A request id that wasn't there before - so we don't re-announce one that's + * just sitting there or getting resolved. */ +function appeared(prevId: string | null, nextId: string | null): boolean { + return nextId !== null && nextId !== prevId; +} + +function gatewayAppeared(prev: readonly string[], next: readonly string[]): boolean { + const before = new Set(prev); + return next.some((id) => !before.has(id)); +} + +/** + * What to announce going from `prev` to `next`, or null if nothing's new. First + * snapshot (prev === null) stays quiet so opening an old thread doesn't replay + * its last event. Something the user has to act on wins over a settled turn. + */ +export function agentAnnouncement(prev: AgentActivity | null, next: AgentActivity): string | null { + if (!prev) return null; + + // Waiting on the user comes first - that's what's blocking them now. + if ( + appeared(prev.approvalId, next.approvalId) || + gatewayAppeared(prev.pendingApprovalIds, next.pendingApprovalIds) + ) { + return MESSAGES.approval; + } + if (appeared(prev.questionId, next.questionId)) { + return MESSAGES.question; + } + + // Turn just settled. + if (next.outcome !== null && next.outcome !== prev.outcome) { + return MESSAGES[next.outcome]; + } + + return null; +} diff --git a/frontend/components/chat/agent-announcer.interaction.test.tsx b/frontend/components/chat/agent-announcer.interaction.test.tsx new file mode 100644 index 00000000..412aa27e --- /dev/null +++ b/frontend/components/chat/agent-announcer.interaction.test.tsx @@ -0,0 +1,94 @@ +// Mounts the real AgentAnnouncer in happy-dom and drives the props through the +// transitions a thread goes through, checking the live region off the DOM. The +// pure rule is unit-tested separately; this covers the region itself - polite +// status, one sentence per event, and re-reading an identical one. +import { afterEach, describe, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +if (typeof window === "undefined") GlobalRegistrator.register(); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const { act } = await import("react"); +const { createRoot } = await import("react-dom/client"); +const { AgentAnnouncer } = await import("./agent-announcer"); + +import type { ComponentProps } from "react"; + +type Props = ComponentProps; + +const IDLE: Props = { + running: false, + outcome: null, + approvalId: null, + questionId: null, + pendingApprovalIds: [], +}; + +let root: ReturnType | null = null; +let container: HTMLElement | null = null; + +function mount(props: Props) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => root?.render()); +} + +function rerender(props: Props) { + act(() => root?.render()); +} + +function region(): HTMLElement { + const el = container?.querySelector('[role="status"]') as HTMLElement | null; + if (!el) throw new Error("live region did not render"); + return el; +} + +afterEach(() => { + if (root) act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; +}); + +describe("AgentAnnouncer", () => { + test("renders a visually hidden polite, atomic status region", () => { + mount(IDLE); + const el = region(); + expect(el.getAttribute("aria-live")).toBe("polite"); + expect(el.getAttribute("aria-atomic")).toBe("true"); + expect(el.className).toContain("sr-only"); + // Nothing is announced on the baseline render. + expect(el.textContent).toBe(""); + }); + + test("announces a finished reply once, not the baseline", () => { + mount({ ...IDLE, running: true }); + expect(region().textContent).toBe(""); + rerender({ ...IDLE, outcome: "replied" }); + expect(region().textContent).toBe("The agent replied."); + }); + + test("announces a newly waiting approval, and never streams tokens", () => { + mount({ ...IDLE, running: true }); + rerender({ ...IDLE, running: true, approvalId: "ap1" }); + expect(region().textContent).toBe("The agent needs your approval."); + // Resolving it does not re-announce or leave stale text as a new event. + rerender({ ...IDLE, running: true }); + expect(region().textContent).toBe("The agent needs your approval."); + }); + + test("a second identical approval is announced again (keyed remount)", () => { + mount({ ...IDLE, running: true }); + rerender({ ...IDLE, running: true, approvalId: "ap1" }); + const firstSpan = region().querySelector("span"); + expect(firstSpan?.textContent).toBe("The agent needs your approval."); + rerender({ ...IDLE, running: true }); + rerender({ ...IDLE, running: true, approvalId: "ap2" }); + expect(region().textContent).toBe("The agent needs your approval."); + // New DOM node the second time, so the screen reader reads it again. + const secondSpan = region().querySelector("span"); + expect(secondSpan).not.toBe(firstSpan); + }); +}); diff --git a/frontend/components/chat/agent-announcer.tsx b/frontend/components/chat/agent-announcer.tsx new file mode 100644 index 00000000..066daf79 --- /dev/null +++ b/frontend/components/chat/agent-announcer.tsx @@ -0,0 +1,52 @@ +"use client"; + +// #350: the one polite live region for the thread. Feeds each render's activity +// through agentAnnouncement and speaks whatever's new. Only ever holds a short +// sentence, never streamed text. + +import { useEffect, useRef, useState } from "react"; +import { type AgentActivity, agentAnnouncement, type TurnOutcome } from "./agent-announcement"; + +export function AgentAnnouncer({ + running, + outcome, + approvalId, + questionId, + pendingApprovalIds, +}: { + readonly running: boolean; + readonly outcome: TurnOutcome | null; + readonly approvalId: string | null; + readonly questionId: string | null; + readonly pendingApprovalIds: readonly string[]; +}) { + // Join the ids so the effect dep is a string, not a new array every render. + const pendingKey = pendingApprovalIds.join("\u0000"); + const prevActivityRef = useRef(null); + const seqRef = useRef(0); + const [announcement, setAnnouncement] = useState<{ text: string; seq: number } | null>(null); + + useEffect(() => { + const activity: AgentActivity = { + running, + outcome, + approvalId, + questionId, + pendingApprovalIds: pendingKey ? pendingKey.split("\u0000") : [], + }; + const message = agentAnnouncement(prevActivityRef.current, activity); + prevActivityRef.current = activity; + if (message) { + seqRef.current += 1; + setAnnouncement({ text: message, seq: seqRef.current }); + } + }, [running, outcome, approvalId, questionId, pendingKey]); + + return ( +
+ {/* key changes per event, so two identical sentences in a row still get + read out instead of being seen as "no change". */} + {announcement && {announcement.text}} +
+ ); +} diff --git a/frontend/components/chat/conversation-announcer.interaction.test.tsx b/frontend/components/chat/conversation-announcer.interaction.test.tsx new file mode 100644 index 00000000..fc80390e --- /dev/null +++ b/frontend/components/chat/conversation-announcer.interaction.test.tsx @@ -0,0 +1,113 @@ +// Checks the mapping ConversationAnnouncer adds on top of the pure rule: +// completed turn -> reply, user cancel -> stop, real failure -> silent (the +// error banner handles that one). +import { afterEach, describe, expect, test } from "bun:test"; +import { GlobalRegistrator } from "@happy-dom/global-registrator"; + +if (typeof window === "undefined") GlobalRegistrator.register(); + +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +const { act } = await import("react"); +const { createRoot } = await import("react-dom/client"); +const { ConversationAnnouncer } = await import("./conversation-announcer"); + +import type { GatewayApproval } from "@/lib/gateway-approvals"; +import type { Turn } from "./conversation"; +import type { ApiRun, RunStatus } from "./types"; + +// The announcer reads only status, summary and the root run id, so the rest of +// the Turn shape is irrelevant here. +function turn(id: string, status: RunStatus, summary: string | null): Turn { + return { + run: { id } as unknown as ApiRun, + steps: [], + status, + summary, + live: status === "running", + liveText: "", + liveReasoning: "", + }; +} + +function gateway(id: string, status: GatewayApproval["status"]): GatewayApproval { + return { + id, + runId: "r1", + toolName: "deploy_service", + arguments: {}, + status, + requestedAt: "2026-01-01T00:00:00Z", + resolvedAt: null, + resolvedBy: null, + }; +} + +let root: ReturnType | null = null; +let container: HTMLElement | null = null; + +type Props = Parameters[0]; + +const RUNNING: Props = { + running: true, + turns: [turn("root", "running", null)], + pendingApproval: null, + pendingQuestion: null, + gatewayApprovals: [], +}; + +function mount(props: Props) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + act(() => root?.render()); +} + +function rerender(props: Props) { + act(() => root?.render()); +} + +function text(): string { + return (container?.querySelector('[role="status"]')?.textContent ?? "").trim(); +} + +afterEach(() => { + if (root) act(() => root?.unmount()); + container?.remove(); + root = null; + container = null; +}); + +describe("ConversationAnnouncer mapping", () => { + test("a completed turn is announced as a reply", () => { + mount(RUNNING); + rerender({ ...RUNNING, running: false, turns: [turn("root", "completed", "Done.")] }); + expect(text()).toBe("The agent replied."); + }); + + test("a user stop is announced as a stop, not a reply", () => { + mount(RUNNING); + rerender({ + ...RUNNING, + running: false, + turns: [turn("root", "failed", "Stopped by user before the first tool call.")], + }); + expect(text()).toBe("The agent stopped."); + }); + + test("a real failure stays silent (the error banner owns it)", () => { + mount(RUNNING); + rerender({ + ...RUNNING, + running: false, + turns: [turn("root", "failed", "The model returned an error.")], + }); + expect(text()).toBe(""); + }); + + test("a newly pending gateway approval is announced", () => { + mount(RUNNING); + rerender({ ...RUNNING, gatewayApprovals: [gateway("g1", "pending")] }); + expect(text()).toBe("The agent needs your approval."); + }); +}); diff --git a/frontend/components/chat/conversation-announcer.tsx b/frontend/components/chat/conversation-announcer.tsx new file mode 100644 index 00000000..001746a4 --- /dev/null +++ b/frontend/components/chat/conversation-announcer.tsx @@ -0,0 +1,51 @@ +"use client"; + +// #350: maps thread state into the activity the announcer wants. completed is a +// reply, a user cancel is a stop, a real failure stays null (the error banner +// handles it). Keyed on the thread root so switching threads resets it and we +// don't replay the new thread's last event. + +import { isUserStopSummary } from "@/components/session-ui/thread-error-banner"; +import type { GatewayApproval } from "@/lib/gateway-approvals"; +import type { TurnOutcome } from "./agent-announcement"; +import { AgentAnnouncer } from "./agent-announcer"; +import type { PendingApproval } from "./approval-state"; +import type { Turn } from "./conversation"; +import type { PendingQuestion } from "./question-state"; + +export function ConversationAnnouncer({ + running, + turns, + pendingApproval, + pendingQuestion, + gatewayApprovals, +}: { + readonly running: boolean; + readonly turns: readonly Turn[]; + readonly pendingApproval: PendingApproval | null; + readonly pendingQuestion: PendingQuestion | null; + readonly gatewayApprovals: readonly GatewayApproval[] | undefined; +}) { + const latestTurn = turns.at(-1); + const outcome: TurnOutcome | null = running + ? null + : latestTurn?.status === "completed" + ? "replied" + : latestTurn?.status === "failed" && isUserStopSummary(latestTurn.summary) + ? "stopped" + : null; + const pendingApprovalIds = (gatewayApprovals ?? []) + .filter((approval) => approval.status === "pending") + .map((approval) => approval.id); + + return ( + + ); +} diff --git a/frontend/components/chat/conversation.tsx b/frontend/components/chat/conversation.tsx index b55fae1c..83f29188 100644 --- a/frontend/components/chat/conversation.tsx +++ b/frontend/components/chat/conversation.tsx @@ -28,6 +28,7 @@ import { type HandoffReceipt, HandoffReceipts, } from "@/components/chat/handoff-receipts"; +import { ConversationAnnouncer } from "@/components/chat/conversation-announcer"; import { InboundAttachments } from "@/components/chat/inbound-attachments"; import { NativeApprovalCard } from "@/components/chat/native-approval-card"; import { PermissionModeTag } from "@/components/pro/permission-mode-chip"; @@ -667,6 +668,14 @@ export const Conversation = memo(function Conversation({ return (
+ {/* Screen-reader announcements: reply done, stopped, new approval/question (#350). */} +