From bc8cb1a49d5d54e2c180f282a8cfc1be12375c73 Mon Sep 17 00:00:00 2001 From: Jessy Le <106968649+JesstLe@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:25:00 +0800 Subject: [PATCH 1/6] fix(desktop): let Windows thread notifications expire without losing activation --- .../app/DesktopThreadNotifications.test.ts | 226 +++++++++++++++++ .../src/app/DesktopThreadNotifications.ts | 229 ++++++++++++++++++ apps/desktop/src/ipc/DesktopIpcHandlers.ts | 6 + apps/desktop/src/ipc/channels.ts | 4 + .../ipc/methods/threadNotifications.test.ts | 62 +++++ .../src/ipc/methods/threadNotifications.ts | 52 ++++ apps/desktop/src/main.ts | 2 + apps/desktop/src/preload.ts | 13 + ...ificationCoordinator.badge.browser.test.ts | 57 ++++- .../ThreadNotificationCoordinator.tsx | 65 ++++- packages/contracts/src/ipc.ts | 23 +- 11 files changed, 731 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/app/DesktopThreadNotifications.test.ts create mode 100644 apps/desktop/src/app/DesktopThreadNotifications.ts create mode 100644 apps/desktop/src/ipc/methods/threadNotifications.test.ts create mode 100644 apps/desktop/src/ipc/methods/threadNotifications.ts diff --git a/apps/desktop/src/app/DesktopThreadNotifications.test.ts b/apps/desktop/src/app/DesktopThreadNotifications.test.ts new file mode 100644 index 0000000000000..84bb660fd9216 --- /dev/null +++ b/apps/desktop/src/app/DesktopThreadNotifications.test.ts @@ -0,0 +1,226 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The fake native notification and webContents expose Electron's event lifecycle. +import * as NodeEvents from "node:events"; +import { assert, it } from "@effect/vitest"; +import { EnvironmentId, ThreadId, type DesktopThreadNotification } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { vi } from "vite-plus/test"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; + +const native = vi.hoisted(() => ({ + create: null as unknown as (options: Electron.NotificationConstructorOptions) => object, + activate: null as null | ((details: Electron.ActivationArguments) => void), + initial: null as null | Electron.ActivationArguments, +})); +vi.mock("electron", () => ({ + Notification: class { + constructor(options: Electron.NotificationConstructorOptions) { + return native.create(options); + } + static isSupported() { + return true; + } + static handleActivation(callback: (details: Electron.ActivationArguments) => void) { + native.activate = callback; + if (native.initial) { + const first = native.initial; + native.initial = null; + callback(first); + } + } + }, +})); + +import * as DesktopThreadNotifications from "./DesktopThreadNotifications.ts"; + +const input: DesktopThreadNotification = { + id: "c815595b-7739-4ff0-aef9-ec101a341639", + tag: "1234567890abcdef", + environmentId: EnvironmentId.make("remote-environment"), + threadId: ThreadId.make("thread-1"), + title: "Thread completed", + body: 'a & "quoted"', +}; + +class NativeNotification extends NodeEvents.EventEmitter { + closeCount = 0; + readonly options: Electron.NotificationConstructorOptions; + constructor(options: Electron.NotificationConstructorOptions) { + super(); + this.options = options; + } + show() { + this.emit("show", {}); + } + close() { + this.closeCount++; + this.emit("close", { reason: "applicationHidden" }); + } +} + +function setup(platform: NodeJS.Platform = "win32") { + native.activate = null; + native.initial = null; + const notifications: NativeNotification[] = []; + native.create = (options) => { + const notification = new NativeNotification(options); + notifications.push(notification); + return notification; + }; + const sent: Array = []; + const app = new NodeEvents.EventEmitter(); + const webContents = Object.assign(new NodeEvents.EventEmitter(), { + id: 1, + isDestroyed: () => false, + send: (_channel: string, payload: unknown) => { + sent.push(payload); + }, + }); + const window = { webContents } as unknown as Electron.BrowserWindow; + const layer = DesktopThreadNotifications.layer.pipe( + Layer.provide([ + Layer.succeed(DesktopEnvironment.DesktopEnvironment, { + platform, + } as DesktopEnvironment.DesktopEnvironment["Service"]), + Layer.succeed(ElectronWindow.ElectronWindow, { + currentMainOrFirst: Effect.succeedSome(window), + } as ElectronWindow.ElectronWindow["Service"]), + Layer.succeed(DesktopWindow.DesktopWindow, { + activate: Effect.void, + } as unknown as DesktopWindow.DesktopWindow["Service"]), + Layer.succeed(ElectronApp.ElectronApp, { + on: (event: string, listener: () => void) => + Effect.acquireRelease( + Effect.sync(() => { + app.on(event, listener); + }), + () => + Effect.sync(() => { + app.removeListener(event, listener); + }), + ).pipe(Effect.asVoid), + } as ElectronApp.ElectronApp["Service"]), + ]), + ); + return { notifications, sent, app, webContents, layer }; +} + +const activationFor = (notification: NativeNotification) => { + const xml = notification.options.toastXml!; + const arguments_ = xml.match(/launch="([^"]+)"/)![1]!.replaceAll("&", "&"); + return { type: "click", arguments: arguments_ }; +}; + +it.effect( + "times out normally, retains clickability, and delivers one navigation across both native callbacks", + () => { + const { notifications, sent, webContents, layer } = setup(); + return Effect.scoped( + Effect.gen(function* () { + const service = yield* DesktopThreadNotifications.DesktopThreadNotifications; + yield* service.register; + assert.strictEqual(yield* service.show(input), true); + const notification = notifications[0]!; + assert.strictEqual(notification.options.timeoutType, "default"); + assert.notInclude(notification.options.toastXml!, 'scenario="reminder"'); + assert.include(notification.options.toastXml!, "a & <b> "quoted""); + notification.emit("close", { reason: "timedOut" }); + assert.strictEqual(notification.closeCount, 0); + notification.emit("click", {}); + native.activate!(activationFor(notification)); + assert.deepStrictEqual(sent, []); + yield* service.setRendererReady(true); + assert.deepStrictEqual(sent, [ + { id: input.id, environmentId: input.environmentId, threadId: input.threadId }, + ]); + assert.strictEqual(notification.closeCount, 1); + webContents.emit("did-start-navigation", { isMainFrame: true, isSameDocument: false }); + const next = { ...input, id: "22f0be88-ea34-4cf3-b0da-b900710cd00a" }; + yield* service.show(next); + native.activate!(activationFor(notifications[1]!)); + assert.strictEqual(sent.length, 1); + yield* service.setRendererReady(true); + assert.strictEqual(sent.length, 2); + }), + ).pipe(Effect.provide(layer)); + }, +); + +it.effect( + "replays a cold activation after renderer readiness and rejects malformed destinations", + () => { + const { sent, layer } = setup(); + native.initial = { + type: "click", + arguments: + "t3-thread-v1:" + + new URLSearchParams({ + id: input.id, + environment: input.environmentId, + thread: input.threadId, + }), + }; + return Effect.scoped( + Effect.gen(function* () { + const service = yield* DesktopThreadNotifications.DesktopThreadNotifications; + yield* service.register; + assert.deepStrictEqual(sent, []); + yield* service.setRendererReady(true); + assert.strictEqual(sent.length, 1); + for (const arguments_ of [ + "https://example.com", + "t3-thread-v1:id=bad&environment=e&thread=t", + "x".repeat(17_000), + ]) { + native.activate!({ type: "click", arguments: arguments_ }); + } + native.activate!({ type: "reply", arguments: native.initial?.arguments ?? "" }); + yield* service.setRendererReady(true); + assert.strictEqual(sent.length, 1); + }), + ).pipe(Effect.provide(layer)); + }, +); + +it.effect( + "replaces by thread without letting an old close erase its successor, and keeps restart notices", + () => { + const { notifications, app, layer } = setup(); + return Effect.scoped( + Effect.gen(function* () { + const service = yield* DesktopThreadNotifications.DesktopThreadNotifications; + yield* service.register; + yield* service.show(input); + const next = { ...input, id: "22f0be88-ea34-4cf3-b0da-b900710cd00a" }; + yield* service.show(next); + assert.strictEqual(notifications[0]!.closeCount, 1); + yield* service.close(input.id); + assert.strictEqual(notifications[1]!.closeCount, 0); + notifications[1]!.emit("close", { reason: "timedOut" }); + yield* service.close(next.id); + assert.strictEqual(notifications[1]!.closeCount, 1); + yield* service.show(input); + app.emit("before-quit"); + yield* service.close(input.id); + assert.strictEqual(notifications[2]!.closeCount, 0); + }), + ).pipe(Effect.provide(layer)); + }, +); + +it.effect("does not register or present native notices on other platforms", () => { + const { notifications, layer } = setup("darwin"); + return Effect.scoped( + Effect.gen(function* () { + const service = yield* DesktopThreadNotifications.DesktopThreadNotifications; + yield* service.register; + assert.strictEqual(native.activate, null); + assert.strictEqual(yield* service.show(input), false); + assert.deepStrictEqual(notifications, []); + }), + ).pipe(Effect.provide(layer)); +}); diff --git a/apps/desktop/src/app/DesktopThreadNotifications.ts b/apps/desktop/src/app/DesktopThreadNotifications.ts new file mode 100644 index 0000000000000..987d5760a0c0d --- /dev/null +++ b/apps/desktop/src/app/DesktopThreadNotifications.ts @@ -0,0 +1,229 @@ +import { + DesktopThreadNotificationTarget, + type DesktopThreadNotification, +} from "@t3tools/contracts"; +import * as Electron from "electron"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; + +import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import { THREAD_NOTIFICATION_ACTIVATED_CHANNEL } from "../ipc/channels.ts"; + +const PREFIX = "t3-thread-v1:"; +const MAX_NOTIFICATIONS = 128; +const isTarget = Schema.is(DesktopThreadNotificationTarget); + +const xmlText = (value: string) => + value + .toWellFormed() + .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/g, "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + +function readActivation(arguments_: string): DesktopThreadNotificationTarget | null { + if (!arguments_.startsWith(PREFIX) || arguments_.length > 16_384) return null; + const params = new URLSearchParams(arguments_.slice(PREFIX.length)); + const target = { + id: params.get("id"), + environmentId: params.get("environment"), + threadId: params.get("thread"), + }; + return isTarget(target) ? target : null; +} + +interface PendingNotification { + readonly target: DesktopThreadNotificationTarget; + readonly notification: Electron.Notification; + readonly settle: (shown: boolean) => void; +} + +export class DesktopThreadNotifications extends Context.Service< + DesktopThreadNotifications, + { + readonly register: Effect.Effect; + readonly show: (input: DesktopThreadNotification) => Effect.Effect; + readonly close: (id: string | null) => Effect.Effect; + readonly setRendererReady: (ready: boolean) => Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopThreadNotifications") {} + +const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const windows = yield* ElectronWindow.ElectronWindow; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const app = yield* ElectronApp.ElectronApp; + const runPromise = Effect.runPromiseWith(yield* Effect.context()); + const pending = new Map(); + const activated = new Set(); + let queued: DesktopThreadNotificationTarget | null = null; + let listening: Electron.WebContents | null = null; + let detach: (() => void) | null = null; + let quitting = false; + + const stopListening = () => { + detach?.(); + detach = null; + listening = null; + }; + const remember = (id: string) => { + activated.add(id); + if (activated.size > MAX_NOTIFICATIONS) activated.delete(activated.values().next().value!); + }; + const close = (id: string) => { + for (const [tag, entry] of pending) { + if (entry.target.id !== id) continue; + pending.delete(tag); + entry.settle(false); + entry.notification.close(); + } + }; + const flush = Effect.gen(function* () { + if (!queued || !listening) return; + const window = yield* windows.currentMainOrFirst; + if (Option.isNone(window) || window.value.webContents !== listening || listening.isDestroyed()) + return; + const target = queued; + queued = null; + listening.send(THREAD_NOTIFICATION_ACTIVATED_CHANNEL, target); + }); + const activate = (target: DesktopThreadNotificationTarget) => { + if (activated.has(target.id)) return; + remember(target.id); + close(target.id); + queued = target; + void runPromise(desktopWindow.activate.pipe(Effect.ignore, Effect.andThen(flush))).catch( + () => undefined, + ); + }; + + return DesktopThreadNotifications.of({ + register: Effect.gen(function* () { + if (environment.platform !== "win32" || !Electron.Notification.isSupported()) return; + Electron.Notification.handleActivation((details) => { + if (details.type !== "click") return; + const target = readActivation(details.arguments); + if (target) activate(target); + }); + yield* app.on("before-quit", () => { + quitting = true; + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + stopListening(); + Electron.Notification.handleActivation(() => undefined); + // Timed-out notices stay in Action Center and carry their own restart destination. + for (const entry of pending.values()) entry.settle(false); + pending.clear(); + }), + ); + }), + show: Effect.fn("DesktopThreadNotifications.show")(function (input) { + if (quitting || environment.platform !== "win32" || !Electron.Notification.isSupported()) { + return Effect.succeed(false); + } + return Effect.promise( + () => + new Promise((resolve) => { + const previous = pending.get(input.tag); + if (previous) { + remember(previous.target.id); + close(previous.target.id); + } + if (pending.size >= MAX_NOTIFICATIONS) { + const evicted = pending.values().next().value!; + remember(evicted.target.id); + close(evicted.target.id); + } + const target = { + id: input.id, + environmentId: input.environmentId, + threadId: input.threadId, + }; + const args = + PREFIX + + new URLSearchParams({ + id: target.id, + environment: target.environmentId, + thread: target.threadId, + }).toString(); + try { + const notification = new Electron.Notification({ + id: input.tag, + groupId: "t3-threads", + timeoutType: "default", + silent: true, + toastXml: `${xmlText(input.title)}${xmlText(input.body)}`, + }); + const forget = () => { + if (pending.get(input.tag)?.target.id === input.id) pending.delete(input.tag); + }; + pending.set(input.tag, { target, notification, settle: resolve }); + notification.once("show", () => resolve(true)); + notification.once("failed", () => { + forget(); + resolve(false); + }); + // Live WinRT body clicks can bypass handleActivation; both paths share the nonce. + notification.on("click", () => activate(target)); + notification.on("close", (event) => { + if (event.reason === "timedOut") return; + if (event.reason === "userCanceled") remember(target.id); + forget(); + resolve(false); + }); + notification.show(); + } catch { + if (pending.get(input.tag)?.target.id === input.id) pending.delete(input.tag); + resolve(false); + } + }), + ); + }), + close: Effect.fn("DesktopThreadNotifications.close")((id: string | null) => + Effect.sync(() => { + if (quitting) return; + if (id !== null) close(id); + else for (const entry of pending.values()) close(entry.target.id); + }), + ), + setRendererReady: Effect.fn("DesktopThreadNotifications.setRendererReady")(function* (ready) { + if (!ready) { + stopListening(); + return; + } + const window = yield* windows.currentMainOrFirst; + if (Option.isNone(window) || window.value.webContents.isDestroyed()) return; + const contents = window.value.webContents; + if (contents !== listening) { + stopListening(); + listening = contents; + const onNavigation = ( + event: Electron.Event, + ) => { + if (event.isMainFrame && !event.isSameDocument) stopListening(); + }; + contents.on("did-start-navigation", onNavigation); + contents.on("render-process-gone", stopListening); + contents.once("destroyed", stopListening); + detach = () => { + contents.removeListener("did-start-navigation", onNavigation); + contents.removeListener("render-process-gone", stopListening); + contents.removeListener("destroyed", stopListening); + }; + } + yield* flush; + }), + }); +}); + +export const layer = Layer.effect(DesktopThreadNotifications, make); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index a46dbafab0f41..c1724f648e097 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -3,6 +3,8 @@ import * as Effect from "effect/Effect"; import { receiveProviderAuthCallback, cancelProviderAuthCallback } from "./methods/providerAuth.ts"; import * as DesktopIpc from "./DesktopIpc.ts"; import { installNotificationBadge } from "./methods/notificationBadge.ts"; +import * as ThreadNotificationsIpc from "./methods/threadNotifications.ts"; +import * as DesktopThreadNotifications from "../app/DesktopThreadNotifications.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; import { getConnectionCatalog, setConnectionCatalog } from "./methods/connectionCatalog.ts"; import { @@ -83,6 +85,10 @@ import { export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; yield* installNotificationBadge(); + yield* (yield* DesktopThreadNotifications.DesktopThreadNotifications).register; + yield* ipc.handle(ThreadNotificationsIpc.show); + yield* ipc.handle(ThreadNotificationsIpc.close); + yield* ipc.handle(ThreadNotificationsIpc.setReady); 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 0f125c05de307..efdebb4a379b9 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -1,5 +1,9 @@ export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; export const SET_NOTIFICATION_BADGE_CHANNEL = "desktop:set-notification-badge"; +export const SHOW_THREAD_NOTIFICATION_CHANNEL = "desktop:show-thread-notification"; +export const CLOSE_THREAD_NOTIFICATION_CHANNEL = "desktop:close-thread-notification"; +export const THREAD_NOTIFICATION_READY_CHANNEL = "desktop:thread-notification-ready"; +export const THREAD_NOTIFICATION_ACTIVATED_CHANNEL = "desktop:thread-notification-activated"; 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/threadNotifications.test.ts b/apps/desktop/src/ipc/methods/threadNotifications.test.ts new file mode 100644 index 0000000000000..e1a4321db9a06 --- /dev/null +++ b/apps/desktop/src/ipc/methods/threadNotifications.test.ts @@ -0,0 +1,62 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; + +import * as DesktopThreadNotifications from "../../app/DesktopThreadNotifications.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as ThreadNotifications from "./threadNotifications.ts"; + +it.effect("only the main renderer can display, clear, or receive thread notifications", () => { + const calls: Array = []; + const input = { + id: "c815595b-7739-4ff0-aef9-ec101a341639", + tag: "1234567890abcdef", + environmentId: "machine", + threadId: "thread", + title: "Thread completed", + body: "done", + }; + const layer = Layer.mergeAll( + Layer.succeed(ElectronWindow.ElectronWindow, { + main: Effect.succeedSome({ + isDestroyed: () => false, + webContents: { id: 1 }, + } as Electron.BrowserWindow), + } as ElectronWindow.ElectronWindow["Service"]), + Layer.succeed(DesktopThreadNotifications.DesktopThreadNotifications, { + register: Effect.void, + show: (value) => + Effect.sync(() => { + calls.push(value); + return true; + }), + close: (value) => + Effect.sync(() => { + calls.push(value); + }), + setRendererReady: (value) => + Effect.sync(() => { + calls.push(value); + }), + }), + ); + return Effect.gen(function* () { + for (const event of [undefined, { sender: { id: 2 } }]) { + assert.strictEqual(yield* ThreadNotifications.show.handler(input, event), false); + yield* ThreadNotifications.close.handler(input.id, event); + yield* ThreadNotifications.setReady.handler(true, event); + } + assert.deepStrictEqual(calls, []); + const event = { sender: { id: 1 } }; + const invalid = yield* Effect.exit( + ThreadNotifications.show.handler({ ...input, body: "x".repeat(1025) }, event), + ); + assert.isTrue(Exit.isFailure(invalid)); + assert.deepStrictEqual(calls, []); + assert.strictEqual(yield* ThreadNotifications.show.handler(input, event), true); + yield* ThreadNotifications.close.handler(input.id, event); + yield* ThreadNotifications.setReady.handler(true, event); + assert.deepStrictEqual(calls, [input, input.id, true]); + }).pipe(Effect.provide(layer)); +}); diff --git a/apps/desktop/src/ipc/methods/threadNotifications.ts b/apps/desktop/src/ipc/methods/threadNotifications.ts new file mode 100644 index 0000000000000..29269f07de5ad --- /dev/null +++ b/apps/desktop/src/ipc/methods/threadNotifications.ts @@ -0,0 +1,52 @@ +import { DesktopThreadNotification } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as DesktopThreadNotifications from "../../app/DesktopThreadNotifications.ts"; +import * as ElectronWindow from "../../electron/ElectronWindow.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import * as Channels from "../channels.ts"; + +const isMainSender = Effect.fn("desktop.ipc.threadNotifications.isMainSender")(function* ( + event: DesktopIpc.DesktopIpcInvokeEvent | undefined, +) { + const windows = yield* ElectronWindow.ElectronWindow; + const window = yield* windows.main; + return ( + event !== undefined && + Option.isSome(window) && + !window.value.isDestroyed() && + window.value.webContents.id === event.sender.id + ); +}); + +export const show = DesktopIpc.makeIpcMethod({ + channel: Channels.SHOW_THREAD_NOTIFICATION_CHANNEL, + payload: DesktopThreadNotification, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.threadNotifications.show")(function* (input, event) { + if (!(yield* isMainSender(event))) return false; + return yield* (yield* DesktopThreadNotifications.DesktopThreadNotifications).show(input); + }), +}); + +export const close = DesktopIpc.makeIpcMethod({ + channel: Channels.CLOSE_THREAD_NOTIFICATION_CHANNEL, + payload: Schema.NullOr(Schema.String.check(Schema.isUUID(4))), + result: Schema.Void, + handler: Effect.fn("desktop.ipc.threadNotifications.close")(function* (id, event) { + if (!(yield* isMainSender(event))) return; + yield* (yield* DesktopThreadNotifications.DesktopThreadNotifications).close(id); + }), +}); + +export const setReady = DesktopIpc.makeIpcMethod({ + channel: Channels.THREAD_NOTIFICATION_READY_CHANNEL, + payload: Schema.Boolean, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.threadNotifications.setReady")(function* (ready, event) { + if (!(yield* isMainSender(event))) return; + yield* (yield* DesktopThreadNotifications.DesktopThreadNotifications).setRendererReady(ready); + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 2fcb8d968c5a4..a61bb47d4bdab 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -36,6 +36,7 @@ import * as ElectronWindow from "./electron/ElectronWindow.ts"; import * as DesktopApp from "./app/DesktopApp.ts"; import * as DesktopAppActivation from "./app/DesktopAppActivation.ts"; import * as DesktopWebLinks from "./app/DesktopWebLinks.ts"; +import * as DesktopThreadNotifications from "./app/DesktopThreadNotifications.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; @@ -208,6 +209,7 @@ const layerDesktopApplication = Layer.mergeAll( DesktopLifecycle.layer, layerDesktopAppActivation, DesktopWebLinks.layer, + DesktopThreadNotifications.layer, DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, DesktopCliCommand.layer, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index da56807186ac5..66a8414f753fe 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -91,6 +91,19 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.on(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); return () => ipcRenderer.removeListener(IpcChannels.SET_NOTIFICATION_BADGE_CHANNEL, handler); }, + showThreadNotification: (notification) => + ipcRenderer.invoke(IpcChannels.SHOW_THREAD_NOTIFICATION_CHANNEL, notification), + closeThreadNotification: (id) => + ipcRenderer.invoke(IpcChannels.CLOSE_THREAD_NOTIFICATION_CHANNEL, id), + setThreadNotificationReady: (ready) => + ipcRenderer.invoke(IpcChannels.THREAD_NOTIFICATION_READY_CHANNEL, ready), + onThreadNotificationActivated: (listener) => { + const handler = (_event: Electron.IpcRendererEvent, target: Parameters[0]) => + listener(target); + ipcRenderer.on(IpcChannels.THREAD_NOTIFICATION_ACTIVATED_CHANNEL, handler); + return () => + ipcRenderer.removeListener(IpcChannels.THREAD_NOTIFICATION_ACTIVATED_CHANNEL, handler); + }, onTrackpadScrollBegin: (listener) => { const handler = () => listener(); ipcRenderer.on(IpcChannels.TRACKPAD_SCROLL_BEGIN_CHANNEL, handler); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index 8b5c2e0b43a01..6ba4499e0ca59 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -1,6 +1,10 @@ import { WORKING_EXIT_GRACE_MS } from "@t3tools/client-runtime/state/thread-inbox"; import type { AnyRouter } from "@tanstack/react-router"; -import { EnvironmentId } from "@t3tools/contracts"; +import { + EnvironmentId, + type DesktopThreadNotification, + type DesktopThreadNotificationTarget, +} from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Option from "effect/Option"; import { Atom, AtomRegistry } from "effect/reactivity"; @@ -261,6 +265,57 @@ it("holds a completion through a continuation gap and notifies once it settles", expect(TestNotification.sent.map((sent) => sent.title)).toEqual(["Thread completed"]); }); +it("uses native Windows completion notifications without a persistent browser banner", async () => { + await using app = setup(); + const show = vi.fn<(notification: DesktopThreadNotification) => Promise>( + async () => true, + ); + const close = vi.fn(async () => undefined); + let activate: ((target: DesktopThreadNotificationTarget) => void) | undefined; + using _bridge = stubGlobal("desktopBridge", { + getClientPlatform: () => "win32", + showThreadNotification: show, + closeThreadNotification: close, + setThreadNotificationReady: async () => true, + onThreadNotificationActivated: ( + listener: (target: DesktopThreadNotificationTarget) => void, + ) => { + activate = listener; + return () => { + activate = undefined; + }; + }, + onNotificationBadgeClear: () => () => undefined, + }); + app.start(); + using _timers = fakeTimers(); + state.shells.set( + "one", + shell({ + latestTurn: { turnId: "turn", state: "completed", completedAt: "2026-09-13T07:00:01.000Z" }, + }), + ); + await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); + expect(show).toHaveBeenCalledWith( + expect.objectContaining({ + environmentId: "one", + threadId: "thread", + title: "Thread completed", + body: "Test thread", + }), + ); + expect(TestNotification.sent).toEqual([]); + const shown = show.mock.calls[0]![0]; + activate!({ id: shown.id, environmentId: shown.environmentId, threadId: shown.threadId }); + expect(state.navigate).toHaveBeenCalledWith({ + to: "/$environmentId/$threadId", + params: { environmentId: "one", threadId: "thread" }, + }); + state.mode = "off"; + app.changeSettings(); + expect(close).toHaveBeenCalledWith(shown.id); +}); + it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( "notifies for offline completion and %s once across reconnects", async (flag) => { diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 74c9aa9561b99..6cbd4626e0df0 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -18,7 +18,7 @@ import { } from "lucide-react"; import { getClientSettings, subscribeClientSettings } from "../hooks/useSettings"; -import { isWindowsPlatform } from "../lib/utils"; +import { isWindowsPlatform, randomUUID } from "../lib/utils"; import { environmentSummaries } from "../state/presentation"; import { environmentShell } from "../state/shell"; import { environmentThreadShells } from "../state/threads"; @@ -64,14 +64,34 @@ export function startThreadNotifications( ): () => void { const pending = new Map< string, - { environmentId: EnvironmentId; threadId: ThreadId; notification: Notification } + { + environmentId: EnvironmentId; + threadId: ThreadId; + notification: Pick; + } >(); const watched = new Map(); + const bridge = window.desktopBridge; + const nativeNotifications = + bridge?.getClientPlatform() === "win32" && + bridge.showThreadNotification && + bridge.closeThreadNotification && + bridge.setThreadNotificationReady && + bridge.onThreadNotificationActivated + ? bridge + : null; + const stopNativeActivations = nativeNotifications?.onThreadNotificationActivated?.((target) => { + void router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId: target.environmentId, threadId: target.threadId }, + }); + }); + void nativeNotifications?.setThreadNotificationReady?.(true).catch(() => undefined); const trackNotification = ( environmentId: EnvironmentId, threadId: ThreadId, - notification: Notification, + notification: Pick, ) => { pending.get(notification.tag)?.notification.close(); pending.set(notification.tag, { environmentId, threadId, notification }); @@ -108,6 +128,7 @@ export function startThreadNotifications( const clearPending = () => { for (const { notification } of pending.values()) notification.close(); pending.clear(); + void nativeNotifications?.closeThreadNotification?.(null).catch(() => undefined); setNotificationBadge(0); }; @@ -189,11 +210,41 @@ export function startThreadNotifications( } if ( !hasDesktopNotifications(mode) || - (document.visibilityState === "visible" && document.hasFocus()) || - typeof Notification === "undefined" || - Notification.permission !== "granted" + (document.visibilityState === "visible" && document.hasFocus()) ) return; + if (nativeNotifications) { + const id = randomUUID(); + const tag = threadNotificationTag(environmentId, thread.id); + const notification = { + tag, + close: () => { + void nativeNotifications.closeThreadNotification?.(id).catch(() => undefined); + }, + }; + trackNotification(environmentId, thread.id, notification); + void nativeNotifications + .showThreadNotification?.({ + id, + tag, + environmentId, + threadId: thread.id, + title, + body: thread.title.slice(0, 1024), + }) + .then((shown) => { + if (shown || pending.get(tag)?.notification !== notification) return; + pending.delete(tag); + setNotificationBadge(pending.size); + }) + .catch(() => { + if (pending.get(tag)?.notification !== notification) return; + pending.delete(tag); + setNotificationBadge(pending.size); + }); + return; + } + if (typeof Notification === "undefined" || Notification.permission !== "granted") return; try { const notification = new Notification(title, { body: thread.title, @@ -411,6 +462,8 @@ export function startThreadNotifications( onSettings(); return () => { + stopNativeActivations?.(); + void nativeNotifications?.setThreadNotificationReady?.(false).catch(() => undefined); unsubscribeSettings(); unsubscribeEnvironmentIds(); for (const unsubscribe of unsubscribeHidden) unsubscribe(); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 5aa9acf2773a7..8a659be5aff77 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -2,7 +2,7 @@ import * as Schema from "effect/Schema"; import type { PreviewCaptureFailureEncoded } from "./previewCapture.ts"; import { SnapShotSource } from "./chatAttachment.ts"; -import { EnvironmentId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { EnvironmentId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { BrowserProfileId } from "./browserProfile.ts"; import type { BrowserImportResult, @@ -1022,6 +1022,21 @@ export type DesktopMediaRead = typeof DesktopMediaReadSchema.Type; export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]); export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; +export const DesktopThreadNotificationTarget = Schema.Struct({ + id: Schema.String.check(Schema.isUUID(4)), + environmentId: EnvironmentId.check(Schema.isMaxLength(512)), + threadId: ThreadId.check(Schema.isMaxLength(512)), +}); +export type DesktopThreadNotificationTarget = typeof DesktopThreadNotificationTarget.Type; + +export const DesktopThreadNotification = Schema.Struct({ + ...DesktopThreadNotificationTarget.fields, + tag: Schema.String.check(Schema.isPattern(/^[a-f0-9]{16}$/)), + title: Schema.String.check(Schema.isMaxLength(128)), + body: Schema.String.check(Schema.isMaxLength(1024)), +}); +export type DesktopThreadNotification = typeof DesktopThreadNotification.Type; + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getRelayTelemetryEnabled: () => boolean; @@ -1029,6 +1044,12 @@ export interface DesktopBridge { getClientPlatform: () => string; setNotificationBadge: (badge: { count: number; image: string | null }) => Promise; onNotificationBadgeClear: (listener: () => void) => () => void; + showThreadNotification?: (notification: DesktopThreadNotification) => Promise; + closeThreadNotification?: (id: string | null) => Promise; + setThreadNotificationReady?: (ready: boolean) => Promise; + onThreadNotificationActivated?: ( + listener: (target: DesktopThreadNotificationTarget) => void, + ) => () => void; onTrackpadScrollBegin: (listener: () => void) => () => void; onTrackpadScrollEnd: (listener: () => void) => () => void; /** From e39b69a007b88a94a8a2b5ce1208fbdf7d33cbc2 Mon Sep 17 00:00:00 2001 From: Jessy Le <106968649+JesstLe@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:33:54 +0800 Subject: [PATCH 2/6] test(desktop): keep native notification regression compatible with immediate completion --- .../ThreadNotificationCoordinator.badge.browser.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index 6ba4499e0ca59..4ae800aa5b4ad 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -288,14 +288,15 @@ it("uses native Windows completion notifications without a persistent browser ba onNotificationBadgeClear: () => () => undefined, }); app.start(); - using _timers = fakeTimers(); + using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; + vi.useFakeTimers(); state.shells.set( "one", shell({ latestTurn: { turnId: "turn", state: "completed", completedAt: "2026-09-13T07:00:01.000Z" }, }), ); - await vi.advanceTimersByTimeAsync(WORKING_EXIT_GRACE_MS); + await vi.runOnlyPendingTimersAsync(); expect(show).toHaveBeenCalledWith( expect.objectContaining({ environmentId: "one", From ac213061f2428e784340bbcfb2f7800d207b37a0 Mon Sep 17 00:00:00 2001 From: Jessy Le <106968649+JesstLe@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:37:45 +0800 Subject: [PATCH 3/6] test(desktop): use disposable browser timers for native notification regression --- .../ThreadNotificationCoordinator.badge.browser.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index 4ae800aa5b4ad..ab59af6aee798 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -288,8 +288,7 @@ it("uses native Windows completion notifications without a persistent browser ba onNotificationBadgeClear: () => () => undefined, }); app.start(); - using _timers = { [Symbol.dispose]: () => vi.useRealTimers() }; - vi.useFakeTimers(); + using _timers = (await import("../../test/render")).fakeTimers(); state.shells.set( "one", shell({ From 7e4dfc3e74e97f81dcc19e04c2f2990bb65dd992 Mon Sep 17 00:00:00 2001 From: JesstLe <106968649+JesstLe@users.noreply.github.com> Date: Wed, 4 Nov 2026 16:30:34 +0800 Subject: [PATCH 4/6] fix(desktop): preserve browser fallback for failed native notifications --- apps/desktop/src/app/DesktopApp.test.ts | 2 + ...ificationCoordinator.badge.browser.test.ts | 126 ++++++++++++++++++ .../ThreadNotificationCoordinator.tsx | 64 ++++----- 3 files changed, 162 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.test.ts b/apps/desktop/src/app/DesktopApp.test.ts index 66fc83adae194..bdb822ee2b84b 100644 --- a/apps/desktop/src/app/DesktopApp.test.ts +++ b/apps/desktop/src/app/DesktopApp.test.ts @@ -33,6 +33,7 @@ import * as DesktopLinuxUrlHandler from "./DesktopLinuxUrlHandler.ts"; import * as DesktopPreReadyPlatform from "./DesktopPreReadyPlatform.ts"; import * as DesktopShutdown from "./DesktopShutdown.ts"; import * as DesktopState from "./DesktopState.ts"; +import * as DesktopThreadNotifications from "./DesktopThreadNotifications.ts"; import * as DesktopBackendPool from "../backend/DesktopBackendPool.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -199,6 +200,7 @@ it.effect( Layer.mock(DesktopSshEnvironment.DesktopSshEnvironment, {}), Layer.mock(DesktopSshPasswordPrompts.DesktopSshPasswordPrompts, {}), Layer.mock(DesktopTelemetryPublisher.DesktopTelemetryPublisher, {}), + Layer.mock(DesktopThreadNotifications.DesktopThreadNotifications, {}), Layer.mock(DesktopWebLinks.DesktopWebLinks, {}), Layer.mock(DesktopWslEnvironment.DesktopWslEnvironment, {}), Layer.mock(ElectronMenu.ElectronMenu, {}), diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index c699cb00cd871..67fe3d69ea5f3 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -104,6 +104,16 @@ class TestNotification extends EventTarget { } } +function pendingDelivery() { + let resolve!: (shown: boolean) => void; + let reject!: (error: Error) => void; + const promise = new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }); + return { promise, resolve, reject }; +} + const thread = { id: "thread", title: "Test thread", @@ -309,6 +319,122 @@ it("uses native Windows completion notifications without a persistent browser ba expect(close).toHaveBeenCalledWith(shown.id); }); +it.each(["false", "reject"] as const)( + "falls back to a tracked browser notification when native delivery returns %s", + async (result) => { + await using app = setup(); + state.mode = "notifications-and-sound"; + const delivery = pendingDelivery(); + const show = vi.fn<(notification: DesktopThreadNotification) => Promise>( + () => delivery.promise, + ); + const close = vi.fn(async () => undefined); + using _bridge = stubGlobal("desktopBridge", { + getClientPlatform: () => "win32", + showThreadNotification: show, + closeThreadNotification: close, + setThreadNotificationReady: async () => true, + onThreadNotificationActivated: () => () => undefined, + onNotificationBadgeClear: () => () => undefined, + revealWindow: async () => undefined, + }); + app.start(); + state.badge.mockClear(); + state.shells.set("one", shell({ hasPendingApprovals: true })); + const shown = show.mock.calls[0]![0]; + if (result === "reject") delivery.reject(new Error("native delivery failed")); + else delivery.resolve(false); + await delivery.promise.catch(() => undefined); + + expect(TestNotification.sent).toHaveLength(1); + const notification = TestNotification.sent[0]!; + expect(notification.title).toBe("Approval needed"); + expect(notification.options).toMatchObject({ body: "Test thread", tag: shown.tag }); + expect(state.badge).toHaveBeenLastCalledWith(1); + expect(state.badge.mock.calls).not.toContainEqual([0]); + expect(state.sound).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledWith(shown.id); + notification.dispatchEvent(new Event("click")); + expect(state.navigate).toHaveBeenCalledWith({ + to: "/$environmentId/$threadId", + params: { environmentId: "one", threadId: "thread" }, + }); + state.mode = "off"; + app.changeSettings(); + expect(notification.close).toHaveBeenCalled(); + expect(state.badge).toHaveBeenLastCalledWith(0); + }, +); + +it.each(["false", "reject"] as const)( + "does not let a replaced native delivery returning %s replace the newer notification", + async (result) => { + await using app = setup(); + const delivery = pendingDelivery(); + const show = vi + .fn<(notification: DesktopThreadNotification) => Promise>(async () => true) + .mockReturnValueOnce(delivery.promise); + const close = vi.fn(async () => undefined); + using _bridge = stubGlobal("desktopBridge", { + getClientPlatform: () => "win32", + showThreadNotification: show, + closeThreadNotification: close, + setThreadNotificationReady: async () => true, + onThreadNotificationActivated: () => () => undefined, + onNotificationBadgeClear: () => () => undefined, + }); + app.start(); + state.shells.set("one", shell({ hasPendingApprovals: true })); + state.shells.set("one", shell({ hasPendingUserInput: true })); + expect(show).toHaveBeenCalledTimes(2); + const replaced = show.mock.calls[0]![0]; + const current = show.mock.calls[1]![0]; + if (result === "reject") delivery.reject(new Error("old delivery failed")); + else delivery.resolve(false); + await delivery.promise.catch(() => undefined); + + expect(TestNotification.sent).toEqual([]); + expect(close).toHaveBeenCalledWith(replaced.id); + expect(close).not.toHaveBeenCalledWith(current.id); + expect(state.badge).toHaveBeenLastCalledWith(1); + state.mode = "off"; + app.changeSettings(); + expect(close).toHaveBeenCalledWith(current.id); + }, +); + +it.each(["permission", "focus", "settings", "environment", "dispose"] as const)( + "does not revive a failed native notification after %s changes", + async (change) => { + await using app = setup(); + const delivery = pendingDelivery(); + using _bridge = stubGlobal("desktopBridge", { + getClientPlatform: () => "win32", + showThreadNotification: () => delivery.promise, + closeThreadNotification: async () => undefined, + setThreadNotificationReady: async () => true, + onThreadNotificationActivated: () => () => undefined, + onNotificationBadgeClear: () => () => undefined, + }); + app.start(); + state.shells.set("one", shell({ hasPendingApprovals: true })); + if (change === "permission") TestNotification.permission = "denied"; + else if (change === "focus") { + focused = true; + window.dispatchEvent(new Event("focus")); + } else if (change === "settings") { + state.mode = "off"; + app.changeSettings(); + } else if (change === "environment") app.setEnvironmentIds(["two"]); + else await app[Symbol.asyncDispose](); + delivery.reject(new Error("native delivery failed")); + await delivery.promise.catch(() => undefined); + + expect(TestNotification.sent).toEqual([]); + expect(state.badge).toHaveBeenLastCalledWith(0); + }, +); + it.each(["hasPendingApprovals", "hasPendingUserInput"] as const)( "notifies for offline completion and %s once across reconnects", async (flag) => { diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 9a82cb3c9836b..ea3e4a1f8b9ea 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -212,6 +212,30 @@ export function startThreadNotifications( (document.visibilityState === "visible" && document.hasFocus()) ) return; + const presentBrowserNotification = () => { + if (typeof Notification === "undefined" || Notification.permission !== "granted") return; + try { + const notification = new Notification(title, { + body: thread.title, + tag: threadNotificationTag(environmentId, thread.id), + silent: true, + // Windows drops clicks after a toast moves into Notification Center. + requireInteraction: kind === "completion" && isWindowsPlatform(navigator.platform), + }); + trackNotification(environmentId, thread.id, notification); + notification.addEventListener("click", () => { + notification.close(); + window.focus(); + void window.desktopBridge?.revealWindow().catch(() => undefined); + void router.navigate({ + to: "/$environmentId/$threadId", + params: { environmentId, threadId: thread.id }, + }); + }); + } catch { + // Some browsers expose Notification but reject desktop presentation. + } + }; if (nativeNotifications) { const id = randomUUID(); const tag = threadNotificationTag(environmentId, thread.id); @@ -222,6 +246,13 @@ export function startThreadNotifications( }, }; trackNotification(environmentId, thread.id, notification); + const fallback = () => { + if (pending.get(tag)?.notification !== notification) return; + presentBrowserNotification(); + if (pending.get(tag)?.notification !== notification) return; + pending.delete(tag); + setNotificationBadge(pending.size); + }; void nativeNotifications .showThreadNotification?.({ id, @@ -232,39 +263,12 @@ export function startThreadNotifications( body: thread.title.slice(0, 1024), }) .then((shown) => { - if (shown || pending.get(tag)?.notification !== notification) return; - pending.delete(tag); - setNotificationBadge(pending.size); + if (!shown) fallback(); }) - .catch(() => { - if (pending.get(tag)?.notification !== notification) return; - pending.delete(tag); - setNotificationBadge(pending.size); - }); + .catch(fallback); return; } - if (typeof Notification === "undefined" || Notification.permission !== "granted") return; - try { - const notification = new Notification(title, { - body: thread.title, - tag: threadNotificationTag(environmentId, thread.id), - silent: true, - // Windows drops clicks after a toast moves into Notification Center. - requireInteraction: kind === "completion" && isWindowsPlatform(navigator.platform), - }); - trackNotification(environmentId, thread.id, notification); - notification.addEventListener("click", () => { - notification.close(); - window.focus(); - void window.desktopBridge?.revealWindow().catch(() => undefined); - void router.navigate({ - to: "/$environmentId/$threadId", - params: { environmentId, threadId: thread.id }, - }); - }); - } catch { - // Some browsers expose Notification but reject desktop presentation. - } + presentBrowserNotification(); }; const observeThreads = ( From cc99c0393d5981f837e38874054606d9f12a689c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 20:29:15 +0000 Subject: [PATCH 5/6] test(desktop): keep one case per native notification regression AI-assisted; verified with the tests and tools listed above. --- .../app/DesktopThreadNotifications.test.ts | 17 +- .../ipc/methods/threadNotifications.test.ts | 6 - ...ificationCoordinator.badge.browser.test.ts | 202 ++++++++---------- 3 files changed, 94 insertions(+), 131 deletions(-) diff --git a/apps/desktop/src/app/DesktopThreadNotifications.test.ts b/apps/desktop/src/app/DesktopThreadNotifications.test.ts index 84bb660fd9216..dcf044f45ca8d 100644 --- a/apps/desktop/src/app/DesktopThreadNotifications.test.ts +++ b/apps/desktop/src/app/DesktopThreadNotifications.test.ts @@ -62,7 +62,7 @@ class NativeNotification extends NodeEvents.EventEmitter { } } -function setup(platform: NodeJS.Platform = "win32") { +function setup() { native.activate = null; native.initial = null; const notifications: NativeNotification[] = []; @@ -84,7 +84,7 @@ function setup(platform: NodeJS.Platform = "win32") { const layer = DesktopThreadNotifications.layer.pipe( Layer.provide([ Layer.succeed(DesktopEnvironment.DesktopEnvironment, { - platform, + platform: "win32", } as DesktopEnvironment.DesktopEnvironment["Service"]), Layer.succeed(ElectronWindow.ElectronWindow, { currentMainOrFirst: Effect.succeedSome(window), @@ -211,16 +211,3 @@ it.effect( ).pipe(Effect.provide(layer)); }, ); - -it.effect("does not register or present native notices on other platforms", () => { - const { notifications, layer } = setup("darwin"); - return Effect.scoped( - Effect.gen(function* () { - const service = yield* DesktopThreadNotifications.DesktopThreadNotifications; - yield* service.register; - assert.strictEqual(native.activate, null); - assert.strictEqual(yield* service.show(input), false); - assert.deepStrictEqual(notifications, []); - }), - ).pipe(Effect.provide(layer)); -}); diff --git a/apps/desktop/src/ipc/methods/threadNotifications.test.ts b/apps/desktop/src/ipc/methods/threadNotifications.test.ts index e1a4321db9a06..c317ae9624d57 100644 --- a/apps/desktop/src/ipc/methods/threadNotifications.test.ts +++ b/apps/desktop/src/ipc/methods/threadNotifications.test.ts @@ -1,6 +1,5 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Exit from "effect/Exit"; import * as Layer from "effect/Layer"; import * as DesktopThreadNotifications from "../../app/DesktopThreadNotifications.ts"; @@ -49,11 +48,6 @@ it.effect("only the main renderer can display, clear, or receive thread notifica } assert.deepStrictEqual(calls, []); const event = { sender: { id: 1 } }; - const invalid = yield* Effect.exit( - ThreadNotifications.show.handler({ ...input, body: "x".repeat(1025) }, event), - ); - assert.isTrue(Exit.isFailure(invalid)); - assert.deepStrictEqual(calls, []); assert.strictEqual(yield* ThreadNotifications.show.handler(input, event), true); yield* ThreadNotifications.close.handler(input.id, event); yield* ThreadNotifications.setReady.handler(true, event); diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index e481c1af20172..48059381f441d 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -358,121 +358,103 @@ it("uses native Windows completion notifications without a persistent browser ba expect(close).toHaveBeenCalledWith(shown.id); }); -it.each(["false", "reject"] as const)( - "falls back to a tracked browser notification when native delivery returns %s", - async (result) => { - await using app = setup(); - state.mode = "notifications-and-sound"; - const delivery = pendingDelivery(); - const show = vi.fn<(notification: DesktopThreadNotification) => Promise>( - () => delivery.promise, - ); - const close = vi.fn(async () => undefined); - using _bridge = stubGlobal("desktopBridge", { - getClientPlatform: () => "win32", - showThreadNotification: show, - closeThreadNotification: close, - setThreadNotificationReady: async () => true, - onThreadNotificationActivated: () => () => undefined, - onNotificationBadgeClear: () => () => undefined, - revealWindow: async () => undefined, - }); - app.start(); - state.badge.mockClear(); - state.shells.set("one", shell({ hasPendingApprovals: true })); - const shown = show.mock.calls[0]![0]; - if (result === "reject") delivery.reject(new Error("native delivery failed")); - else delivery.resolve(false); - await delivery.promise.catch(() => undefined); +it("falls back to a tracked browser notification when native delivery fails", async () => { + await using app = setup(); + state.mode = "notifications-and-sound"; + const delivery = pendingDelivery(); + const show = vi.fn<(notification: DesktopThreadNotification) => Promise>( + () => delivery.promise, + ); + const close = vi.fn(async () => undefined); + using _bridge = stubGlobal("desktopBridge", { + getClientPlatform: () => "win32", + showThreadNotification: show, + closeThreadNotification: close, + setThreadNotificationReady: async () => true, + onThreadNotificationActivated: () => () => undefined, + onNotificationBadgeClear: () => () => undefined, + revealWindow: async () => undefined, + }); + app.start(); + state.badge.mockClear(); + state.shells.set("one", shell({ hasPendingApprovals: true })); + const shown = show.mock.calls[0]![0]; + delivery.resolve(false); + await delivery.promise.catch(() => undefined); - expect(TestNotification.sent).toHaveLength(1); - const notification = TestNotification.sent[0]!; - expect(notification.title).toBe("Approval needed"); - expect(notification.options).toMatchObject({ body: "Test thread", tag: shown.tag }); - expect(state.badge).toHaveBeenLastCalledWith(1); - expect(state.badge.mock.calls).not.toContainEqual([0]); - expect(state.sound).toHaveBeenCalledTimes(1); - expect(close).toHaveBeenCalledWith(shown.id); - notification.dispatchEvent(new Event("click")); - expect(state.navigate).toHaveBeenCalledWith({ - to: "/$environmentId/$threadId", - params: { environmentId: "one", threadId: "thread" }, - }); - state.mode = "off"; - app.changeSettings(); - expect(notification.close).toHaveBeenCalled(); - expect(state.badge).toHaveBeenLastCalledWith(0); - }, -); + expect(TestNotification.sent).toHaveLength(1); + const notification = TestNotification.sent[0]!; + expect(notification.title).toBe("Approval needed"); + expect(notification.options).toMatchObject({ body: "Test thread", tag: shown.tag }); + expect(state.badge).toHaveBeenLastCalledWith(1); + expect(state.badge.mock.calls).not.toContainEqual([0]); + expect(state.sound).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledWith(shown.id); + notification.dispatchEvent(new Event("click")); + expect(state.navigate).toHaveBeenCalledWith({ + to: "/$environmentId/$threadId", + params: { environmentId: "one", threadId: "thread" }, + }); + state.mode = "off"; + app.changeSettings(); + expect(notification.close).toHaveBeenCalled(); + expect(state.badge).toHaveBeenLastCalledWith(0); +}); -it.each(["false", "reject"] as const)( - "does not let a replaced native delivery returning %s replace the newer notification", - async (result) => { - await using app = setup(); - const delivery = pendingDelivery(); - const show = vi - .fn<(notification: DesktopThreadNotification) => Promise>(async () => true) - .mockReturnValueOnce(delivery.promise); - const close = vi.fn(async () => undefined); - using _bridge = stubGlobal("desktopBridge", { - getClientPlatform: () => "win32", - showThreadNotification: show, - closeThreadNotification: close, - setThreadNotificationReady: async () => true, - onThreadNotificationActivated: () => () => undefined, - onNotificationBadgeClear: () => () => undefined, - }); - app.start(); - state.shells.set("one", shell({ hasPendingApprovals: true })); - state.shells.set("one", shell({ hasPendingUserInput: true })); - expect(show).toHaveBeenCalledTimes(2); - const replaced = show.mock.calls[0]![0]; - const current = show.mock.calls[1]![0]; - if (result === "reject") delivery.reject(new Error("old delivery failed")); - else delivery.resolve(false); - await delivery.promise.catch(() => undefined); +it("does not let a replaced native delivery failure replace the newer notification", async () => { + await using app = setup(); + const delivery = pendingDelivery(); + const show = vi + .fn<(notification: DesktopThreadNotification) => Promise>(async () => true) + .mockReturnValueOnce(delivery.promise); + const close = vi.fn(async () => undefined); + using _bridge = stubGlobal("desktopBridge", { + getClientPlatform: () => "win32", + showThreadNotification: show, + closeThreadNotification: close, + setThreadNotificationReady: async () => true, + onThreadNotificationActivated: () => () => undefined, + onNotificationBadgeClear: () => () => undefined, + }); + app.start(); + state.shells.set("one", shell({ hasPendingApprovals: true })); + state.shells.set("one", shell({ hasPendingUserInput: true })); + expect(show).toHaveBeenCalledTimes(2); + const replaced = show.mock.calls[0]![0]; + const current = show.mock.calls[1]![0]; + delivery.reject(new Error("old delivery failed")); + await delivery.promise.catch(() => undefined); - expect(TestNotification.sent).toEqual([]); - expect(close).toHaveBeenCalledWith(replaced.id); - expect(close).not.toHaveBeenCalledWith(current.id); - expect(state.badge).toHaveBeenLastCalledWith(1); - state.mode = "off"; - app.changeSettings(); - expect(close).toHaveBeenCalledWith(current.id); - }, -); + expect(TestNotification.sent).toEqual([]); + expect(close).toHaveBeenCalledWith(replaced.id); + expect(close).not.toHaveBeenCalledWith(current.id); + expect(state.badge).toHaveBeenLastCalledWith(1); + state.mode = "off"; + app.changeSettings(); + expect(close).toHaveBeenCalledWith(current.id); +}); -it.each(["permission", "focus", "settings", "environment", "dispose"] as const)( - "does not revive a failed native notification after %s changes", - async (change) => { - await using app = setup(); - const delivery = pendingDelivery(); - using _bridge = stubGlobal("desktopBridge", { - getClientPlatform: () => "win32", - showThreadNotification: () => delivery.promise, - closeThreadNotification: async () => undefined, - setThreadNotificationReady: async () => true, - onThreadNotificationActivated: () => () => undefined, - onNotificationBadgeClear: () => () => undefined, - }); - app.start(); - state.shells.set("one", shell({ hasPendingApprovals: true })); - if (change === "permission") TestNotification.permission = "denied"; - else if (change === "focus") { - focused = true; - window.dispatchEvent(new Event("focus")); - } else if (change === "settings") { - state.mode = "off"; - app.changeSettings(); - } else if (change === "environment") app.setEnvironmentIds(["two"]); - else await app[Symbol.asyncDispose](); - delivery.reject(new Error("native delivery failed")); - await delivery.promise.catch(() => undefined); +it("does not revive a failed native notification after settings turn it off", async () => { + await using app = setup(); + const delivery = pendingDelivery(); + using _bridge = stubGlobal("desktopBridge", { + getClientPlatform: () => "win32", + showThreadNotification: () => delivery.promise, + closeThreadNotification: async () => undefined, + setThreadNotificationReady: async () => true, + onThreadNotificationActivated: () => () => undefined, + onNotificationBadgeClear: () => () => undefined, + }); + app.start(); + state.shells.set("one", shell({ hasPendingApprovals: true })); + state.mode = "off"; + app.changeSettings(); + delivery.reject(new Error("native delivery failed")); + await delivery.promise.catch(() => undefined); - expect(TestNotification.sent).toEqual([]); - expect(state.badge).toHaveBeenLastCalledWith(0); - }, -); + expect(TestNotification.sent).toEqual([]); + expect(state.badge).toHaveBeenLastCalledWith(0); +}); it.each([false, true])( "presents one completion when a monitor ends with a final wake (disconnected: %s)", From 847c6f6979212610bce59bf5a7de996a0c07dc89 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 23:51:27 +0000 Subject: [PATCH 6/6] test(web): drop duplicate stale native-delivery case The replaced-delivery case already covers the guard that keeps a stale native failure from presenting a browser notification. Co-Authored-By: Claude Opus 5.5 --- ...ificationCoordinator.badge.browser.test.ts | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index 48059381f441d..9e674b64aff02 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -434,28 +434,6 @@ it("does not let a replaced native delivery failure replace the newer notificati expect(close).toHaveBeenCalledWith(current.id); }); -it("does not revive a failed native notification after settings turn it off", async () => { - await using app = setup(); - const delivery = pendingDelivery(); - using _bridge = stubGlobal("desktopBridge", { - getClientPlatform: () => "win32", - showThreadNotification: () => delivery.promise, - closeThreadNotification: async () => undefined, - setThreadNotificationReady: async () => true, - onThreadNotificationActivated: () => () => undefined, - onNotificationBadgeClear: () => () => undefined, - }); - app.start(); - state.shells.set("one", shell({ hasPendingApprovals: true })); - state.mode = "off"; - app.changeSettings(); - delivery.reject(new Error("native delivery failed")); - await delivery.promise.catch(() => undefined); - - expect(TestNotification.sent).toEqual([]); - expect(state.badge).toHaveBeenLastCalledWith(0); -}); - it.each([false, true])( "presents one completion when a monitor ends with a final wake (disconnected: %s)", async (disconnect) => {