fix(notifications): preserve recovered attention until interaction

This commit is contained in:
Jinwoo-H
2026-08-23 20:07:03 -04:00
parent 6112086dab
commit bfa61d5aa6
6 changed files with 100 additions and 20 deletions
+2 -1
View File
@@ -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/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 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/hooks/useAutoAckViewedAgent.attention.test.tsx 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": [
@@ -4861,6 +4861,7 @@
"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/hooks/useAutoAckViewedAgent.attention.test.tsx",
"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",
@@ -0,0 +1,57 @@
// @vitest-environment happy-dom
import { cleanup, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { makePaneKey } from '../../../shared/stable-pane-id'
import { useAppStore } from '../store'
import { makeTab } from '../store/slices/store-test-helpers'
import { useAutoAckViewedAgent } from './useAutoAckViewedAgent'
const WORKTREE_ID = 'wt-attention'
const TAB_ID = 'tab-attention'
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
const PANE_KEY = makePaneKey(TAB_ID, LEAF_ID)
describe('useAutoAckViewedAgent recovered attention', () => {
const acknowledgeAgents = vi.fn()
const clearWorktreeUnread = vi.fn()
beforeEach(() => {
vi.spyOn(document, 'hasFocus').mockReturnValue(true)
useAppStore.setState({
activeView: 'terminal',
activeTabId: TAB_ID,
activeWorktreeId: WORKTREE_ID,
tabsByWorktree: { [WORKTREE_ID]: [makeTab({ id: TAB_ID, worktreeId: WORKTREE_ID })] },
terminalLayoutsByTabId: {
[TAB_ID]: { root: null, activeLeafId: LEAF_ID, expandedLeafId: null }
},
agentStatusByPaneKey: {},
retainedAgentsByPaneKey: {},
acknowledgedAgentsByPaneKey: {},
unreadAgentCompletionPanes: {},
unreadTerminalTabs: {},
acknowledgeAgents,
clearWorktreeUnread
})
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.clearAllMocks()
})
it('acks the visible row without clearing unread that has no completion-source marker', () => {
renderHook(() => useAutoAckViewedAgent(false))
useAppStore.getState().setAgentStatus(PANE_KEY, {
state: 'waiting',
prompt: 'Approve?',
agentType: 'claude'
})
expect(acknowledgeAgents).toHaveBeenCalledWith([PANE_KEY])
expect(clearWorktreeUnread).not.toHaveBeenCalled()
})
})
@@ -258,13 +258,16 @@ export function useAutoAckViewedAgent(floatingPanelVisible: boolean): void {
}
const worktreeId = target.worktreeId
acknowledgeViewedAgentAttention(current, {
activeWorktreeId: shouldClearViewedAgentWorktreeUnread(current, {
activeWorktreeId: worktreeId,
activeTabId: tabId,
paneKeysToClear
})
? worktreeId
: null,
// Why: row acknowledgement does not own coarse unread; only the completion-source marker may auto-clear it.
activeWorktreeId:
activePaneKey &&
shouldClearViewedAgentWorktreeUnread(current, {
activeWorktreeId: worktreeId,
activeTabId: tabId,
paneKeysToClear
})
? worktreeId
: null,
activeTabId: tabId,
paneKeys: toAck,
activePaneKey
@@ -23,7 +23,7 @@ export type WebSessionTabsNotificationReconciler = {
observeSnapshot: (
snapshot: RuntimeMobileSessionTabsResult,
options?: { attentionRequired?: boolean }
) => void
) => boolean
observeInventory: (
snapshots: readonly RuntimeMobileSessionTabsResult[],
options: { armPublished: boolean; attentionRequired?: boolean }
@@ -62,14 +62,14 @@ export function createWebSessionTabsNotificationReconciler(args: {
const observeAcceptedSnapshot = (
snapshot: RuntimeMobileSessionTabsResult,
attentionRequired: boolean
): void => {
): boolean => {
const current = worktrees.get(snapshot.worktree)
if (isRemoval(snapshot)) {
worktrees.delete(snapshot.worktree)
return
return current !== undefined
}
if (current && !advancesFreshness(snapshot, current)) {
return
return false
}
const eligible = current?.eligible === true
worktrees.set(snapshot.worktree, {
@@ -81,15 +81,17 @@ export function createWebSessionTabsNotificationReconciler(args: {
seedOnly: !eligible,
attentionRequired: eligible && attentionRequired
})
return true
}
return {
observeSnapshot: (snapshot, options) => {
observeAcceptedSnapshot(snapshot, options?.attentionRequired === true)
const accepted = observeAcceptedSnapshot(snapshot, options?.attentionRequired === true)
const state = worktrees.get(snapshot.worktree)
if (state) {
state.eligible = true
}
return accepted
},
observeInventory: (snapshots, options) => {
const publishedWorktrees = new Set<string>()
@@ -361,13 +361,21 @@ function untrackWebSessionTabsWorktree(environmentId: string, worktreeId: string
export function recordReceivedWebSessionTabsSnapshot(
environmentId: string,
snapshot: RuntimeMobileSessionTabsResult,
options: { observeNotification?: boolean; attentionRequired?: boolean } = {}
options: {
observeNotification?: boolean
attentionRequired?: boolean
onNotificationAccepted?: () => void
} = {}
): number {
const receivedFrame = (receivedSessionTabsFrameSequence += 1)
if (options.observeNotification !== false) {
getWebSessionTabsNotificationLifecycle(environmentId).observeSnapshot(snapshot, {
attentionRequired: options.attentionRequired
})
const accepted = getWebSessionTabsNotificationLifecycle(environmentId).observeSnapshot(
snapshot,
{ attentionRequired: options.attentionRequired }
)
if (accepted) {
options.onNotificationAccepted?.()
}
}
const key = sessionTabsFreshnessKey(environmentId, snapshot.worktree)
latestReceivedSessionTabsSnapshotByWorktree.set(key, {
@@ -5046,9 +5054,11 @@ export function useWebSessionTabsSync(): void {
return
}
const attentionRequired = awaitingVisibilityResumeSnapshot
awaitingVisibilityResumeSnapshot = false
const receivedFrame = recordReceivedWebSessionTabsSnapshot(environmentId, event, {
attentionRequired
attentionRequired,
onNotificationAccepted: () => {
awaitingVisibilityResumeSnapshot = false
}
})
recordVisibilityResumeSnapshotReceiptRef.current(
environmentId,
@@ -7,6 +7,7 @@ import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-ty
import { makePaneKey } from '../../src/shared/stable-pane-id'
import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id'
import { WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS } from '../../src/renderer/src/runtime/window-visibility-subscription-parking'
import { WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT } from '../../src/renderer/src/runtime/window-visibility-subscription-parking'
import { expect, test } from './helpers/orca-app'
import { readHookEndpoint } from './helpers/agent-hook-endpoint'
import {
@@ -21,6 +22,7 @@ import { revealPairedClientWindow } from './helpers/paired-client-window-reveal'
const REQUIRED_CLAUDE_EVENTS = ['UserPromptSubmit', 'Stop', 'PermissionRequest'] as const
const NOTIFICATION_BARRIER_SOURCE = 'sta-5244-barrier'
let completedHideCycles = 0
type NotificationDispatch = {
source?: string
agentState?: string
@@ -200,7 +202,11 @@ async function hideUntilSubscriptionsPark(client: PairedElectronClient): Promise
})
document.dispatchEvent(new Event('visibilitychange'))
})
await client.page.waitForTimeout(WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS + 100)
const parkDelay =
WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_MS *
Math.min(2 ** completedHideCycles, WINDOW_VISIBILITY_SUBSCRIPTION_PARK_DELAY_BACKOFF_LIMIT)
await client.page.waitForTimeout(parkDelay + 100)
completedHideCycles += 1
}
async function revealAfterSubscriptionsPark(client: PairedElectronClient): Promise<void> {
@@ -320,6 +326,7 @@ test('recovers hidden remote completion and permission alerts exactly once', asy
let coldClient: PairedElectronClient | null = null
let terminal: string | null = null
let testError: unknown
completedHideCycles = 0
try {
await host.client.call('repo.add', { path: testRepoPath, kind: 'git' })