Layout groundwork (switched off): main starts every split and new pane, the window only attaches (#31258)

* feat(terminal): the window's store shows main's pane start status (layout-owner build)

A status-only frame changes one store entry and no pane tree, so the pane's view stays mounted.

* refactor(terminal): keep the frame sync within its line budget

* feat(terminal): every pane main starts shows 'starting' from before it is written (layout-owner build)

* feat(terminal): a pane main is starting waits and attaches; a failed start shows why (layout-owner build)

Fixes owner-visible row 15: a failed agent or setup start no longer opens a plain shell.

* test(terminal): iterate store listeners directly

* fix(terminal): an exit that reports no code keeps its pane, as lost contact (layout-owner build)

* test(terminal): a pane main started attaches to main's terminal at its own grid

* test(terminal): detach census tests assert main's move and frame with the switch on

* test(terminal): exited-pane tests assert main's close with the switch on

These 8 have failed switch-on since #31130 moved the exit close to main.

* feat(terminal): main writes and starts every split of a pane it holds; the window attaches (layout-owner build)

The window's split, a startup split with its command, and a CLI split all go through main's
terminal.split; main's frame mounts the pane, with or without a window intent. Deletes the
window's split wait and withdraw, the split appear intents, and the queued-split skip and cancel.
The e2e helper uses the real split. A startup split leaves focus on its source pane.

* fix(terminal): the attach-only wait leaves sleeping, resuming and held panes to their restore path

* fix(terminal): a split pane attaches to the terminal main bound it to; a taken pane id is refused

Also restores the proof that a split closed while it starts gets one answer and leaves no
pane or start status behind.

* fix(terminal): a window split main cannot start still shows its pane, failed, with why

* fix(terminal): check a taken pane id first, keep the start's own error, clear a gone pane's status

Three test stores gain the pane-start field the window's store always has.

* test(terminal): the admission stub answers whether its pane is still written; a closed pane's failed start clears

* refactor(terminal): the rebound workspaces' bindings show through one call in the bindings module

* test(terminal): a pane main wrote and the user closed mid-start drops its failed status

* fix(terminal): a split main starts is listed to phones and session.tabs without a window's report

* fix(terminal): a reconnect shows its pane starting and why it failed; a lost owner race shows no failure; a split no one asked to see keeps the phones' tab

* fix(terminal): a kept create another owner won shows no failure; the window attaches to the winner
This commit is contained in:
Jinwoo Hong
2026-10-21 00:14:06 -04:00
committed by GitHub
parent 046f29b9e5
commit ce71535bbf
61 changed files with 2921 additions and 1306 deletions
@@ -85,17 +85,6 @@ export function clearPaneSpawnReservation(
}
}
/** Whether a window or runtime spawn of this pane has begun and not yet bound or failed. */
export function isPaneSpawnInFlight(worktreeId: string, paneKey: string): boolean {
for (const ownerKey of paneSpawnReservationsByOwnerKey.keys()) {
const parsed: unknown = JSON.parse(ownerKey)
if (Array.isArray(parsed) && parsed[1] === worktreeId && parsed[2] === paneKey) {
return true
}
}
return false
}
export function makePaneSpawnReservationKey(
worktreeId: string | undefined,
connectionId: string | null | undefined,
@@ -150,6 +150,25 @@ describe('layout-owner build: main closes an exited split pane itself', () => {
expect(panes(session())).toEqual([LEFT])
})
it('keeps a split pane whose exit came with no code, as when its socket closed', async () => {
const { runtime, session } = splitRuntime()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: node-pty reports no exit code on a socket close, despite its types.
await runtime.onPtyExit('pty-right', undefined as unknown as number, undefined, {
providerExitObserved: true
})
expect(panes(session())).toEqual([LEFT, RIGHT])
})
it("judges an older daemon's exit, which names no incarnation, by its code alone", async () => {
const kept = splitRuntime()
await kept.runtime.onPtyExit('pty-right', -1, undefined, { providerExitObserved: true })
expect(panes(kept.session())).toEqual([LEFT, RIGHT])
const closed = splitRuntime()
await closed.runtime.onPtyExit('pty-right', 0, undefined, { providerExitObserved: true })
expect(panes(closed.session())).toEqual([LEFT])
})
it('tells the phone a closed pane retired, and sends it nothing for a pane main keeps', async () => {
const phone = async (runtime: OrcaRuntimeService) => {
const tabs = await runtime.listMobileSessionTabs(`id:${WORKTREE_ID}`)
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
import { collectLeafIds } from '../../shared/terminal-pane-layout-tree'
import { NEWER_DAEMON_HOLD_ERROR } from '../../shared/newer-daemon-hold'
import { setupLayoutOwnerRuntime, WORKTREE_ID } from './orca-runtime-layout-owner-start-fixture'
import { PaneOwnerContentionError } from '../ipc/pty/pane/pane-spawn-operation'
vi.mock('../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
@@ -61,6 +62,18 @@ describe('layout-owner build: a start main could not make keeps its pane, showin
})
})
it('shows no failure when another owner won the pane, whose terminal the window attaches to', async () => {
const { runtime, spawn } = setupLayoutOwnerRuntime()
spawn.mockRejectedValueOnce(new PaneOwnerContentionError())
await expect(
runtime.createTerminal(`id:${WORKTREE_ID}`, { keepPaneOnStartFailure: true })
).rejects.toThrow('terminal_pane_owner_changed')
const { tabId, leafId } = spawn.mock.calls[0]![0]
expect(runtime.startStatusOf(`${tabId}:${leafId}`)).toBeNull()
})
it('still takes back the pane of a start that did not ask to keep it', async () => {
const { runtime, spawn, session } = setupLayoutOwnerRuntime()
spawn.mockRejectedValueOnce(new Error('shell not found'))
@@ -88,4 +101,31 @@ describe('layout-owner build: a start main could not make keeps its pane, showin
expect(session().tabsByWorktree[WORKTREE_ID] ?? []).toEqual([])
})
it('shows every pane main starts as starting from before the pane is written, then clears it', async () => {
const { runtime, spawn, session } = setupLayoutOwnerRuntime()
const seen: unknown[] = []
spawn.mockImplementationOnce(async ({ tabId, leafId }) => {
// The pane is written by now: no frame shows it unbound without a status.
expect(session().tabsByWorktree[WORKTREE_ID]?.map((tab) => tab.id)).toEqual([tabId])
seen.push(runtime.startStatusOf(`${tabId}:${leafId}`))
return { id: 'pty-1' }
})
await runtime.createTerminal(`id:${WORKTREE_ID}`)
expect(seen).toEqual([{ status: 'starting' }])
const { tabId, leafId } = spawn.mock.calls[0]![0]
expect(runtime.startStatusOf(`${tabId}:${leafId}`)).toBeNull()
})
it('clears the status of a start whose pane it took back', async () => {
const { runtime, spawn } = setupLayoutOwnerRuntime()
spawn.mockRejectedValueOnce(new Error('shell not found'))
await expect(runtime.createTerminal(`id:${WORKTREE_ID}`)).rejects.toThrow('shell not found')
const { tabId, leafId } = spawn.mock.calls[0]![0]
expect(runtime.startStatusOf(`${tabId}:${leafId}`)).toBeNull()
})
})
@@ -5,6 +5,7 @@ import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-type
import type { TerminalLayoutSnapshot } from '../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { makePaneKey } from '../../shared/stable-pane-id'
import { collectLeafIds } from '../../shared/terminal-pane-layout-tree'
import { OrcaRuntimeService } from './orca-runtime'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
@@ -223,9 +224,6 @@ function createHarness(
}
}
},
/** The split pane's terminal starting, as its window reports it. */
registerSplitPty: (leafId: string) =>
runtime.registerPty(SPLIT_PTY_ID, WORKTREE_ID, connectionId, { tabId: TAB_ID, leafId }),
resolveReveal: () => resolveReveal?.({ tabId: TAB_ID }),
resolveSpawn: () => resolveSpawn?.({ id: SPLIT_PTY_ID, incarnationId: SPLIT_INCARNATION_ID })
}
@@ -234,9 +232,26 @@ function createHarness(
describe('remote runtime terminal split authority', () => {
it('addresses a graph-backed split by stable leaf identity across a parked remount', async () => {
const harness = createHarness(true, { rendererMounted: true, graphOnlySource: true })
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// Main writes the split and starts its terminal under the new pane's stable id; no window
// is asked, so a parked or remounting tab cannot hold the split up.
const started = await harness.runtime.splitTerminal(harness.handle, {
direction: 'vertical'
})
const startedLeafId = started.leafId ?? ''
expect(harness.rendererSplitTerminal).not.toHaveBeenCalled()
expect(started).toMatchObject({ tabId: TAB_ID, handle: expect.stringMatching(/^term_/) })
expect(harness.spawn).toHaveBeenCalledWith(
expect.objectContaining({ tabId: TAB_ID, leafId: startedLeafId })
)
expect(collectLeafIds(harness.getSession().terminalLayoutsByTabId[TAB_ID]?.root)).toEqual([
SOURCE_LEAF_ID,
startedLeafId
])
return
}
const split = harness.runtime.splitTerminal(harness.handle, { direction: 'vertical' })
// A switch-on build tells the window once main has admitted the new pane.
await vi.waitFor(() => expect(harness.rendererSplitTerminal).toHaveBeenCalled())
const newLeafId = harness.rendererSplitTerminal.mock.calls[0]?.[2]?.newLeafId
@@ -252,10 +267,6 @@ describe('remote runtime terminal split authority', () => {
telemetrySource: undefined,
newLeafId
})
// A switch-on build binds the new pane only once its terminal registered, before the graph.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
harness.registerSplitPty(newLeafId)
}
harness.runtime.syncWindowGraph(1, {
tabs: [
{
@@ -24,93 +24,181 @@ import {
spawnedPtyEnv,
store
} from '../orca-runtime-test-fixtures.spec'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { collectLeafIds } from '../../../shared/terminal-pane-layout-tree'
describe('OrcaRuntimeService', () => {
it('returns the exact pre-minted leaf for concurrent renderer-backed splits', async () => {
const tabId = 'tab-concurrent-splits'
const sourceLeafId = '11111111-1111-4111-8111-111111111111'
const splitTerminal = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setNotifier({ splitTerminal } as never)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId,
worktreeId: TEST_WORKTREE_ID,
title: 'shell',
activeLeafId: sourceLeafId,
layout: null
}
],
leaves: [
{
tabId,
worktreeId: TEST_WORKTREE_ID,
leafId: sourceLeafId,
paneRuntimeId: 1,
ptyId: 'pty-source',
paneTitle: null
}
]
})
const sourceHandle = runtime.getTerminalHandleForPaneKey(makePaneKey(tabId, sourceLeafId))
expect(sourceHandle).not.toBeNull()
it.runIf(ORCA_LAYOUT_OWNER_IS_MAIN)(
'returns the exact pre-minted leaf for concurrent renderer-backed splits, each started by main',
async () => {
const tabId = 'tab-concurrent-splits'
const sourceLeafId = HEADLESS_LEAF_ID
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal({
tabsByWorktree: {
[TEST_WORKTREE_ID]: [
{
id: tabId,
ptyId: 'pty-source',
worktreeId: TEST_WORKTREE_ID,
title: 'shell',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
[tabId]: makeHeadlessTerminalLayout({ [sourceLeafId]: 'pty-source' })
}
})
)
const splitTerminal = vi.fn()
const spawn = vi.fn(async (args: { leafId?: string }) => ({ id: `pty-${args.leafId}` }))
const runtime = new OrcaRuntimeService(runtimeStore)
runtime.setNotifier({ splitTerminal } as never)
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
runtime.syncWindowGraph(1, {
tabs: [
{
tabId,
worktreeId: TEST_WORKTREE_ID,
title: 'shell',
activeLeafId: sourceLeafId,
layout: null
}
],
leaves: [
{
tabId,
worktreeId: TEST_WORKTREE_ID,
leafId: sourceLeafId,
paneRuntimeId: 1,
ptyId: 'pty-source',
paneTitle: null
}
]
})
const sourceHandle = runtime.getTerminalHandleForPaneKey(makePaneKey(tabId, sourceLeafId))
expect(sourceHandle).not.toBeNull()
const horizontal = runtime.splitTerminal(sourceHandle!, { direction: 'horizontal' })
const vertical = runtime.splitTerminal(sourceHandle!, { direction: 'vertical' })
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(2))
const horizontalLeafId = splitTerminal.mock.calls.find(
(call) => call[2]?.direction === 'horizontal'
)?.[2]?.newLeafId
const verticalLeafId = splitTerminal.mock.calls.find(
(call) => call[2]?.direction === 'vertical'
)?.[2]?.newLeafId
expect(horizontalLeafId).toEqual(expect.any(String))
expect(verticalLeafId).toEqual(expect.any(String))
expect(horizontalLeafId).not.toBe(verticalLeafId)
const [horizontal, vertical] = await Promise.all([
runtime.splitTerminal(sourceHandle!, { direction: 'horizontal' }),
runtime.splitTerminal(sourceHandle!, { direction: 'vertical' })
])
runtime.syncWindowGraph(1, {
tabs: [
{
tabId,
worktreeId: TEST_WORKTREE_ID,
title: 'shell',
activeLeafId: verticalLeafId,
layout: null
}
],
// Reverse publication order so a first-new-leaf heuristic would swap the receipts.
leaves: [
{
tabId,
worktreeId: TEST_WORKTREE_ID,
leafId: verticalLeafId!,
paneRuntimeId: 3,
ptyId: 'pty-vertical',
paneTitle: null
},
{
tabId,
worktreeId: TEST_WORKTREE_ID,
leafId: horizontalLeafId!,
paneRuntimeId: 2,
ptyId: 'pty-horizontal',
paneTitle: null
},
{
tabId,
worktreeId: TEST_WORKTREE_ID,
leafId: sourceLeafId,
paneRuntimeId: 1,
ptyId: 'pty-source',
paneTitle: null
}
]
})
// Main splits itself: no window is asked, and each split names its own pane.
expect(splitTerminal).not.toHaveBeenCalled()
expect(horizontal.leafId).not.toBe(vertical.leafId)
const leafIds = collectLeafIds(getSession().terminalLayoutsByTabId[tabId]?.root)
expect(leafIds).toEqual(
expect.arrayContaining([sourceLeafId, horizontal.leafId, vertical.leafId])
)
// Each started under its own pane key, in the tab it split.
for (const split of [horizontal, vertical]) {
expect(split.tabId).toBe(tabId)
const leafId = split.leafId ?? ''
const call = spawn.mock.calls.find(([args]) => args.leafId === leafId)
expect(spawnedPtyEnv(call?.[0]).ORCA_PANE_KEY).toBe(makePaneKey(tabId, leafId))
}
expect(spawn).toHaveBeenCalledTimes(2)
}
)
await expect(horizontal).resolves.toMatchObject({ leafId: horizontalLeafId })
await expect(vertical).resolves.toMatchObject({ leafId: verticalLeafId })
})
it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)(
'returns the exact pre-minted leaf for concurrent renderer-backed splits',
async () => {
const tabId = 'tab-concurrent-splits'
const sourceLeafId = '11111111-1111-4111-8111-111111111111'
const splitTerminal = vi.fn()
const runtime = new OrcaRuntimeService(store)
runtime.setNotifier({ splitTerminal } as never)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId,
worktreeId: TEST_WORKTREE_ID,
title: 'shell',
activeLeafId: sourceLeafId,
layout: null
}
],
leaves: [
{
tabId,
worktreeId: TEST_WORKTREE_ID,
leafId: sourceLeafId,
paneRuntimeId: 1,
ptyId: 'pty-source',
paneTitle: null
}
]
})
const sourceHandle = runtime.getTerminalHandleForPaneKey(makePaneKey(tabId, sourceLeafId))
expect(sourceHandle).not.toBeNull()
const horizontal = runtime.splitTerminal(sourceHandle!, { direction: 'horizontal' })
const vertical = runtime.splitTerminal(sourceHandle!, { direction: 'vertical' })
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(2))
const horizontalLeafId = splitTerminal.mock.calls.find(
(call) => call[2]?.direction === 'horizontal'
)?.[2]?.newLeafId
const verticalLeafId = splitTerminal.mock.calls.find(
(call) => call[2]?.direction === 'vertical'
)?.[2]?.newLeafId
expect(horizontalLeafId).toEqual(expect.any(String))
expect(verticalLeafId).toEqual(expect.any(String))
expect(horizontalLeafId).not.toBe(verticalLeafId)
runtime.syncWindowGraph(1, {
tabs: [
{
tabId,
worktreeId: TEST_WORKTREE_ID,
title: 'shell',
activeLeafId: verticalLeafId,
layout: null
}
],
// Reverse publication order so a first-new-leaf heuristic would swap the receipts.
leaves: [
{
tabId,
worktreeId: TEST_WORKTREE_ID,
leafId: verticalLeafId!,
paneRuntimeId: 3,
ptyId: 'pty-vertical',
paneTitle: null
},
{
tabId,
worktreeId: TEST_WORKTREE_ID,
leafId: horizontalLeafId!,
paneRuntimeId: 2,
ptyId: 'pty-horizontal',
paneTitle: null
},
{
tabId,
worktreeId: TEST_WORKTREE_ID,
leafId: sourceLeafId,
paneRuntimeId: 1,
ptyId: 'pty-source',
paneTitle: null
}
]
})
await expect(horizontal).resolves.toMatchObject({ leafId: horizontalLeafId })
await expect(vertical).resolves.toMatchObject({ leafId: verticalLeafId })
}
)
it('splits visible pty-backed terminal sessions through the parent renderer tab', async () => {
const spawn = vi
+118 -3
View File
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest'
import type { RuntimeStore } from './runtime-store-contract'
import { spawnInAdmittedPane } from './runtime-pane-admission'
import { PaneOwnerContentionError } from '../ipc/pty/pane/pane-spawn-operation'
import {
bindingOf,
PaneStartStatuses,
@@ -23,12 +24,17 @@ const ARGS = {
}
const PANE_KEY = `${ARGS.tabId}:${ARGS.leafId}`
function storeAnswering(admit: 'admitted' | 'exists') {
function storeAnswering(admit: 'admitted' | 'exists', paneWritten = true) {
const store = {
admitTerminalPane: vi.fn(async () => admit),
withdrawTerminalPane: vi.fn(async () => null)
withdrawTerminalPane: vi.fn(async () => null),
getWorkspaceSession: vi.fn(() => ({
terminalLayoutsByTabId: paneWritten
? { [ARGS.tabId]: { root: { type: 'leaf', leafId: ARGS.leafId } } }
: {}
}))
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission path calls only these two store methods.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the admission path calls only these three store methods.
return { store, runtimeStore: store as unknown as RuntimeStore }
}
@@ -77,6 +83,58 @@ describe('a start into a pane main holds', () => {
expect(store.withdrawTerminalPane).not.toHaveBeenCalled()
})
it('clears the status when the pane was closed before its start failed', async () => {
const { register } = statuses()
const { runtimeStore } = storeAnswering('exists', false)
await expect(
spawnInAdmittedPane(
runtimeStore,
{
spawn: vi.fn(async () => {
throw new Error('shell not found')
})
},
{ ...ARGS, startStatus: { sink: register, keepOnFailure: false } }
)
).rejects.toThrow('shell not found')
expect(register.get(PANE_KEY, UNBOUND)).toBeNull()
})
it('clears the status of a pane it wrote when the user closed it before its start failed', async () => {
const { register } = statuses()
const { store, runtimeStore } = storeAnswering('admitted', false)
await expect(
spawnInAdmittedPane(
runtimeStore,
{
spawn: vi.fn(async () => {
throw new Error('no pty')
})
},
{ ...ARGS, startStatus: { sink: register, keepOnFailure: true } }
)
).rejects.toThrow('no pty')
expect(register.get(PANE_KEY, UNBOUND)).toBeNull()
expect(store.withdrawTerminalPane).not.toHaveBeenCalled()
})
it('shows no failure when another owner won the pane it held', async () => {
const { register } = statuses()
const { runtimeStore } = storeAnswering('exists')
await expect(
spawnInAdmittedPane(
runtimeStore,
{
spawn: vi.fn(async () => {
throw new PaneOwnerContentionError()
})
},
{ ...ARGS, startStatus: { sink: register, keepOnFailure: true } }
)
).rejects.toThrow('terminal_pane_owner_changed')
expect(register.get(PANE_KEY, UNBOUND)).toBeNull()
})
it('keeps a pane the create wrote only when asked to; otherwise takes it back as today', async () => {
const failing = {
spawn: vi.fn(async () => {
@@ -107,6 +165,63 @@ describe('a start into a pane main holds', () => {
})
})
describe('a reconnect into a pane main already holds', () => {
const RECONNECT = { ...ARGS, paneSpawnOperation: { mustExist: true } }
it('shows the pane starting without asking for admission, then clears', async () => {
const { register } = statuses()
const { store, runtimeStore } = storeAnswering('exists')
let seen: unknown
await spawnInAdmittedPane(
runtimeStore,
{
spawn: vi.fn(async () => {
seen = register.get(PANE_KEY, UNBOUND)
return { id: 'pty-1' }
})
},
{ ...RECONNECT, startStatus: { sink: register, keepOnFailure: false } }
)
expect(seen).toEqual({ status: 'starting' })
expect(register.get(PANE_KEY, UNBOUND)).toBeNull()
expect(store.admitTerminalPane).not.toHaveBeenCalled()
})
it('keeps the pane showing why when its start fails', async () => {
const { register } = statuses()
const { runtimeStore } = storeAnswering('exists')
await expect(
spawnInAdmittedPane(
runtimeStore,
{
spawn: vi.fn(async () => {
throw new Error('shell not found')
})
},
{ ...RECONNECT, startStatus: { sink: register, keepOnFailure: false } }
)
).rejects.toThrow('shell not found')
expect(register.get(PANE_KEY, UNBOUND)).toEqual({ status: 'failed', reason: 'shell not found' })
})
it('shows no failure when another owner won the pane, which attaches to the winner', async () => {
const { register } = statuses()
const { runtimeStore } = storeAnswering('exists')
await expect(
spawnInAdmittedPane(
runtimeStore,
{
spawn: vi.fn(async () => {
throw new PaneOwnerContentionError()
})
},
{ ...RECONNECT, startStatus: { sink: register, keepOnFailure: false } }
)
).rejects.toThrow('terminal_pane_owner_changed')
expect(register.get(PANE_KEY, UNBOUND)).toBeNull()
})
})
describe('a pane’s start status in the published layout', () => {
const layout: PublishedWorkspaceLayout = {
worktreeId: 'wt-1',
@@ -104,12 +104,25 @@ export const TERMINAL_LIFECYCLE_METHODS = [
permission: 'workspace',
params: TerminalSplit,
handler: async (params, { runtime }) => ({
split: await runtime.splitTerminal(params.terminal, {
direction: params.direction,
command: params.command,
env: params.env,
telemetrySource: params.telemetrySource
})
split:
params.pane && !params.terminal
? await runtime.splitTerminalPane(params.pane, {
direction: params.direction ?? 'horizontal',
newLeafId: params.newLeafId,
cwd: params.cwd,
initialSize: params.initialSize,
command: params.command,
env: params.env,
// The window's split shows why its start failed, as its own spawn did.
keepPaneOnStartFailure: true
})
: // The schema requires a handle when no pane is named.
await runtime.splitTerminal(params.terminal ?? '', {
direction: params.direction,
command: params.command,
env: params.env,
telemetrySource: params.telemetrySource
})
})
}),
defineMethod({
+53 -29
View File
@@ -1,5 +1,6 @@
import { PaneOwnerContentionError } from '../ipc/pty/pane/pane-spawn-operation'
import { toSshExecutionHostId } from '../../shared/execution-host'
import { collectLeafIds } from '../../shared/terminal-pane-layout-tree'
import type {
TerminalPaneAdmission,
TerminalPaneAdmissionOutcome
@@ -63,6 +64,14 @@ function admissionFor(args: AdmittedSpawnArgs): TerminalPaneAdmission {
}
}
function paneStillWritten(
store: RuntimeStore | null | undefined,
args: AdmittedSpawnArgs
): boolean {
const layout = store?.getWorkspaceSession?.(hostIdOf(args))?.terminalLayoutsByTabId?.[args.tabId]
return collectLeafIds(layout?.root).includes(args.leafId)
}
function hostIdOf(args: AdmittedSpawnArgs): string | undefined {
return args.connectionId ? toSshExecutionHostId(args.connectionId) : undefined
}
@@ -129,7 +138,8 @@ export async function startInAdmittedPane<T>(
/**
* Starts a runtime-originated terminal: its pane, tab-bar entry and group are written first, then
* the process starts and binds to that pane. A start that fails, or that attaches to a session
* already running in another pane, takes back the pane it wrote.
* already running in another pane, takes back the pane it wrote. With `startStatus`, the pane
* shows 'starting' from before it is written, so no view sees it unbound and starts its own.
*/
export async function spawnInAdmittedPane(
store: RuntimeStore | null | undefined,
@@ -137,36 +147,50 @@ export async function spawnInAdmittedPane(
{ startStatus, ...args }: AdmittedSpawnArgs & PaneStartArgs
): ReturnType<Spawn> {
const { groupId: _groupId, afterTabId: _afterTabId, ...spawnArgs } = args
if (args.paneSpawnOperation?.mustExist) {
const paneKey = `${args.tabId}:${args.leafId}`
let admittedAs: TerminalPaneAdmissionOutcome | undefined
startStatus?.sink.set(paneKey, { status: 'starting' })
let run: { outcome: TerminalPaneAdmissionOutcome | undefined; result: Awaited<ReturnType<Spawn>> }
const start = async (
admitted: TerminalPaneAdmissionOutcome | undefined
): Promise<Awaited<ReturnType<Spawn>>> => {
if (admitted !== undefined && admitted !== 'admitted' && admitted !== 'exists') {
// A tab main recorded closed, or a split whose parent pane left the layout.
throw new Error(admitted === 'tab_closed' ? admitted : 'terminal_split_source_not_found')
}
admittedAs = admitted
// SAFETY: Callers require spawn; a lost capability during admission keeps the original failure.
return controller.spawn!(spawnArgs)
}
const { outcome, result } = await startInAdmittedPane(
store,
admissionFor(args),
hostIdOf(args),
async (admitted) => {
if (admitted !== undefined && admitted !== 'admitted' && admitted !== 'exists') {
// A tab main recorded closed, or a split whose parent pane left the layout.
throw new Error(admitted === 'tab_closed' ? admitted : 'terminal_split_source_not_found')
}
if (!startStatus || (admitted !== 'exists' && !startStatus.keepOnFailure)) {
// SAFETY: Callers require spawn; a lost capability during admission keeps the original failure.
return controller.spawn!(spawnArgs)
}
const { sink } = startStatus
const paneKey = `${args.tabId}:${args.leafId}`
sink.set(paneKey, { status: 'starting' })
try {
const started = await controller.spawn!(spawnArgs)
sink.clear(paneKey)
return started
} catch (error) {
sink.set(paneKey, paneStartFailure(error))
throw error
}
},
startStatus?.keepOnFailure
)
try {
// A reconnect into its own pane skips admission: the pane is already written.
run = args.paneSpawnOperation?.mustExist
? { outcome: undefined, result: await start('exists') }
: await startInAdmittedPane(
store,
admissionFor(args),
hostIdOf(args),
start,
startStatus?.keepOnFailure
)
} catch (error) {
// A pane that stays (kept to show the failure, or one main already held) shows why; one
// closed while it started is gone, and one another owner won attaches to the winner.
if (
startStatus &&
admittedAs &&
!(error instanceof PaneOwnerContentionError) &&
(startStatus.keepOnFailure || admittedAs === 'exists') &&
paneStillWritten(store, args)
) {
startStatus.sink.set(paneKey, paneStartFailure(error))
} else {
startStatus?.sink.clear(paneKey)
}
throw error
}
startStatus?.sink.clear(paneKey)
const { outcome, result } = run
const started = result.agentSessionEnsure?.owner.surface ?? result.stablePaneOwner
const elsewhere = started && (started.tabId !== args.tabId || started.leafId !== args.leafId)
if (outcome === 'admitted' && elsewhere) {
@@ -7,6 +7,7 @@ import { PaneStartStatuses, paneStartFailure } from '../../pane-start-statuses'
import type { WindowLayoutCommand } from '../../runtime-layout-apply'
import { allocateTerminalPaneIdentity } from '../../runtime-terminal-pane-identity'
import { OrcaRuntimeWithCreateTerminal } from './terminal-create'
import { PaneOwnerContentionError } from '../../../ipc/pty/pane/pane-spawn-operation'
export type FailedStartPane = { tabId: string; leafId: string }
@@ -81,6 +82,10 @@ export abstract class OrcaRuntimeWithFailedStartPanes extends OrcaRuntimeWithCre
{ title }: dependencies.TerminalCreateOptions,
error: unknown
): Promise<void> {
if (error instanceof PaneOwnerContentionError) {
// Another owner's terminal runs in the pane; the window attaches to it.
return
}
const workspace = await this.resolveTerminalWorkspaceLaunchScope(worktreeSelector, created)
const create = { type: 'createTerminalTab' as const, workspace: workspace.id, ...pane }
const reply = await this.applyLayoutCommand(title ? { ...create, title } : create)
@@ -0,0 +1,155 @@
// Layout-owner build: a split of a pane in main's layout is written and started by main, under the
// caller's proposed pane id; every view, the window's included, attaches to the terminal it starts.
import { OrcaRuntimeWithSplitPtyBackedTerminal } from './terminal-split-surface'
import type { RuntimeTerminalSplit } from '../../../../shared/runtime-types'
import { parseExecutionHostId } from '../../../../shared/execution-host'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { recordPtySurface, spawnSurfaceClaimSequence } from '../../pty-recorded-surface-topology'
import { runtimeSplitPlacement } from '../../runtime-terminal-spawn-placement'
import { spawnInAdmittedPane } from '../../runtime-pane-admission'
import { resolvePtyFirstSize, type PtyGridSize } from '../../../ipc/pty/pty-first-size'
import { isTerminalLeafId, makePaneKey } from '../../../../shared/stable-pane-id'
import { randomUUID } from 'node:crypto'
import { paneStartFailure } from '../../pane-start-statuses'
import { collectLeafIds } from '../../../../shared/terminal-pane-layout-tree'
export type SplitPaneSource = { worktreeId: string; tabId: string; leafId: string }
export type SplitPaneInMainOptions = {
direction: 'horizontal' | 'vertical'
/** The caller's id for the new pane; main refuses one another pane holds. */
newLeafId?: string
cwd?: string
initialSize?: PtyGridSize
command?: string
env?: Record<string, string>
envToDelete?: string[]
/** False for a split no one asked to see (an agent team's); phones keep their active tab. */
activate?: boolean
/** The window's own split keeps a pane whose start failed, showing why, as its spawn did. */
keepPaneOnStartFailure?: boolean
}
export abstract class OrcaRuntimeWithSplitPaneInMain extends OrcaRuntimeWithSplitPtyBackedTerminal {
/** Whether main's layout holds the tab, so main writes and starts its splits. */
protected ownsTabLayout(worktreeId: string, tabId: string): boolean {
const [hostId] = this.getWorkspaceSessionHostIdsForTab(worktreeId, tabId)
return Boolean(hostId && parseExecutionHostId(hostId)?.kind !== 'runtime')
}
/** A window split whose start fails before main could start it still shows, with why. */
private async keepSplitThatCannotStart(
source: SplitPaneSource,
opts: SplitPaneInMainOptions,
error: unknown
): Promise<never> {
if (opts.keepPaneOnStartFailure && opts.newLeafId && isTerminalLeafId(opts.newLeafId)) {
const reply = await this.applyLayoutCommand({
type: 'splitPane',
workspace: source.worktreeId,
tabId: source.tabId,
leafId: source.leafId,
direction: opts.direction,
newLeafId: opts.newLeafId
}).catch((writeError: unknown) => {
// The caller still hears why the start failed, not why showing it did.
console.warn('[terminal-split] could not keep a split that failed to start:', writeError)
return null
})
if (reply?.ok) {
this.paneStartStatuses?.set(
makePaneKey(source.tabId, opts.newLeafId),
paneStartFailure(error)
)
}
}
throw error
}
private holdsLeaf(worktreeId: string, leafId: string): boolean {
const layouts = this.getWorkspaceSessionForWorktree(worktreeId)?.terminalLayoutsByTabId ?? {}
return Object.values(layouts).some((layout) => collectLeafIds(layout?.root).includes(leafId))
}
/** `terminal.split` from a view that names its pane: main writes the split and starts it. */
async splitTerminalPane(
source: SplitPaneSource,
opts: SplitPaneInMainOptions
): Promise<RuntimeTerminalSplit> {
if (!ORCA_LAYOUT_OWNER_IS_MAIN || !this.ownsTabLayout(source.worktreeId, source.tabId)) {
// The window then splits it as before (a paired server's tab, or the window-owned build).
throw new Error('host_not_owned')
}
return this.splitPaneInMain(source, opts)
}
protected async splitPaneInMain(
source: SplitPaneSource,
opts: SplitPaneInMainOptions
): Promise<RuntimeTerminalSplit> {
const leafId =
opts.newLeafId && isTerminalLeafId(opts.newLeafId) ? opts.newLeafId : randomUUID()
// A named pane id another pane holds is refused: the start must not land in a live pane.
if (opts.newLeafId && this.holdsLeaf(source.worktreeId, leafId)) {
throw new Error('pane_exists')
}
const controller = this.ptyController
if (!controller?.spawn) {
return this.keepSplitThatCannotStart(source, opts, new Error('runtime_unavailable'))
}
const workspace = await this.resolveTerminalWorkspaceLaunchScope(
`id:${source.worktreeId}`
).catch((error: unknown) => this.keepSplitThatCannotStart(source, opts, error))
const preAllocatedHandle = this.createPreAllocatedTerminalHandle()
const result = await spawnInAdmittedPane(this.store, controller, {
...resolvePtyFirstSize({ pane: opts.initialSize }),
cwd: opts.cwd ?? workspace.path,
command: opts.command,
commandDelivery: 'provider',
env: this.buildTerminalWorkspaceEnv(workspace, {
...(workspace.connectionId ? {} : this.accounts.hostLaunchEnvFor(null)),
...opts.env
}),
envToDelete: opts.envToDelete,
connectionId: workspace.connectionId,
worktreeId: workspace.id,
preAllocatedHandle,
tabId: source.tabId,
leafId,
persistHostSessionBinding: true,
placement: runtimeSplitPlacement(source.leafId, opts.direction),
...this.paneStartStatusArgs(
opts.keepPaneOnStartFailure ? { keepPaneOnStartFailure: true } : {}
)
})
this.registerPreAllocatedHandleForPty(result.id, preAllocatedHandle)
if (result.wslDistro) {
this.preparePtyExecutionContext(result.id, result.wslDistro)
}
this.registerPty(result.id, workspace.id, workspace.connectionId)
const createdPty = this.getOrCreatePtyWorktreeRecord(result.id)
if (!createdPty) {
throw new Error('terminal_exited')
}
recordPtySurface(
createdPty,
source.tabId,
makePaneKey(source.tabId, leafId),
spawnSurfaceClaimSequence(this.graphSequence)
)
// Phones and session.tabs list the pane without a window's report, as the other splits do.
this.publishPtyBackedMobileSessionTerminal(workspace.id, createdPty, {
tabId: source.tabId,
leafId,
title: null,
activate: opts.activate !== false,
split: { splitFromLeafId: source.leafId, direction: opts.direction }
})
return {
handle: this.issuePtyHandle(createdPty),
tabId: source.tabId,
paneRuntimeId: -1,
leafId
}
}
}
@@ -1,95 +1,73 @@
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService, makePaneKey } from '../../orca-runtime-test-mocks.spec'
import { makePaneKey } from '../../orca-runtime-test-mocks.spec'
import {
HEADLESS_LEAF_ID,
TEST_WORKTREE_ID,
TEST_WORKTREE_PATH,
makeRuntimeStoreWithWorkspaceSession,
makeWorkspaceSessionWithHeadlessTerminal
} from '../../orca-runtime-test-fixtures.spec'
import { createMobileCreateTestNotifier } from '../../orca-runtime-test-scenario-builders.spec'
import { collectLayoutLeafIdsInOrder } from '../../../../shared/workspace-layout/terminal-pane-tree'
import {
clearPaneSpawnReservation,
makePaneSpawnReservationKey,
reservePaneSpawn
} from '../../../ipc/pty/pane/spawn-reservation'
import { ProvisioningRuntime } from '../../orca-runtime-layout-owner-start-fixture'
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
const TAB_ID = 'host-tab'
const NEW_LEAF_ID = '22222222-2222-4222-8222-222222222222'
function graph(leafIds: string[], unboundLeafIds: string[] = []) {
function graph() {
return {
tabs: [
{
tabId: TAB_ID,
worktreeId: TEST_WORKTREE_ID,
title: 'shell',
activeLeafId: leafIds[0]!,
activeLeafId: HEADLESS_LEAF_ID,
layout: null
}
],
leaves: [...leafIds, ...unboundLeafIds].map((leafId, index) => ({
tabId: TAB_ID,
worktreeId: TEST_WORKTREE_ID,
leafId,
paneRuntimeId: index + 1,
ptyId: index < leafIds.length ? `pty-${index}` : null,
paneTitle: null
}))
}
}
type RuntimeInternals = {
recordPtyWorktree: (
ptyId: string,
worktreeId: string,
opts: { connected: boolean; tabId: string; paneKey: string }
) => void
}
function internalsOf(runtime: OrcaRuntimeService): RuntimeInternals {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: recordPtyWorktree is declared on OrcaRuntimeService's inheritance chain.
return runtime as unknown as RuntimeInternals
}
function graphWithSplit(newLeafId: string) {
const next = graph([HEADLESS_LEAF_ID, newLeafId])
return {
...next,
tabs: next.tabs.map((tab) => ({
...tab,
layout: {
type: 'split' as const,
direction: 'vertical' as const,
ratio: 0.5,
first: { type: 'leaf' as const, leafId: HEADLESS_LEAF_ID },
second: { type: 'leaf' as const, leafId: newLeafId }
leaves: [
{
tabId: TAB_ID,
worktreeId: TEST_WORKTREE_ID,
leafId: HEADLESS_LEAF_ID,
paneRuntimeId: 1,
ptyId: 'pty-0',
paneTitle: null
}
}))
]
}
}
describe('a split of a pane the window draws, in a layout-owner build', () => {
function setup() {
const store = makeRuntimeStoreWithWorkspaceSession(makeWorkspaceSessionWithHeadlessTerminal())
// The production store's write notice, which the fixture lacks.
const written = new Set<() => void>()
const setSession = store.runtimeStore.setWorkspaceSession.getMockImplementation()!
store.runtimeStore.setWorkspaceSession.mockImplementation((session, hostId) => {
setSession(session, hostId)
for (const listener of written) {
listener()
}
})
Reflect.set(store.runtimeStore, 'onWorkspaceSessionWritten', (listener: () => void) => {
written.add(listener)
return () => written.delete(listener)
})
const notifier = { ...createMobileCreateTestNotifier(vi.fn()), closeTerminalPane: vi.fn() }
const runtime = new OrcaRuntimeService(store.runtimeStore)
const runtime = new ProvisioningRuntime(store.runtimeStore)
runtime.setNotifier(notifier)
runtime.syncWindowGraph(1, graph([HEADLESS_LEAF_ID]))
Object.assign(runtime, {
resolveTerminalWorkspaceLaunchScope: vi.fn(async () => ({
id: TEST_WORKTREE_ID,
path: TEST_WORKTREE_PATH,
connectionId: null,
repo: null,
folderWorkspace: null
}))
})
const statusesSeen: unknown[] = []
const spawn = vi.fn(async (args: { tabId?: string; leafId?: string }) => {
statusesSeen.push(runtime.startStatusOf(`${args.tabId}:${args.leafId}`))
return { id: `pty-split-${spawn.mock.calls.length}` }
})
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
getCwd: async () => '/repo/src'
})
runtime.syncWindowGraph(1, graph())
const handle = runtime.getTerminalHandleForPaneKey(makePaneKey(TAB_ID, HEADLESS_LEAF_ID))
const leafIds = () =>
collectLayoutLeafIdsInOrder(store.getSession().terminalLayoutsByTabId[TAB_ID]!.root!)
@@ -97,168 +75,123 @@ describe('a split of a pane the window draws, in a layout-owner build', () => {
...store,
notifier,
runtime,
spawn,
statusesSeen,
handle: handle!,
splitTerminal: vi.mocked(notifier.splitTerminal),
leafIds
}
}
it('writes the new pane in main before the window mounts it, and takes it back when its terminal never starts', async () => {
const { runtime, handle, splitTerminal, leafIds, notifier } = setup()
it('main writes the pane and starts its terminal there, in the source pane’s folder; the window is not asked', async () => {
const { runtime, handle, splitTerminal, leafIds, spawn, statusesSeen } = setup()
vi.useFakeTimers()
try {
const split = runtime.splitTerminal(handle, { direction: 'vertical', command: 'make test' })
const rejected = expect(split).rejects.toThrow(
/^split_pane_not_started: .*did not start within 10 s.*closed/
const split = await runtime.splitTerminal(handle, {
direction: 'vertical',
command: 'make test'
})
expect(splitTerminal).not.toHaveBeenCalled()
expect(leafIds()).toEqual([HEADLESS_LEAF_ID, split.leafId])
expect(spawn).toHaveBeenCalledTimes(1)
expect(spawn.mock.calls[0]![0]).toMatchObject({
tabId: TAB_ID,
leafId: split.leafId,
cwd: '/repo/src',
command: 'make test',
cols: 120,
rows: 40
})
// Views see the pane as starting while it starts, so none starts its own; then it clears.
expect(statusesSeen).toEqual([{ status: 'starting' }])
expect(runtime.startStatusOf(makePaneKey(TAB_ID, split.leafId ?? ''))).toBeNull()
expect(split.tabId).toBe(TAB_ID)
})
it('lists the new pane to phones and session.tabs with no window report', async () => {
const { runtime, handle } = setup()
// A phone already lists the tab, so the new pane must be published, not just hydrated.
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
const split = await runtime.splitTerminal(handle, { direction: 'vertical' })
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(
listed.tabs.some(
(tab) =>
tab.type === 'terminal' && tab.parentTabId === TAB_ID && tab.leafId === split.leafId
)
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(1))
const notice = splitTerminal.mock.calls[0]![2]!
expect(notice).toMatchObject({ command: 'make test', sourceLeafId: HEADLESS_LEAF_ID })
expect(leafIds()).toEqual([HEADLESS_LEAF_ID, notice.newLeafId])
// Its terminal never starts: main takes the pane back, and the window drops it too.
await vi.advanceTimersByTimeAsync(11_000)
await rejected
expect(leafIds()).toEqual([HEADLESS_LEAF_ID])
expect(notifier.closeTerminalPane).toHaveBeenCalledWith(TAB_ID, notice.newLeafId)
} finally {
vi.useRealTimers()
}
).toBe(true)
})
it('keeps a pane whose terminal bound before the wait ran out, though the window reported it late', async () => {
const { runtime, runtimeStore, handle, splitTerminal, leafIds, notifier } = setup()
it('a split no one asked to see leaves the phones’ active tab alone; one asked for takes it', async () => {
const { runtime, handle } = setup()
const listed = () => runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
const before = (await listed()).activeTabId
vi.useFakeTimers()
try {
const startedAt = Date.now()
const split = runtime.splitTerminal(handle, { direction: 'vertical' })
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(1))
const newLeafId = splitTerminal.mock.calls[0]![2]!.newLeafId!
await vi.advanceTimersByTimeAsync(9_900 - (Date.now() - startedAt))
// Main binds the started terminal at 9.9 s; the busy window's report lands after 10 s.
await runtimeStore.persistPtyBinding({
worktreeId: TEST_WORKTREE_ID,
tabId: TAB_ID,
leafId: newLeafId,
ptyId: 'pty-1'
})
await vi.advanceTimersByTimeAsync(600)
internalsOf(runtime).recordPtyWorktree('pty-1', TEST_WORKTREE_ID, {
connected: true,
tabId: TAB_ID,
paneKey: makePaneKey(TAB_ID, newLeafId)
})
runtime.syncWindowGraph(1, graphWithSplit(newLeafId))
await vi.advanceTimersByTimeAsync(0)
await expect(split).resolves.toMatchObject({ tabId: TAB_ID, leafId: newLeafId })
expect(leafIds()).toEqual([HEADLESS_LEAF_ID, newLeafId])
expect(notifier.closeTerminalPane).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
await runtime.splitTerminal(handle, { direction: 'vertical', activate: false })
expect((await listed()).activeTabId).toBe(before)
const shown = await runtime.splitTerminal(handle, { direction: 'horizontal' })
expect((await listed()).activeTabId).toBe(`${TAB_ID}::${shown.leafId}`)
})
it('keeps the pane of a start still under way when the wait runs out, with the plain timeout', async () => {
const { runtime, handle, splitTerminal, leafIds, notifier } = setup()
const reservations: (() => void)[] = []
it('the window’s own split keeps its proposed pane id, folder and size', async () => {
const { runtime, leafIds, spawn } = setup()
vi.useFakeTimers()
try {
const split = runtime.splitTerminal(handle, { direction: 'vertical' })
const rejected = expect(split).rejects.toThrow(/^Timed out waiting for split pane handle$/)
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(1))
const newLeafId = splitTerminal.mock.calls[0]![2]!.newLeafId!
// A slow (SSH-style) start of the pane's terminal is under way and has not bound yet.
const reservationKey = makePaneSpawnReservationKey(
TEST_WORKTREE_ID,
'ssh-1',
makePaneKey(TAB_ID, newLeafId)
)!
const reservation = reservePaneSpawn(reservationKey)
reservations.push(() => clearPaneSpawnReservation(reservationKey, reservation))
const split = await runtime.splitTerminalPane(
{ worktreeId: TEST_WORKTREE_ID, tabId: TAB_ID, leafId: HEADLESS_LEAF_ID },
{
direction: 'horizontal',
newLeafId: NEW_LEAF_ID,
cwd: TEST_WORKTREE_PATH,
initialSize: { cols: 90, rows: 20 },
keepPaneOnStartFailure: true
}
)
await vi.advanceTimersByTimeAsync(11_000)
await rejected
expect(leafIds()).toEqual([HEADLESS_LEAF_ID, newLeafId])
expect(notifier.closeTerminalPane).not.toHaveBeenCalled()
} finally {
reservations.forEach((release) => release())
vi.useRealTimers()
}
expect(split.leafId).toBe(NEW_LEAF_ID)
expect(leafIds()).toEqual([HEADLESS_LEAF_ID, NEW_LEAF_ID])
expect(spawn.mock.calls[0]![0]).toMatchObject({
leafId: NEW_LEAF_ID,
cwd: TEST_WORKTREE_PATH,
cols: 90,
rows: 20
})
})
it('closes a CLI split before its terminal starts, and the CLI hears so at once', async () => {
const { runtime, handle, splitTerminal, leafIds } = setup()
it('a window split whose terminal fails to start keeps its pane, showing why', async () => {
const { runtime, leafIds, spawn } = setup()
spawn.mockRejectedValueOnce(new Error('shell not found'))
vi.useFakeTimers()
try {
const split = runtime.splitTerminal(handle, { direction: 'vertical' })
const rejected = expect(split).rejects.toThrow('split_pane_closed')
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(1))
const newLeafId = splitTerminal.mock.calls[0]![2]!.newLeafId!
await expect(
runtime.splitTerminalPane(
{ worktreeId: TEST_WORKTREE_ID, tabId: TAB_ID, leafId: HEADLESS_LEAF_ID },
{ direction: 'vertical', newLeafId: NEW_LEAF_ID, keepPaneOnStartFailure: true }
)
).rejects.toThrow('shell not found')
await runtime.closeTerminalSurfaceFromRenderer({
worktreeId: TEST_WORKTREE_ID,
target: { kind: 'pane', tabId: TAB_ID, leafId: newLeafId },
reason: 'user'
})
expect(leafIds()).toEqual([HEADLESS_LEAF_ID])
// Settled by the close, well before the 10 s wait.
await vi.advanceTimersByTimeAsync(0)
await rejected
await vi.advanceTimersByTimeAsync(11_000)
expect(leafIds()).toEqual([HEADLESS_LEAF_ID])
} finally {
vi.useRealTimers()
}
expect(leafIds()).toEqual([HEADLESS_LEAF_ID, NEW_LEAF_ID])
expect(runtime.startStatusOf(makePaneKey(TAB_ID, NEW_LEAF_ID))).toEqual({
status: 'failed',
reason: 'shell not found'
})
})
it('settles a CLI split whose tab main closes before its terminal starts', async () => {
const { runtime, handle, splitTerminal, getSession } = setup()
it('a CLI split whose terminal fails to start leaves no pane, and the CLI hears why', async () => {
const { runtime, handle, leafIds, spawn, notifier } = setup()
spawn.mockRejectedValueOnce(new Error('shell not found'))
vi.useFakeTimers()
try {
const split = runtime.splitTerminal(handle, { direction: 'vertical' })
const rejected = expect(split).rejects.toThrow('split_pane_closed')
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(1))
await expect(runtime.splitTerminal(handle, { direction: 'vertical' })).rejects.toThrow(
'shell not found'
)
await runtime.closeTerminalSurfaceFromRenderer({
worktreeId: TEST_WORKTREE_ID,
target: { kind: 'tab', tabId: TAB_ID },
reason: 'user'
})
expect(getSession().terminalLayoutsByTabId[TAB_ID]).toBeUndefined()
await vi.advanceTimersByTimeAsync(0)
await rejected
} finally {
vi.useRealTimers()
}
expect(leafIds()).toEqual([HEADLESS_LEAF_ID])
expect(notifier.closeTerminalPane).not.toHaveBeenCalled()
})
it('keeps waiting through writes that keep the pane', async () => {
const { runtime, runtimeStore, handle, splitTerminal } = setup()
vi.useFakeTimers()
try {
const split = runtime.splitTerminal(handle, { direction: 'vertical' })
const rejected = expect(split).rejects.toThrow('split_pane_not_started')
await vi.waitFor(() => expect(splitTerminal).toHaveBeenCalledTimes(1))
// An unrelated write while the pane waits for its terminal.
runtimeStore.setWorkspaceSession(runtimeStore.getWorkspaceSession())
await vi.advanceTimersByTimeAsync(11_000)
await rejected
} finally {
vi.useRealTimers()
}
})
it('splits nothing when main cannot write the pane', async () => {
const { runtime, runtimeStore, handle, splitTerminal, leafIds } = setup()
it('starts nothing when main cannot write the pane', async () => {
const { runtime, runtimeStore, handle, splitTerminal, leafIds, spawn } = setup()
Reflect.set(
runtimeStore,
'admitTerminalPane',
@@ -266,9 +199,118 @@ describe('a split of a pane the window draws, in a layout-owner build', () => {
)
await expect(runtime.splitTerminal(handle, { direction: 'vertical' })).rejects.toThrow(
'split_not_written: pane_not_found'
'terminal_split_source_not_found'
)
expect(spawn).not.toHaveBeenCalled()
expect(splitTerminal).not.toHaveBeenCalled()
expect(leafIds()).toEqual([HEADLESS_LEAF_ID])
})
it('starts nothing for a pane-named split of a tab main does not hold', async () => {
const { runtime, spawn } = setup()
await expect(
runtime.splitTerminalPane(
{ worktreeId: TEST_WORKTREE_ID, tabId: 'tab-elsewhere', leafId: HEADLESS_LEAF_ID },
{ direction: 'vertical' }
)
).rejects.toThrow('terminal_split_source_not_found')
expect(spawn).not.toHaveBeenCalled()
})
// The real spawn commit refuses a pane that left the layout while it started (spawn-commit.ts).
function closeWhileStarting(setupResult: ReturnType<typeof setup>, close: () => Promise<void>) {
const { spawn, getSession } = setupResult
spawn.mockImplementationOnce(async (args: { tabId?: string; leafId?: string }) => {
await close()
const root = getSession().terminalLayoutsByTabId[TAB_ID]?.root
if (!root || !collectLayoutLeafIdsInOrder(root).includes(args.leafId!)) {
throw new Error('terminal_split_source_not_found')
}
return { id: 'pty-late' }
})
}
it.each([
['its pane', { kind: 'pane', tabId: TAB_ID, leafId: NEW_LEAF_ID }],
['its tab', { kind: 'tab', tabId: TAB_ID }]
] as const)(
'answers a split once, and leaves nothing, when the user closes %s while it starts',
async (_, target) => {
const context = setup()
const { runtime, getSession } = context
closeWhileStarting(context, () =>
runtime.closeTerminalSurfaceFromRenderer({
worktreeId: TEST_WORKTREE_ID,
target,
reason: 'user'
})
)
await expect(
runtime.splitTerminalPane(
{ worktreeId: TEST_WORKTREE_ID, tabId: TAB_ID, leafId: HEADLESS_LEAF_ID },
{ direction: 'vertical', newLeafId: NEW_LEAF_ID, keepPaneOnStartFailure: true }
)
).rejects.toThrow('terminal_split_source_not_found')
const root = getSession().terminalLayoutsByTabId[TAB_ID]?.root
expect(root ? collectLayoutLeafIdsInOrder(root) : []).not.toContain(NEW_LEAF_ID)
// Nothing is left showing a start for a pane that is gone.
expect(runtime.startStatusOf(makePaneKey(TAB_ID, NEW_LEAF_ID))).toBeNull()
}
)
it('refuses a named pane id another pane already holds, starting nothing', async () => {
const { runtime, spawn } = setup()
await expect(
runtime.splitTerminalPane(
{ worktreeId: TEST_WORKTREE_ID, tabId: TAB_ID, leafId: HEADLESS_LEAF_ID },
{ direction: 'vertical', newLeafId: HEADLESS_LEAF_ID }
)
).rejects.toThrow('pane_exists')
expect(spawn).not.toHaveBeenCalled()
expect(runtime.startStatusOf(makePaneKey(TAB_ID, HEADLESS_LEAF_ID))).toBeNull()
})
it('a window split main cannot start at all still shows its pane, failed, with why', async () => {
const { runtime, leafIds } = setup()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
await expect(
runtime.splitTerminalPane(
{ worktreeId: TEST_WORKTREE_ID, tabId: TAB_ID, leafId: HEADLESS_LEAF_ID },
{ direction: 'vertical', newLeafId: NEW_LEAF_ID, keepPaneOnStartFailure: true }
)
).rejects.toThrow('runtime_unavailable')
expect(leafIds()).toEqual([HEADLESS_LEAF_ID, NEW_LEAF_ID])
expect(runtime.startStatusOf(makePaneKey(TAB_ID, NEW_LEAF_ID))).toEqual({
status: 'failed',
reason: 'runtime_unavailable'
})
})
it('refuses a taken pane id before anything else, even when main cannot start', async () => {
const { runtime } = setup()
runtime.setPtyController({
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
})
await expect(
runtime.splitTerminalPane(
{ worktreeId: TEST_WORKTREE_ID, tabId: TAB_ID, leafId: HEADLESS_LEAF_ID },
{ direction: 'vertical', newLeafId: HEADLESS_LEAF_ID, keepPaneOnStartFailure: true }
)
).rejects.toThrow('pane_exists')
// The live pane is not marked as a failed start.
expect(runtime.startStatusOf(makePaneKey(TAB_ID, HEADLESS_LEAF_ID))).toBeNull()
})
})
@@ -1,5 +1,5 @@
import type { OrcaRuntimeWithPruneMobileSessionTabGroupLayout } from '../../session-lifecycle/projection/prune-mobile-session-tab-group-layout'
import { OrcaRuntimeWithSplitPtyBackedTerminal } from './terminal-split-surface'
import { OrcaRuntimeWithSplitPaneInMain } from './terminal-split-in-main'
import { resolveTerminalSessionWorktreeId } from '../../runtime-worktree-path-identity'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { terminalLayoutContainsLeaf } from '../../../../shared/workspace-session-pane-ownership'
@@ -15,7 +15,7 @@ import { applyClaudeEnvPatch } from '../../../claude-accounts/environment'
import { worktreeIdsEqual } from '../../../../shared/worktree/id'
import { SPLIT_PANE_WAIT_TIMEOUT_MESSAGE } from './terminal-split-wait-timeout'
export abstract class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRuntimeWithSplitPtyBackedTerminal {
export abstract class OrcaRuntimeWithResolveTerminalSplitSourceAuthority extends OrcaRuntimeWithSplitPaneInMain {
abstract getTerminalHandleForPaneKey(
...args: Parameters<
OrcaRuntimeWithPruneMobileSessionTabGroupLayout['getTerminalHandleForPaneKey']
@@ -81,6 +81,8 @@ export abstract class OrcaRuntimeWithSplitPtyBackedTerminal extends OrcaRuntimeW
leafId,
persistHostSessionBinding: true,
placement: runtimeSplitPlacement(parsedPaneKey.leafId, direction),
// Layout-owner build: views mounting the pane from main's frame attach, never spawn.
...this.paneStartStatusArgs({}),
...(sourceAuthority.persisted
? {
expectedSourceBinding: {
@@ -1,20 +1,12 @@
import type { TerminalSplitOptions } from './terminal-split-surface'
import type { SplitPaneInMainOptions, SplitPaneSource } from './terminal-split-in-main'
import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records'
import { OrcaRuntimeWithStopExplicitlyClosedTabPtys } from '../../session-tabs/explicit-terminal-close-stops'
import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry'
import type { RuntimeTerminalSplit } from '../../../../shared/runtime-types'
import { randomUUID } from 'node:crypto'
import { parseExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { admitRuntimePane, withdrawAdmittedPane } from '../../runtime-pane-admission'
import type { RuntimeStore } from '../../runtime-store-contract'
import type { Worktree } from '../../../../shared/worktree/types'
import { collectLeafIds } from '../../../../shared/terminal-pane-layout-tree'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { isPaneSpawnInFlight } from '../../../ipc/pty/pane/spawn-reservation'
import { SPLIT_PANE_WAIT_TIMEOUT_MESSAGE } from './terminal-split-wait-timeout'
const SPLIT_PANE_START_TIMEOUT_MS = 10_000
export abstract class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopExplicitlyClosedTabPtys {
protected abstract splitPtyBackedTerminal(
@@ -23,6 +15,13 @@ export abstract class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopEx
createdWorktree?: Worktree
): Promise<RuntimeTerminalSplit>
protected abstract ownsTabLayout(worktreeId: string, tabId: string): boolean
protected abstract splitPaneInMain(
source: SplitPaneSource,
opts: SplitPaneInMainOptions
): Promise<RuntimeTerminalSplit>
protected abstract waitForLeafInTab(
tabId: string,
leafId: string,
@@ -52,92 +51,22 @@ export abstract class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopEx
this.assertGraphReady()
const { leaf } = this.getLiveLeafForHandle(handle)
const direction = opts.direction ?? 'horizontal'
// Switch point: main writes the split first; the window then mounts main's pane.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
const [hostId] = this.getWorkspaceSessionHostIdsForTab(leaf.worktreeId, leaf.tabId)
if (hostId && parseExecutionHostId(hostId)?.kind !== 'runtime') {
const newLeafId = randomUUID()
// No pane written, no split: nothing else would put it in main's copy. A pane whose
// terminal never starts is taken back below, so the CLI's failure is the truth.
const outcome = await admitRuntimePane(
this.store,
{
type: 'splitPane',
workspace: leaf.worktreeId,
tabId: leaf.tabId,
leafId: leaf.leafId,
direction,
newLeafId
},
hostId
)
if (outcome !== 'admitted' && outcome !== 'exists') {
throw new Error(`split_not_written: ${outcome ?? 'unknown'}`)
}
this.notifier?.splitTerminal(leaf.tabId, leaf.paneRuntimeId, {
// Switch point: main writes the split and starts its terminal; the window attaches to it.
if (ORCA_LAYOUT_OWNER_IS_MAIN && this.ownsTabLayout(leaf.worktreeId, leaf.tabId)) {
const cwd = leaf.ptyId
? await this.ptyController?.getCwd?.(leaf.ptyId).catch(() => null)
: null
return this.splitPaneInMain(
{ worktreeId: leaf.worktreeId, tabId: leaf.tabId, leafId: leaf.leafId },
{
direction,
...(cwd ? { cwd } : {}),
command: opts.command,
worktreeId: leaf.worktreeId,
sourceLeafId: leaf.leafId,
telemetrySource: opts.telemetrySource,
newLeafId
})
const newHandle = await settledByPaneClose(
this.store,
() => this.getWorkspaceSessionHostIdsForTab(leaf.worktreeId, leaf.tabId),
leaf.tabId,
newLeafId,
this.waitForLeafInTab(leaf.tabId, newLeafId, SPLIT_PANE_START_TIMEOUT_MS)
).catch(async (error: unknown) => {
if (
outcome !== 'admitted' ||
!(error instanceof Error) ||
error.message !== SPLIT_PANE_WAIT_TIMEOUT_MESSAGE
) {
throw error
}
const taken = await withdrawAdmittedPane(
this.store,
{
type: 'splitPane',
workspace: leaf.worktreeId,
tabId: leaf.tabId,
leafId: leaf.leafId,
direction,
newLeafId
},
hostId,
// Read in the write queue: a start under way (a slow SSH host) keeps its pane.
() => isPaneSpawnInFlight(leaf.worktreeId, makePaneKey(leaf.tabId, newLeafId))
).catch((withdrawError: unknown) => {
console.warn('[layout] an unstarted split pane stayed in main:', withdrawError)
return 'threw' as const
})
if (taken === 'bound') {
// Bound just before the take-back: the window's late report brings its handle.
return settledByPaneClose(
this.store,
() => this.getWorkspaceSessionHostIdsForTab(leaf.worktreeId, leaf.tabId),
leaf.tabId,
newLeafId,
this.waitForLeafInTab(leaf.tabId, newLeafId, SPLIT_PANE_START_TIMEOUT_MS)
)
}
if (taken !== null) {
throw error
}
this.notifier?.closeTerminalPane?.(leaf.tabId, newLeafId)
throw new Error(
`split_pane_not_started: the new pane's terminal did not start within ${SPLIT_PANE_START_TIMEOUT_MS / 1000} s, so the pane was closed${opts.command ? ' and its command was not run' : ''}`
)
})
return {
handle: newHandle,
tabId: leaf.tabId,
paneRuntimeId: leaf.paneRuntimeId,
leafId: newLeafId
env: opts.env,
envToDelete: opts.envToDelete,
activate: opts.activate
}
}
)
}
const newLeafId = randomUUID()
@@ -160,31 +89,3 @@ export abstract class OrcaRuntimeWithSplitTerminal extends OrcaRuntimeWithStopEx
}
}
}
/** Main's own close of the pane settles the wait at once: no terminal will start there. */
function settledByPaneClose(
store: RuntimeStore | null | undefined,
hostIds: () => readonly ExecutionHostId[],
tabId: string,
leafId: string,
waiting: Promise<string>
): Promise<string> {
if (!store?.getWorkspaceSession || !store.onWorkspaceSessionWritten) {
return waiting
}
const getSession = store.getWorkspaceSession.bind(store)
let stopWatching: (() => void) | undefined
const closed = new Promise<never>((_resolve, reject) => {
const check = (): void => {
const held = hostIds().some((hostId) =>
collectLeafIds(getSession(hostId)?.terminalLayoutsByTabId?.[tabId]?.root).includes(leafId)
)
if (!held) {
reject(new Error('split_pane_closed'))
}
}
stopWatching = store.onWorkspaceSessionWritten?.(check)
check()
})
return Promise.race([waiting, closed]).finally(() => stopWatching?.())
}
@@ -3,6 +3,7 @@
import type { RuntimePtyWorktreeRecord } from '../../runtime-terminal-state-records'
import type { TerminalRunFactsRegister } from '../../terminal-run-facts'
import { UNVERIFIED_PROCESS_EXIT_CODE } from '../../../../shared/terminal-exit-cause'
export type ExitedSplitPaneFacts = {
exitCode: number
@@ -48,7 +49,8 @@ export function exitedSplitPaneFacts(
): ExitedSplitPaneFacts {
const run = runs.read(ptyId, pty?.incarnationId)
return {
exitCode,
// node-pty can report no code at all (its socket closed): that is lost contact, not a finish.
exitCode: Number.isFinite(exitCode) ? exitCode : UNVERIFIED_PROCESS_EXIT_CODE,
freshSpawn: run.freshSpawn,
inputSeen: run.firstUserInputAt !== null || runs.readLastInputAt(ptyId) !== null,
outputSeen: pty?.lastOutputAt != null,
@@ -1,4 +1,5 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { CLOSE_TERMINAL_PANE_EVENT } from '@/constants/terminal'
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
@@ -7,7 +8,6 @@ import { applyClosedTerminalLeafNotice } from './closed-terminal-leaf-notice'
import {
_resetTerminalPaneSplitRequestRoutingForTests,
hasTerminalPaneSplitMountLease,
queueTerminalPaneSplitRequest,
takeQueuedTerminalPaneSplitRequests
} from './terminal-pane-split-request-routing'
@@ -16,22 +16,26 @@ afterEach(() => {
vi.unstubAllGlobals()
})
describe('a pane main closed while its split waited for the tab to mount', () => {
it('drops the waiting split, so the tab mounts without the closed pane', () => {
vi.stubGlobal('window', new EventTarget())
queueTerminalPaneSplitRequest({
tabId: 'tab-1',
worktreeId: 'wt-1',
paneRuntimeId: 1,
sourceLeafId: '11111111-1111-4111-8111-111111111111',
direction: 'vertical',
newLeafId: '22222222-2222-4222-8222-222222222222',
command: 'make test'
describe('a pane main closed before its tab mounted, in a layout-owner build', () => {
// Main writes and starts every split itself, so no split waits in the window to mount the pane:
// the tab mounts from main's layout, which no longer holds it.
it('leaves nothing waiting to mount it, and drops it from a mounted tab as before', () => {
const target = new EventTarget()
vi.stubGlobal('window', target)
const closed = vi.fn()
target.addEventListener(CLOSE_TERMINAL_PANE_EVENT, (event) => {
if (event instanceof CustomEvent) {
closed(event.detail)
}
})
applyClosedTerminalLeafNotice('tab-1', '22222222-2222-4222-8222-222222222222')
expect(takeQueuedTerminalPaneSplitRequests('tab-1', 'wt-1')).toEqual([])
expect(hasTerminalPaneSplitMountLease('tab-1')).toBe(false)
expect(closed).toHaveBeenCalledWith({
tabId: 'tab-1',
leafId: '22222222-2222-4222-8222-222222222222'
})
})
})
@@ -1,18 +1,12 @@
import { CLOSE_TERMINAL_PANE_EVENT, type CloseTerminalPaneDetail } from '@/constants/terminal'
import { collapseParkedTerminalLeaf } from './terminal-parked-pty-watcher'
import { isTerminalTabParked } from './terminal-parked-watcher-registry'
import { cancelQueuedTerminalPaneSplitForLeaf } from './terminal-pane-split-request-routing'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
/**
* Drops a split pane main already closed. Addressed by leaf, so it is a no-op once the pane's own
* exit handling removed it, and the exit is a no-op after it: whichever lands first wins.
*/
export function applyClosedTerminalLeafNotice(tabId: string, leafId: string): void {
// Switch point: main wrote a split's pane before its tab mounted; closed meanwhile, it mounts never.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
cancelQueuedTerminalPaneSplitForLeaf(tabId, leafId)
}
if (isTerminalTabParked(tabId)) {
collapseParkedTerminalLeaf(tabId, leafId)
return
@@ -357,6 +357,7 @@ describe('fish never receives a color-scheme report it did not query (#9993)', (
activeWorktreeId: 'wt-1',
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'fish-pty' }] },
ptyIdsByTabId: { 'tab-1': ['fish-pty'] },
terminalPaneStartsByPaneKey: {},
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_1 },
@@ -0,0 +1,182 @@
// Layout-owner build: a pane main is starting a terminal for never starts one of its own. It waits,
// then attaches to main's terminal; a start that failed shows why.
import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushAsyncTicks } from './pty-connection-test-async'
import {
createMockTransport,
createPane,
createManager,
leafIdForPane,
type MockTransport
} from './pty-connection-test-pane-fixtures'
import { buildPaneConnectionDeps } from './pty-connection-test-deps'
import { createInitialStoreState } from './pty-connection-test-store-fixtures'
import type { StoreState } from './pty-connection-test-store-state'
import type { TerminalPaneStart } from '@/store/terminals/terminal-state'
import {
installTerminalTestGlobals,
restoreTerminalTestGlobals
} from './pty-connection-test-environment'
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
const TAB = 'tab-main-start'
const PANE_KEY = `${TAB}:${leafIdForPane(1)}`
let mockStoreState: StoreState
let transportFactoryQueue: MockTransport[] = []
let storeSubscribers: ((state: StoreState) => void)[] = []
function setStore(patch: Partial<StoreState>): void {
mockStoreState = { ...mockStoreState, ...patch }
for (const listener of storeSubscribers) {
listener(mockStoreState)
}
}
async function connectWithStart(start: TerminalPaneStart | undefined) {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
setStore({
tabsByWorktree: { 'wt-1': [{ id: TAB, ptyId: null }] },
terminalPaneStartsByPaneKey: start ? { [PANE_KEY]: start } : {}
})
const deps = buildPaneConnectionDeps(() => mockStoreState, { tabId: TAB })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads.
const args = [createPane(1), createManager(1), deps] as unknown as Parameters<
typeof connectPanePty
>
connectPanePty(...args)
await flushAsyncTicks()
return { transport, deps }
}
describe('a pane main is starting a terminal for', () => {
beforeEach(async () => {
vi.resetModules()
vi.clearAllMocks()
transportFactoryQueue = []
storeSubscribers = []
mockStoreState = createInitialStoreState(() => mockStoreState)
bindPtyConnectionTestMocks({
getState: () => mockStoreState,
getTransportQueue: () => transportFactoryQueue,
getTransportOptions: () => [],
getStoreSubscribers: () => storeSubscribers,
setStoreSubscribers: (subscribers) => {
storeSubscribers = subscribers
}
})
await installTerminalTestGlobals()
})
afterEach(async () => {
await restoreTerminalTestGlobals()
})
it('starts nothing while main starts it, then attaches to main’s terminal', async () => {
const { transport } = await connectWithStart({ status: 'starting' })
expect(transport.connect).not.toHaveBeenCalled()
expect(transport.attach).not.toHaveBeenCalled()
// Main's start ends as the pane binds to main's terminal.
const leafId = leafIdForPane(1)
setStore({
tabsByWorktree: { 'wt-1': [{ id: TAB, ptyId: 'pty-main' }] },
terminalLayoutsByTabId: {
[TAB]: {
root: { type: 'leaf', leafId },
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: 'pty-main' }
}
},
terminalPaneStartsByPaneKey: {}
})
await flushAsyncTicks()
// It attaches to main's terminal at the pane's own grid, and starts none of its own.
expect(transport.connect).toHaveBeenCalledTimes(1)
expect(transport.connect).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: 'pty-main', cols: 120, rows: 40 })
)
})
it('attaches a split pane to the terminal main bound it to, not its tab’s', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport()
transportFactoryQueue.push(transport)
const newLeaf = leafIdForPane(2)
setStore({
tabsByWorktree: { 'wt-1': [{ id: TAB, ptyId: 'pty-source' }] },
terminalPaneStartsByPaneKey: { [`${TAB}:${newLeaf}`]: { status: 'starting' } }
})
// The source pane already shows the tab's terminal.
const source = createMockTransport('pty-source')
const deps = buildPaneConnectionDeps(() => mockStoreState, {
tabId: TAB,
paneTransportsRef: { current: new Map([[1, source]]) }
})
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads.
const args = [createPane(2), createManager(2), deps] as unknown as Parameters<
typeof connectPanePty
>
connectPanePty(...args)
await flushAsyncTicks()
expect(transport.connect).not.toHaveBeenCalled()
setStore({
terminalLayoutsByTabId: {
[TAB]: {
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: leafIdForPane(1) },
second: { type: 'leaf', leafId: newLeaf }
},
activeLeafId: newLeaf,
expandedLeafId: null,
ptyIdsByLeafId: { [leafIdForPane(1)]: 'pty-source', [newLeaf]: 'pty-split' }
}
},
terminalPaneStartsByPaneKey: {}
})
await flushAsyncTicks()
expect(transport.connect).toHaveBeenCalledTimes(1)
expect(transport.connect).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: 'pty-split' })
)
})
it('shows why a failed start failed and starts nothing', async () => {
const { transport, deps } = await connectWithStart({
status: 'failed',
reason: 'agent runtime needs repair'
})
expect(transport.connect).not.toHaveBeenCalled()
expect(transport.attach).not.toHaveBeenCalled()
expect(deps.onPtyErrorRef.current).toHaveBeenCalledWith(
1,
'The terminal could not start: agent runtime needs repair'
)
})
it.each(['sleeping', 'resuming', 'held'] as const)(
'leaves a %s pane to its restore path, as before',
async (status) => {
const { transport } = await connectWithStart({ status })
expect(transport.connect.mock.calls.length + transport.attach.mock.calls.length).toBe(1)
}
)
it('starts a pane with no start from main as before', async () => {
const { transport } = await connectWithStart(undefined)
expect(transport.connect.mock.calls.length + transport.attach.mock.calls.length).toBe(1)
})
})
@@ -3,7 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { makePaneKey } from '../../../../shared/stable-pane-id'
import { sendTerminalInputThroughPane } from './pty-connection-test-dom'
import { flushAsyncTicks } from './pty-connection-test-async'
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { expectMainFrameClosesExitedSplitPaneOnce } from './pty-connection-test-exited-split-pane-close'
import {
LEAF_1,
LEAF_2,
@@ -260,12 +261,14 @@ export function registerPtyConnectionPtyExitTeardownTests(): void {
expect(onPtyExit).toBeTypeOf('function')
onPtyExit?.('pty-pane-2')
// A switch-on build closes it on main's answer.
await layoutApplyTestTransport.settle()
expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-pane-2')
expect(deps.onPtyExitRef.current).not.toHaveBeenCalled()
expect(manager.closePane).toHaveBeenCalledWith(2)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
await expectMainFrameClosesExitedSplitPaneOnce(manager)
} else {
expect(manager.closePane).toHaveBeenCalledWith(2)
}
})
it('keeps a worktree sole terminal mounted when its freshly-spawned PTY exits before input (direnv failure)', async () => {
@@ -763,10 +766,12 @@ export function registerPtyConnectionPtyExitTeardownTests(): void {
capturedDataCallback.current?.('shell prompt')
onPtyExit?.('pty-pane-2')
// A switch-on build closes it on main's answer.
await layoutApplyTestTransport.settle()
expect(manager.closePane).toHaveBeenCalledWith(2)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
await expectMainFrameClosesExitedSplitPaneOnce(manager)
} else {
expect(manager.closePane).toHaveBeenCalledWith(2)
}
})
it('removes the agent row when an established PTY exits through EOF (#12907)', async () => {
@@ -814,10 +819,12 @@ export function registerPtyConnectionPtyExitTeardownTests(): void {
terminalInputHandler?.('exit\r')
onPtyExit?.('pty-pane-2')
// A switch-on build closes it on main's answer.
await layoutApplyTestTransport.settle()
expect(manager.closePane).toHaveBeenCalledWith(2)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
await expectMainFrameClosesExitedSplitPaneOnce(manager)
} else {
expect(manager.closePane).toHaveBeenCalledWith(2)
}
})
})
}
@@ -1,5 +1,6 @@
import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec'
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { expectMainFrameClosesExitedSplitPaneOnce } from './pty-connection-test-exited-split-pane-close'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushAsyncTicks } from './pty-connection-test-async'
import { sendTerminalInputThroughPane } from './pty-connection-test-dom'
@@ -107,7 +108,12 @@ export function registerPtyConnectionSessionLivenessTests(): void {
await flushAsyncTicks()
expect(hasPty).toHaveBeenCalledWith('pty-pane-2')
expect(manager.closePane).toHaveBeenCalledWith(2)
// Switch on: the host's authoritative absence is a confirmed end; main closes, the frame shows it.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
await expectMainFrameClosesExitedSplitPaneOnce(manager)
} else {
expect(manager.closePane).toHaveBeenCalledWith(2)
}
})
it('does not close when targeted liveness is live or unknown', async () => {
@@ -229,7 +235,11 @@ export function registerPtyConnectionSessionLivenessTests(): void {
binding.reconcileIfSessionMissing(async () => false, afterSpawn + 1)
await flushAsyncTicks()
expect(manager.closePane).toHaveBeenCalledWith(2)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
await expectMainFrameClosesExitedSplitPaneOnce(manager)
} else {
expect(manager.closePane).toHaveBeenCalledWith(2)
}
})
it('routes the last pane through onPtyExitRef when its session is dead', async () => {
@@ -349,11 +359,13 @@ export function registerPtyConnectionSessionLivenessTests(): void {
// Restart rebinds the pane to a new live PTY; its later real exit must not be ignored by a stale guard.
transport.getPtyId.mockReturnValue('pty-pane-2-restarted')
onPtyExit?.('pty-pane-2-restarted')
// A switch-on build closes it on main's answer.
await layoutApplyTestTransport.settle()
expect(manager.closePane).toHaveBeenCalledTimes(1)
expect(manager.closePane).toHaveBeenCalledWith(2)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
await expectMainFrameClosesExitedSplitPaneOnce(manager)
} else {
expect(manager.closePane).toHaveBeenCalledTimes(1)
expect(manager.closePane).toHaveBeenCalledWith(2)
}
})
it('closes a split pane exactly once across reconcile + a racing real exit', async () => {
@@ -385,8 +397,14 @@ export function registerPtyConnectionSessionLivenessTests(): void {
// A racing real/synthetic pty:exit for the SAME id must not close twice.
onPtyExit?.('pty-pane-2')
expect(manager.closePane).toHaveBeenCalledTimes(1)
expect(manager.closePane).toHaveBeenCalledWith(2)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
// The racing exit must not re-run the teardown that registered main's close.
expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledTimes(1)
await expectMainFrameClosesExitedSplitPaneOnce(manager)
} else {
expect(manager.closePane).toHaveBeenCalledTimes(1)
expect(manager.closePane).toHaveBeenCalledWith(2)
}
})
it('closes a genuinely-dead non-suppressed pane once (not misread as suppressed)', async () => {
@@ -419,8 +437,13 @@ export function registerPtyConnectionSessionLivenessTests(): void {
onPtyExit?.('pty-pane-2')
// Observable outcome: single close, pane was treated as dead (closed).
expect(manager.closePane).toHaveBeenCalledTimes(1)
expect(manager.closePane).toHaveBeenCalledWith(2)
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledTimes(1)
await expectMainFrameClosesExitedSplitPaneOnce(manager)
} else {
expect(manager.closePane).toHaveBeenCalledTimes(1)
expect(manager.closePane).toHaveBeenCalledWith(2)
}
})
})
@@ -0,0 +1,42 @@
import { expect, vi } from 'vitest'
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
import { LEAF_1, LEAF_2, type MockPaneManager } from './pty-connection-test-pane-fixtures'
/**
* Switch on: tab-1's pane 2 ended, so the window leaves its close to main: it neither closes the
* pane nor sends a close, and main's frame without the leaf takes it once, as an exit.
* Imports are dynamic so they share the module graph the test's `import('./pty-connection')` used.
*/
export async function expectMainFrameClosesExitedSplitPaneOnce(
manager: MockPaneManager
): Promise<void> {
const { layoutApplyTestTransport } =
await import('@/runtime/layout-apply-test-transport.test-fixture')
const { reconcileMountedPanesToLayout } = await import('./terminal-pane-frame-reconciliation')
await layoutApplyTestTransport.settle()
expect(manager.closePane).not.toHaveBeenCalled()
expect(layoutApplyTestTransport.sentOfType('closePane')).toEqual([])
const frameLayout: TerminalLayoutSnapshot = {
root: { type: 'leaf', leafId: LEAF_1 },
activeLeafId: LEAF_1,
expandedLeafId: null
}
const closePane = vi.fn()
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the mock manager implements every PaneManager member the frame reconciler reads.
const framedManager = { ...manager, applyLayoutGeometry: vi.fn() } as unknown as Parameters<
typeof reconcileMountedPanesToLayout
>[0]['manager']
// A repeated frame (the stream re-sends main's tab) must not close it again.
for (let frame = 0; frame < 2; frame += 1) {
reconcileMountedPanesToLayout({
tabId: 'tab-1',
manager: framedManager,
layout: frameLayout,
closePane,
isExpanded: false
})
}
expect(closePane).toHaveBeenCalledTimes(1)
expect(closePane).toHaveBeenCalledWith(2, LEAF_2, 'exit')
}
@@ -12,6 +12,7 @@ export function createInitialStoreState(getState: () => StoreState): StoreState
ptyIdsByTabId: {
'tab-1': ['tab-pty']
},
terminalPaneStartsByPaneKey: {},
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_1 },
@@ -92,7 +93,7 @@ export function createInitialStoreState(getState: () => StoreState): StoreState
}),
markTerminalBell: vi.fn(),
markAgentAttentionUnread: vi.fn()
} as StoreState
}
}
export function buildReattachPaneTitleState(current: StoreState, title: string): StoreState {
@@ -104,7 +105,7 @@ export function buildReattachPaneTitleState(current: StoreState, title: string):
runtimePaneTitlesByTabId: {
'tab-1': { 1: title }
}
} as StoreState
}
}
// Why: activeRuntimeEnvironmentId exercises the remote-runtime path where the renderer still owns OSC 9999 status.
@@ -21,6 +21,7 @@ export type StoreState = {
>
ptyIdsByTabId?: Record<string, string[]>
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot>
terminalPaneStartsByPaneKey?: AppState['terminalPaneStartsByPaneKey']
/** Pty id → the agent its pane restarts as once the account change lands. */
pendingPaneAccountRestarts?: AppState['pendingPaneAccountRestarts']
unreadTerminalTabs?: Record<string, true>
@@ -0,0 +1,83 @@
// Layout-owner build: a pane main is starting a terminal for waits and then attaches to it, and a
// start that failed shows why. Neither starts a terminal of its own.
import { useAppStore } from '@/store'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../../shared/layout-owner-switch'
import { recordPtyConnectDiagnostic } from './pty-connect-limits'
import type { PaneStartStatus } from '../../../../../shared/workspace-layout/workspace-layout-published'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
import { resolveTerminalTabId } from './terminal-tab-id'
const waiting = new WeakSet<ConnectPanePtySession>()
/** The starts this gate covers; a sleeping, resuming or held pane keeps its restore path (C1c). */
const gated = (status: PaneStartStatus | undefined): boolean =>
status === 'starting' || status === 'failed'
/** Whether the pane waits on main's start; `connect` runs once main's start for it ends. */
export function waitsForMainPaneStart(
session: ConnectPanePtySession,
connect: () => void
): boolean {
if (!ORCA_LAYOUT_OWNER_IS_MAIN) {
return false
}
const start = useAppStore.getState().terminalPaneStartsByPaneKey[session.cacheKey]
if (!start || !gated(start.status)) {
return false
}
if (waiting.has(session)) {
return true
}
waiting.add(session)
recordPtyConnectDiagnostic(`pane=${session.pane.id} -> WAIT FOR MAIN START (${start.status})`)
let shown = start
const showFailure = (): void => {
if (shown.status === 'failed' && !session.disposed) {
session.deps.onPtyErrorRef?.current?.(
session.pane.id,
`The terminal could not start: ${shown.reason ?? 'unknown error'}`
)
}
}
showFailure()
const unsubscribe = useAppStore.subscribe((state) => {
const found = state.terminalPaneStartsByPaneKey[session.cacheKey]
const next = found && gated(found.status) ? found : undefined
if (next === shown) {
return
}
if (next) {
shown = next
showFailure()
return
}
// Main's start ended: the pane is bound to its terminal now, or main took it back.
adoptMainBinding(session, state)
unsubscribe()
waiting.delete(session)
const index = session.waitTeardowns.indexOf(unsubscribe)
if (index !== -1) {
session.waitTeardowns.splice(index, 1)
}
if (!session.disposed) {
connect()
}
})
session.waitTeardowns.push(unsubscribe)
return true
}
/** The terminal main bound the pane to, as the pane's restore id: the connect then attaches it. */
function adoptMainBinding(
session: ConnectPanePtySession,
state: ReturnType<typeof useAppStore.getState>
): void {
const tabId = resolveTerminalTabId(state, session.deps.tabId)
const leafId = session.pane.leafId
const ptyId = state.terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId]
if (ptyId) {
session.deps.restoredLeafId = leafId
session.deps.restoredPtyIdByLeafId = { ...session.deps.restoredPtyIdByLeafId, [leafId]: ptyId }
}
}
@@ -8,6 +8,7 @@ import { createCodexBackfillErrorDetector } from '../codex-backfill-error-detect
import { hasWorktreeSleepIntent, onWorktreeSleepIntentCleared } from '@/lib/worktree-sleep-intent'
import { recordPtyConnectDiagnostic } from './pty-connect-limits'
import { waitsForMainPaneStart } from './main-pane-start-wait'
import { findTerminalTabForPane } from './terminal-tab-id'
import type { ConnectPanePtySession } from './connect-pane-pty-session'
@@ -132,6 +133,14 @@ export function installRunDeferredConnect(session: ConnectPanePtySession): void
session.connectFrame = requestAnimationFrame(session.runDeferredConnect)
return
}
if (waitsForMainPaneStart(session, session.runDeferredConnect)) {
session.cancelScheduledConnectFrame()
if (session.connectFallbackTimer !== null) {
clearTimeout(session.connectFallbackTimer)
session.connectFallbackTimer = null
}
return
}
session.connectStarted = true
releaseHideWait?.()
session.cancelScheduledConnectFrame()
@@ -452,6 +452,7 @@ describe('remote hidden-output restore outcomes', () => {
ptyIdsByTabId: {
'tab-1': ['tab-pty']
},
terminalPaneStartsByPaneKey: {},
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_1 },
@@ -481,6 +481,7 @@ describe('remote hidden-output restore abandonment (issue2-hidden-output-skip)',
ptyIdsByTabId: {
'tab-1': ['tab-pty']
},
terminalPaneStartsByPaneKey: {},
terminalLayoutsByTabId: {
'tab-1': {
root: { type: 'leaf', leafId: LEAF_1 },
@@ -0,0 +1,86 @@
import { beforeEach, expect, it, vi } from 'vitest'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { PtyConnectionDeps } from './pty-connection-types'
import type { splitPaneInMain } from './terminal-pane-split-in-main'
import { runBootstrapSplitInMain } from './terminal-pane-bootstrap-split-in-main'
const mocks = vi.hoisted(() => ({
splitPaneInMain: vi.fn<typeof splitPaneInMain>(),
runTerminalPaneBootstrapSplits: vi.fn()
}))
vi.mock('./terminal-pane-split-in-main', async (importOriginal) => ({
...(await importOriginal<object>()),
splitPaneInMain: mocks.splitPaneInMain
}))
vi.mock('./terminal-pane-mount-bootstrap', () => ({
runTerminalPaneBootstrapSplits: mocks.runTerminalPaneBootstrapSplits
}))
const SOURCE = '11111111-1111-4111-8111-111111111111'
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the startup split reads only the target pane.
const manager = {
getActivePane: () => ({ id: 1, leafId: SOURCE, terminal: { cols: 120, rows: 30 } }),
getPanes: () => [{ id: 1, leafId: SOURCE, terminal: { cols: 120, rows: 30 } }]
} as unknown as PaneManager
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: passed through to today's split only.
const ptyDeps = {} as PtyConnectionDeps
const issue = { command: 'issue runner', env: { ISSUE: '42' } }
beforeEach(() => {
mocks.splitPaneInMain.mockReset()
mocks.runTerminalPaneBootstrapSplits.mockReset()
})
function run(): void {
runBootstrapSplitInMain({
manager,
worktreeId: 'wt-1',
tabId: 'tab-1',
ptyDeps,
issueCommandSplit: issue,
isActive: true
})
}
it('asks main to split the tab and start its startup command at half the source pane, keeping focus there', () => {
mocks.splitPaneInMain.mockReturnValue(true)
run()
expect(mocks.splitPaneInMain).toHaveBeenCalledWith(
{
worktreeId: 'wt-1',
tabId: 'tab-1',
sourceLeafId: SOURCE,
direction: 'vertical',
initialSize: { cols: 60, rows: 30 },
keepSourceFocused: true,
command: 'issue runner',
env: { ISSUE: '42' }
},
expect.any(Function)
)
expect(mocks.runTerminalPaneBootstrapSplits).not.toHaveBeenCalled()
})
it('splits as today where main does not own the tab', () => {
mocks.splitPaneInMain.mockReturnValue(false)
run()
expect(mocks.runTerminalPaneBootstrapSplits).toHaveBeenCalledWith({
manager,
ptyDeps,
issueCommandSplit: issue,
isActive: true
})
})
it('splits as today when main answers that a paired server holds the tab', () => {
mocks.splitPaneInMain.mockReturnValue(true)
run()
mocks.splitPaneInMain.mock.calls[0]![1]()
expect(mocks.runTerminalPaneBootstrapSplits).toHaveBeenCalledTimes(1)
})
@@ -0,0 +1,47 @@
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { TerminalPaneIssueCommandSplit } from './terminal-pane-lifecycle-types'
import { splitPaneFirstSize, splitPaneInMain } from './terminal-pane-split-in-main'
import { runTerminalPaneBootstrapSplits } from './terminal-pane-mount-bootstrap'
import type { PtyConnectionDeps } from './pty-connection-types'
/**
* A tab's startup split in a layout-owner build: main writes it and starts its command; main's
* frame draws the pane. A paired server's tab splits in the window as today.
*/
export function runBootstrapSplitInMain(args: {
manager: PaneManager
worktreeId: string
tabId: string
ptyDeps: PtyConnectionDeps
issueCommandSplit: TerminalPaneIssueCommandSplit | null | undefined
isActive: boolean
}): void {
const { manager, issueCommandSplit } = args
const targetPane = manager.getActivePane() ?? manager.getPanes()[0]
const asToday = (): void =>
runTerminalPaneBootstrapSplits({
manager: args.manager,
ptyDeps: args.ptyDeps,
issueCommandSplit,
isActive: args.isActive
})
if (!issueCommandSplit || !targetPane) {
return
}
const sentToMain = splitPaneInMain(
{
worktreeId: args.worktreeId,
tabId: args.tabId,
sourceLeafId: targetPane.leafId,
direction: 'vertical',
initialSize: splitPaneFirstSize(targetPane.terminal, 'vertical'),
keepSourceFocused: true,
command: issueCommandSplit.command,
...(issueCommandSplit.env ? { env: issueCommandSplit.env } : {})
},
asToday
)
if (!sentToMain) {
asToday()
}
}
@@ -1,55 +0,0 @@
import { beforeEach, expect, it, vi } from 'vitest'
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
import type { recordLayoutApply } from '@/runtime/layout-apply-send'
import { isTerminalLeafId, type TerminalLeafId } from '../../../../shared/stable-pane-id'
import { recordingBootstrapSplits } from './terminal-pane-bootstrap-split-record'
const mocks = vi.hoisted(() => ({ recordLayoutApply: vi.fn<typeof recordLayoutApply>() }))
vi.mock('@/runtime/layout-apply-send', () => ({ recordLayoutApply: mocks.recordLayoutApply }))
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
function leafId(value: string): TerminalLeafId {
if (!isTerminalLeafId(value)) {
throw new Error(`not a pane id: ${value}`)
}
return value
}
const SOURCE = leafId('11111111-1111-4111-8111-111111111111')
const CREATED = leafId('22222222-2222-4222-8222-222222222222')
function fakeManager(created: Pick<ManagedPane, 'id' | 'leafId'> | null) {
const splitPane = vi.fn(() => created)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the recorder reads only these members.
const manager = {
getLeafId: (paneId: number) => (paneId === 1 ? SOURCE : null),
splitPane
} as unknown as PaneManager
return { manager, splitPane }
}
beforeEach(() => mocks.recordLayoutApply.mockReset())
it('draws a startup split at once, then records it in main under the window’s pane id', () => {
const { manager, splitPane } = fakeManager({ id: 2, leafId: CREATED })
const pane = recordingBootstrapSplits(manager, 'wt-1', 'tab-1').splitPane(1, 'horizontal')
expect(pane).toEqual({ id: 2, leafId: CREATED })
expect(splitPane).toHaveBeenCalledWith(1, 'horizontal', undefined)
expect(mocks.recordLayoutApply).toHaveBeenCalledWith({}, 'wt-1', {
type: 'splitPane',
tabId: 'tab-1',
leafId: SOURCE,
direction: 'horizontal',
newLeafId: CREATED
})
})
it('records nothing for a split the manager did not make', () => {
const { manager } = fakeManager(null)
recordingBootstrapSplits(manager, 'wt-1', 'tab-1').splitPane(1, 'vertical')
expect(mocks.recordLayoutApply).not.toHaveBeenCalled()
})
@@ -1,49 +0,0 @@
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import { useAppStore } from '@/store'
import { recordLayoutApply } from '@/runtime/layout-apply-send'
export type BootstrapSplitManager = Pick<
PaneManager,
'getActivePane' | 'getPanes' | 'setActivePane' | 'splitPane'
>
/**
* The manager a tab's startup splits use in a layout-owner build: each split is drawn at once
* under the window's pane id (its startup command rides on the split), then recorded in main.
*/
export function recordingBootstrapSplits(
manager: PaneManager,
worktreeId: string,
tabId: string
): BootstrapSplitManager {
return {
getActivePane: () => manager.getActivePane(),
getPanes: () => manager.getPanes(),
setActivePane: (paneId, opts) => manager.setActivePane(paneId, opts),
splitPane: (paneId, direction, options) => {
const sourceLeafId = manager.getLeafId(paneId)
const created = manager.splitPane(paneId, direction, options)
if (created && sourceLeafId) {
recordDrawnPaneSplit(worktreeId, tabId, sourceLeafId, direction, created.leafId)
}
return created
}
}
}
/** Records a split the window drew under its pane id; main writes it, or finds it already held. */
export function recordDrawnPaneSplit(
worktreeId: string,
tabId: string,
sourceLeafId: string,
direction: 'vertical' | 'horizontal',
newLeafId: string
): void {
recordLayoutApply(useAppStore.getState(), worktreeId, {
type: 'splitPane',
tabId,
leafId: sourceLeafId,
direction,
newLeafId
})
}
@@ -4,9 +4,8 @@ import type { PaneManagerOptions } from '@/lib/pane-manager/pane-manager-types'
import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types'
import {
_resetLayoutOwnerPaneIntentsForTests,
expectPaneToAppear,
expectPaneToClose,
takePaneAppearIntent
expectPaneToMountUnfocused
} from '@/runtime/layout-owner-pane-intents'
import { serializePaneTree } from './layout-serialization'
@@ -15,8 +14,6 @@ vi.mock('@/lib/pane-manager/pane-lifecycle', async (importOriginal) => {
const actual: object = await importOriginal()
return { ...actual, openTerminal: vi.fn() }
})
const recordCreatedTerminalPaneSplit = vi.hoisted(() => vi.fn())
vi.mock('./terminal-pane-split-completion', () => ({ recordCreatedTerminalPaneSplit }))
const { PaneManager } = await import('@/lib/pane-manager/pane-manager')
const { reconcileMountedPanesToLayout } = await import('./terminal-pane-frame-reconciliation')
@@ -40,11 +37,20 @@ describe('reconcileMountedPanesToLayout', () => {
let onPaneCreated: Mock<NonNullable<PaneManagerOptions['onPaneCreated']>>
const closePane = vi.fn()
const reconcile = (layout: TerminalPaneLayoutNode, isExpanded = false): boolean =>
const reconcile = (
layout: TerminalPaneLayoutNode,
isExpanded = false,
ptyIdsByLeafId?: Record<string, string>
): boolean =>
reconcileMountedPanesToLayout({
tabId: TAB,
manager,
layout: { root: layout, activeLeafId: null, expandedLeafId: null },
layout: {
root: layout,
activeLeafId: null,
expandedLeafId: null,
...(ptyIdsByLeafId ? { ptyIdsByLeafId } : {})
},
closePane,
isExpanded
})
@@ -57,7 +63,6 @@ describe('reconcileMountedPanesToLayout', () => {
beforeEach(() => {
_resetLayoutOwnerPaneIntentsForTests()
closePane.mockReset()
recordCreatedTerminalPaneSplit.mockReset()
vi.stubGlobal('requestAnimationFrame', () => 1)
vi.stubGlobal('cancelAnimationFrame', () => {})
root = document.createElement('div')
@@ -75,35 +80,58 @@ describe('reconcileMountedPanesToLayout', () => {
vi.unstubAllGlobals()
})
it('mounts the window’s own split where main placed it, with the cwd it asked for', () => {
expectPaneToAppear(C, {
tabId: TAB,
cwd: '/repo',
telemetry: { source: 'keyboard', direction: 'horizontal' }
})
it('mounts a pane main is starting where main placed it, starting nothing of its own', () => {
const layout = split('vertical', leaf(A), split('horizontal', leaf(B), leaf(C), 0.4), 0.3)
expect(reconcile(layout)).toBe(true)
expect(serialized()).toEqual(layout)
expect(onPaneCreated).toHaveBeenCalledWith(
expect.objectContaining({ leafId: C }),
expect.objectContaining({ cwd: '/repo' })
)
expect(recordCreatedTerminalPaneSplit).toHaveBeenCalledWith(
expect.objectContaining({ leafId: C }),
{ source: 'keyboard', direction: 'horizontal' }
)
expect(takePaneAppearIntent(TAB, C)).toBeNull()
// No spawn input: the pane waits on main's start, then attaches (main-pane-start-wait).
const [created, hints] = onPaneCreated.mock.calls[0]!
expect(created).toMatchObject({ leafId: C })
expect(hints?.cwd).toBeUndefined()
expect(hints?.cwdPromise).toBeUndefined()
})
it('leaves a pane main added without the window’s ask to main’s notice', () => {
it('mounts a pane main added that the window never asked for, attached to main’s terminal', () => {
// Main's own split, a CLI split or another client's: the pane sets agree, none pending.
const layout = split('vertical', leaf(A), split('horizontal', leaf(B), leaf(C)))
expect(reconcile(layout)).toBe(false)
expect(reconcile(layout, false, { [C]: 'pty-main-c' })).toBe(true)
expect(leaves()).toEqual([A, B])
expect(onPaneCreated).not.toHaveBeenCalled()
expect(leaves()).toEqual([A, B, C])
expect(onPaneCreated).toHaveBeenCalledTimes(1)
expect(onPaneCreated).toHaveBeenCalledWith(
expect.objectContaining({ leafId: C }),
expect.objectContaining({ ptyId: 'pty-main-c' })
)
// The next frame finds the sets agreeing and mounts nothing again.
expect(reconcile(layout, false, { [C]: 'pty-main-c' })).toBe(false)
expect(onPaneCreated).toHaveBeenCalledTimes(1)
})
it('focuses a pane main added, as a split does', () => {
manager.setActivePane(manager.getNumericIdForLeaf(A)!, { focus: false })
reconcile(split('vertical', leaf(A), split('horizontal', leaf(B), leaf(C))))
expect(manager.getActivePane()?.leafId).toBe(C)
})
it('leaves focus on the source pane for the window’s startup split, as switch-off does', () => {
manager.setActivePane(manager.getNumericIdForLeaf(A)!, { focus: false })
expectPaneToMountUnfocused(TAB, C)
reconcile(split('vertical', leaf(A), split('horizontal', leaf(B), leaf(C))))
expect(leaves()).toEqual([A, B, C])
expect(manager.getActivePane()?.leafId).toBe(A)
// Asked once: a later pane main adds takes focus again.
const D = '44444444-4444-4444-8444-444444444444'
reconcile(
split('vertical', leaf(A), split('horizontal', leaf(B), split('vertical', leaf(C), leaf(D))))
)
expect(manager.getActivePane()?.leafId).toBe(D)
})
it('closes a pane main dropped only when the window asked, with the close it asked for', () => {
@@ -126,17 +154,6 @@ describe('reconcileMountedPanesToLayout', () => {
expect(leaves()).toEqual([A])
})
it('ignores an intent for another tab', () => {
expectPaneToAppear(C, {
tabId: 'tab-2',
telemetry: { source: 'keyboard', direction: 'vertical' }
})
reconcile(split('vertical', leaf(A), split('vertical', leaf(B), leaf(C))))
expect(leaves()).toEqual([A, B])
})
it('takes main’s ratios, then another client’s move, without recreating a pane', () => {
const before = manager.getPanes().map((pane) => pane.container)
@@ -150,12 +167,7 @@ describe('reconcileMountedPanesToLayout', () => {
expect(onPaneCreated).not.toHaveBeenCalled()
})
it('leaves a zoomed tab’s shape for later, still mounting its own split', () => {
expectPaneToAppear(C, {
tabId: TAB,
telemetry: { source: 'keyboard', direction: 'vertical' }
})
it('leaves a zoomed tab’s shape for later, still mounting main’s new pane', () => {
reconcile(split('horizontal', leaf(B), split('vertical', leaf(A), leaf(C))), true)
expect(leaves()).toEqual([A, B, C])
@@ -1,20 +1,19 @@
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
import {
expectPaneToAppear,
type PaneCloseKind,
takePaneAppearIntent,
takePaneCloseIntent
takePaneCloseIntent,
takePaneMountUnfocused
} from '@/runtime/layout-owner-pane-intents'
import { planTerminalLiveLayoutInsertions } from './terminal-live-layout-reconciliation'
import { collectLeafIds } from '../../../../shared/terminal-pane-layout-tree'
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
/**
* A layout-owner build's mounted tab drawn from main's tree (the store's layout, which frames set).
* The window's own split and close show here, with the inputs and close it asked for; a pane main
* added or dropped without such an intent is left to main's notice. Then the panes take main's
* shape (by moving, never recreating) and ratios. True when panes were added or closed.
* Every pane main added is mounted, whoever split it: main starts its terminal, and the pane
* attaches to it. The window's own close shows here with the close it asked for; a pane main
* dropped without one is left to main's notice. Then the panes take main's shape (by moving,
* never recreating) and ratios. True when panes were added or closed.
*/
export function reconcileMountedPanesToLayout(args: {
tabId: string
@@ -49,16 +48,14 @@ export function reconcileMountedPanesToLayout(args: {
if (sourcePaneId === null || manager.getNumericIdForLeaf(insertion.newLeafId) !== null) {
continue
}
const intent = takePaneAppearIntent(tabId, insertion.newLeafId)
if (!intent) {
continue
}
const ratio =
insertion.ratio === undefined
? undefined
: insertion.placement === 'before'
? 1 - insertion.ratio
: insertion.ratio
const ptyId = layout.ptyIdsByLeafId?.[insertion.newLeafId]
const activeBefore = manager.getActivePane()
const created = manager.splitPaneAroundLeafIds(
insertion.sourceLeafIds,
sourcePaneId,
@@ -67,16 +64,18 @@ export function reconcileMountedPanesToLayout(args: {
leafId: insertion.newLeafId,
placement: insertion.placement,
...(ratio !== undefined && { ratio }),
...(intent.cwd ? { cwd: intent.cwd } : {}),
...(intent.cwdPromise ? { cwdPromise: intent.cwdPromise } : {})
// Bound already: the pane attaches to main's terminal; unbound, it waits on main's start.
...(ptyId ? { ptyId } : {})
}
)
if (!created) {
expectPaneToAppear(insertion.newLeafId, intent)
continue
if (created) {
changed = true
// As switch-off's startup split: the new pane mounts, focus stays on the source pane.
if (activeBefore && takePaneMountUnfocused(tabId, insertion.newLeafId)) {
const hadFocus = created.container.contains(document.activeElement)
manager.setActivePane(activeBefore.id, { focus: hadFocus })
}
}
recordCreatedTerminalPaneSplit(created, intent.telemetry)
changed = true
}
if (args.isExpanded) {
@@ -8,8 +8,6 @@ import type { TerminalPaneLifecycleRefs } from './use-terminal-pane-lifecycle-re
import type { UseTerminalPaneLifecycleDeps } from './terminal-pane-lifecycle-types'
import type { PtyConnectionDeps } from './pty-connection-types'
import { resolveParkRevealLiveTuiLeafIds } from './park-reveal-live-tui-leaves'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { withoutQueuedMainSplits } from './terminal-pane-split-request-routing'
import {
mapRestoredPaneTitlesByPaneId,
replayLayoutWithOneShotParkIntent
@@ -30,15 +28,7 @@ export function restoreTerminalPaneLayout(args: {
initialLayoutRef.current.ptyIdsByLeafId
)
const restoredPaneByLeafId = replayLayoutWithOneShotParkIntent(ptyDeps, () =>
replayTerminalLayout(
manager,
// Switch point: main's frames already hold a split main asked for; its queued request mounts
// it, with its startup command.
ORCA_LAYOUT_OWNER_IS_MAIN
? withoutQueuedMainSplits(initialLayoutRef.current, tabId, worktreeId)
: initialLayoutRef.current,
isActive
)
replayTerminalLayout(manager, initialLayoutRef.current, isActive)
)
const localOnlyBuffers = useAppStore.getState().localOnlyScrollbackByTabId[tabId]
const restoredBuffers = resolveLeafScrollbackBuffers({
@@ -1,7 +1,9 @@
import { describe, expect, it, vi } from 'vitest'
import type { BootstrapSplitManager } from './terminal-pane-bootstrap-split-record'
import type { PtyConnectionDeps } from './pty-connection-types'
import { runTerminalPaneBootstrapSplits } from './terminal-pane-mount-bootstrap'
import {
runTerminalPaneBootstrapSplits,
type BootstrapSplitManager
} from './terminal-pane-mount-bootstrap'
// Only startup is read by the bootstrap; the full PTY dependency bag belongs to mounting.
function startupDeps(): PtyConnectionDeps {
@@ -1,8 +1,13 @@
import type { BootstrapSplitManager } from './terminal-pane-bootstrap-split-record'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { PtyConnectionDeps } from './pty-connection-types'
import type { TerminalPaneIssueCommandSplit } from './terminal-pane-lifecycle-types'
import { splitPaneWithOneShotStartup } from './terminal-pane-lifecycle-primitives'
export type BootstrapSplitManager = Pick<
PaneManager,
'getActivePane' | 'getPanes' | 'setActivePane' | 'splitPane'
>
export function runTerminalPaneBootstrapSplits(args: {
manager: BootstrapSplitManager
ptyDeps: PtyConnectionDeps
@@ -6,9 +6,7 @@ import type { PtyConnectionDeps } from './pty-connection-types'
import { installTerminalPaneMountEvents } from './terminal-pane-mount-events'
import {
_resetTerminalPaneSplitRequestRoutingForTests,
dispatchTerminalPaneSplitRequest,
queueTerminalPaneSplitRequest,
withoutQueuedMainSplits
dispatchTerminalPaneSplitRequest
} from './terminal-pane-split-request-routing'
import {
_resetLayoutFramePositionForTests,
@@ -117,44 +115,4 @@ describe('a split main asked for, mounted in a layout-owner build', () => {
expect(sendLayoutApply).not.toHaveBeenCalled()
uninstall()
})
it('mounts a split into an unmounted tab with its command, not as a fresh shell', () => {
// The split lands while the tab is not mounted: its request waits; main's frame already
// holds the pane.
queueTerminalPaneSplitRequest({
tabId: 'tab-1',
worktreeId: 'wt-1',
paneRuntimeId: 1,
sourceLeafId: SOURCE,
direction: 'vertical',
newLeafId: NEW,
command: 'make test'
})
const framed = {
root: {
type: 'split' as const,
direction: 'vertical' as const,
first: { type: 'leaf' as const, leafId: SOURCE },
second: { type: 'leaf' as const, leafId: NEW }
},
activeLeafId: SOURCE,
expandedLeafId: null
}
// The tab's first mount replays its layout without the pane...
expect(withoutQueuedMainSplits(framed, 'tab-1', 'wt-1').root).toEqual({
type: 'leaf',
leafId: SOURCE
})
// ...and the waiting request mounts it, starting its command.
const ptyDeps: { startup?: unknown } = {}
const { manager, splitPane, startups } = mountedTab(ptyDeps)
const uninstall = install(manager, ptyDeps)
expect(splitPane).toHaveBeenCalledTimes(1)
expect(splitPane).toHaveBeenCalledWith(1, 'vertical', { leafId: NEW })
expect(startups).toEqual([{ command: 'make test' }])
expect(sendLayoutApply).not.toHaveBeenCalled()
uninstall()
})
})
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import { splitPaneFirstSize, splitPaneInMain } from './terminal-pane-split-in-main'
const mocks = vi.hoisted(
(): {
callRuntimeRpc: ReturnType<typeof vi.fn>
pairedServer: string | null
actsAsToday: boolean
recordCreatedTerminalPaneSplit: ReturnType<typeof vi.fn>
} => ({
callRuntimeRpc: vi.fn(),
pairedServer: null,
actsAsToday: false,
recordCreatedTerminalPaneSplit: vi.fn()
})
)
vi.mock('@/runtime/runtime-rpc-client', () => ({ callRuntimeRpc: mocks.callRuntimeRpc }))
vi.mock('@/lib/resolve-owner', () => ({
runtimeEnvironmentIdUnlessTwin: () => mocks.pairedServer
}))
vi.mock('@/runtime/layout-apply-send', () => ({ refusalActsAsToday: () => mocks.actsAsToday }))
vi.mock('@/store', () => ({ useAppStore: { getState: () => ({}) } }))
vi.mock('./terminal-pane-split-completion', () => ({
recordCreatedTerminalPaneSplit: mocks.recordCreatedTerminalPaneSplit
}))
const REQUEST = {
worktreeId: 'wt-1',
tabId: 'tab-1',
sourceLeafId: 'leaf-1',
direction: 'vertical' as const
}
describe('a window split in a layout-owner build', () => {
beforeEach(() => {
mocks.callRuntimeRpc.mockReset()
mocks.recordCreatedTerminalPaneSplit.mockReset()
mocks.pairedServer = null
mocks.actsAsToday = false
})
it('asks main to write and start the pane under the window’s id, with its folder and size', async () => {
mocks.callRuntimeRpc.mockResolvedValue({ split: {} })
const asToday = vi.fn()
expect(
splitPaneInMain(
{
...REQUEST,
cwd: Promise.resolve('/repo/src'),
initialSize: { cols: 40, rows: 24 },
command: 'make test',
telemetry: { source: 'keyboard' }
},
asToday
)
).toBe(true)
await vi.waitFor(() => expect(mocks.recordCreatedTerminalPaneSplit).toHaveBeenCalled())
const [target, method, params] = mocks.callRuntimeRpc.mock.calls[0]!
expect(target).toEqual({ kind: 'local' })
expect(method).toBe('terminal.split')
expect(params).toEqual({
pane: { worktreeId: 'wt-1', tabId: 'tab-1', leafId: 'leaf-1' },
newLeafId: expect.any(String),
direction: 'vertical',
cwd: '/repo/src',
initialSize: { cols: 40, rows: 24 },
command: 'make test'
})
expect(isTerminalLeafId(params.newLeafId)).toBe(true)
expect(mocks.recordCreatedTerminalPaneSplit).toHaveBeenCalledWith(true, {
source: 'keyboard',
direction: 'vertical'
})
expect(asToday).not.toHaveBeenCalled()
})
it('splits as today when main finds the tab is a paired server’s twin', async () => {
mocks.actsAsToday = true
mocks.callRuntimeRpc.mockRejectedValue(new Error('host_not_owned'))
const asToday = vi.fn()
splitPaneInMain(REQUEST, asToday)
await vi.waitFor(() => expect(asToday).toHaveBeenCalledTimes(1))
expect(mocks.recordCreatedTerminalPaneSplit).not.toHaveBeenCalled()
})
it('keeps a startup split’s source focused until the pane mounts, or forgets it on failure', async () => {
const { takePaneMountUnfocused } = await import('@/runtime/layout-owner-pane-intents')
mocks.callRuntimeRpc.mockResolvedValue({ split: {} })
splitPaneInMain({ ...REQUEST, keepSourceFocused: true }, vi.fn())
await vi.waitFor(() => expect(mocks.callRuntimeRpc).toHaveBeenCalled())
const leafId = mocks.callRuntimeRpc.mock.calls[0]![2].newLeafId
expect(takePaneMountUnfocused('tab-1', leafId)).toBe(true)
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
mocks.callRuntimeRpc.mockRejectedValue(new Error('shell not found'))
splitPaneInMain({ ...REQUEST, keepSourceFocused: true }, vi.fn())
await vi.waitFor(() => expect(warn).toHaveBeenCalled())
const failedLeafId = mocks.callRuntimeRpc.mock.calls[1]![2].newLeafId
expect(takePaneMountUnfocused('tab-1', failedLeafId)).toBe(false)
warn.mockRestore()
})
it('draws nothing itself when main refuses or the start fails', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
mocks.callRuntimeRpc.mockRejectedValue(new Error('shell not found'))
const asToday = vi.fn()
splitPaneInMain(REQUEST, asToday)
await vi.waitFor(() => expect(warn).toHaveBeenCalled())
expect(asToday).not.toHaveBeenCalled()
warn.mockRestore()
})
it('leaves a paired server’s workspace to the caller', () => {
mocks.pairedServer = 'env-1'
expect(splitPaneInMain(REQUEST, vi.fn())).toBe(false)
expect(mocks.callRuntimeRpc).not.toHaveBeenCalled()
})
it('sizes the new pane as its share of the source pane', () => {
expect(splitPaneFirstSize({ cols: 81, rows: 24 }, 'vertical')).toEqual({ cols: 40, rows: 24 })
expect(splitPaneFirstSize({ cols: 80, rows: 25 }, 'horizontal')).toEqual({
cols: 80,
rows: 12
})
})
})
@@ -0,0 +1,82 @@
// Layout-owner build: the window's splits are written and started by main under the window's pane
// id. Main's frame draws the pane, which attaches to main's terminal (frame reconciliation).
import type { TerminalPaneSplitSource } from '../../../../shared/feature-education-telemetry'
import { runtimeEnvironmentIdUnlessTwin } from '@/lib/resolve-owner'
import { mintStablePaneId } from '@/lib/pane-manager/mint-stable-pane-id'
import { refusalActsAsToday } from '@/runtime/layout-apply-send'
import { callRuntimeRpc } from '@/runtime/runtime-rpc-client'
import { expectPaneToMountUnfocused } from '@/runtime/layout-owner-pane-intents'
import { useAppStore } from '@/store'
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
export type SplitInMainRequest = {
worktreeId: string
tabId: string
sourceLeafId: string
direction: 'vertical' | 'horizontal'
cwd?: string | Promise<string>
initialSize?: { cols: number; rows: number }
command?: string
env?: Record<string, string>
/** The user's split; startup splits record no interaction. */
telemetry?: { source: TerminalPaneSplitSource }
/** Focus stays on the source pane when the new pane mounts, as a startup split's does. */
keepSourceFocused?: true
}
/**
* Asks main to split. False when a paired server owns the workspace's layout: the caller splits
* as today, as it does through `asToday` when main finds the tab is a paired server's twin.
*/
export function splitPaneInMain(request: SplitInMainRequest, asToday: () => void): boolean {
const { worktreeId, tabId, sourceLeafId, direction, telemetry } = request
if (runtimeEnvironmentIdUnlessTwin(useAppStore.getState(), worktreeId)) {
return false
}
const newLeafId = mintStablePaneId()
const forgetFocus = request.keepSourceFocused
? expectPaneToMountUnfocused(tabId, newLeafId)
: () => {}
void (async () => {
const cwd = await request.cwd
await callRuntimeRpc({ kind: 'local' }, 'terminal.split', {
pane: { worktreeId, tabId, leafId: sourceLeafId },
newLeafId,
direction,
...(cwd ? { cwd } : {}),
...(request.initialSize ? { initialSize: request.initialSize } : {}),
...(request.command ? { command: request.command } : {}),
...(request.env ? { env: request.env } : {})
})
})().then(
() => {
if (telemetry) {
recordCreatedTerminalPaneSplit(true, { source: telemetry.source, direction })
}
},
(error: unknown) => {
forgetFocus()
const message = error instanceof Error ? error.message : String(error)
if (
message === 'host_not_owned' &&
refusalActsAsToday(useAppStore.getState(), worktreeId, 'host_not_owned')
) {
asToday()
return
}
// A start that failed keeps its pane, which shows why; anything else left no pane.
console.warn('[layout-owner] split refused:', message)
}
)
return true
}
/** A first size for the new pane: its share of the source pane's grid, until it is laid out. */
export function splitPaneFirstSize(
source: { cols: number; rows: number },
direction: 'vertical' | 'horizontal'
): { cols: number; rows: number } {
return direction === 'vertical'
? { cols: Math.max(1, Math.floor(source.cols / 2)), rows: source.rows }
: { cols: source.cols, rows: Math.max(1, Math.floor(source.rows / 2)) }
}
@@ -5,7 +5,6 @@ import type { SplitTerminalPaneDetail } from '@/constants/terminal'
import { BACKGROUND_WORKTREE_MEASURE_WINDOW_MS } from '../terminal/background-terminal-worktree-visibility'
import {
_resetTerminalPaneSplitRequestRoutingForTests,
cancelQueuedTerminalPaneSplitForLeaf,
cancelQueuedTerminalPaneSplitRequests,
dispatchTerminalPaneSplitRequest,
hasTerminalPaneSplitMountLease,
@@ -13,8 +12,7 @@ import {
registerTerminalPaneSplitRequestHandler,
resolveTerminalPaneSplitSourceId,
takeQueuedTerminalPaneSplitRequests,
TERMINAL_PANE_SPLIT_QUEUE_CAPACITY,
withoutQueuedMainSplits
TERMINAL_PANE_SPLIT_QUEUE_CAPACITY
} from './terminal-pane-split-request-routing'
const SOURCE_LEAF_ID = '11111111-1111-4111-8111-111111111111'
@@ -138,77 +136,3 @@ describe('parked terminal split request routing', () => {
unregisterSecond()
})
})
describe('withoutQueuedMainSplits', () => {
const SOURCE = '11111111-1111-4111-8111-111111111111'
const NEW = '22222222-2222-4222-8222-222222222222'
const layout = {
root: {
type: 'split' as const,
direction: 'vertical' as const,
first: { type: 'leaf' as const, leafId: SOURCE },
second: { type: 'leaf' as const, leafId: NEW }
},
activeLeafId: SOURCE,
expandedLeafId: null
}
it('leaves out a pane whose queued split main asked for, so that request mounts it with its command', () => {
_resetTerminalPaneSplitRequestRoutingForTests()
queueTerminalPaneSplitRequest({
tabId: 'tab-1',
worktreeId: 'wt-1',
paneRuntimeId: 1,
sourceLeafId: SOURCE,
direction: 'vertical',
newLeafId: NEW,
command: 'make test'
})
expect(withoutQueuedMainSplits(layout, 'tab-1', 'wt-1').root).toEqual({
type: 'leaf',
leafId: SOURCE
})
expect(withoutQueuedMainSplits(layout, 'tab-2', 'wt-1')).toBe(layout)
expect(takeQueuedTerminalPaneSplitRequests('tab-1', 'wt-1')[0]).toMatchObject({
newLeafId: NEW,
command: 'make test'
})
expect(withoutQueuedMainSplits(layout, 'tab-1', 'wt-1')).toBe(layout)
_resetTerminalPaneSplitRequestRoutingForTests()
})
})
describe('cancelQueuedTerminalPaneSplitForLeaf', () => {
const split = (newLeafId: string): SplitTerminalPaneDetail => ({
...splitRequest('tab-parked'),
worktreeId: 'wt-1',
newLeafId
})
it('drops only the closed pane’s split, and the lease with the tab’s last one', () => {
queueTerminalPaneSplitRequest(split('leaf-a'))
queueTerminalPaneSplitRequest(split('leaf-b'))
cancelQueuedTerminalPaneSplitForLeaf('tab-parked', 'leaf-a')
expect(hasTerminalPaneSplitMountLease('tab-parked')).toBe(true)
cancelQueuedTerminalPaneSplitForLeaf('tab-other', 'leaf-b')
expect(hasTerminalPaneSplitMountLease('tab-parked')).toBe(true)
cancelQueuedTerminalPaneSplitForLeaf('tab-parked', 'leaf-b')
expect(hasTerminalPaneSplitMountLease('tab-parked')).toBe(false)
expect(takeQueuedTerminalPaneSplitRequests('tab-parked', 'wt-1')).toEqual([])
})
it('keeps the lease of another split still waiting there', () => {
queueTerminalPaneSplitRequest(split('leaf-a'))
queueTerminalPaneSplitRequest(split('leaf-b'))
cancelQueuedTerminalPaneSplitForLeaf('tab-parked', 'leaf-b')
expect(hasTerminalPaneSplitMountLease('tab-parked')).toBe(true)
expect(
takeQueuedTerminalPaneSplitRequests('tab-parked', 'wt-1').map((r) => r.newLeafId)
).toEqual(['leaf-a'])
})
})
@@ -1,7 +1,5 @@
import { SPLIT_TERMINAL_PANE_EVENT, type SplitTerminalPaneDetail } from '@/constants/terminal'
import { BACKGROUND_WORKTREE_MEASURE_WINDOW_MS } from '../terminal/background-terminal-worktree-visibility'
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
import { removeLayoutLeaf } from '../../../../shared/workspace-layout/terminal-pane-tree'
// Why 32: ordinary use has one request; the cap tolerates automation bursts without letting a stalled renderer retain unbounded commands or mount leases.
export const TERMINAL_PANE_SPLIT_QUEUE_CAPACITY = 32
@@ -125,50 +123,11 @@ export function takeQueuedTerminalPaneSplitRequests(
return requests
}
/** The tab's layout without the panes of splits main asked for that wait here, which mount them. */
export function withoutQueuedMainSplits(
layout: TerminalLayoutSnapshot,
tabId: string,
worktreeId: string
): TerminalLayoutSnapshot {
let root = layout.root
for (const leafId of queuedMainSplitLeafIds(tabId, worktreeId)) {
root = removeLayoutLeaf(root, leafId) ?? root
}
return root === layout.root ? layout : { ...layout, root }
}
function queuedMainSplitLeafIds(tabId: string, worktreeId: string): string[] {
return queuedRequests.flatMap((request) =>
request.tabId === tabId &&
request.newLeafId &&
(request.worktreeId === undefined || request.worktreeId === worktreeId)
? [request.newLeafId]
: []
)
}
export function cancelQueuedTerminalPaneSplitRequests(tabId: string, worktreeId?: string): void {
removeQueuedRequestsForTarget(tabId, worktreeId)
releaseSplitMountLease(tabId, worktreeId)
}
/** Drops a waiting split whose pane closed before its tab mounted; the tab's lease goes with its last one. */
export function cancelQueuedTerminalPaneSplitForLeaf(tabId: string, leafId: string): void {
const index = queuedRequests.findIndex(
(request) => request.tabId === tabId && request.newLeafId === leafId
)
if (index === -1) {
return
}
const [{ worktreeId }] = queuedRequests.splice(index, 1)
if (
!queuedRequests.some((request) => request.tabId === tabId && request.worktreeId === worktreeId)
) {
releaseSplitMountLease(tabId, worktreeId)
}
}
export function hasTerminalPaneSplitMountLease(tabId: string, worktreeId?: string): boolean {
return [...splitMountLeasesByTarget.values()].some(
(lease) =>
@@ -4,18 +4,13 @@ import type { PtyTransport } from './pty-transport-types'
import type { PaneCwdMap } from './resolve-split-cwd'
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
import { createDeferred } from './pty-connection-test-async'
import type { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command'
import { endLayoutFramePositions, noteLayoutFramePosition } from '@/runtime/layout-frame-position'
import {
_resetLayoutOwnerPaneIntentsForTests,
takePaneAppearIntent
} from '@/runtime/layout-owner-pane-intents'
import type { splitPaneInMain } from './terminal-pane-split-in-main'
const mocks = vi.hoisted(() => ({
layoutOwnerIsMain: false,
recordCreatedTerminalPaneSplit: vi.fn(),
resolveSplitCwd: vi.fn(),
sendLayoutCommandToOwner: vi.fn<typeof sendLayoutCommandToOwner>(),
splitPaneInMain: vi.fn<typeof splitPaneInMain>(),
splitWebRuntimeTerminal: vi.fn()
}))
@@ -25,8 +20,9 @@ vi.mock('../../../../shared/layout-owner-switch', () => ({
}
}))
vi.mock('@/runtime/layout-owner-command', () => ({
sendLayoutCommandToOwner: mocks.sendLayoutCommandToOwner
vi.mock('./terminal-pane-split-in-main', async (importOriginal) => ({
...(await importOriginal<object>()),
splitPaneInMain: mocks.splitPaneInMain
}))
vi.mock('@/runtime/web-runtime-terminal-actions', () => ({
@@ -53,11 +49,17 @@ function makeManager(splitPane: ReturnType<typeof vi.fn>): PaneManager {
return { splitPane, getNumericIdForLeaf } as unknown as PaneManager
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the split reads only the pane's ids and grid size.
const LAYOUT_OWNER_SOURCE_PANE = {
id: 1,
leafId: 'leaf-1',
terminal: { cols: 80, rows: 24 }
} as unknown as ManagedPane
describe('splitTerminalPaneWithInheritedCwd', () => {
beforeEach(() => {
_resetLayoutOwnerPaneIntentsForTests()
mocks.layoutOwnerIsMain = false
mocks.sendLayoutCommandToOwner.mockReset()
mocks.splitPaneInMain.mockReset()
mocks.recordCreatedTerminalPaneSplit.mockReset()
mocks.resolveSplitCwd.mockReset()
mocks.splitWebRuntimeTerminal.mockReset()
@@ -254,81 +256,43 @@ describe('splitTerminalPaneWithInheritedCwd', () => {
source: 'keyboard'
})
expect(mocks.sendLayoutCommandToOwner).not.toHaveBeenCalled()
expect(mocks.splitPaneInMain).not.toHaveBeenCalled()
expect(splitPane).toHaveBeenCalledWith(1, 'vertical', { cwd: '/cached' })
})
it('in a layout-owner build, asks main for the split and leaves drawing it to main’s frame', () => {
mocks.layoutOwnerIsMain = true
mocks.sendLayoutCommandToOwner.mockReturnValue(true)
const splitPane = vi.fn(() => ({ id: 2 }))
splitTerminalPaneWithInheritedCwd({
worktreeId: 'worktree-1',
tabId: 'tab-1',
manager: makeManager(splitPane),
paneTransports: new Map(),
paneCwdMap: new Map([[1, { cwd: '/cached', confirmed: true }]]),
fallbackCwd: '/fallback',
pane: { id: 1, leafId: 'leaf-1' } as ManagedPane,
direction: 'vertical',
source: 'keyboard'
})
const [, command, handlers] = mocks.sendLayoutCommandToOwner.mock.calls[0] ?? []
expect(command).toEqual({
type: 'splitPane',
tabId: 'tab-1',
leafId: 'leaf-1',
direction: 'vertical',
newLeafId: expect.any(String)
})
noteLayoutFramePosition(1)
handlers?.apply({})
endLayoutFramePositions()
expect(splitPane).not.toHaveBeenCalled()
const newLeafId = command?.type === 'splitPane' ? (command.newLeafId ?? '') : ''
expect(takePaneAppearIntent('tab-1', newLeafId)).toEqual({
tabId: 'tab-1',
cwd: '/cached',
telemetry: { source: 'keyboard', direction: 'vertical' }
})
})
it('in a layout-owner build, draws main’s pane itself when no frame is coming', () => {
it('in a layout-owner build, asks main to split and start the pane, and leaves drawing it to main’s frame', () => {
const splitPane = vi.fn(() => ({ id: 2 }))
splitInLayoutOwnerBuild(makeManager(splitPane))
const [, command, handlers] = mocks.sendLayoutCommandToOwner.mock.calls[0] ?? []
const newLeafId = command?.type === 'splitPane' ? (command.newLeafId ?? '') : ''
handlers?.apply({})
expect(splitPane).toHaveBeenCalledWith(1, 'vertical', { cwd: '/cached', leafId: newLeafId })
expect(takePaneAppearIntent('tab-1', newLeafId)).toBeNull()
})
it('in a layout-owner build, forgets the pane when main refuses', () => {
splitInLayoutOwnerBuild(makeManager(vi.fn()))
const [, command, handlers] = mocks.sendLayoutCommandToOwner.mock.calls[0] ?? []
handlers?.refused?.('pane_not_found')
const newLeafId = command?.type === 'splitPane' ? (command.newLeafId ?? '') : ''
expect(takePaneAppearIntent('tab-1', newLeafId)).toBeNull()
expect(mocks.splitPaneInMain).toHaveBeenCalledWith(
{
worktreeId: 'worktree-1',
tabId: 'tab-1',
sourceLeafId: 'leaf-1',
direction: 'vertical',
cwd: '/cached',
// Half the source pane's 80x24 grid, side by side.
initialSize: { cols: 40, rows: 24 },
telemetry: { source: 'keyboard' }
},
expect.any(Function)
)
expect(splitPane).not.toHaveBeenCalled()
expect(mocks.recordCreatedTerminalPaneSplit).not.toHaveBeenCalled()
})
it('in a layout-owner build, splits here when main answers as today', () => {
const splitPane = vi.fn(() => ({ id: 2 }))
splitInLayoutOwnerBuild(makeManager(splitPane))
mocks.sendLayoutCommandToOwner.mock.calls[0]?.[2].asToday()
mocks.splitPaneInMain.mock.calls[0]?.[1]()
expect(splitPane).toHaveBeenCalledWith(1, 'vertical', { cwd: '/cached' })
})
it('in a layout-owner build, splits as today where a paired server owns the layout', () => {
mocks.layoutOwnerIsMain = true
mocks.sendLayoutCommandToOwner.mockReturnValue(false)
mocks.splitPaneInMain.mockReturnValue(false)
const splitPane = vi.fn(() => ({ id: 2 }))
splitTerminalPaneWithInheritedCwd({
@@ -338,7 +302,7 @@ describe('splitTerminalPaneWithInheritedCwd', () => {
paneTransports: new Map(),
paneCwdMap: new Map([[1, { cwd: '/cached', confirmed: true }]]),
fallbackCwd: '/fallback',
pane: { id: 1, leafId: 'leaf-1' } as ManagedPane,
pane: LAYOUT_OWNER_SOURCE_PANE,
direction: 'horizontal',
source: 'keyboard'
})
@@ -348,7 +312,7 @@ describe('splitTerminalPaneWithInheritedCwd', () => {
function splitInLayoutOwnerBuild(manager: PaneManager): void {
mocks.layoutOwnerIsMain = true
mocks.sendLayoutCommandToOwner.mockReturnValue(true)
mocks.splitPaneInMain.mockReturnValue(true)
splitTerminalPaneWithInheritedCwd({
worktreeId: 'worktree-1',
tabId: 'tab-1',
@@ -356,7 +320,7 @@ describe('splitTerminalPaneWithInheritedCwd', () => {
paneTransports: new Map(),
paneCwdMap: new Map([[1, { cwd: '/cached', confirmed: true }]]),
fallbackCwd: '/fallback',
pane: { id: 1, leafId: 'leaf-1' } as ManagedPane,
pane: LAYOUT_OWNER_SOURCE_PANE,
direction: 'vertical',
source: 'keyboard'
})
@@ -5,10 +5,7 @@ import type { PtyTransport } from './pty-transport-types'
import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd'
import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { sendLayoutCommandToOwner } from '@/runtime/layout-owner-command'
import { expectPaneToAppear } from '@/runtime/layout-owner-pane-intents'
import { layoutFramesFlowing } from '@/runtime/layout-frame-position'
import { mintStablePaneId } from '@/lib/pane-manager/mint-stable-pane-id'
import { splitPaneFirstSize, splitPaneInMain } from './terminal-pane-split-in-main'
type SplitArgs = {
worktreeId: string
@@ -34,44 +31,26 @@ export function splitTerminalPaneWithInheritedCwd(args: SplitArgs): void {
) {
return
}
// Switch point: main writes the split; the window draws it from main's frame, with its cwd.
// Switch point: main writes the split and starts its terminal; main's frame draws the pane.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
const newLeafId = mintStablePaneId()
const cwd = splitCwd(args, ptyId)
const forget = expectPaneToAppear(newLeafId, {
tabId: args.tabId,
...cwd,
telemetry: { source: args.source, direction: args.direction }
})
const asToday = (): void => splitLocally(args, () => cwd)
if (
sendLayoutCommandToOwner(
args.worktreeId,
splitPaneInMain(
{
type: 'splitPane',
worktreeId: args.worktreeId,
tabId: args.tabId,
leafId: args.pane.leafId,
sourceLeafId: args.pane.leafId,
direction: args.direction,
newLeafId
cwd: 'cwd' in cwd ? cwd.cwd : cwd.cwdPromise,
initialSize: splitPaneFirstSize(args.pane.terminal, args.direction),
telemetry: { source: args.source }
},
{
// No frame is coming (the stream is down): the window draws main's pane itself.
apply: () => {
if (!layoutFramesFlowing()) {
forget()
splitLocally(args, () => cwd, newLeafId)
}
},
asToday: () => {
forget()
splitLocally(args, () => cwd)
},
refused: forget
}
asToday
)
) {
return
}
forget()
}
splitLocally(args, () => splitCwd(args, ptyId))
}
@@ -97,15 +76,12 @@ function splitCwd(args: SplitArgs, ptyId: string | null): SplitCwd {
}
/** `cwd` is read only once a manager is there to split. */
function splitLocally(args: SplitArgs, cwd: () => SplitCwd, leafId?: string): void {
function splitLocally(args: SplitArgs, cwd: () => SplitCwd): void {
const manager = args.getManager ? args.getManager() : args.manager
if (!manager) {
return
}
const createdPane = manager.splitPane(args.pane.id, args.direction, {
...cwd(),
...(leafId ? { leafId } : {})
})
const createdPane = manager.splitPane(args.pane.id, args.direction, cwd())
recordCreatedTerminalPaneSplit(createdPane, {
source: args.source,
direction: args.direction
@@ -14,6 +14,11 @@ import {
unboundSplitLayout,
WORKTREE_ID
} from './terminal-pane-tab-detach-fixture'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
// Main answers through today's IPC; the layout-owner build's equivalents (main's command plus its
// frame) are in terminal-pane-tab-detach-main-commit.test.ts.
const itSwitchOff = it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)
const toastErrorMock = vi.hoisted(() => vi.fn())
vi.mock('sonner', () => ({ toast: { error: toastErrorMock } }))
@@ -97,31 +102,34 @@ describe('detachTerminalPaneToTab rolls a committed move forward', () => {
}
})
it('closes main’s new tab, without a toast, when the user closed the pane meanwhile', async () => {
let paneIds = [1, 2]
const manager = managerWithPanes(() => paneIds)
const commitMove = vi.fn(
async (_request: TerminalLeafMoveRequest): Promise<TerminalLeafMoveResult> => {
paneIds = [1]
manager.getLeafId.mockImplementation((paneId) => (paneId === 1 ? LEAF_1 : null))
return moved
}
)
const store = createStore()
itSwitchOff(
'closes main’s new tab, without a toast, when the user closed the pane meanwhile',
async () => {
let paneIds = [1, 2]
const manager = managerWithPanes(() => paneIds)
const commitMove = vi.fn(
async (_request: TerminalLeafMoveRequest): Promise<TerminalLeafMoveResult> => {
paneIds = [1]
manager.getLeafId.mockImplementation((paneId) => (paneId === 1 ? LEAF_1 : null))
return moved
}
)
const store = createStore()
await expect(detach({ commitMove, manager, store })).resolves.toBeNull()
await expect(detach({ commitMove, manager, store })).resolves.toBeNull()
const targetTabId = commitMove.mock.calls[0]?.[0]?.targetTabId
expect(closeTerminalSurface).toHaveBeenCalledWith({
worktreeId: WORKTREE_ID,
target: { kind: 'tab', tabId: targetTabId },
reason: 'cleanup'
})
expect(store.createTab).not.toHaveBeenCalled()
expect(toastErrorMock).not.toHaveBeenCalled()
})
const targetTabId = commitMove.mock.calls[0]?.[0]?.targetTabId
expect(closeTerminalSurface).toHaveBeenCalledWith({
worktreeId: WORKTREE_ID,
target: { kind: 'tab', tabId: targetTabId },
reason: 'cleanup'
})
expect(store.createTab).not.toHaveBeenCalled()
expect(toastErrorMock).not.toHaveBeenCalled()
}
)
it('closes main’s new tab when the user closed the source tab meanwhile', async () => {
itSwitchOff('closes main’s new tab when the user closed the source tab meanwhile', async () => {
const store = createStore()
const commitMove = vi.fn(
async (_request: TerminalLeafMoveRequest): Promise<TerminalLeafMoveResult> => {
@@ -138,7 +146,7 @@ describe('detachTerminalPaneToTab rolls a committed move forward', () => {
expect(store.createTab).not.toHaveBeenCalled()
})
it('finds the pane by its leaf when its pane id changed meanwhile', async () => {
itSwitchOff('finds the pane by its leaf when its pane id changed meanwhile', async () => {
const manager = managerWithPanes(() => [1, 7])
manager.getLeafId.mockImplementation((paneId) => (paneId === 1 ? LEAF_1 : LEAF_2))
const commitMove = vi.fn(
@@ -155,89 +163,98 @@ describe('detachTerminalPaneToTab rolls a committed move forward', () => {
expect(manager.detachPaneForExternalMove).toHaveBeenCalledWith(7)
})
it('closes main’s new tab when the pane manager refuses to detach the pane', async () => {
const manager = managerWithPanes()
manager.detachPaneForExternalMove.mockReturnValue(false)
const commitMove = mainAnswering([moved])
const store = createStore()
itSwitchOff(
'closes main’s new tab when the pane manager refuses to detach the pane',
async () => {
const manager = managerWithPanes()
manager.detachPaneForExternalMove.mockReturnValue(false)
const commitMove = mainAnswering([moved])
const store = createStore()
await expect(detach({ commitMove, manager, store })).resolves.toBeNull()
await expect(detach({ commitMove, manager, store })).resolves.toBeNull()
expect(closeTerminalSurface).toHaveBeenCalledWith({
worktreeId: WORKTREE_ID,
target: { kind: 'tab', tabId: commitMove.mock.calls[0]?.[0]?.targetTabId },
reason: 'cleanup'
})
expect(store.createTab).not.toHaveBeenCalled()
expect(store.setTabLayout).not.toHaveBeenCalled()
})
expect(closeTerminalSurface).toHaveBeenCalledWith({
worktreeId: WORKTREE_ID,
target: { kind: 'tab', tabId: commitMove.mock.calls[0]?.[0]?.targetTabId },
reason: 'cleanup'
})
expect(store.createTab).not.toHaveBeenCalled()
expect(store.setTabLayout).not.toHaveBeenCalled()
}
)
it('moves the last pane, after a sibling closed meanwhile, without killing its PTY', async () => {
const store = createStore()
let paneIds = [1, 2]
const manager = managerWithPanes(() => paneIds)
const commitMove = vi.fn(async (_request: TerminalLeafMoveRequest) => {
// Closing a pane persists the layout synchronously (onLayoutChanged), as the real close does.
paneIds = [2]
store.terminalLayoutsByTabId[SOURCE_TAB_ID] = {
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_2]: 'pty-right' }
}
return { status: 'moved', ptyId: 'pty-right' } satisfies TerminalLeafMoveResult
})
itSwitchOff(
'moves the last pane, after a sibling closed meanwhile, without killing its PTY',
async () => {
const store = createStore()
let paneIds = [1, 2]
const manager = managerWithPanes(() => paneIds)
const commitMove = vi.fn(async (_request: TerminalLeafMoveRequest) => {
// Closing a pane persists the layout synchronously (onLayoutChanged), as the real close does.
paneIds = [2]
store.terminalLayoutsByTabId[SOURCE_TAB_ID] = {
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_2]: 'pty-right' }
}
return { status: 'moved', ptyId: 'pty-right' } satisfies TerminalLeafMoveResult
})
await expect(detach({ commitMove, manager, store })).resolves.toMatchObject({
leafId: LEAF_2,
ptyId: 'pty-right'
})
await expect(detach({ commitMove, manager, store })).resolves.toMatchObject({
leafId: LEAF_2,
ptyId: 'pty-right'
})
expect(closeTerminalSurface).not.toHaveBeenCalled()
expect(manager.detachPaneForExternalMove).not.toHaveBeenCalled()
expect(store.createTab).toHaveBeenCalledOnce()
expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith(
expect.objectContaining({ detachedLeafId: LEAF_2, detachedPtyId: 'pty-right' })
)
expect(store.closeTab).toHaveBeenCalledWith(
SOURCE_TAB_ID,
expect.objectContaining({ localPtyTeardownOwnedExternally: true })
)
})
expect(closeTerminalSurface).not.toHaveBeenCalled()
expect(manager.detachPaneForExternalMove).not.toHaveBeenCalled()
expect(store.createTab).toHaveBeenCalledOnce()
expect(store.syncPaneDetachPtyOwnership).toHaveBeenCalledWith(
expect.objectContaining({ detachedLeafId: LEAF_2, detachedPtyId: 'pty-right' })
)
expect(store.closeTab).toHaveBeenCalledWith(
SOURCE_TAB_ID,
expect.objectContaining({ localPtyTeardownOwnedExternally: true })
)
}
)
it('commits the move in main before the target tab exists, using the same tab id', async () => {
const store = createStore()
let release!: () => void
const commitMove = vi.fn(
(_request: TerminalLeafMoveRequest) =>
new Promise<TerminalLeafMoveResult>((resolve) => {
release = () => resolve(moved)
})
)
itSwitchOff(
'commits the move in main before the target tab exists, using the same tab id',
async () => {
const store = createStore()
let release!: () => void
const commitMove = vi.fn(
(_request: TerminalLeafMoveRequest) =>
new Promise<TerminalLeafMoveResult>((resolve) => {
release = () => resolve(moved)
})
)
const detaching = detach({ commitMove, store })
await vi.waitFor(() => expect(commitMove).toHaveBeenCalledOnce())
expect(store.createTab).not.toHaveBeenCalled()
release()
await detaching
const detaching = detach({ commitMove, store })
await vi.waitFor(() => expect(commitMove).toHaveBeenCalledOnce())
expect(store.createTab).not.toHaveBeenCalled()
release()
await detaching
const request = commitMove.mock.calls[0]?.[0]
expect(request).toEqual({
worktreeId: WORKTREE_ID,
sourceTabId: SOURCE_TAB_ID,
targetTabId: expect.any(String),
leafId: LEAF_2,
ptyId: expect.any(String)
})
expect(store.createTab).toHaveBeenCalledWith(
WORKTREE_ID,
TARGET_GROUP_ID,
'powershell.exe',
expect.objectContaining({ id: request?.targetTabId, initialLeafId: LEAF_2 })
)
})
const request = commitMove.mock.calls[0]?.[0]
expect(request).toEqual({
worktreeId: WORKTREE_ID,
sourceTabId: SOURCE_TAB_ID,
targetTabId: expect.any(String),
leafId: LEAF_2,
ptyId: expect.any(String)
})
expect(store.createTab).toHaveBeenCalledWith(
WORKTREE_ID,
TARGET_GROUP_ID,
'powershell.exe',
expect.objectContaining({ id: request?.targetTabId, initialLeafId: LEAF_2 })
)
}
)
it('ignores a repeat drop of a pane whose move is still committing', async () => {
itSwitchOff('ignores a repeat drop of a pane whose move is still committing', async () => {
const store = createStore()
let release!: () => void
const commitMove = vi.fn(
@@ -257,7 +274,7 @@ describe('detachTerminalPaneToTab rolls a committed move forward', () => {
expect(toastErrorMock).not.toHaveBeenCalled()
})
it('binds the moved tab’s layout to the live PTY, not the saved one', async () => {
itSwitchOff('binds the moved tab’s layout to the live PTY, not the saved one', async () => {
const store = createStore(splitLayout())
const saved = store.terminalLayoutsByTabId[SOURCE_TAB_ID]?.ptyIdsByLeafId?.[LEAF_2]
expect(saved).toBeTruthy()
@@ -0,0 +1,248 @@
// Layout-owner build: terminal-pane-tab-detach-commit.test.ts's roll-forward outcomes, with main's
// `movePaneToNewTab` answered (and its frame drawn) after what the user did meanwhile.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { toast } from 'sonner'
import type * as AgentStatusModule from '@/lib/agent-status'
import { layoutApplyTestTransport as main } from '@/runtime/layout-apply-test-transport.test-fixture'
import { createStoreCascadesMockApi } from '../../store/slices/store-cascades-test-harness'
import { LEAF_1, LEAF_2, splitLayout } from './terminal-pane-tab-detach-fixture'
import {
detachFrom,
GROUP,
mainMovedTabId,
openMainFrames,
seedDetachStore,
sentMove,
SOURCE,
twoPaneManager,
WT,
type DetachStore
} from './terminal-pane-tab-detach-main.test-fixture'
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeLayoutClientWithTestFrames(await importOriginal())
)
vi.mock('sonner', () => ({
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
}))
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
restorePtyDataHandlersAfterFailedShutdown: vi.fn(),
unregisterPtyDataHandlers: vi.fn(() => [])
}))
vi.mock('@/lib/agent-status', async (importOriginal) => {
const actual = await importOriginal<typeof AgentStatusModule>()
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
})
const closeTerminalSurface = vi.fn(async (_args: unknown) => {})
const moveLeafToNewTab = vi.fn()
let api: ReturnType<typeof createStoreCascadesMockApi>
let closeFrames: () => void = () => {}
beforeEach(() => {
vi.mocked(toast.error).mockClear()
closeTerminalSurface.mockClear()
moveLeafToNewTab.mockClear()
api = createStoreCascadesMockApi()
Object.assign(api, { session: { closeTerminalSurface } })
Object.assign(api.pty, { moveLeafToNewTab })
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
afterEach(() => {
closeFrames()
// Main moved through layout.apply, never today's IPC.
expect(moveLeafToNewTab).not.toHaveBeenCalled()
})
async function seeded(layout = splitLayout()): Promise<DetachStore> {
const store = seedDetachStore(layout)
closeFrames = await openMainFrames(store)
return store
}
/** Holds main's answer to the move until `answer`, which applies it and draws its frame. */
async function moveHeldByMain<T>(detaching: () => Promise<T>) {
main.answerWith('pending')
const result = detaching()
await vi.waitFor(() => expect(main.pendingCount).toBe(1))
return {
result,
answer: async (): Promise<T> => {
main.answerWith('apply')
main.answerPending()
await main.settle()
return result
}
}
}
/** Every tab the window shows is one main's model holds: the window made none of its own. */
function expectOnlyMainTabs(store: DetachStore): void {
const mainIds = main.mainModel?.workspaces[WT]?.tabs.map((tab) => tab.id) ?? []
const shown = store.getState().tabsByWorktree[WT]?.map((tab) => tab.id) ?? []
expect(shown.filter((id) => !mainIds.includes(id))).toEqual([])
}
function expectMainTabClosed(): void {
expect(closeTerminalSurface).toHaveBeenCalledWith({
worktreeId: WT,
target: { kind: 'tab', tabId: mainMovedTabId() },
reason: 'cleanup'
})
}
describe('detachTerminalPaneToTab rolls main’s committed move forward (layout-owner build)', () => {
it('closes main’s new tab, without a toast, when the user closed the pane meanwhile', async () => {
const store = await seeded()
let paneIds = [1, 2]
const manager = twoPaneManager()
manager.getPanes.mockImplementation(() => paneIds.map((id) => ({ id })))
const move = await moveHeldByMain(() => detachFrom(store, { manager }))
paneIds = [1]
manager.getLeafId.mockImplementation((paneId) => (paneId === 1 ? LEAF_1 : null))
await expect(move.answer()).resolves.toBeNull()
expectMainTabClosed()
expectOnlyMainTabs(store)
expect(toast.error).not.toHaveBeenCalled()
})
it('closes main’s new tab when the user closed the source tab meanwhile', async () => {
const store = await seeded()
const move = await moveHeldByMain(() => detachFrom(store))
// A tab close drops its row and its layout.
store.setState((state) => {
const { [SOURCE]: _closed, ...terminalLayoutsByTabId } = state.terminalLayoutsByTabId
return {
tabsByWorktree: {
[WT]: (state.tabsByWorktree[WT] ?? []).filter((tab) => tab.id !== SOURCE)
},
terminalLayoutsByTabId
}
})
await expect(move.answer()).resolves.toBeNull()
expect(closeTerminalSurface).toHaveBeenCalledOnce()
expectMainTabClosed()
expectOnlyMainTabs(store)
})
it('finds the pane by its leaf when its pane id changed meanwhile', async () => {
const store = await seeded()
const manager = twoPaneManager()
const move = await moveHeldByMain(() => detachFrom(store, { manager }))
manager.getPanes.mockImplementation(() => [{ id: 1 }, { id: 7 }])
manager.getLeafId.mockImplementation((paneId) =>
paneId === 1 ? LEAF_1 : paneId === 7 ? LEAF_2 : null
)
await expect(move.answer()).resolves.toMatchObject({ leafId: LEAF_2 })
expect(manager.detachPaneForExternalMove).toHaveBeenCalledWith(7)
})
it('closes main’s new tab when the pane manager refuses to detach the pane', async () => {
const store = await seeded()
const manager = twoPaneManager()
manager.detachPaneForExternalMove.mockReturnValue(false)
await expect(detachFrom(store, { manager })).resolves.toBeNull()
expectMainTabClosed()
expectOnlyMainTabs(store)
})
it('moves the last pane, after a sibling closed meanwhile, without killing its PTY', async () => {
const store = await seeded({
...splitLayout(),
ptyIdsByLeafId: { [LEAF_1]: 'pty-left', [LEAF_2]: 'pty-right' }
})
let paneIds = [1, 2]
const manager = twoPaneManager()
manager.getPanes.mockImplementation(() => paneIds.map((id) => ({ id })))
const move = await moveHeldByMain(() => detachFrom(store, { manager }))
// Closing a pane persists the layout synchronously (onLayoutChanged), as the real close does.
paneIds = [2]
store.getState().setTabLayout(SOURCE, {
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_2]: 'pty-right' }
})
await expect(move.answer()).resolves.toMatchObject({ leafId: LEAF_2, ptyId: 'pty-right' })
const movedId = mainMovedTabId()
expect(closeTerminalSurface).not.toHaveBeenCalledWith(
expect.objectContaining({ target: { kind: 'tab', tabId: movedId } })
)
expect(manager.detachPaneForExternalMove).not.toHaveBeenCalled()
expectOnlyMainTabs(store)
const state = store.getState()
expect(state.tabsByWorktree[WT]?.some((tab) => tab.id === SOURCE)).toBe(false)
expect(state.terminalLayoutsByTabId[movedId]?.ptyIdsByLeafId).toEqual({
[LEAF_2]: 'pty-right'
})
expect(state.ptyIdsByTabId[movedId]).toEqual(['pty-right'])
expect(api.pty.kill).not.toHaveBeenCalledWith('pty-right', expect.anything())
expect(api.pty.kill).not.toHaveBeenCalledWith('pty-right')
})
it('commits the move in main before the target tab exists, then shows main’s tab', async () => {
const store = await seeded()
const tabsBefore = store.getState().tabsByWorktree[WT]?.map((tab) => tab.id)
const move = await moveHeldByMain(() => detachFrom(store))
expect(sentMove()).toEqual({
type: 'movePaneToNewTab',
workspace: WT,
tabId: SOURCE,
leafId: LEAF_2,
groupId: GROUP,
windowDetach: { ptyId: 'remote:env-1@@terminal-1' }
})
expect(store.getState().tabsByWorktree[WT]?.map((tab) => tab.id)).toEqual(tabsBefore)
const result = await move.answer()
const movedId = mainMovedTabId()
expect(result?.tab.id).toBe(movedId)
expect(store.getState().terminalLayoutsByTabId[movedId]?.root).toEqual({
type: 'leaf',
leafId: LEAF_2
})
})
it('ignores a repeat drop of a pane whose move is still committing', async () => {
const store = await seeded()
const move = await moveHeldByMain(() => detachFrom(store))
await expect(detachFrom(store)).resolves.toBeNull()
await expect(move.answer()).resolves.toMatchObject({ leafId: LEAF_2 })
expect(main.sentOfType('movePaneToNewTab')).toHaveLength(1)
expect(toast.error).not.toHaveBeenCalled()
})
it('binds the moved tab’s layout to the live PTY, not the saved one', async () => {
const store = await seeded()
const saved = store.getState().terminalLayoutsByTabId[SOURCE]?.ptyIdsByLeafId?.[LEAF_2]
expect(saved).toBeTruthy()
const result = await detachFrom(store, { livePtyId: 'pty-respawned' })
expect(sentMove().windowDetach).toEqual({ ptyId: 'pty-respawned' })
expect(result?.ptyId).toBe('pty-respawned')
expect(store.getState().terminalLayoutsByTabId[mainMovedTabId()]?.ptyIdsByLeafId).toEqual({
[LEAF_2]: 'pty-respawned'
})
})
})
@@ -0,0 +1,218 @@
// Layout-owner build: terminal-pane-tab-detach.test.ts's drag-out outcomes, reached through
// main's `movePaneToNewTab` and the frame that draws its result.
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as AgentStatusModule from '@/lib/agent-status'
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
import { createStoreCascadesMockApi } from '../../store/slices/store-cascades-test-harness'
import { LEAF_1, LEAF_2, splitLayout, unboundSplitLayout } from './terminal-pane-tab-detach-fixture'
import {
detachFrom,
GROUP,
mainMovedTabId,
openMainFrames,
OTHER_1,
OTHER_2,
seedDetachStore,
sentMove,
SOURCE,
twoPaneManager,
WT,
type DetachStore
} from './terminal-pane-tab-detach-main.test-fixture'
vi.mock('../../../../shared/layout-owner-switch', () => ({ ORCA_LAYOUT_OWNER_IS_MAIN: true }))
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeLayoutClientWithTestFrames(await importOriginal())
)
vi.mock('sonner', () => ({
toast: { info: vi.fn(), success: vi.fn(), error: vi.fn(), warning: vi.fn() }
}))
vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({
restorePtyDataHandlersAfterFailedShutdown: vi.fn(),
unregisterPtyDataHandlers: vi.fn(() => [])
}))
vi.mock('@/lib/agent-status', async (importOriginal) => {
const actual = await importOriginal<typeof AgentStatusModule>()
return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) }
})
let closeFrames: () => void = () => {}
beforeEach(() => {
createStoreCascadesMockApi()
})
afterEach(() => {
closeFrames()
})
async function seeded(...args: Parameters<typeof seedDetachStore>): Promise<DetachStore> {
const store = seedDetachStore(...args)
closeFrames = await openMainFrames(store)
return store
}
function movedRow(store: DetachStore) {
return store.getState().tabsByWorktree[WT]?.find((tab) => tab.id === mainMovedTabId())
}
describe('detachTerminalPaneToTab, main moving the pane (layout-owner build)', () => {
it.each([LEAF_1, LEAF_2])('moves chat mode only with its owning leaf %s', async (chatLeafId) => {
const store = await seeded({ ...splitLayout(), chatLeafId })
await detachFrom(store)
const movedId = mainMovedTabId()
const state = store.getState()
const movedTab = state.unifiedTabsByWorktree[WT]?.find((tab) => tab.entityId === movedId)
expect(movedTab?.viewMode ?? 'terminal').toBe(chatLeafId === LEAF_2 ? 'chat' : 'terminal')
expect(state.terminalLayoutsByTabId[movedId]?.chatLeafId).toBe(
chatLeafId === LEAF_2 ? LEAF_2 : undefined
)
expect(state.terminalLayoutsByTabId[SOURCE]?.chatLeafId).toBe(
chatLeafId === LEAF_1 ? LEAF_1 : undefined
)
})
it('shows main’s new terminal tab holding the moved pane and its terminal', async () => {
const store = await seeded(splitLayout(), { shellOverride: 'powershell.exe', sshRepo: true })
const manager = twoPaneManager()
const persistLayoutSnapshot = vi.fn()
const result = await detachFrom(store, {
manager,
persistLayoutSnapshot,
sourcePaneCwd: {
cwd: '/remote/repo',
confirmed: false,
deferredSplitSpawn: true,
pendingCwd: Promise.resolve('/remote/repo/packages/app')
}
})
expect(sentMove()).toMatchObject({
workspace: WT,
tabId: SOURCE,
leafId: LEAF_2,
groupId: GROUP,
windowDetach: { ptyId: 'remote:env-1@@terminal-1' }
})
const movedId = mainMovedTabId()
expect(result).toMatchObject({ tab: { id: movedId }, ptyId: 'remote:env-1@@terminal-1' })
expect(manager.detachPaneForExternalMove).toHaveBeenCalledWith(2)
expect(movedRow(store)).toMatchObject({ shellOverride: 'powershell.exe' })
const state = store.getState()
expect(state.terminalLayoutsByTabId[SOURCE]).toMatchObject({
root: { type: 'leaf', leafId: LEAF_1 },
activeLeafId: LEAF_1,
ptyIdsByLeafId: { [LEAF_1]: 'pty-left' }
})
expect(state.terminalLayoutsByTabId[movedId]).toMatchObject({
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' },
titlesByLeafId: { [LEAF_2]: 'remote shell' }
})
// The pane's scrollback is keyed by its leaf, so the moved tab saves it.
const saved = buildWorkspaceSessionPayload(state).terminalLayoutsByTabId[movedId]
expect(saved?.buffersByLeafId).toEqual({ [LEAF_2]: 'remote-buffer' })
expect(state.ptyIdsByTabId[movedId]).toEqual(['remote:env-1@@terminal-1'])
expect(state.ptyIdsByTabId[SOURCE]).toEqual(['pty-left'])
expect(state.activeTabIdByWorktree[WT]).toBe(movedId)
expect(state.activeTabTypeByWorktree[WT]).toBe('terminal')
expect(persistLayoutSnapshot).toHaveBeenCalled()
})
it.each(['powershell.exe', 'wsl.exe'])(
'preserves the moved PTY shell override when the source uses %s',
async (shellOverride) => {
const store = await seeded(splitLayout(), { shellOverride })
await detachFrom(store, { sourcePaneId: 1 })
expect(sentMove()).toMatchObject({ leafId: LEAF_1, windowDetach: { ptyId: 'pty-left' } })
expect(movedRow(store)).toMatchObject({ shellOverride })
expect(store.getState().ptyIdsByTabId[mainMovedTabId()]).toEqual(['pty-left'])
}
)
it('syncs PTY ownership when the primary source pane is detached', async () => {
const store = await seeded(splitLayout())
const result = await detachFrom(store, { sourcePaneId: 1 })
const movedId = mainMovedTabId()
expect(result?.ptyId).toBe('pty-left')
const state = store.getState()
expect(state.terminalLayoutsByTabId[SOURCE]).toMatchObject({
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-1@@terminal-1' },
titlesByLeafId: { [LEAF_2]: 'remote shell' }
})
expect(state.terminalLayoutsByTabId[movedId]?.root).toEqual({ type: 'leaf', leafId: LEAF_1 })
expect(state.ptyIdsByTabId[SOURCE]).toEqual(['remote:env-1@@terminal-1'])
expect(state.ptyIdsByTabId[movedId]).toEqual(['pty-left'])
})
it('moves the detached tab into the requested group slot', async () => {
const store = await seeded(splitLayout())
await detachFrom(store, { targetIndex: 1 })
expect(sentMove()).toMatchObject({ groupId: GROUP, index: 1 })
expect(store.getState().groupsByWorktree[WT]?.[0]?.tabOrder).toEqual([
OTHER_1,
mainMovedTabId(),
OTHER_2,
SOURCE
])
})
it('uses the live transport PTY id when the snapshot has not persisted it yet', async () => {
const store = await seeded(unboundSplitLayout())
await detachFrom(store, {
livePtyId: 'remote:env-2@@terminal-9',
sourcePaneCwd: {
cwd: '/remote/repo',
confirmed: false,
deferredSplitSpawn: true,
pendingCwd: Promise.resolve('/remote/repo/packages/app')
}
})
expect(sentMove().windowDetach).toMatchObject({ ptyId: 'remote:env-2@@terminal-9' })
expect(store.getState().terminalLayoutsByTabId[mainMovedTabId()]).toMatchObject({
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-2@@terminal-9' }
})
})
it('carries resolved cwd when detaching an unbound non-deferred pane', async () => {
const store = await seeded(unboundSplitLayout(), { shellOverride: 'powershell.exe' })
const result = await detachFrom(store, {
sourcePaneCwd: { cwd: '/remote/repo/packages/app', confirmed: false }
})
expect(sentMove().windowDetach).toEqual({
ptyId: null,
startupCwd: '/remote/repo/packages/app'
})
expect(result?.ptyId).toBeNull()
expect(movedRow(store)).toMatchObject({
ptyId: null,
shellOverride: 'powershell.exe',
startupCwd: '/remote/repo/packages/app',
pendingActivationSpawn: true
})
expect(store.getState().activeTabIdByWorktree[WT]).toBe(mainMovedTabId())
})
})
@@ -0,0 +1,167 @@
// A drag-out in a layout-owner build, end to end: a real store, main's model answering the
// window's `movePaneToNewTab` through the layout transport, and main's frame drawing the result.
// The importing test file installs the transport mocks (see layout-apply-test-transport).
import { expect, vi } from 'vitest'
import { buildWorkspaceSessionPayload } from '@/lib/workspace-session'
import { layoutApplyTestTransport } from '@/runtime/layout-apply-test-transport.test-fixture'
import { loadMainLayoutModel } from '@/runtime/main-loaded-layout-frames.test-fixture'
import { openWorkspaceLayoutFrameSync } from '@/runtime/workspace-layout-frame-sync'
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
import {
createTestStore,
makeWorktree,
seedStore,
TEST_REPO
} from '../../store/slices/store-test-helpers'
import { detachTerminalPaneToTab } from './terminal-pane-tab-detach'
import { LEAF_1, LEAF_2 } from './terminal-pane-tab-detach-fixture'
export const WT = 'repo1::/path/wt'
export const SOURCE = 'aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee'
export const OTHER_1 = 'cccccccc-dddd-4eee-8fff-000000000001'
export const OTHER_2 = 'cccccccc-dddd-4eee-8fff-000000000002'
export const GROUP = 'group-1'
export type DetachStore = ReturnType<typeof createTestStore>
function row(id: string, ptyId: string | null, createdAt: number, shellOverride?: string) {
return {
id,
ptyId,
worktreeId: WT,
title: `Terminal ${createdAt}`,
defaultTitle: `Terminal ${createdAt}`,
customTitle: null,
color: null,
sortOrder: createdAt,
createdAt,
...(shellOverride ? { shellOverride } : {})
}
}
function entry(id: string, createdAt: number, viewMode?: 'chat') {
return {
id,
entityId: id,
groupId: GROUP,
worktreeId: WT,
contentType: 'terminal' as const,
label: `Terminal ${createdAt}`,
customLabel: null,
color: null,
sortOrder: createdAt,
createdAt,
...(viewMode ? { viewMode } : {})
}
}
function soloLayout(leafId: string, ptyId: string): TerminalLayoutSnapshot {
return {
root: { type: 'leaf', leafId },
activeLeafId: leafId,
expandedLeafId: null,
ptyIdsByLeafId: { [leafId]: ptyId }
}
}
/** The source tab with `layout`, after two other tabs in its group: [OTHER_1, OTHER_2, SOURCE]. */
export function seedDetachStore(
layout: TerminalLayoutSnapshot,
/** `sshRepo`: the window keeps the pane's scrollback, as for any SSH terminal. */
options: { shellOverride?: string; sshRepo?: true } = {}
): DetachStore {
const store = createTestStore()
const sourcePty = layout.ptyIdsByLeafId?.[LEAF_1] ?? null
seedStore(store, {
...(options.sshRepo ? { repos: [{ ...TEST_REPO, connectionId: 'ssh-1' }] } : {}),
worktreesByRepo: { repo1: [makeWorktree({ id: WT, repoId: 'repo1', path: '/path/wt' })] },
tabsByWorktree: {
[WT]: [
row(OTHER_1, 'pty-other-1', 1),
row(OTHER_2, 'pty-other-2', 2),
row(SOURCE, sourcePty, 3, options.shellOverride)
]
},
unifiedTabsByWorktree: {
[WT]: [
entry(OTHER_1, 1),
entry(OTHER_2, 2),
entry(SOURCE, 3, layout.chatLeafId ? 'chat' : undefined)
]
},
groupsByWorktree: {
[WT]: [
{
id: GROUP,
worktreeId: WT,
activeTabId: SOURCE,
tabOrder: [OTHER_1, OTHER_2, SOURCE],
recentTabIds: [SOURCE]
}
]
},
activeGroupIdByWorktree: { [WT]: GROUP },
activeTabIdByWorktree: { [WT]: SOURCE },
terminalLayoutsByTabId: {
[OTHER_1]: soloLayout('aaaaaaaa-1111-4111-8111-111111111111', 'pty-other-1'),
[OTHER_2]: soloLayout('bbbbbbbb-2222-4222-8222-222222222222', 'pty-other-2'),
[SOURCE]: layout
}
})
return store
}
/** Main loads the store's session as its model, and the window draws main's frames from now on. */
export async function openMainFrames(store: DetachStore): Promise<() => void> {
layoutApplyTestTransport.reset()
layoutApplyTestTransport.answerWith('apply')
layoutApplyTestTransport.useMainModel(
loadMainLayoutModel('local', buildWorkspaceSessionPayload(store.getState()))
)
const frames = openWorkspaceLayoutFrameSync(store)
frames.start()
await vi.waitFor(() => expect(store.getState().tabsByWorktree[WT]).toBeDefined())
return () => frames.close()
}
export type DetachArgs = Partial<Parameters<typeof detachTerminalPaneToTab>[0]>
/** The pane manager of a two-pane source tab: pane 1 is LEAF_1, pane 2 is LEAF_2. */
export function twoPaneManager() {
return {
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
getLeafId: vi.fn((paneId: number): string | null => (paneId === 2 ? LEAF_2 : LEAF_1)),
detachPaneForExternalMove: vi.fn(() => true)
}
}
export function detachFrom(store: DetachStore, args: DetachArgs = {}) {
return detachTerminalPaneToTab({
getStore: () => store.getState(),
manager: twoPaneManager(),
persistLayoutSnapshot: vi.fn(),
sourcePaneId: 2,
sourceTabId: SOURCE,
targetGroupId: GROUP,
worktreeId: WT,
...args
})
}
/** The one `movePaneToNewTab` the window sent main. */
export function sentMove() {
const sent = layoutApplyTestTransport.sentOfType('movePaneToNewTab')
expect(sent).toHaveLength(1)
return sent[0]!
}
/** The tab main minted for the move, as main's model holds it. */
export function mainMovedTabId(): string {
const workspace = layoutApplyTestTransport.mainModel?.workspaces[WT]
const ids = (workspace?.tabs ?? [])
.map((tab) => tab.id)
.filter((id) => ![SOURCE, OTHER_1, OTHER_2].includes(id))
expect(ids).toHaveLength(1)
return ids[0]!
}
@@ -16,6 +16,11 @@ import {
unboundSplitLayout,
WORKTREE_ID
} from './terminal-pane-tab-detach-fixture'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
// The window makes the new tab itself; the layout-owner build's equivalents (main's command plus
// its frame) are in terminal-pane-tab-detach-main-move.test.ts.
const itSwitchOff = it.skipIf(ORCA_LAYOUT_OWNER_IS_MAIN)
const toastErrorMock = vi.hoisted(() => vi.fn())
vi.mock('sonner', () => ({ toast: { error: toastErrorMock } }))
@@ -177,32 +182,35 @@ describe('resolveTerminalTabStripDropTarget', () => {
})
describe('detachTerminalPaneToTab', () => {
it.each([LEAF_1, LEAF_2])('moves chat mode only with its owning leaf %s', async (chatLeafId) => {
const store = createStore({ ...splitLayout(), chatLeafId })
await detachTerminalPaneToTab({
getStore: () => store,
manager: {
getPanes: () => [{ id: 1 }, { id: 2 }],
getLeafId: () => LEAF_2,
detachPaneForExternalMove: () => true
},
persistLayoutSnapshot: vi.fn(),
sourcePaneId: 2,
sourceTabId: SOURCE_TAB_ID,
targetGroupId: TARGET_GROUP_ID,
worktreeId: WORKTREE_ID
})
const options = vi.mocked(store.createTab).mock.calls[0]?.[3]
expect(options?.viewMode ?? 'terminal').toBe(chatLeafId === LEAF_2 ? 'chat' : 'terminal')
expect(store.terminalLayoutsByTabId['tab-detached']?.chatLeafId).toBe(
chatLeafId === LEAF_2 ? LEAF_2 : undefined
)
expect(store.terminalLayoutsByTabId[SOURCE_TAB_ID]?.chatLeafId).toBe(
chatLeafId === LEAF_1 ? LEAF_1 : undefined
)
})
itSwitchOff.each([LEAF_1, LEAF_2])(
'moves chat mode only with its owning leaf %s',
async (chatLeafId) => {
const store = createStore({ ...splitLayout(), chatLeafId })
await detachTerminalPaneToTab({
getStore: () => store,
manager: {
getPanes: () => [{ id: 1 }, { id: 2 }],
getLeafId: () => LEAF_2,
detachPaneForExternalMove: () => true
},
persistLayoutSnapshot: vi.fn(),
sourcePaneId: 2,
sourceTabId: SOURCE_TAB_ID,
targetGroupId: TARGET_GROUP_ID,
worktreeId: WORKTREE_ID
})
const options = vi.mocked(store.createTab).mock.calls[0]?.[3]
expect(options?.viewMode ?? 'terminal').toBe(chatLeafId === LEAF_2 ? 'chat' : 'terminal')
expect(store.terminalLayoutsByTabId['tab-detached']?.chatLeafId).toBe(
chatLeafId === LEAF_2 ? LEAF_2 : undefined
)
expect(store.terminalLayoutsByTabId[SOURCE_TAB_ID]?.chatLeafId).toBe(
chatLeafId === LEAF_1 ? LEAF_1 : undefined
)
}
)
it('creates a new terminal tab with the detached leaf layout and PTY id', async () => {
itSwitchOff('creates a new terminal tab with the detached leaf layout and PTY id', async () => {
const store = createStore()
const manager = {
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
@@ -267,7 +275,7 @@ describe('detachTerminalPaneToTab', () => {
expect(persistLayoutSnapshot).toHaveBeenCalled()
})
it.each(['powershell.exe', 'wsl.exe'])(
itSwitchOff.each(['powershell.exe', 'wsl.exe'])(
'preserves the moved PTY shell override when the source uses %s',
async (shellOverride) => {
const store = createStore(splitLayout(), [EXISTING_TAB_1], shellOverride)
@@ -296,7 +304,7 @@ describe('detachTerminalPaneToTab', () => {
}
)
it('syncs PTY ownership when the primary source pane is detached', async () => {
itSwitchOff('syncs PTY ownership when the primary source pane is detached', async () => {
const store = createStore()
const manager = {
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
@@ -339,7 +347,7 @@ describe('detachTerminalPaneToTab', () => {
})
})
it('moves the detached tab into the requested group slot', async () => {
itSwitchOff('moves the detached tab into the requested group slot', async () => {
const store = createStore(splitLayout(), [EXISTING_TAB_1, EXISTING_TAB_2])
const manager = {
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
@@ -365,47 +373,50 @@ describe('detachTerminalPaneToTab', () => {
)
})
it('uses the live transport PTY id when the snapshot has not persisted it yet', async () => {
const store = createStore({
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: LEAF_1 },
second: { type: 'leaf', leafId: LEAF_2 }
},
activeLeafId: LEAF_2,
expandedLeafId: null
})
const manager = {
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
getLeafId: vi.fn(() => LEAF_2),
detachPaneForExternalMove: vi.fn(() => true)
itSwitchOff(
'uses the live transport PTY id when the snapshot has not persisted it yet',
async () => {
const store = createStore({
root: {
type: 'split',
direction: 'vertical',
first: { type: 'leaf', leafId: LEAF_1 },
second: { type: 'leaf', leafId: LEAF_2 }
},
activeLeafId: LEAF_2,
expandedLeafId: null
})
const manager = {
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
getLeafId: vi.fn(() => LEAF_2),
detachPaneForExternalMove: vi.fn(() => true)
}
await detachTerminalPaneToTab({
livePtyId: 'remote:env-2@@terminal-9',
getStore: () => store,
manager,
persistLayoutSnapshot: vi.fn(),
sourcePaneCwd: {
cwd: '/remote/repo',
confirmed: false,
deferredSplitSpawn: true,
pendingCwd: Promise.resolve('/remote/repo/packages/app')
},
sourcePaneId: 2,
sourceTabId: SOURCE_TAB_ID,
targetGroupId: TARGET_GROUP_ID,
worktreeId: WORKTREE_ID
})
expect(store.setTabLayout).toHaveBeenCalledWith('tab-detached', {
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-2@@terminal-9' }
})
}
await detachTerminalPaneToTab({
livePtyId: 'remote:env-2@@terminal-9',
getStore: () => store,
manager,
persistLayoutSnapshot: vi.fn(),
sourcePaneCwd: {
cwd: '/remote/repo',
confirmed: false,
deferredSplitSpawn: true,
pendingCwd: Promise.resolve('/remote/repo/packages/app')
},
sourcePaneId: 2,
sourceTabId: SOURCE_TAB_ID,
targetGroupId: TARGET_GROUP_ID,
worktreeId: WORKTREE_ID
})
expect(store.setTabLayout).toHaveBeenCalledWith('tab-detached', {
root: { type: 'leaf', leafId: LEAF_2 },
activeLeafId: LEAF_2,
expandedLeafId: null,
ptyIdsByLeafId: { [LEAF_2]: 'remote:env-2@@terminal-9' }
})
})
)
it('rejects a deferred split while inherited cwd is pending', async () => {
await expectDeferredSplitDetachRejected({
@@ -430,7 +441,7 @@ describe('detachTerminalPaneToTab', () => {
})
})
it('carries resolved cwd when detaching an unbound non-deferred pane', async () => {
itSwitchOff('carries resolved cwd when detaching an unbound non-deferred pane', async () => {
const store = createStore(unboundSplitLayout())
const manager = {
getPanes: vi.fn(() => [{ id: 1 }, { id: 2 }]),
@@ -8,7 +8,7 @@ import { prepareTerminalPaneMount } from './terminal-pane-mount-preparation'
import { createTerminalPaneManagerOptions } from './terminal-pane-manager-options'
import { restoreTerminalPaneLayout } from './terminal-pane-layout-restore'
import { runTerminalPaneBootstrapSplits } from './terminal-pane-mount-bootstrap'
import { recordingBootstrapSplits } from './terminal-pane-bootstrap-split-record'
import { runBootstrapSplitInMain } from './terminal-pane-bootstrap-split-in-main'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch'
import { installTerminalPaneMountEvents } from './terminal-pane-mount-events'
import { cleanupTerminalPaneMount } from './terminal-pane-mount-cleanup'
@@ -85,15 +85,24 @@ export function useTerminalPaneMountLifecycle(
ptyDeps,
initialLayoutHadBuffers: preparation.initialLayoutHadBuffers
})
runTerminalPaneBootstrapSplits({
// Switch point: startup splits are recorded in main under the window's pane ids.
manager: ORCA_LAYOUT_OWNER_IS_MAIN
? recordingBootstrapSplits(manager, deps.worktreeId, deps.tabId)
: manager,
ptyDeps,
issueCommandSplit: deps.issueCommandSplit,
isActive: deps.isActive
})
// Switch point: main writes a startup split and starts its command; its frame draws the pane.
if (ORCA_LAYOUT_OWNER_IS_MAIN) {
runBootstrapSplitInMain({
manager,
worktreeId: deps.worktreeId,
tabId: deps.tabId,
ptyDeps,
issueCommandSplit: deps.issueCommandSplit,
isActive: deps.isActive
})
} else {
runTerminalPaneBootstrapSplits({
manager,
ptyDeps,
issueCommandSplit: deps.issueCommandSplit,
isActive: deps.isActive
})
}
shouldPersistLayout = true
preparation.syncCanExpandState()
preparation.syncPaneCount()
@@ -1,7 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
_resetLayoutOwnerPaneIntentsForTests,
expectPaneToAppear,
expectPaneToClose,
expectTabRowFields,
holdTabPaneTree,
@@ -9,23 +8,16 @@ import {
onTabPaneTreeRedraw,
panesLeftAfterPendingCloses,
requestTabPaneTreeRedraw,
takePaneAppearIntent,
takePaneCloseIntent,
takeTabRowFields
} from './layout-owner-pane-intents'
const telemetry = { source: 'keyboard', direction: 'vertical' } as const
describe('layout-owner pane intents', () => {
beforeEach(() => _resetLayoutOwnerPaneIntentsForTests())
it('hands an intent out once, and only for its own tab', () => {
expectPaneToAppear('leaf-new', { tabId: 'tab-1', cwd: '/repo', telemetry })
expectPaneToClose('tab-1', 'leaf-old', 'user')
expect(takePaneAppearIntent('tab-2', 'leaf-new')).toBeNull()
expect(takePaneAppearIntent('tab-1', 'leaf-new')).toMatchObject({ cwd: '/repo' })
expect(takePaneAppearIntent('tab-1', 'leaf-new')).toBeNull()
expect(takePaneCloseIntent('tab-2', 'leaf-old')).toBeNull()
expect(takePaneCloseIntent('tab-1', 'leaf-old')).toBe('user')
expect(takePaneCloseIntent('tab-1', 'leaf-old')).toBeNull()
@@ -42,11 +34,11 @@ describe('layout-owner pane intents', () => {
it('keeps only the newest intents', () => {
for (let index = 0; index < 65; index += 1) {
expectPaneToAppear(`leaf-${index}`, { tabId: 'tab-1', telemetry })
expectPaneToClose('tab-1', `leaf-${index}`, 'exit')
}
expect(takePaneAppearIntent('tab-1', 'leaf-0')).toBeNull()
expect(takePaneAppearIntent('tab-1', 'leaf-64')).not.toBeNull()
expect(takePaneCloseIntent('tab-1', 'leaf-0')).toBeNull()
expect(takePaneCloseIntent('tab-1', 'leaf-64')).toBe('exit')
})
it('hands out a new row’s fields once, until released', () => {
@@ -1,23 +1,15 @@
// What this window asked main to do to a tab's panes, in a layout-owner build: main's frames say
// which panes a tab has, these say what the window wants done when one appears or goes (its
// spawn inputs, its close, the selection of a tab it moved) and keep a tab's drawn tree while the
// window's own command is in flight. A pane a frame adds or drops with no intent here is left to
// main's notice to the window.
// which panes a tab has, these say how the window wants a pane closed when one goes and the
// selection of a tab it moved, and keep a tab's drawn tree while the window's own command is in
// flight. A pane a frame drops with no intent here is left to main's notice to the window; every
// pane a frame adds is mounted, and takes focus unless the window's own split asked to keep focus
// where it was.
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import type { TerminalPaneSplitSource } from '../../../shared/feature-education-telemetry'
export type PaneAppearIntent = {
tabId: string
cwd?: string
cwdPromise?: Promise<string>
telemetry: { source: TerminalPaneSplitSource; direction: 'vertical' | 'horizontal' }
}
/** Intents outlive their frame only when a tab never mounts again; keep the newest few. */
const MAX_INTENTS = 64
const appearing = new Map<string, PaneAppearIntent>()
/** 'user': the user's close, with its status and launch clean-up. 'exit': its terminal ended. */
export type PaneCloseKind = 'user' | 'exit'
@@ -31,24 +23,6 @@ function remember<T>(map: Map<string, T>, key: string, value: T): void {
}
}
export function expectPaneToAppear(leafId: string, intent: PaneAppearIntent): () => void {
remember(appearing, leafId, intent)
return () => {
if (appearing.get(leafId) === intent) {
appearing.delete(leafId)
}
}
}
export function takePaneAppearIntent(tabId: string, leafId: string): PaneAppearIntent | null {
const intent = appearing.get(leafId)
if (intent?.tabId !== tabId) {
return null
}
appearing.delete(leafId)
return intent
}
export function expectPaneToClose(tabId: string, leafId: string, kind: PaneCloseKind): () => void {
const intent = { tabId, kind }
remember(closing, leafId, intent)
@@ -59,6 +33,26 @@ export function expectPaneToClose(tabId: string, leafId: string, kind: PaneClose
}
}
const unfocused = new Map<string, string>()
/** A split of the window's that leaves focus on its source pane (a tab's startup split). */
export function expectPaneToMountUnfocused(tabId: string, leafId: string): () => void {
remember(unfocused, leafId, tabId)
return () => {
if (unfocused.get(leafId) === tabId) {
unfocused.delete(leafId)
}
}
}
export function takePaneMountUnfocused(tabId: string, leafId: string): boolean {
if (unfocused.get(leafId) !== tabId) {
return false
}
unfocused.delete(leafId)
return true
}
/** Of these panes, how many no close of this window's is about to take. */
export function panesLeftAfterPendingCloses(tabId: string, leafIds: readonly string[]): number {
return leafIds.filter((leafId) => closing.get(leafId)?.tabId !== tabId).length
@@ -171,8 +165,8 @@ export function onTabPaneTreeRedraw(listener: (tabId: string) => void): () => vo
}
export function _resetLayoutOwnerPaneIntentsForTests(): void {
appearing.clear()
closing.clear()
unfocused.clear()
rowFields.clear()
movedTabSelections.clear()
heldTabs.clear()
@@ -253,3 +253,68 @@ describe('a frame that changes only panes’ terminal bindings', () => {
sync.close()
})
})
/** The frame with one pane's start status set, or cleared when `status` is absent. */
function started(
layout: PublishedWorkspaceLayout,
leafId: string,
status?: 'starting' | 'failed',
reason?: string
): PublishedWorkspaceLayout {
const next = structuredClone(layout)
const pane = next.tabs
.find((tab) => tab.entityId === 'tab-shell')!
.terminal!.panes.find((candidate) => candidate.leafId === leafId)!
delete pane.status
delete pane.statusReason
if (status) {
pane.status = status
}
if (reason) {
pane.statusReason = reason
}
return next
}
describe('a frame that changes only a pane’s start status', () => {
it('shows the status for that one pane, with no build and the pane tree untouched', async () => {
const { store, sync, first, send } = await boot()
const layoutsBefore = store.getState().terminalLayoutsByTabId
const unbound = rebound(first, 'tab-shell', leaf(1), undefined)
await send(unbound)
const layouts = store.getState().terminalLayoutsByTabId
check.forgetBuilds()
await send(started(unbound, leaf(1), 'starting'))
expect(store.getState().terminalPaneStartsByPaneKey).toEqual({
[`tab-shell:${leaf(1)}`]: { status: 'starting' }
})
await send(started(unbound, leaf(1), 'failed', 'shell not found'))
expect([...check.fullBuilt, ...check.workspaceBuilt]).toEqual([])
// Same tree objects: the pane's view is not remounted.
expect(store.getState().terminalLayoutsByTabId).toBe(layouts)
expect(layouts['tab-shell']!.root).toBe(layoutsBefore['tab-shell']!.root)
expect(store.getState().terminalPaneStartsByPaneKey).toEqual({
[`tab-shell:${leaf(1)}`]: { status: 'failed', reason: 'shell not found' }
})
sync.close()
})
it('drops the status once main binds the pane', async () => {
const { store, sync, first, send } = await boot()
const unbound = rebound(first, 'tab-shell', leaf(1), undefined)
await send(started(unbound, leaf(1), 'starting'))
expect(Object.keys(store.getState().terminalPaneStartsByPaneKey)).toEqual([
`tab-shell:${leaf(1)}`
])
await send(rebound(unbound, 'tab-shell', leaf(1), 'pty-fresh'))
expect(store.getState().terminalPaneStartsByPaneKey).toEqual({})
expect(store.getState().terminalLayoutsByTabId['tab-shell']!.ptyIdsByLeafId?.[leaf(1)]).toBe(
'pty-fresh'
)
sync.close()
})
})
@@ -6,6 +6,7 @@ import { resolvePtyBoundActiveLeafId } from '@/components/terminal-pane/terminal
import type { AppState } from '@/store/types'
import { layoutContainsLeafId } from '../../../shared/workspace-layout/terminal-pane-tree'
import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published'
import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache'
import { isDrawnTabHeld } from './layout-owner-drawn-tab-holds'
import { isTabPaneTreeHeld } from './layout-owner-pane-intents'
@@ -70,3 +71,21 @@ export function terminalLayoutsWithFrameBindings(
}
return next
}
/** Every rebound workspace's bindings shown, and the workspaces only a build shows so. */
export function terminalLayoutsWithReboundFrames(
layouts: TerminalLayouts,
cache: WorkspaceLayoutCache,
rebound: ReadonlyMap<string, ReadonlySet<string>>
): { layouts: TerminalLayouts; unshown: string[] } {
const unshown: string[] = []
for (const [key, tabIds] of rebound) {
const next = terminalLayoutsWithFrameBindings(layouts, key, cache[key]!, tabIds)
if (next) {
layouts = next
} else {
unshown.push(key)
}
}
return { layouts, unshown }
}
@@ -0,0 +1,78 @@
// Main's pane starts, as the window's store shows them: a status-only frame changes this map and no
// pane tree, so the pane's view stays mounted.
import type { AppState } from '@/store/types'
import type { TerminalPaneStart } from '@/store/terminals/terminal-state'
import { paneKeyOf } from '../../../shared/workspace-layout/workspace-layout-model'
import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published'
type PaneStarts = AppState['terminalPaneStartsByPaneKey']
function startsOf(layout: PublishedWorkspaceLayout | undefined): Map<string, TerminalPaneStart> {
const starts = new Map<string, TerminalPaneStart>()
for (const tab of layout?.tabs ?? []) {
for (const pane of tab.terminal?.panes ?? []) {
if (pane.status) {
starts.set(paneKeyOf(tab.entityId, pane.leafId), {
status: pane.status,
...(pane.statusReason ? { reason: pane.statusReason } : {})
})
}
}
}
return starts
}
const sameStart = (a: TerminalPaneStart | undefined, b: TerminalPaneStart): boolean =>
a?.status === b.status && a.reason === b.reason
/**
* `current` with each of `keys`' panes showing what its workspace's frame says, the same object
* when nothing changed. `shownByKey` holds the pane keys each workspace last showed, so a start
* that ended (the pane bound, or left) drops from the map; it is updated in place.
*/
export function paneStartsWithFrames(
current: PaneStarts,
keys: Iterable<string>,
frames: Readonly<Record<string, PublishedWorkspaceLayout | undefined>>,
shownByKey: Map<string, ReadonlySet<string>>
): PaneStarts {
let next = current
const write = (): PaneStarts => (next === current ? { ...current } : next)
for (const key of keys) {
const now = startsOf(frames[key])
for (const paneKey of shownByKey.get(key) ?? []) {
if (!now.has(paneKey) && paneKey in next) {
next = write()
delete next[paneKey]
}
}
for (const [paneKey, start] of now) {
if (!sameStart(next[paneKey], start)) {
next = write()
next[paneKey] = start
}
}
if (now.size > 0) {
shownByKey.set(key, new Set(now.keys()))
} else {
shownByKey.delete(key)
}
}
return next
}
/** Shows the starts of the `changed` workspaces, and drops those of workspaces main stopped sending. */
export function showFramePaneStarts(
store: { getState: () => AppState; setState: (patch: Partial<AppState>) => void },
changed: readonly string[],
cache: Readonly<Record<string, PublishedWorkspaceLayout | undefined>>,
shownByKey: Map<string, ReadonlySet<string>>
): void {
const starts = store.getState().terminalPaneStartsByPaneKey
const gone = [...shownByKey.keys()].filter((key) => !(key in cache))
const next = paneStartsWithFrames(starts, [...changed, ...gone], cache, shownByKey)
if (next !== starts) {
store.setState({ terminalPaneStartsByPaneKey: next })
}
}
@@ -25,8 +25,9 @@ import {
import type { TerminalTabTitleUpdate } from '@/store/slices/terminal-tab-title-batch'
import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames'
import { liveTitleUpdates } from './workspace-layout-frame-changes'
import { terminalLayoutsWithFrameBindings } from './workspace-layout-frame-bindings'
import { terminalLayoutsWithReboundFrames } from './workspace-layout-frame-bindings'
import { terminalLayoutsWithFramePaneTrees } from './workspace-layout-frame-pane-trees'
import { showFramePaneStarts } from './workspace-layout-frame-pane-starts'
import {
fullLayoutFrameBuildPatch,
workspaceLayoutFrameBuildPatch,
@@ -93,6 +94,7 @@ export function openWorkspaceLayoutFrameSync(
/** Workspaces whose pane trees the store may still lack: a build ran for them, or a tab the
* window's own command held skipped one. */
let paneTreeKeys = new Set<string>()
const paneStartsShown = new Map<string, ReadonlySet<string>>()
/** The moved tabs' selections this flush's writes took. */
let selectionsTaken: MovedTabSelection[] = []
@@ -134,18 +136,13 @@ export function openWorkspaceLayoutFrameSync(
// Only panes' bindings changed: the pane trees take them, unless only a build shows them so.
const showFrameBindings = (rebound: ReadonlyMap<string, ReadonlySet<string>>): string[] => {
const state = store.getState()
let layouts = state.terminalLayoutsByTabId
const unshown: string[] = []
for (const [key, tabIds] of rebound) {
const next = terminalLayoutsWithFrameBindings(layouts, key, cache[key]!, tabIds)
if (next) {
layouts = next
} else {
unshown.push(key)
}
}
show(terminalLayoutsPatch(state, layouts))
return unshown
const shownBindings = terminalLayoutsWithReboundFrames(
state.terminalLayoutsByTabId,
cache,
rebound
)
show(terminalLayoutsPatch(state, shownBindings.layouts))
return shownBindings.unshown
}
const flush = (): void => {
@@ -222,6 +219,7 @@ export function openWorkspaceLayoutFrameSync(
if (titleUpdates.length > 0) {
store.getState().updateTabTitles(titleUpdates)
}
showFramePaneStarts(store, changed, cache, paneStartsShown)
for (const key of [...fullBuilt, ...workspaceBuilt]) {
shown.add(key)
// Against the store, not the frame: a held tab's tree may lag the pane trees drawn since.
@@ -39,6 +39,7 @@ export const createTerminalSlice: StateCreator<AppState, [], [], TerminalSlice>
expandedPaneByTabId: {},
canExpandPaneByTabId: {},
terminalLayoutsByTabId: {},
terminalPaneStartsByPaneKey: {},
localOnlyScrollbackByTabId: {},
pendingStartupByTabId: {},
pendingInitialCwdByTabId: {},
@@ -1,5 +1,6 @@
import type { ClosedTerminalTabTombstonesByTabId } from '../../../../shared/closed-terminal-tab-tombstones'
import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/terminal-tab-types'
import type { PaneStartStatus } from '../../../../shared/workspace-layout/workspace-layout-published'
import type { TuiAgent } from '../../../../shared/tui-agent'
import type {
AgentProviderSessionMetadata,
@@ -38,6 +39,9 @@ export type TerminalState = {
expandedPaneByTabId: Record<string, boolean>
canExpandPaneByTabId: Record<string, boolean>
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
/** Main's start of a pane's terminal, by pane key, as its layout frames publish it (layout-owner
* build). Live state, never saved: a pane with an entry has no terminal of its own yet. */
terminalPaneStartsByPaneKey: Record<string, TerminalPaneStart>
/** Ordinary-park scrollback, tabId -> leafId -> buffer. Never uploaded to a peer; see
* WorkspaceSessionState.localOnlyScrollbackByTabId. Read via resolveLeafScrollbackBuffers only. */
localOnlyScrollbackByTabId: Record<string, Record<string, string>>
@@ -145,3 +149,5 @@ export type TerminalSlice = TerminalState & TerminalActions & PairedTerminalTabC
export type TerminalStoreSet = Parameters<StateCreator<AppState, [], [], AppState>>[0]
export type TerminalStoreGet = Parameters<StateCreator<AppState, [], [], AppState>>[1]
export type TerminalPaneStart = { status: PaneStartStatus; reason?: string }
@@ -236,16 +236,41 @@ export const TerminalCreateParams = z.object({
shell: WindowsShellOverrideParam.optional()
})
export const TerminalSplit = TerminalHandle.extend({
direction: z
.unknown()
.transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined))
.pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()]))
.optional(),
command: OptionalString,
env: z.record(z.string(), z.string()).optional(),
telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional()
})
export const TerminalSplit = z
.object({
// Required unless `pane` names the source; the refinement keeps the handle's message.
terminal: OptionalString,
expectedIncarnationId: TerminalHandle.shape.expectedIncarnationId,
direction: z
.unknown()
.transform((v) => (v === 'vertical' || v === 'horizontal' ? v : undefined))
.pipe(z.union([z.enum(['vertical', 'horizontal']), z.undefined()]))
.optional(),
command: OptionalString,
env: z.record(z.string(), z.string()).optional(),
telemetrySource: z.enum(TERMINAL_PANE_SPLIT_SOURCES).optional(),
// Layout-owner build: a window names its own pane; main writes the split and starts it.
pane: z
.object({
worktreeId: requiredString('Missing worktree'),
tabId: requiredString('Missing tab'),
leafId: requiredString('Missing pane')
})
.optional(),
newLeafId: OptionalString,
cwd: OptionalString,
initialSize: z
.object({
cols: z.number().int().min(1).max(1000),
rows: z.number().int().min(1).max(500)
})
.optional()
})
.superRefine((params, ctx) => {
if (!params.terminal && !params.pane) {
ctx.addIssue({ code: 'custom', message: 'Missing terminal handle', path: ['terminal'] })
}
})
export const TerminalStop = z.object({
worktree: requiredString('Missing worktree selector')
+11 -17
View File
@@ -4,7 +4,10 @@ import {
readPaneIdentitySnapshot,
resolveActiveTabId
} from './terminal-pane-identity'
import { REQUEST_ACTIVE_TERMINAL_PANE_CLOSE_EVENT } from '../../../src/renderer/src/constants/terminal'
import {
REQUEST_ACTIVE_TERMINAL_PANE_CLOSE_EVENT,
REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT
} from '../../../src/renderer/src/constants/terminal'
export async function readTerminalPaneDomLeafOrder(page: Page): Promise<string[]> {
const snapshot = await readPaneIdentitySnapshot(page)
@@ -99,25 +102,16 @@ export async function splitActiveTerminalPane(
throw new Error('splitActiveTerminalPane: no active terminal tab')
}
await page.evaluate(
({ tabId, direction }) => {
const paneManagers = window.__paneManagers
if (!paneManagers) {
throw new Error('splitActiveTerminalPane: terminal store/manager unavailable')
}
const manager = paneManagers.get(tabId)
const activePane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
if (!manager?.splitPane || !activePane) {
({ tabId, direction, splitEvent }) => {
const manager = window.__paneManagers?.get(tabId)
if (!manager?.getActivePane?.() && !manager?.getPanes?.()[0]) {
throw new Error('splitActiveTerminalPane: active pane manager not ready')
}
// Why: Electron key delivery to the terminal pane layer is flaky in E2E
// even when the visible pane tree is mounted. Driving the active
// PaneManager directly still exercises the real split/layout/PTY path
// without depending on window-focus timing.
manager.splitPane(activePane.id, direction)
// Why: Electron key delivery to the terminal pane layer is flaky in E2E. The pane menu's
// split request takes the user's split path, which a layout-owner build sends to main.
window.dispatchEvent(new CustomEvent(splitEvent, { detail: { tabId, direction } }))
},
{ tabId, direction }
{ tabId, direction, splitEvent: REQUEST_ACTIVE_TERMINAL_PANE_SPLIT_EVENT }
)
}
@@ -88,96 +88,20 @@ async function handleFor(
return handle
}
// Main closes the split's tab while the window is too busy to mount the split: the CLI hears so
// once the close lands, not after its 10 s wait.
test('a CLI split whose tab closes before it mounts answers promptly', async ({
electronApp,
orcaPage
}, testInfo) => {
test.skip(!LAYOUT_OWNER_BUILD, 'layout-owner build only')
test.setTimeout(180_000)
await waitForSessionReady(orcaPage)
const worktreeId = await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData'))
const client = new RuntimeClient(userDataDir, 30_000)
const tabId = await createActiveTab(orcaPage, worktreeId)
const source = await handleFor(client, worktreeId, tabId)
await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { parkDelayMs: PARKING_DELAY_MS })
await orcaPage.evaluate(() => {
window.addEventListener(
'orca-background-mount-terminal-worktree',
() => {
const until = performance.now() + 2_500
while (performance.now() < until) {
// Busy renderer.
}
},
{ once: true, capture: true }
)
})
const startedAt = Date.now()
const split = runCliSplit(userDataDir, source, 'echo never')
// The CLI starts and main writes the pane while the window stalls on the split's mount.
await new Promise((resolve) => setTimeout(resolve, 800))
const closeStartedAt = Date.now()
const closed = await client.call('terminal.closeTab', { terminal: source }).then(
() => 'ok',
(error: unknown) => String(error)
)
const outcome = await split
const elapsedMs = Date.now() - startedAt
const report = { closed, closeAfterMs: closeStartedAt - startedAt, elapsedMs, outcome }
await testInfo.attach('split-closed-tab.json', {
body: JSON.stringify(report, null, 2),
contentType: 'application/json'
})
expect(outcome.ok).toBe(false)
// Failed because of the close, not some earlier CLI failure.
expect(outcome.ok ? '' : outcome.error).toContain('split_pane_closed')
expect(elapsedMs).toBeLessThan(6_000)
})
/** Keeps the renderer busy for `ms`, as a window too busy to mount anything. */
async function stallRenderer(page: Page, ms: number): Promise<void> {
await page.evaluate((stallMs) => {
setTimeout(() => {
const until = performance.now() + stallMs
while (performance.now() < until) {
// Busy renderer.
}
}, 0)
}, ms)
}
// The window is too busy to start the split's terminal within the CLI's 10 s: main takes the
// pane back, so the CLI's failure is the truth and the startup command never runs.
test('a CLI split whose terminal does not start within 10 s leaves no pane', async ({
electronApp,
orcaPage
}, testInfo) => {
test.skip(!LAYOUT_OWNER_BUILD, 'layout-owner build only')
test.setTimeout(180_000)
await waitForSessionReady(orcaPage)
const worktreeId = await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData'))
const client = new RuntimeClient(userDataDir, 30_000)
const tabId = await createActiveTab(orcaPage, worktreeId)
const source = await handleFor(client, worktreeId, tabId)
await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { parkDelayMs: PARKING_DELAY_MS })
await orcaPage.evaluate(() => {
window.addEventListener(
'orca-background-mount-terminal-worktree',
() => {
const until = performance.now() + 11_000
while (performance.now() < until) {
// Busy renderer.
}
},
{ once: true, capture: true }
)
})
// A file the command writes: a terminal that ran it and was then killed still leaves it.
const marker = testInfo.outputPath(`split-ran-${Date.now()}`)
mkdirSync(path.dirname(marker), { recursive: true })
const outcome = await runCliSplit(userDataDir, source, `touch '${marker}'`)
// Past the stall: the window has handled the split and main's take-back.
await new Promise((resolve) => setTimeout(resolve, 5_000))
const tabTerminals = await client
.call<RuntimeTerminalListResult>('terminal.list', { worktree: `id:${worktreeId}`, limit: 200 })
.then((listed) => listed.result.terminals.filter((t) => t.tabId === tabId))
const windowLeafIds = await orcaPage.evaluate((id) => {
function windowLeafIdsOf(page: Page, tabId: string): Promise<string[]> {
return page.evaluate((id) => {
const collect = (node: unknown): string[] => {
if (typeof node !== 'object' || node === null) {
return []
@@ -192,15 +116,75 @@ test('a CLI split whose terminal does not start within 10 s leaves no pane', asy
}
return collect(window.__store?.getState().terminalLayoutsByTabId[id]?.root)
}, tabId)
const report = { outcome, tabTerminals: tabTerminals.map((t) => t.leafId), windowLeafIds }
await testInfo.attach('split-taken-back.json', {
body: JSON.stringify(report, null, 2),
}
// Main writes the split and starts its terminal itself, so a busy window never holds the CLI up.
test('a CLI split of a parked tab answers promptly while the window is busy', async ({
electronApp,
orcaPage
}, testInfo) => {
test.skip(!LAYOUT_OWNER_BUILD, 'layout-owner build only')
test.setTimeout(180_000)
await waitForSessionReady(orcaPage)
const worktreeId = await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData'))
const client = new RuntimeClient(userDataDir, 30_000)
const tabId = await createActiveTab(orcaPage, worktreeId)
const source = await handleFor(client, worktreeId, tabId)
await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { parkDelayMs: PARKING_DELAY_MS })
await stallRenderer(orcaPage, 8_000)
const startedAt = Date.now()
const outcome = await runCliSplit(userDataDir, source, 'echo started-by-main')
const elapsedMs = Date.now() - startedAt
await testInfo.attach('split-busy-window.json', {
body: JSON.stringify({ elapsedMs, outcome }, null, 2),
contentType: 'application/json'
})
expect(outcome.ok ? '' : outcome.error).toContain(
"split_pane_not_started: the new pane's terminal did not start within 10 s, so the pane was closed and its command was not run"
)
expect(tabTerminals).toHaveLength(1)
expect(windowLeafIds).toEqual([tabTerminals[0]!.leafId])
expect(existsSync(marker)).toBe(false)
expect(outcome).toEqual({ ok: true })
expect(elapsedMs).toBeLessThan(6_000)
})
// The window is stalled past the old 10 s wait: main still starts the split's terminal, its
// command runs, and the window shows the pane from main's layout once it is free.
test('a CLI split runs its command while the window is stalled, and the window shows the pane', async ({
electronApp,
orcaPage
}, testInfo) => {
test.skip(!LAYOUT_OWNER_BUILD, 'layout-owner build only')
test.setTimeout(180_000)
await waitForSessionReady(orcaPage)
const worktreeId = await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const userDataDir = await electronApp.evaluate(({ app }) => app.getPath('userData'))
const client = new RuntimeClient(userDataDir, 30_000)
const tabId = await createActiveTab(orcaPage, worktreeId)
const source = await handleFor(client, worktreeId, tabId)
await parkHiddenTabBehindDecoy(orcaPage, worktreeId, tabId, { parkDelayMs: PARKING_DELAY_MS })
await stallRenderer(orcaPage, 11_000)
const marker = testInfo.outputPath(`split-ran-${Date.now()}`)
mkdirSync(path.dirname(marker), { recursive: true })
const outcome = await runCliSplit(userDataDir, source, `touch '${marker}'`)
expect(outcome).toEqual({ ok: true })
await expect.poll(() => existsSync(marker), { timeout: 30_000 }).toBe(true)
let tabLeafIds: string[] = []
await expect
.poll(async () => {
const listed = await client.call<RuntimeTerminalListResult>('terminal.list', {
worktree: `id:${worktreeId}`,
limit: 200
})
tabLeafIds = listed.result.terminals
.filter((t) => t.tabId === tabId)
.map((t) => t.leafId)
.sort()
return tabLeafIds.length
})
.toBe(2)
// Past the stall, the window holds both panes main has.
await expect
.poll(async () => (await windowLeafIdsOf(orcaPage, tabId)).sort(), { timeout: 30_000 })
.toEqual(tabLeafIds)
})