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>
This commit is contained in:
David Kaya
2026-03-29 17:40:59 +02:00
co-authored by Copilot
parent 395965c639
commit bb713f61be
9 changed files with 155 additions and 1 deletions
+11
View File
@@ -257,6 +257,11 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
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<void> {
this.ptyManager.dispose();
await this.sidecar.dispose();
@@ -503,6 +508,12 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async setNotificationsEnabled(enabled: boolean): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
workspace.settings.notificationsEnabled = enabled;
return this.persistAndBroadcast(workspace);
}
async describeTerminal(): Promise<TerminalSnapshot | undefined> {
return this.ptyManager.getSnapshot();
}
+13
View File
@@ -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);
});
+86
View File
@@ -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<WorkspaceState>,
): (event: SessionEventRecord) => void {
const runningSessions = new Set<string>();
const notifiedApprovals = new Set<string>();
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<WorkspaceState>,
): void {
const notification = new Notification({ title, body, silent: false });
notification.on('click', () => {
window?.show();
window?.focus();
void selectSession(sessionId);
});
notification.show();
}