1import type { ClientSettings } from "@t3tools/contracts/settings";
2import * as DateTime from "effect/DateTime";
3import * as Option from "effect/Option";
4import { act } from "react";
5import { create, type ReactTestRenderer } from "react-test-renderer";
6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
7
8const state = vi.hoisted(() => ({
9 mode: "off" as ClientSettings["notificationMode"],
10 inApp: true,
11 active: { environmentId: "env-1", threadId: "other-thread" },
12 focused: true,
13 visible: "visible",
14 live: true,
15 completedAt: null as string | null,
16 archivedAt: null as string | null,
17 input: false,
18 approval: false,
19 sessionError: false,
20 turnError: false,
21 limited: false,
22 subagent: false,
23 background: [] as Array<{ taskId: string; kind: "command" | "monitor" }>,
24 delegatedChild: null as "running" | "approval" | null,
25 add: vi.fn(
26 (_toast: { title: string; description: string; actionProps: { onClick: () => void } }) =>
27 "toast-1",
28 ),
29 close: vi.fn(),
30 navigate: vi.fn(),
31 sound: vi.fn(),
32 notification: vi.fn(function (_title: string, options: NotificationOptions) {
33 return Object.assign(new EventTarget(), { tag: options.tag, close: vi.fn() });
34 }),
35}));
36
37const SHELL_NOW = DateTime.makeUnsafe("2026-09-13T09:00:00.000Z");
38
39function mockThreadShell() {
40 return {
41 id: "thread-1",
42 projectId: "project-1",
43 title: "Fix the login form",
44 providerInstanceId: "codex",
45 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
46 runtimeMode: "full-access",
47 interactionMode: "default",
48 branch: null,
49 worktreePath: null,
50 activeProviderThreadId: null,
51 lineage: {
52 rootThreadId: "thread-1",
53 parentThreadId: state.subagent ? "parent" : null,
54 relationshipToParent: state.subagent ? "subagent" : null,
55 },
56 forkedFrom: null,
57 createdBy: "user",
58 creationSource: "web",
59 latestRunId: "run-1",
60 activeRunId: null,
61 status: state.completedAt
62 ? "completed"
63 : state.sessionError || state.turnError || state.limited
64 ? "failed"
65 : "running",
66 lastErrorClass: state.limited ? "usage_limit" : null,
67 pendingRuntimeRequest: state.input
68 ? { id: "request-1", kind: "user_input", createdAt: SHELL_NOW }
69 : state.approval
70 ? { id: "request-1", kind: "command", createdAt: SHELL_NOW }
71 : null,
72 latestVisibleMessage: null,
73 latestUserMessageAt: null,
74 hasActionableProposedPlan: false,
75 pendingBackgroundTasks: state.background,
76 itemCount: 0,
77 visibleItemCount: 0,
78 createdAt: SHELL_NOW,
79 updatedAt: SHELL_NOW,
80 latestRunRequestedAt: SHELL_NOW,
81 latestRunStartedAt: SHELL_NOW,
82 latestRunCompletedAt: state.completedAt ? DateTime.makeUnsafe(state.completedAt) : undefined,
83 archivedAt: state.archivedAt ? DateTime.makeUnsafe(state.archivedAt) : null,
84 settledOverride: null,
85 settledAt: null,
86 lastVisitedAt: null,
87 deletedAt: null,
88 };
89}
90
91// A delegated child changes while its parent's shell object stays the same.
92let stableParent: ReturnType<typeof mockThreadShell> | null = null;
93function mockThreads() {
94 if (state.delegatedChild === null) return [mockThreadShell()];
95 stableParent ??= mockThreadShell();
96 const child = {
97 ...mockThreadShell(),
98 id: "child-1",
99 title: "Review the login form",
100 creationSource: "mcp",
101 lineage: {
102 rootThreadId: "thread-1",
103 parentThreadId: "thread-1",
104 relationshipToParent: "subagent",
105 },
106 pendingRuntimeRequest:
107 state.delegatedChild === "approval"
108 ? { id: "request-2", kind: "command", createdAt: SHELL_NOW }
109 : null,
110 };
111 return [stableParent, child];
112}
113
114vi.mock("@effect/atom-react", () => ({
115 useAtomValue: () => ({
116 status: state.live ? "live" : "disconnected",
117 snapshot: Option.some({ threads: mockThreads() }),
118 }),
119}));
120vi.mock("@tanstack/react-router", () => ({
121 useNavigate: () => state.navigate,
122 useParams: () => state.active,
123}));
124vi.mock("../hooks/useSettings", () => ({
125 useClientSettings: (
126 select: (
127 settings: Pick<ClientSettings, "notificationMode" | "inAppNotificationsEnabled">,
128 ) => unknown,
129 ) => select({ notificationMode: state.mode, inAppNotificationsEnabled: state.inApp }),
130 getClientSettings: () => ({ notificationMode: state.mode }),
131}));
132vi.mock("../state/environments", () => ({
133 useEnvironmentIds: () => ["env-1"],
134}));
135vi.mock("../state/shell", () => ({
136 environmentShell: { stateValueAtom: vi.fn() },
137}));
138vi.mock("../threadNotifications", async (importOriginal) => ({
139 ...(await importOriginal<typeof import("../threadNotifications")>()),
140 playNotificationSound: state.sound,
141 setNotificationBadge: vi.fn(),
142}));
143vi.mock("./ui/toast", () => ({
144 toastManager: { add: state.add, close: state.close },
145}));
146
147import { ThreadNotificationCoordinator } from "./ThreadNotificationCoordinator";
148
149let renderer: ReactTestRenderer | undefined;
150
151async function render() {
152 await act(() => {
153 if (renderer) renderer.update(<ThreadNotificationCoordinator />);
154 else renderer = create(<ThreadNotificationCoordinator />);
155 });
156}
157
158async function complete() {
159 state.completedAt = "2026-09-13T10:00:00.000Z";
160 await render();
161}
162
163beforeEach(() => {
164 vi.clearAllMocks();
165 Object.assign(state, {
166 mode: "off",
167 inApp: true,
168 active: { environmentId: "env-1", threadId: "other-thread" },
169 focused: true,
170 visible: "visible",
171 live: true,
172 completedAt: null,
173 archivedAt: null,
174 input: false,
175 approval: false,
176 sessionError: false,
177 turnError: false,
178 limited: false,
179 subagent: false,
180 background: [],
181 delegatedChild: null,
182 });
183 stableParent = null;
184 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
185 vi.stubGlobal("window", new EventTarget());
186 vi.stubGlobal("document", {
187 get visibilityState() {
188 return state.visible;
189 },
190 hasFocus: () => state.focused,
191 addEventListener: vi.fn(),
192 removeEventListener: vi.fn(),
193 });
194 vi.stubGlobal("Notification", Object.assign(state.notification, { permission: "granted" }));
195});
196
197afterEach(async () => {
198 await act(() => renderer?.unmount());
199 renderer = undefined;
200 vi.unstubAllGlobals();
201});
202
203describe("thread notifications", () => {
204 it.each([true, false])("keeps subagents silent with focus=%s", async (focused) => {
205 state.subagent = true;
206 state.focused = focused;
207 state.mode = "notifications-and-sound";
208 await render();
209 await complete();
210 state.input = true;
211 await render();
212 expect(state.sound).not.toHaveBeenCalled();
213 expect(state.add).not.toHaveBeenCalled();
214 expect(state.notification).not.toHaveBeenCalled();
215 });
216
217 it("alerts on the parent when its delegated subagent asks for approval", async () => {
218 state.mode = "notifications-and-sound";
219 state.delegatedChild = "running";
220 await render();
221 state.delegatedChild = "approval";
222 await render();
223 await render();
224 expect(state.add).toHaveBeenCalledTimes(1);
225 expect(state.add).toHaveBeenLastCalledWith(
226 expect.objectContaining({ title: "Approval needed", description: "Fix the login form" }),
227 );
228 });
229
230 it("alerts once with system alerts off and opens the completed thread", async () => {
231 await render();
232 await complete();
233 await render();
234 expect(state.add).toHaveBeenCalledTimes(1);
235 const toast = state.add.mock.calls[0]?.[0];
236 expect(toast?.title).toBe("Thread completed");
237 expect(toast?.description).toBe("Fix the login form");
238 toast?.actionProps.onClick();
239 expect(state.close).toHaveBeenCalledWith("toast-1");
240 expect(state.navigate).toHaveBeenCalledWith({
241 to: "/$environmentId/$threadId",
242 params: { environmentId: "env-1", threadId: "thread-1" },
243 });
244 expect(state.notification).not.toHaveBeenCalled();
245 });
246
247 it.each(["active", "blurred", "hidden", "archived", "disabled"])(
248 "does not show a completion toast for %s threads",
249 async (condition) => {
250 await render();
251 if (condition === "active") state.active.threadId = "thread-1";
252 if (condition === "blurred") state.focused = false;
253 if (condition === "hidden") state.visible = "hidden";
254 if (condition === "archived") state.archivedAt = "2026-09-13T09:00:00.000Z";
255 if (condition === "disabled") state.inApp = false;
256 await complete();
257 expect(state.add).not.toHaveBeenCalled();
258 },
259 );
260
261 it.each([
262 ["input", "Input needed"],
263 ["approval", "Approval needed"],
264 ["sessionError", "Thread failed"],
265 ["turnError", "Thread failed"],
266 ["limited", "Usage limit reached"],
267 ] as const)("uses the same %s event for in-app and desktop alerts", async (event, title) => {
268 state.mode = "notifications-and-sound";
269 await render();
270 state[event] = true;
271 await render();
272 await render();
273 expect(state.add).toHaveBeenCalledTimes(1);
274 expect(state.add).toHaveBeenLastCalledWith(expect.objectContaining({ title }));
275 expect(state.sound).toHaveBeenCalledWith("input", expect.any(Function));
276 expect(state.notification).not.toHaveBeenCalled();
277
278 state[event] = false;
279 await render();
280 state.focused = false;
281 state[event] = true;
282 await render();
283 await render();
284 expect(state.add).toHaveBeenCalledTimes(1);
285 expect(state.notification).toHaveBeenCalledTimes(1);
286 expect(state.notification).toHaveBeenCalledWith(title, {
287 body: "Fix the login form",
288 tag: "env-1:thread-1",
289 silent: true,
290 });
291 });
292
293 it("alerts when only a dev server is left running, not while a monitor can wake the agent", async () => {
294 await render();
295 state.background = [{ taskId: "watch", kind: "monitor" }];
296 await complete();
297 expect(state.add).not.toHaveBeenCalled();
298 state.background = [{ taskId: "dev", kind: "command" }];
299 await render();
300 expect(state.add).toHaveBeenCalledTimes(1);
301 expect(state.add).toHaveBeenLastCalledWith(
302 expect.objectContaining({ title: "Thread completed" }),
303 );
304 });
305
306 it("keeps background desktop alerts when in-app notifications are disabled", async () => {
307 state.focused = false;
308 state.inApp = false;
309 state.mode = "notifications";
310 await render();
311 await complete();
312 expect(state.add).not.toHaveBeenCalled();
313 expect(state.notification).toHaveBeenCalledTimes(1);
314 state.inApp = true;
315 await render();
316 expect(state.add).not.toHaveBeenCalled();
317 });
318
319 it("does not replay a completion when opting in from all alerts off", async () => {
320 state.inApp = false;
321 await render();
322 await complete();
323 state.inApp = true;
324 await render();
325 expect(state.add).not.toHaveBeenCalled();
326 });
327
328 it("compares the environment as well as the thread", async () => {
329 state.active = { environmentId: "env-2", threadId: "thread-1" };
330 await render();
331 await complete();
332 expect(state.add).toHaveBeenCalledTimes(1);
333 });
334
335 it("does not replay completed threads on first load or reconnect", async () => {
336 await complete();
337 state.live = false;
338 await render();
339 state.live = true;
340 await render();
341 expect(state.add).not.toHaveBeenCalled();
342 });
343
344 it("keeps sound but replaces the system popup when showing a toast", async () => {
345 state.mode = "notifications-and-sound";
346 await render();
347 await complete();
348 expect(state.sound).toHaveBeenCalledWith("completion", expect.any(Function));
349 expect(state.add).toHaveBeenCalledTimes(1);
350 expect(state.notification).not.toHaveBeenCalled();
351 });
352
353 it("keeps system alerts when the app is in the background", async () => {
354 state.mode = "notifications";
355 state.focused = false;
356 await render();
357 await complete();
358 expect(state.add).not.toHaveBeenCalled();
359 expect(state.notification).toHaveBeenCalledWith("Thread completed", {
360 body: "Fix the login form",
361 tag: "env-1:thread-1",
362 silent: true,
363 });
364 });
365});