From bb713f61be71cc3260fb5df3205300d630dff8c3 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 29 Mar 2026 17:40:59 +0200 Subject: [PATCH] feat: add desktop notifications for run completion Show native OS notifications when a session run completes, fails, or needs approval while the app window is unfocused. Clicking a notification focuses the window and selects the relevant session. Includes a toggle in Settings > Appearance to enable/disable notifications (enabled by default). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/main/AryxAppService.ts | 11 +++ src/main/ipc/registerIpcHandlers.ts | 13 ++++ src/main/services/desktopNotifications.ts | 86 +++++++++++++++++++++++ src/preload/index.ts | 1 + src/renderer/App.tsx | 2 + src/renderer/components/SettingsPanel.tsx | 40 ++++++++++- src/shared/contracts/channels.ts | 1 + src/shared/contracts/ipc.ts | 1 + src/shared/domain/tooling.ts | 1 + 9 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 src/main/services/desktopNotifications.ts diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index 720cf89..dfcddcb 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -257,6 +257,11 @@ export class AryxAppService extends EventEmitter { return this.workspace; } + /** Returns the in-memory workspace without loading from disk. Used for synchronous checks. */ + getCachedWorkspace(): WorkspaceState | undefined { + return this.workspace; + } + async dispose(): Promise { this.ptyManager.dispose(); await this.sidecar.dispose(); @@ -503,6 +508,12 @@ export class AryxAppService extends EventEmitter { return this.persistAndBroadcast(workspace); } + async setNotificationsEnabled(enabled: boolean): Promise { + const workspace = await this.loadWorkspace(); + workspace.settings.notificationsEnabled = enabled; + return this.persistAndBroadcast(workspace); + } + async describeTerminal(): Promise { return this.ptyManager.getSnapshot(); } diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index e94d69c..ef7ad9d 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -36,6 +36,7 @@ import type { QuerySessionsInput } from '@shared/domain/sessionLibrary'; import type { AppearanceTheme } from '@shared/domain/tooling'; import { AryxAppService } from '@main/AryxAppService'; +import { createDesktopNotificationHandler } from '@main/services/desktopNotifications'; import { applyTitleBarTheme } from '@main/windows/titleBarTheme'; const { ipcMain } = electron; @@ -86,6 +87,10 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi ipcChannels.setTerminalHeight, (_event, input: SetTerminalHeightInput) => service.setTerminalHeight(input.height), ); + ipcMain.handle( + ipcChannels.setNotificationsEnabled, + (_event, enabled: boolean) => service.setNotificationsEnabled(enabled), + ); ipcMain.handle(ipcChannels.saveMcpServer, (_event, input: SaveMcpServerInput) => service.saveMcpServer(input.server), ); @@ -183,6 +188,14 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi window.webContents.send(ipcChannels.sessionEvent, event); }); + // Desktop notifications for run completion, failure, and approval requests + const handleNotification = createDesktopNotificationHandler( + () => window, + () => service.getCachedWorkspace(), + (sessionId) => service.selectSession(sessionId), + ); + service.on('session-event', handleNotification); + service.on('terminal-data', (data) => { window.webContents.send(ipcChannels.terminalData, data); }); diff --git a/src/main/services/desktopNotifications.ts b/src/main/services/desktopNotifications.ts new file mode 100644 index 0000000..c12d3f5 --- /dev/null +++ b/src/main/services/desktopNotifications.ts @@ -0,0 +1,86 @@ +import electron from 'electron'; +import type { BrowserWindow } from 'electron'; + +import type { SessionEventRecord } from '@shared/domain/event'; +import type { WorkspaceState } from '@shared/domain/workspace'; + +const { Notification } = electron; + +/** + * Creates a handler that shows native OS notifications for session run + * completions, failures, and approval requests when the window is unfocused. + * + * Clicking a notification focuses the window and selects the relevant session. + */ +export function createDesktopNotificationHandler( + getWindow: () => BrowserWindow | undefined, + getWorkspace: () => WorkspaceState | undefined, + selectSession: (sessionId: string) => Promise, +): (event: SessionEventRecord) => void { + const runningSessions = new Set(); + const notifiedApprovals = new Set(); + + return (event: SessionEventRecord) => { + const window = getWindow(); + if (window?.isFocused()) return; + + const workspace = getWorkspace(); + if (workspace?.settings.notificationsEnabled === false) return; + + if (!Notification.isSupported()) return; + + const session = workspace?.sessions.find((s) => s.id === event.sessionId); + const sessionTitle = session?.title ?? 'Session'; + + // Track running sessions to detect completion/failure transitions + if (event.kind === 'status') { + if (event.status === 'running') { + runningSessions.add(event.sessionId); + return; + } + + if (!runningSessions.has(event.sessionId)) return; + runningSessions.delete(event.sessionId); + + if (event.status === 'idle') { + showNotification('Run completed', sessionTitle, event.sessionId, window, selectSession); + } else if (event.status === 'error') { + showNotification('Run failed', sessionTitle, event.sessionId, window, selectSession); + } + return; + } + + // Detect new approval requests from run-updated events + if (event.kind === 'run-updated' && event.run) { + const approvalEvent = [...event.run.events] + .reverse() + .find((e) => e.kind === 'approval' && e.status === 'running'); + + if (approvalEvent?.approvalId && !notifiedApprovals.has(approvalEvent.approvalId)) { + notifiedApprovals.add(approvalEvent.approvalId); + const body = approvalEvent.approvalTitle + ? `${sessionTitle}: ${approvalEvent.approvalTitle}` + : sessionTitle; + showNotification('Approval needed', body, event.sessionId, window, selectSession); + } + } + }; +} + +function showNotification( + title: string, + body: string, + sessionId: string, + window: BrowserWindow | undefined, + selectSession: (sessionId: string) => Promise, +): void { + const notification = new Notification({ title, body, silent: false }); + + notification.on('click', () => { + window?.show(); + window?.focus(); + void selectSession(sessionId); + }); + + notification.show(); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 65d8d5e..e05a27f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -26,6 +26,7 @@ const api: ElectronApi = { setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input), setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme), setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input), + setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled), saveMcpServer: (input) => ipcRenderer.invoke(ipcChannels.saveMcpServer, input), deleteMcpServer: (serverId) => ipcRenderer.invoke(ipcChannels.deleteMcpServer, serverId), saveLspProfile: (input) => ipcRenderer.invoke(ipcChannels.saveLspProfile, input), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index f7ed7db..fc5d316 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -485,6 +485,8 @@ export default function App() { await api.savePattern({ pattern }); }} onSetTheme={(theme) => void api.setTheme(theme)} + notificationsEnabled={workspace.settings.notificationsEnabled !== false} + onSetNotificationsEnabled={(enabled) => void api.setNotificationsEnabled(enabled)} onOpenAppDataFolder={() => void api.openAppDataFolder()} onResetLocalWorkspace={async () => { const fresh = await api.resetLocalWorkspace(); diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 6798bdb..32966a8 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -3,6 +3,7 @@ import { ChevronLeft, ChevronRight, Code, Cpu, FolderOpen, Palette, Plus, Server import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard'; import { PatternEditor } from '@renderer/components/PatternEditor'; +import { ToggleSwitch } from '@renderer/components/ui'; import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor'; import { McpServerEditor } from '@renderer/components/settings/McpServerEditor'; import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar'; @@ -39,6 +40,8 @@ interface SettingsPanelProps { onDeleteLspProfile: (profileId: string) => Promise; onNewLspProfile: () => LspProfileDefinition; onSetTheme: (theme: AppearanceTheme) => void; + notificationsEnabled: boolean; + onSetNotificationsEnabled: (enabled: boolean) => void; onOpenAppDataFolder: () => void; onResetLocalWorkspace: () => Promise; onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; @@ -117,6 +120,8 @@ export function SettingsPanel({ onDeleteLspProfile, onNewLspProfile, onSetTheme, + notificationsEnabled, + onSetNotificationsEnabled, onOpenAppDataFolder, onResetLocalWorkspace, onResolveUserDiscoveredTooling, @@ -255,7 +260,12 @@ export function SettingsPanel({
{activeSection === 'appearance' && ( - + )} {activeSection === 'connection' && ( void; + notificationsEnabled: boolean; + onSetNotificationsEnabled: (enabled: boolean) => void; }) { return (
@@ -359,6 +373,30 @@ function AppearanceSection({ ); })}
+ + {/* Notifications */} +
+

Notifications

+

+ Control when Aryx sends desktop notifications +

+
+ +
); } diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index 7a806af..be9b957 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -15,6 +15,7 @@ export const ipcChannels = { setPatternFavorite: 'patterns:set-favorite', setTheme: 'settings:set-theme', setTerminalHeight: 'settings:set-terminal-height', + setNotificationsEnabled: 'settings:set-notifications-enabled', saveMcpServer: 'tooling:mcp:save', deleteMcpServer: 'tooling:mcp:delete', saveLspProfile: 'tooling:lsp:save', diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index 7d391f0..4baee39 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -194,6 +194,7 @@ export interface ElectronApi { setPatternFavorite(input: SetPatternFavoriteInput): Promise; setTheme(theme: AppearanceTheme): Promise; setTerminalHeight(input: SetTerminalHeightInput): Promise; + setNotificationsEnabled(enabled: boolean): Promise; describeTerminal(): Promise; createTerminal(): Promise; restartTerminal(): Promise; diff --git a/src/shared/domain/tooling.ts b/src/shared/domain/tooling.ts index 609edee..f9ead2f 100644 --- a/src/shared/domain/tooling.ts +++ b/src/shared/domain/tooling.ts @@ -64,6 +64,7 @@ export interface WorkspaceSettings { tooling: WorkspaceToolingSettings; discoveredUserTooling: DiscoveredToolingState; terminalHeight?: number; + notificationsEnabled?: boolean; } export interface SessionToolingSelection {