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 @@
11import * as Effect from "effect/Effect";
22
33import * as DesktopIpc from "./DesktopIpc.ts";
4import { installNotificationBadge } from "./methods/notificationBadge.ts";
45import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
56import {
67 clearConnectionCatalog,
......@@ -68,6 +69,7 @@ import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./m
6869
6970export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () {
7071 const ipc = yield* DesktopIpc.DesktopIpc;
72 yield* installNotificationBadge();
7173 yield* PreviewIpc.installPreviewEventForwarding();
7274
7375 yield* ipc.handle(AppActivationIpc.setReady);
apps/desktop/src/ipc/channels.ts+1
......@@ -1,4 +1,5 @@
11export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
2export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge";
23export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon";
34export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
45export 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", {
5757 return result as ReturnType<DesktopBridge["getAppBranding"]>;
5858 },
5959 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 },
6067 getSystemLocale: () => {
6168 const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL);
6269 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(() => ({
2424 close: vi.fn(),
2525 navigate: vi.fn(),
2626 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 }),
2830}));
2931
3032vi.mock("@effect/atom-react", () => ({
......@@ -70,6 +72,7 @@ vi.mock("../state/shell", () => ({
7072vi.mock("../threadNotifications", async (importOriginal) => ({
7173 ...(await importOriginal<typeof import("../threadNotifications")>()),
7274 playNotificationSound: state.sound,
75 setNotificationBadge: vi.fn(),
7376}));
7477vi.mock("./ui/toast", () => ({
7578 toastManager: { add: state.add, close: state.close },
......@@ -108,6 +111,7 @@ beforeEach(() => {
108111 turnError: false,
109112 });
110113 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
114 vi.stubGlobal("window", new EventTarget());
111115 vi.stubGlobal("document", {
112116 get visibilityState() {
113117 return state.visible;
......@@ -188,7 +192,8 @@ describe("thread notifications", () => {
188192 });
189193 });
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;
192197 state.inApp = false;
193198 state.mode = "notifications";
194199 await render();
apps/web/src/components/ThreadNotificationCoordinator.tsx+49-2
......@@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
22import { useNavigate, useParams } from "@tanstack/react-router";
33import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
44import * as Option from "effect/Option";
5import { useEffect, useRef } from "react";
5import { useCallback, useEffect, useRef } from "react";
66
77import { getClientSettings, useClientSettings } from "../hooks/useSettings";
88import { useEnvironments } from "../state/environments";
......@@ -11,6 +11,7 @@ import {
1111 hasDesktopNotifications,
1212 hasNotificationSound,
1313 playNotificationSound,
14 setNotificationBadge,
1415 unlockNotificationAudio,
1516} from "../threadNotifications";
1617import { resolveSidebarThreadStatus } from "./Sidebar.logic";
......@@ -22,6 +23,42 @@ export function ThreadNotificationCoordinator() {
2223 const inAppNotificationsEnabled = useClientSettings(
2324 (settings) => settings.inAppNotificationsEnabled,
2425 );
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
2663 useEffect(() => {
2764 if (!hasNotificationSound(mode)) return;
......@@ -39,11 +76,18 @@ export function ThreadNotificationCoordinator() {
3976 <EnvironmentNotifications
4077 key={environment.environmentId}
4178 environmentId={environment.environmentId}
79 onNotification={onNotification}
4280 />
4381 ));
4482}
4583
46function EnvironmentNotifications({ environmentId }: { environmentId: EnvironmentId }) {
84function EnvironmentNotifications({
85 environmentId,
86 onNotification,
87}: {
88 environmentId: EnvironmentId;
89 onNotification: (environmentId: EnvironmentId, notification: Notification) => void;
90}) {
4791 const shell = useAtomValue(environmentShell.stateValueAtom(environmentId));
4892 const mode = useClientSettings((settings) => settings.notificationMode);
4993 const inAppNotificationsEnabled = useClientSettings(
......@@ -126,6 +170,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen
126170 }
127171 if (
128172 !hasDesktopNotifications(mode) ||
173 (document.visibilityState === "visible" && document.hasFocus()) ||
129174 typeof Notification === "undefined" ||
130175 Notification.permission !== "granted"
131176 )
......@@ -136,6 +181,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen
136181 tag: `${environmentId}:${thread.id}`,
137182 silent: true,
138183 });
184 onNotification(environmentId, notification);
139185 notification.addEventListener("click", () => {
140186 notification.close();
141187 window.focus();
......@@ -156,6 +202,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen
156202 inAppNotificationsEnabled,
157203 mode,
158204 navigate,
205 onNotification,
159206 shell,
160207 ]);
161208
apps/web/src/threadNotifications.ts+45
......@@ -19,6 +19,51 @@ export function hasDesktopNotifications(mode: NotificationMode) {
1919 return mode === "notifications" || mode === "notifications-and-sound";
2020}
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
2267let audioContext: AudioContext | undefined;
2368const buffers = new Map<string, Promise<AudioBuffer>>();
2469
packages/contracts/src/ipc.ts+2
......@@ -1215,6 +1215,8 @@ export interface DesktopBridge {
12151215 getAppBranding: () => DesktopAppBranding | null;
12161216 /** The desktop client's OS platform, read from Electron's preload process. */
12171217 getClientPlatform?: () => string;
1218 setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise<void>;
1219 onNotificationBadgeClear?: (listener: () => void) => () => void;
12181220 /**
12191221 * The OS locale as a BCP-47 tag, which the renderer cannot read for itself:
12201222 * the packaged app ships only the `en-US` Chromium locale pak, so