1import { EnvironmentId } from "@t3tools/contracts";
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, expect, it, vi } from "vite-plus/test";
7
8const state = vi.hoisted(() => ({
9 mode: "notifications",
10 inApp: false,
11 toast: vi.fn(),
12 shells: new Map(),
13 navigate: vi.fn(),
14 sound: vi.fn(),
15 badge: vi.fn(),
16 environmentIds: ["one", "two"],
17}));
18vi.mock("@effect/atom-react", () => ({ useAtomValue: (id: string) => state.shells.get(id) }));
19vi.mock("@tanstack/react-router", () => ({
20 useNavigate: () => state.navigate,
21 useParams: () => ({}),
22}));
23vi.mock("./ui/toast", () => ({ toastManager: { add: state.toast } }));
24vi.mock("../state/shell", () => ({ environmentShell: { stateValueAtom: (id: string) => id } }));
25vi.mock("../state/environments", () => ({
26 useEnvironmentIds: () => state.environmentIds,
27}));
28vi.mock("../hooks/useSettings", () => ({
29 useClientSettings: (
30 select: (settings: { notificationMode: string; inAppNotificationsEnabled: boolean }) => unknown,
31 ) => select({ notificationMode: state.mode, inAppNotificationsEnabled: state.inApp }),
32 getClientSettings: () => ({ notificationMode: state.mode }),
33}));
34vi.mock("../threadNotifications", async (importOriginal) => ({
35 ...(await importOriginal<typeof import("../threadNotifications")>()),
36 playNotificationSound: state.sound,
37 unlockNotificationAudio: vi.fn(),
38 setNotificationBadge: state.badge,
39}));
40
41import { ThreadNotificationCoordinator } from "./ThreadNotificationCoordinator";
42
43class TestNotification extends EventTarget {
44 static permission = "granted";
45 static sent: TestNotification[] = [];
46 close = vi.fn();
47 get tag() {
48 return this.options.tag ?? "";
49 }
50 constructor(
51 readonly title: string,
52 readonly options: NotificationOptions,
53 ) {
54 super();
55 TestNotification.sent.push(this);
56 }
57}
58
59const thread = {
60 id: "thread",
61 title: "Test thread",
62 archivedAt: null as string | null,
63 hasPendingApprovals: false,
64 hasPendingUserInput: false,
65 session: null as { status: string } | null,
66 latestTurn: { turnId: "turn", state: "running", completedAt: null as string | null },
67};
68let renderer: ReactTestRenderer | undefined;
69let focused = false;
70let visibility = "visible";
71
72const SHELL_NOW = DateTime.makeUnsafe("2026-09-13T07:00:00.000Z");
73
74function toV2ThreadShell(input: typeof thread) {
75 const latestTurn = input.latestTurn;
76 return {
77 id: input.id,
78 projectId: "project",
79 title: input.title,
80 providerInstanceId: "codex",
81 modelSelection: { instanceId: "codex", model: "gpt-5.4" },
82 runtimeMode: "full-access",
83 interactionMode: "default",
84 branch: null,
85 worktreePath: null,
86 activeProviderThreadId: null,
87 lineage: {
88 rootThreadId: input.id,
89 parentThreadId: null,
90 relationshipToParent: null,
91 },
92 forkedFrom: null,
93 createdBy: "user",
94 creationSource: "web",
95 latestRunId: latestTurn.turnId,
96 activeRunId: null,
97 status:
98 latestTurn.state === "completed"
99 ? "completed"
100 : latestTurn.state === "error" || input.session?.status === "error"
101 ? "failed"
102 : "running",
103 pendingRuntimeRequest: input.hasPendingUserInput
104 ? { id: "request-1", kind: "user_input", createdAt: SHELL_NOW }
105 : input.hasPendingApprovals
106 ? { id: "request-1", kind: "command", createdAt: SHELL_NOW }
107 : null,
108 latestVisibleMessage: null,
109 latestUserMessageAt: null,
110 hasActionableProposedPlan: false,
111 itemCount: 0,
112 visibleItemCount: 0,
113 createdAt: SHELL_NOW,
114 updatedAt: SHELL_NOW,
115 latestRunRequestedAt: SHELL_NOW,
116 latestRunStartedAt: SHELL_NOW,
117 latestRunCompletedAt: latestTurn.completedAt
118 ? DateTime.makeUnsafe(latestTurn.completedAt)
119 : undefined,
120 archivedAt: input.archivedAt ? DateTime.makeUnsafe(input.archivedAt) : null,
121 settledOverride: null,
122 settledAt: null,
123 lastVisitedAt: null,
124 deletedAt: null,
125 };
126}
127
128function shell(overrides: Partial<typeof thread> = {}) {
129 return {
130 status: "live",
131 snapshot: Option.some({ threads: [toV2ThreadShell({ ...thread, ...overrides })] }),
132 };
133}
134function complete(environment = "one", completedAt = "2026-09-13T08:00:00Z") {
135 state.shells.set(
136 environment,
137 shell({ latestTurn: { turnId: "turn", state: "completed", completedAt } }),
138 );
139}
140async function render() {
141 await act(async () => {
142 if (renderer) renderer.update(<ThreadNotificationCoordinator />);
143 else renderer = create(<ThreadNotificationCoordinator />);
144 });
145}
146
147beforeEach(() => {
148 vi.clearAllMocks();
149 state.mode = "notifications";
150 state.inApp = false;
151 state.environmentIds = ["one", "two"];
152 state.shells.set("one", shell());
153 state.shells.set("two", shell());
154 focused = false;
155 visibility = "visible";
156 TestNotification.permission = "granted";
157 TestNotification.sent = [];
158 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
159 vi.stubGlobal("Notification", TestNotification);
160 vi.stubGlobal("window", Object.assign(new EventTarget(), { focus: vi.fn() }));
161 vi.stubGlobal(
162 "document",
163 Object.assign(new EventTarget(), {
164 hasFocus: () => focused,
165 get visibilityState() {
166 return visibility;
167 },
168 }),
169 );
170});
171
172afterEach(async () => {
173 await act(async () => renderer?.unmount());
174 renderer = undefined;
175 vi.unstubAllGlobals();
176});
177
178it("counts notifying threads across environments, replaces repeat alerts, and clears on focus", async () => {
179 await render();
180 complete();
181 await render();
182 expect(state.badge).toHaveBeenLastCalledWith(1);
183 complete("one", "2026-09-13T08:01:00Z");
184 complete("two");
185 await render();
186 expect(state.badge).toHaveBeenLastCalledWith(2);
187 expect(TestNotification.sent[0]!.close).toHaveBeenCalledOnce();
188 focused = true;
189 window.dispatchEvent(new Event("focus"));
190 expect(state.badge).toHaveBeenLastCalledWith(0);
191 expect(
192 TestNotification.sent.every((notification) => notification.close.mock.calls.length > 0),
193 ).toBe(true);
194 focused = false;
195 complete("two", "2026-09-13T08:02:00Z");
196 await render();
197 expect(state.badge).toHaveBeenLastCalledWith(1);
198});
199
200it("does not badge old completions on first load or reconnect", async () => {
201 complete();
202 await render();
203 state.shells.set("one", { status: "connecting", snapshot: Option.none() });
204 await render();
205 complete("one", "2026-09-13T08:01:00Z");
206 await render();
207 expect(TestNotification.sent).toHaveLength(0);
208 expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true);
209});
210
211it("removes alerts only from environments that leave the client", async () => {
212 await render();
213 complete("one");
214 complete("two");
215 await render();
216 expect(state.badge).toHaveBeenLastCalledWith(2);
217 const [removed, retained] = TestNotification.sent;
218 state.environmentIds = ["two"];
219 await render();
220 expect(state.badge).toHaveBeenLastCalledWith(1);
221 expect(removed!.close).toHaveBeenCalledOnce();
222 expect(retained!.close).not.toHaveBeenCalled();
223 await render();
224 expect(removed!.close).toHaveBeenCalledOnce();
225 state.environmentIds = [];
226 await render();
227 expect(state.badge).toHaveBeenLastCalledWith(0);
228 expect(retained!.close).toHaveBeenCalledOnce();
229});
230
231it("starts a fresh count after another native app window gains focus", async () => {
232 let clear: (() => void) | undefined;
233 const unsubscribe = vi.fn();
234 Object.assign(window, {
235 desktopBridge: {
236 onNotificationBadgeClear: (listener: () => void) => {
237 clear = listener;
238 return unsubscribe;
239 },
240 },
241 });
242 await render();
243 complete();
244 await render();
245 clear!();
246 expect(state.badge).toHaveBeenLastCalledWith(0);
247 complete("two");
248 await render();
249 expect(state.badge).toHaveBeenLastCalledWith(1);
250 await act(async () => renderer!.unmount());
251 renderer = undefined;
252 expect(unsubscribe).toHaveBeenCalledOnce();
253 expect(state.badge).toHaveBeenLastCalledWith(0);
254});
255
256it.each(["off", "sound", "focused", "denied", "archived"])(
257 "does not show visual alerts when %s",
258 async (condition) => {
259 if (condition === "off" || condition === "sound") state.mode = condition;
260 if (condition === "focused") focused = true;
261 if (condition === "denied") TestNotification.permission = "denied";
262 await render();
263 complete();
264 if (condition === "archived")
265 state.shells.set(
266 "one",
267 shell({
268 archivedAt: "2026-09-13T08:00:00Z",
269 hasPendingApprovals: true,
270 }),
271 );
272 await render();
273 expect(TestNotification.sent).toHaveLength(0);
274 expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true);
275 },
276);
277
278it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)(
279 "badges %s and clears when notifications are disabled",
280 async (flag) => {
281 await render();
282 state.shells.set("one", shell({ [flag]: true }));
283 await render();
284 expect(state.badge).toHaveBeenLastCalledWith(1);
285 const notification = TestNotification.sent[0]!;
286 notification.dispatchEvent(new Event("click"));
287 expect(state.navigate).toHaveBeenCalledWith({
288 to: "/$environmentId/$threadId",
289 params: { environmentId: EnvironmentId.make("one"), threadId: "thread" },
290 });
291 state.mode = "sound";
292 await render();
293 expect(state.badge).toHaveBeenLastCalledWith(0);
294 expect(notification.close).toHaveBeenCalled();
295 },
296);
297
298it("shows in-app alerts without adding a badge while focused", async () => {
299 state.inApp = true;
300 focused = true;
301 await render();
302 complete();
303 await render();
304 expect(state.toast).toHaveBeenCalledOnce();
305 expect(TestNotification.sent).toHaveLength(0);
306 expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true);
307});
308
309it("badges background failures with in-app notifications enabled", async () => {
310 state.inApp = true;
311 await render();
312 state.shells.set("one", shell({ latestTurn: { ...thread.latestTurn, state: "error" } }));
313 await render();
314 expect(TestNotification.sent[0]?.title).toBe("Thread failed");
315 expect(state.badge).toHaveBeenLastCalledWith(1);
316 expect(state.toast).not.toHaveBeenCalled();
317});