fix(terminal): let a layout that carries the leaf beat the spawn-time tab hint

This commit is contained in:
Jinwoo-H
2026-09-21 00:23:24 -04:00
parent ff1c20724e
commit 3eb4471390
2 changed files with 57 additions and 5 deletions
@@ -2,6 +2,7 @@
// identity, and the renderer delivers PTY data to one handler slot per pty id — so one pane
// starves and both fight over the grid size. That is STA-7961.
import { describe, expect, it } from 'vitest'
import { collectLeafIdsInOrder } from '@/components/terminal-pane/terminal-layout-leaf-ids'
import {
createHarnessStoreState,
createStoreWithOwnerFiledElsewhere,
@@ -98,4 +99,53 @@ describe('terminal reveal must not mint a tab that reuses a bound leaf id (STA-7
expect(storeState.createTab).not.toHaveBeenCalled()
expect(tabsBindingSharedLeaf(storeState)).toEqual(['tab-a'])
})
it('adopts the tab whose layout carries the leaf over the tab the pty was minted against', async () => {
// The hint is a spawn-time tab id, not a binding. tab-b already holds the leaf, so reusing
// the hinted tab-a writes a single pane over its split and orphans leaf-a2's pty.
const storeState: HarnessStoreState = createHarnessStoreState({
tabsByWorktree: {
[WORKTREE_ID]: [
{ id: 'tab-a', ptyId: 'pty-a1', title: 'Terminal 1' },
{ id: 'tab-b', ptyId: null, title: 'Terminal 2' }
]
},
ptyIdsByTabId: {},
terminalLayoutsByTabId: {
'tab-a': {
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'leaf-a1' },
second: { type: 'leaf', leafId: 'leaf-a2' }
},
ptyIdsByLeafId: { 'leaf-a1': 'pty-a1', 'leaf-a2': 'pty-a2' }
},
// Bound to a dead pty, so the revealed pty is still owned by nobody.
'tab-b': {
root: { type: 'leaf', leafId: SHARED_LEAF_ID },
ptyIdsByLeafId: { [SHARED_LEAF_ID]: 'pty-stale' }
}
}
})
const harness = await loadIpcEventsHarness(storeState)
harness.useIpcEvents()
harness.createTerminal({
requestId: 'reveal',
worktreeId: WORKTREE_ID,
ptyId: SHARED_PTY_ID,
tabId: 'tab-a',
leafId: SHARED_LEAF_ID,
presentation: 'focused',
title: 'OpenCode'
})
expect(harness.replyTerminalCreate.mock.calls[0]?.[0]).toMatchObject({ tabId: 'tab-b' })
expect(storeState.createTab).not.toHaveBeenCalled()
expect(tabsBindingSharedLeaf(storeState)).toEqual(['tab-b'])
const hintedLayout = storeState.terminalLayoutsByTabId['tab-a']!
expect(collectLeafIdsInOrder(hintedLayout.root ?? null)).toEqual(['leaf-a1', 'leaf-a2'])
expect(hintedLayout.ptyIdsByLeafId).toEqual({ 'leaf-a1': 'pty-a1', 'leaf-a2': 'pty-a2' })
})
})
@@ -53,11 +53,6 @@ export function resolveTerminalRevealTabAdoption(
if (ownership.kind === 'owned') {
return ownership.owner.tabId
}
// Why: nothing holds the PTY yet, so the tab it was minted against is the only thing left that
// keeps paneKey hook attribution intact (#10486), and a split names its parent by that id.
if (ownership.kind === 'none' && hintTabId !== undefined) {
return hintTabId
}
// STA-7961: the PTY is unowned here, but the leaf id may already be someone's pane.
const leafOwnerTabId = request.leafId
? findTerminalTabIdBindingLeafId(state, request.leafId)
@@ -65,6 +60,13 @@ export function resolveTerminalRevealTabAdoption(
if (leafOwnerTabId !== null) {
return leafOwnerTabId
}
// Why: nothing holds the PTY yet, so the tab it was minted against is the only thing left that
// keeps paneKey hook attribution intact (#10486), and a split names its parent by that id. It
// ranks below the leaf owner because a layout already carrying the leaf is that pane's home,
// and reusing the hinted tab instead writes a single pane over it and orphans the rest.
if (ownership.kind === 'none' && hintTabId !== undefined) {
return hintTabId
}
// Why mint even when claimants exist: no layout carries this leaf id, so the bridge would
// replace the adopted tab's whole layout with a single pane and orphan its other panes' PTYs.
const claim =