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..4931c49d8ed --- /dev/null +++ b/apps/desktop/src/ipc/methods/notification.ts @@ -0,0 +1,46 @@ +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; + } + + // 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, + }); + + 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..58fca28c97d --- /dev/null +++ b/apps/web/src/desktopNotifications.ts @@ -0,0 +1,89 @@ +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;