fix(ssh): reconcile the tab model after a remote snapshot rebinds its panes

Closes the second defect with the same user-visible symptom as the reported one,
and unlike the partition bug it needs no partition at all — a fresh client, a
reset profile, or tabs created from another client will do.

A remote snapshot is terminal-only: RemoteWorkspaceSession has no unifiedTabs or
tabGroups field to carry, so replacing tabsByWorktree leaves tabs that no group's
tabOrder names. The tab bar renders from tabOrder, so those tabs exist and are
invisible.

Placement is the whole fix. I first put the reconcile beside the hydrate and
concluded from a red convergence test that it caused a repair loop. That was
wrong: it was DELETING the tabs, and the test was failing at its precondition,
which I never read. Hydration nulls ptyId, so before reconnect rebinds the panes
the records own no PTY, the orphan sweep calls them dead and wipes them. Running
after finalizeHydratedTerminals means reconnect has already rebound them.

No wire change. Teaching the snapshot to carry the group model remains the
durable answer for preserving real order and grouping, but it is
capability-negotiated and old hosts would not send it, so this is needed anyway.

720 renderer-hook tests pass, including 'converges the active-terminal repair
instead of re-running it forever' with zero churn and zero extra passes.
This commit is contained in:
Neil
2026-08-13 22:41:46 -07:00
parent 330ce992a8
commit 3605167585
2 changed files with 13 additions and 16 deletions
@@ -130,22 +130,6 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({
replaceWorkspaceKeys
})
currentStore.hydrateTabsSession(merged, { replaceWorkspaceKeys })
// KNOWN DEFECT, deliberately not repaired here (STA-3077 follow-up).
//
// A remote snapshot is terminal-only: `RemoteWorkspaceSession` has no `unifiedTabs` or
// `tabGroups` field to carry, so replacing `tabsByWorktree` leaves the tab model inconsistent
// in BOTH directions — remote tabs that no group's `tabOrder` names, and group entries naming
// a local tab whose record the replace removed. The tab bar renders from `tabOrder`, so those
// tabs exist and are invisible. Same user-visible symptom as the partition-upgrade bug, but
// reachable with no partition at all: a fresh client, a reset profile, or tabs created from
// another client. The load-time partition hoist does NOT cover it.
//
// `reconcileWorktreeTabModel` already adopts reconnectable records and prunes unrenderable
// ones, and calling it here is the obvious fix — but doing so breaks
// "converges the active-terminal repair instead of re-running it forever", i.e. it reopens a
// repair loop. Any fix has to converge; the durable answer is probably to teach the snapshot
// to carry the group model, which is a capability-negotiated wire change.
//
// Why: direct SSH snapshots project terminal state only; global editor/browser hydration would reset unrelated hosts.
currentStore.markRemoteWorkspaceHydrated(authority.targetId)
currentStore.setRemoteWorkspaceSyncStatus(authority.targetId, {
@@ -179,6 +163,15 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({
}
if (isArrivalCurrent(authority.targetId, arrival) && isPreparationTokenCurrent(token)) {
finalizeHydratedTerminals(authority)
// Why here and not beside the hydrate above: a snapshot is terminal-only —
// `RemoteWorkspaceSession` has no `unifiedTabs`/`tabGroups` to carry — so replacing
// `tabsByWorktree` leaves tabs that no group's `tabOrder` names, and the tab bar renders
// from `tabOrder`. Those tabs exist and are invisible. Reconciling earlier does NOT work:
// hydration nulls `ptyId`, so before reconnect rebinds the panes the records own no PTY,
// the orphan sweep classifies them as dead and DELETES them rather than adopting them.
for (const worktreeId of replaceWorkspaceKeys) {
store.getState().reconcileWorktreeTabModel(worktreeId)
}
}
} finally {
snapshotWriteSuppressUntil = Date.now() + REMOTE_WORKSPACE_SNAPSHOT_WRITE_SUPPRESS_MS
@@ -117,6 +117,10 @@ function appState(overrides: Record<string, unknown> = {}): AppState {
defaultTerminalTabsAppliedByWorktreeId: {},
hydrateWorkspaceSession: vi.fn(),
hydrateTabsSession: vi.fn(),
reconcileWorktreeTabModel: vi.fn(() => ({
renderableTabCount: 0,
activeRenderableTabId: null
})),
hydrateEditorSession: vi.fn(),
hydrateBrowserSession: vi.fn(),
markRemoteWorkspaceHydrated: vi.fn(),