fix(remote): recover hidden agent notifications

This commit is contained in:
Jinwoo-H
2026-08-23 20:07:02 -04:00
parent 4971a425a0
commit 6112086dab
26 changed files with 555 additions and 389 deletions
+24 -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/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": [
@@ -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({
+1
View File
@@ -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()
@@ -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 = {
@@ -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' }]
@@ -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) => {
@@ -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
})
},
@@ -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()
}))
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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.
@@ -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),
@@ -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)
@@ -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'])
})
})
@@ -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<string, NotificationWorktreeState>(
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<string>()
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
}
}
}
}
+135 -373
View File
@@ -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<typeof pickParsedAgentStatusPayload> & {
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<string>
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<number, WebSessionTabsNotificationReceipt>()
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<WebSessionTabsSyncState> => {
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:',
@@ -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()
})
@@ -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
@@ -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<void> {
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))