authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-09-13 20:52:26+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-13 14:52:26-03:00
log6e5e986f15b3cbec3588646ddf216f00b4301785
tree78e10acbc254efdc65fb025052e5ae9ab1a682ba
parentdd6ba84dc96f83000388e14b90f6533d5f63315d
signature Signed by PGP key B5690EEEBB952194

feat: badge background thread notifications on desktop and web (#11569)

Co-authored-by: maria-rcks <maria@kuuro.net>

10 files changed, 587 insertions(+), 4 deletions(-)

apps/desktop/src/ipc/DesktopIpcHandlers.ts+2
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import * as Effect from "effect/Effect";1import * as Effect from "effect/Effect";
22
3import * as DesktopIpc from "./DesktopIpc.ts";3import * as DesktopIpc from "./DesktopIpc.ts";
4import { installNotificationBadge } from "./methods/notificationBadge.ts";
4import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";5import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
5import {6import {
6 clearConnectionCatalog,7 clearConnectionCatalog,
...@@ -68,6 +69,7 @@ import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./m...@@ -68,6 +69,7 @@ import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./m
6869
69export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () {70export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () {
70 const ipc = yield* DesktopIpc.DesktopIpc;71 const ipc = yield* DesktopIpc.DesktopIpc;
72 yield* installNotificationBadge();
71 yield* PreviewIpc.installPreviewEventForwarding();73 yield* PreviewIpc.installPreviewEventForwarding();
7274
73 yield* ipc.handle(AppActivationIpc.setReady);75 yield* ipc.handle(AppActivationIpc.setReady);
apps/desktop/src/ipc/channels.ts+1
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";1export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
2export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge";
2export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon";3export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon";
3export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";4export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
4export const SET_THEME_CHANNEL = "desktop:set-theme";5export const SET_THEME_CHANNEL = "desktop:set-theme";
apps/desktop/src/ipc/methods/notificationBadge.test.ts created+144
...@@ -0,0 +1,144 @@
1import * as Effect from "effect/Effect";
2import { beforeEach, expect, vi } from "vite-plus/test";
3import { it } from "@effect/vitest";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
5
6const native = vi.hoisted(() => ({
7 setBadgeCount: vi.fn(),
8 setOverlayIcon: vi.fn(),
9 isDestroyed: vi.fn(() => false),
10 getFocusedWindow: vi.fn(() => null as object | null),
11 image: { isEmpty: vi.fn(() => false) },
12 createFromDataURL: vi.fn(),
13 webContents: { send: vi.fn() },
14 listeners: new Map<string, () => void>(),
15}));
16vi.mock("electron", () => ({
17 app: {
18 setBadgeCount: native.setBadgeCount,
19 on: (event: string, listener: () => void) => native.listeners.set(event, listener),
20 removeListener: (event: string) => native.listeners.delete(event),
21 },
22 BrowserWindow: {
23 getFocusedWindow: native.getFocusedWindow,
24 getAllWindows: () => [native],
25 },
26 nativeImage: { createFromDataURL: native.createFromDataURL },
27}));
28
29import * as ElectronApp from "../../electron/ElectronApp.ts";
30import * as DesktopIpc from "../DesktopIpc.ts";
31import { applyNotificationBadge, installNotificationBadge } from "./notificationBadge.ts";
32
33const badge = { count: 2, image: "data:image/png;base64,aGVsbG8=" };
34
35beforeEach(() => {
36 vi.clearAllMocks();
37 native.getFocusedWindow.mockReturnValue(null);
38 native.isDestroyed.mockReturnValue(false);
39 native.image.isEmpty.mockReturnValue(false);
40 native.createFromDataURL.mockReturnValue(native.image);
41 native.setBadgeCount.mockImplementation(() => true);
42 native.listeners.clear();
43});
44
45it.each(["darwin", "linux"] as const)("sets and clears the native %s count", (platform) => {
46 applyNotificationBadge(platform, badge);
47 applyNotificationBadge(platform, { count: 0, image: null });
48 expect(native.setBadgeCount.mock.calls).toEqual([[2], [0]]);
49 expect(native.createFromDataURL).not.toHaveBeenCalled();
50});
51
52it("sets and clears the Windows taskbar overlay", () => {
53 applyNotificationBadge("win32", badge);
54 expect(native.setOverlayIcon).toHaveBeenLastCalledWith(
55 native.image,
56 "2 threads with new notifications",
57 );
58 applyNotificationBadge("win32", { count: 0, image: null });
59 expect(native.setOverlayIcon).toHaveBeenLastCalledWith(null, "");
60});
61
62it.each(["win32", "darwin", "linux"] as const)(
63 "rejects a late positive count while %s is focused",
64 (platform) => {
65 native.getFocusedWindow.mockReturnValue({});
66 applyNotificationBadge(platform, badge);
67 if (platform === "win32") expect(native.setOverlayIcon).toHaveBeenCalledWith(null, "");
68 else expect(native.setBadgeCount).toHaveBeenCalledWith(0);
69 expect(native.createFromDataURL).not.toHaveBeenCalled();
70 },
71);
72
73it("ignores destroyed windows and clears invalid images", () => {
74 native.isDestroyed.mockReturnValue(true);
75 applyNotificationBadge("win32", badge);
76 expect(native.setOverlayIcon).not.toHaveBeenCalled();
77 native.isDestroyed.mockReturnValue(false);
78 native.image.isEmpty.mockReturnValue(true);
79 applyNotificationBadge("win32", badge);
80 expect(native.setOverlayIcon.mock.calls[0]?.[0]).toBeNull();
81});
82
83it("keeps notifications working when the native badge API fails", () => {
84 native.setBadgeCount.mockImplementation(() => {
85 throw new Error("Unavailable");
86 });
87 expect(() => applyNotificationBadge("linux", badge)).not.toThrow();
88});
89
90it.effect("validates IPC and clears on native focus, quit, and disposal", () =>
91 Effect.gen(function* () {
92 const handlers = new Map<string, DesktopIpc.DesktopIpcHandleListener>();
93 yield* Effect.scoped(
94 Effect.gen(function* () {
95 yield* installNotificationBadge();
96 const handler = handlers.get("desktop:set-notification-badge")!;
97 const event = { sender: { id: 1 } };
98 for (const invalid of [
99 { ...badge, count: -1 },
100 { ...badge, count: 0.5 },
101 { ...badge, count: Infinity },
102 { ...badge, image: "https://example.com/icon.png" },
103 { ...badge, image: `data:image/png;base64,${"a".repeat(16_384)}` },
104 ]) {
105 yield* Effect.promise(() => expect(handler(event, invalid)).rejects.toBeDefined());
106 }
107 expect(native.setBadgeCount).not.toHaveBeenCalled();
108 yield* Effect.promise(() => Promise.resolve(handler(event, badge)));
109 expect(native.setBadgeCount).toHaveBeenLastCalledWith(2);
110 native.listeners.get("browser-window-focus")!();
111 expect(native.setBadgeCount).toHaveBeenLastCalledWith(0);
112 expect(native.webContents.send).toHaveBeenCalledWith("desktop:set-notification-badge");
113 native.getFocusedWindow.mockReturnValue({});
114 yield* Effect.promise(() => Promise.resolve(handler(event, badge)));
115 expect(native.setBadgeCount).toHaveBeenLastCalledWith(0);
116 expect(native.webContents.send).toHaveBeenCalledTimes(2);
117 yield* Effect.promise(() => Promise.resolve(handler(event, { count: 0, image: null })));
118 expect(native.webContents.send).toHaveBeenCalledTimes(2);
119 native.getFocusedWindow.mockReturnValue(null);
120 yield* Effect.promise(() => Promise.resolve(handler(event, badge)));
121 native.listeners.get("before-quit")!();
122 expect(native.setBadgeCount).toHaveBeenLastCalledWith(0);
123 }),
124 ).pipe(
125 Effect.provideService(HostProcessPlatform, "linux"),
126 Effect.provide([
127 ElectronApp.layer,
128 DesktopIpc.layer({
129 handle: (channel, handler) => {
130 handlers.set(channel, handler);
131 },
132 removeHandler: (channel) => {
133 handlers.delete(channel);
134 },
135 on: vi.fn(),
136 removeAllListeners: vi.fn(),
137 }),
138 ]),
139 );
140 expect(native.setBadgeCount).toHaveBeenLastCalledWith(0);
141 expect(native.listeners.size).toBe(0);
142 expect(handlers.size).toBe(0);
143 }),
144);
apps/desktop/src/ipc/methods/notificationBadge.ts created+71
...@@ -0,0 +1,71 @@
1import * as Electron from "electron";
2import * as Effect from "effect/Effect";
3import * as Schema from "effect/Schema";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
5
6import * as ElectronApp from "../../electron/ElectronApp.ts";
7import * as DesktopIpc from "../DesktopIpc.ts";
8import { SET_NOTIFICATION_BADGE_CHANNEL } from "../channels.ts";
9
10const NotificationBadge = Schema.Struct({
11 count: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 2_147_483_647 })),
12 image: Schema.NullOr(
13 Schema.String.check(
14 Schema.isMaxLength(16_384),
15 Schema.isPattern(/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i),
16 ),
17 ),
18});
19
20export function applyNotificationBadge(
21 platform: NodeJS.Platform,
22 { count, image }: typeof NotificationBadge.Type,
23): void {
24 try {
25 if (Electron.BrowserWindow.getFocusedWindow()) count = 0;
26 if (platform === "win32") {
27 const overlay = count > 0 && image ? Electron.nativeImage.createFromDataURL(image) : null;
28 for (const window of Electron.BrowserWindow.getAllWindows()) {
29 if (!window.isDestroyed()) {
30 window.setOverlayIcon(
31 overlay?.isEmpty() ? null : overlay,
32 count > 0 ? `${count} threads with new notifications` : "",
33 );
34 }
35 }
36 } else if (platform === "darwin" || platform === "linux") {
37 Electron.app.setBadgeCount(count);
38 }
39 } catch (error) {
40 Effect.runSync(Effect.logWarning("Could not update notification badge", error));
41 }
42}
43
44export const installNotificationBadge = Effect.fn("desktop.ipc.installNotificationBadge")(
45 function* () {
46 const ipc = yield* DesktopIpc.DesktopIpc;
47 const app = yield* ElectronApp.ElectronApp;
48 const platform = yield* HostProcessPlatform;
49 const clear = () => {
50 applyNotificationBadge(platform, { count: 0, image: null });
51 for (const window of Electron.BrowserWindow.getAllWindows()) {
52 if (!window.isDestroyed()) window.webContents.send(SET_NOTIFICATION_BADGE_CHANNEL);
53 }
54 };
55 yield* ipc.handle(
56 DesktopIpc.makeIpcMethod({
57 channel: SET_NOTIFICATION_BADGE_CHANNEL,
58 payload: NotificationBadge,
59 result: Schema.Void,
60 handler: (badge) =>
61 Effect.sync(() => {
62 if (badge.count > 0 && Electron.BrowserWindow.getFocusedWindow()) clear();
63 else applyNotificationBadge(platform, badge);
64 }),
65 }),
66 );
67 yield* app.on("browser-window-focus", clear);
68 yield* app.on("before-quit", clear);
69 yield* Effect.addFinalizer(() => Effect.sync(clear));
70 },
71);
apps/desktop/src/preload.ts+7
...@@ -57,6 +57,13 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -57,6 +57,13 @@ contextBridge.exposeInMainWorld("desktopBridge", {
57 return result as ReturnType<DesktopBridge["getAppBranding"]>;57 return result as ReturnType<DesktopBridge["getAppBranding"]>;
58 },58 },
59 getClientPlatform: () => clientPlatform,59 getClientPlatform: () => clientPlatform,
60 setNotificationBadge: (badge) =>
61 ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge),
62 onNotificationBadgeClear: (listener) => {
63 const handler = () => listener();
64 ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler);
65 return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler);
66 },
60 getSystemLocale: () => {67 getSystemLocale: () => {
61 const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL);68 const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL);
62 return typeof result === "string" ? result : null;69 return typeof result === "string" ? result : null;
apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx created+259
...@@ -0,0 +1,259 @@
1import { EnvironmentId } from "@t3tools/contracts";
2import * as Option from "effect/Option";
3import { act } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
6
7const state = vi.hoisted(() => ({
8 mode: "notifications",
9 inApp: false,
10 toast: vi.fn(),
11 shells: new Map(),
12 navigate: vi.fn(),
13 sound: vi.fn(),
14 badge: vi.fn(),
15 environmentIds: ["one", "two"],
16}));
17vi.mock("@effect/atom-react", () => ({ useAtomValue: (id: string) => state.shells.get(id) }));
18vi.mock("@tanstack/react-router", () => ({
19 useNavigate: () => state.navigate,
20 useParams: () => ({}),
21}));
22vi.mock("./ui/toast", () => ({ toastManager: { add: state.toast } }));
23vi.mock("../state/shell", () => ({ environmentShell: { stateValueAtom: (id: string) => id } }));
24vi.mock("../state/environments", () => ({
25 useEnvironments: () => ({
26 environments: state.environmentIds.map((environmentId) => ({ environmentId })),
27 }),
28}));
29vi.mock("../hooks/useSettings", () => ({
30 useClientSettings: (
31 select: (settings: { notificationMode: string; inAppNotificationsEnabled: boolean }) => unknown,
32 ) => select({ notificationMode: state.mode, inAppNotificationsEnabled: state.inApp }),
33 getClientSettings: () => ({ notificationMode: state.mode }),
34}));
35vi.mock("../threadNotifications", async (importOriginal) => ({
36 ...(await importOriginal<typeof import("../threadNotifications")>()),
37 playNotificationSound: state.sound,
38 unlockNotificationAudio: vi.fn(),
39 setNotificationBadge: state.badge,
40}));
41
42import { ThreadNotificationCoordinator } from "./ThreadNotificationCoordinator";
43
44class TestNotification extends EventTarget {
45 static permission = "granted";
46 static sent: TestNotification[] = [];
47 close = vi.fn();
48 get tag() {
49 return this.options.tag ?? "";
50 }
51 constructor(
52 readonly title: string,
53 readonly options: NotificationOptions,
54 ) {
55 super();
56 TestNotification.sent.push(this);
57 }
58}
59
60const thread = {
61 id: "thread",
62 title: "Test thread",
63 archivedAt: null as string | null,
64 hasPendingApprovals: false,
65 hasPendingUserInput: false,
66 session: null,
67 latestTurn: { turnId: "turn", state: "running", completedAt: null as string | null },
68};
69let renderer: ReactTestRenderer | undefined;
70let focused = false;
71let visibility = "visible";
72
73function shell(overrides: Partial<typeof thread> = {}) {
74 return { status: "live", snapshot: Option.some({ threads: [{ ...thread, ...overrides }] }) };
75}
76function complete(environment = "one", completedAt = "2026-09-13T08:00:00Z") {
77 state.shells.set(
78 environment,
79 shell({ latestTurn: { turnId: "turn", state: "completed", completedAt } }),
80 );
81}
82async function render() {
83 await act(async () => {
84 if (renderer) renderer.update(<ThreadNotificationCoordinator />);
85 else renderer = create(<ThreadNotificationCoordinator />);
86 });
87}
88
89beforeEach(() => {
90 vi.clearAllMocks();
91 state.mode = "notifications";
92 state.inApp = false;
93 state.environmentIds = ["one", "two"];
94 state.shells.set("one", shell());
95 state.shells.set("two", shell());
96 focused = false;
97 visibility = "visible";
98 TestNotification.permission = "granted";
99 TestNotification.sent = [];
100 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
101 vi.stubGlobal("Notification", TestNotification);
102 vi.stubGlobal("window", Object.assign(new EventTarget(), { focus: vi.fn() }));
103 vi.stubGlobal(
104 "document",
105 Object.assign(new EventTarget(), {
106 hasFocus: () => focused,
107 get visibilityState() {
108 return visibility;
109 },
110 }),
111 );
112});
113
114afterEach(async () => {
115 await act(async () => renderer?.unmount());
116 renderer = undefined;
117 vi.unstubAllGlobals();
118});
119
120it("counts notifying threads across environments, replaces repeat alerts, and clears on focus", async () => {
121 await render();
122 complete();
123 await render();
124 expect(state.badge).toHaveBeenLastCalledWith(1);
125 complete("one", "2026-09-13T08:01:00Z");
126 complete("two");
127 await render();
128 expect(state.badge).toHaveBeenLastCalledWith(2);
129 expect(TestNotification.sent[0]!.close).toHaveBeenCalledOnce();
130 focused = true;
131 window.dispatchEvent(new Event("focus"));
132 expect(state.badge).toHaveBeenLastCalledWith(0);
133 expect(
134 TestNotification.sent.every((notification) => notification.close.mock.calls.length > 0),
135 ).toBe(true);
136 focused = false;
137 complete("two", "2026-09-13T08:02:00Z");
138 await render();
139 expect(state.badge).toHaveBeenLastCalledWith(1);
140});
141
142it("does not badge old completions on first load or reconnect", async () => {
143 complete();
144 await render();
145 state.shells.set("one", { status: "connecting", snapshot: Option.none() });
146 await render();
147 complete("one", "2026-09-13T08:01:00Z");
148 await render();
149 expect(TestNotification.sent).toHaveLength(0);
150 expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true);
151});
152
153it("removes alerts only from environments that leave the client", async () => {
154 await render();
155 complete("one");
156 complete("two");
157 await render();
158 expect(state.badge).toHaveBeenLastCalledWith(2);
159 const [removed, retained] = TestNotification.sent;
160 state.environmentIds = ["two"];
161 await render();
162 expect(state.badge).toHaveBeenLastCalledWith(1);
163 expect(removed!.close).toHaveBeenCalledOnce();
164 expect(retained!.close).not.toHaveBeenCalled();
165 await render();
166 expect(removed!.close).toHaveBeenCalledOnce();
167 state.environmentIds = [];
168 await render();
169 expect(state.badge).toHaveBeenLastCalledWith(0);
170 expect(retained!.close).toHaveBeenCalledOnce();
171});
172
173it("starts a fresh count after another native app window gains focus", async () => {
174 let clear: (() => void) | undefined;
175 const unsubscribe = vi.fn();
176 Object.assign(window, {
177 desktopBridge: {
178 onNotificationBadgeClear: (listener: () => void) => {
179 clear = listener;
180 return unsubscribe;
181 },
182 },
183 });
184 await render();
185 complete();
186 await render();
187 clear!();
188 expect(state.badge).toHaveBeenLastCalledWith(0);
189 complete("two");
190 await render();
191 expect(state.badge).toHaveBeenLastCalledWith(1);
192 await act(async () => renderer!.unmount());
193 renderer = undefined;
194 expect(unsubscribe).toHaveBeenCalledOnce();
195 expect(state.badge).toHaveBeenLastCalledWith(0);
196});
197
198it.each(["off", "sound", "focused", "denied", "archived"])(
199 "does not show visual alerts when %s",
200 async (condition) => {
201 if (condition === "off" || condition === "sound") state.mode = condition;
202 if (condition === "focused") focused = true;
203 if (condition === "denied") TestNotification.permission = "denied";
204 await render();
205 complete();
206 if (condition === "archived")
207 state.shells.set(
208 "one",
209 shell({
210 archivedAt: "2026-09-13T08:00:00Z",
211 hasPendingApprovals: true,
212 }),
213 );
214 await render();
215 expect(TestNotification.sent).toHaveLength(0);
216 expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true);
217 },
218);
219
220it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)(
221 "badges %s and clears when notifications are disabled",
222 async (flag) => {
223 await render();
224 state.shells.set("one", shell({ [flag]: true }));
225 await render();
226 expect(state.badge).toHaveBeenLastCalledWith(1);
227 const notification = TestNotification.sent[0]!;
228 notification.dispatchEvent(new Event("click"));
229 expect(state.navigate).toHaveBeenCalledWith({
230 to: "/$environmentId/$threadId",
231 params: { environmentId: EnvironmentId.make("one"), threadId: "thread" },
232 });
233 state.mode = "sound";
234 await render();
235 expect(state.badge).toHaveBeenLastCalledWith(0);
236 expect(notification.close).toHaveBeenCalled();
237 },
238);
239
240it("shows in-app alerts without adding a badge while focused", async () => {
241 state.inApp = true;
242 focused = true;
243 await render();
244 complete();
245 await render();
246 expect(state.toast).toHaveBeenCalledOnce();
247 expect(TestNotification.sent).toHaveLength(0);
248 expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true);
249});
250
251it("badges background failures with in-app notifications enabled", async () => {
252 state.inApp = true;
253 await render();
254 state.shells.set("one", shell({ latestTurn: { ...thread.latestTurn, state: "error" } }));
255 await render();
256 expect(TestNotification.sent[0]?.title).toBe("Thread failed");
257 expect(state.badge).toHaveBeenLastCalledWith(1);
258 expect(state.toast).not.toHaveBeenCalled();
259});
apps/web/src/components/ThreadNotificationCoordinator.test.tsx+7-2
...@@ -24,7 +24,9 @@ const state = vi.hoisted(() => ({...@@ -24,7 +24,9 @@ const state = vi.hoisted(() => ({
24 close: vi.fn(),24 close: vi.fn(),
25 navigate: vi.fn(),25 navigate: vi.fn(),
26 sound: vi.fn(),26 sound: vi.fn(),
27 notification: vi.fn(function () {}),27 notification: vi.fn(function (_title: string, options: NotificationOptions) {
28 return Object.assign(new EventTarget(), { tag: options.tag, close: vi.fn() });
29 }),
28}));30}));
2931
30vi.mock("@effect/atom-react", () => ({32vi.mock("@effect/atom-react", () => ({
...@@ -70,6 +72,7 @@ vi.mock("../state/shell", () => ({...@@ -70,6 +72,7 @@ vi.mock("../state/shell", () => ({
70vi.mock("../threadNotifications", async (importOriginal) => ({72vi.mock("../threadNotifications", async (importOriginal) => ({
71 ...(await importOriginal<typeof import("../threadNotifications")>()),73 ...(await importOriginal<typeof import("../threadNotifications")>()),
72 playNotificationSound: state.sound,74 playNotificationSound: state.sound,
75 setNotificationBadge: vi.fn(),
73}));76}));
74vi.mock("./ui/toast", () => ({77vi.mock("./ui/toast", () => ({
75 toastManager: { add: state.add, close: state.close },78 toastManager: { add: state.add, close: state.close },
...@@ -108,6 +111,7 @@ beforeEach(() => {...@@ -108,6 +111,7 @@ beforeEach(() => {
108 turnError: false,111 turnError: false,
109 });112 });
110 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);113 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
114 vi.stubGlobal("window", new EventTarget());
111 vi.stubGlobal("document", {115 vi.stubGlobal("document", {
112 get visibilityState() {116 get visibilityState() {
113 return state.visible;117 return state.visible;
...@@ -188,7 +192,8 @@ describe("thread notifications", () => {...@@ -188,7 +192,8 @@ describe("thread notifications", () => {
188 });192 });
189 });193 });
190194
191 it("keeps desktop alerts when in-app notifications are disabled", async () => {195 it("keeps background desktop alerts when in-app notifications are disabled", async () => {
196 state.focused = false;
192 state.inApp = false;197 state.inApp = false;
193 state.mode = "notifications";198 state.mode = "notifications";
194 await render();199 await render();
apps/web/src/components/ThreadNotificationCoordinator.tsx+49-2
...@@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react";...@@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
2import { useNavigate, useParams } from "@tanstack/react-router";2import { useNavigate, useParams } from "@tanstack/react-router";
3import type { EnvironmentId, ThreadId } from "@t3tools/contracts";3import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
4import * as Option from "effect/Option";4import * as Option from "effect/Option";
5import { useEffect, useRef } from "react";5import { useCallback, useEffect, useRef } from "react";
66
7import { getClientSettings, useClientSettings } from "../hooks/useSettings";7import { getClientSettings, useClientSettings } from "../hooks/useSettings";
8import { useEnvironments } from "../state/environments";8import { useEnvironments } from "../state/environments";
...@@ -11,6 +11,7 @@ import {...@@ -11,6 +11,7 @@ import {
11 hasDesktopNotifications,11 hasDesktopNotifications,
12 hasNotificationSound,12 hasNotificationSound,
13 playNotificationSound,13 playNotificationSound,
14 setNotificationBadge,
14 unlockNotificationAudio,15 unlockNotificationAudio,
15} from "../threadNotifications";16} from "../threadNotifications";
16import { resolveSidebarThreadStatus } from "./Sidebar.logic";17import { resolveSidebarThreadStatus } from "./Sidebar.logic";
...@@ -22,6 +23,42 @@ export function ThreadNotificationCoordinator() {...@@ -22,6 +23,42 @@ export function ThreadNotificationCoordinator() {
22 const inAppNotificationsEnabled = useClientSettings(23 const inAppNotificationsEnabled = useClientSettings(
23 (settings) => settings.inAppNotificationsEnabled,24 (settings) => settings.inAppNotificationsEnabled,
24 );25 );
26 const pending = useRef(
27 new Map<string, { environmentId: EnvironmentId; notification: Notification }>(),
28 );
29 const onNotification = useCallback((environmentId: EnvironmentId, notification: Notification) => {
30 pending.current.get(notification.tag)?.notification.close();
31 pending.current.set(notification.tag, { environmentId, notification });
32 setNotificationBadge(pending.current.size);
33 }, []);
34
35 useEffect(() => {
36 const activeIds = new Set(environments.map(({ environmentId }) => environmentId));
37 const count = pending.current.size;
38 for (const [tag, { environmentId, notification }] of pending.current) {
39 if (activeIds.has(environmentId)) continue;
40 notification.close();
41 pending.current.delete(tag);
42 }
43 if (count !== pending.current.size) setNotificationBadge(pending.current.size);
44 }, [environments]);
45
46 useEffect(() => {
47 const clear = () => {
48 for (const { notification } of pending.current.values()) notification.close();
49 pending.current.clear();
50 setNotificationBadge(0);
51 };
52 clear();
53 if (!hasDesktopNotifications(mode)) return;
54 const unsubscribe = window.desktopBridge?.onNotificationBadgeClear?.(clear);
55 window.addEventListener("focus", clear);
56 return () => {
57 unsubscribe?.();
58 window.removeEventListener("focus", clear);
59 clear();
60 };
61 }, [mode]);
2562
26 useEffect(() => {63 useEffect(() => {
27 if (!hasNotificationSound(mode)) return;64 if (!hasNotificationSound(mode)) return;
...@@ -39,11 +76,18 @@ export function ThreadNotificationCoordinator() {...@@ -39,11 +76,18 @@ export function ThreadNotificationCoordinator() {
39 <EnvironmentNotifications76 <EnvironmentNotifications
40 key={environment.environmentId}77 key={environment.environmentId}
41 environmentId={environment.environmentId}78 environmentId={environment.environmentId}
79 onNotification={onNotification}
42 />80 />
43 ));81 ));
44}82}
4583
46function EnvironmentNotifications({ environmentId }: { environmentId: EnvironmentId }) {84function EnvironmentNotifications({
85 environmentId,
86 onNotification,
87}: {
88 environmentId: EnvironmentId;
89 onNotification: (environmentId: EnvironmentId, notification: Notification) => void;
90}) {
47 const shell = useAtomValue(environmentShell.stateValueAtom(environmentId));91 const shell = useAtomValue(environmentShell.stateValueAtom(environmentId));
48 const mode = useClientSettings((settings) => settings.notificationMode);92 const mode = useClientSettings((settings) => settings.notificationMode);
49 const inAppNotificationsEnabled = useClientSettings(93 const inAppNotificationsEnabled = useClientSettings(
...@@ -126,6 +170,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen...@@ -126,6 +170,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen
126 }170 }
127 if (171 if (
128 !hasDesktopNotifications(mode) ||172 !hasDesktopNotifications(mode) ||
173 (document.visibilityState === "visible" && document.hasFocus()) ||
129 typeof Notification === "undefined" ||174 typeof Notification === "undefined" ||
130 Notification.permission !== "granted"175 Notification.permission !== "granted"
131 )176 )
...@@ -136,6 +181,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen...@@ -136,6 +181,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen
136 tag: `${environmentId}:${thread.id}`,181 tag: `${environmentId}:${thread.id}`,
137 silent: true,182 silent: true,
138 });183 });
184 onNotification(environmentId, notification);
139 notification.addEventListener("click", () => {185 notification.addEventListener("click", () => {
140 notification.close();186 notification.close();
141 window.focus();187 window.focus();
...@@ -156,6 +202,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen...@@ -156,6 +202,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen
156 inAppNotificationsEnabled,202 inAppNotificationsEnabled,
157 mode,203 mode,
158 navigate,204 navigate,
205 onNotification,
159 shell,206 shell,
160 ]);207 ]);
161208
apps/web/src/threadNotifications.ts+45
...@@ -19,6 +19,51 @@ export function hasDesktopNotifications(mode: NotificationMode) {...@@ -19,6 +19,51 @@ export function hasDesktopNotifications(mode: NotificationMode) {
19 return mode === "notifications" || mode === "notifications-and-sound";19 return mode === "notifications" || mode === "notifications-and-sound";
20}20}
2121
22let originalFavicon: HTMLLinkElement | undefined;
23let badgeFavicon: HTMLLinkElement | undefined;
24
25export function setNotificationBadge(count: number) {
26 const bridge = window.desktopBridge;
27 let image: string | null = null;
28 if (count > 0 && (!bridge || bridge.getClientPlatform?.() === "win32")) {
29 const canvas = document.createElement("canvas");
30 canvas.width = canvas.height = 64;
31 const context = canvas.getContext("2d");
32 if (context) {
33 context.fillStyle = "#e5484d";
34 context.beginPath();
35 context.arc(32, 32, 28, 0, Math.PI * 2);
36 context.fill();
37 context.fillStyle = "white";
38 context.font = `600 ${count > 9 ? 30 : 40}px "Segoe UI", sans-serif`;
39 context.textAlign = "center";
40 context.textBaseline = "middle";
41 context.fillText(count > 9 ? "9+" : String(count), 32, 34);
42 image = canvas.toDataURL("image/png");
43 }
44 }
45 if (!bridge) {
46 if (image) {
47 if (!badgeFavicon) {
48 originalFavicon = document.querySelector<HTMLLinkElement>('link[rel="icon"]') ?? undefined;
49 badgeFavicon = document.createElement("link");
50 badgeFavicon.rel = "icon";
51 badgeFavicon.type = "image/png";
52 badgeFavicon.sizes.value = "64x64";
53 originalFavicon?.remove();
54 document.head.append(badgeFavicon);
55 }
56 badgeFavicon.href = image;
57 } else if (badgeFavicon) {
58 badgeFavicon.remove();
59 badgeFavicon = undefined;
60 if (originalFavicon) document.head.append(originalFavicon);
61 originalFavicon = undefined;
62 }
63 }
64 void bridge?.setNotificationBadge?.({ count, image }).catch(() => undefined);
65}
66
22let audioContext: AudioContext | undefined;67let audioContext: AudioContext | undefined;
23const buffers = new Map<string, Promise<AudioBuffer>>();68const buffers = new Map<string, Promise<AudioBuffer>>();
2469
packages/contracts/src/ipc.ts+2
...@@ -1215,6 +1215,8 @@ export interface DesktopBridge {...@@ -1215,6 +1215,8 @@ export interface DesktopBridge {
1215 getAppBranding: () => DesktopAppBranding | null;1215 getAppBranding: () => DesktopAppBranding | null;
1216 /** The desktop client's OS platform, read from Electron's preload process. */1216 /** The desktop client's OS platform, read from Electron's preload process. */
1217 getClientPlatform?: () => string;1217 getClientPlatform?: () => string;
1218 setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise<void>;
1219 onNotificationBadgeClear?: (listener: () => void) => () => void;
1218 /**1220 /**
1219 * The OS locale as a BCP-47 tag, which the renderer cannot read for itself:1221 * The OS locale as a BCP-47 tag, which the renderer cannot read for itself:
1220 * the packaged app ships only the `en-US` Chromium locale pak, so1222 * the packaged app ships only the `en-US` Chromium locale pak, so