fix(mobile): deliver alerts despite desktop focus

This commit is contained in:
Brennan Benson
2026-07-14 16:34:27 -07:00
parent 3827d91851
commit d9fd182d6a
2 changed files with 52 additions and 34 deletions
+16 -8
View File
@@ -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 () => {
+36 -26
View File
@@ -312,8 +312,24 @@ function pruneRecentNotifications(recentNotifications: Map<string, number>, now:
}
}
function reserveNotificationCooldown(
recentNotifications: Map<string, number>,
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<string, number>()
const recentDesktopNotifications = new Map<string, number>()
const recentMobileNotifications = new Map<string, number>()
// 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()) {