diff --git a/apps/desktop/src/electron/ElectronNotification.test.ts b/apps/desktop/src/electron/ElectronNotification.test.ts new file mode 100644 index 0000000000..52af64edf4 --- /dev/null +++ b/apps/desktop/src/electron/ElectronNotification.test.ts @@ -0,0 +1,96 @@ +import { queryObjects } from "node:v8"; +import { assert, describe, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; + +const { NativeNotification } = vi.hoisted(() => { + class NativeNotification { + static closed = 0; + static eventOnShow: string | undefined; + static closeReason: string | undefined; + private handlers = new Map void>(); + static isSupported() { + return true; + } + on(event: string, callback: (event: { reason: string | undefined }) => void) { + this.handlers.set(event, () => callback({ reason: NativeNotification.closeReason })); + return this; + } + once(event: string, callback: () => void) { + return this.on(event, callback); + } + show() { + if (NativeNotification.eventOnShow) this.handlers.get(NativeNotification.eventOnShow)?.(); + } + close() { + NativeNotification.closed++; + } + } + return { NativeNotification }; +}); + +vi.mock("electron", () => ({ Notification: NativeNotification })); + +import * as ElectronNotification from "./ElectronNotification.ts"; + +describe("ElectronNotification lifetime", () => { + it("keeps displayed notifications alive for clicks and releases them at shutdown", () => { + // queryObjects performs a full collection before counting; no timing or GC polling. + const baseline = queryObjects(NativeNotification); + if (typeof baseline !== "number") throw new Error("Expected an instance count"); + const closedBefore = NativeNotification.closed; + Effect.runSync( + Effect.gen(function* () { + const service = yield* ElectronNotification.ElectronNotification; + yield* service.show({ title: "Thread", body: "Agent finished", onClick: () => {} }); + assert.strictEqual(queryObjects(NativeNotification), baseline + 1); + }).pipe(Effect.provide(ElectronNotification.layer), Effect.scoped), + ); + assert.strictEqual(NativeNotification.closed, closedBefore + 1); + assert.strictEqual(queryObjects(NativeNotification), baseline); + }); + for (const event of ["click", "close", "failed"]) { + it(`releases a notification after ${event}`, () => { + const baseline = queryObjects(NativeNotification); + let clicked = 0; + NativeNotification.eventOnShow = event; + try { + Effect.runSync( + Effect.gen(function* () { + const service = yield* ElectronNotification.ElectronNotification; + yield* service.show({ + title: "Thread", + body: "Done", + onClick: () => { + clicked++; + }, + }); + assert.strictEqual(queryObjects(NativeNotification), baseline); + assert.strictEqual(clicked, event === "click" ? 1 : 0); + }).pipe(Effect.provide(ElectronNotification.layer), Effect.scoped), + ); + } finally { + NativeNotification.eventOnShow = undefined; + } + }); + } + + it("retains a Windows notification moved into Action Center", () => { + const baseline = queryObjects(NativeNotification); + if (typeof baseline !== "number") throw new Error("Expected an instance count"); + NativeNotification.eventOnShow = "close"; + NativeNotification.closeReason = "timedOut"; + try { + Effect.runSync( + Effect.gen(function* () { + const service = yield* ElectronNotification.ElectronNotification; + yield* service.show({ title: "Thread", body: "Done", onClick: () => {} }); + assert.strictEqual(queryObjects(NativeNotification), baseline + 1); + }).pipe(Effect.provide(ElectronNotification.layer), Effect.scoped), + ); + } finally { + NativeNotification.eventOnShow = undefined; + NativeNotification.closeReason = undefined; + } + }); +}); diff --git a/apps/desktop/src/electron/ElectronNotification.ts b/apps/desktop/src/electron/ElectronNotification.ts new file mode 100644 index 0000000000..20a0c53899 --- /dev/null +++ b/apps/desktop/src/electron/ElectronNotification.ts @@ -0,0 +1,62 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +import * as Electron from "electron"; + +export class ElectronNotification extends Context.Service< + ElectronNotification, + { + /** False on Linux without a notification daemon; the feature no-ops. */ + readonly isSupported: Effect.Effect; + readonly show: (input: { + readonly title: string; + readonly body: string; + readonly onClick: () => void; + }) => Effect.Effect; + } +>()("@t3tools/desktop/electron/ElectronNotification") {} + +export const make = Effect.gen(function* () { + // Electron drops the native event delegate when the JS notification is collected. + const pending = new Set(); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + for (const notification of pending) notification.close(); + pending.clear(); + }), + ); + + return ElectronNotification.of({ + isSupported: Effect.sync(() => Electron.Notification.isSupported()), + show: (input) => + Effect.sync(() => { + const notification = new Electron.Notification({ title: input.title, body: input.body }); + pending.add(notification); + const release = () => { + pending.delete(notification); + }; + notification.once("click", () => { + try { + input.onClick(); + } finally { + release(); + } + }); + notification.on("close", (event) => { + // Windows can move a banner into Action Center while it remains clickable. + if (event.reason === "timedOut" || event.reason === "applicationHidden") return; + release(); + }); + notification.once("failed", release); + try { + notification.show(); + } catch (cause) { + release(); + throw cause; + } + }), + }); +}); + +export const layer = Layer.effect(ElectronNotification, make); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 3e30083064..58556917d3 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -50,6 +50,8 @@ import * as PreviewIpc from "./methods/preview.ts"; import * as AppActivationIpc from "./methods/appActivation.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; +import { notifyAgentAwareness, sendTestNotification } from "./methods/notifications.ts"; + export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers")(function* () { const ipc = yield* DesktopIpc.DesktopIpc; yield* PreviewIpc.installPreviewEventForwarding(); @@ -92,6 +94,8 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(pickFolder); yield* ipc.handle(pickProjectFavicon); yield* ipc.handle(pickThemeFiles); + yield* ipc.handle(notifyAgentAwareness); + yield* ipc.handle(sendTestNotification); yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 5b2c815eaa..644289884f 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -90,3 +90,8 @@ export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; export const PREVIEW_RECORDING_FRAME_CHANNEL = "desktop:preview-recording-frame"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; + +export const NOTIFICATION_NAVIGATE_CHANNEL = "desktop:notification-navigate"; + +export const NOTIFY_AGENT_AWARENESS_CHANNEL = "desktop:notify-agent-awareness"; +export const SEND_TEST_NOTIFICATION_CHANNEL = "desktop:send-test-notification"; diff --git a/apps/desktop/src/ipc/methods/notifications.ts b/apps/desktop/src/ipc/methods/notifications.ts new file mode 100644 index 0000000000..955fe4b42f --- /dev/null +++ b/apps/desktop/src/ipc/methods/notifications.ts @@ -0,0 +1,27 @@ +import { DesktopNotificationCandidate } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopNotifications from "../../notifications/DesktopNotifications.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const notifyAgentAwareness = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.NOTIFY_AGENT_AWARENESS_CHANNEL, + payload: Schema.Array(DesktopNotificationCandidate), + result: Schema.Void, + handler: Effect.fn("desktop.ipc.notifications.notifyAgentAwareness")(function* (candidates) { + const notifications = yield* DesktopNotifications.DesktopNotifications; + yield* notifications.deliver(candidates); + }), +}); + +export const sendTestNotification = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SEND_TEST_NOTIFICATION_CHANNEL, + payload: Schema.Undefined, + result: Schema.Boolean, + handler: Effect.fn("desktop.ipc.notifications.sendTestNotification")(function* () { + const notifications = yield* DesktopNotifications.DesktopNotifications; + return yield* notifications.sendTest; + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3337228aa9..5fb16f4b16 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -23,6 +23,8 @@ import serverPackageJson from "../../server/package.json" with { type: "json" }; import * as DesktopIpc from "./ipc/DesktopIpc.ts"; import * as ElectronApp from "./electron/ElectronApp.ts"; import * as ElectronDialog from "./electron/ElectronDialog.ts"; +import * as ElectronNotification from "./electron/ElectronNotification.ts"; +import * as DesktopNotifications from "./notifications/DesktopNotifications.ts"; import * as ElectronMenu from "./electron/ElectronMenu.ts"; import * as ElectronPowerMonitor from "./electron/ElectronPowerMonitor.ts"; import * as ElectronProtocol from "./electron/ElectronProtocol.ts"; @@ -121,6 +123,7 @@ const electronLayer = Layer.mergeAll( ElectronApp.layer, ElectronDialog.layer, ElectronMenu.layer, + ElectronNotification.layer, ElectronPowerMonitor.layer, ElectronProtocol.layer, ElectronSafeStorage.layer, @@ -195,6 +198,7 @@ const desktopLocalEnvironmentAuthLayer = DesktopLocalEnvironmentAuth.layer.pipe( const desktopApplicationLayer = Layer.mergeAll( DesktopLifecycle.layer, desktopAppActivationLayer, + DesktopNotifications.layer.pipe(Layer.provide(desktopWindowLayer)), DesktopApplicationMenu.layer, DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, diff --git a/apps/desktop/src/notifications/DesktopNotifications.test.ts b/apps/desktop/src/notifications/DesktopNotifications.test.ts new file mode 100644 index 0000000000..5bd8f5ed7a --- /dev/null +++ b/apps/desktop/src/notifications/DesktopNotifications.test.ts @@ -0,0 +1,141 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import { vi } from "vite-plus/test"; +import type * as Electron from "electron"; + +import * as ElectronNotification from "../electron/ElectronNotification.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; +import { NOTIFICATION_NAVIGATE_CHANNEL } from "../ipc/channels.ts"; +import * as DesktopNotifications from "./DesktopNotifications.ts"; + +const candidates = [ + { environmentId: "env-1", threadId: "t1", title: "First — Pylon", body: "Agent finished" }, + { environmentId: "env-2", threadId: "t2", title: "Second — Pylon", body: "Approval needed" }, +]; + +function harness( + options: { supported?: boolean; focused?: boolean; noWindow?: boolean; onSend?: () => void } = {}, +) { + const shown: Array[0]> = + []; + const send = vi.fn(() => options.onSend?.()); + const window = { + isFocused: () => options.focused ?? false, + webContents: { send }, + } as unknown as Electron.BrowserWindow; + const reveal = vi.fn(() => window); + const dependencies = Layer.mergeAll( + Layer.succeed(ElectronNotification.ElectronNotification, { + isSupported: Effect.succeed(options.supported ?? true), + show: (input) => + Effect.sync(() => { + shown.push(input); + }), + }), + Layer.mock(ElectronWindow.ElectronWindow)({ + focusedMainOrFirst: Effect.succeed(options.noWindow ? Option.none() : Option.some(window)), + }), + Layer.mock(DesktopWindow.DesktopWindow)({ revealOrCreateMain: Effect.sync(reveal) }), + ); + return { + shown, + send, + reveal, + layer: DesktopNotifications.layer.pipe(Layer.provide(dependencies)), + }; +} + +describe("DesktopNotifications", () => { + it.effect("drops all candidates while a Pylon window is focused", () => { + const h = harness({ focused: true }); + return Effect.gen(function* () { + const service = yield* DesktopNotifications.DesktopNotifications; + yield* service.deliver(candidates); + assert.deepEqual(h.shown, []); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("delivers each candidate when the fallback main window is unfocused", () => { + const h = harness(); + return Effect.gen(function* () { + const service = yield* DesktopNotifications.DesktopNotifications; + yield* service.deliver(candidates); + assert.deepEqual( + h.shown.map(({ title, body }) => ({ title, body })), + candidates.map(({ title, body }) => ({ title, body })), + ); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("delivers when the focused-window lookup is empty", () => { + const h = harness({ noWindow: true }); + return Effect.gen(function* () { + const service = yield* DesktopNotifications.DesktopNotifications; + yield* service.deliver(candidates); + assert.lengthOf(h.shown, 2); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("silently drops candidates when unsupported", () => { + const h = harness({ supported: false }); + return Effect.gen(function* () { + const service = yield* DesktopNotifications.DesktopNotifications; + yield* service.deliver(candidates); + assert.deepEqual(h.shown, []); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("returns false for an unsupported test notification", () => { + const h = harness({ supported: false }); + return Effect.gen(function* () { + const service = yield* DesktopNotifications.DesktopNotifications; + assert.isFalse(yield* service.sendTest); + assert.deepEqual(h.shown, []); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("shows the test notification even while focused", () => { + const h = harness({ focused: true }); + return Effect.gen(function* () { + const service = yield* DesktopNotifications.DesktopNotifications; + assert.isTrue(yield* service.sendTest); + assert.lengthOf(h.shown, 1); + assert.strictEqual(h.shown[0]?.title, "Pylon"); + assert.strictEqual( + h.shown[0]?.body, + "Notifications are working. Pylon will tell you when an agent needs you.", + ); + h.shown[0]?.onClick(); + assert.strictEqual(h.reveal.mock.calls.length, 0); + }).pipe(Effect.provide(h.layer)); + }); + + it.effect("reveals the window before sending the clicked candidate's route parameters", () => + Effect.gen(function* () { + const sent = yield* Deferred.make(); + const runSync = Effect.runSyncWith(yield* Effect.context()); + const h = harness({ + onSend: () => { + runSync(Deferred.succeed(sent, undefined)); + }, + }); + yield* Effect.gen(function* () { + const service = yield* DesktopNotifications.DesktopNotifications; + yield* service.deliver(candidates); + const second = h.shown[1]; + assert.isDefined(second); + second!.onClick(); + yield* Deferred.await(sent); + assert.strictEqual(h.reveal.mock.calls.length, 1); + assert.deepEqual(h.send.mock.calls, [ + [NOTIFICATION_NAVIGATE_CHANNEL, { environmentId: "env-2", threadId: "t2" }], + ]); + assert.isBelow(h.reveal.mock.invocationCallOrder[0]!, h.send.mock.invocationCallOrder[0]!); + }).pipe(Effect.provide(h.layer)); + }), + ); +}); diff --git a/apps/desktop/src/notifications/DesktopNotifications.ts b/apps/desktop/src/notifications/DesktopNotifications.ts new file mode 100644 index 0000000000..a212998175 --- /dev/null +++ b/apps/desktop/src/notifications/DesktopNotifications.ts @@ -0,0 +1,71 @@ +import type { DesktopNotificationCandidate } from "@t3tools/contracts"; +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 { makeComponentLogger } from "../app/DesktopObservability.ts"; +import * as ElectronNotification from "../electron/ElectronNotification.ts"; +import * as ElectronWindow from "../electron/ElectronWindow.ts"; +import { NOTIFICATION_NAVIGATE_CHANNEL } from "../ipc/channels.ts"; +import * as DesktopWindow from "../window/DesktopWindow.ts"; + +export class DesktopNotifications extends Context.Service< + DesktopNotifications, + { + readonly deliver: ( + candidates: ReadonlyArray, + ) => Effect.Effect; + readonly sendTest: Effect.Effect; + } +>()("@t3tools/desktop/notifications/DesktopNotifications") {} + +const { logWarning } = makeComponentLogger("desktop-notifications"); + +export const make = Effect.gen(function* () { + const notifications = yield* ElectronNotification.ElectronNotification; + const windows = yield* ElectronWindow.ElectronWindow; + const desktopWindow = yield* DesktopWindow.DesktopWindow; + const context = yield* Effect.context(); + const runFork = Effect.runForkWith(context); + + const navigate = Effect.fn("desktop.notifications.navigate")( + function* (candidate: DesktopNotificationCandidate) { + const window = yield* desktopWindow.revealOrCreateMain; + window.webContents.send(NOTIFICATION_NAVIGATE_CHANNEL, { + environmentId: candidate.environmentId, + threadId: candidate.threadId, + }); + }, + Effect.catchCause((cause) => logWarning("failed to open notification thread", { cause })), + ); + + return DesktopNotifications.of({ + deliver: Effect.fn("desktop.notifications.deliver")(function* (candidates) { + if (!(yield* notifications.isSupported)) return; + const focused = yield* windows.focusedMainOrFirst; + // The accessor falls back to the main window even when no window is focused. + if (Option.isSome(focused) && focused.value.isFocused()) return; + for (const candidate of candidates) { + yield* notifications.show({ + title: candidate.title, + body: candidate.body, + onClick: () => { + runFork(navigate(candidate)); + }, + }); + } + }), + sendTest: Effect.gen(function* () { + if (!(yield* notifications.isSupported)) return false; + yield* notifications.show({ + title: "Pylon", + body: "Notifications are working. Pylon will tell you when an agent needs you.", + onClick: () => {}, + }); + return true; + }), + }); +}); + +export const layer = Layer.effect(DesktopNotifications, make); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index c93ee67b5c..310cb390f2 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -28,6 +28,30 @@ function unwrapEnsureSshEnvironmentResult(result: unknown) { } contextBridge.exposeInMainWorld("desktopBridge", { + notifyAgentAwareness: (candidates) => + ipcRenderer.invoke(IpcChannels.NOTIFY_AGENT_AWARENESS_CHANNEL, candidates), + sendTestNotification: () => + ipcRenderer.invoke(IpcChannels.SEND_TEST_NOTIFICATION_CHANNEL, undefined), + onNotificationNavigate: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, target: unknown) => { + if ( + typeof target !== "object" || + target === null || + !("environmentId" in target) || + typeof target.environmentId !== "string" || + !("threadId" in target) || + typeof target.threadId !== "string" + ) + return; + listener({ environmentId: target.environmentId, threadId: target.threadId }); + }; + + ipcRenderer.on(IpcChannels.NOTIFICATION_NAVIGATE_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.NOTIFICATION_NAVIGATE_CHANNEL, wrappedListener); + }; + }, + getAppBranding: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_APP_BRANDING_CHANNEL); if (typeof result !== "object" || result === null) { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 8f204da03b..a64f654df5 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -26,6 +26,11 @@ const clientSettings: ClientSettings = { confirmThreadArchive: true, confirmThreadDelete: false, confirmThreadUnpin: false, + desktopNotificationsEnabled: true, + desktopNotifyOnApproval: true, + desktopNotifyOnInput: true, + desktopNotifyOnCompletion: true, + desktopNotifyOnFailure: true, contextWindowMeterEnabled: false, composerCollapseOnScroll: true, dismissedProviderUpdateNotificationKeys: [], diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 334aa529d4..9ed4e952b7 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -198,6 +198,16 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { return unsubscribe; }, [isMacosDesktop]); + useEffect(() => { + const onNotificationNavigate = window.desktopBridge?.onNotificationNavigate; + if (typeof onNotificationNavigate !== "function") { + return; + } + return onNotificationNavigate(({ environmentId, threadId }) => { + void navigate({ to: "/$environmentId/$threadId", params: { environmentId, threadId } }); + }); + }, [navigate]); + useEffect(() => { const onMenuAction = window.desktopBridge?.onMenuAction; if (typeof onMenuAction !== "function") { diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 7479a32dbd..7b56c88b1b 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2769,6 +2769,179 @@ export function GeneralSettingsPanel() { ) : null} + {isElectron ? ( + + + updateSettings({ + desktopNotificationsEnabled: + DEFAULT_UNIFIED_SETTINGS.desktopNotificationsEnabled, + }) + } + /> + ) : null + } + control={ + + updateSettings({ desktopNotificationsEnabled: Boolean(checked) }) + } + aria-label="Desktop notifications" + /> + } + /> + + updateSettings({ + desktopNotifyOnApproval: DEFAULT_UNIFIED_SETTINGS.desktopNotifyOnApproval, + }) + } + /> + ) : null + } + control={ + + updateSettings({ desktopNotifyOnApproval: Boolean(checked) }) + } + aria-label="Notify when approval is needed" + /> + } + /> + + updateSettings({ + desktopNotifyOnInput: DEFAULT_UNIFIED_SETTINGS.desktopNotifyOnInput, + }) + } + /> + ) : null + } + control={ + + updateSettings({ desktopNotifyOnInput: Boolean(checked) }) + } + aria-label="Notify when input is needed" + /> + } + /> + + updateSettings({ + desktopNotifyOnCompletion: DEFAULT_UNIFIED_SETTINGS.desktopNotifyOnCompletion, + }) + } + /> + ) : null + } + control={ + + updateSettings({ desktopNotifyOnCompletion: Boolean(checked) }) + } + aria-label="Notify when an agent finishes" + /> + } + /> + + updateSettings({ + desktopNotifyOnFailure: DEFAULT_UNIFIED_SETTINGS.desktopNotifyOnFailure, + }) + } + /> + ) : null + } + control={ + + updateSettings({ desktopNotifyOnFailure: Boolean(checked) }) + } + aria-label="Notify when an agent fails" + /> + } + /> + { + void window.desktopBridge + ?.sendTestNotification?.() + .then((supported) => { + if (supported === false) { + toastManager.add({ + type: "warning", + title: "Notifications unavailable", + description: + "The system reports no notification support. On Linux, check that a notification daemon is running.", + }); + } + }) + .catch(() => { + toastManager.add({ + type: "error", + title: "Could not send test notification", + description: "Try again and check your system notification settings.", + }); + }); + }} + > + Send test notification + + } + /> + + ) : null} + > & { threadId: string }, +): AgentAwarenessState { + return { + environmentId: EnvironmentId.make("env-1"), + projectTitle: "Pylon", + threadTitle: "Fix the sidebar", + phase: "running", + headline: "Agent is working", + modelTitle: "some-model", + updatedAt: "2026-09-08T00:00:00.000Z", + deepLink: `/threads/env-1/${overrides.threadId}`, + ...overrides, + threadId: ThreadId.make(overrides.threadId), + }; +} + +describe("reconcileAwarenessNotifications", () => { + it("primes on first snapshot without notifying", () => { + const { notifications, nextPhases } = reconcileAwarenessNotifications({ + previousPhases: new Map(), + states: [makeState({ threadId: "t1", phase: "completed", headline: "Agent finished" })], + preferences: allOn, + }); + expect(notifications).toEqual([]); + expect(nextPhases.get("env-1:t1")).toBe("completed"); + }); + + it("produces nothing when a reconnect replays unchanged phases", () => { + const primed = new Map([["env-1:t1", "completed" as const]]); + const { notifications } = reconcileAwarenessNotifications({ + previousPhases: primed, + states: [makeState({ threadId: "t1", phase: "completed", headline: "Agent finished" })], + preferences: allOn, + }); + expect(notifications).toEqual([]); + }); + + it("fires on entry into each notifiable phase", () => { + for (const [phase, headline] of [ + ["waiting_for_approval", "Approval needed"], + ["waiting_for_input", "Waiting for input"], + ["completed", "Agent finished"], + ["failed", "Agent failed"], + ] as const) { + const { notifications } = reconcileAwarenessNotifications({ + previousPhases: new Map([["env-1:t1", "running" as const]]), + states: [makeState({ threadId: "t1", phase, headline })], + preferences: allOn, + }); + expect(notifications).toHaveLength(1); + expect(notifications[0]).toEqual({ + environmentId: "env-1", + threadId: "t1", + title: "Fix the sidebar — Pylon", + body: headline, + }); + } + }); + + it("never fires for starting, running, or stale", () => { + for (const phase of ["starting", "running", "stale"] as const) { + const { notifications } = reconcileAwarenessNotifications({ + previousPhases: new Map([["env-1:t1", "completed" as const]]), + states: [makeState({ threadId: "t1", phase })], + preferences: allOn, + }); + expect(notifications).toEqual([]); + } + }); + + it("fires once per phase entry, not per update within a phase", () => { + const first = reconcileAwarenessNotifications({ + previousPhases: new Map([["env-1:t1", "running" as const]]), + states: [ + makeState({ threadId: "t1", phase: "waiting_for_approval", headline: "Approval needed" }), + ], + preferences: allOn, + }); + const second = reconcileAwarenessNotifications({ + previousPhases: first.nextPhases, + states: [ + makeState({ threadId: "t1", phase: "waiting_for_approval", headline: "Approval needed" }), + ], + preferences: allOn, + }); + expect(first.notifications).toHaveLength(1); + expect(second.notifications).toEqual([]); + }); + + it("fires again when a thread re-enters waiting_for_approval after running", () => { + const approvedOnce = new Map([["env-1:t1", "waiting_for_approval" as const]]); + const ran = reconcileAwarenessNotifications({ + previousPhases: approvedOnce, + states: [makeState({ threadId: "t1", phase: "running" })], + preferences: allOn, + }); + const askedAgain = reconcileAwarenessNotifications({ + previousPhases: ran.nextPhases, + states: [ + makeState({ threadId: "t1", phase: "waiting_for_approval", headline: "Approval needed" }), + ], + preferences: allOn, + }); + expect(ran.notifications).toEqual([]); + expect(askedAgain.notifications).toHaveLength(1); + }); + + it("a thread first appearing mid-session primes silently and drops from the map when gone", () => { + const existing = new Map([["env-1:t1", "running" as const]]); + const appeared = reconcileAwarenessNotifications({ + previousPhases: existing, + states: [ + makeState({ threadId: "t1", phase: "running" }), + makeState({ threadId: "t2", phase: "completed", headline: "Agent finished" }), + ], + preferences: allOn, + }); + expect(appeared.notifications).toEqual([]); + const removed = reconcileAwarenessNotifications({ + previousPhases: appeared.nextPhases, + states: [makeState({ threadId: "t1", phase: "running" })], + preferences: allOn, + }); + expect(removed.nextPhases.has("env-1:t2")).toBe(false); + }); + + it("each toggle gates only its own phase; the master switch suppresses all", () => { + const previous = new Map([["env-1:t1", "running" as const]]); + const completedState = [ + makeState({ threadId: "t1", phase: "completed", headline: "Agent finished" }), + ]; + expect( + reconcileAwarenessNotifications({ + previousPhases: previous, + states: completedState, + preferences: { ...allOn, notifyOnCompletion: false }, + }).notifications, + ).toEqual([]); + const failedState = [makeState({ threadId: "t1", phase: "failed", headline: "Agent failed" })]; + expect( + reconcileAwarenessNotifications({ + previousPhases: previous, + states: failedState, + preferences: { ...allOn, notifyOnCompletion: false }, + }).notifications, + ).toHaveLength(1); + expect( + reconcileAwarenessNotifications({ + previousPhases: previous, + states: failedState, + preferences: { ...allOn, enabled: false }, + }).notifications, + ).toEqual([]); + }); + + it("still records phases while suppressed, so enabling later never replays", () => { + const suppressed = reconcileAwarenessNotifications({ + previousPhases: new Map([["env-1:t1", "running" as const]]), + states: [makeState({ threadId: "t1", phase: "completed", headline: "Agent finished" })], + preferences: { ...allOn, enabled: false }, + }); + expect(suppressed.nextPhases.get("env-1:t1")).toBe("completed"); + const enabledLater = reconcileAwarenessNotifications({ + previousPhases: suppressed.nextPhases, + states: [makeState({ threadId: "t1", phase: "completed", headline: "Agent finished" })], + preferences: allOn, + }); + expect(enabledLater.notifications).toEqual([]); + }); + + it("appends detail to the body when present", () => { + const { notifications } = reconcileAwarenessNotifications({ + previousPhases: new Map([["env-1:t1", "running" as const]]), + states: [ + makeState({ + threadId: "t1", + phase: "failed", + headline: "Agent failed", + detail: "provider exited with code 1", + }), + ], + preferences: allOn, + }); + expect(notifications[0]?.body).toBe("Agent failed — provider exited with code 1"); + }); +}); + +describe("projectAwarenessStates (real phase cascade)", () => { + // Shell fixtures use only the fields ProjectThreadAwarenessInput picks. + const baseThread: ProjectThreadAwarenessInput["thread"] & { projectId: string } = { + id: ThreadId.make("t1"), + title: "Fix the sidebar", + projectId: "p1", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "some-model" }, + updatedAt: "2026-09-08T00:00:00.000Z", + hasPendingApprovals: false, + hasPendingUserInput: false, + latestTurn: null, + session: null, + }; + const titles = new Map([["env-1:p1", "Pylon"]]); + const session = ( + status: NonNullable["status"], + ): NonNullable => ({ + threadId: ThreadId.make("t1"), + status, + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: baseThread.updatedAt, + }); + const turn = ( + state: "running" | "completed", + ): NonNullable => ({ + turnId: TurnId.make("turn-1"), + state, + requestedAt: baseThread.updatedAt, + startedAt: baseThread.updatedAt, + completedAt: state === "completed" ? "2026-09-08T00:01:00.000Z" : null, + assistantMessageId: null, + }); + + it("a session flicker running -> ready while the turn is still running is not a completion", () => { + const running = { + ...baseThread, + session: session("running"), + latestTurn: turn("running"), + }; + const flicker = { ...running, session: session("ready") }; + const states = projectAwarenessStates({ + threads: [{ environmentId: EnvironmentId.make("env-1"), ...flicker }], + projectTitleByKey: titles, + }); + // latestTurn.state === "running" outranks session ready in the cascade. + expect(states[0]?.phase).toBe("running"); + }); + + it("session ready with a completed turn is a completion", () => { + const done = { + ...baseThread, + session: session("ready"), + latestTurn: turn("completed"), + }; + const states = projectAwarenessStates({ + threads: [{ environmentId: EnvironmentId.make("env-1"), ...done }], + projectTitleByKey: titles, + }); + expect(states[0]?.phase).toBe("completed"); + }); + + it("falls back to the thread title alone when the project is unknown", () => { + const states = projectAwarenessStates({ + threads: [ + { + environmentId: EnvironmentId.make("env-1"), + ...baseThread, + projectId: "unknown", + session: session("ready"), + }, + ], + projectTitleByKey: titles, + }); + expect(states[0]?.projectTitle).toBe(""); + }); +}); + +describe("delivery-independent phase recording", () => { + it("never replays a candidate dropped by main while focused", () => { + const states = [makeState({ threadId: "t1", phase: "completed" })]; + const focused = reconcileAwarenessNotifications({ + previousPhases: new Map([["env-1:t1", "running"]]), + states, + preferences: allOn, + }); + expect(focused.notifications).toHaveLength(1); + // Main drops this candidate while focused. The next snapshot still uses the new map. + const blurred = reconcileAwarenessNotifications({ + previousPhases: focused.nextPhases, + states, + preferences: allOn, + }); + expect(blurred.notifications).toEqual([]); + }); + + it("gates every phase independently and records disabled transitions", () => { + const phases = ["waiting_for_approval", "waiting_for_input", "completed", "failed"] as const; + const keys = [ + "notifyOnApproval", + "notifyOnInput", + "notifyOnCompletion", + "notifyOnFailure", + ] as const; + for (const [index, key] of keys.entries()) { + for (const [phaseIndex, phase] of phases.entries()) { + const result = reconcileAwarenessNotifications({ + previousPhases: new Map([["env-1:t1", "running"]]), + states: [makeState({ threadId: "t1", phase })], + preferences: { ...allOn, [key]: false }, + }); + expect(result.notifications).toHaveLength(index === phaseIndex ? 0 : 1); + expect(result.nextPhases.get("env-1:t1")).toBe(phase); + } + } + }); + + it("re-primes a thread after it disappears", () => { + const absent = reconcileAwarenessNotifications({ + previousPhases: new Map([["env-1:t1", "running"]]), + states: [], + preferences: allOn, + }); + const reappeared = reconcileAwarenessNotifications({ + previousPhases: absent.nextPhases, + states: [makeState({ threadId: "t1", phase: "completed" })], + preferences: allOn, + }); + expect(reappeared.notifications).toEqual([]); + }); + + it("keeps matching thread IDs in different environments independent", () => { + const result = reconcileAwarenessNotifications({ + previousPhases: new Map([ + ["env-1:t1", "running"], + ["env-2:t1", "completed"], + ]), + states: [ + makeState({ threadId: "t1", phase: "completed" }), + makeState({ + environmentId: EnvironmentId.make("env-2"), + threadId: "t1", + phase: "completed", + }), + ], + preferences: allOn, + }); + expect(result.notifications).toHaveLength(1); + expect(result.notifications[0]?.environmentId).toBe("env-1"); + }); +}); diff --git a/apps/web/src/notifications/awarenessNotifications.logic.ts b/apps/web/src/notifications/awarenessNotifications.logic.ts new file mode 100644 index 0000000000..a925c23fdb --- /dev/null +++ b/apps/web/src/notifications/awarenessNotifications.logic.ts @@ -0,0 +1,132 @@ +import type { + AgentAwarenessPhase, + AgentAwarenessState, + ProjectThreadAwarenessInput, +} from "@t3tools/shared/agentAwareness"; +import { projectThreadAwareness } from "@t3tools/shared/agentAwareness"; +import type { ClientSettings } from "@t3tools/contracts/settings"; +import type { EnvironmentId } from "@t3tools/contracts"; + +export interface AwarenessNotificationPreferences { + readonly enabled: boolean; + readonly notifyOnApproval: boolean; + readonly notifyOnInput: boolean; + readonly notifyOnCompletion: boolean; + readonly notifyOnFailure: boolean; +} + +export function selectAwarenessNotificationPreferences( + settings: ClientSettings, +): AwarenessNotificationPreferences { + return { + enabled: settings.desktopNotificationsEnabled, + notifyOnApproval: settings.desktopNotifyOnApproval, + notifyOnInput: settings.desktopNotifyOnInput, + notifyOnCompletion: settings.desktopNotifyOnCompletion, + notifyOnFailure: settings.desktopNotifyOnFailure, + }; +} + +/** + * What crosses the IPC boundary to the main process. The mobile-shaped + * `deepLink` string is deliberately not used: the web router's thread route + * is `/$environmentId/$threadId`, so the ids travel as fields and the click + * listener navigates by params. + */ +export interface AwarenessNotificationCandidate { + readonly environmentId: string; + readonly threadId: string; + readonly title: string; + readonly body: string; +} + +export type ThreadPhaseMap = ReadonlyMap; + +function awarenessThreadKey(state: Pick) { + return `${state.environmentId}:${state.threadId}`; +} + +function preferenceAllowsPhase( + phase: AgentAwarenessPhase, + preferences: AwarenessNotificationPreferences, +): boolean { + if (!preferences.enabled) return false; + switch (phase) { + case "waiting_for_approval": + return preferences.notifyOnApproval; + case "waiting_for_input": + return preferences.notifyOnInput; + case "completed": + return preferences.notifyOnCompletion; + case "failed": + return preferences.notifyOnFailure; + default: + // starting, running, stale never notify. + return false; + } +} + +function buildCandidate(state: AgentAwarenessState): AwarenessNotificationCandidate { + return { + environmentId: state.environmentId, + threadId: state.threadId, + title: state.projectTitle ? `${state.threadTitle} — ${state.projectTitle}` : state.threadTitle, + body: state.detail === undefined ? state.headline : `${state.headline} — ${state.detail}`, + }; +} + +/** Map thread shells to awareness states through the shared cascade. */ +export function projectAwarenessStates(input: { + readonly threads: ReadonlyArray< + { + readonly environmentId: EnvironmentId; + readonly projectId: string; + } & ProjectThreadAwarenessInput["thread"] + >; + readonly projectTitleByKey: ReadonlyMap; +}): AgentAwarenessState[] { + const states: AgentAwarenessState[] = []; + for (const thread of input.threads) { + const state = projectThreadAwareness({ + environmentId: thread.environmentId, + project: { + title: input.projectTitleByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? "", + }, + thread, + }); + if (state !== null) states.push(state); + } + return states; +} + +/** + * The whole feature's brain. Two rules, both load-bearing: + * + * 1. A thread key absent from previousPhases primes without notifying, so + * launch and reconnect bursts (where every thread's state arrives at once) + * are silent. Threads absent from the snapshot drop out of nextPhases, so + * a reappearing thread re-primes rather than firing on stale history. + * 2. The phase map updates unconditionally; preferences gate only emission. + * A suppressed transition is recorded and never replayed — flipping a + * toggle on later cannot flood the user with the past. + */ +export function reconcileAwarenessNotifications(input: { + readonly previousPhases: ThreadPhaseMap; + readonly states: ReadonlyArray; + readonly preferences: AwarenessNotificationPreferences; +}): { + readonly notifications: ReadonlyArray; + readonly nextPhases: ThreadPhaseMap; +} { + const notifications: AwarenessNotificationCandidate[] = []; + const nextPhases = new Map(); + for (const state of input.states) { + const key = awarenessThreadKey(state); + const previousPhase = input.previousPhases.get(key); + nextPhases.set(key, state.phase); + if (previousPhase === undefined || previousPhase === state.phase) continue; + if (!preferenceAllowsPhase(state.phase, input.preferences)) continue; + notifications.push(buildCandidate(state)); + } + return { notifications, nextPhases }; +} diff --git a/apps/web/src/notifications/useAwarenessNotifications.ts b/apps/web/src/notifications/useAwarenessNotifications.ts new file mode 100644 index 0000000000..7ebddd306d --- /dev/null +++ b/apps/web/src/notifications/useAwarenessNotifications.ts @@ -0,0 +1,45 @@ +import { useEffect, useRef } from "react"; + +import { useProjects, useThreadShells } from "~/state/entities"; +import { useClientSettings } from "~/hooks/useSettings"; +import { + projectAwarenessStates, + reconcileAwarenessNotifications, + selectAwarenessNotificationPreferences, + type ThreadPhaseMap, +} from "./awarenessNotifications.logic"; + +/** + * Forwards agent-awareness transitions to the desktop main process for + * native notification delivery. Reads the same thread-shell atom the + * sidebar renders from, so it observes every thread in every connected + * environment, not only the open one. No-ops outside Electron and on + * older desktop shells whose bridge lacks the method. + */ +export function useAwarenessNotifications(): void { + const threads = useThreadShells(); + const projects = useProjects(); + const preferences = useClientSettings(selectAwarenessNotificationPreferences); + const previousPhasesRef = useRef(new Map()); + + useEffect(() => { + const notify = window.desktopBridge?.notifyAgentAwareness; + if (typeof notify !== "function") { + return; + } + const projectTitleByKey = new Map( + projects.map((project) => [`${project.environmentId}:${project.id}`, project.title]), + ); + const { notifications, nextPhases } = reconcileAwarenessNotifications({ + previousPhases: previousPhasesRef.current, + states: projectAwarenessStates({ threads, projectTitleByKey }), + preferences, + }); + previousPhasesRef.current = nextPhases; + if (notifications.length > 0) { + void notify(notifications).catch((error: unknown) => { + console.warn("Could not deliver desktop notifications", error); + }); + } + }, [threads, projects, preferences]); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 64e858d186..1c38dbe380 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,3 +1,5 @@ +import { isElectron } from "../env"; +import { useAwarenessNotifications } from "../notifications/useAwarenessNotifications"; import { type ServerLifecycleWelcomePayload } from "@t3tools/contracts"; import { scopedProjectKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; @@ -152,6 +154,7 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} + {isElectron ? : null} {appShell} {/* Above the router: a theme draft is judged by walking the app, so the editor has to survive navigation away from settings. */} @@ -521,3 +524,8 @@ function EventRouter() { return null; } + +function AwarenessNotificationsBridge() { + useAwarenessNotifications(); + return null; +} diff --git a/docs/user/desktop-notifications.md b/docs/user/desktop-notifications.md new file mode 100644 index 0000000000..10bb381bef --- /dev/null +++ b/docs/user/desktop-notifications.md @@ -0,0 +1,37 @@ +# Desktop notifications + +Pylon can show a system notification when an agent needs approval, asks for input, +finishes its work, or fails. Notifications appear while a Pylon window is open and +no Pylon window is focused. Click a notification to bring Pylon forward and open +the thread that raised it. + +Closing the Pylon window stops new notifications, even if the server keeps +running. Desktop notifications are unavailable in a plain web browser. + +## Preferences + +Open **Settings → General → Notifications**. Desktop notifications and all four +event types are on by default. Use the master switch to turn them all off, or +choose which events notify you. Preferences are stored per device, so each +machine has its own settings. + +Events that happen while Pylon is focused or a notification preference is off +are not saved for later delivery. Opening Pylon also does not notify you about +threads that were already finished or waiting for attention. + +## Check delivery + +Select **Send test notification** to check delivery, including while Pylon is +focused. This test works independently of the notification switches. + +If no banner appears: + +- On macOS, open **System Settings → Notifications** and allow notifications for + the app. With unsigned builds, notifications may be attributed to **Electron**; + check its notification settings as well. +- Check whether your operating system's Focus or Do Not Disturb mode is hiding + banners. +- On Linux, make sure a notification daemon is running. + +The test button reports when the system says notifications are unsupported. +It cannot reliably detect whether notification permission has been denied. diff --git a/docs/user/settings.md b/docs/user/settings.md index 080f4186c3..c115c6fadb 100644 --- a/docs/user/settings.md +++ b/docs/user/settings.md @@ -7,3 +7,8 @@ Providers shows an instance list beside the selected instance’s settings on wi Model settings group favorites first and hidden models last. Large catalogs have a filter. Custom models stay visible in the picker; remove a custom entry to stop offering it. Reorder controls apply within a visible group and are unavailable while filtering. An update indicator beside a provider can copy its update command. Read-only connections still let you inspect settings and status, but cannot change configuration or run updates. + +On desktop, **General → Notifications** controls system notifications for agent +approval requests, input requests, completion, and failure. Each device has its own +preferences. See [Desktop notifications](desktop-notifications.md) for delivery +requirements and troubleshooting. diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 7d28d9236f..f77255705a 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1065,7 +1065,34 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ export const SystemSettingsPaneSchema = Schema.Literals(["full-disk-access"]); export type SystemSettingsPane = typeof SystemSettingsPaneSchema.Type; +/** + * One notification the renderer asks the desktop main process to deliver. + * Carries ids rather than a routed link: the renderer owns navigation and + * its thread route takes params, while main only needs display strings. + */ +export const DesktopNotificationCandidate = Schema.Struct({ + environmentId: Schema.String, + threadId: Schema.String, + title: Schema.String, + body: Schema.String, +}); +export type DesktopNotificationCandidate = typeof DesktopNotificationCandidate.Type; + export interface DesktopBridge { + /** Optional while older desktop shells can host a newer web client. */ + notifyAgentAwareness?: (candidates: ReadonlyArray) => Promise; + /** + * Shows a notification immediately, bypassing the focus gate (the user is + * necessarily focused while clicking the button). Resolves false when the + * system reports no notification support. Optional while older desktop + * shells can host a newer web client. + */ + sendTestNotification?: () => Promise; + /** Optional while older desktop shells can host a newer web client. */ + onNotificationNavigate?: ( + listener: (target: { environmentId: string; threadId: string }) => void, + ) => () => void; + getAppBranding: () => DesktopAppBranding | null; /** * The OS locale as a BCP-47 tag, which the renderer cannot read for itself: diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 96be737739..a82c16f5cc 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -660,3 +660,27 @@ describe("ServerSettings environment icon", () => { ).toBe("linux"); }); }); + +describe("ClientSettings desktop notification preferences", () => { + it("defaults every desktop notification preference on", () => { + const settings = decodeClientSettings({}); + expect(settings.desktopNotificationsEnabled).toBe(true); + expect(settings.desktopNotifyOnApproval).toBe(true); + expect(settings.desktopNotifyOnInput).toBe(true); + expect(settings.desktopNotifyOnCompletion).toBe(true); + expect(settings.desktopNotifyOnFailure).toBe(true); + }); + + it("decodes a settings payload written before the feature, new fields taking defaults", () => { + const settings = decodeClientSettings({ confirmQuit: false, wordWrap: false }); + expect(settings.confirmQuit).toBe(false); + expect(settings.wordWrap).toBe(false); + expect(settings.desktopNotificationsEnabled).toBe(true); + }); + + it("accepts desktop notification fields in a client settings patch", () => { + expect(decodeClientSettingsPatch({ desktopNotifyOnCompletion: false })).toEqual({ + desktopNotifyOnCompletion: false, + }); + }); +}); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index dbd1926481..c2d5af71ac 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -234,6 +234,17 @@ export const ClientSettingsSchema = Schema.Struct({ confirmThreadArchive: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), confirmThreadDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), confirmThreadUnpin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Desktop-only: native OS notifications for agent attention. Per device by + // design — a notification preference belongs to the machine the user sits + // at — so these are deliberately absent from SHARED_SERVER_SETTING_KEYS. + // Browser clients ignore them. + desktopNotificationsEnabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + ), + desktopNotifyOnApproval: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + desktopNotifyOnInput: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + desktopNotifyOnCompletion: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + desktopNotifyOnFailure: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), dismissedProviderUpdateNotificationKeys: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), @@ -1171,6 +1182,11 @@ export const ClientSettingsPatch = Schema.Struct({ confirmThreadArchive: Schema.optionalKey(Schema.Boolean), confirmThreadDelete: Schema.optionalKey(Schema.Boolean), confirmThreadUnpin: Schema.optionalKey(Schema.Boolean), + desktopNotificationsEnabled: Schema.optionalKey(Schema.Boolean), + desktopNotifyOnApproval: Schema.optionalKey(Schema.Boolean), + desktopNotifyOnInput: Schema.optionalKey(Schema.Boolean), + desktopNotifyOnCompletion: Schema.optionalKey(Schema.Boolean), + desktopNotifyOnFailure: Schema.optionalKey(Schema.Boolean), diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), diffLayout: Schema.optionalKey(DiffLayout), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode),