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..a47732550f64 --- /dev/null +++ b/apps/desktop/src/ipc/methods/notificationBadge.test.ts @@ -0,0 +1,144 @@ +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"); + 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); + }), + ).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..40e4549138f2 --- /dev/null +++ b/apps/desktop/src/ipc/methods/notificationBadge.ts @@ -0,0 +1,71 @@ +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 }); + 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(() => { + if (badge.count > 0 && Electron.BrowserWindow.getFocusedWindow()) clear(); + else applyNotificationBadge(platform, badge); + }), + }), + ); + yield* app.on("browser-window-focus", clear); + 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.badge.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx new file mode 100644 index 000000000000..3ce4b8d6f176 --- /dev/null +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx @@ -0,0 +1,259 @@ +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", + inApp: false, + toast: vi.fn(), + shells: new Map(), + 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, + useParams: () => ({}), +})); +vi.mock("./ui/toast", () => ({ toastManager: { add: state.toast } })); +vi.mock("../state/shell", () => ({ environmentShell: { stateValueAtom: (id: string) => id } })); +vi.mock("../state/environments", () => ({ + useEnvironments: () => ({ + environments: state.environmentIds.map((environmentId) => ({ environmentId })), + }), +})); +vi.mock("../hooks/useSettings", () => ({ + useClientSettings: ( + select: (settings: { notificationMode: string; inAppNotificationsEnabled: boolean }) => unknown, + ) => select({ notificationMode: state.mode, inAppNotificationsEnabled: state.inApp }), + 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(); + get tag() { + return this.options.tag ?? ""; + } + 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.inApp = false; + state.environmentIds = ["one", "two"]; + 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("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(); + 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(); + }, +); + +it("shows in-app alerts without adding a badge while focused", async () => { + state.inApp = true; + focused = true; + await render(); + complete(); + await render(); + expect(state.toast).toHaveBeenCalledOnce(); + expect(TestNotification.sent).toHaveLength(0); + expect(state.badge.mock.calls.every(([count]) => count === 0)).toBe(true); +}); + +it("badges background failures with in-app notifications enabled", async () => { + state.inApp = true; + await render(); + state.shells.set("one", shell({ latestTurn: { ...thread.latestTurn, state: "error" } })); + await render(); + expect(TestNotification.sent[0]?.title).toBe("Thread failed"); + expect(state.badge).toHaveBeenLastCalledWith(1); + expect(state.toast).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx index aa5c7b67be29..860b6389dc12 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.test.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.test.tsx @@ -24,7 +24,9 @@ const state = vi.hoisted(() => ({ close: vi.fn(), navigate: vi.fn(), sound: vi.fn(), - notification: vi.fn(function () {}), + notification: vi.fn(function (_title: string, options: NotificationOptions) { + return Object.assign(new EventTarget(), { tag: options.tag, close: vi.fn() }); + }), })); vi.mock("@effect/atom-react", () => ({ @@ -70,6 +72,7 @@ vi.mock("../state/shell", () => ({ vi.mock("../threadNotifications", async (importOriginal) => ({ ...(await importOriginal()), playNotificationSound: state.sound, + setNotificationBadge: vi.fn(), })); vi.mock("./ui/toast", () => ({ toastManager: { add: state.add, close: state.close }, @@ -108,6 +111,7 @@ beforeEach(() => { turnError: false, }); vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", new EventTarget()); vi.stubGlobal("document", { get visibilityState() { return state.visible; @@ -188,7 +192,8 @@ describe("thread notifications", () => { }); }); - it("keeps desktop alerts when in-app notifications are disabled", async () => { + it("keeps background desktop alerts when in-app notifications are disabled", async () => { + state.focused = false; state.inApp = false; state.mode = "notifications"; await render(); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 4ccab5c901ce..5feda71ea8f3 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, useParams } 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"; @@ -22,6 +23,42 @@ export function ThreadNotificationCoordinator() { const inAppNotificationsEnabled = useClientSettings( (settings) => settings.inAppNotificationsEnabled, ); + 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(); + 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; @@ -39,11 +76,18 @@ export function ThreadNotificationCoordinator() { )); } -function EnvironmentNotifications({ environmentId }: { environmentId: EnvironmentId }) { +function EnvironmentNotifications({ + environmentId, + onNotification, +}: { + environmentId: EnvironmentId; + onNotification: (environmentId: EnvironmentId, notification: Notification) => void; +}) { const shell = useAtomValue(environmentShell.stateValueAtom(environmentId)); const mode = useClientSettings((settings) => settings.notificationMode); const inAppNotificationsEnabled = useClientSettings( @@ -126,6 +170,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen } if ( !hasDesktopNotifications(mode) || + (document.visibilityState === "visible" && document.hasFocus()) || typeof Notification === "undefined" || Notification.permission !== "granted" ) @@ -136,6 +181,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen tag: `${environmentId}:${thread.id}`, silent: true, }); + onNotification(environmentId, notification); notification.addEventListener("click", () => { notification.close(); window.focus(); @@ -156,6 +202,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen inAppNotificationsEnabled, mode, navigate, + onNotification, shell, ]); diff --git a/apps/web/src/threadNotifications.ts b/apps/web/src/threadNotifications.ts index 8050411382aa..2f9d45497668 100644 --- a/apps/web/src/threadNotifications.ts +++ b/apps/web/src/threadNotifications.ts @@ -19,6 +19,51 @@ 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; + let image: string | null = null; + if (count > 0 && (!bridge || 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"); + } + } + 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; 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