mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 11:48:46 +02:00
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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface WorkspaceSettings {
|
||||
tooling: WorkspaceToolingSettings;
|
||||
discoveredUserTooling: DiscoveredToolingState;
|
||||
terminalHeight?: number;
|
||||
notificationsEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionToolingSelection {
|
||||
|
||||
Reference in New Issue
Block a user