Skip to content
Source
Sign inSign up

BackgroundThreadAnnouncer.tsx

91 lines3.8 KB

1import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment";
2import { effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled";
3import { isListedThreadLineage } from "@t3tools/contracts";
4import type { AnyRouter } from "@tanstack/react-router";
5import type { AtomRegistry } from "effect/reactivity";
6​
7import { useComposerDraftStore } from "../composerDraftStore";
8import { createLiveRegion } from "../hooks/useLiveRegion";
9import { liveShellEnvironmentIdsAtom } from "../state/shell";
10import { environmentThreadShells } from "../state/threads";
11import { resolveActiveThreadRouteRef, resolveThreadRouteTarget } from "../threadRoutes";
12import {
13 type BackgroundThreadMemory,
14 type BackgroundThreadSnapshot,
15 nextBackgroundThreadAnnouncement,
16 resolveBackgroundThreadRequest,
17} from "./backgroundThreadAnnouncement";
18​
19const backgroundThreadLiveRegion = createLiveRegion();
20const attachBackgroundThreadRegion = backgroundThreadLiveRegion.attach;
21​
22function readOpenThreadKey(router: AnyRouter): string | null {
23 const target = resolveThreadRouteTarget(router.state.matches.at(-1)?.params ?? {});
24 const draft =
25 target?.kind === "draft"
26 ? useComposerDraftStore.getState().getDraftSession(target.draftId)
27 : null;
28 const ref = resolveActiveThreadRouteRef(target, draft);
29 return ref === null ? null : scopedThreadKey(ref);
31​
32/** Speaks status changes for threads other than the open one, for the life of the app. */
33export function startBackgroundThreadAnnouncements(
34 registry: AtomRegistry.AtomRegistry,
35 router: AnyRouter,
36): () => void {
37 let memory: BackgroundThreadMemory = new Map();
38 let openThreadKey = readOpenThreadKey(router);
39​
40 const update = () => {
41 const threads = registry.get(environmentThreadShells.quietThreadShellsAtom);
42 const liveEnvironmentIds = registry.get(liveShellEnvironmentIdsAtom);
43 const now = new Date().toISOString();
44 const snapshots: BackgroundThreadSnapshot[] = [];
45 for (const thread of threads) {
46 // Subagents and side chats have no row of their own; their requests surface on the parent.
47 if (thread.archivedAt !== null || !isListedThreadLineage(thread.lineage)) continue;
48 if (!liveEnvironmentIds.has(thread.environmentId)) continue;
49 snapshots.push({
50 key: scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)),
51 title: thread.title,
52 ...resolveBackgroundThreadRequest(thread),
53 runId: thread.latestRun?.runId ?? null,
54 runStatus: thread.latestRun?.status ?? null,
55 activeRunId: thread.runtime?.activeRunId ?? null,
56 snoozed: effectiveSnoozed(thread, { now }),
57 });
58 }
59 const next = nextBackgroundThreadAnnouncement(memory, snapshots, openThreadKey);
60 memory = next.memory;
61 if (next.announcement !== null) backgroundThreadLiveRegion.announce(next.announcement);
62 };
63​
64 // The draft store changes on every keystroke; only a new open thread matters here.
65 const updateOpenThread = () => {
66 const next = readOpenThreadKey(router);
67 if (next === openThreadKey) return;
68 openThreadKey = next;
69 update();
70 };
71​
72 const unsubscribes = [
73 registry.subscribe(environmentThreadShells.quietThreadShellsAtom, update),
74 registry.subscribe(liveShellEnvironmentIdsAtom, update),
75 router.subscribe("onResolved", updateOpenThread),
76 useComposerDraftStore.subscribe(updateOpenThread),
77 ];
78 update();
79 return () => {
80 for (const unsubscribe of unsubscribes) unsubscribe();
81 };
83​
84/**
85 * The region the background announcements are spoken through. It lives outside
86 * the sidebar because a collapsed sidebar is inert, which silences its rows.
87 */
88export function BackgroundThreadAnnouncer() {
89 // No role="status": Orca 50 maps it to a status bar in Chromium and drops its updates.
90 return <div ref={attachBackgroundThreadRegion} aria-live="polite" className="sr-only" />;