mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -170,6 +170,7 @@ export type UiCommandEventApi = {
|
||||
direction: 'horizontal' | 'vertical'
|
||||
command?: string
|
||||
telemetrySource?: TerminalPaneSplitSource
|
||||
newLeafId?: string
|
||||
}) => void
|
||||
) => () => void
|
||||
onRenameTerminal: (
|
||||
|
||||
@@ -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,
|
||||
|
||||
+58
-2
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user