fix(terminal): recovering one split pane keeps its healthy sibling in the saved layout (#29961)

This commit is contained in:
Neil
2026-10-16 21:54:27 -07:00
committed by GitHub
parent 1ed735b5e6
commit dd2bd0b0b7
2 changed files with 112 additions and 1 deletions
@@ -0,0 +1,80 @@
import { expect, it } from 'vitest'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import { makePaneKey } from '../../shared/stable-pane-id'
import type { RuntimeTerminalOrphanAdoptionRequest } from '../../shared/runtime-types'
import { collectLayoutLeafIdsInOrder } from '../../shared/workspace-layout/terminal-pane-tree'
import {
GIT_KEY,
addWorkspace,
emptySession,
leaf
} from '../../shared/workspace-layout/workspace-layout-session.test-fixture'
import { buildRuntimeTerminalOrphanSession } from './runtime-terminal-orphan-session-adoption'
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
const TAB = 'tab-1'
const HEALTHY = leaf(1)
const ORPHAN = leaf(2)
function adopt(existingLeaves: [string, string?][]) {
const session = addWorkspace(emptySession(), GIT_KEY, [
{ id: 'group-1', tabs: [{ id: TAB, leaves: existingLeaves }] }
])
const claim = {
terminal: 'terminal-orphan',
ptyId: 'pty-orphan',
incarnationId: 'incarnation-orphan',
tabId: TAB,
leafId: ORPHAN
}
const topology: NonNullable<RuntimeTerminalOrphanAdoptionRequest['topology']> = {
tabs: [
{
tabId: TAB,
root: { type: 'leaf', leafId: ORPHAN },
activeLeafId: ORPHAN,
expandedLeafId: null
}
],
groups: [{ id: 'group-1', activeTabId: TAB, tabOrder: [TAB] }]
}
const request: RuntimeTerminalOrphanAdoptionRequest = {
worktree: GIT_KEY,
expectedTopologyRevision: 0,
claims: [claim],
topology
}
return buildRuntimeTerminalOrphanSession({
hostId: LOCAL_EXECUTION_HOST_ID,
session,
sessionWorktreeId: GIT_KEY,
worktreeId: GIT_KEY,
request,
validated: [
{
claim,
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the tab already exists, so the builder never reads the PTY record.
pty: {} as RuntimePtyWorktreeRecord,
paneKey: makePaneKey(TAB, ORPHAN)
}
],
topologyTabsById: new Map(topology.tabs.map((tab) => [tab.tabId, tab])),
topologyGroups: topology.groups,
getDisplayTitle: () => null
})
}
it('keeps the healthy sibling pane and its PTY when one split pane is recovered', () => {
const layout = adopt([[HEALTHY, 'pty-healthy']]).terminalLayoutsByTabId[TAB]
expect(collectLayoutLeafIdsInOrder(layout?.root)).toEqual([HEALTHY, ORPHAN])
expect(layout?.ptyIdsByLeafId).toEqual({ [HEALTHY]: 'pty-healthy', [ORPHAN]: 'pty-orphan' })
expect(layout?.activeLeafId).toBe(ORPHAN)
})
it('keeps the saved split when it already holds the recovered pane', () => {
const layout = adopt([[HEALTHY, 'pty-healthy'], [ORPHAN]]).terminalLayoutsByTabId[TAB]
expect(collectLayoutLeafIdsInOrder(layout?.root)).toEqual([HEALTHY, ORPHAN])
expect(layout?.ptyIdsByLeafId).toEqual({ [HEALTHY]: 'pty-healthy', [ORPHAN]: 'pty-orphan' })
})
@@ -6,6 +6,11 @@ import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-la
import { saveWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-save'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { collectPersistedTerminalLeafIds } from './mobile-session-layout-projection'
import {
collectLayoutLeafIdsInOrder,
removeLayoutLeaf
} from '../../shared/workspace-layout/terminal-pane-tree'
import type { TerminalPaneLayoutNode } from '../../shared/terminal-tab-types'
import type { RuntimePtyWorktreeRecord } from './runtime-terminal-state-records'
import { mergeTerminalOrphanGroupLayout } from './terminal-orphan-topology'
import { canonicalizeTerminalSessionWorktreeId } from './workspace-session-worktree-id'
@@ -62,7 +67,7 @@ export function buildRuntimeTerminalOrphanSession(args: {
next.terminalLayoutsByTabId[claim.tabId] = topologyTab
? {
...existingLayout,
root: topologyTab.root,
root: mergeAdoptedTopologyRoot(existingLayout?.root ?? null, topologyTab.root),
activeLeafId: topologyTab.activeLeafId,
expandedLeafId: topologyTab.expandedLeafId,
ptyIdsByLeafId: {
@@ -196,3 +201,29 @@ export function buildRuntimeTerminalOrphanSession(args: {
loadWorkspaceLayoutWithoutReport(hostId, persisted, nameBasedLoadContext())
)
}
/**
* The client's topology names only the panes it adopts; the tab's other panes keep their place
* beside them, so recovering one split never drops a healthy sibling.
*/
function mergeAdoptedTopologyRoot(
existingRoot: TerminalPaneLayoutNode | null,
adoptedRoot: TerminalPaneLayoutNode
): TerminalPaneLayoutNode {
if (!existingRoot) {
return adoptedRoot
}
const existingLeafIds = collectLayoutLeafIdsInOrder(existingRoot)
const adoptedLeafIds = collectLayoutLeafIdsInOrder(adoptedRoot)
if (adoptedLeafIds.every((leafId) => existingLeafIds.includes(leafId))) {
return existingRoot
}
const siblings = adoptedLeafIds.reduce<TerminalPaneLayoutNode | null>(
(root, leafId) => removeLayoutLeaf(root, leafId),
existingRoot
)
if (!siblings) {
return adoptedRoot
}
return { type: 'split', direction: 'vertical', first: siblings, second: adoptedRoot }
}