fix(remote): simplify browser preview ownership

This commit is contained in:
Jinwoo-H
2026-08-12 04:38:02 -04:00
parent 39cbd4efa4
commit 100736e7d9
18 changed files with 170 additions and 446 deletions
+15 -13
View File
@@ -1378,35 +1378,31 @@
"providers": ["remote-runtime"],
"coveredPlatforms": ["macos"],
"coveredProviders": ["remote-runtime"],
"coverageNotes": "A headed macOS desktop host and separate paired web client exercise the Explorer eye-button path with a real remote file URL, host browser DOM, renderer split layout, terminal focus transitions, and browser close. Unit contracts cover routing, one-shot split placement, manual moves, overlapping opens, teardown, non-secure LAN UUID generation, and capability-gated deterministic headless page creation after response loss. Linux, Windows, and a live headless-server journey remain gaps.",
"coverageNotes": "A headed macOS desktop host and separate paired web client exercise the Explorer eye-button path with a real remote file URL, host browser DOM, renderer split layout, terminal focus transitions, and browser close. Unit contracts cover owning-runtime routing, one-shot split placement, focus ordering against the exact predecessor tab, manual moves, overlapping opens, failure cleanup, and teardown. Linux, Windows, and a live headless-server journey remain gaps.",
"motivatingLinks": [
"https://linear.app/stably/issue/STA-3872/unable-to-open-html-files-in-remote-server",
"https://github.com/stablyai/orca/pull/13876"
],
"invariant": "Opening one HTML preview from a paired client creates exactly one browser page on the worktree-owning runtime and exactly one mirrored client browser workspace. The preview remains in the requested client-side split, activating it remains authoritative after a terminal was active, and closing it removes only the browser identities while the source editor and terminal survive.",
"oracle": "Open a real HTML file through Explorer on a separate client paired to an isolated headed desktop host. Require one new host browser tab, one client browser workspace and unified tab, two visible split groups, an exact client-handle-to-host-page identity, rendered fixture DOM, host and client browser focus after a terminal-to-browser click, and browser-only close convergence with the editor and terminal still visible. Separately drop create responses and retry the same requested page identity, requiring one physical headless page. Run the byte-identical Electron test on latest main, the candidate, and the candidate with its fix reverted.",
"oracle": "Open a real HTML file through Explorer on a separate client paired to an isolated headed desktop host. Require one new host browser tab, one client browser workspace and unified tab, two visible split groups, an exact client-handle-to-host-page identity, rendered fixture DOM, host and client browser focus after a terminal-to-browser click, and browser-only close convergence with the editor and terminal still visible. Run the byte-identical Electron test on latest main, the candidate, and the candidate with owning-runtime routing disabled.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/browser/offscreen-browser-backend.test.ts src/main/runtime/orca-runtime-browser.test.ts src/main/runtime/rpc/methods/browser.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/runtime/web-session-browser-placement.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/orca-runtime-browser.test.ts src/main/runtime/rpc/methods/browser.test.ts src/renderer/src/lib/file-preview.test.ts src/renderer/src/runtime/web-session-browser-placement.test.ts src/renderer/src/runtime/web-runtime-session.test.ts src/renderer/src/runtime/web-session-tabs-sync.test.ts src/renderer/src/runtime/remote-server-parity.test.ts",
"ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3",
"SKIP_BUILD=1 ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1",
"ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1"
],
"testFiles": [
"src/main/browser/offscreen-browser-backend.test.ts",
"src/main/runtime/orca-runtime-browser.test.ts",
"src/main/runtime/rpc/methods/browser.test.ts",
"src/renderer/src/lib/file-preview.test.ts",
"src/renderer/src/runtime/web-session-browser-placement.test.ts",
"src/renderer/src/runtime/web-runtime-session.test.ts",
"src/renderer/src/runtime/web-session-tabs-sync.test.ts",
"src/renderer/src/runtime/remote-server-parity.test.ts",
"tests/e2e/paired-remote-html-browser-focus.spec.ts"
],
"assertionRefs": [
{
"file": "src/main/browser/offscreen-browser-backend.test.ts",
"assertions": ["retrying one requested page identity returns one physical offscreen page"]
},
{
"file": "src/renderer/src/runtime/web-session-browser-placement.test.ts",
"assertions": [
@@ -1422,6 +1418,12 @@
"later client moves supersede initial preview placement"
]
},
{
"file": "src/renderer/src/runtime/remote-server-parity.test.ts",
"assertions": [
"a caller-created browser gains focus when the exact predecessor tab is still visible"
]
},
{
"file": "tests/e2e/paired-remote-html-browser-focus.spec.ts",
"assertions": [
@@ -1439,7 +1441,7 @@
"command": "ORCA_E2E_WEB_CLIENT=1 ORCA_E2E_FORCE_HEADFUL=1 ORCA_E2E_FORWARD_APP_LOGS=1 pnpm exec playwright test tests/e2e/paired-remote-html-browser-focus.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1 --repeat-each=3",
"result": "passed",
"durationSeconds": 16.1,
"summary": "Deterministic-identity candidate on merge-base 7319d59a103311a5fbf3affb5d293f3ba1b9594a passed exact host/client cardinality, split stability, rendered DOM, focus authority, close convergence, and surviving editor and terminal."
"summary": "The candidate passed exact host/client cardinality, split stability, rendered DOM, focus authority, close convergence, and surviving editor and terminal."
},
{
"date": "2026-08-12",
@@ -1471,7 +1473,7 @@
],
"runtimeBudget": {
"p95Seconds": 60,
"scope": "six focused host/renderer contracts plus one isolated headed paired-runtime journey"
"scope": "seven focused host/renderer contracts plus one isolated headed paired-runtime journey"
},
"flakeHistory": {
"status": "unknown",
@@ -1483,16 +1485,16 @@
},
"performanceBudget": {
"required": true,
"evidence": "The change adds no timer, polling, subprocess, RPC method, stream opcode, receipt ledger, or host publication. One optional requested-page field is capability-gated for mixed versions. Pending client placements are capped at 128 and cleared on adoption or worktree/environment teardown."
"evidence": "The change adds no timer, polling, retry, subprocess, RPC method, stream opcode, receipt ledger, or host publication. It performs one bounded scan of the current worktree's local tabs at user-initiated browser creation. Pending client placements are capped at 128 and cleared on adoption, failure, or worktree/environment teardown."
},
"promotionCriteria": [
"Collect Linux and physical Windows paired-server evidence.",
"Collect a live headless-serve response-loss journey.",
"Collect a live headless-serve HTML preview journey.",
"Collect 100 consecutive focused CI passes or 14 days without an unexplained flake."
],
"knownGaps": [
"Live paired-server validation currently covers macOS only.",
"A live headless-serve response-loss journey and multiple simultaneous viewers are not exercised by the Electron journey; deterministic host/renderer contracts cover response-loss replay.",
"A live headless-serve journey and multiple simultaneous viewers are not exercised by the Electron journey.",
"Direct SSH remote file URLs remain intentionally unsupported and local file previews retain their existing path."
],
"demotionRule": "Demote if one paired HTML action can create duplicate client or host browser identities, lose the requested split, bounce browser focus to another surface, or remove unrelated editor or terminal state on close."
-1
View File
@@ -10,7 +10,6 @@ export type BrowserBackendCreateTab = {
url: string
worktreeId?: string
profileId?: string
browserPageId?: string
}
export type BrowserBackend = {
@@ -1,71 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BrowserManager } from './browser-manager'
const mocks = vi.hoisted(() => ({
constructed: vi.fn(),
nextWebContentsId: 1
}))
vi.mock('electron', () => ({
BrowserWindow: class {
private destroyed = false
readonly webContents = {
id: mocks.nextWebContentsId++,
once: vi.fn(),
on: vi.fn((event: string, listener: () => void) => {
if (event === 'did-finish-load') {
listener()
}
}),
removeListener: vi.fn(),
loadURL: vi.fn(async () => undefined)
}
constructor() {
mocks.constructed()
}
isDestroyed(): boolean {
return this.destroyed
}
destroy(): void {
this.destroyed = true
}
}
}))
vi.mock('./browser-session-registry', () => ({
browserSessionRegistry: {
getDefaultProfile: vi.fn(() => null),
getProfile: vi.fn(() => null)
}
}))
import { OffscreenBrowserBackend } from './offscreen-browser-backend'
describe('OffscreenBrowserBackend deterministic page creation', () => {
beforeEach(() => {
mocks.constructed.mockClear()
mocks.nextWebContentsId = 1
})
it('returns the existing page when the same requested identity is retried', async () => {
const browserManager = {
registerOffscreenGuest: vi.fn(),
unregisterGuest: vi.fn()
} as unknown as BrowserManager
const backend = new OffscreenBrowserBackend(browserManager)
const browserPageId = '00000000-0000-4000-8000-000000000001'
await expect(
backend.createTab({ url: 'about:blank', worktreeId: 'worktree-1', browserPageId })
).resolves.toEqual({ browserPageId })
await expect(
backend.createTab({ url: 'about:blank', worktreeId: 'worktree-1', browserPageId })
).resolves.toEqual({ browserPageId })
expect(mocks.constructed).toHaveBeenCalledOnce()
expect(browserManager.registerOffscreenGuest).toHaveBeenCalledOnce()
})
})
@@ -23,11 +23,7 @@ export class OffscreenBrowserBackend implements BrowserBackend {
constructor(private readonly browserManager: BrowserManager) {}
async createTab(params: BrowserBackendCreateTab): Promise<{ browserPageId: string }> {
const browserPageId = params.browserPageId ?? randomUUID()
const existing = this.windowsByPageId.get(browserPageId)
if (existing && !existing.isDestroyed()) {
return { browserPageId }
}
const browserPageId = randomUUID()
// Why: profiles map to Electron partitions; using the profile's partition
// makes cookies/storage persist in the same SQLite DB the desktop path uses.
const profile = params.profileId
@@ -56,10 +52,8 @@ export class OffscreenBrowserBackend implements BrowserBackend {
// teardown), drop the registry entry so commands fail cleanly instead of
// resolving a dead WebContents.
win.webContents.once('destroyed', () => {
if (this.windowsByPageId.get(browserPageId) === win) {
this.windowsByPageId.delete(browserPageId)
this.browserManager.unregisterGuest(browserPageId)
}
this.windowsByPageId.delete(browserPageId)
this.browserManager.unregisterGuest(browserPageId)
})
// Why: register the guest and return immediately so the new tab appears
+1 -31
View File
@@ -95,7 +95,6 @@ function createHost(overrides: Partial<RuntimeBrowserCommandHost> = {}): Runtime
}))
} as unknown as AgentBrowserBridge)
return {
getRuntimeId: () => 'runtime-1',
resolveWorktreeSelector: async (selector) => ({ id: selector.replace(/^id:/, '') }),
getAuthoritativeWindow: vi.fn(),
getAvailableAuthoritativeWindow: vi.fn(() => null),
@@ -642,42 +641,13 @@ describe('RuntimeBrowserCommands headless offscreen routing', () => {
expect(createTab).toHaveBeenCalledWith({
url: 'https://example.com',
worktreeId: 'wt-1',
profileId: undefined,
browserPageId: undefined
profileId: undefined
})
// No renderer round-trip in headless mode.
expect(waitForTabRegistrationMock).not.toHaveBeenCalled()
expect(setActiveTab).toHaveBeenCalledWith(202, 'wt-1')
})
it('forwards deterministic page identity to the headless backend', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const pageId = '00000000-0000-4000-8000-000000000001'
const createTab = vi.fn(async () => ({ browserPageId: pageId }))
const bridge = {
getRegisteredTabs: vi.fn(() => new Map([[pageId, 202]])),
setActiveTab: vi.fn()
} as unknown as AgentBrowserBridge
const commands = new RuntimeBrowserCommands(
createHost({
getAgentBrowserBridge: () => bridge,
getAvailableAuthoritativeWindow: vi.fn(() => null),
getOffscreenBrowserBackend: vi.fn(() => ({ createTab, closeTab: vi.fn() }))
})
)
await expect(
commands.browserTabCreate({ url: 'https://example.com', requestedPageId: pageId })
).resolves.toEqual({ browserPageId: pageId })
expect(createTab).toHaveBeenCalledWith({
url: 'https://example.com',
worktreeId: undefined,
profileId: undefined,
browserPageId: pageId
})
})
it('rejects tab creation when neither a renderer nor an offscreen backend is available', async () => {
const { RuntimeBrowserCommands } = await import('./orca-runtime-browser')
const commands = new RuntimeBrowserCommands(
+4 -11
View File
@@ -162,6 +162,7 @@ export class RuntimeBrowserCommands {
private readonly activeScreencastPageIds = new Set<string>()
private readonly activeScreencastsByPageId = new Map<string, ActiveBrowserScreencastPage>()
private readonly stoppingScreencastPageIds = new Map<string, Promise<void>>()
constructor(private readonly host: RuntimeBrowserCommandHost) {}
private requireAgentBrowserBridge(): AgentBrowserBridge {
@@ -1297,7 +1298,6 @@ export class RuntimeBrowserCommands {
waitForRegistration?: boolean
activate?: boolean
targetGroupId?: string
requestedPageId?: string
}): Promise<{ browserPageId: string }> {
const url = params.url ?? 'about:blank'
const worktreeId = params.worktree
@@ -1322,8 +1322,7 @@ export class RuntimeBrowserCommands {
worktreeId,
params.profileId,
params.activate,
params.targetGroupId,
params.requestedPageId
params.targetGroupId
)
}
const { browserPageId } = await this.createBrowserTabInRenderer(
@@ -1707,15 +1706,9 @@ export class RuntimeBrowserCommands {
worktreeId?: string,
profileId?: string,
activate?: boolean,
targetGroupId?: string,
requestedPageId?: string
targetGroupId?: string
): Promise<{ browserPageId: string }> {
const { browserPageId } = await offscreen.createTab({
url,
worktreeId,
profileId,
browserPageId: requestedPageId
})
const { browserPageId } = await offscreen.createTab({ url, worktreeId, profileId })
const bridge = this.host.getAgentBrowserBridge()
const wcId = bridge?.getRegisteredTabs(worktreeId).get(browserPageId)
if (bridge && wcId != null) {
-2
View File
@@ -2182,7 +2182,6 @@ describe('OrcaRuntimeService', () => {
expect(capabilities).toContain('browser.screencast.v1')
// ...and the headless marker tells clients not to fall back to a local tab.
expect(capabilities).toContain('browser.headless.v1')
expect(capabilities).toContain('browser.deterministic-page-create.v1')
expect(capabilities).toContain('browser.certificate-trust.v1')
})
it('surfaces live offscreen load failures in headless browser snapshots', () => {
@@ -2308,7 +2307,6 @@ describe('OrcaRuntimeService', () => {
runtime.setOffscreenBrowserBackend({ createTab: vi.fn(), closeTab: vi.fn() })
expect(runtime.getStatus().capabilities).not.toContain('browser.headless.v1')
expect(runtime.getStatus().capabilities).not.toContain('browser.deterministic-page-create.v1')
// Desktop webviews still host certificate trust, so the proceed capability stays advertised for remote clients controlling those pages.
expect(runtime.getStatus().capabilities).toContain('browser.certificate-trust.v1')
})
-2
View File
@@ -492,7 +492,6 @@ import {
} from '../../shared/agent-scratch-worktrees'
import {
BROWSER_HEADLESS_RUNTIME_CAPABILITY,
BROWSER_DETERMINISTIC_PAGE_CREATE_RUNTIME_CAPABILITY,
BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY,
MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION,
ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY,
@@ -4878,7 +4877,6 @@ export class OrcaRuntimeService {
)
if (hasOffscreen) {
capabilities.push(BROWSER_HEADLESS_RUNTIME_CAPABILITY)
capabilities.push(BROWSER_DETERMINISTIC_PAGE_CREATE_RUNTIME_CAPABILITY)
}
// Why: certificate proceed is owned by the browser-hosting process for both
// desktop webviews and offscreen pages. Advertise whenever either backend
+2 -1
View File
@@ -21,6 +21,7 @@ import {
Scroll,
Select,
SelectorPath,
TabCurrent,
TabSetProfile,
TabClose,
TabCreate,
@@ -101,7 +102,7 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [
}),
defineMethod({
name: 'browser.tabCurrent',
params: TabList,
params: TabCurrent,
handler: async (params, { runtime }) => runtime.browserTabCurrent(params)
}),
defineMethod({
@@ -1,4 +1,5 @@
// Why: separate schemas keep dispatch wiring scannable and both files under the cap.
// Why: browser schemas stay separate from handler registration so both sides
// remain under the line cap and dispatch wiring stays scannable.
import { z } from 'zod'
import {
BrowserTarget,
@@ -115,10 +116,11 @@ export const TabCreate = z.object({
worktree: OptionalString,
profileId: OptionalString,
waitForRegistration: z.boolean().optional(),
// User-initiated opens focus the tab; agent/automation opens stay background.
activate: z.boolean().optional(),
// Why: headless hosts need the clicked split instead of their first/active group.
targetGroupId: OptionalString,
requestedPageId: z.uuid().optional()
// Why: the split group whose "+" was clicked, so a headless host places the
// new browser tab there instead of coalescing into the first/active group.
targetGroupId: OptionalString
})
export const TabShow = z.object({
@@ -126,6 +128,8 @@ export const TabShow = z.object({
worktree: OptionalString
})
export const TabCurrent = z.object({ worktree: OptionalString })
export const TabClose = z.object({
index: z
.unknown()
+2 -4
View File
@@ -53,8 +53,7 @@ describe('browser RPC methods', () => {
makeRequest('browser.tabCreate', {
worktree: 'id:wt-1',
url: 'https://example.com',
profileId: 'profile-1',
requestedPageId: '00000000-0000-4000-8000-000000000001'
profileId: 'profile-1'
})
)
await dispatcher.dispatch(
@@ -82,8 +81,7 @@ describe('browser RPC methods', () => {
expect(runtime.browserTabCreate).toHaveBeenCalledWith({
worktree: 'id:wt-1',
url: 'https://example.com',
profileId: 'profile-1',
requestedPageId: '00000000-0000-4000-8000-000000000001'
profileId: 'profile-1'
})
expect(runtime.browserTabSwitch).toHaveBeenCalledWith({
worktree: 'id:wt-1',
@@ -304,7 +304,13 @@ describe('parity §11: remote browser create focuses the new browser tab', () =>
it('honors focus intent for a newly created browser session tab', () => {
const pageId = 'browser-page-1'
const prior = stateWithLocalTerminals([localTerminal('host-tab-1', 0, true)])
recordWebSessionFocusIntent({ environmentId: ENV }, WT, pageId)
recordWebSessionFocusIntent(
{ environmentId: ENV },
WT,
pageId,
undefined,
toWebTerminalSurfaceTabId('host-tab-1')
)
const patch = applyWebSessionTabsSnapshot(
prior,
makeSnapshot([{ parentTab: 'host-tab-1', leaf: LEAF_A, active: false }], {
@@ -1,7 +1,6 @@
/* eslint-disable max-lines */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-types'
import type * as RuntimeRpcClientModule from './runtime-rpc-client'
import {
activateWebRuntimeSessionWorktree,
activateWebRuntimeSessionTab,
@@ -35,7 +34,6 @@ import {
resetWebAgentSessionHandoffsForTests
} from './web-agent-session-handoff'
import { toRuntimeExecutionHostId } from '../../../shared/execution-host'
import { BROWSER_DETERMINISTIC_PAGE_CREATE_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import {
isWebSessionBrowserPlacementGroupReserved,
resetWebSessionBrowserPlacementsForTests
@@ -56,8 +54,7 @@ const mocks = vi.hoisted(() => ({
trackTerminalPaneSplit: vi.fn(),
deliverLaunchPromptToAgentTab: vi.fn(),
seedNativeChatLaunchDraftForAgentTab: vi.fn(),
getRuntimeEnvironmentIdForWorktree: vi.fn(),
getRuntimeEnvironmentStatus: vi.fn()
getRuntimeEnvironmentIdForWorktree: vi.fn()
}))
vi.mock('../store', () => ({
@@ -75,11 +72,6 @@ vi.mock('./web-session-tabs-sync', () => ({
resolveHostSessionTabIdForWebSessionTab: mocks.resolveHostSessionTabIdForWebSessionTab
}))
vi.mock('./runtime-rpc-client', async (importOriginal) => ({
...(await importOriginal<typeof RuntimeRpcClientModule>()),
getRuntimeEnvironmentStatus: mocks.getRuntimeEnvironmentStatus
}))
vi.mock('@/lib/feature-education-telemetry', () => ({
trackTerminalPaneSplit: mocks.trackTerminalPaneSplit
}))
@@ -264,9 +256,20 @@ describe('createWebRuntimeSessionBrowserTab', () => {
activeRuntimeEnvironmentId: ENVIRONMENT_ID
},
activeWorktreeId: WORKTREE_ID,
activeTabType: 'editor',
activeTabTypeByWorktree: { [WORKTREE_ID]: 'editor' },
activeFileIdByWorktree: { [WORKTREE_ID]: '/worktree/index.html' },
browserPagesByWorkspace: {},
remoteBrowserPageHandlesByPageId: {},
unifiedTabsByWorktree: {},
unifiedTabsByWorktree: {
[WORKTREE_ID]: [
{
id: 'local-editor-tab',
entityId: '/worktree/index.html',
contentType: 'editor'
}
]
},
createBrowserTab: mocks.createBrowserTab,
closeEmptyGroup: mocks.closeEmptyGroup,
moveUnifiedTabToGroup: mocks.moveUnifiedTabToGroup,
@@ -289,10 +292,6 @@ describe('createWebRuntimeSessionBrowserTab', () => {
mocks.applyFreshWebSessionTabsSnapshot.mockReturnValue({ state: 'after' })
mocks.resolveHostSessionTabIdForWebSessionTab.mockReturnValue(null)
mocks.deliverLaunchPromptToAgentTab.mockResolvedValue(true)
mocks.getRuntimeEnvironmentStatus.mockResolvedValue({
runtimeId: 'runtime-1',
capabilities: [BROWSER_DETERMINISTIC_PAGE_CREATE_RUNTIME_CAPABILITY]
})
})
afterEach(() => {
@@ -346,8 +345,7 @@ describe('createWebRuntimeSessionBrowserTab', () => {
// Why: a user-initiated "New Browser Tab" focuses the new tab, which on a
// headless host marks it active in the session snapshot.
activate: true,
waitForRegistration: false,
requestedPageId: expect.any(String)
waitForRegistration: false
},
timeoutMs: 15_000
})
@@ -364,78 +362,11 @@ describe('createWebRuntimeSessionBrowserTab', () => {
snapshot,
ENVIRONMENT_ID
)
expect(mocks.getRuntimeEnvironmentStatus).toHaveBeenCalledWith(ENVIRONMENT_ID, 15_000)
expect(mocks.createBrowserTab).not.toHaveBeenCalled()
})
it('retries an ambiguous create with the same page identity on a capable host', async () => {
let createAttempts = 0
const runtimeCall = vi.fn(
async (request: { method: string; params?: Record<string, string> }) => {
if (request.method === 'browser.tabCreate' && createAttempts++ === 0) {
return {
id: 'create-lost',
ok: false,
error: { code: 'remote_runtime_unavailable', message: 'response lost' }
}
}
if (request.method === 'browser.tabCreate') {
return {
id: 'create-replayed',
ok: true,
result: { browserPageId: 'remote-browser-page-1' }
}
}
return { id: 'list', ok: true, result: makeSnapshot() }
}
)
vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } })
await expect(
createWebRuntimeSessionBrowserTab({ worktreeId: WORKTREE_ID, url: 'https://example.com/' })
).resolves.toBe(true)
const createCalls = runtimeCall.mock.calls.filter(
([request]) => request.method === 'browser.tabCreate'
)
expect(createCalls).toHaveLength(2)
expect(createCalls[1]?.[0].params?.requestedPageId).toBe(
createCalls[0]?.[0].params?.requestedPageId
)
expect(mocks.createBrowserTab).not.toHaveBeenCalled()
})
it('creates a deterministic page in non-secure LAN contexts without randomUUID', async () => {
vi.stubGlobal('crypto', {
getRandomValues: (bytes: Uint8Array) => {
bytes.fill(7)
return bytes
}
expect(peekWebSessionFocusIntent({ environmentId: ENVIRONMENT_ID }, WORKTREE_ID)).toEqual({
hostTabId: 'remote-browser-page-1',
expectedCurrentLocalTabId: 'local-editor-tab'
})
const runtimeCall = vi.fn(
async (request: { method: string; params?: { requestedPageId?: string } }) => {
if (request.method === 'browser.tabCreate') {
return {
id: 'create',
ok: true,
result: { browserPageId: request.params?.requestedPageId }
}
}
return { id: 'list', ok: true, result: makeSnapshot() }
}
)
vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } })
await expect(
createWebRuntimeSessionBrowserTab({ worktreeId: WORKTREE_ID, url: 'https://example.com/' })
).resolves.toBe(true)
const createRequest = runtimeCall.mock.calls.find(
([request]) => request.method === 'browser.tabCreate'
)
expect(createRequest?.[0].params?.requestedPageId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
)
expect(mocks.createBrowserTab).not.toHaveBeenCalled()
})
it('does not let a slow browser create replace a newer browser selection', async () => {
@@ -477,11 +408,7 @@ describe('createWebRuntimeSessionBrowserTab', () => {
})
})
it('does not retry an ambiguous create on a host without deterministic identity', async () => {
mocks.getRuntimeEnvironmentStatus.mockResolvedValue({
runtimeId: 'runtime-1',
capabilities: []
})
it('does not retry a failed browser create', async () => {
const runtimeCall = vi.fn().mockResolvedValue({
id: 'create-lost',
ok: false,
@@ -497,58 +424,6 @@ describe('createWebRuntimeSessionBrowserTab', () => {
expect(mocks.createBrowserTab).not.toHaveBeenCalled()
})
it('adopts the deterministic page after both create replies are lost', async () => {
let snapshotApplied = false
mocks.getState.mockImplementation(() => ({
settings: { activeRuntimeEnvironmentId: ENVIRONMENT_ID },
activeWorktreeId: WORKTREE_ID,
browserPagesByWorkspace: snapshotApplied
? { 'browser-workspace': [{ id: 'local-page', workspaceId: 'browser-workspace' }] }
: {},
remoteBrowserPageHandlesByPageId: snapshotApplied
? {
'local-page': {
environmentId: ENVIRONMENT_ID,
remotePageId: '07070707-0707-4707-8707-070707070707'
}
}
: {},
closeEmptyGroup: mocks.closeEmptyGroup,
setActiveWorktree: mocks.setActiveWorktree
}))
vi.stubGlobal('crypto', {
getRandomValues: (bytes: Uint8Array) => {
bytes.fill(7)
return bytes
}
})
let createAttempts = 0
const runtimeCall = vi.fn(async (request: { method: string }) => {
if (request.method === 'browser.tabCreate') {
createAttempts += 1
return {
id: `create-lost-${createAttempts}`,
ok: false,
error: { code: 'remote_runtime_unavailable', message: 'response lost' }
}
}
snapshotApplied = true
return { id: 'list', ok: true, result: makeSnapshot() }
})
vi.stubGlobal('window', { api: { runtimeEnvironments: { call: runtimeCall } } })
await expect(
createWebRuntimeSessionBrowserTab({
worktreeId: WORKTREE_ID,
clientTargetGroupId: 'preview-group',
clientTargetGroupCreated: true
})
).resolves.toBe(true)
expect(createAttempts).toBe(2)
expect(mocks.closeEmptyGroup).not.toHaveBeenCalled()
})
it('keeps the requested split reserved until a pre-published browser gains a unified tab', async () => {
mocks.getState.mockReturnValue({
settings: { activeRuntimeEnvironmentId: ENVIRONMENT_ID },
+46 -92
View File
@@ -18,11 +18,7 @@ import type {
SleepingAgentLaunchConfig,
AgentProviderSessionMetadata
} from '../../../shared/agent-session-resume'
import {
AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY,
BROWSER_DETERMINISTIC_PAGE_CREATE_RUNTIME_CAPABILITY
} from '../../../shared/protocol-version'
import { isRecoverableRemoteRuntimeConnectionError } from '../../../shared/remote-runtime-client-error-classification'
import { AGENT_SESSION_OMP_RESUME_PATH_RUNTIME_CAPABILITY } from '../../../shared/protocol-version'
import type {
AgentLaunchPreferences,
AgentPromptDelivery,
@@ -30,15 +26,10 @@ import type {
RuntimeEnsureAgentSessionResult
} from '../../../shared/agent-session-host-authority'
import type { TerminalPaneLayoutNode, TuiAgent } from '../../../shared/types'
import type { AppState } from '../store/types'
import { createBrowserUuid } from '../lib/browser-uuid'
import { getRuntimeEnvironmentIdForWorktree } from '../lib/worktree-runtime-owner'
import { useAppStore } from '../store'
import {
RuntimeRpcCallError,
getRuntimeEnvironmentStatus,
unwrapRuntimeRpcResult
} from './runtime-rpc-client'
import { unwrapRuntimeRpcResult } from './runtime-rpc-client'
import {
createAgentSessionCreateOperation,
withAgentSessionCreateOperationId
@@ -48,7 +39,8 @@ import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
import {
clearWebSessionFocusIntentIfMatches,
peekWebSessionFocusIntent,
recordWebSessionFocusIntent
recordWebSessionFocusIntent,
resolveWebSessionVisibleTabId
} from './web-session-focus-intent'
import { clearWebSessionCloseIntent, recordWebSessionCloseIntent } from './web-session-close-intent'
import {
@@ -459,12 +451,16 @@ export async function createWebRuntimeSessionBrowserTab(args: {
const intentOwner = captureWebSessionIntentOwner(environmentId)
const callEnvironment = captureRuntimeEnvironmentCall(environmentId, intentOwner.pairingRevision)
const shouldSelectWorktree = args.selectWorktree !== false
const requestedPageId = createBrowserUuid()
const provisionalPageId = createBrowserUuid()
const expectedCurrentLocalTabId = resolveWebSessionVisibleTabId(
useAppStore.getState(),
args.worktreeId
)
if (args.clientTargetGroupId) {
recordWebSessionBrowserPlacement({
environmentId,
worktreeId: args.worktreeId,
remotePageId: requestedPageId,
remotePageId: provisionalPageId,
groupId: args.clientTargetGroupId
})
}
@@ -472,74 +468,48 @@ export async function createWebRuntimeSessionBrowserTab(args: {
selectWebRuntimeSessionBrowserWorktree(args.worktreeId, environmentId)
}
try {
const status = await getRuntimeEnvironmentStatus(environmentId, 15_000)
const supportsDeterministicCreate =
status.capabilities?.includes(BROWSER_DETERMINISTIC_PAGE_CREATE_RUNTIME_CAPABILITY) === true
if (matchesWebSessionIntentOwner(intentOwner)) {
recordWebSessionFocusIntent(intentOwner, args.worktreeId, requestedPageId)
}
const create = async (): Promise<BrowserTabCreateResult> =>
unwrapRuntimeRpcResult(
(await callEnvironment({
method: 'browser.tabCreate',
params: {
worktree: toRuntimeWorktreeSelector(args.worktreeId),
url: args.url,
profileId: args.profileId ?? undefined,
// Why: user clicked "New Browser Tab", so mark it active in the snapshot, else the reconcile snaps back to a terminal.
activate: true,
// Why: place the new browser in the clicked split group so the host snapshot is authoritative for it (no left-snap).
...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}),
// Why: web clients need the local tab now; waiting for host webview registration makes the workspace appear to close.
waitForRegistration: false,
...(supportsDeterministicCreate ? { requestedPageId } : {})
},
timeoutMs: 15_000
})) as RuntimeRpcResponse<BrowserTabCreateResult>
recordWebSessionFocusIntent(
intentOwner,
args.worktreeId,
provisionalPageId,
undefined,
expectedCurrentLocalTabId
)
let created: BrowserTabCreateResult
try {
created = await create()
} catch (error) {
const retryable =
error instanceof RuntimeRpcCallError && isRecoverableRemoteRuntimeConnectionError(error)
if (!retryable || !supportsDeterministicCreate) {
throw error
}
try {
created = await create()
} catch (retryError) {
const retryOutcomeUnknown =
retryError instanceof RuntimeRpcCallError &&
isRecoverableRemoteRuntimeConnectionError(retryError)
if (!retryOutcomeUnknown) {
throw retryError
}
try {
await refreshWebRuntimeSessionTabsSnapshot(environmentId, args.worktreeId, {
expectedEnvironmentPairingRevision: intentOwner.pairingRevision
})
} catch {
// Why: the deterministic page may already exist; its next snapshot adopts it without duplicating.
return true
}
if (
hasLocalBrowserPageForRemotePage(useAppStore.getState(), environmentId, requestedPageId)
) {
return true
}
throw retryError
}
}
if (created.browserPageId !== requestedPageId) {
const created = unwrapRuntimeRpcResult(
(await callEnvironment({
method: 'browser.tabCreate',
params: {
worktree: toRuntimeWorktreeSelector(args.worktreeId),
url: args.url,
profileId: args.profileId ?? undefined,
// Why: user clicked "New Browser Tab", so mark it active in the snapshot, else the reconcile snaps back to a terminal.
activate: true,
// Why: place the new browser in the clicked split group so the host snapshot is authoritative for it (no left-snap).
...(args.targetGroupId ? { targetGroupId: args.targetGroupId } : {}),
// Why: web clients need the local tab now; waiting for host webview registration makes the workspace appear to close.
waitForRegistration: false
},
timeoutMs: 15_000
})) as RuntimeRpcResponse<BrowserTabCreateResult>
)
if (created.browserPageId !== provisionalPageId) {
moveWebSessionBrowserPlacement({
environmentId,
worktreeId: args.worktreeId,
fromRemotePageId: requestedPageId,
fromRemotePageId: provisionalPageId,
toRemotePageId: created.browserPageId
})
if (peekWebSessionFocusIntent(intentOwner, args.worktreeId)?.hostTabId === requestedPageId) {
recordWebSessionFocusIntent(intentOwner, args.worktreeId, created.browserPageId)
const focusIntent = peekWebSessionFocusIntent(intentOwner, args.worktreeId)
if (focusIntent?.hostTabId === provisionalPageId) {
recordWebSessionFocusIntent(
intentOwner,
args.worktreeId,
created.browserPageId,
undefined,
focusIntent.expectedCurrentLocalTabId
)
}
}
try {
@@ -557,9 +527,9 @@ export async function createWebRuntimeSessionBrowserTab(args: {
forgetWebSessionBrowserPlacement({
environmentId,
worktreeId: args.worktreeId,
remotePageId: requestedPageId
remotePageId: provisionalPageId
})
clearWebSessionFocusIntentIfMatches(intentOwner, args.worktreeId, requestedPageId)
clearWebSessionFocusIntentIfMatches(intentOwner, args.worktreeId, provisionalPageId)
if (args.clientTargetGroupId && args.clientTargetGroupCreated) {
const reserved = isWebSessionBrowserPlacementGroupReserved({
environmentId,
@@ -596,22 +566,6 @@ function selectWebRuntimeSessionBrowserWorktree(worktreeId: string, environmentI
}
}
function hasLocalBrowserPageForRemotePage(
state: AppState,
environmentId: string,
remotePageId: string
): boolean {
for (const pages of Object.values(state.browserPagesByWorkspace)) {
for (const page of pages) {
const handle = state.remoteBrowserPageHandlesByPageId[page.id]
if (handle?.environmentId === environmentId && handle.remotePageId === remotePageId) {
return true
}
}
}
return false
}
export async function refreshWebRuntimeSessionTabsSnapshot(
environmentId: string,
worktreeId: string,
@@ -10,14 +10,50 @@
// snapshots, unlike a transient per-snapshot flag).
import { webSessionIntentOwnerKey, type WebSessionIntentOwner } from './web-session-intent-owner'
import type { AppState } from '../store/types'
export type WebSessionFocusIntent = {
hostTabId: string
leafId?: string
expectedCurrentLocalTabId?: string | null
}
const pendingFocusByOwnerAndWorktree = new Map<string, WebSessionFocusIntent>()
type WebSessionVisibleTabState = Pick<
AppState,
| 'activeBrowserTabIdByWorktree'
| 'activeFileIdByWorktree'
| 'activeTabIdByWorktree'
| 'activeTabType'
| 'activeTabTypeByWorktree'
| 'activeWorktreeId'
| 'unifiedTabsByWorktree'
>
export function resolveWebSessionVisibleTabId(
state: WebSessionVisibleTabState,
worktreeId: string,
tabs = state.unifiedTabsByWorktree?.[worktreeId] ?? []
): string | null {
const currentType =
state.activeTabTypeByWorktree?.[worktreeId] ??
(state.activeWorktreeId === worktreeId ? state.activeTabType : null)
if (currentType === 'terminal') {
const tabId = state.activeTabIdByWorktree?.[worktreeId]
return tabId && tabs.some((tab) => tab.id === tabId) ? tabId : null
}
const entityId =
currentType === 'browser'
? state.activeBrowserTabIdByWorktree?.[worktreeId]
: currentType === 'editor'
? state.activeFileIdByWorktree?.[worktreeId]
: null
return (
tabs.find((tab) => tab.contentType === currentType && tab.entityId === entityId)?.id ?? null
)
}
function focusIntentPartitionKey(owner: WebSessionIntentOwner, worktreeId: string): string {
return `${webSessionIntentOwnerKey(owner)}\0${worktreeId}`
}
@@ -26,7 +62,8 @@ export function recordWebSessionFocusIntent(
owner: WebSessionIntentOwner,
worktreeId: string,
hostTabId: string,
leafId?: string
leafId?: string,
expectedCurrentLocalTabId?: string | null
): void {
const trimmed = hostTabId.trim()
if (!worktreeId || !trimmed) {
@@ -35,7 +72,8 @@ export function recordWebSessionFocusIntent(
const trimmedLeafId = leafId?.trim()
pendingFocusByOwnerAndWorktree.set(focusIntentPartitionKey(owner, worktreeId), {
hostTabId: trimmed,
...(trimmedLeafId ? { leafId: trimmedLeafId } : {})
...(trimmedLeafId ? { leafId: trimmedLeafId } : {}),
...(expectedCurrentLocalTabId !== undefined ? { expectedCurrentLocalTabId } : {})
})
}
@@ -3380,7 +3380,13 @@ describe('applyWebSessionTabsSnapshot', () => {
isPreview: false,
isPinned: false
}
recordWebSessionFocusIntent({ environmentId: ENV }, WT, 'host-browser')
recordWebSessionFocusIntent(
{ environmentId: ENV },
WT,
'host-browser',
undefined,
'previous-local-tab'
)
const patch = applyWebSessionTabsSnapshot(
makeState({
@@ -3467,7 +3473,13 @@ describe('applyWebSessionTabsSnapshot', () => {
isPreview: false,
isPinned: false
}
recordWebSessionFocusIntent({ environmentId: ENV }, WT, 'host-browser')
recordWebSessionFocusIntent(
{ environmentId: ENV },
WT,
'host-browser',
undefined,
'previous-local-tab'
)
const patch = applyWebSessionTabsSnapshot(
makeState({
@@ -54,7 +54,8 @@ import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
import {
clearWebSessionFocusIntent,
clearWebSessionFocusIntentsForOwner,
peekWebSessionFocusIntent
peekWebSessionFocusIntent,
resolveWebSessionVisibleTabId
} from './web-session-focus-intent'
import {
clearWebSessionCloseIntentsForOwner,
@@ -2310,52 +2311,6 @@ function toVisibleTabType(tab: Tab): WebSessionTabsSyncState['activeTabType'] {
return 'editor'
}
function runtimeTabMatchesVisibleType(
tab: RuntimeMobileSessionTabsResult['tabs'][number],
visibleType: WebSessionTabsSyncState['activeTabType'] | null
): boolean {
if (tab.type === 'browser' || tab.type === 'terminal') {
return tab.type === visibleType
}
return visibleType === 'editor'
}
function findCurrentVisibleUnifiedTabId(args: {
state: WebSessionTabsSyncState
worktreeId: string
nextUnifiedTabs: readonly Tab[] | null
}): string | null {
const { state, worktreeId, nextUnifiedTabs } = args
if (!nextUnifiedTabs) {
return null
}
const currentVisibleType =
state.activeTabTypeByWorktree[worktreeId] ??
(state.activeWorktreeId === worktreeId ? state.activeTabType : null)
if (currentVisibleType === 'terminal') {
const terminalTabId = state.activeTabIdByWorktree[worktreeId]
return terminalTabId && nextUnifiedTabs.some((tab) => tab.id === terminalTabId)
? terminalTabId
: null
}
if (currentVisibleType === 'browser') {
const browserWorkspaceId = state.activeBrowserTabIdByWorktree[worktreeId]
return (
nextUnifiedTabs.find(
(tab) => tab.contentType === 'browser' && tab.entityId === browserWorkspaceId
)?.id ?? null
)
}
if (currentVisibleType === 'editor') {
const fileId = state.activeFileIdByWorktree[worktreeId]
return (
nextUnifiedTabs.find((tab) => tab.contentType === 'editor' && tab.entityId === fileId)?.id ??
null
)
}
return null
}
function applyWebSessionTabsSnapshotWithContext(
state: WebSessionTabsSyncState,
rawSnapshot: RuntimeMobileSessionTabsResult,
@@ -2411,12 +2366,12 @@ function applyWebSessionTabsSnapshotWithContext(
(tab.type === 'terminal' && tab.parentTabId === focusIntentHostTabId) ||
(tab.type === 'browser' && tab.browserPageId === focusIntentHostTabId)
) ?? null)
const currentVisibleType =
state.activeTabTypeByWorktree[worktreeId] ??
(state.activeWorktreeId === worktreeId ? state.activeTabType : null)
const expectedCurrentLocalTabId = focusIntent?.expectedCurrentLocalTabId
const currentVisibleLocalTabId = resolveWebSessionVisibleTabId(state, worktreeId)
const callerFocusIntentTab =
matchingFocusIntentTab &&
runtimeTabMatchesVisibleType(matchingFocusIntentTab, currentVisibleType)
(expectedCurrentLocalTabId === undefined ||
expectedCurrentLocalTabId === currentVisibleLocalTabId)
? matchingFocusIntentTab
: null
const followIntentTab =
@@ -2743,11 +2698,11 @@ function applyWebSessionTabsSnapshotWithContext(
? (activeMirroredEditorFileId ?? mirroredEditorTabs[0]?.file.id)
: mirroredEditorTabs[0]?.file.id) ??
null
const currentVisibleUnifiedTabId = findCurrentVisibleUnifiedTabId({
const currentVisibleUnifiedTabId = resolveWebSessionVisibleTabId(
state,
worktreeId,
nextUnifiedTabs
})
nextUnifiedTabs ?? []
)
// Why: a client-initiated activation also drives the visible unified tab, overriding the sticky current-visible tab.
const intentUnifiedTabId = honorSnapshotActiveFocus
? navigationIntentTab?.type === 'browser'
-2
View File
@@ -55,8 +55,6 @@ export const AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY = 'aiVault.session-title
// clients never fall back to a local desktop browser tab for a remote-owned page.
export const BROWSER_HEADLESS_RUNTIME_CAPABILITY = 'browser.headless.v1' as const
export const BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY = 'browser.certificate-trust.v1' as const
export const BROWSER_DETERMINISTIC_PAGE_CREATE_RUNTIME_CAPABILITY =
'browser.deterministic-page-create.v1' as const
// Why: hosts without this strip terminal.send's inputKind (zod object drops
// unknown keys), so a mobile xterm query reply would land as ordinary
// floor-taking input. Mobile must not forward replies unless advertised.