Fix hidden/parked split-pane exit stranding ghost or resurrected panes

Deterministic e2e repro of the field 'ghost blank pane' incident (a
closed/finished setup-split leaf persisted in root with no binding and
remounted as a permanently blank pane) found two teardown gaps at the
hidden-view parking boundary:

1. The kept-exit guard ('freshly split pane can lose its newborn PTY
   during setup') fired for HIDDEN panes. The hidden-delivery gate
   withholds their bytes, so a hidden split always looks output-less and
   its dead pane was kept — a binding-less ghost that dead-session
   reconcile can never reach (no PTY id to prove dead). The keep is a
   visible-failure UX; gate it on isVisibleRef and close hidden panes.

2. A PTY exit landing while the tab is PARKED reached only the parked
   watcher's exit sidecar (hosts' onPtyExit requires a mounted
   TerminalPane), which only disposed the watcher. The leaf's stale
   binding then reattached on reveal and the daemon re-created the
   exited session id as a fresh shell — silent pane resurrection. The
   sidecar now collapses the dead leaf out of the stored layout via
   detachTerminalLayoutLeaf (the observed-exit teardown's data half).

New e2e suite terminal-pane-close-layout-consistency.spec.ts sweeps
close/shell-exit at every hidden/park lifecycle phase and asserts
leaves(root) == bindings == live panes; all 7 scenarios pass. Unit
regressions added for both fixes.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-09 22:42:15 -04:00
co-authored by Orca
parent 5138c69ac4
commit 121faeca08
6 changed files with 526 additions and 2 deletions
+17
View File
@@ -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
@@ -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
@@ -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
}
@@ -56,6 +56,7 @@ type MockStoreState = {
>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
setTabLayout: ReturnType<typeof vi.fn>
}
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 },
@@ -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)
}
}
/**
@@ -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<void> {
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<LayoutConsistency> {
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<HTMLElement>('.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<void> {
// 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<void> {
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<string> {
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<void> {
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<void> {
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<void> {
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<SplitTabSetup> {
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)
})
})