diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 0ec7b674926..0d577b2bb5f 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -4845,7 +4845,7 @@ "pnpm exec playwright test tests/e2e/paired-remote-agent-visibility-notifications.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime.test.ts src/main/runtime/terminal-orphan-owner.test.ts src/main/runtime/terminal-orphan-topology.test.ts src/renderer/src/runtime/web-session-terminal-orphan-recovery.test.ts src/renderer/src/runtime/web-session-terminal-orphan-mixed-version.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts --maxWorkers=1", "pnpm exec vitest run --config config/vitest.config.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/main/runtime/orca-runtime.test.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts", - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx src/renderer/src/hooks/agent-hook-completion-notifications-inventory.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx src/renderer/src/runtime/web-session-tabs-notification-receipt-order.test.tsx src/renderer/src/runtime/web-session-tabs-notification-reconciler.test.ts src/renderer/src/hooks/agent-hook-completion-notifications-inventory.test.ts src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts src/main/ipc/notifications-delivery-gating.test.ts", "pnpm exec vitest run --config config/vitest.config.ts tests/e2e/remote-agent-completion-authority.unit.test.ts src/main/providers/pty-process-inspection.test.ts src/main/daemon/terminal-host.test.ts src/main/daemon/daemon-pty-router.test.ts src/main/daemon/degraded-daemon-pty-provider.test.ts src/relay/pty-handler-spawn-admission.test.ts src/main/daemon/daemon-pty-adapter.test.ts src/renderer/src/runtime/runtime-terminal-inspection.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts src/renderer/src/components/terminal-pane/pty-connection-task-complete-dispatch.test.ts src/renderer/src/lib/codex-session-restart.test.ts" ], "testFiles": [ @@ -4858,7 +4858,11 @@ "tests/e2e/remote-agent-completion-authority.unit.test.ts", "tests/e2e/paired-remote-agent-visibility-notifications.spec.ts", "src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx", + "src/renderer/src/runtime/web-session-tabs-notification-receipt-order.test.tsx", + "src/renderer/src/runtime/web-session-tabs-notification-reconciler.test.ts", "src/renderer/src/hooks/agent-hook-completion-notifications-inventory.test.ts", + "src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts", + "src/main/ipc/notifications-delivery-gating.test.ts", "src/renderer/src/runtime/runtime-terminal-inspection.test.ts", "src/renderer/src/components/terminal-pane/agent-completion-coordinator-process-cadence.test.ts", "src/renderer/src/components/terminal-pane/agent-completion-coordinator-pending-title-inspection.test.ts", @@ -4933,6 +4937,25 @@ "the active scoped stream may win resume ordering without losing or duplicating attention" ] }, + { + "file": "src/renderer/src/runtime/web-session-tabs-notification-receipt-order.test.tsx", + "assertions": [ + "notification order is independent of slow or failed terminal-orphan recovery", + "a failed cold recovery remains silent while a failed live transition still alerts" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts", + "assertions": [ + "attention recovered after hidden parking marks unread even when its pane is focused on reveal" + ] + }, + { + "file": "src/main/ipc/notifications-delivery-gating.test.ts", + "assertions": [ + "attention recovered after hidden parking bypasses focus suppression exactly for that dispatch" + ] + }, { "file": "src/renderer/src/hooks/agent-hook-completion-notifications-inventory.test.ts", "assertions": [ diff --git a/src/main/ipc/notifications-delivery-gating.test.ts b/src/main/ipc/notifications-delivery-gating.test.ts index c7fb4fe3c01..8353d175381 100644 --- a/src/main/ipc/notifications-delivery-gating.test.ts +++ b/src/main/ipc/notifications-delivery-gating.test.ts @@ -160,6 +160,34 @@ describe('registerNotificationHandlers', () => { expect(notificationCtorMock).not.toHaveBeenCalled() }) + it('delivers recovered hidden attention after the worktree regains focus', async () => { + getAllWindowsMock.mockReturnValue([ + { + isDestroyed: () => false, + isFocused: () => true + } as never + ]) + registerNotificationHandlers({ + getSettings: () => ({ + notifications: { + enabled: true, + agentTaskComplete: true, + terminalBell: true, + suppressWhenFocused: true + } + }) + } as never) + + const handler = getDispatchHandler() + expect( + await handler( + {}, + { source: 'agent-task-complete', isActiveWorktree: true, attentionRequired: true } + ) + ).toEqual({ delivered: true }) + expect(notificationShowMock).toHaveBeenCalledTimes(1) + }) + describe('minimized tray attention dot', () => { function registerEnabledNotifications(): void { registerNotificationHandlers({ diff --git a/src/main/ipc/notifications.ts b/src/main/ipc/notifications.ts index 28f6bfd95e5..c58536e1ff1 100644 --- a/src/main/ipc/notifications.ts +++ b/src/main/ipc/notifications.ts @@ -151,6 +151,7 @@ export function registerNotificationHandlers(store: Store, runtime?: OrcaRuntime BrowserWindow.getAllWindows().find((window) => !window.isDestroyed()) ?? null if ( settings.suppressWhenFocused && + !args.attentionRequired && args.isActiveWorktree && browserWindow && browserWindow.isFocused() diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts index 9614d0bbffc..65d5e7bfa57 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts @@ -7,6 +7,8 @@ export type AgentCompletionStatusSnapshot = ParsedAgentStatusPayload & { stateStartedAt?: number /** Renderer-local boundary used only to reject a delayed cross-host completion. */ localStateStartedAt?: number + /** The transition was recovered after hidden subscription parking. */ + attentionRequired?: boolean } export type AgentCompletionDispatchMeta = { diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts index 1267b986827..eac64f9268c 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.test.ts @@ -449,6 +449,24 @@ describe('dispatchTerminalNotification', () => { expect(mockState.markAgentCompletionPaneUnread).toHaveBeenCalledWith(paneKey) }) + it('preserves attention recovered after hiding when the pane is focused on reveal', () => { + mockState.activeWorktreeId = 'wt-primary' + stubDocumentFocus({ visibilityState: 'visible', focused: true }) + + dispatchTerminalNotification('wt-primary', { + source: 'agent-task-complete', + terminalTitle: 'codex', + paneKey, + attentionRequired: true + }) + + expect(mockState.markWorktreeUnread).toHaveBeenCalledWith('wt-primary') + expect(mockState.markAgentCompletionPaneUnread).not.toHaveBeenCalled() + expect(window.api.notifications.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ attentionRequired: true, isActiveWorktree: true }) + ) + }) + it('drops a pane key when its tab is hydrated under another worktree', () => { mockState.tabsByWorktree = { 'wt-secondary': [{ id: 'tab-1' }] diff --git a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts index 29b4ace2c1f..efddd556ec5 100644 --- a/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/use-notification-dispatch.ts @@ -59,6 +59,7 @@ export type TerminalNotificationEvent = { agentStatusSnapshot?: AgentCompletionStatusSnapshot agentCompletionSource?: AgentCompletionDispatchMeta['source'] suppressOsNotification?: boolean + attentionRequired?: boolean } /** @@ -157,14 +158,16 @@ export function dispatchTerminalNotification( // Why: a focused worktree can still hide other terminal tabs/split panes; // only the exact active pane counts as already viewed. - const shouldMarkUnread = event.paneKey - ? !isVisibleForegroundPaneKey(state, worktreeId, event.paneKey) - : state.activeWorktreeId !== worktreeId || !isOrcaWindowForegroundFocused() + const shouldMarkUnread = + event.attentionRequired === true || + (event.paneKey + ? !isVisibleForegroundPaneKey(state, worktreeId, event.paneKey) + : state.activeWorktreeId !== worktreeId || !isOrcaWindowForegroundFocused()) if (shouldMarkUnread) { // Why: activeWorktreeId is only in-app selection. If Orca is backgrounded, // a selected chat finishing still needs unread/Dock attention. state.markWorktreeUnread(worktreeId) - if (event.paneKey) { + if (event.paneKey && !event.attentionRequired) { // Why: focus-return auto-ack needs an agent-specific source marker; // generic pane unread also covers BEL and must still show until interact. state.markAgentCompletionPaneUnread(event.paneKey) @@ -225,6 +228,7 @@ export function dispatchTerminalNotification( hasMultipleActiveRepos: countReposNeedingNotificationDisambiguation(state) > 1, terminalTitle: event.terminalTitle, isActiveWorktree: state.activeWorktreeId === worktreeId, + ...(event.attentionRequired ? { attentionRequired: true } : {}), ...agentSnapshot }) .then((result) => { diff --git a/src/renderer/src/hooks/agent-hook-completion-notifications.ts b/src/renderer/src/hooks/agent-hook-completion-notifications.ts index 7d226331eed..fd6c99913e2 100644 --- a/src/renderer/src/hooks/agent-hook-completion-notifications.ts +++ b/src/renderer/src/hooks/agent-hook-completion-notifications.ts @@ -261,6 +261,7 @@ function createCoordinator(paneKey: string, worktreeId: string): AgentCompletion terminalTitle: title, paneKey, suppressOsNotification: !isAgentTaskCompleteNotificationEnabled(), + ...(meta?.agentStatus?.attentionRequired ? { attentionRequired: true } : {}), ...(meta?.agentStatus ? { agentStatusSnapshot: meta.agentStatus } : {}) }) }, @@ -275,6 +276,7 @@ function createCoordinator(paneKey: string, worktreeId: string): AgentCompletion terminalTitle: title, paneKey, suppressOsNotification: !isAgentTaskCompleteNotificationEnabled(), + ...(meta.agentStatus.attentionRequired ? { attentionRequired: true } : {}), agentStatusSnapshot: meta.agentStatus }) }, diff --git a/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts b/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts index 760f025f57a..4fc63817e73 100644 --- a/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts +++ b/src/renderer/src/runtime/web-runtime-browser-capability-cleanup.test.ts @@ -24,6 +24,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: vi.fn(), applyWebSessionTabsSnapshot: vi.fn(), decideWebSessionTabsSnapshot: vi.fn(() => ({ apply: true, settlesHostMirror: true })), + recordReceivedWebSessionTabsSnapshot: vi.fn(() => 1), applyWebSessionTabsStorePatch: vi.fn(() => () => {}), resolveHostSessionTabIdForWebSessionTab: vi.fn() })) diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts index 66e3c78b299..e6eec0457c9 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-failure.test.ts @@ -42,6 +42,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + recordReceivedWebSessionTabsSnapshot: vi.fn(() => 1), applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts index b6a6e34482a..e133b1f0c0c 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-focus.test.ts @@ -47,6 +47,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + recordReceivedWebSessionTabsSnapshot: vi.fn(() => 1), applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts b/src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts index 4161eb26a23..06d304ec5a0 100644 --- a/src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-browser-create-split-placement.test.ts @@ -45,6 +45,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + recordReceivedWebSessionTabsSnapshot: vi.fn(() => 1), applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts index e744ba5de2d..0a5520ea7a1 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-activate-close.test.ts @@ -44,6 +44,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + recordReceivedWebSessionTabsSnapshot: vi.fn(() => 1), applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts index f9e1fee9dd4..4becb75475d 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-move.test.ts @@ -37,6 +37,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + recordReceivedWebSessionTabsSnapshot: vi.fn(() => 1), applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-tab-props.test.ts b/src/renderer/src/runtime/web-runtime-session-tab-props.test.ts index 9585f87fd33..51b9c126848 100644 --- a/src/renderer/src/runtime/web-runtime-session-tab-props.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-tab-props.test.ts @@ -36,6 +36,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + recordReceivedWebSessionTabsSnapshot: vi.fn(() => 1), applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts index 209cd182da9..41a3b6c5c14 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-host-authority.test.ts @@ -49,6 +49,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + recordReceivedWebSessionTabsSnapshot: vi.fn(() => 1), applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts index 58c3b369712..ee5a62aa448 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-legacy-create.test.ts @@ -42,6 +42,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + recordReceivedWebSessionTabsSnapshot: vi.fn(() => 1), applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts index d632e8b80fa..74238cc5ae7 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts @@ -40,6 +40,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + recordReceivedWebSessionTabsSnapshot: vi.fn(() => 1), applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-runtime-session.test.ts b/src/renderer/src/runtime/web-runtime-session.test.ts index 76af63fbd63..f0d79788fbf 100644 --- a/src/renderer/src/runtime/web-runtime-session.test.ts +++ b/src/renderer/src/runtime/web-runtime-session.test.ts @@ -46,6 +46,7 @@ vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, decideWebSessionTabsSnapshot: mocks.decideWebSessionTabsSnapshot, + recordReceivedWebSessionTabsSnapshot: vi.fn(() => 1), applyWebSessionTabsStorePatch: (buildPatch: (state: unknown) => unknown) => { mocks.setState(buildPatch) // The production caller invokes the returned settle receipt. diff --git a/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts b/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts index a355c924612..1b194ac017f 100644 --- a/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-agent-completion-notifications.test.ts @@ -26,6 +26,7 @@ import { applyWebSessionTabsSnapshot, applyWebSessionTabsStorePatch, decideWebSessionTabsSnapshot, + recordReceivedWebSessionTabsSnapshot, resetWebSessionTabsSnapshotFreshnessForTests } from './web-session-tabs-sync' @@ -77,6 +78,7 @@ function makeAgentSnapshot( } function applySnapshot(snapshot: RuntimeMobileSessionTabsResult, _live: boolean): void { + recordReceivedWebSessionTabsSnapshot(ENVIRONMENT_ID, snapshot) const decision = decideWebSessionTabsSnapshot(snapshot, ENVIRONMENT_ID) applyWebSessionTabsStorePatch( (state) => applyWebSessionTabsSnapshot(state, snapshot, ENVIRONMENT_ID, NOW), diff --git a/src/renderer/src/runtime/web-session-tabs-notification-receipt-order.test.tsx b/src/renderer/src/runtime/web-session-tabs-notification-receipt-order.test.tsx index c047fb793a5..d3b1ae84968 100644 --- a/src/renderer/src/runtime/web-session-tabs-notification-receipt-order.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-notification-receipt-order.test.tsx @@ -306,7 +306,7 @@ describe('paired session-tab notification receipt ordering', () => { hook.unmount() }) - it('orders a live completion after slow initial inventory recovery', async () => { + it('delivers a live completion without waiting for slow terminal recovery', async () => { const hook = renderHook(() => useWebSessionTabsSync()) await act(settle) const global = findSubscription('session.tabs.subscribeAll') @@ -315,13 +315,44 @@ describe('paired session-tab notification receipt ordering', () => { await publish(global, { type: 'snapshots', snapshots: [snapshot(1, 'working', NOW)] }) await publish(global, { type: 'updated', ...snapshot(2, 'done', NOW + 1_000) }) - expect(notificationDispatch).not.toHaveBeenCalled() + vi.advanceTimersByTime(1_500) + expect(notificationDispatch).toHaveBeenCalledTimes(1) await act(async () => { slowWorkingRecovery.resolve(snapshot(1, 'working', NOW)) await settle() await settle() }) + expect(notificationDispatch).toHaveBeenCalledTimes(1) + expect(getUnreadBadgeCount(useAppStore.getState())).toBe(1) + hook.unmount() + }) + + it('keeps a failed cold recovery seeded and its later duplicate silent', async () => { + const hook = renderHook(() => useWebSessionTabsSync()) + await act(settle) + const global = findSubscription('session.tabs.subscribeAll') + mocks.recoverSnapshot.mockResolvedValueOnce(null) + + await publish(global, { + type: 'snapshots', + snapshots: [snapshot(1, 'done', NOW)] + }) + await publish(global, { type: 'updated', ...snapshot(2, 'done', NOW) }) + vi.advanceTimersByTime(1_500) + + expect(notificationDispatch).not.toHaveBeenCalled() + expect(getUnreadBadgeCount(useAppStore.getState())).toBe(0) + hook.unmount() + }) + + it('delivers a transition even when that frame terminal recovery failed', async () => { + const hook = renderHook(() => useWebSessionTabsSync()) + await act(settle) + const global = findSubscription('session.tabs.subscribeAll') + await publish(global, { type: 'updated', ...snapshot(1, 'working', NOW) }) + mocks.recoverSnapshot.mockResolvedValueOnce(null) + await publish(global, { type: 'updated', ...snapshot(2, 'done', NOW + 1_000) }) vi.advanceTimersByTime(1_500) expect(notificationDispatch).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/runtime/web-session-tabs-notification-reconciler.test.ts b/src/renderer/src/runtime/web-session-tabs-notification-reconciler.test.ts new file mode 100644 index 00000000000..f5cbbd00e5d --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-notification-reconciler.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from 'vitest' +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' +import { createWebSessionTabsNotificationReconciler } from './web-session-tabs-notification-reconciler' + +function snapshot( + worktree: string, + snapshotVersion: number, + publicationEpoch = 'epoch-1' +): RuntimeMobileSessionTabsResult { + return { + worktree, + publicationEpoch, + snapshotVersion, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } +} + +describe('web session-tabs notification reconciler', () => { + it('seeds a cold frame, ignores exact replay, and observes the next version', () => { + const observeAcceptedSnapshot = vi.fn() + const reconciler = createWebSessionTabsNotificationReconciler({ + trackedWorktrees: [], + observeAcceptedSnapshot + }) + + reconciler.observeSnapshot(snapshot('wt-a', 1)) + reconciler.observeSnapshot(snapshot('wt-a', 1)) + reconciler.observeSnapshot(snapshot('wt-a', 2)) + + expect(observeAcceptedSnapshot).toHaveBeenNthCalledWith(1, expect.anything(), { + seedOnly: true, + attentionRequired: false + }) + expect(observeAcceptedSnapshot).toHaveBeenNthCalledWith(2, expect.anything(), { + seedOnly: false, + attentionRequired: false + }) + expect(observeAcceptedSnapshot).toHaveBeenCalledTimes(2) + }) + + it('carries hidden attention only for an armed fresh transition', () => { + const observeAcceptedSnapshot = vi.fn() + const reconciler = createWebSessionTabsNotificationReconciler({ + trackedWorktrees: [], + observeAcceptedSnapshot + }) + + reconciler.observeInventory([snapshot('wt-a', 1)], { + armPublished: true, + attentionRequired: true + }) + reconciler.observeInventory([snapshot('wt-a', 2)], { + armPublished: true, + attentionRequired: true + }) + + expect(observeAcceptedSnapshot).toHaveBeenNthCalledWith(1, expect.anything(), { + seedOnly: true, + attentionRequired: false + }) + expect(observeAcceptedSnapshot).toHaveBeenNthCalledWith(2, expect.anything(), { + seedOnly: false, + attentionRequired: true + }) + }) + + it('makes a same-ID reappearance cold after authoritative absence', () => { + const observeAcceptedSnapshot = vi.fn() + const reconciler = createWebSessionTabsNotificationReconciler({ + trackedWorktrees: [{ worktree: 'wt-a', freshness: snapshot('wt-a', 1) }], + observeAcceptedSnapshot + }) + + reconciler.observeInventory([], { armPublished: false }) + reconciler.observeSnapshot(snapshot('wt-a', 1, 'epoch-2')) + + expect(observeAcceptedSnapshot).toHaveBeenCalledWith(expect.anything(), { + seedOnly: true, + attentionRequired: false + }) + }) + + it('does not couple one worktree observation to another', () => { + const observed: string[] = [] + const reconciler = createWebSessionTabsNotificationReconciler({ + trackedWorktrees: [ + { worktree: 'wt-a', freshness: snapshot('wt-a', 1) }, + { worktree: 'wt-b', freshness: snapshot('wt-b', 1) } + ], + observeAcceptedSnapshot: (value) => observed.push(value.worktree) + }) + + reconciler.observeSnapshot(snapshot('wt-b', 2)) + + expect(observed).toEqual(['wt-b']) + }) +}) diff --git a/src/renderer/src/runtime/web-session-tabs-notification-reconciler.ts b/src/renderer/src/runtime/web-session-tabs-notification-reconciler.ts new file mode 100644 index 00000000000..d1204f89323 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-notification-reconciler.ts @@ -0,0 +1,120 @@ +import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types' + +type SnapshotFreshness = { + publicationEpoch: string + snapshotVersion: number +} + +export type WebSessionTabsNotificationTrackedWorktree = { + worktree: string + freshness: SnapshotFreshness +} + +type NotificationWorktreeState = SnapshotFreshness & { + eligible: boolean +} + +export type WebSessionTabsNotificationObservation = { + seedOnly: boolean + attentionRequired: boolean +} + +export type WebSessionTabsNotificationReconciler = { + observeSnapshot: ( + snapshot: RuntimeMobileSessionTabsResult, + options?: { attentionRequired?: boolean } + ) => void + observeInventory: ( + snapshots: readonly RuntimeMobileSessionTabsResult[], + options: { armPublished: boolean; attentionRequired?: boolean } + ) => void + armPresentWorktrees: () => void +} + +function isRemoval(snapshot: RuntimeMobileSessionTabsResult): boolean { + return (snapshot as { removed?: unknown }).removed === true +} + +function advancesFreshness( + snapshot: RuntimeMobileSessionTabsResult, + current: NotificationWorktreeState +): boolean { + return ( + snapshot.publicationEpoch !== current.publicationEpoch || + snapshot.snapshotVersion > current.snapshotVersion + ) +} + +export function createWebSessionTabsNotificationReconciler(args: { + trackedWorktrees: readonly WebSessionTabsNotificationTrackedWorktree[] + observeAcceptedSnapshot: ( + snapshot: RuntimeMobileSessionTabsResult, + observation: WebSessionTabsNotificationObservation + ) => void +}): WebSessionTabsNotificationReconciler { + const worktrees = new Map( + args.trackedWorktrees.map(({ worktree, freshness }) => [ + worktree, + { ...freshness, eligible: true } + ]) + ) + + const observeAcceptedSnapshot = ( + snapshot: RuntimeMobileSessionTabsResult, + attentionRequired: boolean + ): void => { + const current = worktrees.get(snapshot.worktree) + if (isRemoval(snapshot)) { + worktrees.delete(snapshot.worktree) + return + } + if (current && !advancesFreshness(snapshot, current)) { + return + } + const eligible = current?.eligible === true + worktrees.set(snapshot.worktree, { + publicationEpoch: snapshot.publicationEpoch, + snapshotVersion: snapshot.snapshotVersion, + eligible + }) + args.observeAcceptedSnapshot(snapshot, { + seedOnly: !eligible, + attentionRequired: eligible && attentionRequired + }) + } + + return { + observeSnapshot: (snapshot, options) => { + observeAcceptedSnapshot(snapshot, options?.attentionRequired === true) + const state = worktrees.get(snapshot.worktree) + if (state) { + state.eligible = true + } + }, + observeInventory: (snapshots, options) => { + const publishedWorktrees = new Set() + for (const snapshot of snapshots) { + publishedWorktrees.add(snapshot.worktree) + observeAcceptedSnapshot(snapshot, options.attentionRequired === true) + } + for (const worktree of worktrees.keys()) { + if (!publishedWorktrees.has(worktree)) { + worktrees.delete(worktree) + } + } + if (options.armPublished) { + for (const worktree of publishedWorktrees) { + const state = worktrees.get(worktree) + if (state) { + state.eligible = true + } + } + } + }, + armPresentWorktrees: () => { + for (const state of worktrees.values()) { + state.eligible = true + } + } + } +} diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index f98c1947475..aaa5c5f1232 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -115,6 +115,11 @@ import { buildWebSessionExistingTabIndex, type WebSessionExistingTabIndex } from './web-session-existing-tab-index' +import { + createWebSessionTabsNotificationReconciler, + type WebSessionTabsNotificationObservation, + type WebSessionTabsNotificationReconciler +} from './web-session-tabs-notification-reconciler' const WEB_SESSION_GROUP_PREFIX = 'web-session-tabs:' export const WEB_SESSION_TABS_VISIBILITY_RESUME_STAGGER_MS = 100 @@ -356,14 +361,14 @@ function untrackWebSessionTabsWorktree(environmentId: string, worktreeId: string export function recordReceivedWebSessionTabsSnapshot( environmentId: string, snapshot: RuntimeMobileSessionTabsResult, - armAfter = true + options: { observeNotification?: boolean; attentionRequired?: boolean } = {} ): number { const receivedFrame = (receivedSessionTabsFrameSequence += 1) - getWebSessionTabsNotificationLifecycle(environmentId).registerSnapshot( - receivedFrame, - snapshot, - armAfter - ) + if (options.observeNotification !== false) { + getWebSessionTabsNotificationLifecycle(environmentId).observeSnapshot(snapshot, { + attentionRequired: options.attentionRequired + }) + } const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree) latestReceivedSessionTabsSnapshotByWorktree.set(key, { receivedFrame, @@ -379,14 +384,10 @@ export function recordReceivedWebSessionTabsSnapshot( function recordReceivedWebSessionTabsInventory( environmentId: string, snapshots: readonly RuntimeMobileSessionTabsResult[], - armAfter: boolean + options: { armPublished: boolean; attentionRequired?: boolean } ): number { const receivedFrame = (receivedSessionTabsFrameSequence += 1) - getWebSessionTabsNotificationLifecycle(environmentId).registerInventory( - receivedFrame, - snapshots.map((snapshot) => snapshot.worktree), - armAfter - ) + getWebSessionTabsNotificationLifecycle(environmentId).observeInventory(snapshots, options) return receivedFrame } @@ -406,11 +407,6 @@ function beginWebSessionTabsSnapshotRecovery( return } settled = true - getWebSessionTabsNotificationLifecycle(environmentId).settleSnapshot(receivedFrame, { - snapshot, - validPresence: true, - statuses: [] - }) recoveryState.pendingCount -= 1 if ( recoveryState.pendingCount === 0 && @@ -759,60 +755,9 @@ export function shouldSyncAllRuntimeSessionTabs(args: { return Boolean(environmentId && args.workspaceSessionReady) } -type WebSessionTabsPendingNotificationStatus = { - paneKey: string - worktreeId: string - payload: ReturnType & { - stateStartedAt: number - localStateStartedAt?: number - } -} - -type WebSessionTabsNotificationSnapshotOutcome = { - snapshot: RuntimeMobileSessionTabsResult - validPresence: boolean - statuses: readonly WebSessionTabsPendingNotificationStatus[] -} - -type WebSessionTabsNotificationReceipt = - | { - type: 'snapshot' - receivedFrame: number - snapshot: RuntimeMobileSessionTabsResult - armAfter: boolean - outcome?: WebSessionTabsNotificationSnapshotOutcome - } - | { - type: 'inventory' - receivedFrame: number - publishedWorktrees: ReadonlySet - armAfter: boolean - settled: boolean - } - -type WebSessionTabsNotificationLifecycle = { - registerSnapshot: ( - receivedFrame: number, - snapshot: RuntimeMobileSessionTabsResult, - armAfter: boolean - ) => void - registerInventory: ( - receivedFrame: number, - publishedWorktreeIds: readonly string[], - armAfter: boolean - ) => void - settleSnapshot: ( - receivedFrame: number, - outcome: WebSessionTabsNotificationSnapshotOutcome - ) => void - settleInventory: (receivedFrame: number) => void - applyUnsequencedSnapshot: (outcome: WebSessionTabsNotificationSnapshotOutcome) => void - armPresentWorktrees: () => void -} - type WebSessionTabsNotificationLifecycleOwner = { pairingRevision: number | undefined - lifecycle: WebSessionTabsNotificationLifecycle + lifecycle: WebSessionTabsNotificationReconciler } const webSessionTabsNotificationLifecyclesByEnvironment = new Map< @@ -822,129 +767,18 @@ const webSessionTabsNotificationLifecyclesByEnvironment = new Map< function createWebSessionTabsNotificationLifecycle( environmentId: string -): WebSessionTabsNotificationLifecycle { - const eligibleWorktrees = new Set( - getTrackedWebSessionTabsWorktrees(environmentId).map(({ worktree }) => worktree) - ) - const presentWorktrees = new Set(eligibleWorktrees) - const receipts: WebSessionTabsNotificationReceipt[] = [] - const receiptByFrame = new Map() - let receiptIndex = 0 - const applySnapshot = (outcome: WebSessionTabsNotificationSnapshotOutcome): void => { - if (!outcome.validPresence) { - return - } - const notificationAllowed = eligibleWorktrees.has(outcome.snapshot.worktree) - if (isWebSessionTabsWorktreeRemovalFrame(outcome.snapshot)) { - eligibleWorktrees.delete(outcome.snapshot.worktree) - presentWorktrees.delete(outcome.snapshot.worktree) - } else { - presentWorktrees.add(outcome.snapshot.worktree) - } - for (const status of outcome.statuses) { - try { - observeAgentHookCompletionForNotification({ - ...status, - ...(!notificationAllowed ? { seedOnly: true as const } : {}) - }) - } catch (error) { - console.warn('[web-session-tabs-sync] notification receipt failed:', error) - } - } - } - const drain = (): void => { - while (receiptIndex < receipts.length) { - const receipt = receipts[receiptIndex]! - if (receipt.type === 'snapshot') { - if (!receipt.outcome) { - break - } - applySnapshot(receipt.outcome) - if (receipt.armAfter && !isWebSessionTabsWorktreeRemovalFrame(receipt.outcome.snapshot)) { - eligibleWorktrees.add(receipt.outcome.snapshot.worktree) - } - } else { - if (!receipt.settled) { - break - } - for (const worktree of eligibleWorktrees) { - if (!receipt.publishedWorktrees.has(worktree)) { - eligibleWorktrees.delete(worktree) - } - } - for (const worktree of presentWorktrees) { - if (!receipt.publishedWorktrees.has(worktree)) { - presentWorktrees.delete(worktree) - } - } - for (const worktree of receipt.publishedWorktrees) { - presentWorktrees.add(worktree) - } - if (receipt.armAfter) { - for (const worktree of receipt.publishedWorktrees) { - eligibleWorktrees.add(worktree) - } - } - } - receiptByFrame.delete(receipt.receivedFrame) - receiptIndex += 1 - } - if (receiptIndex > 64 && receiptIndex * 2 > receipts.length) { - receipts.splice(0, receiptIndex) - receiptIndex = 0 - } - } - return { - registerSnapshot: (receivedFrame, snapshot, armAfter) => { - const receipt = { type: 'snapshot', receivedFrame, snapshot, armAfter } as const - receipts.push(receipt) - receiptByFrame.set(receivedFrame, receipt) - }, - registerInventory: (receivedFrame, publishedWorktreeIds, armAfter) => { - const receipt: WebSessionTabsNotificationReceipt = { - type: 'inventory', - receivedFrame, - publishedWorktrees: new Set(publishedWorktreeIds), - armAfter, - settled: false - } - receipts.push(receipt) - receiptByFrame.set(receivedFrame, receipt) - }, - settleSnapshot: (receivedFrame, outcome) => { - const receipt = receiptByFrame.get(receivedFrame) - if (receipt?.type !== 'snapshot' || receipt.outcome) { - return - } - receipt.outcome = outcome - drain() - }, - settleInventory: (receivedFrame) => { - const receipt = receiptByFrame.get(receivedFrame) - if (receipt?.type !== 'inventory' || receipt.settled) { - return - } - receipt.settled = true - drain() - }, - applyUnsequencedSnapshot: (outcome) => { - applySnapshot(outcome) - if (outcome.validPresence && !isWebSessionTabsWorktreeRemovalFrame(outcome.snapshot)) { - eligibleWorktrees.add(outcome.snapshot.worktree) - } - }, - armPresentWorktrees: () => { - for (const worktree of presentWorktrees) { - eligibleWorktrees.add(worktree) - } - } - } +): WebSessionTabsNotificationReconciler { + return createWebSessionTabsNotificationReconciler({ + trackedWorktrees: getTrackedWebSessionTabsWorktrees(environmentId), + observeAcceptedSnapshot: (snapshot, observation) => + observeWebSessionTabsNotificationSnapshot(snapshot, observation) + }) } function getWebSessionTabsNotificationLifecycle( environmentId: string, pairingRevision = getRuntimeEnvironmentRevision(environmentId) -): WebSessionTabsNotificationLifecycle { +): WebSessionTabsNotificationReconciler { const existing = webSessionTabsNotificationLifecyclesByEnvironment.get(environmentId) if (existing && existing.pairingRevision === pairingRevision) { return existing.lifecycle @@ -957,13 +791,6 @@ function getWebSessionTabsNotificationLifecycle( return lifecycle } -function settleWebSessionTabsNotificationInventory( - environmentId: string, - receivedFrame: number -): void { - getWebSessionTabsNotificationLifecycle(environmentId).settleInventory(receivedFrame) -} - function armWebSessionTabsNotificationPresence( environmentId: string, pairingRevision: number | undefined @@ -1456,6 +1283,96 @@ function hostAgentStatusPiercesClientAuthority(entry: AgentStatusEntry): boolean return entry.state === 'blocked' || entry.interactivePrompt != null } +function observeWebSessionTabsNotificationSnapshot( + snapshot: RuntimeMobileSessionTabsResult, + observation: WebSessionTabsNotificationObservation +): void { + const state = useAppStore.getState() + for (const surface of snapshot.tabs) { + if (surface.type !== 'terminal') { + continue + } + const remapped = remapHostAgentStatus(surface) + if (!remapped) { + continue + } + const turnCompletedAt = normalizeTurnCompletedAtField(surface.turnCompletedAt, remapped.state) + if (remapped.state === 'done' && turnCompletedAt === undefined) { + hostWorkingClientBoundaryByPaneKey.delete(remapped.paneKey) + } + const clientAuthority = isClientAuthoritativeAgentStatusPane(remapped.paneKey) + const clientOwnedNotification = + clientAuthority && (remapped.state === 'working' || turnCompletedAt !== undefined) + if ( + clientAuthority && + !clientOwnedNotification && + !hostAgentStatusPiercesClientAuthority(remapped) + ) { + continue + } + const currentClientStateStartedAt = clientOwnedNotification + ? state.agentStatusByPaneKey[remapped.paneKey]?.stateStartedAt + : undefined + let localStateStartedAt = currentClientStateStartedAt + if ( + clientOwnedNotification && + remapped.state === 'working' && + currentClientStateStartedAt !== undefined + ) { + if (turnCompletedAt === undefined) { + const retainedBoundary = hostWorkingClientBoundaryByPaneKey.get(remapped.paneKey) + if ( + !retainedBoundary || + retainedBoundary.hostStateStartedAt !== remapped.stateStartedAt || + retainedBoundary.hostPrompt !== remapped.prompt || + retainedBoundary.stamped + ) { + hostWorkingClientBoundaryByPaneKey.delete(remapped.paneKey) + hostWorkingClientBoundaryByPaneKey.set(remapped.paneKey, { + hostStateStartedAt: remapped.stateStartedAt, + hostPrompt: remapped.prompt, + clientStateStartedAt: currentClientStateStartedAt, + stamped: false + }) + if (hostWorkingClientBoundaryByPaneKey.size > HOST_WORKING_CLIENT_BOUNDARY_LIMIT) { + const oldestPaneKey = hostWorkingClientBoundaryByPaneKey.keys().next().value + if (oldestPaneKey !== undefined) { + hostWorkingClientBoundaryByPaneKey.delete(oldestPaneKey) + } + } + } + } else { + const retainedBoundary = hostWorkingClientBoundaryByPaneKey.get(remapped.paneKey) + if ( + retainedBoundary?.hostStateStartedAt === remapped.stateStartedAt && + retainedBoundary.hostPrompt === remapped.prompt + ) { + localStateStartedAt = retainedBoundary.clientStateStartedAt + retainedBoundary.stamped = true + } + } + } + try { + observeAgentHookCompletionForNotification({ + paneKey: remapped.paneKey, + worktreeId: remapped.worktreeId ?? snapshot.worktree, + ...(observation.seedOnly ? { seedOnly: true as const } : {}), + payload: { + ...pickParsedAgentStatusPayload({ + ...remapped, + ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) + }), + stateStartedAt: remapped.stateStartedAt, + ...(clientOwnedNotification ? { localStateStartedAt } : {}), + ...(observation.attentionRequired ? { attentionRequired: true as const } : {}) + } + }) + } catch (error) { + console.warn('[web-session-tabs-sync] notification reconciliation failed:', error) + } + } +} + /** True while this renderer's own byte-derived status owns the pane: it claimed * the pane at transport creation and wrote status from bytes. The claim is * released on pane teardown, which is how the host takes the pane back. */ @@ -3955,11 +3872,6 @@ export type HostSessionMirrorPatchFrame = { export type HostSessionMirrorPatchVerdict = { frames: readonly HostSessionMirrorPatchFrame[] fullInventory?: { environmentId: string; publishedSnapshotCount: number } - notificationInventory?: { - environmentId: string - publishedWorktreeIds: readonly string[] - receivedFrame: number - } } function createHostSessionMirrorSettle( @@ -4002,120 +3914,11 @@ export function applyWebSessionTabsStorePatch( // Why: zustand commits before notifying subscribers, so a producer that ran // to completion has landed even when a subscriber throws afterwards. let patchCommitted = false - const acceptedNotificationStatusesByFrame = new Map< - HostSessionMirrorPatchFrame, - WebSessionTabsPendingNotificationStatus[] - >() const runStorePatch = ( state: AppState ): WebSessionTabsSyncState | Partial => { const patch = buildPatch(state) mirroredAgentStatusChanged = patch !== state && Object.hasOwn(patch, 'agentStatusByPaneKey') - if (hostMirrorVerdict.frames.length > 0) { - const nextAgentStatuses = patch.agentStatusByPaneKey ?? state.agentStatusByPaneKey - for (const frame of hostMirrorVerdict.frames) { - const { snapshot, decision } = frame - if (!decision.apply) { - continue - } - const acceptedNotificationStatuses: WebSessionTabsPendingNotificationStatus[] = [] - acceptedNotificationStatusesByFrame.set(frame, acceptedNotificationStatuses) - for (const surface of snapshot.tabs) { - if (surface.type !== 'terminal') { - continue - } - const remapped = remapHostAgentStatus(surface) - const accepted = remapped ? nextAgentStatuses[remapped.paneKey] : undefined - const turnCompletedAt = remapped - ? normalizeTurnCompletedAtField(surface.turnCompletedAt, remapped.state) - : undefined - if (remapped?.state === 'done' && turnCompletedAt === undefined) { - hostWorkingClientBoundaryByPaneKey.delete(remapped.paneKey) - } - // Why: client OSC owns display state, but only the host hook stream carries background-turn stamps. - const clientOwnedNotification = Boolean( - remapped && - isClientAuthoritativeAgentStatusPane(remapped.paneKey) && - (remapped.state === 'working' || turnCompletedAt !== undefined) - ) - if ( - !remapped || - (!clientOwnedNotification && - (!accepted || - accepted === state.agentStatusByPaneKey[remapped.paneKey] || - !agentStatusEntryEqual(accepted, remapped))) - ) { - continue - } - const notificationStatus = clientOwnedNotification ? remapped : accepted - if (!notificationStatus) { - continue - } - const currentClientStateStartedAt = clientOwnedNotification - ? state.agentStatusByPaneKey[notificationStatus.paneKey]?.stateStartedAt - : undefined - let localStateStartedAt = currentClientStateStartedAt - if ( - clientOwnedNotification && - notificationStatus.state === 'working' && - currentClientStateStartedAt !== undefined - ) { - if (turnCompletedAt === undefined) { - const retainedBoundary = hostWorkingClientBoundaryByPaneKey.get( - notificationStatus.paneKey - ) - if ( - !retainedBoundary || - retainedBoundary.hostStateStartedAt !== notificationStatus.stateStartedAt || - retainedBoundary.hostPrompt !== notificationStatus.prompt || - retainedBoundary.stamped - ) { - hostWorkingClientBoundaryByPaneKey.delete(notificationStatus.paneKey) - hostWorkingClientBoundaryByPaneKey.set(notificationStatus.paneKey, { - hostStateStartedAt: notificationStatus.stateStartedAt, - hostPrompt: notificationStatus.prompt, - clientStateStartedAt: currentClientStateStartedAt, - stamped: false - }) - if (hostWorkingClientBoundaryByPaneKey.size > HOST_WORKING_CLIENT_BOUNDARY_LIMIT) { - const oldestPaneKey = hostWorkingClientBoundaryByPaneKey.keys().next().value - if (oldestPaneKey !== undefined) { - hostWorkingClientBoundaryByPaneKey.delete(oldestPaneKey) - } - } - } - } else { - const retainedBoundary = hostWorkingClientBoundaryByPaneKey.get( - notificationStatus.paneKey - ) - if ( - retainedBoundary?.hostStateStartedAt === notificationStatus.stateStartedAt && - retainedBoundary.hostPrompt === notificationStatus.prompt - ) { - localStateStartedAt = retainedBoundary.clientStateStartedAt - retainedBoundary.stamped = true - } - } - } - acceptedNotificationStatuses.push({ - paneKey: notificationStatus.paneKey, - worktreeId: notificationStatus.worktreeId ?? snapshot.worktree, - payload: { - ...pickParsedAgentStatusPayload({ - ...notificationStatus, - ...(turnCompletedAt !== undefined ? { turnCompletedAt } : {}) - }), - stateStartedAt: notificationStatus.stateStartedAt, - ...(clientOwnedNotification - ? { - localStateStartedAt - } - : {}) - } - }) - } - } - } patchCommitted = true return patch } @@ -4127,28 +3930,6 @@ export function applyWebSessionTabsStorePatch( } console.warn('[web-session-tabs-sync] a store subscriber failed after the patch landed:', error) } - for (const frame of hostMirrorVerdict.frames) { - const { environmentId, snapshot, decision, receivedFrame } = frame - const outcome = { - snapshot, - validPresence: - decision.apply || - (hostMirrorVerdict.notificationInventory?.environmentId === environmentId && - decision.settlesHostMirror && - !isWebSessionTabsWorktreeRemovalFrame(snapshot)), - statuses: acceptedNotificationStatusesByFrame.get(frame) ?? [] - } - const lifecycle = getWebSessionTabsNotificationLifecycle(environmentId) - if (receivedFrame === undefined) { - lifecycle.applyUnsequencedSnapshot(outcome) - } else { - lifecycle.settleSnapshot(receivedFrame, outcome) - } - } - if (hostMirrorVerdict.notificationInventory) { - const { environmentId, receivedFrame } = hostMirrorVerdict.notificationInventory - settleWebSessionTabsNotificationInventory(environmentId, receivedFrame) - } // Why: the receipt exists exactly from the commit on, so no caller can settle // evidence the store does not hold, and none can lose one it does. const settleHostMirror = createHostSessionMirrorSettle(hostMirrorVerdict) @@ -4198,13 +3979,13 @@ function loadInitialWebSessionTabs( return } const receivedFrames = result.snapshots.map((snapshot) => - recordReceivedWebSessionTabsSnapshot(environmentId, snapshot, false) - ) - const inventoryReceivedFrame = recordReceivedWebSessionTabsInventory( - environmentId, - result.snapshots, - false + recordReceivedWebSessionTabsSnapshot(environmentId, snapshot, { + observeNotification: false + }) ) + recordReceivedWebSessionTabsInventory(environmentId, result.snapshots, { + armPublished: false + }) const finishRecoveries = result.snapshots.map((snapshot, index) => beginWebSessionTabsSnapshotRecovery(environmentId, snapshot, receivedFrames[index]!) ) @@ -4256,11 +4037,6 @@ function loadInitialWebSessionTabs( publishedSnapshotCount: result.snapshots.filter((snapshot) => isHostMirroredWorktree(snapshot.worktree) ).length - }, - notificationInventory: { - environmentId, - publishedWorktreeIds: result.snapshots.map((snapshot) => snapshot.worktree), - receivedFrame: inventoryReceivedFrame } } ) @@ -4268,7 +4044,6 @@ function loadInitialWebSessionTabs( for (const finishRecovery of finishRecoveries) { finishRecovery() } - settleWebSessionTabsNotificationInventory(environmentId, inventoryReceivedFrame) } }) .catch((error) => { @@ -4851,6 +4626,7 @@ export function useWebSessionTabsSync(): void { const event = response.result as SessionTabsStreamEvent const replayed = isRuntimeSubscriptionReplayResponse(response) if (event.type === 'snapshots') { + const attentionRequired = awaitingVisibilityResumeInventory const skipUnchangedResumeWork = awaitingVisibilityResumeInventory && !replayed awaitingVisibilityResumeInventory = false // Why: an unchanged epoch/version proves the host published nothing while parked, so there is no missed frame to recover or replay. @@ -4869,7 +4645,7 @@ export function useWebSessionTabsSync(): void { const receivedFrame = recordReceivedWebSessionTabsSnapshot( environmentId, snapshot, - false + { observeNotification: false } ) recordVisibilityResumeSnapshotReceipt(environmentId, snapshot, receivedFrame) return receivedFrame @@ -4877,7 +4653,7 @@ export function useWebSessionTabsSync(): void { const inventoryReceivedFrame = recordReceivedWebSessionTabsInventory( environmentId, event.snapshots, - true + { armPublished: true, attentionRequired } ) const missingWorktrees = recordVisibilityResumeInventoryReceipt( environmentId, @@ -4958,13 +4734,6 @@ export function useWebSessionTabsSync(): void { publishedSnapshotCount: event.snapshots.filter((snapshot) => isHostMirroredWorktree(snapshot.worktree) ).length - }, - notificationInventory: { - environmentId, - publishedWorktreeIds: event.snapshots.map( - (snapshot) => snapshot.worktree - ), - receivedFrame: inventoryReceivedFrame } } ) @@ -4998,24 +4767,9 @@ export function useWebSessionTabsSync(): void { } }) .finally(() => { - for (const [index, finishRecovery] of finishRecoveries.entries()) { - if (finishRecovery) { - finishRecovery() - } else { - getWebSessionTabsNotificationLifecycle(environmentId).settleSnapshot( - receivedFrames[index]!, - { - snapshot: event.snapshots[index]!, - validPresence: true, - statuses: [] - } - ) - } + for (const finishRecovery of finishRecoveries) { + finishRecovery?.() } - settleWebSessionTabsNotificationInventory( - environmentId, - inventoryReceivedFrame - ) // Why: an inventory speaks only once its patch is in the // store — settling earlier, or on snapshots recovery // discarded, drains parked work against state nobody wrote. @@ -5030,7 +4784,9 @@ export function useWebSessionTabsSync(): void { // talking has not reported a single PTY dead. return } - const receivedFrame = recordReceivedWebSessionTabsSnapshot(environmentId, event) + const receivedFrame = recordReceivedWebSessionTabsSnapshot(environmentId, event, { + attentionRequired: awaitingVisibilityResumeInventory + }) recordVisibilityResumeSnapshotReceipt(environmentId, event, receivedFrame) const finishRecovery = beginWebSessionTabsSnapshotRecovery( environmentId, @@ -5257,8 +5013,9 @@ export function useWebSessionTabsSync(): void { } const disposeSubscription = installWindowVisibilitySubscriptionParking([ { - subscribe: (isCurrent) => - window.api.runtimeEnvironments.subscribe( + subscribe: (isCurrent, { visibilityGeneration }) => { + let awaitingVisibilityResumeSnapshot = visibilityGeneration > 0 + return window.api.runtimeEnvironments.subscribe( { selector: environmentId, method: 'session.tabs.subscribe', @@ -5288,7 +5045,11 @@ export function useWebSessionTabsSync(): void { // this worktree — let alone about the whole environment. return } - const receivedFrame = recordReceivedWebSessionTabsSnapshot(environmentId, event) + const attentionRequired = awaitingVisibilityResumeSnapshot + awaitingVisibilityResumeSnapshot = false + const receivedFrame = recordReceivedWebSessionTabsSnapshot(environmentId, event, { + attentionRequired + }) recordVisibilityResumeSnapshotReceiptRef.current( environmentId, event, @@ -5324,7 +5085,8 @@ export function useWebSessionTabsSync(): void { } } } - ), + ) + }, onSubscribeError: (error) => { console.warn( '[web-session-tabs-sync] failed to subscribe:', diff --git a/src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx b/src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx index bb06f485b24..6be2891995f 100644 --- a/src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx @@ -372,6 +372,11 @@ describe('paired session-tab visibility-resume notifications', () => { vi.advanceTimersByTime(1_500) expect(notificationDispatch).toHaveBeenCalledTimes(1) + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenLastCalledWith( + expect.objectContaining({ + payload: expect.objectContaining({ attentionRequired: true }) + }) + ) expect(badgeCount()).toBe(1) hook.unmount() } @@ -818,7 +823,7 @@ describe('paired session-tab visibility-resume notifications', () => { hook.unmount() }) - it('alerts once when the resumed active stream wins the inventory race', async () => { + it('alerts once when the active replay precedes the hidden transition and inventory', async () => { seedRemoteMirrorState(true) mocks.getExplicitRuntimeEnvironmentIdForWorktree.mockReturnValue(ENVIRONMENT_ID) const hook = renderHook(() => useWebSessionTabsSync()) @@ -830,10 +835,9 @@ describe('paired session-tab visibility-resume notifications', () => { notificationDispatch.mockClear() await parkAndReveal() - await publish(findSubscription('session.tabs.subscribe', 1), { - type: 'snapshot', - ...snapshot(2, 'done', NOW + 1_000) - }) + const active = findSubscription('session.tabs.subscribe', 1) + await publish(active, { type: 'snapshot', ...snapshot(1, 'working', NOW) }) + await publish(active, { type: 'updated', ...snapshot(2, 'done', NOW + 1_000) }) vi.advanceTimersByTime(1_500) await publish(findSubscription('session.tabs.subscribeAll', 1), { type: 'snapshots', @@ -841,6 +845,9 @@ describe('paired session-tab visibility-resume notifications', () => { }) expect(notificationDispatch).toHaveBeenCalledTimes(1) + expect(mocks.observeAgentHookCompletionForNotification).toHaveBeenLastCalledWith( + expect.objectContaining({ payload: expect.objectContaining({ attentionRequired: true }) }) + ) expect(badgeCount()).toBe(1) hook.unmount() }) diff --git a/src/shared/notification-settings-types.ts b/src/shared/notification-settings-types.ts index fe4ef74015c..4ff2833d254 100644 --- a/src/shared/notification-settings-types.ts +++ b/src/shared/notification-settings-types.ts @@ -36,6 +36,8 @@ export type NotificationDispatchRequest = { hasMultipleActiveRepos?: boolean terminalTitle?: string isActiveWorktree?: boolean + /** Bypass focus suppression for an event recovered after the window was hidden. */ + attentionRequired?: boolean agentType?: AgentType agentState?: AgentStatusState agentPrompt?: string diff --git a/tests/e2e/paired-remote-agent-visibility-notifications.spec.ts b/tests/e2e/paired-remote-agent-visibility-notifications.spec.ts index 5a3bd0d64c1..59785aa89cc 100644 --- a/tests/e2e/paired-remote-agent-visibility-notifications.spec.ts +++ b/tests/e2e/paired-remote-agent-visibility-notifications.spec.ts @@ -21,7 +21,12 @@ import { revealPairedClientWindow } from './helpers/paired-client-window-reveal' const REQUIRED_CLAUDE_EVENTS = ['UserPromptSubmit', 'Stop', 'PermissionRequest'] as const const NOTIFICATION_BARRIER_SOURCE = 'sta-5244-barrier' -type NotificationDispatch = { source?: string; agentState?: string; worktreeId?: string } +type NotificationDispatch = { + source?: string + agentState?: string + worktreeId?: string + attentionRequired?: boolean +} type AgentStatusSummary = { state: string prompt: string @@ -199,7 +204,17 @@ async function hideUntilSubscriptionsPark(client: PairedElectronClient): Promise } async function revealAfterSubscriptionsPark(client: PairedElectronClient): Promise { - await client.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.show()) + await client.app.evaluate(({ app, BrowserWindow }) => { + app.focus({ steal: true }) + const window = BrowserWindow.getAllWindows()[0] + window?.show() + window?.focus() + }) + await expect + .poll(() => + client.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.isFocused()) + ) + .toBe(true) await client.page.evaluate(() => { if (document.visibilityState === 'visible') { return @@ -355,12 +370,49 @@ test('recovers hidden remote completion and permission alerts exactly once', asy message: 'isolated host did not install every managed Claude hook' }) .toBe(true) + const clientTabId = toWebTerminalSurfaceTabId(surface.parentTabId) + await expect + .poll(() => + client!.page.evaluate( + ({ worktreeId, tabId }) => { + const state = window.__store?.getState() + const tabs = state?.tabsByWorktree[worktreeId] ?? [] + return tabs.some((tab) => tab.id === tabId) + }, + { worktreeId, tabId: clientTabId } + ) + ) + .toBe(true) + await client.page.evaluate( + ({ worktreeId, tabId }) => { + const state = window.__store?.getState() + state?.setActiveWorktree(worktreeId) + state?.setActiveTab(tabId) + state?.setActiveTabType('terminal') + }, + { worktreeId, tabId: clientTabId } + ) await revealPairedClientWindow(client) await expect .poll(() => client!.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.isVisible()) ) .toBe(true) + await expect + .poll(() => + client!.page.evaluate( + ({ tabId }) => { + const state = window.__store?.getState() + return { + worktreeId: state?.activeWorktreeId, + tabId: state?.activeTabId, + leafId: state?.terminalLayoutsByTabId[tabId]?.activeLeafId + } + }, + { tabId: clientTabId } + ) + ) + .toEqual({ worktreeId, tabId: clientTabId, leafId: surface.leafId }) const completionPrompt = `STA-5244 completion ${Date.now()}` await postClaudeHook(endpoint, hostPaneKey, worktreeId, { @@ -409,7 +461,8 @@ test('recovers hidden remote completion and permission alerts exactly once', asy expect.objectContaining({ source: 'agent-task-complete', agentState: 'done', - worktreeId + worktreeId, + attentionRequired: true }) ]) await expect @@ -469,7 +522,7 @@ test('recovers hidden remote completion and permission alerts exactly once', asy .poll(() => notificationDispatches(client!.app), { timeout: 30_000 }) .toEqual([ expect.objectContaining({ agentState: 'done', worktreeId }), - expect.objectContaining({ agentState: 'waiting', worktreeId }) + expect.objectContaining({ agentState: 'waiting', worktreeId, attentionRequired: true }) ]) await expect .poll(() => unreadBadgeState(client!, worktreeId))