diff --git a/src/renderer/src/hooks/terminal-reveal-duplicate-leaf-mint.test.ts b/src/renderer/src/hooks/terminal-reveal-duplicate-leaf-mint.test.ts index df2912fcd22..5def89a2763 100644 --- a/src/renderer/src/hooks/terminal-reveal-duplicate-leaf-mint.test.ts +++ b/src/renderer/src/hooks/terminal-reveal-duplicate-leaf-mint.test.ts @@ -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' }) + }) }) diff --git a/src/renderer/src/lib/terminal-reveal-tab-adoption.ts b/src/renderer/src/lib/terminal-reveal-tab-adoption.ts index 840f65e981f..abfdaed0906 100644 --- a/src/renderer/src/lib/terminal-reveal-tab-adoption.ts +++ b/src/renderer/src/lib/terminal-reveal-tab-adoption.ts @@ -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 =