1import { useIsFocused } from "@react-navigation/native";
2import {
3 nextThreadAnnouncement,
4 type ThreadAnnouncementMemory,
5} from "@t3tools/client-runtime/state/thread-activity-announcement";
6import { useEffect, useRef } from "react";
7import { AccessibilityInfo } from "react-native";
8
9import { useAppActive } from "../../lib/appState";
10import {
11 nextThreadActivitySnapshot,
12 type ThreadActivityInput,
13 type ThreadActivityMemory,
14} from "./threadStatusAnnouncement";
15
16/** Tells VoiceOver and TalkBack when the open thread starts working, needs the user, or finishes. */
17export function useThreadStatusAnnouncements({
18 threadKey,
19 subject,
20 detailBehind,
21 approvalRequestId,
22 userInputRequestId,
23 shellRequestId,
24 shellPendingUserInput,
25 working,
26 run,
27}: ThreadActivityInput & {
28 /** Null when nothing should be announced, such as a provider subagent's thread. */
29 readonly threadKey: string | null;
30 /** The thread's title, said after each status when another thread is on screen beside it. */
31 readonly subject: string | null;
32 /** See isDetailBehindShell. */
33 readonly detailBehind: boolean;
34}) {
35 // A retained route stays mounted under the screen above it; it speaks only while shown,
36 // and starts from a silent baseline when it is shown again.
37 const focused = useIsFocused();
38 const appActive = useAppActive();
39 const activeKey = focused && appActive ? threadKey : null;
40 const memoryRef = useRef<{
41 threadKey: string;
42 activity: ThreadActivityMemory;
43 announcement: ThreadAnnouncementMemory;
44 } | null>(null);
45 const runId = run?.runId ?? null;
46 const runStatus = run?.status ?? null;
47
48 // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- speaks committed status changes through the platform screen reader
49 useEffect(() => {
50 if (activeKey === null) {
51 memoryRef.current = null;
52 return;
53 }
54 if (detailBehind) return;
55 const previous = memoryRef.current?.threadKey === activeKey ? memoryRef.current : null;
56 const activity = nextThreadActivitySnapshot(previous?.activity ?? null, {
57 approvalRequestId,
58 userInputRequestId,
59 shellRequestId,
60 shellPendingUserInput,
61 working,
62 run: runId === null || runStatus === null ? null : { runId, status: runStatus },
63 });
64 const next = nextThreadAnnouncement(previous?.announcement ?? null, activity.snapshot);
65 memoryRef.current = { threadKey: activeKey, activity, announcement: next.memory };
66 // Queued, like the web's polite region, so VoiceOver doesn't cut off what it is reading.
67 if (next.announcement !== null) {
68 AccessibilityInfo.announceForAccessibilityWithOptions(
69 subject === null ? next.announcement : `${next.announcement}: ${subject}`,
70 { queue: true },
71 );
72 }
73 }, [
74 activeKey,
75 subject,
76 detailBehind,
77 approvalRequestId,
78 userInputRequestId,
79 shellRequestId,
80 shellPendingUserInput,
81 working,
82 runId,
83 runStatus,
84 ]);
85}