Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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 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.
3 changes: 3 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,9 @@ if (!IS_VITEST) {
app.quit();
return;
}
// Windows shows toasts only for the AppUserModelID on the installer's
// Start Menu shortcut, which electron-builder sets to the appId.
if (process.platform === 'win32') app.setAppUserModelId('ai.opencowork.codesign');
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
Expand Down
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();
});
}
65 changes: 63 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,66 @@ describe('preferences memory schema fields', () => {
}),
).rejects.toThrow(/proxyUrl must be a string/);
});

it('leaves system notifications off for installs upgraded from an earlier schema', async () => {
readFileMock.mockResolvedValueOnce(
JSON.stringify({ schemaVersion: 9, diagnosticsLastReadTs: 1, proxyUrl: '' }),
);

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

it('keeps an upgraded install opted out when another preference is saved', async () => {
readFileMock.mockResolvedValueOnce(
JSON.stringify({ schemaVersion: 9, diagnosticsLastReadTs: 1, proxyUrl: '' }),
);
await (handlers['preferences:v1:update'] as (_e: null, raw: unknown) => Promise<unknown>)(
null,
{ proxyUrl: 'http://127.0.0.1:7890' },
);

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('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
29 changes: 27 additions & 2 deletions apps/desktop/src/main/preferences-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,18 @@ 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.
const V1_DEFAULT_TIMEOUT_SEC = 120;
// v2 -> v3: 600s still clips slower long-form multi-turn runs, so the default
// moves to 1200s.
const V2_DEFAULT_TIMEOUT_SEC = 600;
// v9 -> v10: system notifications. Installs upgraded from an earlier schema
// keep them off until the user turns them on in Settings -> Advanced; new
// installs start with them on.
const SYSTEM_NOTIFICATIONS_SCHEMA_VERSION = 10;

function prefsFile(): string {
return join(configDir(), 'preferences.json');
Expand All @@ -47,6 +51,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 +73,7 @@ const DEFAULTS: Preferences = {
workspaceMemoryAutoUpdate: true,
userMemoryAutoUpdate: false,
proxyUrl: '',
systemNotifications: true,
};

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

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

Expand Down Expand Up @@ -332,6 +346,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 +412,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)]"
/>
</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 Expand Up @@ -500,6 +533,35 @@ describe('durable run refresh recovery', () => {
expect(append).not.toHaveBeenCalled();
});

it('does not notify for run outcomes recovered after a refresh', async () => {
const shown: string[] = [];
vi.stubGlobal(
'Notification',
class {
onclick: (() => void) | null = null;
constructor(title: string) {
shown.push(title);
}
},
);
windowFocused = false;

await remount([
durable('turn_start', 1),
durable('run_settled', 2, { outcome: 'completed' }),
{ ...durable('turn_start', 1), runId: 'durable-2', generationId: 'durable-2' },
{
...durable('run_settled', 2, { outcome: 'failed', message: 'Old failure' }),
runId: 'durable-2',
generationId: 'durable-2',
},
]);
await vi.advanceTimersByTimeAsync(0);

expect(useCodesignStore.getState().isGenerating).toBe(false);
expect(shown).toEqual([]);
});

it('replays persisted chat and terminal outcomes without duplicate writes or automatic generation', async () => {
useCodesignStore.setState({ previewSource: 'Edited after generation' });
await remount([
Expand Down
Loading
Loading