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();
}
+1
View File
@@ -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),
+2
View File
@@ -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();
+39 -1
View File
@@ -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<void>;
onNewLspProfile: () => LspProfileDefinition;
onSetTheme: (theme: AppearanceTheme) => void;
notificationsEnabled: boolean;
onSetNotificationsEnabled: (enabled: boolean) => void;
onOpenAppDataFolder: () => void;
onResetLocalWorkspace: () => Promise<void>;
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({
<div className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-2xl px-8 py-6">
{activeSection === 'appearance' && (
<AppearanceSection theme={theme} onSetTheme={onSetTheme} />
<AppearanceSection
theme={theme}
onSetTheme={onSetTheme}
notificationsEnabled={notificationsEnabled}
onSetNotificationsEnabled={onSetNotificationsEnabled}
/>
)}
{activeSection === 'connection' && (
<ConnectionSection
@@ -315,9 +325,13 @@ const themeOptions: { value: AppearanceTheme; label: string; description: string
function AppearanceSection({
theme,
onSetTheme,
notificationsEnabled,
onSetNotificationsEnabled,
}: {
theme: AppearanceTheme;
onSetTheme: (theme: AppearanceTheme) => void;
notificationsEnabled: boolean;
onSetNotificationsEnabled: (enabled: boolean) => void;
}) {
return (
<div>
@@ -359,6 +373,30 @@ function AppearanceSection({
);
})}
</div>
{/* Notifications */}
<div className="mt-8 mb-1">
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Notifications</h3>
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
Control when Aryx sends desktop notifications
</p>
</div>
<button
className="mt-4 flex w-full items-center justify-between rounded-lg border border-[var(--color-border)] px-4 py-3 text-left transition hover:bg-[var(--color-surface-3)]/40"
onClick={() => onSetNotificationsEnabled(!notificationsEnabled)}
type="button"
>
<div>
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
Run completion alerts
</span>
<p className="text-[12px] text-[var(--color-text-muted)]">
Notify when a session run completes, fails, or needs approval while the app is unfocused
</p>
</div>
<ToggleSwitch enabled={notificationsEnabled} />
</button>
</div>
);
}
+1
View File
@@ -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',
+1
View File
@@ -194,6 +194,7 @@ export interface ElectronApi {
setPatternFavorite(input: SetPatternFavoriteInput): Promise<WorkspaceState>;
setTheme(theme: AppearanceTheme): Promise<WorkspaceState>;
setTerminalHeight(input: SetTerminalHeightInput): Promise<WorkspaceState>;
setNotificationsEnabled(enabled: boolean): Promise<WorkspaceState>;
describeTerminal(): Promise<TerminalSnapshot | undefined>;
createTerminal(): Promise<TerminalSnapshot>;
restartTerminal(): Promise<TerminalSnapshot>;
+1
View File
@@ -64,6 +64,7 @@ export interface WorkspaceSettings {
tooling: WorkspaceToolingSettings;
discoveredUserTooling: DiscoveredToolingState;
terminalHeight?: number;
notificationsEnabled?: boolean;
}
export interface SessionToolingSelection {