diff --git a/src/main/ipc/pty/pane/spawn-reservation.ts b/src/main/ipc/pty/pane/spawn-reservation.ts index 4fb51d0a690..256b7ad4f56 100644 --- a/src/main/ipc/pty/pane/spawn-reservation.ts +++ b/src/main/ipc/pty/pane/spawn-reservation.ts @@ -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, diff --git a/src/main/runtime/orca-runtime-exited-split-pane-close.test.ts b/src/main/runtime/orca-runtime-exited-split-pane-close.test.ts index c25704f7cc6..4e619e873f4 100644 --- a/src/main/runtime/orca-runtime-exited-split-pane-close.test.ts +++ b/src/main/runtime/orca-runtime-exited-split-pane-close.test.ts @@ -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}`) diff --git a/src/main/runtime/orca-runtime-failed-start-pane-layout-owner.test.ts b/src/main/runtime/orca-runtime-failed-start-pane-layout-owner.test.ts index fb9246b7fc0..43dbf42b958 100644 --- a/src/main/runtime/orca-runtime-failed-start-pane-layout-owner.test.ts +++ b/src/main/runtime/orca-runtime-failed-start-pane-layout-owner.test.ts @@ -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() + }) }) diff --git a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts index dbbd6331521..fef3f5e7149 100644 --- a/src/main/runtime/orca-runtime-terminal-split-authority.test.ts +++ b/src/main/runtime/orca-runtime-terminal-split-authority.test.ts @@ -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: [ { diff --git a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts index a5bd05e8973..4f47d48c4e8 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-creation-and-readiness-part-05.spec.ts @@ -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 diff --git a/src/main/runtime/pane-start-statuses.test.ts b/src/main/runtime/pane-start-statuses.test.ts index c1a7c850f0e..759c4bee0b5 100644 --- a/src/main/runtime/pane-start-statuses.test.ts +++ b/src/main/runtime/pane-start-statuses.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts index 1cae7585db9..587dbf285d8 100644 --- a/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts +++ b/src/main/runtime/rpc/methods/terminal/terminal-lifecycle-methods.ts @@ -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({ diff --git a/src/main/runtime/runtime-pane-admission.ts b/src/main/runtime/runtime-pane-admission.ts index 93732cd9152..9666257cd03 100644 --- a/src/main/runtime/runtime-pane-admission.ts +++ b/src/main/runtime/runtime-pane-admission.ts @@ -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( /** * 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 { 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> } + const start = async ( + admitted: TerminalPaneAdmissionOutcome | undefined + ): Promise>> => { + 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) { diff --git a/src/main/runtime/terminal/launch/failed-start-pane.ts b/src/main/runtime/terminal/launch/failed-start-pane.ts index 6f6326c717d..ffe582d960a 100644 --- a/src/main/runtime/terminal/launch/failed-start-pane.ts +++ b/src/main/runtime/terminal/launch/failed-start-pane.ts @@ -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 { + 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) diff --git a/src/main/runtime/terminal/launch/terminal-split-in-main.ts b/src/main/runtime/terminal/launch/terminal-split-in-main.ts new file mode 100644 index 00000000000..3ec8fea2d94 --- /dev/null +++ b/src/main/runtime/terminal/launch/terminal-split-in-main.ts @@ -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 + 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 { + 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 { + 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 { + 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 + } + } +} diff --git a/src/main/runtime/terminal/launch/terminal-split-layout-owner.test.ts b/src/main/runtime/terminal/launch/terminal-split-layout-owner.test.ts index 770175c23c9..8f4cfb96136 100644 --- a/src/main/runtime/terminal/launch/terminal-split-layout-owner.test.ts +++ b/src/main/runtime/terminal/launch/terminal-split-layout-owner.test.ts @@ -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, close: () => Promise) { + 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() + }) }) diff --git a/src/main/runtime/terminal/launch/terminal-split-source-authority.ts b/src/main/runtime/terminal/launch/terminal-split-source-authority.ts index 50c516b9a88..de18f9d21f8 100644 --- a/src/main/runtime/terminal/launch/terminal-split-source-authority.ts +++ b/src/main/runtime/terminal/launch/terminal-split-source-authority.ts @@ -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'] diff --git a/src/main/runtime/terminal/launch/terminal-split-surface.ts b/src/main/runtime/terminal/launch/terminal-split-surface.ts index a6cb2ce5fab..4af876d6060 100644 --- a/src/main/runtime/terminal/launch/terminal-split-surface.ts +++ b/src/main/runtime/terminal/launch/terminal-split-surface.ts @@ -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: { diff --git a/src/main/runtime/terminal/launch/terminal-split.ts b/src/main/runtime/terminal/launch/terminal-split.ts index 22e58ed6432..c4d22cdc816 100644 --- a/src/main/runtime/terminal/launch/terminal-split.ts +++ b/src/main/runtime/terminal/launch/terminal-split.ts @@ -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 + protected abstract ownsTabLayout(worktreeId: string, tabId: string): boolean + + protected abstract splitPaneInMain( + source: SplitPaneSource, + opts: SplitPaneInMainOptions + ): Promise + 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 -): Promise { - if (!store?.getWorkspaceSession || !store.onWorkspaceSessionWritten) { - return waiting - } - const getSession = store.getWorkspaceSession.bind(store) - let stopWatching: (() => void) | undefined - const closed = new Promise((_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?.()) -} diff --git a/src/main/runtime/terminal/lifecycle/exited-split-pane-rule.ts b/src/main/runtime/terminal/lifecycle/exited-split-pane-rule.ts index f9254be1550..6e59e36eac2 100644 --- a/src/main/runtime/terminal/lifecycle/exited-split-pane-rule.ts +++ b/src/main/runtime/terminal/lifecycle/exited-split-pane-rule.ts @@ -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, diff --git a/src/renderer/src/components/terminal-pane/closed-terminal-leaf-notice-layout-owner.test.ts b/src/renderer/src/components/terminal-pane/closed-terminal-leaf-notice-layout-owner.test.ts index 42f513acc03..66f9813ccf9 100644 --- a/src/renderer/src/components/terminal-pane/closed-terminal-leaf-notice-layout-owner.test.ts +++ b/src/renderer/src/components/terminal-pane/closed-terminal-leaf-notice-layout-owner.test.ts @@ -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' + }) }) }) diff --git a/src/renderer/src/components/terminal-pane/closed-terminal-leaf-notice.ts b/src/renderer/src/components/terminal-pane/closed-terminal-leaf-notice.ts index 35c8c4d7243..2b465db2878 100644 --- a/src/renderer/src/components/terminal-pane/closed-terminal-leaf-notice.ts +++ b/src/renderer/src/components/terminal-pane/closed-terminal-leaf-notice.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts b/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts index 5a8b5d13eee..60f0d45ab34 100644 --- a/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts +++ b/src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts @@ -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 }, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-main-pane-start.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-main-pane-start.test.ts new file mode 100644 index 00000000000..dda99584fbb --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-main-pane-start.test.ts @@ -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): 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) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.spec.ts index 8ffea7c279e..f5df40f3df0 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-pty-exit-teardown.spec.ts @@ -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) + } }) }) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-session-liveness.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-session-liveness.spec.ts index c65ebffb5c9..635d2ea7725 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-session-liveness.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-session-liveness.spec.ts @@ -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) + } }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-exited-split-pane-close.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-exited-split-pane-close.ts new file mode 100644 index 00000000000..9043170b764 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-exited-split-pane-close.ts @@ -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 { + 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') +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts index 7fdaec460dc..007d038378a 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts @@ -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. diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts index 4a3688c818c..7533afb4eaa 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts @@ -21,6 +21,7 @@ export type StoreState = { > ptyIdsByTabId?: Record terminalLayoutsByTabId?: Record + terminalPaneStartsByPaneKey?: AppState['terminalPaneStartsByPaneKey'] /** Pty id → the agent its pane restarts as once the account change lands. */ pendingPaneAccountRestarts?: AppState['pendingPaneAccountRestarts'] unreadTerminalTabs?: Record diff --git a/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.ts b/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.ts new file mode 100644 index 00000000000..02220d20807 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection/main-pane-start-wait.ts @@ -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() + +/** 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 +): 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 } + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts index fe040fbe430..57fd1a84817 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/run-deferred-connect.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts index ea56a0879d0..17ad2086193 100644 --- a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts @@ -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 }, diff --git a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts index 6c0a99815e2..8d1bc13f83d 100644 --- a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-unavailable-banner.repro.test.ts @@ -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 }, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-bootstrap-split-in-main.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-bootstrap-split-in-main.test.ts new file mode 100644 index 00000000000..c22dd1a7670 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-bootstrap-split-in-main.test.ts @@ -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(), + runTerminalPaneBootstrapSplits: vi.fn() +})) +vi.mock('./terminal-pane-split-in-main', async (importOriginal) => ({ + ...(await importOriginal()), + 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) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-bootstrap-split-in-main.ts b/src/renderer/src/components/terminal-pane/terminal-pane-bootstrap-split-in-main.ts new file mode 100644 index 00000000000..d2f0afd515e --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-bootstrap-split-in-main.ts @@ -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() + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-bootstrap-split-record.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-bootstrap-split-record.test.ts deleted file mode 100644 index db3dc08b3ae..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-pane-bootstrap-split-record.test.ts +++ /dev/null @@ -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() })) -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 | 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() -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-bootstrap-split-record.ts b/src/renderer/src/components/terminal-pane/terminal-pane-bootstrap-split-record.ts deleted file mode 100644 index 232b8ad8b77..00000000000 --- a/src/renderer/src/components/terminal-pane/terminal-pane-bootstrap-split-record.ts +++ /dev/null @@ -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 - }) -} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-frame-reconciliation.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-frame-reconciliation.test.ts index 59e8bce8dd7..c0ce06793d2 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-frame-reconciliation.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-frame-reconciliation.test.ts @@ -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> const closePane = vi.fn() - const reconcile = (layout: TerminalPaneLayoutNode, isExpanded = false): boolean => + const reconcile = ( + layout: TerminalPaneLayoutNode, + isExpanded = false, + ptyIdsByLeafId?: Record + ): 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]) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-frame-reconciliation.ts b/src/renderer/src/components/terminal-pane/terminal-pane-frame-reconciliation.ts index 800ea14e25b..b522ec78362 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-frame-reconciliation.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-frame-reconciliation.ts @@ -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) { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-layout-restore.ts b/src/renderer/src/components/terminal-pane/terminal-pane-layout-restore.ts index 15a040725b6..e3634481502 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-layout-restore.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-layout-restore.ts @@ -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({ diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-bootstrap.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-bootstrap.test.ts index ce52cb96744..abe7f52d107 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-bootstrap.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-bootstrap.test.ts @@ -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 { diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-bootstrap.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-bootstrap.ts index 5a319a90c55..f08a46f82bf 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-bootstrap.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-bootstrap.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-mount-events-layout-owner.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-mount-events-layout-owner.test.ts index 63b48a0e3ba..9a08697a5ac 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-mount-events-layout-owner.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-mount-events-layout-owner.test.ts @@ -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() - }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-in-main.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-in-main.test.ts new file mode 100644 index 00000000000..03edd8c7757 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-in-main.test.ts @@ -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 + pairedServer: string | null + actsAsToday: boolean + recordCreatedTerminalPaneSplit: ReturnType + } => ({ + 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 + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-in-main.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-in-main.ts new file mode 100644 index 00000000000..a4a26d7bc12 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-in-main.ts @@ -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 + initialSize?: { cols: number; rows: number } + command?: string + env?: Record + /** 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)) } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.test.ts index 8cb176c1a13..6fc9fdda38b 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.test.ts @@ -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']) - }) -}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.ts index 9de42e5ba00..26b03981309 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-request-routing.ts @@ -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) => diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts index 33d636951bb..cb6fb0c2afb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.test.ts @@ -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(), + splitPaneInMain: vi.fn(), 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()), + splitPaneInMain: mocks.splitPaneInMain })) vi.mock('@/runtime/web-runtime-terminal-actions', () => ({ @@ -53,11 +49,17 @@ function makeManager(splitPane: ReturnType): 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' }) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts index c08ac528031..5148437f810 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-split-with-inherited-cwd.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts index 8b83383572d..d0ab25485c3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-commit.test.ts @@ -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 => { - 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 => { + 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 => { @@ -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((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((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() diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-main-commit.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-main-commit.test.ts new file mode 100644 index 00000000000..4523a7bd99d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-main-commit.test.ts @@ -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() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +const closeTerminalSurface = vi.fn(async (_args: unknown) => {}) +const moveLeafToNewTab = vi.fn() +let api: ReturnType +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 { + 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(detaching: () => Promise) { + main.answerWith('pending') + const result = detaching() + await vi.waitFor(() => expect(main.pendingCount).toBe(1)) + return { + result, + answer: async (): Promise => { + 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' + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-main-move.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-main-move.test.ts new file mode 100644 index 00000000000..4f9a266d79d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-main-move.test.ts @@ -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() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +let closeFrames: () => void = () => {} +beforeEach(() => { + createStoreCascadesMockApi() +}) +afterEach(() => { + closeFrames() +}) + +async function seeded(...args: Parameters): Promise { + 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()) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-main.test-fixture.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-main.test-fixture.ts new file mode 100644 index 00000000000..d13334f7a4c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach-main.test-fixture.ts @@ -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 + +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[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]! +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts index 42e7b585956..fb605894f42 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-tab-detach.test.ts @@ -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 }]), diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts index 84af096a896..6adfab7015a 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-mount-lifecycle.ts @@ -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() diff --git a/src/renderer/src/runtime/layout-owner-pane-intents.test.ts b/src/renderer/src/runtime/layout-owner-pane-intents.test.ts index 567d44a5ec0..7a97053daf0 100644 --- a/src/renderer/src/runtime/layout-owner-pane-intents.test.ts +++ b/src/renderer/src/runtime/layout-owner-pane-intents.test.ts @@ -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', () => { diff --git a/src/renderer/src/runtime/layout-owner-pane-intents.ts b/src/renderer/src/runtime/layout-owner-pane-intents.ts index 163e1f70a82..fb411feb579 100644 --- a/src/renderer/src/runtime/layout-owner-pane-intents.ts +++ b/src/renderer/src/runtime/layout-owner-pane-intents.ts @@ -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 - 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() /** '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(map: Map, 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() + +/** 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() diff --git a/src/renderer/src/runtime/workspace-layout-frame-binding.test.ts b/src/renderer/src/runtime/workspace-layout-frame-binding.test.ts index 8fbfe31f6ea..6fc43058448 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-binding.test.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-binding.test.ts @@ -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() + }) +}) diff --git a/src/renderer/src/runtime/workspace-layout-frame-bindings.ts b/src/renderer/src/runtime/workspace-layout-frame-bindings.ts index d2d5f131cb7..468da77a8d3 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-bindings.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-bindings.ts @@ -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> +): { 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 } +} diff --git a/src/renderer/src/runtime/workspace-layout-frame-pane-starts.ts b/src/renderer/src/runtime/workspace-layout-frame-pane-starts.ts new file mode 100644 index 00000000000..24ed295ac31 --- /dev/null +++ b/src/renderer/src/runtime/workspace-layout-frame-pane-starts.ts @@ -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 { + const starts = new Map() + 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, + frames: Readonly>, + shownByKey: Map> +): 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) => void }, + changed: readonly string[], + cache: Readonly>, + shownByKey: Map> +): 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 }) + } +} diff --git a/src/renderer/src/runtime/workspace-layout-frame-sync.ts b/src/renderer/src/runtime/workspace-layout-frame-sync.ts index fe9a2a905b5..88fb299d425 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-sync.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-sync.ts @@ -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() + const paneStartsShown = new Map>() /** 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[] => { 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. diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 5ae9b5e46e9..2aa73b42fca 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -39,6 +39,7 @@ export const createTerminalSlice: StateCreator expandedPaneByTabId: {}, canExpandPaneByTabId: {}, terminalLayoutsByTabId: {}, + terminalPaneStartsByPaneKey: {}, localOnlyScrollbackByTabId: {}, pendingStartupByTabId: {}, pendingInitialCwdByTabId: {}, diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index fae95a4cbbb..5d0d56fa9b3 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -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 canExpandPaneByTabId: Record terminalLayoutsByTabId: Record + /** 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 /** Ordinary-park scrollback, tabId -> leafId -> buffer. Never uploaded to a peer; see * WorkspaceSessionState.localOnlyScrollbackByTabId. Read via resolveLeafScrollbackBuffers only. */ localOnlyScrollbackByTabId: Record> @@ -145,3 +149,5 @@ export type TerminalSlice = TerminalState & TerminalActions & PairedTerminalTabC export type TerminalStoreSet = Parameters>[0] export type TerminalStoreGet = Parameters>[1] + +export type TerminalPaneStart = { status: PaneStartStatus; reason?: string } diff --git a/src/shared/rpc-contract/terminal-unary-params.ts b/src/shared/rpc-contract/terminal-unary-params.ts index 1b043f70b52..60030bdb637 100644 --- a/src/shared/rpc-contract/terminal-unary-params.ts +++ b/src/shared/rpc-contract/terminal-unary-params.ts @@ -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') diff --git a/tests/e2e/helpers/terminal-pane-operations.ts b/tests/e2e/helpers/terminal-pane-operations.ts index 460cdbe237a..0934bab5092 100644 --- a/tests/e2e/helpers/terminal-pane-operations.ts +++ b/tests/e2e/helpers/terminal-pane-operations.ts @@ -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 { 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 } ) } diff --git a/tests/e2e/terminal-cli-split-closed-before-mount.spec.ts b/tests/e2e/terminal-cli-split-closed-before-mount.spec.ts index 3a9676e7773..b788a389bd4 100644 --- a/tests/e2e/terminal-cli-split-closed-before-mount.spec.ts +++ b/tests/e2e/terminal-cli-split-closed-before-mount.spec.ts @@ -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 { + 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('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 { + 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('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) })