fix(remote): focus host-delegated split panes (#16886)

* fix(remote): focus host-delegated split panes

Return the authoritative leaf identity from terminal.split, record viewer-local focus intent behind the captured pairing revision, and replay the mirrored layout before focusing the exact pane. Preserve old-host fallback and prevent delayed split responses from stealing focus after the viewer moves away.

Add deterministic runtime, renderer, concurrency, compatibility, and headed paired-Electron coverage for Cmd+D, header splits, and immediate PTY input routing.

Fixes #16510

* fix(remote): preserve split focus across tab groups

Resolve the initiating source tab and leaf from the remote PTY, while keeping the viewer's current focus as a separate anti-steal baseline. This lets context-menu/header splits from non-focused group tabs focus their result without allowing delayed responses to override a later navigation.

* test(remote): drive split focus with key events

* test(remote): use the platform split shortcut

* fix(remote): fence concurrent split focus intent

* fix(remote): harden split focus ordering

* fix(remote): preserve split focus after runtime refactor

* fix(remote): fence stale split focus gestures

* test(remote): keep split focus regression within line budget
This commit is contained in:
Jinwoo Hong
2026-08-30 03:07:20 -04:00
committed by GitHub
parent 72cf80dc18
commit ae0f3675a1
24 changed files with 1472 additions and 90 deletions
+89 -1
View File
@@ -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<string, string> } | 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<string, string> } | 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)
+20 -22
View File
@@ -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<string>()
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<string>,
timeoutMs = 10_000
): Promise<string> {
private waitForLeafInTab(tabId: string, leafId: string, timeoutMs = 10_000): Promise<string> {
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()
+2 -1
View File
@@ -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 }),
+1
View File
@@ -170,6 +170,7 @@ export type UiCommandEventApi = {
direction: 'horizontal' | 'vertical'
command?: string
telemetrySource?: TerminalPaneSplitSource
newLeafId?: string
}) => void
) => () => void
onRenameTerminal: (
+2
View File
@@ -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)
@@ -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(
@@ -13,6 +13,8 @@ import type { resolveTerminalKeyboardShortcutAction } from './terminal-keyboard-
type TerminalShortcutAction = NonNullable<ReturnType<typeof resolveTerminalKeyboardShortcutAction>>
type ActionDispatchContext = {
tabId: string
worktreeId: string
fallbackCwd: string
expandedPaneIdRef: React.RefObject<number | null>
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,
@@ -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,
@@ -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<number, PtyTransport>(),
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<number, PtyTransport>(),
paneCwdMap: new Map(),
fallbackCwd: '/fallback',
pane: { id: 1 } as ManagedPane,
pane: { id: 1, leafId: 'leaf-1' } as ManagedPane,
direction: 'horizontal',
source: 'context_menu'
})
@@ -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<number, PtyTransport>
@@ -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)
@@ -139,6 +139,7 @@ export function useTerminalPaneContextMenu({
paneCwdRef,
contextPaneIdRef,
tabId,
worktreeId,
fallbackCwd,
resolveMenuPane
})
@@ -26,6 +26,7 @@ type UseTerminalPaneSplitActionsDeps = {
paneCwdRef: React.RefObject<PaneCwdMap>
contextPaneIdRef: React.RefObject<number | null>
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')
@@ -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 }))
}
@@ -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<string, unknown> {
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)
})
})
@@ -28,3 +28,4 @@ export {
setWebRuntimeTabProps,
clearWebRuntimeTerminalBuffer
} from './web-runtime-terminal-actions'
export type { WebRuntimeSplitSource } from './web-runtime-split-focus'
@@ -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<string, number>()
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<void> {
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)
}
@@ -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<string, Set<string>>()
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
}
@@ -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)
}
}
@@ -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<WebSessionTabsSyncState>
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<WebSessionTabsSyncState>
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<WebSessionTabsSyncState>
expect(otherClient.terminalLayoutsByTabId?.[mirroredTabId]?.activeLeafId).toBe(LEAF_ID)
})
})
+2
View File
@@ -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 = {
@@ -0,0 +1,41 @@
import type { Page } from '@stablyai/playwright-test'
import { expect } from './orca-app'
export async function callPairedRuntime<TResult>(
page: Page,
selector: string,
method: string,
params: unknown
): Promise<TResult> {
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<TResult>
}
export async function waitForPairedClientWorktree(
page: Page,
expectedId?: string
): Promise<string> {
const read = (): Promise<string | null> =>
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
}
@@ -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<PaneIdentity
return {
tabId,
activeLeafId: activePane?.leafId ?? null,
storeActiveLeafId: store.getState().terminalLayoutsByTabId[tabId]?.activeLeafId ?? null,
panes: manager.getPanes().map((pane) => ({
numericPaneId: pane.id,
leafId: pane.leafId,
+32 -25
View File
@@ -49,11 +49,11 @@ export async function focusActiveTerminalInput(page: Page): Promise<void> {
: 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<PaneIdentitySnapshot> {
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) {
@@ -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<HTMLElement>('.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<HTMLElement>('.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<HTMLElement>('.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()
}
})