From 5c3f8def52a129ceb75fc96f7b54eabe1f2f1325 Mon Sep 17 00:00:00 2001 From: Ben Scholar Date: Wed, 27 May 2026 22:25:47 -0700 Subject: [PATCH 1/4] Add desktop notification support with configurable triggers - Three notification types: turn completed, input needed, error - User-configurable in settings with per-thread cooldown - Notifications click through to thread when dismissed - Skips notifications when app window is focused --- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 2 + apps/desktop/src/ipc/methods/notification.ts | 44 ++++++++++ apps/desktop/src/preload.ts | 13 +++ .../settings/DesktopClientSettings.test.ts | 3 + apps/web/src/components/AppSidebarLayout.tsx | 21 +++++ .../settings/SettingsPanels.browser.tsx | 2 + .../components/settings/SettingsPanels.tsx | 84 +++++++++++++++++++ apps/web/src/desktopNotifications.ts | 81 ++++++++++++++++++ apps/web/src/environments/runtime/service.ts | 18 +++- apps/web/src/localApi.test.ts | 8 ++ packages/contracts/src/ipc.ts | 19 +++++ packages/contracts/src/settings.ts | 7 ++ 13 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/ipc/methods/notification.ts create mode 100644 apps/web/src/desktopNotifications.ts diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 8717c877951..8887d7c66db 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -32,6 +32,7 @@ import { installUpdate, setUpdateChannel, } from "./methods/updates.ts"; +import { showNotification } from "./methods/notification.ts"; import { confirm, getAppBranding, @@ -75,6 +76,7 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { yield* ipc.handle(setTheme); yield* ipc.handle(showContextMenu); yield* ipc.handle(openExternal); + yield* ipc.handle(showNotification); yield* ipc.handle(getUpdateState); yield* ipc.handle(setUpdateChannel); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 2715b20cb36..e34830cf73d 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -33,3 +33,5 @@ export const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mod export const SET_TAILSCALE_SERVE_ENABLED_CHANNEL = "desktop:set-tailscale-serve-enabled"; export const GET_ADVERTISED_ENDPOINTS_CHANNEL = "desktop:get-advertised-endpoints"; export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; +export const SHOW_NOTIFICATION_CHANNEL = "desktop:show-notification"; +export const NOTIFICATION_NAVIGATE_CHANNEL = "desktop:notification-navigate"; diff --git a/apps/desktop/src/ipc/methods/notification.ts b/apps/desktop/src/ipc/methods/notification.ts new file mode 100644 index 00000000000..91e128a1c1d --- /dev/null +++ b/apps/desktop/src/ipc/methods/notification.ts @@ -0,0 +1,44 @@ +import { DesktopNotificationPayloadSchema } from "@t3tools/contracts"; +import * as Electron from "electron"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as IpcChannels from "../channels.ts"; +import { makeIpcMethod } from "../DesktopIpc.ts"; + +export const showNotification = makeIpcMethod({ + channel: IpcChannels.SHOW_NOTIFICATION_CHANNEL, + payload: DesktopNotificationPayloadSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.notification.show")(function* (payload) { + // Skip if any window is currently focused — user is already looking at the app + const focused = Electron.BrowserWindow.getFocusedWindow(); + if (focused && !focused.isDestroyed()) { + return; + } + + const notification = new Electron.Notification({ + title: payload.title, + body: payload.body, + }); + + const navigatePayload = { + environmentId: payload.threadEnvironmentId, + threadId: payload.threadId, + }; + + // The click handler fires asynchronously after IPC resolution so raw + // Electron APIs are used here instead of the Effect-wrapped services. + notification.on("click", () => { + const win = Electron.BrowserWindow.getAllWindows()[0]; + if (!win || win.isDestroyed()) return; + if (win.isMinimized()) win.restore(); + if (!win.isVisible()) win.show(); + Electron.app.focus({ steal: true }); + win.focus(); + win.webContents.send(IpcChannels.NOTIFICATION_NAVIGATE_CHANNEL, navigatePayload); + }); + + notification.show(); + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 173be8fb54a..84071dfdb88 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -124,4 +124,17 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.removeListener(IpcChannels.UPDATE_STATE_CHANNEL, wrappedListener); }; }, + showDesktopNotification: (payload) => + ipcRenderer.invoke(IpcChannels.SHOW_NOTIFICATION_CHANNEL, payload), + onNotificationNavigate: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, payload: unknown) => { + if (typeof payload !== "object" || payload === null) return; + listener(payload as Parameters[0]); + }; + + ipcRenderer.on(IpcChannels.NOTIFICATION_NAVIGATE_CHANNEL, wrappedListener); + return () => { + ipcRenderer.removeListener(IpcChannels.NOTIFICATION_NAVIGATE_CHANNEL, wrappedListener); + }; + }, } satisfies DesktopBridge); diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index f666e692860..63feda04fa8 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -28,6 +28,9 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, timestampFormat: "24-hour", + desktopNotifyOnTurnCompleted: false, + desktopNotifyOnInputNeeded: false, + desktopNotifyOnError: false, }; const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema)); diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index d98f30a1e5c..694873fc68b 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -53,6 +53,27 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }; }, [navigate]); + useEffect(() => { + const onNotificationNavigate = window.desktopBridge?.onNotificationNavigate; + if (typeof onNotificationNavigate !== "function") { + return; + } + + const unsubscribe = onNotificationNavigate((payload) => { + void navigate({ + to: "/$environmentId/$threadId", + params: { + environmentId: payload.environmentId, + threadId: payload.threadId, + }, + }); + }); + + return () => { + unsubscribe?.(); + }; + }, [navigate]); + return ( () => {}, + showDesktopNotification: vi.fn().mockResolvedValue(undefined), + onNotificationNavigate: () => () => {}, }; }; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e7f21da4809..97252cff2cc 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -891,6 +891,90 @@ export function GeneralSettingsPanel() { /> + {isElectron ? ( + + + updateSettings({ + desktopNotifyOnTurnCompleted: + DEFAULT_UNIFIED_SETTINGS.desktopNotifyOnTurnCompleted, + }) + } + /> + ) : null + } + control={ + + updateSettings({ desktopNotifyOnTurnCompleted: Boolean(checked) }) + } + aria-label="Notify on turn completed" + /> + } + /> + + updateSettings({ + desktopNotifyOnInputNeeded: + DEFAULT_UNIFIED_SETTINGS.desktopNotifyOnInputNeeded, + }) + } + /> + ) : null + } + control={ + + updateSettings({ desktopNotifyOnInputNeeded: Boolean(checked) }) + } + aria-label="Notify on input needed" + /> + } + /> + + updateSettings({ + desktopNotifyOnError: DEFAULT_UNIFIED_SETTINGS.desktopNotifyOnError, + }) + } + /> + ) : null + } + control={ + + updateSettings({ desktopNotifyOnError: Boolean(checked) }) + } + aria-label="Notify on error" + /> + } + /> + + ) : null} + {isElectron || HOSTED_APP_CHANNEL ? ( diff --git a/apps/web/src/desktopNotifications.ts b/apps/web/src/desktopNotifications.ts new file mode 100644 index 00000000000..15a11630309 --- /dev/null +++ b/apps/web/src/desktopNotifications.ts @@ -0,0 +1,81 @@ +import type { ClientSettings, EnvironmentId, ThreadId } from "@t3tools/contracts"; + +import { getClientSettings } from "~/hooks/useSettings"; +import type { SidebarThreadSummary } from "./types"; + +const lastNotificationByThread = new Map(); +const NOTIFICATION_COOLDOWN_MS = 5_000; + +export function maybeFireDesktopNotification( + environmentId: EnvironmentId, + threadId: ThreadId, + previous: SidebarThreadSummary | undefined, + next: SidebarThreadSummary, +): void { + const bridge = window.desktopBridge; + if (!bridge?.showDesktopNotification) return; + + const settings = getClientSettings(); + if ( + !settings.desktopNotifyOnTurnCompleted && + !settings.desktopNotifyOnInputNeeded && + !settings.desktopNotifyOnError + ) { + return; + } + + const key = `${environmentId}:${threadId}`; + const now = Date.now(); + const last = lastNotificationByThread.get(key); + if (last !== undefined && now - last < NOTIFICATION_COOLDOWN_MS) return; + + const notification = deriveNotification(previous, next, settings); + if (!notification) return; + + lastNotificationByThread.set(key, now); + + void bridge.showDesktopNotification({ + title: notification.title, + body: notification.body, + threadEnvironmentId: environmentId, + threadId, + }); +} + +function deriveNotification( + previous: SidebarThreadSummary | undefined, + next: SidebarThreadSummary, + settings: ClientSettings, +): { title: string; body: string } | null { + const threadLabel = next.title || "Thread"; + + if ( + settings.desktopNotifyOnError && + next.session?.status === "error" && + previous?.session?.status !== "error" + ) { + return { title: "Error", body: threadLabel }; + } + + if (settings.desktopNotifyOnInputNeeded && next.hasPendingApprovals && !previous?.hasPendingApprovals) { + return { title: "Approval needed", body: threadLabel }; + } + + if (settings.desktopNotifyOnInputNeeded && next.hasPendingUserInput && !previous?.hasPendingUserInput) { + return { title: "Input needed", body: threadLabel }; + } + + if (settings.desktopNotifyOnTurnCompleted) { + const prevTurnState = previous?.latestTurn?.state; + const nextTurnState = next.latestTurn?.state; + if ( + nextTurnState === "completed" && + prevTurnState !== "completed" && + prevTurnState !== undefined + ) { + return { title: "Turn completed", body: threadLabel }; + } + } + + return null; +} diff --git a/apps/web/src/environments/runtime/service.ts b/apps/web/src/environments/runtime/service.ts index 21a4561beb4..34e0b48df73 100644 --- a/apps/web/src/environments/runtime/service.ts +++ b/apps/web/src/environments/runtime/service.ts @@ -27,6 +27,7 @@ import { } from "~/composerDraftStore"; import { ensureLocalApi } from "~/localApi"; import { collectActiveTerminalThreadIds } from "~/lib/terminalStateCleanup"; +import { maybeFireDesktopNotification } from "~/desktopNotifications"; import { deriveOrchestrationBatchEffects } from "~/orchestrationEventEffects"; import { projectQueryKeys } from "~/lib/projectReactQuery"; import { providerQueryKeys } from "~/lib/providerReactQuery"; @@ -1048,7 +1049,11 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En ? event.threadId : null; const threadRef = threadId ? scopeThreadRef(environmentId, threadId) : null; - const previousThread = threadRef ? selectThreadByRef(useStore.getState(), threadRef) : undefined; + const previousStoreState = useStore.getState(); + const previousThread = threadRef ? selectThreadByRef(previousStoreState, threadRef) : undefined; + const previousSidebarSummary = threadRef + ? selectSidebarThreadSummaryByRef(previousStoreState, threadRef) + : undefined; useStore.getState().applyShellEvent(event, environmentId); markAppliedProjectionEvent(environmentId, event.sequence); @@ -1068,6 +1073,17 @@ function applyShellEvent(event: OrchestrationShellStreamEvent, environmentId: En } reconcileThreadDetailSubscriptionEvictionForThread(environmentId, event.thread.id); evictIdleThreadDetailSubscriptionsToCapacity(); + if (threadRef) { + const nextSidebarSummary = selectSidebarThreadSummaryByRef(useStore.getState(), threadRef); + if (nextSidebarSummary) { + maybeFireDesktopNotification( + environmentId, + event.thread.id, + previousSidebarSummary, + nextSidebarSummary, + ); + } + } return; case "thread-removed": if (threadRef) { diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 8bfb0e599ad..303fba3e586 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -242,6 +242,8 @@ function makeDesktopBridge(overrides: Partial = {}): DesktopBridg throw new Error("installUpdate not implemented in test"); }, onUpdateState: () => () => undefined, + showDesktopNotification: async () => undefined, + onNotificationNavigate: () => () => undefined, ...overrides, }; } @@ -615,6 +617,9 @@ describe("wsApi", () => { sidebarThreadSortOrder: "created_at" as const, sidebarThreadPreviewCount: 6, timestampFormat: "24-hour" as const, + desktopNotifyOnTurnCompleted: false, + desktopNotifyOnInputNeeded: false, + desktopNotifyOnError: false, }; const getClientSettings = vi.fn().mockResolvedValue({ ...clientSettings, @@ -678,6 +683,9 @@ describe("wsApi", () => { sidebarThreadSortOrder: "created_at" as const, sidebarThreadPreviewCount: 6, timestampFormat: "24-hour" as const, + desktopNotifyOnTurnCompleted: false, + desktopNotifyOnInputNeeded: false, + desktopNotifyOnError: false, }; await api.persistence.setClientSettings(clientSettings); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b191b35851c..af70d4ea19f 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -369,6 +369,21 @@ export const PickFolderOptionsSchema = Schema.Struct({ initialPath: Schema.optionalKey(Schema.NullOr(Schema.String)), }); +export const DesktopNotificationPayloadSchema = Schema.Struct({ + title: Schema.String, + body: Schema.String, + threadEnvironmentId: Schema.String, + threadId: Schema.String, +}); +export type DesktopNotificationPayload = typeof DesktopNotificationPayloadSchema.Type; + +export const DesktopNotificationNavigatePayloadSchema = Schema.Struct({ + environmentId: Schema.String, + threadId: Schema.String, +}); +export type DesktopNotificationNavigatePayload = + typeof DesktopNotificationNavigatePayloadSchema.Type; + export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null; @@ -421,6 +436,10 @@ export interface DesktopBridge { downloadUpdate: () => Promise; installUpdate: () => Promise; onUpdateState: (listener: (state: DesktopUpdateState) => void) => () => void; + showDesktopNotification: (payload: DesktopNotificationPayload) => Promise; + onNotificationNavigate: ( + listener: (payload: DesktopNotificationNavigatePayload) => void, + ) => () => void; } /** diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 217c17dc18c..aff69ded138 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -92,6 +92,13 @@ export const ClientSettingsSchema = Schema.Struct({ timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), + desktopNotifyOnTurnCompleted: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), + desktopNotifyOnInputNeeded: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(false)), + ), + desktopNotifyOnError: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), }); export type ClientSettings = typeof ClientSettingsSchema.Type; From 07ce756518c1a2fc9b10e608230f655c74c4ba6c Mon Sep 17 00:00:00 2001 From: Ben Scholar Date: Wed, 27 May 2026 22:28:25 -0700 Subject: [PATCH 2/4] fix(notifications): fix formatting in desktopNotifications.ts Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/desktopNotifications.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/web/src/desktopNotifications.ts b/apps/web/src/desktopNotifications.ts index 15a11630309..58fca28c97d 100644 --- a/apps/web/src/desktopNotifications.ts +++ b/apps/web/src/desktopNotifications.ts @@ -57,11 +57,19 @@ function deriveNotification( return { title: "Error", body: threadLabel }; } - if (settings.desktopNotifyOnInputNeeded && next.hasPendingApprovals && !previous?.hasPendingApprovals) { + if ( + settings.desktopNotifyOnInputNeeded && + next.hasPendingApprovals && + !previous?.hasPendingApprovals + ) { return { title: "Approval needed", body: threadLabel }; } - if (settings.desktopNotifyOnInputNeeded && next.hasPendingUserInput && !previous?.hasPendingUserInput) { + if ( + settings.desktopNotifyOnInputNeeded && + next.hasPendingUserInput && + !previous?.hasPendingUserInput + ) { return { title: "Input needed", body: threadLabel }; } From e8d687029295db3a30c38935ab8ebb1c92175521 Mon Sep 17 00:00:00 2001 From: Ben Scholar Date: Wed, 27 May 2026 22:35:47 -0700 Subject: [PATCH 3/4] feat(notifications): use app icon in desktop notifications Co-Authored-By: Claude Sonnet 4.6 --- apps/desktop/src/ipc/methods/notification.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/apps/desktop/src/ipc/methods/notification.ts b/apps/desktop/src/ipc/methods/notification.ts index 91e128a1c1d..b0e7504c3e3 100644 --- a/apps/desktop/src/ipc/methods/notification.ts +++ b/apps/desktop/src/ipc/methods/notification.ts @@ -1,8 +1,10 @@ import { DesktopNotificationPayloadSchema } from "@t3tools/contracts"; import * as Electron from "electron"; import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as DesktopAssets from "../../app/DesktopAssets.ts"; import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; @@ -17,9 +19,14 @@ export const showNotification = makeIpcMethod({ return; } + const assets = yield* DesktopAssets.DesktopAssets; + const iconPaths = yield* assets.iconPaths; + const iconPath = Option.getOrUndefined(iconPaths.png); + const notification = new Electron.Notification({ title: payload.title, body: payload.body, + ...(iconPath !== undefined ? { icon: iconPath } : {}), }); const navigatePayload = { From a91634ae60f0987e3dd13609ddf13e76f1badff3 Mon Sep 17 00:00:00 2001 From: Ben Scholar Date: Wed, 27 May 2026 22:45:10 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix(notifications):=20remove=20explicit=20i?= =?UTF-8?q?con=20=E2=80=94=20macOS=20uses=20app=20bundle=20icon=20automati?= =?UTF-8?q?cally?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- apps/desktop/src/ipc/methods/notification.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/ipc/methods/notification.ts b/apps/desktop/src/ipc/methods/notification.ts index b0e7504c3e3..4931c49d8ed 100644 --- a/apps/desktop/src/ipc/methods/notification.ts +++ b/apps/desktop/src/ipc/methods/notification.ts @@ -1,10 +1,8 @@ import { DesktopNotificationPayloadSchema } from "@t3tools/contracts"; import * as Electron from "electron"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as DesktopAssets from "../../app/DesktopAssets.ts"; import * as IpcChannels from "../channels.ts"; import { makeIpcMethod } from "../DesktopIpc.ts"; @@ -19,14 +17,11 @@ export const showNotification = makeIpcMethod({ return; } - const assets = yield* DesktopAssets.DesktopAssets; - const iconPaths = yield* assets.iconPaths; - const iconPath = Option.getOrUndefined(iconPaths.png); - + // macOS uses the app bundle's .icns as the notification icon automatically; + // setting `icon` here would add a secondary content image on the right instead. const notification = new Electron.Notification({ title: payload.title, body: payload.body, - ...(iconPath !== undefined ? { icon: iconPath } : {}), }); const navigatePayload = {