Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/system-notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@open-codesign/desktop": patch
"@open-codesign/i18n": patch
---

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 focuses the app and opens that design. Settings → Advanced can turn it off; preferences move to schema version 10 and existing installs default to on.
9 changes: 8 additions & 1 deletion apps/desktop/src/main/ipc/shell.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { mkdir } from 'node:fs/promises';
import path_module from 'node:path';
import { CodesignError } from '@open-codesign/shared';
import { app, ipcMain, shell } from '../electron-runtime';
import { app, BrowserWindow, ipcMain, shell } from '../electron-runtime';
import { getLogPath } from '../logger';
import { isAllowedExternalUrl } from '../open-external';

Expand All @@ -25,4 +25,11 @@ export function registerShellIpc(): void {
}
await shell.openExternal(url);
});

ipcMain.handle('codesign:v1:focus-window', (event) => {
const win = BrowserWindow.fromWebContents(event.sender);
if (win?.isMinimized()) win.restore();
win?.show();
win?.focus();
});
}
46 changes: 44 additions & 2 deletions apps/desktop/src/main/preferences-ipc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ describe('readPersisted()', () => {
workspaceMemoryAutoUpdate: true,
userMemoryAutoUpdate: false,
proxyUrl: '',
systemNotifications: true,
});
});

Expand Down Expand Up @@ -241,7 +242,7 @@ describe('readPersisted()', () => {
schemaVersion: number;
diagnosticsLastReadTs: number;
};
expect(written.schemaVersion).toBe(9);
expect(written.schemaVersion).toBe(10);
expect(written.diagnosticsLastReadTs).toBe(result.diagnosticsLastReadTs);
expect(written.diagnosticsLastReadTs).toBeGreaterThanOrEqual(before);
expect(written.diagnosticsLastReadTs).toBeLessThanOrEqual(after);
Expand Down Expand Up @@ -361,7 +362,7 @@ describe('preferences memory schema fields', () => {
workspaceMemoryAutoUpdate: boolean;
userMemoryAutoUpdate: boolean;
};
expect(written.schemaVersion).toBe(9);
expect(written.schemaVersion).toBe(10);
expect(written.memoryEnabled).toBe(false);
expect(written.workspaceMemoryAutoUpdate).toBe(false);
expect(written.userMemoryAutoUpdate).toBe(true);
Expand Down Expand Up @@ -402,6 +403,47 @@ describe('preferences memory schema fields', () => {
}),
).rejects.toThrow(/proxyUrl must be a string/);
});

it('keeps system notifications on for files written before the setting existed', async () => {
readFileMock.mockResolvedValueOnce(
JSON.stringify({ schemaVersion: 9, diagnosticsLastReadTs: 1, proxyUrl: '' }),
);

const prefs = await readPersisted();
expect(prefs.systemNotifications).toBe(true);
});

it('round-trips systemNotifications through preferences:v1:update', async () => {
readFileMock.mockResolvedValueOnce(
JSON.stringify({ schemaVersion: 10, diagnosticsLastReadTs: 1, systemNotifications: true }),
);
const updated = await (
handlers['preferences:v1:update'] as (_e: null, raw: unknown) => Promise<unknown>
)(null, { systemNotifications: false });

expect((updated as { systemNotifications: boolean }).systemNotifications).toBe(false);
const lastCall = writeFileMock.mock.calls.at(-1);
if (!lastCall) throw new Error('writeFile was not called');
const written = JSON.parse(lastCall[1] as string) as {
schemaVersion: number;
systemNotifications: boolean;
};
expect(written.schemaVersion).toBe(10);
expect(written.systemNotifications).toBe(false);
});

it('rejects non-boolean systemNotifications updates and persisted values', async () => {
await expect(
(handlers['preferences:v1:update'] as (_e: null, raw: unknown) => Promise<unknown>)(null, {
systemNotifications: 'yes',
}),
).rejects.toThrow(/systemNotifications must be a boolean/);

readFileMock.mockResolvedValueOnce(
JSON.stringify({ schemaVersion: 10, diagnosticsLastReadTs: 1, systemNotifications: 1 }),
);
await expect(readPersisted()).rejects.toThrow(/systemNotifications must be a boolean/);
});
});

describe('applyProxyConfig()', () => {
Expand Down
25 changes: 23 additions & 2 deletions apps/desktop/src/main/preferences-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { getLogger } from './logger';

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

const SCHEMA_VERSION = 9;
const SCHEMA_VERSION = 10;
// v1 → v2: raise the abandoned 120s timeout default (which aborted real
// agentic runs mid-loop) to 600s. Values that happen to equal the old
// default are treated as unmigrated defaults, not user intent.
Expand Down Expand Up @@ -47,6 +47,8 @@ export interface Preferences {
/** HTTP/HTTPS proxy URL applied to Chromium and Node outbound traffic.
* Empty string disables the proxy. */
proxyUrl: string;
/** Notify through the OS when a design run ends or asks a question while the window is in the background. */
systemNotifications: boolean;
}

interface PreferencesFile extends Preferences {
Expand All @@ -67,6 +69,7 @@ const DEFAULTS: Preferences = {
workspaceMemoryAutoUpdate: true,
userMemoryAutoUpdate: false,
proxyUrl: '',
systemNotifications: true,
};

const PREFERENCE_UPDATE_FIELDS = [
Expand All @@ -79,6 +82,7 @@ const PREFERENCE_UPDATE_FIELDS = [
'workspaceMemoryAutoUpdate',
'userMemoryAutoUpdate',
'proxyUrl',
'systemNotifications',
] as const;

function assertKnownPreferenceFields(r: Record<string, unknown>): void {
Expand Down Expand Up @@ -137,7 +141,8 @@ function readPersistedBoolean(
| 'checkForUpdatesOnStartup'
| 'memoryEnabled'
| 'workspaceMemoryAutoUpdate'
| 'userMemoryAutoUpdate',
| 'userMemoryAutoUpdate'
| 'systemNotifications',
defaultValue: boolean,
): boolean {
const value = r[key];
Expand Down Expand Up @@ -224,6 +229,11 @@ function parsePersistedFile(rawJson: unknown): Preferences {
DEFAULTS.userMemoryAutoUpdate,
),
proxyUrl: readPersistedString(parsed, 'proxyUrl', DEFAULTS.proxyUrl),
systemNotifications: readPersistedBoolean(
parsed,
'systemNotifications',
DEFAULTS.systemNotifications,
),
};
}

Expand Down Expand Up @@ -332,6 +342,15 @@ function readMemoryAutoUpdate(
return value;
}

function readSystemNotifications(r: Record<string, unknown>): boolean | undefined {
const value = r['systemNotifications'];
if (value === undefined) return undefined;
if (typeof value !== 'boolean') {
throw new CodesignError('systemNotifications must be a boolean', ERROR_CODES.IPC_BAD_INPUT);
}
return value;
}

function readDismissedVersion(r: Record<string, unknown>): string | undefined {
const value = r['dismissedUpdateVersion'];
if (value === undefined) return undefined;
Expand Down Expand Up @@ -389,6 +408,8 @@ function parsePreferences(raw: unknown): Partial<Preferences> {
if (userMemoryAutoUpdate !== undefined) out.userMemoryAutoUpdate = userMemoryAutoUpdate;
const proxyUrl = readProxyUrl(r);
if (proxyUrl !== undefined) out.proxyUrl = proxyUrl;
const systemNotifications = readSystemNotifications(r);
if (systemNotifications !== undefined) out.systemNotifications = systemNotifications;
return out;
}

Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ export interface Preferences {
workspaceMemoryAutoUpdate: boolean;
userMemoryAutoUpdate: boolean;
proxyUrl: string;
systemNotifications: boolean;
}

export interface MemoryFileRead {
Expand Down Expand Up @@ -1018,6 +1019,7 @@ const api = {
},
openExternal: (url: string) =>
ipcRenderer.invoke('codesign:v1:open-external', url) as Promise<void>,
focusWindow: () => ipcRenderer.invoke('codesign:v1:focus-window') as Promise<void>,
ask: {
history: (filter?: { runId?: string; designId?: string }) =>
ipcRenderer.invoke('ask:history', filter) as Promise<AskHistoryEntry[]>,
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { UpdateBanner } from './components/UpdateBanner';
import { useAgentStream } from './hooks/useAgentStream';
import { useKeyboard } from './hooks/useKeyboard';
import { useUpdateWiring } from './hooks/useUpdateWiring';
import { notifyDesignInBackground } from './lib/system-notifications';

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

useEffect(() => {
if (!window.codesign) {
Expand Down
14 changes: 14 additions & 0 deletions apps/desktop/src/renderer/src/components/settings/AdvancedTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export function AdvancedTab() {
workspaceMemoryAutoUpdate: true,
userMemoryAutoUpdate: false,
proxyUrl: '',
systemNotifications: true,
});

useEffect(() => {
Expand Down Expand Up @@ -142,6 +143,19 @@ export function AdvancedTab() {
/>
</Row>

<Row
label={t('settings.advanced.systemNotifications')}
hint={t('settings.advanced.systemNotificationsHint')}
>
<input
type="checkbox"
checked={prefs.systemNotifications}
aria-label={t('settings.advanced.systemNotifications')}
onChange={(e) => void updatePref({ systemNotifications: e.target.checked })}
className="h-4 w-4 accent-[var(--color-accent)]"
/>
Comment on lines +150 to +156
</Row>

<Row label={t('settings.advanced.timeout')} hint={t('settings.advanced.timeoutHint')}>
<NativeSelect
value={String(prefs.generationTimeoutSec)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const DEFAULT_PREFS: Preferences = {
workspaceMemoryAutoUpdate: true,
userMemoryAutoUpdate: false,
proxyUrl: '',
systemNotifications: true,
};

export function MemoryTab() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const design = {
workspacePath: '/tmp/codesign-completion-test',
};
let listener: ((event: AgentStreamEvent) => void) | undefined;
let windowFocused = true;
const append = vi.fn(async (input: { designId: string; kind: string; payload: unknown }) => ({
...input,
id: 'row',
Expand Down Expand Up @@ -89,12 +90,19 @@ beforeEach(() => {
persistAgentRunSnapshot: vi.fn(async () => {}),
tryAutoPolish: vi.fn(),
});
windowFocused = true;
vi.stubGlobal('document', {
hasFocus: () => windowFocused,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
});
vi.stubGlobal('window', {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
setTimeout,
codesign: {
generate,
preferences: { get: vi.fn(async () => ({ systemNotifications: true })) },
generationStatus: vi.fn(async () => ({ schemaVersion: 1, running: [] })),
snapshots: {
list: vi.fn(async () => []),
Expand Down Expand Up @@ -127,6 +135,31 @@ afterEach(() => {
});

describe('agent stream / IPC completion ordering', () => {
it('notifies through the OS only for live runs that complete or fail in the background', async () => {
const shown: Array<{ title: string; body: string }> = [];
vi.stubGlobal(
'Notification',
class {
onclick: (() => void) | null = null;
constructor(title: string, options: { body: string }) {
shown.push({ title, body: options.body });
}
},
);
windowFocused = false;

emit('run_settled', 'completed-run', { outcome: 'completed' });
emit('run_settled', 'failed-run', { outcome: 'failed' });
emit('run_settled', 'cancelled-run', { outcome: 'cancelled' });
emit('run_settled', 'interrupted-run', { outcome: 'interrupted' });
await vi.advanceTimersByTimeAsync(0);

expect(shown).toEqual([
{ title: 'Aurora', body: 'Design ready' },
{ title: 'Aurora', body: 'Generation failed' },
]);
});

it('keeps exactly one canonical active message through delivery, turn end and completion', async () => {
const get = useCodesignStore.getState;
const rows: ChatMessageRow[] = [];
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/renderer/src/hooks/useAgentStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import { DEFAULT_SOURCE_ENTRY, LEGACY_SOURCE_ENTRY } from '@open-codesign/shared';
import { useEffect, useRef } from 'react';
import type { AgentStreamEvent } from '../../../preload/index';
import { notifyDesignInBackground } from '../lib/system-notifications';
import { resolveReferencedWorkspacePreviewPath } from '../preview/workspace-source';
import { useCodesignStore } from '../store';
import { coerceUsageSnapshot } from '../store/slices/usage';
Expand Down Expand Up @@ -510,6 +511,15 @@ export function useAgentStream(): void {
if (event.type === 'run_settled') {
handleSettled(event, replay);
forgetCancelledGeneration(event.generationId);
if (!replay && (event.outcome === 'completed' || event.outcome === 'failed')) {
notifyDesignInBackground(
useCodesignStore.getState(),
event.designId,
event.outcome === 'completed' ? 'done' : 'failed',
).catch((error: unknown) =>
console.warn('[open-codesign] system notification failed:', error),
);
}
return;
}
if (
Expand Down
Loading
Loading