Skip to content

Commit c7b0f23

Browse files
authored
feat(desktop): notify when a background design run finishes (#472)
## Summary Long runs, especially on local models (see #411), often finish while the user is in another app. This shows a system notification when a live run settles as completed or failed, or when the agent asks a question, while the Open CoDesign window is unfocused. The notification is titled with the design name; clicking it focuses the window and opens that design. Cancelled, interrupted, and replayed runs do not notify. Window focus is checked after the setting is read, so returning to the app meanwhile suppresses the notification; a failed read, or a failed focus or design switch after a click, is logged. The main process installs no permission handler, so Electron allows the renderer `Notification` API. Settings → Advanced has a "System notifications" checkbox. It is stored in `preferences.json`, which moves to schema version 10. New installs start with it on. Installs upgraded from schema 9 or earlier keep it off until the user turns it on, so an update does not start sending notifications nobody asked for; the changeset (and so the release notes) says this and names the switch, which also turns notifications off. On Windows the main process sets the AppUserModelID to the electron-builder `appId` (`ai.opencowork.codesign`, from `apps/desktop/electron-builder.yml`), which the NSIS Start Menu shortcut carries; Windows only shows toasts for that ID. This has not been checked on a Windows machine. Known limit, left for a follow-up: notifications come from the renderer. On macOS, closing the last window keeps the app running without a renderer, so runs that settle after that are not announced until a window is open again. The settings hint and the changeset both state this. ## Type of change - [ ] Bug fix - [x] New feature - [ ] Refactor (no behavior change) - [ ] Documentation - [ ] Build / CI / tooling - [ ] Breaking change ## Linked issue Refs #411 (overnight local runs). It does not change the timeout. ## Checklist - [x] I checked the linked issue / relevant context before starting - [x] `pnpm lint && pnpm typecheck && pnpm test` passes locally - [x] Added/updated tests for the change - [x] Added a changeset (`pnpm changeset`) if user-visible - [ ] Updated docs if behavior changed ## Dependency additions (if any) None. ## Screenshots / recordings (UI changes) Not attached. Settings → Advanced gains one checkbox row. ## Checks - Pre-push `pnpm -r typecheck && pnpm lint && pnpm test` with `CI=1` (the repo's two-worker limit): 10 tasks passed; desktop 179 files, 2,622 tests. `pnpm lint` checked 680 files. - `useAgentStream.completion.test.ts`: live `run_settled` events notify for completed and failed runs only (this test fails without the hook change); outcomes recovered after a refresh do not notify (fails if the replay check is removed) - `lib/system-notifications.test.ts`: design title and click behaviour, focused window, focus regained during the setting read, a failed click logged, setting off, unknown design - `preferences-ipc.test.ts`: on for new installs (no file), off for files from schema 9 and still off after another preference is saved (both fail without the migration), update round trip at schema 10, non-boolean values rejected The notification has not been exercised in a running desktop app or with a live model. Still to check on an installed Windows build: completed, failed, and question notifications while the app is in the background; a click restoring a minimized window and opening that design; nothing shown while the app is focused, for cancelled runs, for replayed history, or with the setting off. The unit tests above cover these cases in the renderer, except the question notification wiring in `App.tsx`; restoring a minimized window is done by `win.restore()` in the main-process `codesign:v1:focus-window` handler, which has no unit test.
1 parent 24815f3 commit c7b0f23

17 files changed

Lines changed: 382 additions & 5 deletions

File tree

‎.changeset/system-notifications.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@open-codesign/desktop": patch
3+
"@open-codesign/i18n": patch
4+
---
5+
6+
Send a system notification when a design run completes or fails, or when the agent asks a question, while the window is in the background. Clicking it brings the window back and opens that design. New installs start with notifications on. Installs upgraded from an earlier version keep them off until you turn on Settings → Advanced → System notifications; the same switch turns them off. On macOS, notifications stop after the last window is closed, until it is opened again. Preferences move to schema version 10.

‎apps/desktop/src/main/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,9 @@ if (!IS_VITEST) {
224224
app.quit();
225225
return;
226226
}
227+
// Windows shows toasts only for the AppUserModelID on the installer's
228+
// Start Menu shortcut, which electron-builder sets to the appId.
229+
if (process.platform === 'win32') app.setAppUserModelId('ai.opencowork.codesign');
227230
app.on('second-instance', () => {
228231
if (mainWindow) {
229232
if (mainWindow.isMinimized()) mainWindow.restore();

‎apps/desktop/src/main/ipc/shell.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { mkdir } from 'node:fs/promises';
22
import path_module from 'node:path';
33
import { CodesignError } from '@open-codesign/shared';
4-
import { app, ipcMain, shell } from '../electron-runtime';
4+
import { app, BrowserWindow, ipcMain, shell } from '../electron-runtime';
55
import { getLogPath } from '../logger';
66
import { isAllowedExternalUrl } from '../open-external';
77

@@ -25,4 +25,11 @@ export function registerShellIpc(): void {
2525
}
2626
await shell.openExternal(url);
2727
});
28+
29+
ipcMain.handle('codesign:v1:focus-window', (event) => {
30+
const win = BrowserWindow.fromWebContents(event.sender);
31+
if (win?.isMinimized()) win.restore();
32+
win?.show();
33+
win?.focus();
34+
});
2835
}

‎apps/desktop/src/main/preferences-ipc.test.ts‎

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ describe('readPersisted()', () => {
6666
workspaceMemoryAutoUpdate: true,
6767
userMemoryAutoUpdate: false,
6868
proxyUrl: '',
69+
systemNotifications: true,
6970
});
7071
});
7172

@@ -241,7 +242,7 @@ describe('readPersisted()', () => {
241242
schemaVersion: number;
242243
diagnosticsLastReadTs: number;
243244
};
244-
expect(written.schemaVersion).toBe(9);
245+
expect(written.schemaVersion).toBe(10);
245246
expect(written.diagnosticsLastReadTs).toBe(result.diagnosticsLastReadTs);
246247
expect(written.diagnosticsLastReadTs).toBeGreaterThanOrEqual(before);
247248
expect(written.diagnosticsLastReadTs).toBeLessThanOrEqual(after);
@@ -361,7 +362,7 @@ describe('preferences memory schema fields', () => {
361362
workspaceMemoryAutoUpdate: boolean;
362363
userMemoryAutoUpdate: boolean;
363364
};
364-
expect(written.schemaVersion).toBe(9);
365+
expect(written.schemaVersion).toBe(10);
365366
expect(written.memoryEnabled).toBe(false);
366367
expect(written.workspaceMemoryAutoUpdate).toBe(false);
367368
expect(written.userMemoryAutoUpdate).toBe(true);
@@ -402,6 +403,66 @@ describe('preferences memory schema fields', () => {
402403
}),
403404
).rejects.toThrow(/proxyUrl must be a string/);
404405
});
406+
407+
it('leaves system notifications off for installs upgraded from an earlier schema', async () => {
408+
readFileMock.mockResolvedValueOnce(
409+
JSON.stringify({ schemaVersion: 9, diagnosticsLastReadTs: 1, proxyUrl: '' }),
410+
);
411+
412+
const prefs = await readPersisted();
413+
expect(prefs.systemNotifications).toBe(false);
414+
});
415+
416+
it('keeps an upgraded install opted out when another preference is saved', async () => {
417+
readFileMock.mockResolvedValueOnce(
418+
JSON.stringify({ schemaVersion: 9, diagnosticsLastReadTs: 1, proxyUrl: '' }),
419+
);
420+
await (handlers['preferences:v1:update'] as (_e: null, raw: unknown) => Promise<unknown>)(
421+
null,
422+
{ proxyUrl: 'http://127.0.0.1:7890' },
423+
);
424+
425+
const lastCall = writeFileMock.mock.calls.at(-1);
426+
if (!lastCall) throw new Error('writeFile was not called');
427+
const written = JSON.parse(lastCall[1] as string) as {
428+
schemaVersion: number;
429+
systemNotifications: boolean;
430+
};
431+
expect(written.schemaVersion).toBe(10);
432+
expect(written.systemNotifications).toBe(false);
433+
});
434+
435+
it('round-trips systemNotifications through preferences:v1:update', async () => {
436+
readFileMock.mockResolvedValueOnce(
437+
JSON.stringify({ schemaVersion: 10, diagnosticsLastReadTs: 1, systemNotifications: true }),
438+
);
439+
const updated = await (
440+
handlers['preferences:v1:update'] as (_e: null, raw: unknown) => Promise<unknown>
441+
)(null, { systemNotifications: false });
442+
443+
expect((updated as { systemNotifications: boolean }).systemNotifications).toBe(false);
444+
const lastCall = writeFileMock.mock.calls.at(-1);
445+
if (!lastCall) throw new Error('writeFile was not called');
446+
const written = JSON.parse(lastCall[1] as string) as {
447+
schemaVersion: number;
448+
systemNotifications: boolean;
449+
};
450+
expect(written.schemaVersion).toBe(10);
451+
expect(written.systemNotifications).toBe(false);
452+
});
453+
454+
it('rejects non-boolean systemNotifications updates and persisted values', async () => {
455+
await expect(
456+
(handlers['preferences:v1:update'] as (_e: null, raw: unknown) => Promise<unknown>)(null, {
457+
systemNotifications: 'yes',
458+
}),
459+
).rejects.toThrow(/systemNotifications must be a boolean/);
460+
461+
readFileMock.mockResolvedValueOnce(
462+
JSON.stringify({ schemaVersion: 10, diagnosticsLastReadTs: 1, systemNotifications: 1 }),
463+
);
464+
await expect(readPersisted()).rejects.toThrow(/systemNotifications must be a boolean/);
465+
});
405466
});
406467

407468
describe('applyProxyConfig()', () => {

‎apps/desktop/src/main/preferences-ipc.ts‎

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,18 @@ import { getLogger } from './logger';
1717

1818
const logger = getLogger('preferences-ipc');
1919

20-
const SCHEMA_VERSION = 9;
20+
const SCHEMA_VERSION = 10;
2121
// v1 → v2: raise the abandoned 120s timeout default (which aborted real
2222
// agentic runs mid-loop) to 600s. Values that happen to equal the old
2323
// default are treated as unmigrated defaults, not user intent.
2424
const V1_DEFAULT_TIMEOUT_SEC = 120;
2525
// v2 -> v3: 600s still clips slower long-form multi-turn runs, so the default
2626
// moves to 1200s.
2727
const V2_DEFAULT_TIMEOUT_SEC = 600;
28+
// v9 -> v10: system notifications. Installs upgraded from an earlier schema
29+
// keep them off until the user turns them on in Settings -> Advanced; new
30+
// installs start with them on.
31+
const SYSTEM_NOTIFICATIONS_SCHEMA_VERSION = 10;
2832

2933
function prefsFile(): string {
3034
return join(configDir(), 'preferences.json');
@@ -47,6 +51,8 @@ export interface Preferences {
4751
/** HTTP/HTTPS proxy URL applied to Chromium and Node outbound traffic.
4852
* Empty string disables the proxy. */
4953
proxyUrl: string;
54+
/** Notify through the OS when a design run ends or asks a question while the window is in the background. */
55+
systemNotifications: boolean;
5056
}
5157

5258
interface PreferencesFile extends Preferences {
@@ -67,6 +73,7 @@ const DEFAULTS: Preferences = {
6773
workspaceMemoryAutoUpdate: true,
6874
userMemoryAutoUpdate: false,
6975
proxyUrl: '',
76+
systemNotifications: true,
7077
};
7178

7279
const PREFERENCE_UPDATE_FIELDS = [
@@ -79,6 +86,7 @@ const PREFERENCE_UPDATE_FIELDS = [
7986
'workspaceMemoryAutoUpdate',
8087
'userMemoryAutoUpdate',
8188
'proxyUrl',
89+
'systemNotifications',
8290
] as const;
8391

8492
function assertKnownPreferenceFields(r: Record<string, unknown>): void {
@@ -137,7 +145,8 @@ function readPersistedBoolean(
137145
| 'checkForUpdatesOnStartup'
138146
| 'memoryEnabled'
139147
| 'workspaceMemoryAutoUpdate'
140-
| 'userMemoryAutoUpdate',
148+
| 'userMemoryAutoUpdate'
149+
| 'systemNotifications',
141150
defaultValue: boolean,
142151
): boolean {
143152
const value = r[key];
@@ -224,6 +233,11 @@ function parsePersistedFile(rawJson: unknown): Preferences {
224233
DEFAULTS.userMemoryAutoUpdate,
225234
),
226235
proxyUrl: readPersistedString(parsed, 'proxyUrl', DEFAULTS.proxyUrl),
236+
systemNotifications: readPersistedBoolean(
237+
parsed,
238+
'systemNotifications',
239+
persistedSchema < SYSTEM_NOTIFICATIONS_SCHEMA_VERSION ? false : DEFAULTS.systemNotifications,
240+
),
227241
};
228242
}
229243

@@ -332,6 +346,15 @@ function readMemoryAutoUpdate(
332346
return value;
333347
}
334348

349+
function readSystemNotifications(r: Record<string, unknown>): boolean | undefined {
350+
const value = r['systemNotifications'];
351+
if (value === undefined) return undefined;
352+
if (typeof value !== 'boolean') {
353+
throw new CodesignError('systemNotifications must be a boolean', ERROR_CODES.IPC_BAD_INPUT);
354+
}
355+
return value;
356+
}
357+
335358
function readDismissedVersion(r: Record<string, unknown>): string | undefined {
336359
const value = r['dismissedUpdateVersion'];
337360
if (value === undefined) return undefined;
@@ -389,6 +412,8 @@ function parsePreferences(raw: unknown): Partial<Preferences> {
389412
if (userMemoryAutoUpdate !== undefined) out.userMemoryAutoUpdate = userMemoryAutoUpdate;
390413
const proxyUrl = readProxyUrl(r);
391414
if (proxyUrl !== undefined) out.proxyUrl = proxyUrl;
415+
const systemNotifications = readSystemNotifications(r);
416+
if (systemNotifications !== undefined) out.systemNotifications = systemNotifications;
392417
return out;
393418
}
394419

‎apps/desktop/src/preload/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ export interface Preferences {
272272
workspaceMemoryAutoUpdate: boolean;
273273
userMemoryAutoUpdate: boolean;
274274
proxyUrl: string;
275+
systemNotifications: boolean;
275276
}
276277

277278
export interface MemoryFileRead {
@@ -1019,6 +1020,7 @@ const api = {
10191020
},
10201021
openExternal: (url: string) =>
10211022
ipcRenderer.invoke('codesign:v1:open-external', url) as Promise<void>,
1023+
focusWindow: () => ipcRenderer.invoke('codesign:v1:focus-window') as Promise<void>,
10221024
ask: {
10231025
history: (filter?: { runId?: string; designId?: string }) =>
10241026
ipcRenderer.invoke('ask:history', filter) as Promise<AskHistoryEntry[]>,

‎apps/desktop/src/renderer/src/App.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { UpdateBanner } from './components/UpdateBanner';
1616
import { useAgentStream } from './hooks/useAgentStream';
1717
import { useKeyboard } from './hooks/useKeyboard';
1818
import { useUpdateWiring } from './hooks/useUpdateWiring';
19+
import { notifyDesignInBackground } from './lib/system-notifications';
1920

2021
// Settings opens in a separate view (Hub ↔ Workspace ↔ Settings). Keep it
2122
// out of the first-paint chunk — its ~2700-line tree + dynamic provider
@@ -71,6 +72,16 @@ export function App() {
7172
const [updateStore] = useState(() => createUpdateStore({ dismissedVersion: '' }));
7273
useUpdateWiring(updateStore);
7374
useAgentStream();
75+
useEffect(() => {
76+
const off = window.codesign?.ask?.onRequest?.((request) => {
77+
notifyDesignInBackground(useCodesignStore.getState(), request.designId, 'ask').catch(
78+
(error: unknown) => console.warn('[open-codesign] system notification failed:', error),
79+
);
80+
});
81+
return () => {
82+
off?.();
83+
};
84+
}, []);
7485

7586
useEffect(() => {
7687
if (!window.codesign) {

‎apps/desktop/src/renderer/src/components/settings/AdvancedTab.tsx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export function AdvancedTab() {
7171
workspaceMemoryAutoUpdate: true,
7272
userMemoryAutoUpdate: false,
7373
proxyUrl: '',
74+
systemNotifications: true,
7475
});
7576

7677
useEffect(() => {
@@ -142,6 +143,19 @@ export function AdvancedTab() {
142143
/>
143144
</Row>
144145

146+
<Row
147+
label={t('settings.advanced.systemNotifications')}
148+
hint={t('settings.advanced.systemNotificationsHint')}
149+
>
150+
<input
151+
type="checkbox"
152+
checked={prefs.systemNotifications}
153+
aria-label={t('settings.advanced.systemNotifications')}
154+
onChange={(e) => void updatePref({ systemNotifications: e.target.checked })}
155+
className="h-4 w-4 accent-[var(--color-accent)]"
156+
/>
157+
</Row>
158+
145159
<Row label={t('settings.advanced.timeout')} hint={t('settings.advanced.timeoutHint')}>
146160
<NativeSelect
147161
value={String(prefs.generationTimeoutSec)}

‎apps/desktop/src/renderer/src/components/settings/MemoryTab.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ const DEFAULT_PREFS: Preferences = {
1515
workspaceMemoryAutoUpdate: true,
1616
userMemoryAutoUpdate: false,
1717
proxyUrl: '',
18+
systemNotifications: true,
1819
};
1920

2021
export function MemoryTab() {

‎apps/desktop/src/renderer/src/hooks/useAgentStream.completion.test.ts‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ const design = {
4343
workspacePath: '/tmp/codesign-completion-test',
4444
};
4545
let listener: ((event: AgentStreamEvent) => void) | undefined;
46+
let windowFocused = true;
4647
const append = vi.fn(async (input: { designId: string; kind: string; payload: unknown }) => ({
4748
...input,
4849
id: 'row',
@@ -89,12 +90,19 @@ beforeEach(() => {
8990
persistAgentRunSnapshot: vi.fn(async () => {}),
9091
tryAutoPolish: vi.fn(),
9192
});
93+
windowFocused = true;
94+
vi.stubGlobal('document', {
95+
hasFocus: () => windowFocused,
96+
addEventListener: vi.fn(),
97+
removeEventListener: vi.fn(),
98+
});
9299
vi.stubGlobal('window', {
93100
addEventListener: vi.fn(),
94101
removeEventListener: vi.fn(),
95102
setTimeout,
96103
codesign: {
97104
generate,
105+
preferences: { get: vi.fn(async () => ({ systemNotifications: true })) },
98106
generationStatus: vi.fn(async () => ({ schemaVersion: 1, running: [] })),
99107
snapshots: {
100108
list: vi.fn(async () => []),
@@ -127,6 +135,31 @@ afterEach(() => {
127135
});
128136

129137
describe('agent stream / IPC completion ordering', () => {
138+
it('notifies through the OS only for live runs that complete or fail in the background', async () => {
139+
const shown: Array<{ title: string; body: string }> = [];
140+
vi.stubGlobal(
141+
'Notification',
142+
class {
143+
onclick: (() => void) | null = null;
144+
constructor(title: string, options: { body: string }) {
145+
shown.push({ title, body: options.body });
146+
}
147+
},
148+
);
149+
windowFocused = false;
150+
151+
emit('run_settled', 'completed-run', { outcome: 'completed' });
152+
emit('run_settled', 'failed-run', { outcome: 'failed' });
153+
emit('run_settled', 'cancelled-run', { outcome: 'cancelled' });
154+
emit('run_settled', 'interrupted-run', { outcome: 'interrupted' });
155+
await vi.advanceTimersByTimeAsync(0);
156+
157+
expect(shown).toEqual([
158+
{ title: 'Aurora', body: 'Design ready' },
159+
{ title: 'Aurora', body: 'Generation failed' },
160+
]);
161+
});
162+
130163
it('keeps exactly one canonical active message through delivery, turn end and completion', async () => {
131164
const get = useCodesignStore.getState;
132165
const rows: ChatMessageRow[] = [];
@@ -500,6 +533,35 @@ describe('durable run refresh recovery', () => {
500533
expect(append).not.toHaveBeenCalled();
501534
});
502535

536+
it('does not notify for run outcomes recovered after a refresh', async () => {
537+
const shown: string[] = [];
538+
vi.stubGlobal(
539+
'Notification',
540+
class {
541+
onclick: (() => void) | null = null;
542+
constructor(title: string) {
543+
shown.push(title);
544+
}
545+
},
546+
);
547+
windowFocused = false;
548+
549+
await remount([
550+
durable('turn_start', 1),
551+
durable('run_settled', 2, { outcome: 'completed' }),
552+
{ ...durable('turn_start', 1), runId: 'durable-2', generationId: 'durable-2' },
553+
{
554+
...durable('run_settled', 2, { outcome: 'failed', message: 'Old failure' }),
555+
runId: 'durable-2',
556+
generationId: 'durable-2',
557+
},
558+
]);
559+
await vi.advanceTimersByTimeAsync(0);
560+
561+
expect(useCodesignStore.getState().isGenerating).toBe(false);
562+
expect(shown).toEqual([]);
563+
});
564+
503565
it('replays persisted chat and terminal outcomes without duplicate writes or automatic generation', async () => {
504566
useCodesignStore.setState({ previewSource: 'Edited after generation' });
505567
await remount([

0 commit comments

Comments
 (0)