Open History to see the last commit
1import type { ScreenReaderAnnouncements } from "@t3tools/contracts";
2import { memo, useEffect, useRef } from "react";
3
4import { useLiveRegion } from "../../hooks/useLiveRegion";
5import type { TimelineEntry } from "../../session-logic";
6
7import {
8 nextThreadAnnouncement,
9 type ThreadActivitySnapshot,
10 type ThreadAnnouncementMemory,
11} from "@t3tools/client-runtime/state/thread-activity-announcement";
12import {
13 nextTurnDetailAnnouncement,
14 NO_TURN_DETAIL,
15 turnDetail,
16 type TurnDetailMemory,
17} from "./turnDetailAnnouncement";
18
19// Long enough for a screen reader to finish a short label before the next one.
20const ACTIVITY_GAP_MS = 2_000;
21// A polite region queues what it can't say yet, so steps written while a
22// message is still being read would all be heard late. They wait this long.
23const MESSAGE_HOLD_MS_PER_CHARACTER = 50;
24const MESSAGE_HOLD_MAX_MS = 15_000;
25
26function holdAfter(text: string) {
27 return Math.min(
28 MESSAGE_HOLD_MAX_MS,
29 Math.max(ACTIVITY_GAP_MS, text.length * MESSAGE_HOLD_MS_PER_CHARACTER),
30 );
31}
32
33/** One write, since a second write to a region replaces the first before it is heard. */
34function asOneAnnouncement(texts: ReadonlyArray<string>) {
35 return texts
36 .map((text, index) => (index === texts.length - 1 || /[.!?:…]$/.test(text) ? text : `${text}.`))
37 .join(" ");
38}
39
40/**
41 * The open thread's polite live regions. Status has its own, so "Needs
42 * approval" landing a moment after a message doesn't replace it unheard.
43 */
44export const ThreadActivityAnnouncer = memo(function ThreadActivityAnnouncer({
45 threadKey,
46 status,
47 requestId,
48 runId,
49 runStatus,
50 detailBehind,
51 level,
52 entries,
53 workspaceRoot,
54}: ThreadActivitySnapshot & {
55 /** Null while the timeline shows another thread during a switch. */
56 readonly threadKey: string | null;
57 /** See isDetailBehindShell. */
58 readonly detailBehind: boolean;
59 readonly level: ScreenReaderAnnouncements;
60 /** The thread's entries before any are hidden; unread at the "status" level. */
61 readonly entries: ReadonlyArray<TimelineEntry>;
62 readonly workspaceRoot: string | undefined;
63}) {
64 const { regionRef, announce, clear } = useLiveRegion();
65 const {
66 regionRef: detailRegionRef,
67 announce: announceDetail,
68 clear: clearDetail,
69 } = useLiveRegion();
70 const memoryRef = useRef<{
71 threadKey: string;
72 level: ScreenReaderAnnouncements;
73 memory: ThreadAnnouncementMemory;
74 detail: TurnDetailMemory;
75 } | null>(null);
76 // Messages are written at once; a step waits its turn, and a newer one replaces it.
77 const stepRef = useRef<{
78 quietUntil: number;
79 waiting: { label: string; timer: ReturnType<typeof setTimeout> } | null;
80 }>({ quietUntil: 0, waiting: null });
81
82 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- writes the turn's status, messages and steps to the aria-live region after commit
83 useEffect(() => {
84 const step = stepRef.current;
85 const write = (text: string, quietMs: number) => {
86 announceDetail(text);
87 step.quietUntil = Date.now() + quietMs;
88 };
89 const takeWaiting = () => {
90 const label = step.waiting?.label ?? null;
91 if (step.waiting !== null) clearTimeout(step.waiting.timer);
92 step.waiting = null;
93 return label;
94 };
95
96 if (threadKey === null) {
97 takeWaiting();
98 memoryRef.current = null;
99 clear();
100 clearDetail();
101 return;
102 }
103 const waitingLabel = takeWaiting();
104 if (detailBehind) {
105 // The dropped step counts as unsaid, so it is read once the detail catches up.
106 if (waitingLabel !== null && memoryRef.current !== null) {
107 const { detail } = memoryRef.current;
108 memoryRef.current = { ...memoryRef.current, detail: { ...detail, activity: null } };
109 }
110 return;
111 }
112 const previous = memoryRef.current?.threadKey === threadKey ? memoryRef.current : null;
113 const turn = nextThreadAnnouncement(previous?.memory ?? null, {
114 status,
115 requestId,
116 runId,
117 runStatus,
118 });
119 // A new level starts from what the turn already holds, so raising it mid-turn replays nothing.
120 const previousDetail = previous?.level === level ? previous.detail : null;
121 const detail = nextTurnDetailAnnouncement(previousDetail, {
122 ...(level === "status"
123 ? NO_TURN_DETAIL
124 : turnDetail(entries, workspaceRoot, previousDetail?.tailId ?? null)),
125 level,
126 status,
127 });
128 memoryRef.current = { threadKey, level, memory: turn.memory, detail: detail.memory };
129 if (previous === null) {
130 step.quietUntil = 0;
131 clear();
132 clearDetail();
133 return;
134 }
135 if (detail.messages.length > 0) {
136 // NVDA reads two regions written in the same frame in either order, so a
137 // status that lands with a message is read as its last sentence.
138 const text = asOneAnnouncement(
139 turn.announcement === null ? detail.messages : [...detail.messages, turn.announcement],
140 );
141 write(text, holdAfter(text));
142 } else if (turn.announcement !== null) {
143 announce(turn.announcement);
144 step.quietUntil = Math.max(step.quietUntil, Date.now() + ACTIVITY_GAP_MS);
145 }
146 // A waiting label is stale once its step is over or the level no longer reads steps.
147 const label =
148 level !== "everything" || detail.memory.activity === null
149 ? null
150 : (detail.activity ?? waitingLabel);
151 if (label === null) return;
152 const speakStep = () => {
153 step.waiting = null;
154 write(label, ACTIVITY_GAP_MS);
155 };
156 const wait = step.quietUntil - Date.now();
157 if (wait <= 0) {
158 speakStep();
159 return;
160 }
161 const timer = setTimeout(speakStep, wait);
162 step.waiting = { label, timer };
163 // The next run takes the label over; after an unmount nothing is left to speak it.
164 return () => clearTimeout(timer);
165 }, [
166 threadKey,
167 status,
168 requestId,
169 runId,
170 runStatus,
171 detailBehind,
172 level,
173 entries,
174 workspaceRoot,
175 announce,
176 clear,
177 announceDetail,
178 clearDetail,
179 ]);
180
181 // No role="status": Orca 50 maps it to a status bar in Chromium and drops its updates.
182 return (
183 <>
184 <div ref={detailRegionRef} aria-live="polite" className="sr-only" />
185 <div ref={regionRef} aria-live="polite" className="sr-only" />
186 </>
187 );
188});