1import {
2 type PendingSubagentRequest,
3 presentThreadShell,
4} from "@t3tools/client-runtime/state/models";
5import { pendingSubagentRequestsByThread } from "@t3tools/client-runtime/state/threads";
6import { useAtomValue } from "@effect/atom-react";
7import { useNavigate, useParams } from "@tanstack/react-router";
8import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts";
9import * as Option from "effect/Option";
10import {
11 CircleAlertIcon,
12 CircleCheckIcon,
13 MessageCircleQuestionIcon,
14 ShieldQuestionIcon,
15} from "lucide-react";
16import { useCallback, useEffect, useRef } from "react";
17
18import { getClientSettings, useClientSettings } from "../hooks/useSettings";
19import { useEnvironmentIds } from "../state/environments";
20import { environmentShell } from "../state/shell";
21import {
22 hasDesktopNotifications,
23 hasNotificationSound,
24 playNotificationSound,
25 setNotificationBadge,
26 unlockNotificationAudio,
27} from "../threadNotifications";
28import { resolveSidebarThreadStatus } from "./Sidebar.logic";
29import { toastManager } from "./ui/toast";
30
31export function ThreadNotificationCoordinator() {
32 const environmentIds = useEnvironmentIds();
33 const mode = useClientSettings((settings) => settings.notificationMode);
34 const inAppNotificationsEnabled = useClientSettings(
35 (settings) => settings.inAppNotificationsEnabled,
36 );
37 const pending = useRef(
38 new Map<string, { environmentId: EnvironmentId; notification: Notification }>(),
39 );
40 const onNotification = useCallback((environmentId: EnvironmentId, notification: Notification) => {
41 pending.current.get(notification.tag)?.notification.close();
42 pending.current.set(notification.tag, { environmentId, notification });
43 setNotificationBadge(pending.current.size);
44 }, []);
45
46 useEffect(() => {
47 const activeIds = new Set(environmentIds);
48 const count = pending.current.size;
49 for (const [tag, { environmentId, notification }] of pending.current) {
50 if (activeIds.has(environmentId)) continue;
51 notification.close();
52 pending.current.delete(tag);
53 }
54 if (count !== pending.current.size) setNotificationBadge(pending.current.size);
55 }, [environmentIds]);
56
57 useEffect(() => {
58 const clear = () => {
59 for (const { notification } of pending.current.values()) notification.close();
60 pending.current.clear();
61 setNotificationBadge(0);
62 };
63 clear();
64 if (!hasDesktopNotifications(mode)) return;
65 const unsubscribe = window.desktopBridge?.onNotificationBadgeClear?.(clear);
66 window.addEventListener("focus", clear);
67 return () => {
68 unsubscribe?.();
69 window.removeEventListener("focus", clear);
70 clear();
71 };
72 }, [mode]);
73
74 useEffect(() => {
75 if (!hasNotificationSound(mode)) return;
76 document.addEventListener("pointerdown", unlockNotificationAudio);
77 document.addEventListener("keydown", unlockNotificationAudio);
78 return () => {
79 document.removeEventListener("pointerdown", unlockNotificationAudio);
80 document.removeEventListener("keydown", unlockNotificationAudio);
81 };
82 }, [mode]);
83
84 if (mode === "off" && !inAppNotificationsEnabled) return null;
85
86 return environmentIds.map((environmentId) => (
87 <EnvironmentNotifications
88 key={environmentId}
89 environmentId={environmentId}
90 onNotification={onNotification}
91 />
92 ));
93}
94
95interface NotificationState {
96 readonly raw: OrchestrationV2ThreadShell;
97 readonly subagentRequest: PendingSubagentRequest | null;
98 readonly attention: string | null;
99 readonly completion: number | null;
100}
101
102function EnvironmentNotifications({
103 environmentId,
104 onNotification,
105}: {
106 environmentId: EnvironmentId;
107 onNotification: (environmentId: EnvironmentId, notification: Notification) => void;
108}) {
109 const shell = useAtomValue(environmentShell.stateValueAtom(environmentId));
110 // The shell reducer keeps the thread list and unchanged thread objects
111 // stable, so this only rescans when a thread actually changed.
112 const threads =
113 shell.status === "live" && Option.isSome(shell.snapshot) ? shell.snapshot.value.threads : null;
114 const mode = useClientSettings((settings) => settings.notificationMode);
115 const inAppNotificationsEnabled = useClientSettings(
116 (settings) => settings.inAppNotificationsEnabled,
117 );
118 const navigate = useNavigate();
119 const { environmentId: activeEnvironmentId, threadId: activeThreadId } = useParams({
120 strict: false,
121 });
122 const previous = useRef(new Map<ThreadId, NotificationState>());
123
124 useEffect(() => {
125 if (threads === null) {
126 previous.current.clear();
127 return;
128 }
129 const next = new Map<ThreadId, NotificationState>();
130 // Subagents are skipped below, so their requests notify through the thread above them.
131 const subagentRequests = pendingSubagentRequestsByThread(threads);
132 for (const rawThread of threads) {
133 if (rawThread.lineage.relationshipToParent === "subagent") continue;
134 const prior = previous.current.get(rawThread.id);
135 const subagentRequest = subagentRequests.get(rawThread.id) ?? null;
136 // The same object cannot produce a new notification.
137 if (prior?.raw === rawThread && prior.subagentRequest === subagentRequest) {
138 next.set(rawThread.id, prior);
139 continue;
140 }
141 const thread = presentThreadShell(environmentId, rawThread, subagentRequest);
142 let status = resolveSidebarThreadStatus(thread);
143 if (status === "ready" && thread.latestRun?.status === "failed") status = "failed";
144 const attention =
145 status === "input" || status === "approval" || status === "failed" || status === "limited"
146 ? `${thread.latestRun?.runId ?? ""}:${status}`
147 : null;
148 const completedAt = Date.parse(thread.latestRun?.completedAt ?? "");
149 // Commands left running (a dev server) read as ready; subagents and monitors wait.
150 const completion =
151 status === "ready" &&
152 thread.latestRun?.status === "completed" &&
153 Number.isFinite(completedAt)
154 ? completedAt
155 : (prior?.completion ?? null);
156 next.set(thread.id, { raw: rawThread, subagentRequest, attention, completion });
157 if (!prior || thread.archivedAt !== null) continue;
158 const kind =
159 attention && attention !== prior.attention
160 ? "input"
161 : completion !== null && (prior.completion === null || completion > prior.completion)
162 ? "completion"
163 : null;
164 if (!kind) continue;
165 const title =
166 kind === "completion"
167 ? "Thread completed"
168 : status === "approval"
169 ? "Approval needed"
170 : status === "limited"
171 ? "Usage limit reached"
172 : status === "failed"
173 ? "Thread failed"
174 : "Input needed";
175 if (hasNotificationSound(mode)) {
176 void playNotificationSound(kind, () =>
177 hasNotificationSound(getClientSettings().notificationMode),
178 );
179 }
180 if (
181 inAppNotificationsEnabled &&
182 document.visibilityState === "visible" &&
183 document.hasFocus() &&
184 (activeEnvironmentId !== environmentId || activeThreadId !== thread.id)
185 ) {
186 const toastId = toastManager.add({
187 type: kind === "completion" ? "success" : status === "failed" ? "error" : "warning",
188 title,
189 description: thread.title,
190 data: {
191 hideCopyButton: true,
192 leadingIcon:
193 kind === "completion" ? (
194 <CircleCheckIcon aria-hidden className="size-4 text-success-foreground" />
195 ) : status === "approval" ? (
196 <ShieldQuestionIcon aria-hidden className="size-4 text-warning-foreground" />
197 ) : status === "failed" ? (
198 <CircleAlertIcon aria-hidden className="size-4 text-destructive-foreground" />
199 ) : (
200 <MessageCircleQuestionIcon aria-hidden className="size-4 text-info-foreground" />
201 ),
202 },
203 actionProps: {
204 children: "Open thread",
205 onClick: () => {
206 toastManager.close(toastId);
207 void navigate({
208 to: "/$environmentId/$threadId",
209 params: { environmentId, threadId: thread.id },
210 });
211 },
212 },
213 });
214 continue;
215 }
216 if (
217 !hasDesktopNotifications(mode) ||
218 (document.visibilityState === "visible" && document.hasFocus()) ||
219 typeof Notification === "undefined" ||
220 Notification.permission !== "granted"
221 )
222 continue;
223 try {
224 const notification = new Notification(title, {
225 body: thread.title,
226 tag: `${environmentId}:${thread.id}`,
227 silent: true,
228 });
229 onNotification(environmentId, notification);
230 notification.addEventListener("click", () => {
231 notification.close();
232 window.focus();
233 void navigate({
234 to: "/$environmentId/$threadId",
235 params: { environmentId, threadId: thread.id },
236 });
237 });
238 } catch {
239 // Some browsers expose Notification but reject desktop presentation.
240 }
241 }
242 previous.current = next;
243 }, [
244 activeEnvironmentId,
245 activeThreadId,
246 environmentId,
247 inAppNotificationsEnabled,
248 mode,
249 navigate,
250 onNotification,
251 threads,
252 ]);
253
254 return null;
255}