diff --git a/src/renderer/src/store/terminals/workspace-terminal-duplicate-binding-reconnect.test.ts b/src/renderer/src/store/terminals/workspace-terminal-duplicate-binding-reconnect.test.ts index c8081339ba8..5fb4f657097 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-duplicate-binding-reconnect.test.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-duplicate-binding-reconnect.test.ts @@ -204,6 +204,30 @@ describe('hydrating the STA-7961 duplicate binding', () => { await expect(rowPtyIdsAfterReconnect(session)).resolves.toEqual([SHARED_PTY_ID, null]) }) + it('still wakes a relay session only a stranded binding names', async () => { + // The split tab's map still names the pty at a leaf its tree dropped, so it reattaches + // nothing. Blocking on that would cost the single tab a real remote restore for no gain. + const strandedPty = 'repo1::/wt-1@@relay-stranded' + const base = duplicateLeafSession() + const splitLayout = base.terminalLayoutsByTabId[SPLIT_TAB_ID]! + const session: WorkspaceSessionState = { + ...base, + remoteSessionIdsByTabId: { [SINGLE_TAB_ID]: strandedPty }, + terminalLayoutsByTabId: { + ...base.terminalLayoutsByTabId, + [SPLIT_TAB_ID]: { + ...splitLayout, + ptyIdsByLeafId: { + ...splitLayout.ptyIdsByLeafId, + 'ac1f6d20-1f3e-4c58-8f2b-0a9e7d4c3b15': strandedPty + } + } + } + } + + expect(hydrate(session).pendingReconnectPtyIdByTabId[SINGLE_TAB_ID]).toBe(strandedPty) + }) + it('still wakes a relay session nothing else binds', async () => { const session = { ...duplicateLeafSession(), diff --git a/src/renderer/src/store/terminals/workspace-terminal-reconnect-plan.ts b/src/renderer/src/store/terminals/workspace-terminal-reconnect-plan.ts index 2c7580d033b..b5ccf07158a 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-reconnect-plan.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-reconnect-plan.ts @@ -2,6 +2,7 @@ import type { Repo } from '../../../../shared/repo-types' import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' import type { Worktree } from '../../../../shared/worktree/types' import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types' +import { collectOwnedLeafIds } from '@/components/terminal-pane/terminal-layout-leaf-claims' import { buildByIdIndex, buildWorktreeByIdIndex } from '../slices/worktree-by-id-index' import { resolvePrimaryLayoutPtyId } from './terminal-pty-identities' @@ -81,9 +82,19 @@ export function buildWorkspaceTerminalReconnectPlan({ // Why indexed: the relay wake handle is a separate fact from the layout, but it must not hand a // tab the very PTY another tab's healed layout binds — that is the duplicate mount reached // through a second door. A wake nothing else binds is untouched, which is every normal one. + // + // Why owned leaves only: a binding whose leaf left the tree reattaches nothing, so blocking on + // it would cost a real remote session its restore for no gain. And why an id match is enough: + // the row, the leaf binding and the relay handle are all written from one spawn id + // (`updateTabPtyId`) and rewritten together by `ssh-target-id-migration.ts`, so the index + // cannot miss on id form. const tabIdsBindingPtyId = new Map>() for (const [tabId, layout] of Object.entries(layoutsByTabId)) { - for (const ptyId of Object.values(layout.ptyIdsByLeafId ?? {})) { + const ownedLeafIds = collectOwnedLeafIds(layout) + for (const [leafId, ptyId] of Object.entries(layout.ptyIdsByLeafId ?? {})) { + if (!ptyId || !ownedLeafIds.has(leafId)) { + continue + } const binders = tabIdsBindingPtyId.get(ptyId) if (binders) { binders.add(tabId)