diff --git a/src/renderer/src/runtime/web-session-tabs-sync.ts b/src/renderer/src/runtime/web-session-tabs-sync.ts index b1bffd6b232..53dfee68d2b 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync.ts @@ -3985,12 +3985,10 @@ function loadInitialWebSessionTabs( } }, freshSnapshots, - notificationBaseline.allowsInitialSnapshot + notificationBaseline.allowsSnapshot ) for (const snapshot of freshSnapshots) { - if (isWebSessionTabsWorktreeRemovalFrame(snapshot)) { - notificationBaseline.recordSnapshot(snapshot) - } + notificationBaseline.recordSnapshot(snapshot) } } finally { for (const finishRecovery of finishRecoveries) { @@ -4020,31 +4018,55 @@ function loadInitialWebSessionTabs( } type WebSessionTabsNotificationBaseline = { - allowsInitialSnapshot: (snapshot: RuntimeMobileSessionTabsResult) => boolean allowsSnapshot: (snapshot: RuntimeMobileSessionTabsResult) => boolean recordSnapshot: (snapshot: RuntimeMobileSessionTabsResult) => void + completeInitialSubscription: () => void +} + +type WebSessionTabsNotificationBaselineOwner = { + pairingRevision: number | undefined + baseline: WebSessionTabsNotificationBaseline } function createWebSessionTabsNotificationBaseline( environmentId: string ): WebSessionTabsNotificationBaseline { - const knownAtEffectStart = new Set( + const knownAtOwnerStart = new Set( getTrackedWebSessionTabsWorktrees(environmentId).map(({ worktree }) => worktree) ) - const knownWorktrees = new Set(knownAtEffectStart) + const knownWorktrees = new Set(knownAtOwnerStart) + let awaitingInitialSubscription = true return { - allowsInitialSnapshot: (snapshot) => knownAtEffectStart.has(snapshot.worktree), - allowsSnapshot: (snapshot) => knownWorktrees.has(snapshot.worktree), + allowsSnapshot: (snapshot) => + (awaitingInitialSubscription ? knownAtOwnerStart : knownWorktrees).has(snapshot.worktree), recordSnapshot: (snapshot) => { if (isWebSessionTabsWorktreeRemovalFrame(snapshot)) { + knownAtOwnerStart.delete(snapshot.worktree) knownWorktrees.delete(snapshot.worktree) } else { knownWorktrees.add(snapshot.worktree) } + }, + completeInitialSubscription: () => { + awaitingInitialSubscription = false } } } +function getWebSessionTabsNotificationBaseline( + baselines: Map, + environmentId: string, + pairingRevision: number | undefined +): WebSessionTabsNotificationBaseline { + const existing = baselines.get(environmentId) + if (existing && existing.pairingRevision === pairingRevision) { + return existing.baseline + } + const baseline = createWebSessionTabsNotificationBaseline(environmentId) + baselines.set(environmentId, { pairingRevision, baseline }) + return baseline +} + export function useWebSessionTabsSync(): void { const recordVisibilityResumeSnapshotRef = useRef< (environmentId: string, snapshot: RuntimeMobileSessionTabsResult, receivedFrame: number) => void @@ -4060,7 +4082,9 @@ export function useWebSessionTabsSync(): void { ) => boolean >(() => true) const visibilityResumeOmissionsByKeyRef = useRef(new Map()) - const visibilityResumeNotificationBaselinesRef = useRef(new Map>()) + const notificationBaselinesByEnvironmentRef = useRef( + new Map() + ) const mirroredSessionTabsOwnerRevisionByEnvironmentRef = useRef( new Map() ) @@ -4109,7 +4133,7 @@ export function useWebSessionTabsSync(): void { } mirroredSessionTabsOwnerRevisionByEnvironmentRef.current.clear() visibilityResumeOmissionsByKeyRef.current.clear() - visibilityResumeNotificationBaselinesRef.current.clear() + notificationBaselinesByEnvironmentRef.current.clear() }, [] ) @@ -4142,7 +4166,7 @@ export function useWebSessionTabsSync(): void { mirroredEnvironmentOwnerRevisions.get(environmentId) !== previousRevision ) { clearWebSessionTabsTrackingForEnvironment(environmentId) - visibilityResumeNotificationBaselinesRef.current.delete(environmentId) + notificationBaselinesByEnvironmentRef.current.delete(environmentId) } } mirroredSessionTabsOwnerRevisionByEnvironmentRef.current = mirroredEnvironmentOwnerRevisions @@ -4162,10 +4186,17 @@ export function useWebSessionTabsSync(): void { return } const notificationBaselinesByEnvironment = new Map( - environments.map(({ environmentId }) => [ - environmentId, - createWebSessionTabsNotificationBaseline(environmentId) - ]) + environments.map( + ({ environmentId, expectedEnvironmentPairingRevision }) => + [ + environmentId, + getWebSessionTabsNotificationBaseline( + notificationBaselinesByEnvironmentRef.current, + environmentId, + expectedEnvironmentPairingRevision + ) + ] as const + ) ) type VisibilityResumeEnvironment = { @@ -4508,11 +4539,11 @@ export function useWebSessionTabsSync(): void { } } const resumedEnvironments = new Map() - const notificationBaselines = new Map>() const trackedWorktreeIds = new Set() for (const index of restartingSpecIndexes) { const environmentId = environmentIdBySubscriptionSpec[index] if (environmentId) { + notificationBaselinesByEnvironment.get(environmentId)?.completeInitialSubscription() const trackedWorktrees = getTrackedWebSessionTabsWorktrees(environmentId) if (trackedWorktrees.length === 0) { continue @@ -4520,10 +4551,6 @@ export function useWebSessionTabsSync(): void { for (const { worktree } of trackedWorktrees) { trackedWorktreeIds.add(worktree) } - notificationBaselines.set( - environmentId, - new Set(trackedWorktrees.map(({ worktree }) => worktree)) - ) resumedEnvironments.set(environmentId, { trackedWorktrees, inventoryReceived: false, @@ -4532,7 +4559,6 @@ export function useWebSessionTabsSync(): void { }) } } - visibilityResumeNotificationBaselinesRef.current = notificationBaselines visibilityResumeBatch = resumedEnvironments.size > 0 ? { @@ -4594,6 +4620,7 @@ export function useWebSessionTabsSync(): void { return } if (response.ok === false) { + notificationBaseline.completeInitialSubscription() console.warn( '[web-session-tabs-sync] global subscription failed:', response.error.message @@ -4712,6 +4739,11 @@ export function useWebSessionTabsSync(): void { for (const snapshot of freshSnapshots) { notificationBaseline.recordSnapshot(snapshot) } + for (const { snapshot } of applicable) { + if (!isWebSessionTabsWorktreeRemovalFrame(snapshot)) { + notificationBaseline.recordSnapshot(snapshot) + } + } const freshSnapshotSet = new Set(freshSnapshots) for (const { index, snapshot } of applicable) { if (unchangedVisibilityResumeSnapshots[index]) { @@ -4742,6 +4774,9 @@ export function useWebSessionTabsSync(): void { } }) .finally(() => { + if (isCurrent()) { + notificationBaseline.completeInitialSubscription() + } for (const finishRecovery of finishRecoveries) { finishRecovery?.() } @@ -4759,6 +4794,7 @@ export function useWebSessionTabsSync(): void { // talking has not reported a single PTY dead. return } + notificationBaseline.completeInitialSubscription() const receivedFrame = recordReceivedWebSessionTabsSnapshot(environmentId, event) recordVisibilityResumeSnapshotReceipt(environmentId, event, receivedFrame) const finishRecovery = beginWebSessionTabsSnapshotRecovery( @@ -4824,6 +4860,7 @@ export function useWebSessionTabsSync(): void { }, onError: (error) => { if (isCurrent()) { + notificationBaseline.completeInitialSubscription() console.warn('[web-session-tabs-sync] global subscription error:', error.message) } } @@ -4831,6 +4868,7 @@ export function useWebSessionTabsSync(): void { ) }, onSubscribeError: (error) => { + notificationBaseline.completeInitialSubscription() console.warn( '[web-session-tabs-sync] failed to subscribe globally:', error instanceof Error ? error.message : String(error) @@ -4883,7 +4921,11 @@ export function useWebSessionTabsSync(): void { let requestedInitialTerminal = false let requestedRespawnAfterWake = false - const notificationBaseline = createWebSessionTabsNotificationBaseline(environmentId) + const notificationBaseline = getWebSessionTabsNotificationBaseline( + notificationBaselinesByEnvironmentRef.current, + environmentId, + expectedEnvironmentPairingRevision + ) /** Resolves the settle receipt of the evidence this frame put (or already * had) in the store, or null when the frame was discarded undecided. */ const applyActiveSnapshot = async ( @@ -5029,10 +5071,7 @@ export function useWebSessionTabsSync(): void { response, isCurrent, receivedFrame, - notificationBaseline.allowsSnapshot(event) || - visibilityResumeNotificationBaselinesRef.current - .get(environmentId) - ?.has(activeWorktreeId) === true + notificationBaseline.allowsSnapshot(event) ) .catch((error) => { if (isCurrent()) { diff --git a/src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx b/src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx index 80fe3650a25..1f803950097 100644 --- a/src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx +++ b/src/renderer/src/runtime/web-session-tabs-visibility-resume-notifications.test.tsx @@ -370,6 +370,71 @@ describe('paired session-tab visibility-resume notifications', () => { hook.unmount() }) + it.each(['done', 'blocked', 'waiting'] as const)( + 'arms later %s resumes after duplicate cold listAll and subscription inventories', + async (state) => { + runtimeCall.mockResolvedValueOnce({ + id: 'list-all', + ok: true, + result: { snapshots: [snapshot(1, 'working', NOW)] }, + _meta: { runtimeId: RUNTIME_ID } + }) + const hook = renderHook(() => useWebSessionTabsSync()) + await act(settle) + await publish(findSubscription('session.tabs.subscribeAll'), { + type: 'snapshots', + snapshots: [snapshot(1, 'working', NOW)] + }) + notificationDispatch.mockClear() + + await parkAndReveal() + await publish(findSubscription('session.tabs.subscribeAll', 1), { + type: 'snapshots', + snapshots: [snapshot(2, state, NOW + 1_000)] + }) + vi.advanceTimersByTime(1_500) + + expect(notificationDispatch).toHaveBeenCalledTimes(1) + expect(badgeCount()).toBe(1) + hook.unmount() + } + ) + + it.each(['done', 'blocked', 'waiting'] as const)( + 'recovers %s after listAll succeeds and the initial subscription fails', + async (state) => { + runtimeCall.mockResolvedValueOnce({ + id: 'list-all', + ok: true, + result: { snapshots: [snapshot(1, 'working', NOW)] }, + _meta: { runtimeId: RUNTIME_ID } + }) + const hook = renderHook(() => useWebSessionTabsSync()) + await act(settle) + await act(async () => { + findSubscription('session.tabs.subscribeAll').callbacks.onResponse({ + id: 'subscription-error', + ok: false, + error: { code: 'disconnected', message: 'subscription unavailable' }, + _meta: { runtimeId: RUNTIME_ID } + }) + await settle() + }) + notificationDispatch.mockClear() + + await parkAndReveal() + await publish(findSubscription('session.tabs.subscribeAll', 1), { + type: 'snapshots', + snapshots: [snapshot(2, state, NOW + 1_000)] + }) + vi.advanceTimersByTime(1_500) + + expect(notificationDispatch).toHaveBeenCalledTimes(1) + expect(badgeCount()).toBe(1) + hook.unmount() + } + ) + it.each(['done', 'blocked'] as const)( 'keeps cold %s inventory silent when first mounted hidden', async (state) => { @@ -510,6 +575,45 @@ describe('paired session-tab visibility-resume notifications', () => { hook.unmount() }) + it('forgets active-stream eligibility after a visibility resume and removal', async () => { + seedRemoteMirrorState(true) + mocks.getExplicitRuntimeEnvironmentIdForWorktree.mockReturnValue(ENVIRONMENT_ID) + const hook = renderHook(() => useWebSessionTabsSync()) + await act(settle) + await publish(findSubscription('session.tabs.subscribeAll'), { + type: 'updated', + ...snapshot(1, 'working', NOW) + }) + await publish(findSubscription('session.tabs.subscribe'), { + type: 'updated', + ...snapshot(1, 'working', NOW) + }) + notificationDispatch.mockClear() + + await parkAndReveal() + const active = findSubscription('session.tabs.subscribe', 1) + await publish(active, { + type: 'updated', + worktree: WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + removed: true, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + }) + await publish(active, { + type: 'snapshot', + ...snapshot(1, 'done', NOW + 1_000, NOW + 1_000, undefined, 'epoch-2') + }) + vi.advanceTimersByTime(1_500) + + expect(notificationDispatch).not.toHaveBeenCalled() + expect(badgeCount()).toBe(0) + hook.unmount() + }) + it.each(['done', 'blocked'] as const)( 'keeps a metadata-only %s inventory refresh silent', async (state) => {