diff --git a/src/main/ipc/notifications.test.ts b/src/main/ipc/notifications.test.ts index 8fb5566a895..d7930066ad7 100644 --- a/src/main/ipc/notifications.test.ts +++ b/src/main/ipc/notifications.test.ts @@ -899,7 +899,7 @@ describe('registerNotificationHandlers', () => { expect(dispatchMobileNotification).not.toHaveBeenCalled() }) - it('does not dispatch mobile notifications for focused active-worktree notifications', async () => { + it('dispatches one mobile notification when the active worktree is focused on desktop', async () => { getAllWindowsMock.mockReturnValue([ { isDestroyed: () => false, @@ -922,17 +922,25 @@ describe('registerNotificationHandlers', () => { ) const handler = getDispatchHandler() - expect( - await handler( - {}, - { source: 'agent-task-complete', worktreeId: 'repo::wt1', isActiveWorktree: true } - ) - ).toEqual({ + const focusedNotification = { + source: 'agent-task-complete' as const, + worktreeId: 'repo::wt1', + isActiveWorktree: true + } + expect(await handler({}, focusedNotification)).toEqual({ + delivered: false, + reason: 'suppressed-focus' + }) + expect(await handler({}, focusedNotification)).toEqual({ delivered: false, reason: 'suppressed-focus' }) - expect(dispatchMobileNotification).not.toHaveBeenCalled() + expect(dispatchMobileNotification).toHaveBeenCalledTimes(1) + expect(dispatchMobileNotification).toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete', worktreeId: 'repo::wt1' }) + ) + expect(notificationCtorMock).not.toHaveBeenCalled() }) it('does not dispatch mobile notifications for cooldown-suppressed bursts', async () => { diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 47baa7e7725..4eb8938f2b4 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -312,8 +312,24 @@ function pruneRecentNotifications(recentNotifications: Map, now: } } +function reserveNotificationCooldown( + recentNotifications: Map, + dedupeKey: string, + now: number +): boolean { + const lastSentAt = recentNotifications.get(dedupeKey) ?? 0 + if (now - lastSentAt < NOTIFICATION_COOLDOWN_MS) { + return false + } + recentNotifications.delete(dedupeKey) + recentNotifications.set(dedupeKey, now) + pruneRecentNotifications(recentNotifications, now) + return true +} + export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntimeService): void { - const recentNotifications = new Map() + const recentDesktopNotifications = new Map() + const recentMobileNotifications = new Map() // Why: handler registration marks a fresh session — permission evidence // from a previous registration must not leak into the new one. lastObservedDeliveryOutcome = null @@ -441,6 +457,24 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime return { delivered: false, reason: 'source-disabled' } } + const notificationOptions = buildNotificationOptions(args) + + // Why: desktop focus only means this computer has the worktree visible; + // the paired phone may be locked or elsewhere and still needs the alert. + if (runtime && args.source !== 'test') { + const dedupeKey = args.worktreeId ?? args.worktreeLabel ?? 'global' + if (reserveNotificationCooldown(recentMobileNotifications, dedupeKey, Date.now())) { + runtime.dispatchMobileNotification({ + type: 'notification', + source: args.source, + title: notificationOptions.title, + body: notificationOptions.body, + worktreeId: args.worktreeId, + ...(args.notificationId ? { notificationId: args.notificationId } : {}) + }) + } + } + const browserWindow = BrowserWindow.getAllWindows().find((window) => !window.isDestroyed()) ?? null if ( @@ -458,33 +492,9 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime // Dedupe by worktree, not by source — an agent finishing and a terminal bell // often fire within the same data chunk so only the first one should surface. const dedupeKey = args.worktreeId ?? args.worktreeLabel ?? 'global' - const now = Date.now() - const lastSentAt = recentNotifications.get(dedupeKey) ?? 0 - if (now - lastSentAt < NOTIFICATION_COOLDOWN_MS) { + if (!reserveNotificationCooldown(recentDesktopNotifications, dedupeKey, Date.now())) { return { delivered: false, reason: 'cooldown' } } - recentNotifications.delete(dedupeKey) - recentNotifications.set(dedupeKey, now) - - // Why: a storm across many worktrees should not make every - // notification dispatch scan an ever-growing cooldown table. - pruneRecentNotifications(recentNotifications, now) - } - - const notificationOptions = buildNotificationOptions(args) - - // Why: paired mobile clients should follow the same user-facing - // notification gates as desktop delivery, while still working on hosts - // where Electron native notifications are unavailable. - if (runtime && args.source !== 'test') { - runtime.dispatchMobileNotification({ - type: 'notification', - source: args.source, - title: notificationOptions.title, - body: notificationOptions.body, - worktreeId: args.worktreeId, - ...(args.notificationId ? { notificationId: args.notificationId } : {}) - }) } if (!Notification.isSupported()) {