fix(sidebar): sweep host-retracted rows without hydrated tabs

This commit is contained in:
Neil
2026-09-14 05:11:55 -07:00
parent 0f0943a6b6
commit 8bc522667e
5 changed files with 135 additions and 13 deletions
@@ -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<typeof makeState>, 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)
@@ -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) {
@@ -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,
@@ -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)
)
@@ -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<string>
currentTerminalIds: ReadonlySet<string>
}): string[] {
const retracted = new Set<string>()
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]
}