| author | |
| committer | |
| log | 6e5e986f15b3cbec3588646ddf216f00b4301785 |
| tree | 78e10acbc254efdc65fb025052e5ae9ab1a682ba |
| parent | dd6ba84dc96f83000388e14b90f6533d5f63315d |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: maria-rcks <maria@kuuro.net>10 files changed, 587 insertions(+), 4 deletions(-)
apps/desktop/src/ipc/DesktopIpcHandlers.ts+2| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | |
| 3 | 3 | import * as DesktopIpc from "./DesktopIpc.ts"; |
| 4 | import { installNotificationBadge } from "./methods/notificationBadge.ts"; | |
| 4 | 5 | import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; |
| 5 | 6 | import { |
| 6 | 7 | clearConnectionCatalog, |
| ... | ... | @@ -68,6 +69,7 @@ import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./m |
| 68 | 69 | |
| 69 | 70 | export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { |
| 70 | 71 | const ipc = yield* DesktopIpc.DesktopIpc; |
| 72 | yield* installNotificationBadge(); | |
| 71 | 73 | yield* PreviewIpc.installPreviewEventForwarding(); |
| 72 | 74 | |
| 73 | 75 | yield* ipc.handle(AppActivationIpc.setReady); |
apps/desktop/src/ipc/channels.ts+1| ... | ... | @@ -1,4 +1,5 @@ |
| 1 | 1 | export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 2 | export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge"; | |
| 2 | 3 | export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; |
| 3 | 4 | export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; |
| 4 | 5 | export const SET_THEME_CHANNEL = "desktop:set-theme"; |
apps/desktop/src/ipc/methods/notificationBadge.test.ts created+144| ... | ... | @@ -0,0 +1,144 @@ |
| 1 | import * as Effect from "effect/Effect"; | |
| 2 | import { beforeEach, expect, vi } from "vite-plus/test"; | |
| 3 | import { it } from "@effect/vitest"; | |
| 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 5 | ||
| 6 | const 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 | })); | |
| 16 | vi.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 | ||
| 29 | import * as ElectronApp from "../../electron/ElectronApp.ts"; | |
| 30 | import * as DesktopIpc from "../DesktopIpc.ts"; | |
| 31 | import { applyNotificationBadge, installNotificationBadge } from "./notificationBadge.ts"; | |
| 32 | ||
| 33 | const badge = { count: 2, image: "data:image/png;base64,aGVsbG8=" }; | |
| 34 | ||
| 35 | beforeEach(() => { | |
| 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 | ||
| 45 | it.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 | ||
| 52 | it("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 | ||
| 62 | it.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 | ||
| 73 | it("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 | ||
| 83 | it("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 | ||
| 90 | it.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 @@ |
| 1 | import * as Electron from "electron"; | |
| 2 | import * as Effect from "effect/Effect"; | |
| 3 | import * as Schema from "effect/Schema"; | |
| 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | |
| 5 | ||
| 6 | import * as ElectronApp from "../../electron/ElectronApp.ts"; | |
| 7 | import * as DesktopIpc from "../DesktopIpc.ts"; | |
| 8 | import { SET_NOTIFICATION_BADGE_CHANNEL } from "../channels.ts"; | |
| 9 | ||
| 10 | const 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 | ||
| 20 | export 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 | ||
| 44 | export const installNotificationBadge = Effect.fn("desktop.ipc.installNotificationBadge")( | |
| 45 | function* () { | |
| 46 | const ipc = yield* DesktopIpc.DesktopIpc; | |
| 47 | const app = yield* ElectronApp.ElectronApp; | |
| 48 | const platform = yield* HostProcessPlatform; | |
| 49 | const clear = () => { | |
| 50 | applyNotificationBadge(platform, { count: 0, image: null }); | |
| 51 | for (const window of Electron.BrowserWindow.getAllWindows()) { | |
| 52 | if (!window.isDestroyed()) window.webContents.send(SET_NOTIFICATION_BADGE_CHANNEL); | |
| 53 | } | |
| 54 | }; | |
| 55 | yield* ipc.handle( | |
| 56 | DesktopIpc.makeIpcMethod({ | |
| 57 | channel: SET_NOTIFICATION_BADGE_CHANNEL, | |
| 58 | payload: NotificationBadge, | |
| 59 | result: Schema.Void, | |
| 60 | handler: (badge) => | |
| 61 | Effect.sync(() => { | |
| 62 | if (badge.count > 0 && Electron.BrowserWindow.getFocusedWindow()) clear(); | |
| 63 | else applyNotificationBadge(platform, badge); | |
| 64 | }), | |
| 65 | }), | |
| 66 | ); | |
| 67 | yield* app.on("browser-window-focus", clear); | |
| 68 | yield* app.on("before-quit", clear); | |
| 69 | yield* Effect.addFinalizer(() => Effect.sync(clear)); | |
| 70 | }, | |
| 71 | ); |
apps/desktop/src/preload.ts+7| ... | ... | @@ -57,6 +57,13 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 57 | 57 | return result as ReturnType<DesktopBridge["getAppBranding"]>; |
| 58 | 58 | }, |
| 59 | 59 | getClientPlatform: () => clientPlatform, |
| 60 | setNotificationBadge: (badge) => | |
| 61 | ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge), | |
| 62 | onNotificationBadgeClear: (listener) => { | |
| 63 | const handler = () => listener(); | |
| 64 | ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); | |
| 65 | return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); | |
| 66 | }, | |
| 60 | 67 | getSystemLocale: () => { |
| 61 | 68 | const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); |
| 62 | 69 | return typeof result === "string" ? result : null; |
apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx created+259| ... | ... | @@ -0,0 +1,259 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | |
| 2 | import * as Option from "effect/Option"; | |
| 3 | import { act } from "react"; | |
| 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; | |
| 5 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 6 | ||
| 7 | const 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 | })); | |
| 17 | vi.mock("@effect/atom-react", () => ({ useAtomValue: (id: string) => state.shells.get(id) })); | |
| 18 | vi.mock("@tanstack/react-router", () => ({ | |
| 19 | useNavigate: () => state.navigate, | |
| 20 | useParams: () => ({}), | |
| 21 | })); | |
| 22 | vi.mock("./ui/toast", () => ({ toastManager: { add: state.toast } })); | |
| 23 | vi.mock("../state/shell", () => ({ environmentShell: { stateValueAtom: (id: string) => id } })); | |
| 24 | vi.mock("../state/environments", () => ({ | |
| 25 | useEnvironments: () => ({ | |
| 26 | environments: state.environmentIds.map((environmentId) => ({ environmentId })), | |
| 27 | }), | |
| 28 | })); | |
| 29 | vi.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 | })); | |
| 35 | vi.mock("../threadNotifications", async (importOriginal) => ({ | |
| 36 | ...(await importOriginal<typeof import("../threadNotifications")>()), | |
| 37 | playNotificationSound: state.sound, | |
| 38 | unlockNotificationAudio: vi.fn(), | |
| 39 | setNotificationBadge: state.badge, | |
| 40 | })); | |
| 41 | ||
| 42 | import { ThreadNotificationCoordinator } from "./ThreadNotificationCoordinator"; | |
| 43 | ||
| 44 | class 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 | ||
| 60 | const 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 | }; | |
| 69 | let renderer: ReactTestRenderer | undefined; | |
| 70 | let focused = false; | |
| 71 | let visibility = "visible"; | |
| 72 | ||
| 73 | function shell(overrides: Partial<typeof thread> = {}) { | |
| 74 | return { status: "live", snapshot: Option.some({ threads: [{ ...thread, ...overrides }] }) }; | |
| 75 | } | |
| 76 | function 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 | } | |
| 82 | async function render() { | |
| 83 | await act(async () => { | |
| 84 | if (renderer) renderer.update(<ThreadNotificationCoordinator />); | |
| 85 | else renderer = create(<ThreadNotificationCoordinator />); | |
| 86 | }); | |
| 87 | } | |
| 88 | ||
| 89 | beforeEach(() => { | |
| 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 | ||
| 114 | afterEach(async () => { | |
| 115 | await act(async () => renderer?.unmount()); | |
| 116 | renderer = undefined; | |
| 117 | vi.unstubAllGlobals(); | |
| 118 | }); | |
| 119 | ||
| 120 | it("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 | ||
| 142 | it("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 | ||
| 153 | it("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 | ||
| 173 | it("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 | ||
| 198 | it.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 | ||
| 220 | it.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 | ||
| 240 | it("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 | ||
| 251 | it("badges background failures with in-app notifications enabled", async () => { | |
| 252 | state.inApp = true; | |
| 253 | await render(); | |
| 254 | state.shells.set("one", shell({ latestTurn: { ...thread.latestTurn, state: "error" } })); | |
| 255 | await render(); | |
| 256 | expect(TestNotification.sent[0]?.title).toBe("Thread failed"); | |
| 257 | expect(state.badge).toHaveBeenLastCalledWith(1); | |
| 258 | expect(state.toast).not.toHaveBeenCalled(); | |
| 259 | }); |
apps/web/src/components/ThreadNotificationCoordinator.test.tsx+7-2| ... | ... | @@ -24,7 +24,9 @@ const state = vi.hoisted(() => ({ |
| 24 | 24 | close: vi.fn(), |
| 25 | 25 | navigate: vi.fn(), |
| 26 | 26 | sound: vi.fn(), |
| 27 | notification: vi.fn(function () {}), | |
| 27 | notification: vi.fn(function (_title: string, options: NotificationOptions) { | |
| 28 | return Object.assign(new EventTarget(), { tag: options.tag, close: vi.fn() }); | |
| 29 | }), | |
| 28 | 30 | })); |
| 29 | 31 | |
| 30 | 32 | vi.mock("@effect/atom-react", () => ({ |
| ... | ... | @@ -70,6 +72,7 @@ vi.mock("../state/shell", () => ({ |
| 70 | 72 | vi.mock("../threadNotifications", async (importOriginal) => ({ |
| 71 | 73 | ...(await importOriginal<typeof import("../threadNotifications")>()), |
| 72 | 74 | playNotificationSound: state.sound, |
| 75 | setNotificationBadge: vi.fn(), | |
| 73 | 76 | })); |
| 74 | 77 | vi.mock("./ui/toast", () => ({ |
| 75 | 78 | toastManager: { add: state.add, close: state.close }, |
| ... | ... | @@ -108,6 +111,7 @@ beforeEach(() => { |
| 108 | 111 | turnError: false, |
| 109 | 112 | }); |
| 110 | 113 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 114 | vi.stubGlobal("window", new EventTarget()); | |
| 111 | 115 | vi.stubGlobal("document", { |
| 112 | 116 | get visibilityState() { |
| 113 | 117 | return state.visible; |
| ... | ... | @@ -188,7 +192,8 @@ describe("thread notifications", () => { |
| 188 | 192 | }); |
| 189 | 193 | }); |
| 190 | 194 | |
| 191 | it("keeps desktop alerts when in-app notifications are disabled", async () => { | |
| 195 | it("keeps background desktop alerts when in-app notifications are disabled", async () => { | |
| 196 | state.focused = false; | |
| 192 | 197 | state.inApp = false; |
| 193 | 198 | state.mode = "notifications"; |
| 194 | 199 | await render(); |
apps/web/src/components/ThreadNotificationCoordinator.tsx+49-2| ... | ... | @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 3 | 3 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 4 | 4 | import * as Option from "effect/Option"; |
| 5 | import { useEffect, useRef } from "react"; | |
| 5 | import { useCallback, useEffect, useRef } from "react"; | |
| 6 | 6 | |
| 7 | 7 | import { getClientSettings, useClientSettings } from "../hooks/useSettings"; |
| 8 | 8 | import { useEnvironments } from "../state/environments"; |
| ... | ... | @@ -11,6 +11,7 @@ import { |
| 11 | 11 | hasDesktopNotifications, |
| 12 | 12 | hasNotificationSound, |
| 13 | 13 | playNotificationSound, |
| 14 | setNotificationBadge, | |
| 14 | 15 | unlockNotificationAudio, |
| 15 | 16 | } from "../threadNotifications"; |
| 16 | 17 | import { resolveSidebarThreadStatus } from "./Sidebar.logic"; |
| ... | ... | @@ -22,6 +23,42 @@ export function ThreadNotificationCoordinator() { |
| 22 | 23 | const inAppNotificationsEnabled = useClientSettings( |
| 23 | 24 | (settings) => settings.inAppNotificationsEnabled, |
| 24 | 25 | ); |
| 26 | const pending = useRef( | |
| 27 | new Map<string, { environmentId: EnvironmentId; notification: Notification }>(), | |
| 28 | ); | |
| 29 | const onNotification = useCallback((environmentId: EnvironmentId, notification: Notification) => { | |
| 30 | pending.current.get(notification.tag)?.notification.close(); | |
| 31 | pending.current.set(notification.tag, { environmentId, notification }); | |
| 32 | setNotificationBadge(pending.current.size); | |
| 33 | }, []); | |
| 34 | ||
| 35 | useEffect(() => { | |
| 36 | const activeIds = new Set(environments.map(({ environmentId }) => environmentId)); | |
| 37 | const count = pending.current.size; | |
| 38 | for (const [tag, { environmentId, notification }] of pending.current) { | |
| 39 | if (activeIds.has(environmentId)) continue; | |
| 40 | notification.close(); | |
| 41 | pending.current.delete(tag); | |
| 42 | } | |
| 43 | if (count !== pending.current.size) setNotificationBadge(pending.current.size); | |
| 44 | }, [environments]); | |
| 45 | ||
| 46 | useEffect(() => { | |
| 47 | const clear = () => { | |
| 48 | for (const { notification } of pending.current.values()) notification.close(); | |
| 49 | pending.current.clear(); | |
| 50 | setNotificationBadge(0); | |
| 51 | }; | |
| 52 | clear(); | |
| 53 | if (!hasDesktopNotifications(mode)) return; | |
| 54 | const unsubscribe = window.desktopBridge?.onNotificationBadgeClear?.(clear); | |
| 55 | window.addEventListener("focus", clear); | |
| 56 | return () => { | |
| 57 | unsubscribe?.(); | |
| 58 | window.removeEventListener("focus", clear); | |
| 59 | clear(); | |
| 60 | }; | |
| 61 | }, [mode]); | |
| 25 | 62 | |
| 26 | 63 | useEffect(() => { |
| 27 | 64 | if (!hasNotificationSound(mode)) return; |
| ... | ... | @@ -39,11 +76,18 @@ export function ThreadNotificationCoordinator() { |
| 39 | 76 | <EnvironmentNotifications |
| 40 | 77 | key={environment.environmentId} |
| 41 | 78 | environmentId={environment.environmentId} |
| 79 | onNotification={onNotification} | |
| 42 | 80 | /> |
| 43 | 81 | )); |
| 44 | 82 | } |
| 45 | 83 | |
| 46 | function EnvironmentNotifications({ environmentId }: { environmentId: EnvironmentId }) { | |
| 84 | function EnvironmentNotifications({ | |
| 85 | environmentId, | |
| 86 | onNotification, | |
| 87 | }: { | |
| 88 | environmentId: EnvironmentId; | |
| 89 | onNotification: (environmentId: EnvironmentId, notification: Notification) => void; | |
| 90 | }) { | |
| 47 | 91 | const shell = useAtomValue(environmentShell.stateValueAtom(environmentId)); |
| 48 | 92 | const mode = useClientSettings((settings) => settings.notificationMode); |
| 49 | 93 | const inAppNotificationsEnabled = useClientSettings( |
| ... | ... | @@ -126,6 +170,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen |
| 126 | 170 | } |
| 127 | 171 | if ( |
| 128 | 172 | !hasDesktopNotifications(mode) || |
| 173 | (document.visibilityState === "visible" && document.hasFocus()) || | |
| 129 | 174 | typeof Notification === "undefined" || |
| 130 | 175 | Notification.permission !== "granted" |
| 131 | 176 | ) |
| ... | ... | @@ -136,6 +181,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen |
| 136 | 181 | tag: `${environmentId}:${thread.id}`, |
| 137 | 182 | silent: true, |
| 138 | 183 | }); |
| 184 | onNotification(environmentId, notification); | |
| 139 | 185 | notification.addEventListener("click", () => { |
| 140 | 186 | notification.close(); |
| 141 | 187 | window.focus(); |
| ... | ... | @@ -156,6 +202,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen |
| 156 | 202 | inAppNotificationsEnabled, |
| 157 | 203 | mode, |
| 158 | 204 | navigate, |
| 205 | onNotification, | |
| 159 | 206 | shell, |
| 160 | 207 | ]); |
| 161 | 208 |
apps/web/src/threadNotifications.ts+45| ... | ... | @@ -19,6 +19,51 @@ export function hasDesktopNotifications(mode: NotificationMode) { |
| 19 | 19 | return mode === "notifications" || mode === "notifications-and-sound"; |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | let originalFavicon: HTMLLinkElement | undefined; | |
| 23 | let badgeFavicon: HTMLLinkElement | undefined; | |
| 24 | ||
| 25 | export 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 | ||
| 22 | 67 | let audioContext: AudioContext | undefined; |
| 23 | 68 | const buffers = new Map<string, Promise<AudioBuffer>>(); |
| 24 | 69 |
packages/contracts/src/ipc.ts+2| ... | ... | @@ -1215,6 +1215,8 @@ export interface DesktopBridge { |
| 1215 | 1215 | getAppBranding: () => DesktopAppBranding | null; |
| 1216 | 1216 | /** The desktop client's OS platform, read from Electron's preload process. */ |
| 1217 | 1217 | getClientPlatform?: () => string; |
| 1218 | setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise<void>; | |
| 1219 | onNotificationBadgeClear?: (listener: () => void) => () => void; | |
| 1218 | 1220 | /** |
| 1219 | 1221 | * The OS locale as a BCP-47 tag, which the renderer cannot read for itself: |
| 1220 | 1222 | * the packaged app ships only the `en-US` Chromium locale pak, so |