From 5c4980a4ae720a5fb0b4461aa6a19f4b05ffa91f Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Wed, 7 Oct 2026 17:51:16 +0800 Subject: [PATCH 1/6] feat(desktop): notify when a background design run finishes Show a system notification when a live run settles as completed or failed, or when the agent asks a question, while the window is unfocused. Clicking it focuses the window and opens the design. Settings > Advanced can turn it off; preferences move to schema version 10 with the setting on by default. --- .changeset/system-notifications.md | 6 ++ apps/desktop/src/main/ipc/shell.ts | 9 +- apps/desktop/src/main/preferences-ipc.test.ts | 46 +++++++- apps/desktop/src/main/preferences-ipc.ts | 25 ++++- apps/desktop/src/preload/index.ts | 2 + apps/desktop/src/renderer/src/App.tsx | 9 ++ .../src/components/settings/AdvancedTab.tsx | 13 +++ .../src/components/settings/MemoryTab.tsx | 1 + .../hooks/useAgentStream.completion.test.ts | 35 ++++++ .../src/renderer/src/hooks/useAgentStream.ts | 8 ++ .../src/lib/system-notifications.test.ts | 100 ++++++++++++++++++ .../renderer/src/lib/system-notifications.ts | 29 +++++ packages/i18n/src/locales/en.json | 5 + packages/i18n/src/locales/es.json | 5 + packages/i18n/src/locales/pt-BR.json | 5 + packages/i18n/src/locales/zh-CN.json | 5 + 16 files changed, 298 insertions(+), 5 deletions(-) create mode 100644 .changeset/system-notifications.md create mode 100644 apps/desktop/src/renderer/src/lib/system-notifications.test.ts create mode 100644 apps/desktop/src/renderer/src/lib/system-notifications.ts diff --git a/.changeset/system-notifications.md b/.changeset/system-notifications.md new file mode 100644 index 000000000..7448ee193 --- /dev/null +++ b/.changeset/system-notifications.md @@ -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. diff --git a/apps/desktop/src/main/ipc/shell.ts b/apps/desktop/src/main/ipc/shell.ts index ea985f997..710911dd2 100644 --- a/apps/desktop/src/main/ipc/shell.ts +++ b/apps/desktop/src/main/ipc/shell.ts @@ -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'; @@ -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(); + }); } diff --git a/apps/desktop/src/main/preferences-ipc.test.ts b/apps/desktop/src/main/preferences-ipc.test.ts index a941ae4f9..8b916282c 100644 --- a/apps/desktop/src/main/preferences-ipc.test.ts +++ b/apps/desktop/src/main/preferences-ipc.test.ts @@ -66,6 +66,7 @@ describe('readPersisted()', () => { workspaceMemoryAutoUpdate: true, userMemoryAutoUpdate: false, proxyUrl: '', + systemNotifications: true, }); }); @@ -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); @@ -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); @@ -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 + )(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)(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()', () => { diff --git a/apps/desktop/src/main/preferences-ipc.ts b/apps/desktop/src/main/preferences-ipc.ts index 444b778ac..4624c8715 100644 --- a/apps/desktop/src/main/preferences-ipc.ts +++ b/apps/desktop/src/main/preferences-ipc.ts @@ -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. @@ -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 { @@ -67,6 +69,7 @@ const DEFAULTS: Preferences = { workspaceMemoryAutoUpdate: true, userMemoryAutoUpdate: false, proxyUrl: '', + systemNotifications: true, }; const PREFERENCE_UPDATE_FIELDS = [ @@ -79,6 +82,7 @@ const PREFERENCE_UPDATE_FIELDS = [ 'workspaceMemoryAutoUpdate', 'userMemoryAutoUpdate', 'proxyUrl', + 'systemNotifications', ] as const; function assertKnownPreferenceFields(r: Record): void { @@ -137,7 +141,8 @@ function readPersistedBoolean( | 'checkForUpdatesOnStartup' | 'memoryEnabled' | 'workspaceMemoryAutoUpdate' - | 'userMemoryAutoUpdate', + | 'userMemoryAutoUpdate' + | 'systemNotifications', defaultValue: boolean, ): boolean { const value = r[key]; @@ -224,6 +229,11 @@ function parsePersistedFile(rawJson: unknown): Preferences { DEFAULTS.userMemoryAutoUpdate, ), proxyUrl: readPersistedString(parsed, 'proxyUrl', DEFAULTS.proxyUrl), + systemNotifications: readPersistedBoolean( + parsed, + 'systemNotifications', + DEFAULTS.systemNotifications, + ), }; } @@ -332,6 +342,15 @@ function readMemoryAutoUpdate( return value; } +function readSystemNotifications(r: Record): 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 | undefined { const value = r['dismissedUpdateVersion']; if (value === undefined) return undefined; @@ -389,6 +408,8 @@ function parsePreferences(raw: unknown): Partial { 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; } diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 6ecfbf78c..38f86a732 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -272,6 +272,7 @@ export interface Preferences { workspaceMemoryAutoUpdate: boolean; userMemoryAutoUpdate: boolean; proxyUrl: string; + systemNotifications: boolean; } export interface MemoryFileRead { @@ -1018,6 +1019,7 @@ const api = { }, openExternal: (url: string) => ipcRenderer.invoke('codesign:v1:open-external', url) as Promise, + focusWindow: () => ipcRenderer.invoke('codesign:v1:focus-window') as Promise, ask: { history: (filter?: { runId?: string; designId?: string }) => ipcRenderer.invoke('ask:history', filter) as Promise, diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 044b65c40..7015fc3b2 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -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 @@ -71,6 +72,14 @@ export function App() { const [updateStore] = useState(() => createUpdateStore({ dismissedVersion: '' })); useUpdateWiring(updateStore); useAgentStream(); + useEffect(() => { + const off = window.codesign?.ask?.onRequest?.((request) => { + void notifyDesignInBackground(useCodesignStore.getState(), request.designId, 'ask'); + }); + return () => { + off?.(); + }; + }, []); useEffect(() => { if (!window.codesign) { diff --git a/apps/desktop/src/renderer/src/components/settings/AdvancedTab.tsx b/apps/desktop/src/renderer/src/components/settings/AdvancedTab.tsx index 28f0a4099..fcd2f2aa6 100644 --- a/apps/desktop/src/renderer/src/components/settings/AdvancedTab.tsx +++ b/apps/desktop/src/renderer/src/components/settings/AdvancedTab.tsx @@ -71,6 +71,7 @@ export function AdvancedTab() { workspaceMemoryAutoUpdate: true, userMemoryAutoUpdate: false, proxyUrl: '', + systemNotifications: true, }); useEffect(() => { @@ -142,6 +143,18 @@ export function AdvancedTab() { /> + + void updatePref({ systemNotifications: e.target.checked })} + className="h-4 w-4 accent-[var(--color-accent)]" + /> + + void) | undefined; +let windowFocused = true; const append = vi.fn(async (input: { designId: string; kind: string; payload: unknown }) => ({ ...input, id: 'row', @@ -89,6 +90,12 @@ 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(), @@ -127,6 +134,34 @@ 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 }); + } + }, + ); + Object.assign(window.codesign ?? {}, { + preferences: { get: vi.fn(async () => ({ systemNotifications: true })) }, + }); + 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[] = []; diff --git a/apps/desktop/src/renderer/src/hooks/useAgentStream.ts b/apps/desktop/src/renderer/src/hooks/useAgentStream.ts index c5a45becc..62273fff7 100644 --- a/apps/desktop/src/renderer/src/hooks/useAgentStream.ts +++ b/apps/desktop/src/renderer/src/hooks/useAgentStream.ts @@ -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'; @@ -510,6 +511,13 @@ export function useAgentStream(): void { if (event.type === 'run_settled') { handleSettled(event, replay); forgetCancelledGeneration(event.generationId); + if (!replay && (event.outcome === 'completed' || event.outcome === 'failed')) { + void notifyDesignInBackground( + useCodesignStore.getState(), + event.designId, + event.outcome === 'completed' ? 'done' : 'failed', + ); + } return; } if ( diff --git a/apps/desktop/src/renderer/src/lib/system-notifications.test.ts b/apps/desktop/src/renderer/src/lib/system-notifications.test.ts new file mode 100644 index 000000000..21753f0a3 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/system-notifications.test.ts @@ -0,0 +1,100 @@ +import { initI18n } from '@open-codesign/i18n'; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { notifyDesignInBackground } from './system-notifications'; + +class FakeNotification { + static created: FakeNotification[] = []; + onclick: (() => void) | null = null; + + constructor( + readonly title: string, + readonly options: { body: string }, + ) { + FakeNotification.created.push(this); + } +} + +const focusWindow = vi.fn(async () => {}); +const switchDesign = vi.fn(async () => {}); +let hasFocus = false; +let systemNotifications = true; + +function state() { + return { + designs: [{ id: 'design-1', name: 'Daymark' }], + switchDesign, + } as unknown as Parameters[0]; +} + +beforeAll(async () => { + await initI18n('en'); +}); + +beforeEach(() => { + FakeNotification.created = []; + focusWindow.mockClear(); + switchDesign.mockClear(); + hasFocus = false; + systemNotifications = true; + vi.stubGlobal('Notification', FakeNotification); + vi.stubGlobal('document', { hasFocus: () => hasFocus }); + vi.stubGlobal('window', { + codesign: { + focusWindow, + preferences: { get: async () => ({ systemNotifications }) }, + }, + }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('notifyDesignInBackground', () => { + it('names the design and opens it when clicked', async () => { + await notifyDesignInBackground(state(), 'design-1', 'done'); + + expect(FakeNotification.created).toHaveLength(1); + const [notification] = FakeNotification.created; + expect(notification?.title).toBe('Daymark'); + expect(notification?.options.body).toBe('Design ready'); + + notification?.onclick?.(); + expect(focusWindow).toHaveBeenCalledTimes(1); + expect(switchDesign).toHaveBeenCalledWith('design-1'); + }); + + it('uses distinct messages for failures and questions', async () => { + await notifyDesignInBackground(state(), 'design-1', 'failed'); + await notifyDesignInBackground(state(), 'design-1', 'ask'); + + expect(FakeNotification.created.map((n) => n.options.body)).toEqual([ + 'Generation failed', + 'Waiting for your answer', + ]); + }); + + it('stays quiet while the window has focus', async () => { + hasFocus = true; + await notifyDesignInBackground(state(), 'design-1', 'done'); + + expect(FakeNotification.created).toHaveLength(0); + }); + + it('respects the Advanced setting', async () => { + systemNotifications = false; + await notifyDesignInBackground(state(), 'design-1', 'done'); + + expect(FakeNotification.created).toHaveLength(0); + }); + + it('falls back to the app name and only focuses for an unknown design', async () => { + await notifyDesignInBackground(state(), undefined, 'ask'); + + const [notification] = FakeNotification.created; + expect(notification?.title).toBe('Open CoDesign'); + notification?.onclick?.(); + expect(focusWindow).toHaveBeenCalledTimes(1); + expect(switchDesign).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/renderer/src/lib/system-notifications.ts b/apps/desktop/src/renderer/src/lib/system-notifications.ts new file mode 100644 index 000000000..702bd2518 --- /dev/null +++ b/apps/desktop/src/renderer/src/lib/system-notifications.ts @@ -0,0 +1,29 @@ +import { i18n } from '@open-codesign/i18n'; +import type { CodesignState } from '../store'; + +export type DesignNotificationKind = 'done' | 'failed' | 'ask'; + +const BODY_KEYS: Record = { + done: 'notifications.systemDesignReady', + failed: 'notifications.systemGenerationFailed', + ask: 'notifications.systemAskWaiting', +}; + +export async function notifyDesignInBackground( + state: Pick, + designId: string | undefined, + kind: DesignNotificationKind, +): Promise { + const api = window.codesign; + if (!api || document.hasFocus()) return; + const prefs = await api.preferences.get(); + if (!prefs.systemNotifications) return; + const design = state.designs.find((item) => item.id === designId); + const notification = new Notification(design?.name ?? 'Open CoDesign', { + body: i18n.t(BODY_KEYS[kind]), + }); + notification.onclick = () => { + void api.focusWindow(); + if (design) void state.switchDesign(design.id); + }; +} diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 3c7c7bdfe..38daad8c8 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -801,6 +801,8 @@ "beta": "Beta", "checkForUpdatesOnStartup": "Check for updates on startup", "checkForUpdatesOnStartupHint": "Automatically check for a new release 30 seconds after launch.", + "systemNotifications": "System notifications", + "systemNotificationsHint": "Notify me when a design finishes, fails, or asks a question while Open CoDesign is in the background.", "timeout": "Generation timeout", "timeoutHint": "Seconds before a generation request is aborted.", "timeoutSeconds": "{{value}} s", @@ -976,6 +978,9 @@ "designSystemCleared": "Design system cleared", "clearDesignSystemFailed": "Unable to clear design system", "generationFailed": "Generation failed", + "systemDesignReady": "Design ready", + "systemGenerationFailed": "Generation failed", + "systemAskWaiting": "Waiting for your answer", "generationFailedApplyFix": "Apply fix", "generationFailedBaseUrlUpdated": "Base URL updated. Try the request again.", "generationFailedFixUnavailable": "Auto-fix unavailable", diff --git a/packages/i18n/src/locales/es.json b/packages/i18n/src/locales/es.json index c612f5a4a..5a81ded6d 100644 --- a/packages/i18n/src/locales/es.json +++ b/packages/i18n/src/locales/es.json @@ -623,6 +623,8 @@ "beta": "Beta", "checkForUpdatesOnStartup": "Buscar actualizaciones al inicio", "checkForUpdatesOnStartupHint": "Buscar automáticamente una nueva versión 30 segundos después del inicio.", + "systemNotifications": "Notificaciones del sistema", + "systemNotificationsHint": "Avisarme cuando un diseño termine, falle o haga una pregunta mientras Open CoDesign está en segundo plano.", "timeout": "Tiempo de espera de generación", "timeoutHint": "Segundos antes de que una solicitud de generación se aborte.", "timeoutSeconds": "{{value}} s", @@ -798,6 +800,9 @@ "designSystemCleared": "Sistema de diseño limpiado", "clearDesignSystemFailed": "No se pudo limpiar el sistema de diseño", "generationFailed": "Fallo en la generación", + "systemDesignReady": "Diseño listo", + "systemGenerationFailed": "Fallo en la generación", + "systemAskWaiting": "Esperando tu respuesta", "generationFailedApplyFix": "Aplicar corrección", "generationFailedBaseUrlUpdated": "URL base actualizada. Intenta la solicitud de nuevo.", "generationFailedFixUnavailable": "Autocorrección no disponible", diff --git a/packages/i18n/src/locales/pt-BR.json b/packages/i18n/src/locales/pt-BR.json index b6747f88d..7a3914c69 100644 --- a/packages/i18n/src/locales/pt-BR.json +++ b/packages/i18n/src/locales/pt-BR.json @@ -619,6 +619,8 @@ "beta": "Beta", "checkForUpdatesOnStartup": "Verificar atualizações ao iniciar", "checkForUpdatesOnStartupHint": "Verifica automaticamente se há nova versão 30 segundos após iniciar o app.", + "systemNotifications": "Notificações do sistema", + "systemNotificationsHint": "Avisar quando um design terminar, falhar ou fizer uma pergunta enquanto o Open CoDesign estiver em segundo plano.", "timeout": "Tempo limite de geração", "timeoutHint": "Segundos antes de uma geração ser abortada.", "timeoutSeconds": "{{value}} s", @@ -794,6 +796,9 @@ "designSystemCleared": "Design system removido", "clearDesignSystemFailed": "Não foi possível remover o design system", "generationFailed": "Falha na geração", + "systemDesignReady": "Design pronto", + "systemGenerationFailed": "Falha na geração", + "systemAskWaiting": "Aguardando sua resposta", "cancellationFailed": "Falha no cancelamento", "inlineCommentFailed": "Falha no comentário inline", "commentNeedsSnapshot": "Gere um design antes de deixar um comentário", diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index 7973e01ee..749d88479 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -801,6 +801,8 @@ "beta": "测试版", "checkForUpdatesOnStartup": "启动时检查更新", "checkForUpdatesOnStartupHint": "启动 30 秒后自动检查新版本。", + "systemNotifications": "系统通知", + "systemNotificationsHint": "Open CoDesign 在后台时,设计完成、生成失败或需要你回答问题时发送系统通知。", "timeout": "生成超时", "timeoutHint": "超过该秒数后会中止生成请求。", "timeoutSeconds": "{{value}} 秒", @@ -976,6 +978,9 @@ "designSystemCleared": "设计系统已清除", "clearDesignSystemFailed": "无法清除设计系统", "generationFailed": "生成失败", + "systemDesignReady": "设计已完成", + "systemGenerationFailed": "生成失败", + "systemAskWaiting": "等待你的回答", "generationFailedApplyFix": "应用修复", "generationFailedBaseUrlUpdated": "Base URL 已更新,请重试。", "generationFailedFixUnavailable": "自动修复不可用", From 72f8a63c1a16050b05da2c31b7fb9db159c8a643 Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Wed, 7 Oct 2026 20:08:37 +0800 Subject: [PATCH 2/6] fix(desktop): check focus after reading the notification setting Read the System notifications preference before checking window focus, so a user who returns while the setting is loading is not notified. Callers log a failed preference read instead of leaving an unhandled rejection, and the Advanced checkbox now has an accessible name. --- apps/desktop/src/renderer/src/App.tsx | 4 +++- .../src/components/settings/AdvancedTab.tsx | 1 + .../src/hooks/useAgentStream.completion.test.ts | 4 +--- .../src/renderer/src/hooks/useAgentStream.ts | 4 +++- .../renderer/src/lib/system-notifications.test.ts | 13 +++++++++++++ .../src/renderer/src/lib/system-notifications.ts | 5 +++-- 6 files changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 7015fc3b2..1f1f8d004 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -74,7 +74,9 @@ export function App() { useAgentStream(); useEffect(() => { const off = window.codesign?.ask?.onRequest?.((request) => { - void notifyDesignInBackground(useCodesignStore.getState(), request.designId, 'ask'); + notifyDesignInBackground(useCodesignStore.getState(), request.designId, 'ask').catch( + (error: unknown) => console.warn('[open-codesign] system notification failed:', error), + ); }); return () => { off?.(); diff --git a/apps/desktop/src/renderer/src/components/settings/AdvancedTab.tsx b/apps/desktop/src/renderer/src/components/settings/AdvancedTab.tsx index fcd2f2aa6..91a1c2b12 100644 --- a/apps/desktop/src/renderer/src/components/settings/AdvancedTab.tsx +++ b/apps/desktop/src/renderer/src/components/settings/AdvancedTab.tsx @@ -150,6 +150,7 @@ export function AdvancedTab() { void updatePref({ systemNotifications: e.target.checked })} className="h-4 w-4 accent-[var(--color-accent)]" /> diff --git a/apps/desktop/src/renderer/src/hooks/useAgentStream.completion.test.ts b/apps/desktop/src/renderer/src/hooks/useAgentStream.completion.test.ts index 2afb3821e..e1117914f 100644 --- a/apps/desktop/src/renderer/src/hooks/useAgentStream.completion.test.ts +++ b/apps/desktop/src/renderer/src/hooks/useAgentStream.completion.test.ts @@ -102,6 +102,7 @@ beforeEach(() => { setTimeout, codesign: { generate, + preferences: { get: vi.fn(async () => ({ systemNotifications: true })) }, generationStatus: vi.fn(async () => ({ schemaVersion: 1, running: [] })), snapshots: { list: vi.fn(async () => []), @@ -145,9 +146,6 @@ describe('agent stream / IPC completion ordering', () => { } }, ); - Object.assign(window.codesign ?? {}, { - preferences: { get: vi.fn(async () => ({ systemNotifications: true })) }, - }); windowFocused = false; emit('run_settled', 'completed-run', { outcome: 'completed' }); diff --git a/apps/desktop/src/renderer/src/hooks/useAgentStream.ts b/apps/desktop/src/renderer/src/hooks/useAgentStream.ts index 62273fff7..138e22407 100644 --- a/apps/desktop/src/renderer/src/hooks/useAgentStream.ts +++ b/apps/desktop/src/renderer/src/hooks/useAgentStream.ts @@ -512,10 +512,12 @@ export function useAgentStream(): void { handleSettled(event, replay); forgetCancelledGeneration(event.generationId); if (!replay && (event.outcome === 'completed' || event.outcome === 'failed')) { - void notifyDesignInBackground( + notifyDesignInBackground( useCodesignStore.getState(), event.designId, event.outcome === 'completed' ? 'done' : 'failed', + ).catch((error: unknown) => + console.warn('[open-codesign] system notification failed:', error), ); } return; diff --git a/apps/desktop/src/renderer/src/lib/system-notifications.test.ts b/apps/desktop/src/renderer/src/lib/system-notifications.test.ts index 21753f0a3..764104a01 100644 --- a/apps/desktop/src/renderer/src/lib/system-notifications.test.ts +++ b/apps/desktop/src/renderer/src/lib/system-notifications.test.ts @@ -81,6 +81,19 @@ describe('notifyDesignInBackground', () => { expect(FakeNotification.created).toHaveLength(0); }); + it('stays quiet when the user returns while the setting is being read', async () => { + const api = window.codesign as unknown as { + preferences: { get: () => Promise<{ systemNotifications: boolean }> }; + }; + api.preferences.get = async () => { + hasFocus = true; + return { systemNotifications: true }; + }; + await notifyDesignInBackground(state(), 'design-1', 'done'); + + expect(FakeNotification.created).toHaveLength(0); + }); + it('respects the Advanced setting', async () => { systemNotifications = false; await notifyDesignInBackground(state(), 'design-1', 'done'); diff --git a/apps/desktop/src/renderer/src/lib/system-notifications.ts b/apps/desktop/src/renderer/src/lib/system-notifications.ts index 702bd2518..b7419fc40 100644 --- a/apps/desktop/src/renderer/src/lib/system-notifications.ts +++ b/apps/desktop/src/renderer/src/lib/system-notifications.ts @@ -15,9 +15,10 @@ export async function notifyDesignInBackground( kind: DesignNotificationKind, ): Promise { const api = window.codesign; - if (!api || document.hasFocus()) return; + if (!api) return; const prefs = await api.preferences.get(); - if (!prefs.systemNotifications) return; + // Focus is read after the setting so a user who returns meanwhile is not notified. + if (!prefs.systemNotifications || document.hasFocus()) return; const design = state.designs.find((item) => item.id === designId); const notification = new Notification(design?.name ?? 'Open CoDesign', { body: i18n.t(BODY_KEYS[kind]), From 18946b60d2421aeb87952d129e8fd43a5661cf3b Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Wed, 7 Oct 2026 20:57:21 +0800 Subject: [PATCH 3/6] fix(desktop): log failures when opening a design from a notification Focusing the window and switching designs are now awaited together, and a failure is logged like the notification call sites instead of becoming an unhandled rejection. --- .../src/renderer/src/lib/system-notifications.test.ts | 11 +++++++++++ .../src/renderer/src/lib/system-notifications.ts | 5 +++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/src/lib/system-notifications.test.ts b/apps/desktop/src/renderer/src/lib/system-notifications.test.ts index 764104a01..d42eab365 100644 --- a/apps/desktop/src/renderer/src/lib/system-notifications.test.ts +++ b/apps/desktop/src/renderer/src/lib/system-notifications.test.ts @@ -101,6 +101,17 @@ describe('notifyDesignInBackground', () => { expect(FakeNotification.created).toHaveLength(0); }); + it('logs a failed click instead of leaving an unhandled rejection', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + focusWindow.mockRejectedValueOnce(new Error('window gone')); + await notifyDesignInBackground(state(), 'design-1', 'done'); + + FakeNotification.created[0]?.onclick?.(); + await vi.waitFor(() => expect(warn).toHaveBeenCalledTimes(1)); + expect(switchDesign).toHaveBeenCalledWith('design-1'); + warn.mockRestore(); + }); + it('falls back to the app name and only focuses for an unknown design', async () => { await notifyDesignInBackground(state(), undefined, 'ask'); diff --git a/apps/desktop/src/renderer/src/lib/system-notifications.ts b/apps/desktop/src/renderer/src/lib/system-notifications.ts index b7419fc40..9581528cb 100644 --- a/apps/desktop/src/renderer/src/lib/system-notifications.ts +++ b/apps/desktop/src/renderer/src/lib/system-notifications.ts @@ -24,7 +24,8 @@ export async function notifyDesignInBackground( body: i18n.t(BODY_KEYS[kind]), }); notification.onclick = () => { - void api.focusWindow(); - if (design) void state.switchDesign(design.id); + Promise.all([api.focusWindow(), design ? state.switchDesign(design.id) : undefined]).catch( + (error: unknown) => console.warn('[open-codesign] opening a notified design failed:', error), + ); }; } From b62d7685ae5e36ad5d42d5b42566b5e1749200e8 Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Wed, 7 Oct 2026 21:03:10 +0800 Subject: [PATCH 4/6] fix(desktop): set the Windows app model ID for notifications Windows shows toasts only for the AppUserModelID on the installer's Start Menu shortcut, which electron-builder sets to the appId. --- apps/desktop/src/main/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index dee1a2d77..71aee161e 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -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(); From d3bfe1e0ac4585f3d78dfbfc2c94c0470aca1fe3 Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Sat, 10 Oct 2026 00:42:24 +0800 Subject: [PATCH 5/6] fix(desktop): leave notifications off for upgraded installs Installs upgraded from preferences schema 9 or earlier now keep system notifications off until the user turns them on in Settings -> Advanced; new installs still start with them on. The changeset says so and names the switch. The settings hint and the changeset also note that on macOS notifications stop after the last window is closed, until it is opened again. --- .changeset/system-notifications.md | 2 +- apps/desktop/src/main/preferences-ipc.test.ts | 23 +++++++++++++++++-- apps/desktop/src/main/preferences-ipc.ts | 6 ++++- packages/i18n/src/locales/en.json | 2 +- packages/i18n/src/locales/es.json | 2 +- packages/i18n/src/locales/pt-BR.json | 2 +- packages/i18n/src/locales/zh-CN.json | 2 +- 7 files changed, 31 insertions(+), 8 deletions(-) diff --git a/.changeset/system-notifications.md b/.changeset/system-notifications.md index 7448ee193..6c12bba85 100644 --- a/.changeset/system-notifications.md +++ b/.changeset/system-notifications.md @@ -3,4 +3,4 @@ "@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. +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. diff --git a/apps/desktop/src/main/preferences-ipc.test.ts b/apps/desktop/src/main/preferences-ipc.test.ts index 8b916282c..60b699ede 100644 --- a/apps/desktop/src/main/preferences-ipc.test.ts +++ b/apps/desktop/src/main/preferences-ipc.test.ts @@ -404,13 +404,32 @@ 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 () => { + 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(true); + 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)( + 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 () => { diff --git a/apps/desktop/src/main/preferences-ipc.ts b/apps/desktop/src/main/preferences-ipc.ts index 4624c8715..bad81fb80 100644 --- a/apps/desktop/src/main/preferences-ipc.ts +++ b/apps/desktop/src/main/preferences-ipc.ts @@ -25,6 +25,10 @@ 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'); @@ -232,7 +236,7 @@ function parsePersistedFile(rawJson: unknown): Preferences { systemNotifications: readPersistedBoolean( parsed, 'systemNotifications', - DEFAULTS.systemNotifications, + persistedSchema < SYSTEM_NOTIFICATIONS_SCHEMA_VERSION ? false : DEFAULTS.systemNotifications, ), }; } diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index 38daad8c8..fab2bb7c5 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -802,7 +802,7 @@ "checkForUpdatesOnStartup": "Check for updates on startup", "checkForUpdatesOnStartupHint": "Automatically check for a new release 30 seconds after launch.", "systemNotifications": "System notifications", - "systemNotificationsHint": "Notify me when a design finishes, fails, or asks a question while Open CoDesign is in the background.", + "systemNotificationsHint": "Notify me when a design finishes, fails, or asks a question while Open CoDesign is in the background. On macOS, notifications stop after you close the last window, until you open it again.", "timeout": "Generation timeout", "timeoutHint": "Seconds before a generation request is aborted.", "timeoutSeconds": "{{value}} s", diff --git a/packages/i18n/src/locales/es.json b/packages/i18n/src/locales/es.json index 5a81ded6d..0a740a2e6 100644 --- a/packages/i18n/src/locales/es.json +++ b/packages/i18n/src/locales/es.json @@ -624,7 +624,7 @@ "checkForUpdatesOnStartup": "Buscar actualizaciones al inicio", "checkForUpdatesOnStartupHint": "Buscar automáticamente una nueva versión 30 segundos después del inicio.", "systemNotifications": "Notificaciones del sistema", - "systemNotificationsHint": "Avisarme cuando un diseño termine, falle o haga una pregunta mientras Open CoDesign está en segundo plano.", + "systemNotificationsHint": "Avisarme cuando un diseño termine, falle o haga una pregunta mientras Open CoDesign está en segundo plano. En macOS, los avisos se detienen al cerrar la última ventana, hasta que la vuelvas a abrir.", "timeout": "Tiempo de espera de generación", "timeoutHint": "Segundos antes de que una solicitud de generación se aborte.", "timeoutSeconds": "{{value}} s", diff --git a/packages/i18n/src/locales/pt-BR.json b/packages/i18n/src/locales/pt-BR.json index 7a3914c69..111d45dfd 100644 --- a/packages/i18n/src/locales/pt-BR.json +++ b/packages/i18n/src/locales/pt-BR.json @@ -620,7 +620,7 @@ "checkForUpdatesOnStartup": "Verificar atualizações ao iniciar", "checkForUpdatesOnStartupHint": "Verifica automaticamente se há nova versão 30 segundos após iniciar o app.", "systemNotifications": "Notificações do sistema", - "systemNotificationsHint": "Avisar quando um design terminar, falhar ou fizer uma pergunta enquanto o Open CoDesign estiver em segundo plano.", + "systemNotificationsHint": "Avisar quando um design terminar, falhar ou fizer uma pergunta enquanto o Open CoDesign estiver em segundo plano. No macOS, os avisos param depois que você fecha a última janela, até abri-la de novo.", "timeout": "Tempo limite de geração", "timeoutHint": "Segundos antes de uma geração ser abortada.", "timeoutSeconds": "{{value}} s", diff --git a/packages/i18n/src/locales/zh-CN.json b/packages/i18n/src/locales/zh-CN.json index 749d88479..994dfab9c 100644 --- a/packages/i18n/src/locales/zh-CN.json +++ b/packages/i18n/src/locales/zh-CN.json @@ -802,7 +802,7 @@ "checkForUpdatesOnStartup": "启动时检查更新", "checkForUpdatesOnStartupHint": "启动 30 秒后自动检查新版本。", "systemNotifications": "系统通知", - "systemNotificationsHint": "Open CoDesign 在后台时,设计完成、生成失败或需要你回答问题时发送系统通知。", + "systemNotificationsHint": "Open CoDesign 在后台时,设计完成、生成失败或需要你回答问题时发送系统通知。在 macOS 上关闭最后一个窗口后不再通知,重新打开窗口后恢复。", "timeout": "生成超时", "timeoutHint": "超过该秒数后会中止生成请求。", "timeoutSeconds": "{{value}} 秒", From 4641a9e9ab1482ed41158a5416391341e8131074 Mon Sep 17 00:00:00 2001 From: lyy <3483820747@qq.com> Date: Sat, 10 Oct 2026 00:46:48 +0800 Subject: [PATCH 6/6] test(desktop): cover no notifications for recovered run outcomes --- .../hooks/useAgentStream.completion.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/apps/desktop/src/renderer/src/hooks/useAgentStream.completion.test.ts b/apps/desktop/src/renderer/src/hooks/useAgentStream.completion.test.ts index e1117914f..fd25e0b4e 100644 --- a/apps/desktop/src/renderer/src/hooks/useAgentStream.completion.test.ts +++ b/apps/desktop/src/renderer/src/hooks/useAgentStream.completion.test.ts @@ -533,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([