| 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,6 +1,7 @@ |
| 1 | import * as Effect from "effect/Effect"; | 1 | import * as Effect from "effect/Effect"; |
| 2 | 2 | ||
| 3 | import * as DesktopIpc from "./DesktopIpc.ts"; | 3 | import * as DesktopIpc from "./DesktopIpc.ts"; |
| 4 | import { installNotificationBadge } from "./methods/notificationBadge.ts"; | ||
| 4 | import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; | 5 | import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; |
| 5 | import { | 6 | import { |
| 6 | clearConnectionCatalog, | 7 | clearConnectionCatalog, |
| ... | @@ -68,6 +69,7 @@ import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./m | ... | @@ -68,6 +69,7 @@ import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./m |
| 68 | 69 | ||
| 69 | export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { | 70 | export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { |
| 70 | const ipc = yield* DesktopIpc.DesktopIpc; | 71 | const ipc = yield* DesktopIpc.DesktopIpc; |
| 72 | yield* installNotificationBadge(); | ||
| 71 | yield* PreviewIpc.installPreviewEventForwarding(); | 73 | yield* PreviewIpc.installPreviewEventForwarding(); |
| 72 | 74 | ||
| 73 | yield* ipc.handle(AppActivationIpc.setReady); | 75 | yield* ipc.handle(AppActivationIpc.setReady); |
apps/desktop/src/ipc/channels.ts+1| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; | 1 | export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 2 | export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge"; | ||
| 2 | export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; | 3 | export const PICK_PROJECT_FAVICON_CHANNEL = "desktop:pick-project-favicon"; |
| 3 | export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; | 4 | export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; |
| 4 | export const SET_THEME_CHANNEL = "desktop:set-theme"; | 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,6 +57,13 @@ contextBridge.exposeInMainWorld("desktopBridge", { |
| 57 | return result as ReturnType<DesktopBridge["getAppBranding"]>; | 57 | return result as ReturnType<DesktopBridge["getAppBranding"]>; |
| 58 | }, | 58 | }, |
| 59 | getClientPlatform: () => clientPlatform, | 59 | getClientPlatform: () => clientPlatform, |
| 60 | setNotificationBadge: (badge) => | ||
| 61 | ipcRenderer.invoke(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, badge), | ||
| 62 | onNotificationBadgeClear: (listener) => { | ||
| 63 | const handler = () => listener(); | ||
| 64 | ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); | ||
| 65 | return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); | ||
| 66 | }, | ||
| 60 | getSystemLocale: () => { | 67 | getSystemLocale: () => { |
| 61 | const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); | 68 | const result = ipcRenderer.sendSync(IpcChannels.GET_SYSTEM_LOCALE_CHANNEL); |
| 62 | return typeof result === "string" ? result : null; | 69 | return typeof result === "string" ? result : null; |
apps/web/src/components/ThreadNotificationCoordinator.badge.test.tsx created+259| ... | @@ -0,0 +1,259 @@ | ||
| 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,7 +24,9 @@ const state = vi.hoisted(() => ({ |
| 24 | close: vi.fn(), | 24 | close: vi.fn(), |
| 25 | navigate: vi.fn(), | 25 | navigate: vi.fn(), |
| 26 | sound: vi.fn(), | 26 | sound: vi.fn(), |
| 27 | notification: vi.fn(function () {}), | 27 | notification: vi.fn(function (_title: string, options: NotificationOptions) { |
| 28 | return Object.assign(new EventTarget(), { tag: options.tag, close: vi.fn() }); | ||
| 29 | }), | ||
| 28 | })); | 30 | })); |
| 29 | 31 | ||
| 30 | vi.mock("@effect/atom-react", () => ({ | 32 | vi.mock("@effect/atom-react", () => ({ |
| ... | @@ -70,6 +72,7 @@ vi.mock("../state/shell", () => ({ | ... | @@ -70,6 +72,7 @@ vi.mock("../state/shell", () => ({ |
| 70 | vi.mock("../threadNotifications", async (importOriginal) => ({ | 72 | vi.mock("../threadNotifications", async (importOriginal) => ({ |
| 71 | ...(await importOriginal<typeof import("../threadNotifications")>()), | 73 | ...(await importOriginal<typeof import("../threadNotifications")>()), |
| 72 | playNotificationSound: state.sound, | 74 | playNotificationSound: state.sound, |
| 75 | setNotificationBadge: vi.fn(), | ||
| 73 | })); | 76 | })); |
| 74 | vi.mock("./ui/toast", () => ({ | 77 | vi.mock("./ui/toast", () => ({ |
| 75 | toastManager: { add: state.add, close: state.close }, | 78 | toastManager: { add: state.add, close: state.close }, |
| ... | @@ -108,6 +111,7 @@ beforeEach(() => { | ... | @@ -108,6 +111,7 @@ beforeEach(() => { |
| 108 | turnError: false, | 111 | turnError: false, |
| 109 | }); | 112 | }); |
| 110 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | 113 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 114 | vi.stubGlobal("window", new EventTarget()); | ||
| 111 | vi.stubGlobal("document", { | 115 | vi.stubGlobal("document", { |
| 112 | get visibilityState() { | 116 | get visibilityState() { |
| 113 | return state.visible; | 117 | return state.visible; |
| ... | @@ -188,7 +192,8 @@ describe("thread notifications", () => { | ... | @@ -188,7 +192,8 @@ describe("thread notifications", () => { |
| 188 | }); | 192 | }); |
| 189 | }); | 193 | }); |
| 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 | state.inApp = false; | 197 | state.inApp = false; |
| 193 | state.mode = "notifications"; | 198 | state.mode = "notifications"; |
| 194 | await render(); | 199 | await render(); |
apps/web/src/components/ThreadNotificationCoordinator.tsx+49-2| ... | @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; | ... | @@ -2,7 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useNavigate, useParams } from "@tanstack/react-router"; | 2 | import { useNavigate, useParams } from "@tanstack/react-router"; |
| 3 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | 3 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; |
| 4 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 5 | import { useEffect, useRef } from "react"; | 5 | import { useCallback, useEffect, useRef } from "react"; |
| 6 | 6 | ||
| 7 | import { getClientSettings, useClientSettings } from "../hooks/useSettings"; | 7 | import { getClientSettings, useClientSettings } from "../hooks/useSettings"; |
| 8 | import { useEnvironments } from "../state/environments"; | 8 | import { useEnvironments } from "../state/environments"; |
| ... | @@ -11,6 +11,7 @@ import { | ... | @@ -11,6 +11,7 @@ import { |
| 11 | hasDesktopNotifications, | 11 | hasDesktopNotifications, |
| 12 | hasNotificationSound, | 12 | hasNotificationSound, |
| 13 | playNotificationSound, | 13 | playNotificationSound, |
| 14 | setNotificationBadge, | ||
| 14 | unlockNotificationAudio, | 15 | unlockNotificationAudio, |
| 15 | } from "../threadNotifications"; | 16 | } from "../threadNotifications"; |
| 16 | import { resolveSidebarThreadStatus } from "./Sidebar.logic"; | 17 | import { resolveSidebarThreadStatus } from "./Sidebar.logic"; |
| ... | @@ -22,6 +23,42 @@ export function ThreadNotificationCoordinator() { | ... | @@ -22,6 +23,42 @@ export function ThreadNotificationCoordinator() { |
| 22 | const inAppNotificationsEnabled = useClientSettings( | 23 | const inAppNotificationsEnabled = useClientSettings( |
| 23 | (settings) => settings.inAppNotificationsEnabled, | 24 | (settings) => settings.inAppNotificationsEnabled, |
| 24 | ); | 25 | ); |
| 26 | const pending = useRef( | ||
| 27 | new Map<string, { environmentId: EnvironmentId; notification: Notification }>(), | ||
| 28 | ); | ||
| 29 | const onNotification = useCallback((environmentId: EnvironmentId, notification: Notification) => { | ||
| 30 | pending.current.get(notification.tag)?.notification.close(); | ||
| 31 | pending.current.set(notification.tag, { environmentId, notification }); | ||
| 32 | setNotificationBadge(pending.current.size); | ||
| 33 | }, []); | ||
| 34 | |||
| 35 | useEffect(() => { | ||
| 36 | const activeIds = new Set(environments.map(({ environmentId }) => environmentId)); | ||
| 37 | const count = pending.current.size; | ||
| 38 | for (const [tag, { environmentId, notification }] of pending.current) { | ||
| 39 | if (activeIds.has(environmentId)) continue; | ||
| 40 | notification.close(); | ||
| 41 | pending.current.delete(tag); | ||
| 42 | } | ||
| 43 | if (count !== pending.current.size) setNotificationBadge(pending.current.size); | ||
| 44 | }, [environments]); | ||
| 45 | |||
| 46 | useEffect(() => { | ||
| 47 | const clear = () => { | ||
| 48 | for (const { notification } of pending.current.values()) notification.close(); | ||
| 49 | pending.current.clear(); | ||
| 50 | setNotificationBadge(0); | ||
| 51 | }; | ||
| 52 | clear(); | ||
| 53 | if (!hasDesktopNotifications(mode)) return; | ||
| 54 | const unsubscribe = window.desktopBridge?.onNotificationBadgeClear?.(clear); | ||
| 55 | window.addEventListener("focus", clear); | ||
| 56 | return () => { | ||
| 57 | unsubscribe?.(); | ||
| 58 | window.removeEventListener("focus", clear); | ||
| 59 | clear(); | ||
| 60 | }; | ||
| 61 | }, [mode]); | ||
| 25 | 62 | ||
| 26 | useEffect(() => { | 63 | useEffect(() => { |
| 27 | if (!hasNotificationSound(mode)) return; | 64 | if (!hasNotificationSound(mode)) return; |
| ... | @@ -39,11 +76,18 @@ export function ThreadNotificationCoordinator() { | ... | @@ -39,11 +76,18 @@ export function ThreadNotificationCoordinator() { |
| 39 | <EnvironmentNotifications | 76 | <EnvironmentNotifications |
| 40 | key={environment.environmentId} | 77 | key={environment.environmentId} |
| 41 | environmentId={environment.environmentId} | 78 | environmentId={environment.environmentId} |
| 79 | onNotification={onNotification} | ||
| 42 | /> | 80 | /> |
| 43 | )); | 81 | )); |
| 44 | } | 82 | } |
| 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 | const shell = useAtomValue(environmentShell.stateValueAtom(environmentId)); | 91 | const shell = useAtomValue(environmentShell.stateValueAtom(environmentId)); |
| 48 | const mode = useClientSettings((settings) => settings.notificationMode); | 92 | const mode = useClientSettings((settings) => settings.notificationMode); |
| 49 | const inAppNotificationsEnabled = useClientSettings( | 93 | const inAppNotificationsEnabled = useClientSettings( |
| ... | @@ -126,6 +170,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen | ... | @@ -126,6 +170,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen |
| 126 | } | 170 | } |
| 127 | if ( | 171 | if ( |
| 128 | !hasDesktopNotifications(mode) || | 172 | !hasDesktopNotifications(mode) || |
| 173 | (document.visibilityState === "visible" && document.hasFocus()) || | ||
| 129 | typeof Notification === "undefined" || | 174 | typeof Notification === "undefined" || |
| 130 | Notification.permission !== "granted" | 175 | Notification.permission !== "granted" |
| 131 | ) | 176 | ) |
| ... | @@ -136,6 +181,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen | ... | @@ -136,6 +181,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen |
| 136 | tag: `${environmentId}:${thread.id}`, | 181 | tag: `${environmentId}:${thread.id}`, |
| 137 | silent: true, | 182 | silent: true, |
| 138 | }); | 183 | }); |
| 184 | onNotification(environmentId, notification); | ||
| 139 | notification.addEventListener("click", () => { | 185 | notification.addEventListener("click", () => { |
| 140 | notification.close(); | 186 | notification.close(); |
| 141 | window.focus(); | 187 | window.focus(); |
| ... | @@ -156,6 +202,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen | ... | @@ -156,6 +202,7 @@ function EnvironmentNotifications({ environmentId }: { environmentId: Environmen |
| 156 | inAppNotificationsEnabled, | 202 | inAppNotificationsEnabled, |
| 157 | mode, | 203 | mode, |
| 158 | navigate, | 204 | navigate, |
| 205 | onNotification, | ||
| 159 | shell, | 206 | shell, |
| 160 | ]); | 207 | ]); |
| 161 | 208 |
apps/web/src/threadNotifications.ts+45| ... | @@ -19,6 +19,51 @@ export function hasDesktopNotifications(mode: NotificationMode) { | ... | @@ -19,6 +19,51 @@ export function hasDesktopNotifications(mode: NotificationMode) { |
| 19 | return mode === "notifications" || mode === "notifications-and-sound"; | 19 | return mode === "notifications" || mode === "notifications-and-sound"; |
| 20 | } | 20 | } |
| 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 | let audioContext: AudioContext | undefined; | 67 | let audioContext: AudioContext | undefined; |
| 23 | const buffers = new Map<string, Promise<AudioBuffer>>(); | 68 | const buffers = new Map<string, Promise<AudioBuffer>>(); |
| 24 | 69 |
packages/contracts/src/ipc.ts+2| ... | @@ -1215,6 +1215,8 @@ export interface DesktopBridge { | ... | @@ -1215,6 +1215,8 @@ export interface DesktopBridge { |
| 1215 | getAppBranding: () => DesktopAppBranding | null; | 1215 | getAppBranding: () => DesktopAppBranding | null; |
| 1216 | /** The desktop client's OS platform, read from Electron's preload process. */ | 1216 | /** The desktop client's OS platform, read from Electron's preload process. */ |
| 1217 | getClientPlatform?: () => string; | 1217 | getClientPlatform?: () => string; |
| 1218 | setNotificationBadge?: (badge: { count: number; image: string | null }) => Promise<void>; | ||
| 1219 | onNotificationBadgeClear?: (listener: () => void) => () => void; | ||
| 1218 | /** | 1220 | /** |
| 1219 | * The OS locale as a BCP-47 tag, which the renderer cannot read for itself: | 1221 | * The OS locale as a BCP-47 tag, which the renderer cannot read for itself: |
| 1220 | * the packaged app ships only the `en-US` Chromium locale pak, so | 1222 | * the packaged app ships only the `en-US` Chromium locale pak, so |