| 1 | import { |
| 2 | type PendingSubagentRequest, |
| 3 | presentThreadShell, |
| 4 | } from "@t3tools/client-runtime/state/models"; |
| 5 | import { pendingSubagentRequestsByThread } from "@t3tools/client-runtime/state/threads"; |
| 6 | import { useAtomValue } from "@effect/atom-react"; |
| 7 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 8 | import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; |
| 9 | import * as Option from "effect/Option"; |
| 10 | import { |
| 11 | CircleAlertIcon, |
| 12 | CircleCheckIcon, |
| 13 | MessageCircleQuestionIcon, |
| 14 | ShieldQuestionIcon, |
| 15 | } from "lucide-react"; |
| 16 | import { useCallback, useEffect, useRef } from "react"; |
| 17 | |
| 18 | import { getClientSettings, useClientSettings } from "../hooks/useSettings"; |
| 19 | import { useEnvironmentIds } from "../state/environments"; |
| 20 | import { environmentShell } from "../state/shell"; |
| 21 | import { |
| 22 | hasDesktopNotifications, |
| 23 | hasNotificationSound, |
| 24 | playNotificationSound, |
| 25 | setNotificationBadge, |
| 26 | unlockNotificationAudio, |
| 27 | } from "../threadNotifications"; |
| 28 | import { resolveSidebarThreadStatus } from "./Sidebar.logic"; |
| 29 | import { toastManager } from "./ui/toast"; |
| 30 | |
| 31 | export 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 | |
| 95 | interface NotificationState { |
| 96 | readonly raw: OrchestrationV2ThreadShell; |
| 97 | readonly subagentRequest: PendingSubagentRequest | null; |
| 98 | readonly attention: string | null; |
| 99 | readonly completion: number | null; |
| 100 | } |
| 101 | |
| 102 | function 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 | } |