diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 95bade079aa..96c6c4e1886 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -15944,6 +15944,92 @@ 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() + + 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 .fn() @@ -15980,7 +16066,8 @@ describe('OrcaRuntimeService', () => { (spawn.mock.calls[0]?.[0] as { env?: Record } | undefined)?.env ?? {} const sourceLeafId = sourceEnv.ORCA_PANE_KEY.slice(`${sourceEnv.ORCA_TAB_ID}:`.length) - await expect(runtime.splitTerminal(handle, { direction: 'vertical' })).resolves.toMatchObject({ + const split = await runtime.splitTerminal(handle, { direction: 'vertical' }) + expect(split).toMatchObject({ handle: expect.stringMatching(/^term_/), tabId: sourceEnv.ORCA_TAB_ID, paneRuntimeId: -1 @@ -15989,6 +16076,7 @@ describe('OrcaRuntimeService', () => { const splitEnv = (spawn.mock.calls[1]?.[0] as { env?: Record } | undefined)?.env ?? {} const splitLeafId = splitEnv.ORCA_PANE_KEY.slice(`${sourceEnv.ORCA_TAB_ID}:`.length) + expect(split.leafId).toBe(splitLeafId) expect(splitTerminal).not.toHaveBeenCalled() expect(splitEnv.ORCA_TAB_ID).toBe(sourceEnv.ORCA_TAB_ID) expect(splitEnv.ORCA_WORKTREE_ID).toBe(TEST_WORKTREE_ID) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 34e3514385d..ea852917b94 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2406,6 +2406,7 @@ type RuntimeNotifier = { direction: 'horizontal' | 'vertical' command?: string telemetrySource?: TerminalPaneSplitSource + newLeafId?: string } ): void renameTerminal(tabId: string, title: string | null): void @@ -32255,22 +32256,22 @@ export class OrcaRuntimeService { const { leaf } = this.getLiveLeafForHandle(handle) const direction = opts.direction ?? 'horizontal' - // Snapshot current leaf keys so the post-split graph-sync delta reveals the new pane. - const leafKeysBefore = new Set() - for (const [key, l] of this.leaves) { - if (l.tabId === leaf.tabId) { - leafKeysBefore.add(key) - } - } + const newLeafId = randomUUID() this.notifier?.splitTerminal(leaf.tabId, leaf.paneRuntimeId, { direction, command: opts.command, - telemetrySource: opts.telemetrySource + telemetrySource: opts.telemetrySource, + newLeafId }) - const newHandle = await this.waitForNewLeafInTab(leaf.tabId, leafKeysBefore) - return { handle: newHandle, tabId: leaf.tabId, paneRuntimeId: leaf.paneRuntimeId } + const newHandle = await this.waitForLeafInTab(leaf.tabId, newLeafId) + return { + handle: newHandle, + tabId: leaf.tabId, + paneRuntimeId: leaf.paneRuntimeId, + leafId: newLeafId + } } private async splitPtyBackedTerminal( @@ -32460,7 +32461,12 @@ export class OrcaRuntimeService { void revealSplit().catch(() => undefined) } - return { handle: this.issuePtyHandle(createdPty ?? pty), tabId: parentTabId, paneRuntimeId: -1 } + return { + handle: this.issuePtyHandle(createdPty ?? pty), + tabId: parentTabId, + paneRuntimeId: -1, + leafId + } } private resolveTerminalSplitSourceAuthority( @@ -32591,18 +32597,10 @@ export class OrcaRuntimeService { this.claudeAgentTeams.removeTeamForLeaderHandle(handle) } - private waitForNewLeafInTab( - tabId: string, - existingLeafKeys: Set, - timeoutMs = 10_000 - ): Promise { + private waitForLeafInTab(tabId: string, leafId: string, timeoutMs = 10_000): Promise { const tryResolve = (): string | null => { - for (const [key, leaf] of this.leaves) { - if (leaf.tabId === tabId && !existingLeafKeys.has(key) && leaf.ptyId !== null) { - return this.issueHandle(leaf) - } - } - return null + const leaf = this.leaves.get(this.getLeafKey(tabId, leafId)) + return leaf?.ptyId !== null && leaf?.ptyId !== undefined ? this.issueHandle(leaf) : null } const existing = tryResolve() diff --git a/src/main/window/runtime-window-lifecycle.ts b/src/main/window/runtime-window-lifecycle.ts index cc6b6b47136..7d86c0bb9b4 100644 --- a/src/main/window/runtime-window-lifecycle.ts +++ b/src/main/window/runtime-window-lifecycle.ts @@ -155,7 +155,8 @@ export function registerRuntimeWindowLifecycle( paneRuntimeId, direction: opts.direction, command: opts.command, - telemetrySource: opts.telemetrySource + telemetrySource: opts.telemetrySource, + newLeafId: opts.newLeafId }) }, renameTerminal: (tabId, title) => send('ui:renameTerminal', { tabId, title }), diff --git a/src/preload/api/ui-command-event-api.ts b/src/preload/api/ui-command-event-api.ts index f287d747aee..d8a1d4d1489 100644 --- a/src/preload/api/ui-command-event-api.ts +++ b/src/preload/api/ui-command-event-api.ts @@ -170,6 +170,7 @@ export type UiCommandEventApi = { direction: 'horizontal' | 'vertical' command?: string telemetrySource?: TerminalPaneSplitSource + newLeafId?: string }) => void ) => () => void onRenameTerminal: ( diff --git a/src/preload/index.ts b/src/preload/index.ts index c13e7a30a7d..7c422ade23f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -4249,6 +4249,7 @@ const api = { direction: 'horizontal' | 'vertical' command?: string telemetrySource?: TerminalPaneSplitSource + newLeafId?: string }) => void ): (() => void) => { const listener = ( @@ -4259,6 +4260,7 @@ const api = { direction: 'horizontal' | 'vertical' command?: string telemetrySource?: TerminalPaneSplitSource + newLeafId?: string } ) => callback(data) ipcRenderer.on('ui:splitTerminal', listener) diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index 4b8d43cac31..045dd8aba8d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -2868,6 +2868,8 @@ function TerminalPane( return } splitTerminalPaneWithInheritedCwd({ + worktreeId, + tabId, manager, getManager: () => managerRef.current, paneTransports: paneTransportsRef.current, @@ -2878,7 +2880,7 @@ function TerminalPane( source: 'context_menu' }) }, - [cwd] + [cwd, tabId, worktreeId] ) const beginPaneDragFromHeader = useCallback( diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-action-dispatch.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-action-dispatch.ts index d3eea4a0681..5429327d91f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-keyboard-action-dispatch.ts +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-action-dispatch.ts @@ -13,6 +13,8 @@ import type { resolveTerminalKeyboardShortcutAction } from './terminal-keyboard- type TerminalShortcutAction = NonNullable> type ActionDispatchContext = { + tabId: string + worktreeId: string fallbackCwd: string expandedPaneIdRef: React.RefObject setExpandedPane: (paneId: number | null) => void @@ -39,6 +41,8 @@ export function dispatchTerminalShortcutAction( context: ActionDispatchContext ): void { const { + tabId, + worktreeId, fallbackCwd, expandedPaneIdRef, setExpandedPane, @@ -205,6 +209,8 @@ export function dispatchTerminalShortcutAction( return } splitTerminalPaneWithInheritedCwd({ + worktreeId, + tabId, manager, getManager: () => managerRef.current, paneTransports: paneTransportsRef.current, diff --git a/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts b/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts index a66fbf96fe4..610a854df35 100644 --- a/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-keyboard-event-handlers.ts @@ -46,6 +46,8 @@ type EventContext = KeyboardHandlersDeps & { export function createTerminalKeyboardEventHandlers(context: EventContext) { const { + tabId, + worktreeId, isMac, isWindows, shortcutPlatform, @@ -263,6 +265,8 @@ export function createTerminalKeyboardEventHandlers(context: EventContext) { } dispatchTerminalShortcutAction(action, e, manager, { + tabId, + worktreeId, fallbackCwd, expandedPaneIdRef, setExpandedPane, 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 53155fa786f..7d4a4bc0188 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 @@ -38,18 +38,72 @@ describe('splitTerminalPaneWithInheritedCwd', () => { mocks.splitWebRuntimeTerminal.mockReturnValue(false) }) + it.each(['keyboard', 'context_menu'] as const)( + 'delegates remote %s splits without creating a competing local pane', + (source) => { + const splitPane = vi.fn() + const transport = { getPtyId: () => 'remote:web-env-1@@terminal-1' } as PtyTransport + mocks.splitWebRuntimeTerminal.mockReturnValue(true) + + splitTerminalPaneWithInheritedCwd({ + worktreeId: 'worktree-1', + tabId: 'tab-1', + manager: makeManager(splitPane), + paneTransports: new Map([[1, transport]]), + paneCwdMap: new Map(), + fallbackCwd: '/fallback', + pane: { id: 1, leafId: 'leaf-1' } as ManagedPane, + direction: 'vertical', + source + }) + + expect(mocks.splitWebRuntimeTerminal).toHaveBeenCalledWith( + 'remote:web-env-1@@terminal-1', + 'vertical', + source, + { worktreeId: 'worktree-1', tabId: 'tab-1', leafId: 'leaf-1' } + ) + expect(splitPane).not.toHaveBeenCalled() + } + ) + + it('keeps the existing local split-and-focus path unchanged', () => { + const createdPane = { id: 2 } + const splitPane = vi.fn(() => createdPane) + + 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: 'horizontal', + source: 'keyboard' + }) + + expect(splitPane).toHaveBeenCalledWith(1, 'horizontal', { cwd: '/cached' }) + expect(mocks.recordCreatedTerminalPaneSplit).toHaveBeenCalledWith(createdPane, { + source: 'keyboard', + direction: 'horizontal' + }) + }) + it('uses the live manager after async cwd resolution', async () => { const staleSplitPane = vi.fn() const liveSplitPane = vi.fn(() => ({ id: 2 })) mocks.resolveSplitCwd.mockResolvedValue('/resolved') splitTerminalPaneWithInheritedCwd({ + worktreeId: 'worktree-1', + tabId: 'tab-1', manager: makeManager(staleSplitPane), getManager: () => makeManager(liveSplitPane), paneTransports: new Map(), paneCwdMap: new Map(), fallbackCwd: '/fallback', - pane: { id: 1 } as ManagedPane, + pane: { id: 1, leafId: 'leaf-1' } as ManagedPane, direction: 'vertical', source: 'context_menu' }) @@ -69,12 +123,14 @@ describe('splitTerminalPaneWithInheritedCwd', () => { mocks.resolveSplitCwd.mockResolvedValue('/resolved') splitTerminalPaneWithInheritedCwd({ + worktreeId: 'worktree-1', + tabId: 'tab-1', manager: makeManager(staleSplitPane), getManager: () => null, paneTransports: new Map(), paneCwdMap: new Map(), fallbackCwd: '/fallback', - pane: { id: 1 } as ManagedPane, + pane: { id: 1, leafId: 'leaf-1' } as ManagedPane, direction: 'horizontal', source: 'context_menu' }) 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 5fefdb78edd..83a278eb00d 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 @@ -6,6 +6,8 @@ import { resolveSplitCwd, type PaneCwdMap } from './resolve-split-cwd' import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' export function splitTerminalPaneWithInheritedCwd(args: { + worktreeId: string + tabId: string manager: PaneManager getManager?: () => PaneManager | null paneTransports: Map @@ -16,7 +18,13 @@ export function splitTerminalPaneWithInheritedCwd(args: { source: TerminalPaneSplitSource }): void { const ptyId = args.paneTransports.get(args.pane.id)?.getPtyId() ?? null - if (splitWebRuntimeTerminal(ptyId, args.direction, args.source)) { + if ( + splitWebRuntimeTerminal(ptyId, args.direction, args.source, { + worktreeId: args.worktreeId, + tabId: args.tabId, + leafId: args.pane.leafId + }) + ) { return } const cached = args.paneCwdMap.get(args.pane.id) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 101169b4304..a6f04fca5f3 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -139,6 +139,7 @@ export function useTerminalPaneContextMenu({ paneCwdRef, contextPaneIdRef, tabId, + worktreeId, fallbackCwd, resolveMenuPane }) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-split-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-split-actions.ts index beaf2dc7b6e..742b57cc074 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-split-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-split-actions.ts @@ -26,6 +26,7 @@ type UseTerminalPaneSplitActionsDeps = { paneCwdRef: React.RefObject contextPaneIdRef: React.RefObject tabId: string + worktreeId: string fallbackCwd: string resolveMenuPane: () => ManagedPane | null } @@ -41,6 +42,7 @@ export function useTerminalPaneSplitActions({ paneCwdRef, contextPaneIdRef, tabId, + worktreeId, fallbackCwd, resolveMenuPane }: UseTerminalPaneSplitActionsDeps): TerminalPaneSplitActions { @@ -55,6 +57,8 @@ export function useTerminalPaneSplitActions({ return } splitTerminalPaneWithInheritedCwd({ + worktreeId, + tabId, manager, getManager: () => managerRef.current, paneTransports: paneTransportsRef.current, @@ -65,7 +69,7 @@ export function useTerminalPaneSplitActions({ source }) }, - [fallbackCwd, managerRef, paneCwdRef, paneTransportsRef, resolveMenuPane] + [fallbackCwd, managerRef, paneCwdRef, paneTransportsRef, resolveMenuPane, tabId, worktreeId] ) const onSplitRight = (): void => splitWithInheritedCwd('vertical') diff --git a/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts b/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts index 4bd590d1d9a..e629f3f8b19 100644 --- a/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts +++ b/src/renderer/src/hooks/ipc-events/terminal-ui-routing-ipc-bridge.ts @@ -11,13 +11,14 @@ import { export function registerTerminalUiRoutingIpcBridge(unsubs: (() => void)[]): void { unsubs.push( window.api.ui.onSplitTerminal( - ({ tabId, paneRuntimeId, direction, command, telemetrySource }) => { + ({ tabId, paneRuntimeId, direction, command, telemetrySource, newLeafId }) => { const detail: SplitTerminalPaneDetail = { tabId, paneRuntimeId, direction, command, - telemetrySource + telemetrySource, + newLeafId } window.dispatchEvent(new CustomEvent(SPLIT_TERMINAL_PANE_EVENT, { detail })) } diff --git a/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts b/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts index b08040c0e66..6e4aa1ff94c 100644 --- a/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts +++ b/src/renderer/src/runtime/web-runtime-session-terminal-pane-delegation.test.ts @@ -2,12 +2,18 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { closeWebRuntimeTerminal, consumePendingWebRuntimeSplitMirrorTelemetry, - isWebRuntimeSessionActive, splitWebRuntimeTerminal } from './web-runtime-session' import { resetWebSessionCloseIntentForTests } from './web-session-close-intent' +import { + peekWebSessionFocusIntent, + resetWebSessionFocusIntentForTests +} from './web-session-focus-intent' +import { toWebTerminalSurfaceTabId } from '../../../shared/terminal-surface-id' +import { replaceRuntimeEnvironmentRevisions } from './runtime-environment-revision' const mocks = vi.hoisted(() => ({ + activateTabAndFocusPane: vi.fn(), getState: vi.fn(), setState: vi.fn(), subscribe: vi.fn(), @@ -37,6 +43,10 @@ vi.mock('../store', () => ({ } })) +vi.mock('../lib/activate-tab-and-focus-pane', () => ({ + activateTabAndFocusPane: mocks.activateTabAndFocusPane +})) + vi.mock('./web-session-tabs-sync', () => ({ acceptReplayedWebSessionTabsSnapshot: mocks.acceptReplayedWebSessionTabsSnapshot, applyWebSessionTabsSnapshot: mocks.applyWebSessionTabsSnapshot, @@ -67,7 +77,67 @@ vi.mock('./web-runtime-browser-materialization', () => ({ hasMaterializedWebRuntimeBrowserPage: mocks.hasMaterializedWebRuntimeBrowserPage })) -afterEach(() => resetWebSessionCloseIntentForTests()) +afterEach(() => { + resetWebSessionCloseIntentForTests() + resetWebSessionFocusIntentForTests() + replaceRuntimeEnvironmentRevisions([]) +}) + +const SPLIT_WORKTREE_ID = 'repo::/worktree' +const SPLIT_SOURCE = { + worktreeId: SPLIT_WORKTREE_ID, + tabId: toWebTerminalSurfaceTabId('tab-1'), + leafId: 'leaf-1' +} + +function makeSplitSourceState( + hostTabId: string, + leafId = 'leaf-1', + activeHostTabId = hostTabId +): Record { + const tabId = toWebTerminalSurfaceTabId(hostTabId) + const activeTabId = toWebTerminalSurfaceTabId(activeHostTabId) + const tabs = [ + { + id: tabId, + worktreeId: SPLIT_WORKTREE_ID, + contentType: 'terminal', + ptyId: 'remote:web-env-1@@terminal-1' + }, + ...(activeTabId === tabId + ? [] + : [{ id: activeTabId, worktreeId: SPLIT_WORKTREE_ID, contentType: 'terminal' }]) + ] + return { + activeWorktreeId: SPLIT_WORKTREE_ID, + activeWorkspaceExecutionHostId: 'runtime:web-env-1', + activeTabType: 'terminal', + activeTabTypeByWorktree: { [SPLIT_WORKTREE_ID]: 'terminal' }, + activeTabIdByWorktree: { [SPLIT_WORKTREE_ID]: activeTabId }, + tabsByWorktree: { [SPLIT_WORKTREE_ID]: tabs }, + unifiedTabsByWorktree: { [SPLIT_WORKTREE_ID]: tabs }, + groupsByWorktree: {}, + terminalLayoutsByTabId: { + [tabId]: { + activeLeafId: leafId, + ptyIdsByLeafId: { [leafId]: 'remote:web-env-1@@terminal-1' } + }, + ...(activeTabId === tabId ? {} : { [activeTabId]: { activeLeafId: 'active-leaf' } }) + } + } +} + +function stubSplitSourceTab(hostTabId: string): void { + mocks.getState.mockReturnValue(makeSplitSourceState(hostTabId)) +} + +function makeSplitResult(leafId: string): unknown { + return { + id: leafId, + ok: true, + result: { split: { handle: leafId, tabId: 'tab-1', paneRuntimeId: -1, leafId } } + } +} describe('splitWebRuntimeTerminal', () => { beforeEach(() => { @@ -99,9 +169,14 @@ describe('splitWebRuntimeTerminal', () => { } }) - expect(splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'horizontal', 'keyboard')).toBe( - true - ) + expect( + splitWebRuntimeTerminal( + 'remote:web-env-1@@terminal-1', + 'horizontal', + 'keyboard', + SPLIT_SOURCE + ) + ).toBe(true) expect( consumePendingWebRuntimeSplitMirrorTelemetry('remote:web-env-1@@terminal-other', 'horizontal') ).toBe(false) @@ -112,6 +187,7 @@ describe('splitWebRuntimeTerminal', () => { await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1)) expect(runtimeCall).toHaveBeenCalledWith({ selector: 'web-env-1', + expectedEnvironmentPairingRevision: undefined, method: 'terminal.split', params: { terminal: 'terminal-1', @@ -138,7 +214,12 @@ describe('splitWebRuntimeTerminal', () => { }) expect( - splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'vertical', 'context_menu') + splitWebRuntimeTerminal( + 'remote:web-env-1@@terminal-1', + 'vertical', + 'context_menu', + SPLIT_SOURCE + ) ).toBe(true) await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1)) @@ -166,14 +247,547 @@ describe('splitWebRuntimeTerminal', () => { } }) - expect(splitWebRuntimeTerminal('pty-local-1', 'horizontal', 'keyboard')).toBe(false) - vi.stubGlobal('__ORCA_WEB_CLIENT__', false) - expect(splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'horizontal', 'keyboard')).toBe( - true + expect(splitWebRuntimeTerminal('pty-local-1', 'horizontal', 'keyboard', SPLIT_SOURCE)).toBe( + false ) + vi.stubGlobal('__ORCA_WEB_CLIENT__', false) + expect( + splitWebRuntimeTerminal( + 'remote:web-env-1@@terminal-1', + 'horizontal', + 'keyboard', + SPLIT_SOURCE + ) + ).toBe(true) await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1)) }) + + it('records the exact host-created leaf before replaying the mirrored layout', async () => { + stubSplitSourceTab('tab-1') + replaceRuntimeEnvironmentRevisions([{ id: 'web-env-1', createdAt: 7 }]) + const runtimeCall = vi.fn((request: { method: string }) => + Promise.resolve( + request.method === 'terminal.split' + ? { + id: 'split', + ok: true, + result: { + split: { + handle: 'terminal-2', + tabId: 'tab-1', + paneRuntimeId: -1, + leafId: 'leaf-2' + } + } + } + : { + id: 'list', + ok: true, + result: { + worktree: SPLIT_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: 'terminal', + tabs: [] + } + } + ) + ) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + expect( + splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'vertical', 'keyboard', SPLIT_SOURCE) + ).toBe(true) + + await vi.waitFor(() => + expect( + peekWebSessionFocusIntent( + { environmentId: 'web-env-1', pairingRevision: 7 }, + SPLIT_WORKTREE_ID + ) + ).toEqual({ + hostTabId: 'tab-1', + leafId: 'leaf-2', + expectedCurrentLocalTabId: toWebTerminalSurfaceTabId('tab-1') + }) + ) + expect(mocks.acceptReplayedWebSessionTabsSnapshot).toHaveBeenCalledWith( + 'web-env-1', + SPLIT_WORKTREE_ID + ) + expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith( + toWebTerminalSurfaceTabId('tab-1'), + 'leaf-2' + ) + expect(runtimeCall).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'terminal.split', + expectedEnvironmentPairingRevision: 7 + }) + ) + }) + + it('uses the gesture pane when a stale layout also records the source PTY', async () => { + const staleTabId = toWebTerminalSurfaceTabId('tab-stale') + const sourceTabId = toWebTerminalSurfaceTabId('tab-source') + const tabs = [ + { + id: staleTabId, + worktreeId: SPLIT_WORKTREE_ID, + contentType: 'terminal', + ptyId: 'remote:web-env-1@@terminal-1' + }, + { + id: sourceTabId, + worktreeId: SPLIT_WORKTREE_ID, + contentType: 'terminal', + ptyId: 'remote:web-env-1@@terminal-1' + } + ] + mocks.getState.mockReturnValue({ + ...makeSplitSourceState('tab-source', 'leaf-source'), + tabsByWorktree: { [SPLIT_WORKTREE_ID]: tabs }, + unifiedTabsByWorktree: { [SPLIT_WORKTREE_ID]: tabs }, + ptyIdsByTabId: { [sourceTabId]: ['remote:web-env-1@@terminal-1'] }, + terminalLayoutsByTabId: { + [staleTabId]: { + activeLeafId: 'leaf-stale', + ptyIdsByLeafId: { 'leaf-stale': 'remote:web-env-1@@terminal-1' } + }, + [sourceTabId]: { + activeLeafId: 'leaf-source', + ptyIdsByLeafId: { 'leaf-source': 'remote:web-env-1@@terminal-1' } + } + } + }) + const runtimeCall = vi.fn((request: { method: string }) => + Promise.resolve( + request.method === 'terminal.split' + ? { + id: 'split', + ok: true, + result: { + split: { + handle: 'terminal-2', + tabId: 'tab-source', + paneRuntimeId: -1, + leafId: 'leaf-created' + } + } + } + : { + id: 'list', + ok: true, + result: { + worktree: SPLIT_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: 'terminal', + tabs: [] + } + } + ) + ) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + expect( + splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'vertical', 'keyboard', { + worktreeId: SPLIT_WORKTREE_ID, + tabId: sourceTabId, + leafId: 'leaf-source' + }) + ).toBe(true) + + await vi.waitFor(() => + expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith(sourceTabId, 'leaf-created') + ) + }) + + it('keeps the latest split focus intent when responses complete out of order', async () => { + stubSplitSourceTab('tab-1') + const splitResolvers: ((response: unknown) => void)[] = [] + let resolveList!: (response: unknown) => void + const runtimeCall = vi.fn((request: { method: string }) => { + if (request.method === 'terminal.split') { + return new Promise((resolve) => splitResolvers.push(resolve)) + } + return new Promise((resolve) => { + resolveList = resolve + }) + }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + const source = { + worktreeId: SPLIT_WORKTREE_ID, + tabId: toWebTerminalSurfaceTabId('tab-1'), + leafId: 'leaf-1' + } + + expect( + splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'vertical', 'keyboard', source) + ).toBe(true) + expect( + splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'vertical', 'keyboard', source) + ).toBe(true) + await vi.waitFor(() => expect(splitResolvers).toHaveLength(2)) + + splitResolvers[1]?.(makeSplitResult('leaf-b')) + await vi.waitFor(() => + expect( + peekWebSessionFocusIntent({ environmentId: 'web-env-1' }, SPLIT_WORKTREE_ID) + ).toMatchObject({ hostTabId: 'tab-1', leafId: 'leaf-b' }) + ) + + splitResolvers[0]?.(makeSplitResult('leaf-a')) + await Promise.resolve() + await Promise.resolve() + const intentAfterOlderCompletion = peekWebSessionFocusIntent( + { environmentId: 'web-env-1' }, + SPLIT_WORKTREE_ID + ) + + resolveList({ + id: 'list', + ok: true, + result: { + worktree: SPLIT_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: 'terminal', + tabs: [] + } + }) + expect(intentAfterOlderCompletion).toMatchObject({ hostTabId: 'tab-1', leafId: 'leaf-b' }) + await vi.waitFor(() => + expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith( + toWebTerminalSurfaceTabId('tab-1'), + 'leaf-b' + ) + ) + expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalledWith( + toWebTerminalSurfaceTabId('tab-1'), + 'leaf-a' + ) + }) + + it('lets a newer split with an unreconciled source supersede older focus', async () => { + stubSplitSourceTab('tab-1') + const splitResolvers: ((response: unknown) => void)[] = [] + const runtimeCall = vi.fn(() => new Promise((resolve) => splitResolvers.push(resolve))) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + const split = (): boolean => + splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'vertical', 'keyboard', SPLIT_SOURCE) + expect(split()).toBe(true) + await vi.waitFor(() => expect(splitResolvers).toHaveLength(1)) + const staleSource = { + worktreeId: SPLIT_WORKTREE_ID, + tabId: toWebTerminalSurfaceTabId('tab-missing'), + leafId: 'leaf-missing' + } + expect( + splitWebRuntimeTerminal( + 'remote:web-env-1@@terminal-missing', + 'vertical', + 'keyboard', + staleSource + ) + ).toBe(true) + await vi.waitFor(() => expect(splitResolvers).toHaveLength(2)) + splitResolvers[0]?.(makeSplitResult('leaf-a')) + await vi.waitFor(() => + expect( + peekWebSessionFocusIntent({ environmentId: 'web-env-1' }, SPLIT_WORKTREE_ID) + ).toBeNull() + ) + expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalled() + splitResolvers[1]?.(makeSplitResult('leaf-b')) + }) + + it('does not let an older snapshot completion clear or focus over a newer split', async () => { + stubSplitSourceTab('tab-1') + const splitResolvers: ((response: unknown) => void)[] = [] + let resolveList!: (response: unknown) => void + const runtimeCall = vi.fn((request: { method: string }) => { + if (request.method === 'terminal.split') { + return new Promise((resolve) => splitResolvers.push(resolve)) + } + return new Promise((resolve) => { + resolveList = resolve + }) + }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + expect( + splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'vertical', 'keyboard', SPLIT_SOURCE) + ).toBe(true) + await vi.waitFor(() => expect(splitResolvers).toHaveLength(1)) + splitResolvers[0]?.({ + id: 'split-a', + ok: true, + result: { + split: { handle: 'terminal-a', tabId: 'tab-1', paneRuntimeId: -1, leafId: 'leaf-a' } + } + }) + await vi.waitFor(() => + expect( + peekWebSessionFocusIntent({ environmentId: 'web-env-1' }, SPLIT_WORKTREE_ID) + ).toMatchObject({ leafId: 'leaf-a' }) + ) + + expect( + splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'vertical', 'keyboard', SPLIT_SOURCE) + ).toBe(true) + await vi.waitFor(() => expect(splitResolvers).toHaveLength(2)) + splitResolvers[1]?.({ + id: 'split-b', + ok: true, + result: { + split: { handle: 'terminal-b', tabId: 'tab-1', paneRuntimeId: -1, leafId: 'leaf-b' } + } + }) + await vi.waitFor(() => + expect( + peekWebSessionFocusIntent({ environmentId: 'web-env-1' }, SPLIT_WORKTREE_ID) + ).toMatchObject({ leafId: 'leaf-b' }) + ) + + resolveList({ + id: 'list', + ok: true, + result: { + worktree: SPLIT_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: 'terminal', + tabs: [] + } + }) + await vi.waitFor(() => + expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith( + toWebTerminalSurfaceTabId('tab-1'), + 'leaf-b' + ) + ) + expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalledWith( + toWebTerminalSurfaceTabId('tab-1'), + 'leaf-a' + ) + }) + + it('does not claim focus from an old host that omits the leaf identity', async () => { + stubSplitSourceTab('tab-1') + const runtimeCall = vi.fn().mockResolvedValue({ + id: 'split', + ok: true, + result: { + split: { handle: 'terminal-2', tabId: 'tab-1', paneRuntimeId: -1 } + } + }) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + expect( + splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'vertical', 'keyboard', SPLIT_SOURCE) + ).toBe(true) + + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledOnce()) + expect(peekWebSessionFocusIntent({ environmentId: 'web-env-1' }, SPLIT_WORKTREE_ID)).toBeNull() + expect(mocks.acceptReplayedWebSessionTabsSnapshot).not.toHaveBeenCalled() + }) + + it('focuses a split invoked from a non-focused group tab when the viewer stays put', async () => { + mocks.getState.mockReturnValue(makeSplitSourceState('tab-1', 'leaf-1', 'tab-2')) + const runtimeCall = vi.fn((request: { method: string }) => + Promise.resolve( + request.method === 'terminal.split' + ? { + id: 'split', + ok: true, + result: { + split: { + handle: 'terminal-2', + tabId: 'tab-1', + paneRuntimeId: -1, + leafId: 'leaf-2' + } + } + } + : { + id: 'list', + ok: true, + result: { + worktree: SPLIT_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: 'terminal', + tabs: [] + } + } + ) + ) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + expect( + splitWebRuntimeTerminal( + 'remote:web-env-1@@terminal-1', + 'vertical', + 'context_menu', + SPLIT_SOURCE + ) + ).toBe(true) + + await vi.waitFor(() => + expect(mocks.activateTabAndFocusPane).toHaveBeenCalledWith( + toWebTerminalSurfaceTabId('tab-1'), + 'leaf-2' + ) + ) + expect( + peekWebSessionFocusIntent({ environmentId: 'web-env-1' }, SPLIT_WORKTREE_ID) + ).toMatchObject({ + hostTabId: 'tab-1', + leafId: 'leaf-2', + expectedCurrentLocalTabId: toWebTerminalSurfaceTabId('tab-2') + }) + }) + + it('does not steal focus after the viewer switches tabs while the host splits', async () => { + stubSplitSourceTab('tab-1') + let resolveSplit!: (response: unknown) => void + const runtimeCall = vi.fn( + () => + new Promise((resolve) => { + resolveSplit = resolve + }) + ) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + expect( + splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'vertical', 'keyboard', SPLIT_SOURCE) + ).toBe(true) + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledOnce()) + mocks.getState.mockReturnValue(makeSplitSourceState('tab-2')) + resolveSplit({ + id: 'split', + ok: true, + result: { + split: { + handle: 'terminal-2', + tabId: 'tab-1', + paneRuntimeId: -1, + leafId: 'leaf-2' + } + } + }) + + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledOnce()) + expect(peekWebSessionFocusIntent({ environmentId: 'web-env-1' }, SPLIT_WORKTREE_ID)).toBeNull() + expect(mocks.acceptReplayedWebSessionTabsSnapshot).not.toHaveBeenCalled() + expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalled() + }) + + it('drops a completed split intent after the environment re-pairs', async () => { + stubSplitSourceTab('tab-1') + replaceRuntimeEnvironmentRevisions([{ id: 'web-env-1', createdAt: 7 }]) + let resolveSplit!: (response: unknown) => void + const runtimeCall = vi.fn( + () => + new Promise((resolve) => { + resolveSplit = resolve + }) + ) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + expect( + splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'vertical', 'keyboard', SPLIT_SOURCE) + ).toBe(true) + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledOnce()) + replaceRuntimeEnvironmentRevisions([{ id: 'web-env-1', createdAt: 9 }]) + resolveSplit({ + id: 'split', + ok: true, + result: { + split: { + handle: 'terminal-2', + tabId: 'tab-1', + paneRuntimeId: -1, + leafId: 'leaf-2' + } + } + }) + + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledOnce()) + expect( + peekWebSessionFocusIntent( + { environmentId: 'web-env-1', pairingRevision: 9 }, + SPLIT_WORKTREE_ID + ) + ).toBeNull() + expect(mocks.acceptReplayedWebSessionTabsSnapshot).not.toHaveBeenCalled() + }) + + it('drops local focus when the environment re-pairs during snapshot replay', async () => { + stubSplitSourceTab('tab-1') + replaceRuntimeEnvironmentRevisions([{ id: 'web-env-1', createdAt: 7 }]) + let resolveList!: (response: unknown) => void + const runtimeCall = vi.fn((request: { method: string }) => + request.method === 'terminal.split' + ? Promise.resolve({ + id: 'split', + ok: true, + result: { + split: { + handle: 'terminal-2', + tabId: 'tab-1', + paneRuntimeId: -1, + leafId: 'leaf-2' + } + } + }) + : new Promise((resolve) => { + resolveList = resolve + }) + ) + vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } }) + + expect( + splitWebRuntimeTerminal('remote:web-env-1@@terminal-1', 'vertical', 'keyboard', SPLIT_SOURCE) + ).toBe(true) + await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(2)) + replaceRuntimeEnvironmentRevisions([{ id: 'web-env-1', createdAt: 9 }]) + resolveList({ + id: 'list', + ok: true, + result: { + worktree: SPLIT_WORKTREE_ID, + publicationEpoch: 'epoch-1', + snapshotVersion: 2, + activeGroupId: null, + activeTabId: null, + activeTabType: 'terminal', + tabs: [] + } + }) + + await vi.waitFor(() => + expect( + peekWebSessionFocusIntent( + { environmentId: 'web-env-1', pairingRevision: 7 }, + SPLIT_WORKTREE_ID + ) + ).toBeNull() + ) + expect(mocks.activateTabAndFocusPane).not.toHaveBeenCalled() + }) }) describe('closeWebRuntimeTerminal', () => { @@ -245,12 +859,4 @@ describe('closeWebRuntimeTerminal', () => { await vi.waitFor(() => expect(runtimeCall).toHaveBeenCalledTimes(1)) }) - - it('treats any configured remote runtime environment as a shared session', () => { - vi.stubGlobal('__ORCA_WEB_CLIENT__', false) - - expect(isWebRuntimeSessionActive('env-1')).toBe(true) - expect(isWebRuntimeSessionActive(' ')).toBe(false) - expect(isWebRuntimeSessionActive(null)).toBe(false) - }) }) diff --git a/src/renderer/src/runtime/web-runtime-session.ts b/src/renderer/src/runtime/web-runtime-session.ts index 12de456a537..d62b48527b3 100644 --- a/src/renderer/src/runtime/web-runtime-session.ts +++ b/src/renderer/src/runtime/web-runtime-session.ts @@ -28,3 +28,4 @@ export { setWebRuntimeTabProps, clearWebRuntimeTerminalBuffer } from './web-runtime-terminal-actions' +export type { WebRuntimeSplitSource } from './web-runtime-split-focus' diff --git a/src/renderer/src/runtime/web-runtime-split-focus.ts b/src/renderer/src/runtime/web-runtime-split-focus.ts new file mode 100644 index 00000000000..8e0fc02ee83 --- /dev/null +++ b/src/renderer/src/runtime/web-runtime-split-focus.ts @@ -0,0 +1,163 @@ +import { activateTabAndFocusPane } from '../lib/activate-tab-and-focus-pane' +import { useAppStore } from '../store' +import { matchesWebSessionIntentOwner } from './web-runtime-session-environment' +import { refreshWebRuntimeSessionTabsSnapshot } from './web-runtime-session-snapshot' +import { webSessionIntentOwnerKey, type WebSessionIntentOwner } from './web-session-intent-owner' +import { + clearWebSessionFocusIntentIfMatches, + recordWebSessionFocusIntent, + resolveWebSessionVisibleTabId +} from './web-session-focus-intent' +import { toHostSessionTabId, toWebTerminalSurfaceTabId } from './web-terminal-surface-id' +import type { RuntimeTerminalSplit } from '../../../shared/runtime-types' + +export type WebRuntimeSplitSource = { worktreeId: string; tabId: string; leafId: string } + +type WebRuntimeSplitFocusTarget = { + worktreeId: string + sourceTabId: string + sourceLeafId: string + sourcePtyId: string + expectedActiveWorktreeId: string | null + expectedExecutionHostId: string | null + expectedCurrentLocalTabId: string | null + expectedCurrentLocalLeafId: string | null +} + +type WebRuntimeSplitFocusRequest = { key: string; id: number } +const latestWebRuntimeSplitFocusRequestByKey = new Map() +let nextWebRuntimeSplitFocusRequestId = 0 + +export function beginWebRuntimeSplitFocusRequest( + owner: WebSessionIntentOwner, + worktreeId: string +): WebRuntimeSplitFocusRequest { + const request = { + key: `${webSessionIntentOwnerKey(owner)}\0${worktreeId}`, + id: ++nextWebRuntimeSplitFocusRequestId + } + latestWebRuntimeSplitFocusRequestByKey.set(request.key, request.id) + return request +} + +function isLatestWebRuntimeSplitFocusRequest(request: WebRuntimeSplitFocusRequest | null): boolean { + return Boolean(request && latestWebRuntimeSplitFocusRequestByKey.get(request.key) === request.id) +} + +export function finishWebRuntimeSplitFocusRequest( + request: WebRuntimeSplitFocusRequest | null +): void { + if (request && isLatestWebRuntimeSplitFocusRequest(request)) { + latestWebRuntimeSplitFocusRequestByKey.delete(request.key) + } +} + +export function captureWebRuntimeSplitFocusTarget( + ptyId: string, + source: WebRuntimeSplitSource +): WebRuntimeSplitFocusTarget | null { + const state = useAppStore.getState() + if (!state) { + return null + } + const sourceTab = state.tabsByWorktree?.[source.worktreeId]?.find( + (tab) => tab.id === source.tabId + ) + if ( + !sourceTab || + state.terminalLayoutsByTabId?.[source.tabId]?.ptyIdsByLeafId?.[source.leafId] !== ptyId + ) { + return null + } + const expectedActiveWorktreeId = state.activeWorktreeId ?? null + const expectedCurrentLocalTabId = expectedActiveWorktreeId + ? resolveWebSessionVisibleTabId(state, expectedActiveWorktreeId) + : null + return { + worktreeId: source.worktreeId, + sourceTabId: source.tabId, + sourceLeafId: source.leafId, + sourcePtyId: ptyId, + expectedActiveWorktreeId, + expectedExecutionHostId: state.activeWorkspaceExecutionHostId ?? null, + expectedCurrentLocalTabId, + expectedCurrentLocalLeafId: expectedCurrentLocalTabId + ? (state.terminalLayoutsByTabId?.[expectedCurrentLocalTabId]?.activeLeafId ?? null) + : null + } +} + +function matchesWebRuntimeSplitFocusTarget( + target: WebRuntimeSplitFocusTarget, + hostTabId: string, + newLeafId?: string +): boolean { + const state = useAppStore.getState() + if ( + !state || + toHostSessionTabId(target.sourceTabId) !== hostTabId || + state.terminalLayoutsByTabId?.[target.sourceTabId]?.ptyIdsByLeafId?.[target.sourceLeafId] !== + target.sourcePtyId || + (state.activeWorktreeId ?? null) !== target.expectedActiveWorktreeId || + (state.activeWorkspaceExecutionHostId ?? null) !== target.expectedExecutionHostId + ) { + return false + } + const currentTabId = target.expectedActiveWorktreeId + ? resolveWebSessionVisibleTabId(state, target.expectedActiveWorktreeId) + : null + if (currentTabId === target.expectedCurrentLocalTabId) { + const currentLeafId = currentTabId + ? (state.terminalLayoutsByTabId?.[currentTabId]?.activeLeafId ?? null) + : null + return currentLeafId === target.expectedCurrentLocalLeafId + } + return Boolean( + currentTabId && + newLeafId && + toHostSessionTabId(currentTabId) === hostTabId && + state.terminalLayoutsByTabId?.[currentTabId]?.activeLeafId === newLeafId + ) +} + +export async function focusSplitWebRuntimeTerminalPane( + owner: WebSessionIntentOwner, + target: WebRuntimeSplitFocusTarget | null, + request: WebRuntimeSplitFocusRequest | null, + split: RuntimeTerminalSplit | undefined +): Promise { + const hostTabId = split?.tabId?.trim() + const leafId = split?.leafId?.trim() + if ( + !hostTabId || + !leafId || + !target || + !isLatestWebRuntimeSplitFocusRequest(request) || + !matchesWebSessionIntentOwner(owner) || + !matchesWebRuntimeSplitFocusTarget(target, hostTabId) + ) { + return + } + recordWebSessionFocusIntent( + owner, + target.worktreeId, + hostTabId, + leafId, + target.expectedCurrentLocalTabId + ) + await refreshWebRuntimeSessionTabsSnapshot(owner.environmentId, target.worktreeId, { + expectedEnvironmentPairingRevision: owner.pairingRevision, + acceptCurrentSnapshot: true + }) + if ( + !isLatestWebRuntimeSplitFocusRequest(request) || + !matchesWebSessionIntentOwner(owner) || + !matchesWebRuntimeSplitFocusTarget(target, hostTabId, leafId) + ) { + if (isLatestWebRuntimeSplitFocusRequest(request)) { + clearWebSessionFocusIntentIfMatches(owner, target.worktreeId, hostTabId, leafId) + } + return + } + activateTabAndFocusPane(toWebTerminalSurfaceTabId(hostTabId), leafId) +} diff --git a/src/renderer/src/runtime/web-runtime-terminal-actions.ts b/src/renderer/src/runtime/web-runtime-terminal-actions.ts index c794031ef59..5c4f7b60781 100644 --- a/src/renderer/src/runtime/web-runtime-terminal-actions.ts +++ b/src/renderer/src/runtime/web-runtime-terminal-actions.ts @@ -11,8 +11,16 @@ import { toRuntimeWorktreeSelector } from './runtime-worktree-selector' import { isWebTerminalSurfaceTabId, toHostSessionTabId } from './web-terminal-surface-id' import { captureRuntimeEnvironmentCall, + captureWebSessionIntentOwner, isWebRuntimeSessionActive } from './web-runtime-session-environment' +import { + beginWebRuntimeSplitFocusRequest, + captureWebRuntimeSplitFocusTarget, + finishWebRuntimeSplitFocusRequest, + focusSplitWebRuntimeTerminalPane, + type WebRuntimeSplitSource +} from './web-runtime-split-focus' const pendingWebRuntimeSplitMirrorTelemetry = new Map>() const WEB_RUNTIME_SPLIT_MIRROR_SUPPRESSION_TTL_MS = 30_000 @@ -21,7 +29,8 @@ let pendingWebRuntimeSplitMirrorTelemetryId = 0 export function splitWebRuntimeTerminal( ptyId: string | null | undefined, direction: 'horizontal' | 'vertical', - telemetrySource: TerminalPaneSplitSource + telemetrySource: TerminalPaneSplitSource, + source?: WebRuntimeSplitSource ): boolean { if (!ptyId) { return false @@ -39,19 +48,29 @@ export function splitWebRuntimeTerminal( direction, pendingMirrorSuppressionId ) - void window.api.runtimeEnvironments - .call({ - selector: environmentId, - method: 'terminal.split', - params: { - terminal: remote.handle, - direction, - telemetrySource - }, - timeoutMs: 15_000 - }) - .then((response) => { - unwrapRuntimeRpcResult(response as RuntimeRpcResponse<{ split: RuntimeTerminalSplit }>) + const intentOwner = captureWebSessionIntentOwner(environmentId) + const focusTarget = source ? captureWebRuntimeSplitFocusTarget(ptyId, source) : null + // Advance the fence for every source-bearing gesture, even when its pane metadata is stale. + const focusRequest = source + ? beginWebRuntimeSplitFocusRequest(intentOwner, source.worktreeId) + : null + void captureRuntimeEnvironmentCall( + environmentId, + intentOwner.pairingRevision + )({ + method: 'terminal.split', + params: { + terminal: remote.handle, + direction, + telemetrySource + }, + timeoutMs: 15_000 + }) + .then(async (response) => { + const result = unwrapRuntimeRpcResult( + response as RuntimeRpcResponse<{ split: RuntimeTerminalSplit }> + ) + await focusSplitWebRuntimeTerminalPane(intentOwner, focusTarget, focusRequest, result?.split) }) .catch((error) => { releasePendingMirrorSuppression() @@ -61,6 +80,7 @@ export function splitWebRuntimeTerminal( toast.error(message) console.warn('[web-runtime-session] failed to split terminal:', message) }) + .finally(() => finishWebRuntimeSplitFocusRequest(focusRequest)) return true } diff --git a/src/renderer/src/runtime/web-session-focus-intent.ts b/src/renderer/src/runtime/web-session-focus-intent.ts index a23013f46ce..e1a45917c79 100644 --- a/src/renderer/src/runtime/web-session-focus-intent.ts +++ b/src/renderer/src/runtime/web-session-focus-intent.ts @@ -164,10 +164,12 @@ export function clearWebSessionFocusIntent(owner: WebSessionIntentOwner, worktre export function clearWebSessionFocusIntentIfMatches( owner: WebSessionIntentOwner, worktreeId: string, - hostTabId: string + hostTabId: string, + leafId?: string ): void { const key = focusIntentPartitionKey(owner, worktreeId) - if (pendingFocusByOwnerAndWorktree.get(key)?.hostTabId === hostTabId) { + const intent = pendingFocusByOwnerAndWorktree.get(key) + if (intent?.hostTabId === hostTabId && (leafId === undefined || intent.leafId === leafId)) { pendingFocusByOwnerAndWorktree.delete(key) } } diff --git a/src/renderer/src/runtime/web-session-tabs-sync-focus-intent.test.ts b/src/renderer/src/runtime/web-session-tabs-sync-focus-intent.test.ts index 9afcc482d40..1ed3b15621d 100644 --- a/src/renderer/src/runtime/web-session-tabs-sync-focus-intent.test.ts +++ b/src/renderer/src/runtime/web-session-tabs-sync-focus-intent.test.ts @@ -691,4 +691,97 @@ describe('applyWebSessionTabsSnapshot', () => { SECOND_LEAF_ID ) }) + + it('activates a host-created split leaf only for the client that requested it', () => { + const mirroredTabId = toWebTerminalSurfaceTabId('host-tab-1') + const sourceOnlyLayout = { + root: { type: 'leaf' as const, leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'remote:web-env-1@@terminal-1' } + } + const splitLayout = { + root: { + type: 'split' as const, + direction: 'vertical' as const, + first: { type: 'leaf' as const, leafId: LEAF_ID }, + second: { type: 'leaf' as const, leafId: SECOND_LEAF_ID } + }, + activeLeafId: SECOND_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { + [LEAF_ID]: 'remote:web-env-1@@terminal-1', + [SECOND_LEAF_ID]: 'remote:web-env-1@@terminal-2' + } + } + const state = makeState({ + activeTabId: mirroredTabId, + activeTabIdByWorktree: { [WT]: mirroredTabId }, + tabsByWorktree: { + [WT]: [ + { + id: mirroredTabId, + ptyId: 'remote:web-env-1@@terminal-1', + worktreeId: WT, + title: 'shell', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: NOW + } + ] + }, + terminalLayoutsByTabId: { [mirroredTabId]: sourceOnlyLayout } + }) + const splitSnapshot = makeSnapshot([ + { + type: 'terminal', + id: `host-tab-1::${LEAF_ID}`, + title: 'shell', + parentTabId: 'host-tab-1', + leafId: LEAF_ID, + parentLayout: splitLayout, + isActive: false, + status: 'ready', + terminal: 'terminal-1' + }, + { + type: 'terminal', + id: `host-tab-1::${SECOND_LEAF_ID}`, + title: 'split', + parentTabId: 'host-tab-1', + leafId: SECOND_LEAF_ID, + parentLayout: splitLayout, + isActive: true, + status: 'ready', + terminal: 'terminal-2' + } + ]) + + const unclaimed = applyWebSessionTabsSnapshot( + state, + splitSnapshot, + ENV, + NOW + 10 + ) as Partial + expect(unclaimed.terminalLayoutsByTabId?.[mirroredTabId]?.activeLeafId).toBe(LEAF_ID) + + recordWebSessionFocusIntent({ environmentId: ENV }, WT, 'host-tab-1', SECOND_LEAF_ID) + const claimed = applyWebSessionTabsSnapshot( + state, + splitSnapshot, + ENV, + NOW + 20 + ) as Partial + expect(claimed.terminalLayoutsByTabId?.[mirroredTabId]?.activeLeafId).toBe(SECOND_LEAF_ID) + + recordWebSessionFocusIntent({ environmentId: 'web-env-2' }, WT, 'host-tab-1', SECOND_LEAF_ID) + const otherClient = applyWebSessionTabsSnapshot( + state, + splitSnapshot, + ENV, + NOW + 30 + ) as Partial + expect(otherClient.terminalLayoutsByTabId?.[mirroredTabId]?.activeLeafId).toBe(LEAF_ID) + }) }) diff --git a/src/shared/runtime-terminal-contracts.ts b/src/shared/runtime-terminal-contracts.ts index d863fada9ba..16a16acf613 100644 --- a/src/shared/runtime-terminal-contracts.ts +++ b/src/shared/runtime-terminal-contracts.ts @@ -269,6 +269,8 @@ export type RuntimeTerminalSplit = { handle: string tabId: string paneRuntimeId: number + // Why: paired callers need the host-created leaf identity to focus the exact pane. + leafId?: string } export type RuntimeTerminalResolvePane = { diff --git a/tests/e2e/helpers/paired-client-host-session.ts b/tests/e2e/helpers/paired-client-host-session.ts new file mode 100644 index 00000000000..0e287bd538e --- /dev/null +++ b/tests/e2e/helpers/paired-client-host-session.ts @@ -0,0 +1,41 @@ +import type { Page } from '@stablyai/playwright-test' +import { expect } from './orca-app' + +export async function callPairedRuntime( + page: Page, + selector: string, + method: string, + params: unknown +): Promise { + return page.evaluate( + async ({ method, params, selector }) => { + const response = await window.api.runtimeEnvironments.call({ selector, method, params }) + if (!response.ok) { + throw new Error(`${response.error.code}: ${response.error.message}`) + } + return response.result + }, + { method, params, selector } + ) as Promise +} + +export async function waitForPairedClientWorktree( + page: Page, + expectedId?: string +): Promise { + const read = (): Promise => + page.evaluate( + (id) => + window.__store + ?.getState() + .allWorktrees() + .find((worktree) => !id || worktree.id === id)?.id ?? null, + expectedId + ) + await expect.poll(read, { timeout: 30_000 }).not.toBeNull() + const worktreeId = await read() + if (!worktreeId) { + throw new Error('Paired client did not receive the host workspace') + } + return worktreeId +} diff --git a/tests/e2e/helpers/terminal-pane-identity.ts b/tests/e2e/helpers/terminal-pane-identity.ts index bdc8f254b2b..5535d125369 100644 --- a/tests/e2e/helpers/terminal-pane-identity.ts +++ b/tests/e2e/helpers/terminal-pane-identity.ts @@ -3,6 +3,7 @@ import type { Page } from '@stablyai/playwright-test' export type PaneIdentitySnapshot = { tabId: string activeLeafId: string | null + storeActiveLeafId: string | null panes: { numericPaneId: number leafId: string @@ -100,6 +101,7 @@ export async function readPaneIdentitySnapshot(page: Page): Promise ({ numericPaneId: pane.id, leafId: pane.leafId, diff --git a/tests/e2e/helpers/terminal.ts b/tests/e2e/helpers/terminal.ts index ddfaf506156..221a65a2e59 100644 --- a/tests/e2e/helpers/terminal.ts +++ b/tests/e2e/helpers/terminal.ts @@ -49,11 +49,11 @@ export async function focusActiveTerminalInput(page: Page): Promise { : null const manager = tabId ? window.__paneManagers?.get(tabId) : null const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - if (!pane) { + if (!state || !tabId || !pane) { throw new Error('No active terminal pane to focus') } - state?.setActiveTab(tabId) - state?.setActiveTabType('terminal') + state.setActiveTab(tabId) + state.setActiveTabType('terminal') pane.terminal.focus() const textarea = pane.container.querySelector( '.xterm-helper-textarea' @@ -99,29 +99,36 @@ export async function waitForPaneIdentitySnapshot( page: Page, paneCount: number ): Promise { - await expect - .poll( - async () => { - const snapshot = await readPaneIdentitySnapshot(page) - return Boolean( - snapshot && - snapshot.panes.length === paneCount && - snapshot.panes.every( - (pane) => - UUID_RE.test(pane.leafId) && - pane.stablePaneId === pane.leafId && - pane.datasetLeafId === pane.leafId && - pane.ptyId !== null && - snapshot.ptyIdsByLeafId[pane.leafId] === pane.ptyId + let latestSnapshot: PaneIdentitySnapshot | null = null + try { + await expect + .poll( + async () => { + latestSnapshot = await readPaneIdentitySnapshot(page) + return Boolean( + latestSnapshot && + latestSnapshot.panes.length === paneCount && + latestSnapshot.panes.every( + (pane) => + UUID_RE.test(pane.leafId) && + pane.stablePaneId === pane.leafId && + pane.datasetLeafId === pane.leafId && + pane.ptyId !== null && + latestSnapshot?.ptyIdsByLeafId[pane.leafId] === pane.ptyId + ) ) - ) - }, - { - timeout: 15_000, - message: 'Split terminal panes did not settle with UUID leaf-keyed PTY bindings' - } - ) - .toBe(true) + }, + { + timeout: 15_000, + message: 'Split terminal panes did not settle with UUID leaf-keyed PTY bindings' + } + ) + .toBe(true) + } catch (error) { + throw new Error(`Last pane identity snapshot: ${JSON.stringify(latestSnapshot)}`, { + cause: error + }) + } const snapshot = await readPaneIdentitySnapshot(page) if (!snapshot) { diff --git a/tests/e2e/paired-remote-split-pane-focus.spec.ts b/tests/e2e/paired-remote-split-pane-focus.spec.ts new file mode 100644 index 00000000000..0f7048d9026 --- /dev/null +++ b/tests/e2e/paired-remote-split-pane-focus.spec.ts @@ -0,0 +1,273 @@ +import { toWebTerminalSurfaceTabId } from '../../src/shared/terminal-surface-id' +import type { RuntimeTerminalRead } from '../../src/shared/runtime-types' +import { expect, test } from './helpers/orca-app' +import { + callPairedRuntime, + waitForPairedClientWorktree +} from './helpers/paired-client-host-session' +import { revealPairedClientWindow } from './helpers/paired-client-window-reveal' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient +} from './helpers/paired-electron-client' +import { + focusActiveTerminalInput, + readPaneIdentitySnapshot, + waitForActivePanePtyId, + waitForPaneIdentitySnapshot +} from './helpers/terminal' + +type HostTerminalSurface = { + type: 'terminal' + parentTabId: string + leafId: string + terminal: string + parentLayout?: { activeLeafId?: string | null } +} + +const splitRightChord = process.platform === 'darwin' ? 'Meta+d' : 'Control+Shift+d' + +test('focuses the pane a client split creates on a paired remote workspace @headful', async ({ + orcaPage +}, testInfo) => { + test.setTimeout(150_000) + const hostWorktreeId = await orcaPage.evaluate(() => window.__store?.getState().activeWorktreeId) + if (!hostWorktreeId) { + throw new Error('Headed host has no active seeded workspace') + } + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const client = await launchPairedElectronClient(offer, testInfo, 'paired-split-focus-client') + try { + await revealPairedClientWindow(client) + await waitForPairedClientWorktree(client.page, hostWorktreeId) + + const created = await callPairedRuntime<{ tab: { parentTabId: string } }>( + client.page, + client.environmentId, + 'session.tabs.createTerminal', + { + worktree: `id:${hostWorktreeId}`, + activate: false, + select: false, + navigation: 'caller' + } + ) + + const webTabId = toWebTerminalSurfaceTabId(created.tab.parentTabId) + await client.page.evaluate( + (id) => window.__store?.getState().setActiveWorktree(id), + hostWorktreeId + ) + const tab = client.page.locator(`[data-testid="sortable-tab"][data-tab-id="${webTabId}"]`) + await expect(tab).toBeVisible({ timeout: 30_000 }) + await tab.click() + await expect(tab).toHaveAttribute('data-active', 'true') + + const sourcePtyId = await waitForActivePanePtyId(client.page, 30_000) + const before = await waitForPaneIdentitySnapshot(client.page, 1) + const sourceLeafId = before.activeLeafId + if (!sourceLeafId) { + throw new Error('Paired source pane has no stable leaf identity') + } + + await focusActiveTerminalInput(client.page) + await client.page.keyboard.press(splitRightChord) + + let after = await waitForPaneIdentitySnapshot(client.page, 2) + let createdPane = after.panes.find((pane) => pane.leafId !== sourceLeafId) + await expect + .poll( + async () => { + const current = await readPaneIdentitySnapshot(client.page) + const created = current?.panes.find((pane) => pane.leafId !== sourceLeafId) + const domLeafId = await client.page.evaluate( + () => document.activeElement?.closest('.pane')?.dataset.leafId ?? null + ) + if (!current || current.panes.length !== 2 || !created) { + return false + } + after = current + createdPane = created + return ( + current.activeLeafId === created.leafId && + current.storeActiveLeafId === created.leafId && + domLeafId === created.leafId + ) + }, + { timeout: 30_000, message: 'Host-created split leaf never claimed client focus' } + ) + .toBe(true) + if (!createdPane) { + throw new Error('Paired split did not materialize a new pane') + } + const createdLeafId = createdPane.leafId + const focusedPtyId = await waitForActivePanePtyId(client.page, 30_000) + expect(focusedPtyId).toBe(createdPane.ptyId) + expect(focusedPtyId).not.toBe(sourcePtyId) + + const focusedDomLeafId = await client.page.evaluate( + () => document.activeElement?.closest('.pane')?.dataset.leafId ?? null + ) + expect(focusedDomLeafId).toBe(createdPane.leafId) + + const marker = `STA_5518_FOCUSED_${Date.now()}` + await client.page.keyboard.type(`printf '%s\\n' ${JSON.stringify(marker)}`) + await client.page.keyboard.press('Enter') + + await expect + .poll( + () => + client.page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + return Object.fromEntries( + (manager?.getPanes() ?? []).map((pane) => [ + pane.leafId, + pane.serializeAddon?.serialize?.() ?? '' + ]) + ) + }, webTabId), + { timeout: 30_000, message: 'Immediate post-split marker never reached the visible pane' } + ) + .toMatchObject({ [createdLeafId]: expect.stringContaining(marker) }) + + const hostTabs = await callPairedRuntime<{ tabs: HostTerminalSurface[] }>( + client.page, + client.environmentId, + 'session.tabs.list', + { worktree: `id:${hostWorktreeId}` } + ) + const hostLeaves = hostTabs.tabs.filter( + (surface) => surface.type === 'terminal' && surface.parentTabId === created.tab.parentTabId + ) + expect(hostLeaves.map((surface) => surface.leafId).sort()).toEqual( + after.panes.map((pane) => pane.leafId).sort() + ) + expect(hostLeaves[0]?.parentLayout?.activeLeafId).toBe(createdPane.leafId) + + const surfaceByLeafId = new Map(hostLeaves.map((surface) => [surface.leafId, surface])) + await expect + .poll( + async () => { + const reads = await Promise.all( + [sourceLeafId, createdLeafId].map(async (leafId) => { + const surface = surfaceByLeafId.get(leafId) + if (!surface) { + return false + } + const result = await callPairedRuntime<{ terminal: RuntimeTerminalRead }>( + client.page, + client.environmentId, + 'terminal.read', + { terminal: surface.terminal } + ) + return result.terminal.tail.join('\n').includes(marker) + }) + ) + return reads + }, + { timeout: 30_000, message: 'Host PTY output did not identify one marker destination' } + ) + .toEqual([false, true]) + + const headerSplit = client.page.locator( + 'button[data-contextual-tour-target="terminal-pane-split-target"]' + ) + await expect(headerSplit).toBeVisible() + await headerSplit.click() + let afterHeaderSplit = await waitForPaneIdentitySnapshot(client.page, 3) + const priorLeafIds = new Set(after.panes.map((pane) => pane.leafId)) + let headerCreatedPane = afterHeaderSplit.panes.find((pane) => !priorLeafIds.has(pane.leafId)) + await expect + .poll( + async () => { + const current = await readPaneIdentitySnapshot(client.page) + const created = current?.panes.find((pane) => !priorLeafIds.has(pane.leafId)) + const domLeafId = await client.page.evaluate( + () => document.activeElement?.closest('.pane')?.dataset.leafId ?? null + ) + if (!current || current.panes.length !== 3 || !created) { + return false + } + afterHeaderSplit = current + headerCreatedPane = created + return ( + current.activeLeafId === created.leafId && + current.storeActiveLeafId === created.leafId && + domLeafId === created.leafId + ) + }, + { timeout: 30_000, message: 'Header-created split leaf never claimed client focus' } + ) + .toBe(true) + if (!headerCreatedPane) { + throw new Error('Header split did not materialize a new pane') + } + const headerCreatedLeafId = headerCreatedPane.leafId + const headerMarker = `STA_5518_HEADER_FOCUSED_${Date.now()}` + await client.page.keyboard.type(`printf '%s\\n' ${JSON.stringify(headerMarker)}`) + await client.page.keyboard.press('Enter') + + await expect + .poll( + () => + client.page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + return Object.fromEntries( + (manager?.getPanes() ?? []).map((pane) => [ + pane.leafId, + pane.serializeAddon?.serialize?.() ?? '' + ]) + ) + }, webTabId), + { timeout: 30_000, message: 'Header split marker never reached the focused pane' } + ) + .toMatchObject({ [headerCreatedLeafId]: expect.stringContaining(headerMarker) }) + + const afterHeaderHostTabs = await callPairedRuntime<{ tabs: HostTerminalSurface[] }>( + client.page, + client.environmentId, + 'session.tabs.list', + { worktree: `id:${hostWorktreeId}` } + ) + const afterHeaderHostLeaves = afterHeaderHostTabs.tabs.filter( + (surface) => surface.type === 'terminal' && surface.parentTabId === created.tab.parentTabId + ) + expect(afterHeaderHostLeaves.map((surface) => surface.leafId).sort()).toEqual( + afterHeaderSplit.panes.map((pane) => pane.leafId).sort() + ) + expect(afterHeaderHostLeaves[0]?.parentLayout?.activeLeafId).toBe(headerCreatedPane.leafId) + const headerSurfaceByLeafId = new Map( + afterHeaderHostLeaves.map((surface) => [surface.leafId, surface]) + ) + await expect + .poll( + async () => { + const reads = await Promise.all( + afterHeaderSplit.panes.map(async ({ leafId }) => { + const surface = headerSurfaceByLeafId.get(leafId) + if (!surface) { + return false + } + const result = await callPairedRuntime<{ terminal: RuntimeTerminalRead }>( + client.page, + client.environmentId, + 'terminal.read', + { terminal: surface.terminal } + ) + return result.terminal.tail.join('\n').includes(headerMarker) + }) + ) + return reads + }, + { timeout: 30_000, message: 'Header marker did not reach exactly its created host PTY' } + ) + .toEqual(afterHeaderSplit.panes.map(({ leafId }) => leafId === headerCreatedLeafId)) + + await testInfo.attach('paired-cmd-d-focused-pane', { + body: await client.page.screenshot(), + contentType: 'image/png' + }) + } finally { + await client.dispose() + } +})