diff --git a/notes/orca-performance-branch-guide.md b/notes/orca-performance-branch-guide.md index 5588855b411..29c68c90e24 100644 --- a/notes/orca-performance-branch-guide.md +++ b/notes/orca-performance-branch-guide.md @@ -127,6 +127,23 @@ Three cooperating layers, innermost first: daemon session ids embed worktree paths. Recording happens only on rare transitions; the table/report is built only when read. This exists so a field freeze report never needs a follow-up ask. +- **Hidden/parked exit teardown completeness** (pty-connection.ts kept-exit + guard + `terminal-parked-tab-watchers.ts` exit sidecar): two invariants that + keep a split pane's death near the hidden/park boundary from stranding state + (field incident: a closed setup-split leaf persisted in `root` with no + binding and remounted as a permanently blank pane, unreachable by + dead-session reconcile — it skips ptyId-null panes by design). + (1) The "keep a fresh split whose newborn PTY died" branch is **gated on + `isVisibleRef`** — hidden panes' bytes are gate-withheld, so "no output" + proves nothing there; a hidden newborn death must `closePane`, or the kept + pane becomes a binding-less ghost. (2) A PTY exit that lands **while parked** + reaches ONLY the parked watcher's exit sidecar (hosts' `onPtyExit` needs a + mounted TerminalPane), so the sidecar itself collapses the dead leaf out of + the stored layout via `detachTerminalLayoutLeaf` — a stale binding left + behind reattaches on reveal and the daemon re-creates the exited session id + as a fresh shell (silent pane resurrection). E2e: + `terminal-pane-close-layout-consistency.spec.ts` sweeps close/exit at every + lifecycle phase and asserts leaves(root) == bindings == live panes. - **Producer flow control** (protocol v19 `pausePty`/`resumePty`, 256KB pause / 32KB resume watermarks, keyed off **pendingData only** — never renderer counters; kill switch `PRODUCER_FLOW_CONTROL_ENABLED`, ipc/pty.ts): when main's diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 71c94b870be..edb4bfd52b0 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -2167,6 +2167,32 @@ describe('connectPanePty', () => { expect(manager.setActivePane).toHaveBeenCalledWith(1, { focus: true }) }) + it('closes a hidden split pane whose PTY exits before output instead of keeping a ghost', async () => { + // Why (regression, ghost blank pane): the keep above is a visible-failure + // UX. A hidden pane's bytes are withheld by the hidden-delivery gate, so + // "no output" proves nothing there — keeping it strands a binding-less + // pane that remounts as a permanently blank ghost on reveal. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2, 2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + isVisibleRef: { current: false }, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + connectPanePty(createPane(2) as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + expect(onPtyExit).toBeTypeOf('function') + + onPtyExit?.('pty-pane-2') + + expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-pane-2') + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + expect(manager.closePane).toHaveBeenCalledWith(2) + }) + it('keeps a worktree sole terminal mounted when its freshly-spawned PTY exits before input (direnv failure)', async () => { // Why (regression): a PR worktree can ship an .envrc whose direnv command // fails, so the only terminal's login shell exits non-zero immediately. The diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 804459619e4..4a48f46d702 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -2108,6 +2108,7 @@ export function connectPanePty( return } if ( + deps.isVisibleRef.current && hadExistingPaneTransportAtConnect && !restoredPtyIdForTransport && !Number.isFinite(lastTerminalInputAt) && @@ -2115,6 +2116,10 @@ export function connectPanePty( ) { // Why: a freshly split pane can lose its newborn PTY during setup; keep // the split visible so the failed session does not immediately collapse. + // Hidden panes must close instead: the hidden-delivery gate withholds + // their bytes, so "no output" is meaningless there, and keeping one + // strands a binding-less pane the exit path never revisits — it remounts + // as a permanently blank ghost on reveal. focusSurvivingPtyPaneAfterKeptExit() return } diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts index 21cac2cd0c3..bfd6b988291 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -56,6 +56,7 @@ type MockStoreState = { > runtimePaneTitlesByTabId: Record> clearRuntimePaneTitle: ReturnType + setTabLayout: ReturnType } let mockStoreState: MockStoreState @@ -103,7 +104,8 @@ describe('terminal-parked-tab-watchers', () => { mockStoreState = { terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {}, - clearRuntimePaneTitle: vi.fn() + clearRuntimePaneTitle: vi.fn(), + setTabLayout: vi.fn() } ;(globalThis as { window?: unknown }).window = { api: { pty: { write: ptyWrite } } } }) @@ -336,6 +338,38 @@ describe('terminal-parked-tab-watchers', () => { expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 1) }) + it('collapses the exited leaf out of the stored layout when deferring', () => { + // Why (regression, ghost/resurrected pane): a deferred parked exit that + // leaves the leaf and its binding in the stored layout reattaches on + // reveal — the daemon re-creates the exited session id as a fresh shell. + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: SECOND_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + + hostOnPtyExit(TAB_ID, SECOND_PTY_ID) + + expect(closeTab).not.toHaveBeenCalled() + expect(mockStoreState.setTabLayout).toHaveBeenCalledWith(TAB_ID, { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + }) + }) + it('keeps exit→closeTab parity for a parked single-leaf tab', () => { capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) syncParked() @@ -351,6 +385,53 @@ describe('terminal-parked-tab-watchers', () => { expect(closeTab).toHaveBeenCalledWith(TAB_ID) }) + it('collapses the exited leaf via the watcher exit sidecar (no host handler while parked)', () => { + // Why: hosts' onPtyExit runs from a mounted TerminalPane, so an exit + // that lands while parked reaches ONLY the watcher sidecar — it must run + // the layout collapse itself or the leaf resurrects on reveal. + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + + exitSubscriptions.find((entry) => entry.ptyId === SECOND_PTY_ID)?.callback(0) + + expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2) + expect(mockStoreState.setTabLayout).toHaveBeenCalledWith(TAB_ID, { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + }) + }) + + it('does not touch the layout when the last parked watcher exits (tab-level close owns it)', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + + exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0) + + expect(mockStoreState.setTabLayout).not.toHaveBeenCalled() + }) + it('closes the tab when the last surviving leaf of a parked split exits', () => { capturePanes([ { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index 4f9bf0f77cc..8871bdf4905 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -14,6 +14,7 @@ import { isTerminalLeafId } from '../../../../shared/stable-pane-id' import type { TerminalTab } from '../../../../shared/types' import { useAppStore } from '@/store' import { collectLeafIdsInOrder } from './terminal-layout-leaf-ids' +import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' import { subscribeToPtyExit } from './pty-dispatcher' import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher' import { isSnapshotBackedTerminalPty } from './terminal-hidden-view-parking' @@ -146,6 +147,15 @@ function startParkedTabWatchers(worktreeId: string, tab: ParkableTerminalTabMode // Why: a PTY that exits while parked has no pane to run exit cleanup; at // minimum its watcher must not outlive it. const unsubscribeExit = subscribeToPtyExit(ptyId, () => { + // Why: while parked this sidecar is the ONLY exit observer — the hosts' + // onPtyExit runs from a mounted TerminalPane. Run the observed-exit + // teardown's data half here for a multi-leaf tab, or the dead leaf's + // stale binding reattaches on reveal and the daemon re-creates the + // exited session id as a fresh shell, resurrecting the pane. + if (disposersByPtyId.size > 1) { + useAppStore.getState().clearRuntimePaneTitle(tab.id, pane.paneId) + collapseParkedExitedLeaf(tab.id, ptyId) + } disposersByPtyId.get(ptyId)?.() disposersByPtyId.delete(ptyId) // Why: with the last watcher gone there is nothing left to watch or @@ -198,7 +208,32 @@ export function shouldDeferParkedPtyExitTabClose(tabId: string, ptyId: string): // removes the dead PTY's watcher — so the watcher count still includes the // exiting PTY. More than one watcher (or an exit for an unwatched PTY) // means live sibling leaves remain. - return remaining > 1 || !entry.disposersByPtyId.has(ptyId) + const defer = remaining > 1 || !entry.disposersByPtyId.has(ptyId) + if (defer) { + collapseParkedExitedLeaf(tabId, ptyId) + } + return defer +} + +// Why: deferring the tab close is not enough — a stale leaf binding left in +// the stored layout reattaches on reveal, and the daemon re-creates the exited +// session id as a fresh shell, resurrecting a pane whose shell already ended. +// With no PaneManager mounted, the observed-exit teardown's data half runs +// here instead: collapse the leaf out of the stored layout so the reveal +// replays only the surviving panes. +function collapseParkedExitedLeaf(tabId: string, ptyId: string): void { + const state = useAppStore.getState() + const layout = state.terminalLayoutsByTabId[tabId] + const leafId = + capturedPanesByTabId.get(tabId)?.panes.find((pane) => pane.ptyId === ptyId)?.leafId ?? + Object.entries(layout?.ptyIdsByLeafId ?? {}).find(([, boundPtyId]) => boundPtyId === ptyId)?.[0] + if (!leafId) { + return + } + const detached = detachTerminalLayoutLeaf(layout, leafId) + if (detached) { + state.setTabLayout(tabId, detached.sourceLayout) + } } /** diff --git a/tests/e2e/terminal-pane-close-layout-consistency.spec.ts b/tests/e2e/terminal-pane-close-layout-consistency.spec.ts new file mode 100644 index 00000000000..db837c317cb --- /dev/null +++ b/tests/e2e/terminal-pane-close-layout-consistency.spec.ts @@ -0,0 +1,360 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + sendToTerminal, + splitActiveTerminalPane, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' + +/** + * Repro hunt for the "ghost blank pane" field incident: a split pane whose PTY + * and leaf→PTY binding were torn down while the persisted layout `root` kept + * the leaf, so revisiting the tab materialized a permanently blank pane with + * no terminal behind it. + * + * Field state (workspace remote-issue-2, 2026-07-09): root held 3 leaves, + * ptyIdsByLeafId held 2, no daemon session for the third — the close/exit ran + * near a hidden/park boundary. Each test here closes (or exits) a split pane + * at a different phase of the hidden-view parking lifecycle and asserts the + * invariant that broke in the field: + * + * leaves(persisted root) === keys(persisted ptyIdsByLeafId) === live panes + * + * A failing scenario IS the finding — it pins which boundary loses the layout + * collapse. + */ + +// Why 2000ms: the override shrinks BOTH cold-park delay and hot-retain, and +// the hidden-but-mounted scenario needs the shell exit to land well inside the +// hot-retain window — 500ms let slow shell teardown race past parking and turn +// that scenario into the exits-while-parked one. +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 2_000 + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) } +}) + +type ParkingDebugWindow = Window & { + __terminalParkingDebug?: { parkDelayMs?: number } +} + +async function skipUnlessParkingWired(page: Page): Promise { + const deadline = Date.now() + 2_000 + let present = await page.evaluate( + () => (window as ParkingDebugWindow).__terminalParkingDebug !== undefined + ) + while (!present && Date.now() < deadline) { + await page.waitForTimeout(250) + present = await page.evaluate( + () => (window as ParkingDebugWindow).__terminalParkingDebug !== undefined + ) + } + test.skip(!present, 'terminal hidden view parking wiring is not compiled in') +} + +type LayoutConsistency = { + rootLeafIds: string[] + boundLeafIds: string[] + boundPtyIds: string[] + livePaneCount: number | null + hasManager: boolean + domPaneCount: number +} + +async function readLayoutConsistency(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const store = window.__store + if (!store) { + throw new Error('window.__store unavailable') + } + const layout = store.getState().terminalLayoutsByTabId[tabId] + const rootLeafIds: string[] = [] + type LayoutNode = + | { type: 'leaf'; leafId: string } + | { type: 'split'; first: LayoutNode; second: LayoutNode } + const walk = (node: LayoutNode | null | undefined): void => { + if (!node) { + return + } + if (node.type === 'leaf') { + rootLeafIds.push(node.leafId) + return + } + walk(node.first) + walk(node.second) + } + walk((layout?.root ?? null) as LayoutNode | null) + const manager = window.__paneManagers?.get(tabId) + const managerPanes = manager?.getPanes?.() ?? null + const paneElements = managerPanes ? new Set(managerPanes.map((pane) => pane.container)) : null + return { + rootLeafIds, + boundLeafIds: Object.keys(layout?.ptyIdsByLeafId ?? {}), + boundPtyIds: Object.values(layout?.ptyIdsByLeafId ?? {}), + livePaneCount: managerPanes ? managerPanes.length : null, + hasManager: manager !== undefined, + domPaneCount: paneElements + ? Array.from(document.querySelectorAll('.pane[data-leaf-id]')).filter( + (element) => paneElements.has(element) + ).length + : 0 + } + }, tabId) +} + +/** + * The invariant under hunt. Polls so post-close persists can land, then does a + * final full read whose diff names the divergence (stale root leaf vs dropped + * binding vs ghost live pane). + */ +async function expectLayoutConsistent( + page: Page, + tabId: string, + expectedPaneCount: number, + phase: string, + deadPtyId?: string +): Promise { + // Why: polling a shape (not a boolean) makes a timeout print the diverged + // state — which of root/bindings/live panes went stale is the finding. + await expect + .poll( + async () => { + const state = await readLayoutConsistency(page, tabId) + return { + hasManager: state.hasManager, + livePaneCount: state.livePaneCount, + domPaneCount: state.domPaneCount, + rootLeafCount: state.rootLeafIds.length, + boundLeafCount: state.boundLeafIds.length, + unboundRootLeafIds: state.rootLeafIds.filter( + (leafId) => !state.boundLeafIds.includes(leafId) + ), + deadPtyStillBound: deadPtyId ? state.boundPtyIds.includes(deadPtyId) : false + } + }, + { + timeout: 15_000, + message: `[${phase}] layout did not settle to ${expectedPaneCount} consistent pane(s)` + } + ) + .toEqual({ + hasManager: true, + livePaneCount: expectedPaneCount, + domPaneCount: expectedPaneCount, + rootLeafCount: expectedPaneCount, + boundLeafCount: expectedPaneCount, + unboundRootLeafIds: [], + deadPtyStillBound: false + }) +} + +async function closeLastPaneOnTab(page: Page, tabId: string): Promise { + await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + if (!manager) { + throw new Error(`closeLastPaneOnTab: no mounted pane manager for tab ${tabId}`) + } + const target = manager.getPanes().at(-1) + if (!target) { + throw new Error('closeLastPaneOnTab: tab has no panes') + } + manager.closePane(target.id) + }, tabId) +} + +async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { + const tabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('createActiveTerminalTab: window.__store is unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, worktreeId) + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'newly created terminal tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneIdentitySnapshot(page, 1) + return tabId +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((targetTabId) => { + const store = window.__store + if (!store) { + throw new Error('activateTerminalTab: window.__store is unavailable') + } + const state = store.getState() + state.setActiveTabType('terminal') + state.setActiveTab(targetTabId) + }, tabId) + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: `terminal tab ${tabId} did not become active` + }) + .toBe(tabId) +} + +// Why: TerminalPane unmount deletes its __paneManagers entry — that absence is +// the observable "parked" signal (same detection as the parking spec). +async function waitForTabParked(page: Page, tabId: string): Promise { + await expect + .poll(async () => page.evaluate((id) => window.__paneManagers?.get(id) !== undefined, tabId), { + timeout: Math.max(20_000, PARKING_DELAY_MS * 10), + message: `terminal tab ${tabId} did not park (pane manager still mounted)` + }) + .toBe(false) +} + +async function waitForTabRemounted(page: Page, tabId: string): Promise { + await expect + .poll(async () => page.evaluate((id) => window.__paneManagers?.get(id) !== undefined, tabId), { + timeout: 15_000, + message: `terminal tab ${tabId} did not remount on reveal` + }) + .toBe(true) +} + +type SplitTabSetup = { + worktreeId: string + tabId: string + splitLeafId: string + splitPtyId: string +} + +// Why: every scenario starts from the field shape — a tab whose main pane got +// a split (the "setup pane" analog) that is fully bound and settled. +async function setUpSplitTab(page: Page): Promise { + await waitForSessionReady(page) + const worktreeId = await waitForActiveWorktree(page) + await skipUnlessParkingWired(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneIdentitySnapshot(page, 1) + const tabId = await getActiveTabId(page) + if (!tabId) { + throw new Error('setUpSplitTab: no active terminal tab') + } + await splitActiveTerminalPane(page, 'vertical') + const snapshot = await waitForPaneIdentitySnapshot(page, 2) + const splitPane = snapshot.panes.at(-1) + if (!splitPane?.ptyId) { + throw new Error('setUpSplitTab: split pane did not bind a PTY') + } + return { worktreeId, tabId, splitLeafId: splitPane.leafId, splitPtyId: splitPane.ptyId } +} + +test.describe('terminal pane close vs hidden/park lifecycle keeps layout consistent', () => { + test('control: close while visible', async ({ orcaPage }) => { + const { tabId } = await setUpSplitTab(orcaPage) + await closeLastPaneOnTab(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'close-visible') + }) + + test('close and hide the tab in the same tick', async ({ orcaPage }) => { + const { worktreeId, tabId } = await setUpSplitTab(orcaPage) + await orcaPage.evaluate( + ({ tabId, worktreeId }) => { + const store = window.__store + const manager = window.__paneManagers?.get(tabId) + if (!store || !manager) { + throw new Error('close+hide: store/manager unavailable') + } + const target = manager.getPanes().at(-1) + if (!target) { + throw new Error('close+hide: no split pane') + } + manager.closePane(target.id) + // Hide tab A before any deferred post-close work can run. + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + }, + { tabId, worktreeId } + ) + await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'close-then-hide-same-tick') + }) + + test('close while hidden but still mounted (hot-retain window)', async ({ orcaPage }) => { + const { worktreeId, tabId } = await setUpSplitTab(orcaPage) + await createActiveTerminalTab(orcaPage, worktreeId) + await closeLastPaneOnTab(orcaPage, tabId) + await waitForTabParked(orcaPage, tabId) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'close-while-hidden-mounted') + }) + + test('close immediately after reveal remount, before panes settle', async ({ orcaPage }) => { + const { worktreeId, tabId } = await setUpSplitTab(orcaPage) + await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabParked(orcaPage, tabId) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + // Close as soon as the manager exists — panes may still be attaching. + await orcaPage.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const target = manager?.getPanes().at(-1) + if (manager && target) { + manager.closePane(target.id) + } + }, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'close-mid-reveal') + }) + + test('clean visible close survives a later park/reveal cycle', async ({ orcaPage }) => { + const { worktreeId, tabId } = await setUpSplitTab(orcaPage) + await closeLastPaneOnTab(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'pre-park close') + await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabParked(orcaPage, tabId) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'post-park-reveal') + }) + + test('split pane shell exits while hidden but still mounted', async ({ orcaPage }) => { + const { worktreeId, tabId, splitPtyId } = await setUpSplitTab(orcaPage) + await createActiveTerminalTab(orcaPage, worktreeId) + // The setup-script analog: the split's shell ends on its own while the + // tab is hidden-but-mounted. + await sendToTerminal(orcaPage, splitPtyId, 'exit\r') + await orcaPage.waitForTimeout(PARKING_DELAY_MS / 2) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'shell-exit-while-hidden-mounted', splitPtyId) + }) + + test('split pane shell exits while the tab is parked', async ({ orcaPage }) => { + const { worktreeId, tabId, splitPtyId } = await setUpSplitTab(orcaPage) + await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabParked(orcaPage, tabId) + await sendToTerminal(orcaPage, splitPtyId, 'exit\r') + await orcaPage.waitForTimeout(PARKING_DELAY_MS) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + // Why: the parked exit is deliberately deferred (no PaneManager to promote + // siblings) — the reveal remount owns the per-leaf teardown. This asserts + // that ownership actually resolves instead of leaving a ghost pane. + await expectLayoutConsistent(orcaPage, tabId, 1, 'shell-exit-while-parked', splitPtyId) + }) +})