mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 16:03:49 +00:00
fix(remote): recover hidden agent notifications
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user