Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions frontend/app/lab/announcer-showcase.tsx
Original file line number Diff line number Diff line change
@@ -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<AgentActivity>({ ...IDLE_ACTIVITY, running: true });
const [spoken, setSpoken] = useState<readonly string[]>([]);
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 (
<div className="flex flex-col gap-3">
<AgentAnnouncer
running={activity.running}
outcome={activity.outcome}
approvalId={activity.approvalId}
questionId={activity.questionId}
pendingApprovalIds={activity.pendingApprovalIds}
/>
<div className="flex flex-wrap gap-2">
<Button
variant="primary"
size="small"
onClick={() => apply({ ...IDLE_ACTIVITY, outcome: "replied" })}
>
Reply finished
</Button>
<Button
variant="secondary"
size="small"
onClick={() => apply({ ...IDLE_ACTIVITY, outcome: "stopped" })}
>
Stopped by user
</Button>
<Button
variant="secondary"
size="small"
onClick={() => {
nextApproval.current += 1;
apply({ ...activity, running: true, approvalId: `ap${nextApproval.current}` });
}}
>
New approval
</Button>
<Button
variant="secondary"
size="small"
onClick={() => apply({ ...activity, running: true, questionId: "q1" })}
>
New question
</Button>
<Button
variant="ghost"
size="small"
onClick={() => apply({ ...IDLE_ACTIVITY, running: true })}
>
Clear (back to running)
</Button>
</div>
<div className="border-border-button-default bg-background-primary-default rounded-xl border px-3 py-2">
<p className="text-caption-1-regular text-text-tertiary">
Announced (newest first) - a real failure is intentionally silent here, the error banner
owns it:
</p>
{spoken.length > 0 ? (
<ul className="mt-1 space-y-0.5">
{spoken.map((line, index) => (
<li
// Newest is index 0; good enough key for a short demo log.
key={`${spoken.length - index}-${line}`}
className="text-caption-1-regular text-text-primary"
>
{line}
</li>
))}
</ul>
) : (
<p className="text-caption-1-regular text-text-secondary mt-1">Nothing announced yet.</p>
)}
</div>
</div>
);
}
5 changes: 5 additions & 0 deletions frontend/app/lab/component-lab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -506,6 +507,10 @@ export function ComponentLab() {
</div>
</Section>

<Section label="Chat · Screen-reader announcements (one polite live region)">
<AnnouncerShowcase />
</Section>

<Section label="AI kit · Context cards">
<ContextCardStack
label="Retrieved chunks"
Expand Down
2 changes: 2 additions & 0 deletions frontend/components/ai/approval-card.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ describe("ApprovalCard", () => {
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", () => {
Expand Down
7 changes: 6 additions & 1 deletion frontend/components/ai/approval-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,12 @@ export function ApprovalCard({
{body}

{note && <p className="text-caption-1-regular text-text-secondary">{note}</p>}
{error && <p className="text-caption-1-regular text-text-error-primary">{error}</p>}
{/* Both lanes send their error here, so role="alert" reads it out once (#350). */}
{error && (
<p role="alert" className="text-caption-1-regular text-text-error-primary">
{error}
</p>
)}

{reason}

Expand Down
88 changes: 88 additions & 0 deletions frontend/components/chat/agent-announcement.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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();
});
});
64 changes: 64 additions & 0 deletions frontend/components/chat/agent-announcement.ts
Original file line number Diff line number Diff line change
@@ -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;
}
94 changes: 94 additions & 0 deletions frontend/components/chat/agent-announcer.interaction.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof AgentAnnouncer>;

const IDLE: Props = {
running: false,
outcome: null,
approvalId: null,
questionId: null,
pendingApprovalIds: [],
};

let root: ReturnType<typeof createRoot> | 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(<AgentAnnouncer {...props} />));
}

function rerender(props: Props) {
act(() => root?.render(<AgentAnnouncer {...props} />));
}

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