From 8bc522667ed1bb7fcfe74799947c4672ff36e375 Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 14 Sep 2026 05:11:55 -0700 Subject: [PATCH] fix(sidebar): sweep host-retracted rows without hydrated tabs --- .../worktree-agent-remote-attribution.test.ts | 92 +++++++++++++++++-- .../agent-status-primitives.ts | 7 +- .../apply-final-patch.ts | 2 - .../apply-preparation-base.ts | 13 +++ .../mirrored-status-tab-retractions.ts | 34 +++++++ 5 files changed, 135 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/runtime/web-session-tabs-sync/mirrored-status-tab-retractions.ts diff --git a/src/renderer/src/components/sidebar/worktree-agent-remote-attribution.test.ts b/src/renderer/src/components/sidebar/worktree-agent-remote-attribution.test.ts index 4e5f9d15d30..f3c19ae8268 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-remote-attribution.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-remote-attribution.test.ts @@ -30,6 +30,25 @@ function status(tabId: string, worktreeId: string): AgentStatusEntry { } } +function hostSnapshot(tabId: string, worktreeId: string) { + return makeSnapshot( + [ + { + type: 'terminal', + id: `${tabId}::${LEAF_ID}`, + parentTabId: tabId, + leafId: LEAF_ID, + title: 'OMP', + isActive: false, + status: 'ready', + terminal: `terminal-${tabId}`, + agentStatus: status(tabId, worktreeId) + } + ], + { worktree: worktreeId } + ) +} + function rows(state: ReturnType, worktreeId: string) { return buildWorktreeAgentRows({ tabs: state.tabsByWorktree[worktreeId] ?? [], @@ -77,9 +96,9 @@ describe('remote completed sidebar rows before tab hydration', () => { expect(rows(mirror, worktreeId)).toMatchObject([{ paneKey, state: 'done', agentType: 'omp' }]) expect(rows(mirror, worktreeId)).toHaveLength(1) const removed = { - ...mirror, + ...beforeTabs, ...applyWebSessionTabsSnapshot( - mirror, + beforeTabs, makeSnapshot([], { worktree: worktreeId, snapshotVersion: 2 }), ENV, NOW + 1 @@ -90,14 +109,73 @@ describe('remote completed sidebar rows before tab hydration', () => { } ) - it('honors status removal before tab hydration', () => { + it('retracts only the publishing host and workspace when client tabs are missing', () => { const worktreeId = 'folder:remote-workspace' - const entry = status(toWebTerminalSurfaceTabId('host-tab'), worktreeId) - const state = makeState({ agentStatusByPaneKey: { [entry.paneKey]: entry } }) - expect(rows(state, worktreeId)).toHaveLength(1) - expect(rows({ ...state, agentStatusByPaneKey: {} }, worktreeId)).toEqual([]) + const initial = makeState() + const owner = applyWebSessionTabsSnapshot(initial, hostSnapshot('owner', worktreeId), ENV, NOW) + const sibling = applyWebSessionTabsSnapshot( + initial, + hostSnapshot('sibling', worktreeId), + 'other-host', + NOW + ) + const otherWorkspace = applyWebSessionTabsSnapshot( + initial, + hostSnapshot('other-workspace', 'folder:other'), + ENV, + NOW + ) + const unknown = status(toWebTerminalSurfaceTabId('unknown-owner'), worktreeId) + const beforeTabs = makeState({ + agentStatusByPaneKey: { + ...owner.agentStatusByPaneKey, + ...sibling.agentStatusByPaneKey, + ...otherWorkspace.agentStatusByPaneKey, + [unknown.paneKey]: unknown + } + }) + const removed = { + ...beforeTabs, + ...applyWebSessionTabsSnapshot( + beforeTabs, + makeSnapshot([], { worktree: worktreeId, snapshotVersion: 2 }), + ENV, + NOW + 1 + ) + } + expect(Object.keys(removed.agentStatusByPaneKey).sort()).toEqual( + [ + makePaneKey(toWebTerminalSurfaceTabId('sibling'), LEAF_ID), + makePaneKey(toWebTerminalSurfaceTabId('other-workspace'), LEAF_ID), + unknown.paneKey + ].sort() + ) }) + it.each(['visibility-inventory-removal', 'removed:host-epoch'])( + 'does not treat worktree visibility removal %s as host tab retraction', + (publicationEpoch) => { + const worktreeId = 'folder:remote-workspace' + const initial = makeState() + const mirror = applyWebSessionTabsSnapshot( + initial, + hostSnapshot('host-tab', worktreeId), + ENV, + NOW + ) + const beforeTabs = makeState({ agentStatusByPaneKey: mirror.agentStatusByPaneKey }) + const tombstone = { + ...makeSnapshot([], { worktree: worktreeId, publicationEpoch }), + removed: true as const + } + const removed = { + ...beforeTabs, + ...applyWebSessionTabsSnapshot(beforeTabs, tombstone, ENV, NOW + 1) + } + expect(removed.agentStatusByPaneKey).toEqual(beforeTabs.agentStatusByPaneKey) + } + ) + it('rebuilds same-key buckets when SSH attribution arrives or is removed', () => { const worktreeId = 'folder:remote-workspace' const entry = status('ssh-tab', worktreeId) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts index f0eb938f00e..4ea53153f96 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/agent-status-primitives.ts @@ -150,7 +150,6 @@ export function buildRemirroredClosedTabMarkerLiftPatch( */ export function buildRetractedMirroredTabSweepPatch( state: WebSessionTabsSyncState, - worktreeId: string, nextTabsByWorktree: WebSessionTabsSyncState['tabsByWorktree'], agentStatusPatch: Pick< WebSessionTabsSyncState, @@ -179,13 +178,13 @@ export function buildRetractedMirroredTabSweepPatch( retainedAgentsByPaneKey: state.retainedAgentsByPaneKey ?? {}, retentionSuppressedPaneKeys: state.retentionSuppressedPaneKeys ?? {}, sortEpoch: agentStatusPatch?.sortEpoch ?? state.sortEpoch, - // Why: the drop's completed-orphan rule reads "keyed under a tab this worktree no longer has", - // so it must see the post-removal tab list, not the one the snapshot replaced. + // Keep the sweep state consistent with the accepted host inventory. tabsByWorktree: nextTabsByWorktree } // Why: a retraction can be a reconnect re-key, not pane death (ssh-execution-boundary); keeping // cutoffs means a republished pane cannot replay activity the user cleared on this client. - const sweep = buildRetiredTerminalTabStateSweepPatch(sweepState, retractedTabIds, worktreeId, { + // The host retracts exact tab ids; a worktree-wide orphan sweep could erase a sibling host. + const sweep = buildRetiredTerminalTabStateSweepPatch(sweepState, retractedTabIds, undefined, { preserveActivityClearedState: true }) if (!sweep?.agentStatusByPaneKey || !batchContext) { diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts index 4ef51cd52f7..610b3de8ddf 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-final-patch.ts @@ -16,7 +16,6 @@ export function buildWebSessionTabsFinalPatch( const { state, snapshot, - worktreeId, now, batchContext, currentTerminalTabs, @@ -65,7 +64,6 @@ export function buildWebSessionTabsFinalPatch( ? null : buildRetractedMirroredTabSweepPatch( state, - worktreeId, nextTabsByWorktree, agentStatusPatch, removedTerminalResourceIds, diff --git a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts index ef697da6999..c9a771f919c 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync/apply-preparation-base.ts @@ -1,3 +1,5 @@ +import { collectUnhydratedMirroredTabRetractions } from './mirrored-status-tab-retractions' +import { isWebSessionTabsWorktreeRemovalFrame } from './session-tabs-inventory-absence' import type { RuntimeMobileSessionTabsResult } from '../../../../shared/runtime-types' import type { WebSessionTabsBatchContext, @@ -188,6 +190,17 @@ export function prepareWebSessionTabsSnapshotBase( const removedTerminalIds = new Set( currentTerminalTabs.filter((tab) => !retainedTerminalIds.has(tab.id)).map((tab) => tab.id) ) + if (reconcilesNonAgentTabs && !isWebSessionTabsWorktreeRemovalFrame(snapshot)) { + for (const tabId of collectUnhydratedMirroredTabRetractions({ + state, + environmentId, + worktreeId, + nextHostTerminalTabIds, + currentTerminalIds: new Set(existingTerminalById.keys()) + })) { + removedTerminalIds.add(tabId) + } + } const removedTerminalResourceIds = [...removedTerminalIds].filter( (tabId) => !mirroredTerminalIds.has(tabId) ) diff --git a/src/renderer/src/runtime/web-session-tabs-sync/mirrored-status-tab-retractions.ts b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-status-tab-retractions.ts new file mode 100644 index 00000000000..101a754de22 --- /dev/null +++ b/src/renderer/src/runtime/web-session-tabs-sync/mirrored-status-tab-retractions.ts @@ -0,0 +1,34 @@ +import { parsePaneKey } from '../../../../shared/stable-pane-id' +import { isWebTerminalSurfaceTabId } from '../../../../shared/terminal-surface-id' +import type { WebSessionTabsSyncState } from './state' +import { resolveHostSessionTabIdForWebSessionTab } from './tracking-mappings' + +/** Reuse the host mapping when a status row outlives this renderer's tab inventory. */ +export function collectUnhydratedMirroredTabRetractions(args: { + state: WebSessionTabsSyncState + environmentId: string + worktreeId: string + nextHostTerminalTabIds: ReadonlySet + currentTerminalIds: ReadonlySet +}): string[] { + const retracted = new Set() + for (const [paneKey, entry] of Object.entries(args.state.agentStatusByPaneKey)) { + if (entry.worktreeId !== args.worktreeId) { + continue + } + const tabId = parsePaneKey(paneKey)?.tabId + if (!tabId || !isWebTerminalSurfaceTabId(tabId) || args.currentTerminalIds.has(tabId)) { + continue + } + const hostTabId = resolveHostSessionTabIdForWebSessionTab(args.state, { + environmentId: args.environmentId, + worktreeId: args.worktreeId, + tabId + }) + // A missing mapping is unknown ownership; another host's snapshot cannot retract it. + if (hostTabId !== null && !args.nextHostTerminalTabIds.has(hostTabId)) { + retracted.add(tabId) + } + } + return [...retracted] +}