From 1b4eb96a18c0155c8fd1f5258487df3f6dd16bc1 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:00:25 +0200 Subject: [PATCH 1/8] feat(desktop): badge background thread notifications --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + .../src/ipc/methods/notificationBadge.test.ts | 137 ++++++++++++ .../src/ipc/methods/notificationBadge.ts | 67 ++++++ apps/desktop/src/preload.ts | 7 + .../ThreadNotificationCoordinator.test.tsx | 203 ++++++++++++++++++ .../ThreadNotificationCoordinator.tsx | 42 +++- apps/web/src/threadNotifications.ts | 24 +++ packages/contracts/src/ipc.ts | 2 + 9 files changed, 481 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/ipc/methods/notificationBadge.test.ts create mode 100644 apps/desktop/src/ipc/methods/notificationBadge.ts create mode 100644 apps/web/src/components/ThreadNotificationCoordinator.test.tsx diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index c1eba805c2b6..6f9bac7333f4 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -1,6 +1,7 @@ import * as Effect from "effect/Effect"; import * as DesktopIpc from "./DesktopIpc.ts"; +import { installNotificationBadge } from "./methods/notificationBadge.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; import { clearConnectionCatalog, @@ -68,6 +69,7 @@ import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./m export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; + yield* installNotificationBadge(); yield* PreviewIpc.installPreviewEventForwarding(); yield* ipc.handle(AppActivationIpc.setReady); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index ca6bbd30b3e4..7106c45af8e8 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,4 +1,5 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; +export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge"; export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; export const SET_THEME_CHANNEL = "desktop:set-theme"; diff --git a/apps/desktop/src/ipc/methods/notificationBadge.test.ts b/apps/desktop/src/ipc/methods/notificationBadge.test.ts new file mode 100644 index 000000000000..c9d9464961ad --- /dev/null +++ b/apps/desktop/src/ipc/methods/notificationBadge.test.ts @@ -0,0 +1,137 @@ +import * as Effect from "effect/Effect"; +import { beforeEach, expect, vi } from "vite-plus/test"; +import { it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +const native = vi.hoisted(() => ({ + setBadgeCount: vi.fn(), + setOverlayIcon: vi.fn(), + isDestroyed: vi.fn(() => false), + getFocusedWindow: vi.fn(() => null as object | null), + image: { isEmpty: vi.fn(() => false) }, + createFromDataURL: vi.fn(), + webContents: { send: vi.fn() }, + listeners: new Map void>(), +})); +vi.mock("electron", () => ({ + app: { + setBadgeCount: native.setBadgeCount, + on: (event: string, listener: () => void) => native.listeners.set(event, listener), + removeListener: (event: string) => native.listeners.delete(event), + }, + BrowserWindow: { + getFocusedWindow: native.getFocusedWindow, + getAllWindows: () => [native], + }, + nativeImage: { createFromDataURL: native.createFromDataURL }, +})); + +import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import { applyNotificationBadge, installNotificationBadge } from "./notificationBadge.ts"; + +const badge = { count: 2, image: "data:image/png;base64,aGVsbG8=" }; + +beforeEach(() => { + vi.clearAllMocks(); + native.getFocusedWindow.mockReturnValue(null); + native.isDestroyed.mockReturnValue(false); + native.image.isEmpty.mockReturnValue(false); + native.createFromDataURL.mockReturnValue(native.image); + native.setBadgeCount.mockImplementation(() => true); + native.listeners.clear(); +}); + +it.each(["darwin", "linux"] as const)("sets and clears the native %s count", (platform) => { + applyNotificationBadge(platform, badge); + applyNotificationBadge(platform, { count: 0, image: null }); + expect(native.setBadgeCount.mock.calls).toEqual([[2], [0]]); + expect(native.createFromDataURL).not.toHaveBeenCalled(); +}); + +it("sets and clears the Windows taskbar overlay", () => { + applyNotificationBadge("win32", badge); + expect(native.setOverlayIcon).toHaveBeenLastCalledWith( + native.image, + "2 threads with new notifications", + ); + applyNotificationBadge("win32", { count: 0, image: null }); + expect(native.setOverlayIcon).toHaveBeenLastCalledWith(null, ""); +}); + +it.each(["win32", "darwin", "linux"] as const)( + "rejects a late positive count while %s is focused", + (platform) => { + native.getFocusedWindow.mockReturnValue({}); + applyNotificationBadge(platform, badge); + if (platform === "win32") expect(native.setOverlayIcon).toHaveBeenCalledWith(null, ""); + else expect(native.setBadgeCount).toHaveBeenCalledWith(0); + expect(native.createFromDataURL).not.toHaveBeenCalled(); + }, +); + +it("ignores destroyed windows and clears invalid images", () => { + native.isDestroyed.mockReturnValue(true); + applyNotificationBadge("win32", badge); + expect(native.setOverlayIcon).not.toHaveBeenCalled(); + native.isDestroyed.mockReturnValue(false); + native.image.isEmpty.mockReturnValue(true); + applyNotificationBadge("win32", badge); + expect(native.setOverlayIcon.mock.calls[0]?.[0]).toBeNull(); +}); + +it("keeps notifications working when the native badge API fails", () => { + native.setBadgeCount.mockImplementation(() => { + throw new Error("Unavailable"); + }); + expect(() => applyNotificationBadge("linux", badge)).not.toThrow(); +}); + +it.effect("validates IPC and clears on native focus, quit, and disposal", () => + Effect.gen(function* () { + const handlers = new Map(); + yield* Effect.scoped( + Effect.gen(function* () { + yield* installNotificationBadge(); + const handler = handlers.get("desktop:set-notification-badge")!; + const event = { sender: { id: 1 } }; + for (const invalid of [ + { ...badge, count: -1 }, + { ...badge, count: 0.5 }, + { ...badge, count: Infinity }, + { ...badge, image: "https://example.com/icon.png" }, + { ...badge, image: `data:image/png;base64,${"a".repeat(16_384)}` }, + ]) { + yield* Effect.promise(() => expect(handler(event, invalid)).rejects.toBeDefined()); + } + expect(native.setBadgeCount).not.toHaveBeenCalled(); + yield* Effect.promise(() => Promise.resolve(handler(event, badge))); + expect(native.setBadgeCount).toHaveBeenLastCalledWith(2); + native.listeners.get("browser-window-focus")!(); + expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); + expect(native.webContents.send).toHaveBeenCalledWith("desktop:set-notification-badge"); + yield* Effect.promise(() => Promise.resolve(handler(event, badge))); + native.listeners.get("before-quit")!(); + expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); + }), + ).pipe( + Effect.provideService(HostProcessPlatform, "linux"), + Effect.provide([ + ElectronApp.layer, + DesktopIpc.layer({ + handle: (channel, handler) => { + handlers.set(channel, handler); + }, + removeHandler: (channel) => { + handlers.delete(channel); + }, + on: vi.fn(), + removeAllListeners: vi.fn(), + }), + ]), + ); + expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); + expect(native.listeners.size).toBe(0); + expect(handlers.size).toBe(0); + }), +); diff --git a/apps/desktop/src/ipc/methods/notificationBadge.ts b/apps/desktop/src/ipc/methods/notificationBadge.ts new file mode 100644 index 000000000000..a5fcc43d95e3 --- /dev/null +++ b/apps/desktop/src/ipc/methods/notificationBadge.ts @@ -0,0 +1,67 @@ +import * as Electron from "electron"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as ElectronApp from "../../electron/ElectronApp.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import { SET_NOTIFICATION_BADGE_CHANNEL } from "../channels.ts"; + +const NotificationBadge = Schema.Struct({ + count: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 2_147_483_647 })), + image: Schema.NullOr( + Schema.String.check( + Schema.isMaxLength(16_384), + Schema.isPattern(/^data:image\/png;base64,[a-z0-9+/]+={0,2}$/i), + ), + ), +}); + +export function applyNotificationBadge( + platform: NodeJS.Platform, + { count, image }: typeof NotificationBadge.Type, +): void { + try { + if (Electron.BrowserWindow.getFocusedWindow()) count = 0; + if (platform === "win32") { + const overlay = count > 0 && image ? Electron.nativeImage.createFromDataURL(image) : null; + for (const window of Electron.BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) { + window.setOverlayIcon( + overlay?.isEmpty() ? null : overlay, + count > 0 ? `${count} threads with new notifications` : "", + ); + } + } + } else if (platform === "darwin" || platform === "linux") { + Electron.app.setBadgeCount(count); + } + } catch (error) { + Effect.runSync(Effect.logWarning("Could not update notification badge", error)); + } +} + +export const installNotificationBadge = Effect.fn("desktop.ipc.installNotificationBadge")( + function* () { + const ipc = yield* DesktopIpc.DesktopIpc; + const app = yield* ElectronApp.ElectronApp; + const platform = yield* HostProcessPlatform; + const clear = () => applyNotificationBadge(platform, { count: 0, image: null }); + yield* ipc.handle( + DesktopIpc.makeIpcMethod({ + channel: SET_NOTIFICATION_BADGE_CHANNEL, + payload: NotificationBadge, + result: Schema.Void, + handler: (badge) => Effect.sync(() => applyNotificationBadge(platform, badge)), + }), + ); + yield* app.on("browser-window-focus", () => { + clear(); + for (const window of Electron.BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) window.webContents.send(SET_NOTIFICATION_BADGE_CHANNEL); + } + }); + yield* app.on("before-quit", clear); + yield* Effect.addFinalizer(() => Effect.sync(clear)); + }, +); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d4edb7818180..4c9a8199de68 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -57,6 +57,13 @@ contextBridge.exposeInMainWorld("desktopBridge", { return result as ReturnType; }, getClientPlatform: () => clientPlatform, + setNotificationBadge: (badge) => + ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge), + onNotificationBadgeClear: (listener) => { + const handler = () => listener(); + ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); + return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); + }, getSystemLocale: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); return typeof result === "string" ? result : null; diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx new file mode 100644 index 000000000000..8f79f4d32aad --- /dev/null +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -0,0 +1,203 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; + +const state = vi.hoisted(() => ({ + mode: "notifications", + shells: new Map(), + navigate: vi.fn(), + sound: vi.fn(), + badge: vi.fn(), +})); +vi.mock("@effect/atom-react", () => ({ useAtomValue: (id: string) => state.shells.get(id) })); +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate })); +vi.mock("../state/shell", () => ({ environmentShell: { stateValueAtom: (id: string) => id } })); +vi.mock("../state/environments", () => ({ + useEnvironments: () => ({ environments: [{ environmentId: "one" }, { environmentId: "two" }] }), +})); +vi.mock("../hooks/useSettings", () => ({ + useClientSettings: (select: (settings: { notificationMode: string }) => unknown) => + select({ notificationMode: state.mode }), + getClientSettings: () => ({ notificationMode: state.mode }), +})); +vi.mock("../threadNotifications", async (importOriginal) => ({ + ...(await importOriginal()), + playNotificationSound: state.sound, + unlockNotificationAudio: vi.fn(), + setNotificationBadge: state.badge, +})); + +import { ThreadNotificationCoordinator } from "./ThreadNotificationCoordinator"; + +class TestNotification extends EventTarget { + static permission = "granted"; + static sent: TestNotification[] = []; + close = vi.fn(); + constructor( + readonly title: string, + readonly options: NotificationOptions, + ) { + super(); + TestNotification.sent.push(this); + } +} + +const thread = { + id: "thread", + title: "Test thread", + archivedAt: null as string | null, + hasPendingApprovals: false, + hasPendingUserInput: false, + session: null, + latestTurn: { turnId: "turn", state: "running", completedAt: null as string | null }, +}; +let renderer: ReactTestRenderer | undefined; +let focused = false; +let visibility = "visible"; + +function shell(overrides: Partial = {}) { + return { status: "live", snapshot: Option.some({ threads: [{ ...thread, ...overrides }] }) }; +} +function complete(environment = "one", completedAt = "2026-09-13T08:00:00Z") { + state.shells.set( + environment, + shell({ latestTurn: { turnId: "turn", state: "completed", completedAt } }), + ); +} +async function render() { + await act(async () => { + if (renderer) renderer.update(); + else renderer = create(); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + state.mode = "notifications"; + state.shells.set("one", shell()); + state.shells.set("two", shell()); + focused = false; + visibility = "visible"; + TestNotification.permission = "granted"; + TestNotification.sent = []; + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("Notification", TestNotification); + vi.stubGlobal("window", Object.assign(new EventTarget(), { focus: vi.fn() })); + vi.stubGlobal( + "document", + Object.assign(new EventTarget(), { + hasFocus: () => focused, + get visibilityState() { + return visibility; + }, + }), + ); +}); + +afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +it("counts notifying threads across environments, replaces repeat alerts, and clears on focus", async () => { + await render(); + complete(); + await render(); + expect(state.badge).toHaveBeenLastCalledWith(1); + complete("one", "2026-09-13T08:01:00Z"); + complete("two"); + await render(); + expect(state.badge).toHaveBeenLastCalledWith(2); + expect(TestNotification.sent[0]!.close).toHaveBeenCalledOnce(); + focused = true; + window.dispatchEvent(new Event("focus")); + expect(state.badge).toHaveBeenLastCalledWith(0); + expect( + TestNotification.sent.every((notification) => notification.close.mock.calls.length > 0), + ).toBe(true); + focused = false; + complete("two", "2026-09-13T08:02:00Z"); + await render(); + expect(state.badge).toHaveBeenLastCalledWith(1); +}); + +it("does not badge old completions on first load or reconnect", async () => { + complete(); + await render(); + state.shells.set("one", { status: "connecting", snapshot: Option.none() }); + await render(); + complete("one", "2026-09-13T08:01:00Z"); + await render(); + expect(TestNotification.sent).toHaveLength(0); + expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true); +}); + +it("starts a fresh count after another native app window gains focus", async () => { + let clear: (() => void) | undefined; + const unsubscribe = vi.fn(); + Object.assign(window, { + desktopBridge: { + onNotificationBadgeClear: (listener: () => void) => { + clear = listener; + return unsubscribe; + }, + }, + }); + await render(); + complete(); + await render(); + clear!(); + expect(state.badge).toHaveBeenLastCalledWith(0); + complete("two"); + await render(); + expect(state.badge).toHaveBeenLastCalledWith(1); + await act(async () => renderer!.unmount()); + renderer = undefined; + expect(unsubscribe).toHaveBeenCalledOnce(); + expect(state.badge).toHaveBeenLastCalledWith(0); +}); + +it.each(["off", "sound", "focused", "denied", "archived"])( + "does not show visual alerts when %s", + async (condition) => { + if (condition === "off" || condition === "sound") state.mode = condition; + if (condition === "focused") focused = true; + if (condition === "denied") TestNotification.permission = "denied"; + await render(); + complete(); + if (condition === "archived") + state.shells.set( + "one", + shell({ + archivedAt: "2026-09-13T08:00:00Z", + hasPendingApprovals: true, + }), + ); + await render(); + expect(TestNotification.sent).toHaveLength(0); + expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true); + }, +); + +it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( + "badges %s and clears when notifications are disabled", + async (flag) => { + await render(); + state.shells.set("one", shell({ [flag]: true })); + await render(); + expect(state.badge).toHaveBeenLastCalledWith(1); + const notification = TestNotification.sent[0]!; + notification.dispatchEvent(new Event("click")); + expect(state.navigate).toHaveBeenCalledWith({ + to: "/$environmentId/$threadId", + params: { environmentId: EnvironmentId.make("one"), threadId: "thread" }, + }); + state.mode = "sound"; + await render(); + expect(state.badge).toHaveBeenLastCalledWith(0); + expect(notification.close).toHaveBeenCalled(); + }, +); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index e89175a77808..65ab813a6abd 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; import { useNavigate } from "@tanstack/react-router"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; import * as Option from "effect/Option"; -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; import { useEnvironments } from "../state/environments"; @@ -11,6 +11,7 @@ import { hasDesktopNotifications, hasNotificationSound, playNotificationSound, + setNotificationBadge, unlockNotificationAudio, } from "../threadNotifications"; import { resolveSidebarThreadStatus } from "./Sidebar.logic"; @@ -18,6 +19,29 @@ import { resolveSidebarThreadStatus } from "./Sidebar.logic"; export function ThreadNotificationCoordinator() { const { environments } = useEnvironments(); const mode = useClientSettings((settings) => settings.notificationMode); + const pending = useRef(new Map()); + const onNotification = useCallback((tag: string, notification: Notification) => { + pending.current.get(tag)?.close(); + pending.current.set(tag, notification); + setNotificationBadge(pending.current.size); + }, []); + + useEffect(() => { + const clear = () => { + for (const notification of pending.current.values()) notification.close(); + pending.current.clear(); + setNotificationBadge(0); + }; + clear(); + if (!hasDesktopNotifications(mode)) return; + const unsubscribe = window.desktopBridge?.onNotificationBadgeClear?.(clear); + window.addEventListener("focus", clear); + return () => { + unsubscribe?.(); + window.removeEventListener("focus", clear); + clear(); + }; + }, [mode]); useEffect(() => { if (!hasNotificationSound(mode)) return; @@ -35,11 +59,18 @@ export function ThreadNotificationCoordinator() { )); } -function EnvironmentNotifications({ environmentId }: { environmentId: EnvironmentId }) { +function EnvironmentNotifications({ + environmentId, + onNotification, +}: { + environmentId: EnvironmentId; + onNotification: (tag: string, notification: Notification) => void; +}) { const shell = useAtomValue(environmentShell.stateValueAtom(environmentId)); const mode = useClientSettings((settings) => settings.notificationMode); const navigate = useNavigate(); @@ -81,19 +112,22 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen } if ( !hasDesktopNotifications(mode) || + (document.visibilityState === "visible" && document.hasFocus()) || typeof Notification === "undefined" || Notification.permission !== "granted" ) continue; try { + const tag = `${environmentId}:${thread.id}`; const notification = new Notification( kind === "completion" ? "Thread completed" : status === "approval" ? "Approval needed" : "Input needed", - { body: thread.title, tag: `${environmentId}:${thread.id}`, silent: true }, + { body: thread.title, tag, silent: true }, ); + onNotification(tag, notification); notification.addEventListener("click", () => { notification.close(); window.focus(); @@ -107,7 +141,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen } } previous.current = next; - }, [environmentId, mode, navigate, shell]); + }, [environmentId, mode, navigate, onNotification, shell]); return null; } diff --git a/apps/web/src/threadNotifications.ts b/apps/web/src/threadNotifications.ts index 8050411382aa..2b1483113562 100644 --- a/apps/web/src/threadNotifications.ts +++ b/apps/web/src/threadNotifications.ts @@ -19,6 +19,30 @@ export function hasDesktopNotifications(mode: NotificationMode) { return mode === "notifications" || mode === "notifications-and-sound"; } +export function setNotificationBadge(count: number) { + const bridge = window.desktopBridge; + if (!bridge?.setNotificationBadge) return; + let image: string | null = null; + if (count > 0 && bridge.getClientPlatform?.() === "win32") { + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 64; + const context = canvas.getContext("2d"); + if (context) { + context.fillStyle = "#e5484d"; + context.beginPath(); + context.arc(32, 32, 28, 0, Math.PI * 2); + context.fill(); + context.fillStyle = "white"; + context.font = `600 ${count > 9 ? 30 : 40}px "Segoe UI", sans-serif`; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(count > 9 ? "9+" : String(count), 32, 34); + image = canvas.toDataURL("image/png"); + } + } + void bridge.setNotificationBadge({ count, image }).catch(() => undefined); +} + let audioContext: AudioContext | undefined; const buffers = new Map>(); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a223ef5feb94..cebe6ae03c06 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1215,6 +1215,8 @@ export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; /** The desktop client's OS platform, read from Electron's preload process. */ getClientPlatform?: () => string; + setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise; + onNotificationBadgeClear?: (listener: () => void) => () => void; /** * The OS locale as a BCP-47 tag, which the renderer cannot read for itself: * the packaged app ships only the `en-US` Chromium locale pak, so From c0fc76f4531cc7b1f5361349a9cbea7b53d81093 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:20:40 +0200 Subject: [PATCH 2/8] fix(desktop): discard badges while another app window is focused --- .../src/ipc/methods/notificationBadge.test.ts | 7 +++++++ .../src/ipc/methods/notificationBadge.ts | 20 +++++++++++-------- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/ipc/methods/notificationBadge.test.ts b/apps/desktop/src/ipc/methods/notificationBadge.test.ts index c9d9464961ad..a47732550f64 100644 --- a/apps/desktop/src/ipc/methods/notificationBadge.test.ts +++ b/apps/desktop/src/ipc/methods/notificationBadge.test.ts @@ -110,6 +110,13 @@ it.effect("validates IPC and clears on native focus, quit, and disposal", () => native.listeners.get("browser-window-focus")!(); expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); expect(native.webContents.send).toHaveBeenCalledWith("desktop:set-notification-badge"); + native.getFocusedWindow.mockReturnValue({}); + yield* Effect.promise(() => Promise.resolve(handler(event, badge))); + expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); + expect(native.webContents.send).toHaveBeenCalledTimes(2); + yield* Effect.promise(() => Promise.resolve(handler(event, { count: 0, image: null }))); + expect(native.webContents.send).toHaveBeenCalledTimes(2); + native.getFocusedWindow.mockReturnValue(null); yield* Effect.promise(() => Promise.resolve(handler(event, badge))); native.listeners.get("before-quit")!(); expect(native.setBadgeCount).toHaveBeenLastCalledWith(0); diff --git a/apps/desktop/src/ipc/methods/notificationBadge.ts b/apps/desktop/src/ipc/methods/notificationBadge.ts index a5fcc43d95e3..40e4549138f2 100644 --- a/apps/desktop/src/ipc/methods/notificationBadge.ts +++ b/apps/desktop/src/ipc/methods/notificationBadge.ts @@ -46,21 +46,25 @@ export const installNotificationBadge = Effect.fn("desktop.ipc.installNotificati const ipc = yield* DesktopIpc.DesktopIpc; const app = yield* ElectronApp.ElectronApp; const platform = yield* HostProcessPlatform; - const clear = () => applyNotificationBadge(platform, { count: 0, image: null }); + const clear = () => { + applyNotificationBadge(platform, { count: 0, image: null }); + for (const window of Electron.BrowserWindow.getAllWindows()) { + if (!window.isDestroyed()) window.webContents.send(SET_NOTIFICATION_BADGE_CHANNEL); + } + }; yield* ipc.handle( DesktopIpc.makeIpcMethod({ channel: SET_NOTIFICATION_BADGE_CHANNEL, payload: NotificationBadge, result: Schema.Void, - handler: (badge) => Effect.sync(() => applyNotificationBadge(platform, badge)), + handler: (badge) => + Effect.sync(() => { + if (badge.count > 0 && Electron.BrowserWindow.getFocusedWindow()) clear(); + else applyNotificationBadge(platform, badge); + }), }), ); - yield* app.on("browser-window-focus", () => { - clear(); - for (const window of Electron.BrowserWindow.getAllWindows()) { - if (!window.isDestroyed()) window.webContents.send(SET_NOTIFICATION_BADGE_CHANNEL); - } - }); + yield* app.on("browser-window-focus", clear); yield* app.on("before-quit", clear); yield* Effect.addFinalizer(() => Effect.sync(clear)); }, From 82a98c913e8af5bea4822fc30f1f0f6db26ef1a1 Mon Sep 17 00:00:00 2001 From: Bil0000 <62337003+Bil0000@users.noreply.github.com> Date: Sun, 13 Sep 2026 11:27:18 +0200 Subject: [PATCH 3/8] fix(web): clear badge alerts for removed environments --- .../ThreadNotificationCoordinator.test.tsx | 29 ++++++++++++++++++- .../ThreadNotificationCoordinator.tsx | 27 ++++++++++++----- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx index 8f79f4d32aad..f6b5d77e6329 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -10,12 +10,15 @@ const state = vi.hoisted(() => ({ navigate: vi.fn(), sound: vi.fn(), badge: vi.fn(), + environmentIds: ["one", "two"], })); vi.mock("@effect/atom-react", () => ({ useAtomValue: (id: string) => state.shells.get(id) })); vi.mock("@tanstack/react-router", () => ({ useNavigate: () => state.navigate })); vi.mock("../state/shell", () => ({ environmentShell: { stateValueAtom: (id: string) => id } })); vi.mock("../state/environments", () => ({ - useEnvironments: () => ({ environments: [{ environmentId: "one" }, { environmentId: "two" }] }), + useEnvironments: () => ({ + environments: state.environmentIds.map((environmentId) => ({ environmentId })), + }), })); vi.mock("../hooks/useSettings", () => ({ useClientSettings: (select: (settings: { notificationMode: string }) => unknown) => @@ -35,6 +38,9 @@ class TestNotification extends EventTarget { static permission = "granted"; static sent: TestNotification[] = []; close = vi.fn(); + get tag() { + return this.options.tag ?? ""; + } constructor( readonly title: string, readonly options: NotificationOptions, @@ -76,6 +82,7 @@ async function render() { beforeEach(() => { vi.clearAllMocks(); state.mode = "notifications"; + state.environmentIds = ["one", "two"]; state.shells.set("one", shell()); state.shells.set("two", shell()); focused = false; @@ -135,6 +142,26 @@ it("does not badge old completions on first load or reconnect", async () => { expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true); }); +it("removes alerts only from environments that leave the client", async () => { + await render(); + complete("one"); + complete("two"); + await render(); + expect(state.badge).toHaveBeenLastCalledWith(2); + const [removed, retained] = TestNotification.sent; + state.environmentIds = ["two"]; + await render(); + expect(state.badge).toHaveBeenLastCalledWith(1); + expect(removed!.close).toHaveBeenCalledOnce(); + expect(retained!.close).not.toHaveBeenCalled(); + await render(); + expect(removed!.close).toHaveBeenCalledOnce(); + state.environmentIds = []; + await render(); + expect(state.badge).toHaveBeenLastCalledWith(0); + expect(retained!.close).toHaveBeenCalledOnce(); +}); + it("starts a fresh count after another native app window gains focus", async () => { let clear: (() => void) | undefined; const unsubscribe = vi.fn(); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 65ab813a6abd..ec42f08f30a8 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -19,16 +19,29 @@ import { resolveSidebarThreadStatus } from "./Sidebar.logic"; export function ThreadNotificationCoordinator() { const { environments } = useEnvironments(); const mode = useClientSettings((settings) => settings.notificationMode); - const pending = useRef(new Map()); - const onNotification = useCallback((tag: string, notification: Notification) => { - pending.current.get(tag)?.close(); - pending.current.set(tag, notification); + const pending = useRef( + new Map(), + ); + const onNotification = useCallback((environmentId: EnvironmentId, notification: Notification) => { + pending.current.get(notification.tag)?.notification.close(); + pending.current.set(notification.tag, { environmentId, notification }); setNotificationBadge(pending.current.size); }, []); + useEffect(() => { + const activeIds = new Set(environments.map(({ environmentId }) => environmentId)); + const count = pending.current.size; + for (const [tag, { environmentId, notification }] of pending.current) { + if (activeIds.has(environmentId)) continue; + notification.close(); + pending.current.delete(tag); + } + if (count !== pending.current.size) setNotificationBadge(pending.current.size); + }, [environments]); + useEffect(() => { const clear = () => { - for (const notification of pending.current.values()) notification.close(); + for (const { notification } of pending.current.values()) notification.close(); pending.current.clear(); setNotificationBadge(0); }; @@ -69,7 +82,7 @@ function EnvironmentNotifications({ onNotification, }: { environmentId: EnvironmentId; - onNotification: (tag: string, notification: Notification) => void; + onNotification: (environmentId: EnvironmentId, notification: Notification) => void; }) { const shell = useAtomValue(environmentShell.stateValueAtom(environmentId)); const mode = useClientSettings((settings) => settings.notificationMode); @@ -127,7 +140,7 @@ function EnvironmentNotifications({ : "Input needed", { body: thread.title, tag, silent: true }, ); - onNotification(tag, notification); + onNotification(environmentId, notification); notification.addEventListener("click", () => { notification.close(); window.focus(); From c1ac3c1b3892ef97a0cf6191fdd373cdd606fed6 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 13 Sep 2026 17:08:44 +0000 Subject: [PATCH 4/8] feat(web): show pending notification count in favicon --- apps/web/src/threadNotifications.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/web/src/threadNotifications.ts b/apps/web/src/threadNotifications.ts index 2b1483113562..2f9d45497668 100644 --- a/apps/web/src/threadNotifications.ts +++ b/apps/web/src/threadNotifications.ts @@ -19,11 +19,13 @@ export function hasDesktopNotifications(mode: NotificationMode) { return mode === "notifications" || mode === "notifications-and-sound"; } +let originalFavicon: HTMLLinkElement | undefined; +let badgeFavicon: HTMLLinkElement | undefined; + export function setNotificationBadge(count: number) { const bridge = window.desktopBridge; - if (!bridge?.setNotificationBadge) return; let image: string | null = null; - if (count > 0 && bridge.getClientPlatform?.() === "win32") { + if (count > 0 && (!bridge || bridge.getClientPlatform?.() === "win32")) { const canvas = document.createElement("canvas"); canvas.width = canvas.height = 64; const context = canvas.getContext("2d"); @@ -40,7 +42,26 @@ export function setNotificationBadge(count: number) { image = canvas.toDataURL("image/png"); } } - void bridge.setNotificationBadge({ count, image }).catch(() => undefined); + if (!bridge) { + if (image) { + if (!badgeFavicon) { + originalFavicon = document.querySelector('link[rel="icon"]') ?? undefined; + badgeFavicon = document.createElement("link"); + badgeFavicon.rel = "icon"; + badgeFavicon.type = "image/png"; + badgeFavicon.sizes.value = "64x64"; + originalFavicon?.remove(); + document.head.append(badgeFavicon); + } + badgeFavicon.href = image; + } else if (badgeFavicon) { + badgeFavicon.remove(); + badgeFavicon = undefined; + if (originalFavicon) document.head.append(originalFavicon); + originalFavicon = undefined; + } + } + void bridge?.setNotificationBadge?.({ count, image }).catch(() => undefined); } let audioContext: AudioContext | undefined; From 5055274a033cf6099ec541045f7b8cdcc8842502 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 13 Sep 2026 17:16:20 +0000 Subject: [PATCH 5/8] fix(web): overlay notification count on original favicon --- apps/web/src/threadNotifications.ts | 93 ++++++++++++++++++++--------- 1 file changed, 64 insertions(+), 29 deletions(-) diff --git a/apps/web/src/threadNotifications.ts b/apps/web/src/threadNotifications.ts index 2f9d45497668..2e6d6b772a5f 100644 --- a/apps/web/src/threadNotifications.ts +++ b/apps/web/src/threadNotifications.ts @@ -21,46 +21,81 @@ export function hasDesktopNotifications(mode: NotificationMode) { let originalFavicon: HTMLLinkElement | undefined; let badgeFavicon: HTMLLinkElement | undefined; +let faviconImage: Promise | undefined; +let faviconRevision = 0; + +function drawNotificationBadge(context: CanvasRenderingContext2D, count: number) { + context.fillStyle = "#e5484d"; + context.beginPath(); + context.arc(32, 32, 28, 0, Math.PI * 2); + context.fill(); + context.fillStyle = "white"; + context.font = `600 ${count > 9 ? 30 : 40}px "Segoe UI", sans-serif`; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(count > 9 ? "9+" : String(count), 32, 34); +} + +async function setFaviconBadge(count: number) { + const revision = ++faviconRevision; + if (count <= 0) { + badgeFavicon?.remove(); + badgeFavicon = undefined; + if (originalFavicon && !originalFavicon.isConnected) document.head.append(originalFavicon); + originalFavicon = undefined; + faviconImage = undefined; + return; + } + originalFavicon ??= document.querySelector('link[rel="icon"]') ?? undefined; + if (!originalFavicon) return; + if (!faviconImage) { + const image = new Image(); + image.src = originalFavicon.href; + faviconImage = image.decode().then(() => image); + } + try { + const image = await faviconImage; + if (revision !== faviconRevision) return; + const canvas = document.createElement("canvas"); + canvas.width = canvas.height = 64; + const context = canvas.getContext("2d"); + if (!context) return; + context.drawImage(image, 0, 0, 64, 64); + context.translate(32, 0); + context.scale(0.5, 0.5); + drawNotificationBadge(context, count); + const href = canvas.toDataURL("image/png"); + if (!badgeFavicon) { + badgeFavicon = document.createElement("link"); + badgeFavicon.rel = "icon"; + badgeFavicon.type = "image/png"; + badgeFavicon.sizes.value = "64x64"; + originalFavicon?.remove(); + document.head.append(badgeFavicon); + } + badgeFavicon.href = href; + } catch { + // Keep the original icon if its image cannot be loaded or drawn. + if (revision === faviconRevision) faviconImage = undefined; + } +} export function setNotificationBadge(count: number) { const bridge = window.desktopBridge; + if (!bridge) { + void setFaviconBadge(count); + return; + } let image: string | null = null; - if (count > 0 && (!bridge || bridge.getClientPlatform?.() === "win32")) { + if (count > 0 && bridge.getClientPlatform?.() === "win32") { const canvas = document.createElement("canvas"); canvas.width = canvas.height = 64; const context = canvas.getContext("2d"); if (context) { - context.fillStyle = "#e5484d"; - context.beginPath(); - context.arc(32, 32, 28, 0, Math.PI * 2); - context.fill(); - context.fillStyle = "white"; - context.font = `600 ${count > 9 ? 30 : 40}px "Segoe UI", sans-serif`; - context.textAlign = "center"; - context.textBaseline = "middle"; - context.fillText(count > 9 ? "9+" : String(count), 32, 34); + drawNotificationBadge(context, count); image = canvas.toDataURL("image/png"); } } - if (!bridge) { - if (image) { - if (!badgeFavicon) { - originalFavicon = document.querySelector('link[rel="icon"]') ?? undefined; - badgeFavicon = document.createElement("link"); - badgeFavicon.rel = "icon"; - badgeFavicon.type = "image/png"; - badgeFavicon.sizes.value = "64x64"; - originalFavicon?.remove(); - document.head.append(badgeFavicon); - } - badgeFavicon.href = image; - } else if (badgeFavicon) { - badgeFavicon.remove(); - badgeFavicon = undefined; - if (originalFavicon) document.head.append(originalFavicon); - originalFavicon = undefined; - } - } void bridge?.setNotificationBadge?.({ count, image }).catch(() => undefined); } From 72707e47d88335890fed2efba94092efb6c2a0d2 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 13 Sep 2026 17:22:42 +0000 Subject: [PATCH 6/8] fix(web): place favicon badge over outer icon corner --- apps/web/src/threadNotifications.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/web/src/threadNotifications.ts b/apps/web/src/threadNotifications.ts index 2e6d6b772a5f..d676d28717bd 100644 --- a/apps/web/src/threadNotifications.ts +++ b/apps/web/src/threadNotifications.ts @@ -60,7 +60,8 @@ async function setFaviconBadge(count: number) { canvas.width = canvas.height = 64; const context = canvas.getContext("2d"); if (!context) return; - context.drawImage(image, 0, 0, 64, 64); + // Leave room for the badge to straddle the icon's corner, like the desktop badge. + context.drawImage(image, 0, 12, 52, 52); context.translate(32, 0); context.scale(0.5, 0.5); drawNotificationBadge(context, count); From de6312770dc7f45b66fcc3eb9ed61f4edfa8201d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 13 Sep 2026 17:29:55 +0000 Subject: [PATCH 7/8] fix(web): preserve full favicon size under notification count --- apps/web/src/threadNotifications.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/threadNotifications.ts b/apps/web/src/threadNotifications.ts index d676d28717bd..65146f069a4f 100644 --- a/apps/web/src/threadNotifications.ts +++ b/apps/web/src/threadNotifications.ts @@ -60,8 +60,8 @@ async function setFaviconBadge(count: number) { canvas.width = canvas.height = 64; const context = canvas.getContext("2d"); if (!context) return; - // Leave room for the badge to straddle the icon's corner, like the desktop badge. - context.drawImage(image, 0, 12, 52, 52); + // Keep the favicon full-size; only overlay the counter in its top-right corner. + context.drawImage(image, 0, 0, 64, 64); context.translate(32, 0); context.scale(0.5, 0.5); drawNotificationBadge(context, count); From f5f48d2194fc484fee6d8dc1824d053b740b001c Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 13 Sep 2026 17:33:15 +0000 Subject: [PATCH 8/8] fix(web): use counter-only notification favicon --- apps/web/src/threadNotifications.ts | 94 +++++++++-------------------- 1 file changed, 29 insertions(+), 65 deletions(-) diff --git a/apps/web/src/threadNotifications.ts b/apps/web/src/threadNotifications.ts index 65146f069a4f..2f9d45497668 100644 --- a/apps/web/src/threadNotifications.ts +++ b/apps/web/src/threadNotifications.ts @@ -21,82 +21,46 @@ export function hasDesktopNotifications(mode: NotificationMode) { let originalFavicon: HTMLLinkElement | undefined; let badgeFavicon: HTMLLinkElement | undefined; -let faviconImage: Promise | undefined; -let faviconRevision = 0; - -function drawNotificationBadge(context: CanvasRenderingContext2D, count: number) { - context.fillStyle = "#e5484d"; - context.beginPath(); - context.arc(32, 32, 28, 0, Math.PI * 2); - context.fill(); - context.fillStyle = "white"; - context.font = `600 ${count > 9 ? 30 : 40}px "Segoe UI", sans-serif`; - context.textAlign = "center"; - context.textBaseline = "middle"; - context.fillText(count > 9 ? "9+" : String(count), 32, 34); -} - -async function setFaviconBadge(count: number) { - const revision = ++faviconRevision; - if (count <= 0) { - badgeFavicon?.remove(); - badgeFavicon = undefined; - if (originalFavicon && !originalFavicon.isConnected) document.head.append(originalFavicon); - originalFavicon = undefined; - faviconImage = undefined; - return; - } - originalFavicon ??= document.querySelector('link[rel="icon"]') ?? undefined; - if (!originalFavicon) return; - if (!faviconImage) { - const image = new Image(); - image.src = originalFavicon.href; - faviconImage = image.decode().then(() => image); - } - try { - const image = await faviconImage; - if (revision !== faviconRevision) return; - const canvas = document.createElement("canvas"); - canvas.width = canvas.height = 64; - const context = canvas.getContext("2d"); - if (!context) return; - // Keep the favicon full-size; only overlay the counter in its top-right corner. - context.drawImage(image, 0, 0, 64, 64); - context.translate(32, 0); - context.scale(0.5, 0.5); - drawNotificationBadge(context, count); - const href = canvas.toDataURL("image/png"); - if (!badgeFavicon) { - badgeFavicon = document.createElement("link"); - badgeFavicon.rel = "icon"; - badgeFavicon.type = "image/png"; - badgeFavicon.sizes.value = "64x64"; - originalFavicon?.remove(); - document.head.append(badgeFavicon); - } - badgeFavicon.href = href; - } catch { - // Keep the original icon if its image cannot be loaded or drawn. - if (revision === faviconRevision) faviconImage = undefined; - } -} export function setNotificationBadge(count: number) { const bridge = window.desktopBridge; - if (!bridge) { - void setFaviconBadge(count); - return; - } let image: string | null = null; - if (count > 0 && bridge.getClientPlatform?.() === "win32") { + if (count > 0 && (!bridge || bridge.getClientPlatform?.() === "win32")) { const canvas = document.createElement("canvas"); canvas.width = canvas.height = 64; const context = canvas.getContext("2d"); if (context) { - drawNotificationBadge(context, count); + context.fillStyle = "#e5484d"; + context.beginPath(); + context.arc(32, 32, 28, 0, Math.PI * 2); + context.fill(); + context.fillStyle = "white"; + context.font = `600 ${count > 9 ? 30 : 40}px "Segoe UI", sans-serif`; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(count > 9 ? "9+" : String(count), 32, 34); image = canvas.toDataURL("image/png"); } } + if (!bridge) { + if (image) { + if (!badgeFavicon) { + originalFavicon = document.querySelector('link[rel="icon"]') ?? undefined; + badgeFavicon = document.createElement("link"); + badgeFavicon.rel = "icon"; + badgeFavicon.type = "image/png"; + badgeFavicon.sizes.value = "64x64"; + originalFavicon?.remove(); + document.head.append(badgeFavicon); + } + badgeFavicon.href = image; + } else if (badgeFavicon) { + badgeFavicon.remove(); + badgeFavicon = undefined; + if (originalFavicon) document.head.append(originalFavicon); + originalFavicon = undefined; + } + } void bridge?.setNotificationBadge?.({ count, image }).catch(() => undefined); }