From e36149d9e8860d77f720d7e1abf971dbb481d8a2 Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 19 Oct 2026 07:39:31 -0700 Subject: [PATCH 01/11] wip: orcad client-hosted browser pages --- src/main/orcad/orcad-browser-startup.ts | 71 ++++++- .../orcad/orcad-client-hosted-browser.test.ts | 168 ++++++++++++++++ .../browser/browser-client-page-opening.ts | 187 ++++++++++++++++++ .../runtime/browser/browser-tab-creation.ts | 95 +-------- .../desktop-browser-bridge-route.ts | 1 + .../runtime-browser-commands-factory.ts | 20 +- .../session-graph/session-graph-authority.ts | 6 +- .../src/lib/client-creation-action-policy.ts | 42 +++- .../src/lib/workspace-browser-tab-open.ts | 4 +- 9 files changed, 489 insertions(+), 105 deletions(-) create mode 100644 src/main/orcad/orcad-client-hosted-browser.test.ts create mode 100644 src/main/runtime/browser/browser-client-page-opening.ts diff --git a/src/main/orcad/orcad-browser-startup.ts b/src/main/orcad/orcad-browser-startup.ts index 8e846be714f7..e94c695fd222 100644 --- a/src/main/orcad/orcad-browser-startup.ts +++ b/src/main/orcad/orcad-browser-startup.ts @@ -9,6 +9,16 @@ import { closeBrowserPageWithoutEngine, closeRegisteredBrowserClientPage } from '../runtime/browser/browser-client-page-close' +import { + createBrowserClientPage, + createBrowserPageOnPairedHost, + DEFAULT_BROWSER_PROFILE_ID, + hasPairedPageHost, + openBrowserUrlOnClient, + type BrowserClientPageCaller, + type BrowserClientPageCreateParams +} from '../runtime/browser/browser-client-page-opening' +import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts' import { resolveOrcadBrowserProvider, type OrcadBrowserProvider, @@ -24,6 +34,7 @@ export function startOrcadBrowserProvider(options: OrcadBrowserProviderOptions): let provider: OrcadBrowserProvider | null = null let startupError: unknown let stopping: Promise | undefined + const engineAvailable = (): boolean => !controller.signal.aborted && !!provider?.isAvailable() setRuntimeBrowserCommandsFactory( (host) => { let commands: RuntimeBrowserCommands | undefined @@ -35,17 +46,43 @@ export function startOrcadBrowserProvider(options: OrcadBrowserProviderOptions): return undefined } const forward = (args: unknown[]): unknown => { - if (controller.signal.aborted || !provider?.isAvailable()) { + if (!engineAvailable() || !provider) { throw browserUnavailableError() } commands ??= provider.factory(host) return callBrowserCommand(commands, property, args) } + if (property === 'browserOpenUrlOnClient') { + // Why: the page renders on a paired desktop, so open-url never needs this host's engine. + return (...args: Parameters) => + openBrowserUrlOnClient(host, args[0]) + } + if (property === 'browserTabCreate') { + return (...args: Parameters) => { + const [params, caller] = args + if (params?.placement?.kind === 'client') { + return createOrcadBrowserClientPage( + host, + { ...params, placement: params.placement }, + caller + ) + } + // Why: with no engine here, a desktop hosting this server's pages is the only browser. + if (!params?.placement && !engineAvailable() && hasPairedPageHost(host)) { + return createBrowserPageOnPairedHost( + host, + params ?? {}, + orcadBrowserProfileId(params?.profileId) + ) + } + return forward(args) + } + } if (property === 'browserTabClose') { // Why: client-hosted pages live in this runtime's registry, never in the provider. return async (...args: Parameters) => { const params = args[0] ?? {} - if (controller.signal.aborted || !provider?.isAvailable()) { + if (!engineAvailable()) { return closeBrowserPageWithoutEngine(host, params, browserUnavailableError) } return (await closeRegisteredBrowserClientPage(host, params)) ?? forward(args) @@ -55,21 +92,22 @@ export function startOrcadBrowserProvider(options: OrcadBrowserProviderOptions): } }) }, - { headless: true, isAvailable: () => !controller.signal.aborted && !!provider?.isAvailable() } + { + headless: true, + clientHostingWithoutEngine: true, + isAvailable: engineAvailable + } ) const ready = Promise.resolve() .then(() => resolveOrcadBrowserProvider({ ...options, signal: controller.signal })) .then( + // Why the factory stays installed without a provider: it still serves desktop-hosted pages. (resolved) => { provider = resolved - if (!resolved && !controller.signal.aborted) { - setRuntimeBrowserCommandsFactory(null) - } }, (error: unknown) => { startupError = error if (!controller.signal.aborted) { - setRuntimeBrowserCommandsFactory(null) console.warn('[orcad] Browser startup failed:', error) } } @@ -108,3 +146,22 @@ function callBrowserCommand( } return member.call(commands, ...args) } + +/** A client-placed page on a host with no profile registry. */ +async function createOrcadBrowserClientPage( + host: RuntimeBrowserCommandHost, + params: BrowserClientPageCreateParams & { worktree?: string; profileId?: string }, + caller: BrowserClientPageCaller | undefined +): Promise<{ browserPageId: string }> { + const worktree = params.worktree ? await host.resolveBrowserWorkspace(params.worktree) : undefined + const browserProfileId = orcadBrowserProfileId(params.profileId) + return createBrowserClientPage(host, params, { worktree, browserProfileId, caller }) +} + +// Why only the default: orcad has no profile registry to describe any other to a desktop. +function orcadBrowserProfileId(profileId: string | undefined): string { + if (profileId !== undefined && profileId !== DEFAULT_BROWSER_PROFILE_ID) { + throw new BrowserError('invalid_argument', `Browser profile ${profileId} was not found`) + } + return DEFAULT_BROWSER_PROFILE_ID +} diff --git a/src/main/orcad/orcad-client-hosted-browser.test.ts b/src/main/orcad/orcad-client-hosted-browser.test.ts new file mode 100644 index 000000000000..262a1db9722b --- /dev/null +++ b/src/main/orcad/orcad-client-hosted-browser.test.ts @@ -0,0 +1,168 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { startOrcadBrowserProvider } from './orcad-browser-startup' +import { resolveOrcadBrowserProvider } from './orcad-browser-provider' +import { + createRuntimeBrowserCommands, + runtimeBrowserCommandsFactoryIsAvailable, + runtimeBrowserCommandsFactorySupportsClientHosting, + runtimeBrowserUnavailableCause, + setRuntimeBrowserCommandsFactory, + setRuntimeBrowserUnavailableCause +} from '../runtime/runtime-browser-commands-factory' +import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts' +import { RuntimeBrowserPageRegistry } from '../runtime/runtime-browser-page-registry' + +vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: vi.fn() })) +afterEach(() => { + vi.restoreAllMocks() + setRuntimeBrowserCommandsFactory(null) + setRuntimeBrowserUnavailableCause(null) +}) + +const PLACEMENT = { + kind: 'client' as const, + browserHostClientId: 'desktop-a', + browserHostGeneration: 2, + pageHostGeneration: 5 +} + +/** orcad as `orca serve` runs it with no browser engine: discovery declines, unconfigured. */ +async function startWithoutEngine() { + vi.mocked(resolveOrcadBrowserProvider).mockImplementationOnce(async () => { + setRuntimeBrowserUnavailableCause({ reason: 'unconfigured' }) + return null + }) + const startup = startOrcadBrowserProvider({ userDataPath: '/private-fixture' }) + await startup.ready + return startup +} + +function pairedHost(options: { desktopAttached: boolean }) { + const pages = new RuntimeBrowserPageRegistry() + const select = vi.fn(() => { + if (!options.desktopAttached) { + throw new Error('browser_host_unavailable') + } + return { browserHostClientId: 'desktop-a', pairedDeviceId: 'device-a' } + }) + const createClientPage = vi.fn(async () => PLACEMENT) + const issueClientPageCommand = vi.fn(() => ({ + event: {} as never, + result: Promise.resolve({ status: 'completed' as const }) + })) + const notifyHeadlessBrowserSessionTabsChanged = vi.fn() + const host = { + getAgentBrowserBridge: () => null, + resolveBrowserWorkspace: async (selector: string) => ({ id: selector.replace(/^id:/, '') }), + resolveBrowserNetworkExecutionHost: async () => ({ + kind: 'native' as const, + runtimeId: 'orcad-a', + revision: 1 + }), + getRuntimeBrowserPageRegistry: () => pages, + getBrowserHostLeaseRegistry: () => ({ + authorityRuntimeId: 'orcad-a', + authorityEpoch: 'epoch-a', + select, + createClientPage, + issueClientPageCommand + }), + markHeadlessBrowserSessionTabActive: vi.fn(), + notifyHeadlessBrowserSessionTabsChanged + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake implements every member the client-page path reads; the rest of the host is engine-only. + const commandHost = host as unknown as RuntimeBrowserCommandHost + return { host: commandHost, pages, select, createClientPage, issueClientPageCommand } +} + +it('opens open-url in the paired desktop when orcad has no browser engine', async () => { + const startup = await startWithoutEngine() + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + + const opened = await commands.browserOpenUrlOnClient({ + url: 'https://example.org/', + worktree: 'id:wt-1' + }) + + expect(paired.select).toHaveBeenCalledWith(undefined, ['webview', 'automation-v1']) + expect(paired.createClientPage).toHaveBeenCalledWith( + expect.objectContaining({ + browserPageId: opened.browserPageId, + browserHostClientId: 'desktop-a', + pairedDeviceId: 'device-a', + browserProfileId: 'default', + workspaceId: 'wt-1' + }) + ) + expect(paired.issueClientPageCommand).toHaveBeenCalledWith( + expect.objectContaining({ browserPageId: opened.browserPageId }), + { type: 'navigate', url: 'https://example.org/' } + ) + expect(paired.pages.getPage(opened.browserPageId)).toMatchObject({ + workspaceId: 'wt-1', + url: 'https://example.org/', + active: true, + placement: PLACEMENT + }) + await startup.stop() +}) + +it('refuses open-url with what to do when no paired desktop hosts this server’s pages', async () => { + const startup = await startWithoutEngine() + const commands = createRuntimeBrowserCommands(pairedHost({ desktopAttached: false }).host) + + await expect( + commands.browserOpenUrlOnClient({ url: 'https://example.org/', worktree: 'id:wt-1' }) + ).rejects.toMatchObject({ + code: 'browser_host_unavailable', + message: expect.stringContaining("open a browser tab in one of this server's workspaces") + }) + await startup.stop() +}) + +it('creates a client-placed tab without an engine, and only in the default profile', async () => { + const startup = await startWithoutEngine() + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + const placement = { kind: 'client' as const, browserHostClientId: 'desktop-a' } + + await expect( + commands.browserTabCreate( + { worktree: 'id:wt-1', page: 'page-a', url: 'https://example.org/', placement }, + { pairedDeviceId: 'device-a', clientKind: 'runtime' } + ) + ).resolves.toEqual({ browserPageId: 'page-a' }) + expect(paired.pages.getPage('page-a')?.workspaceId).toBe('wt-1') + + await expect( + commands.browserTabCreate( + { worktree: 'id:wt-1', profileId: 'work', placement }, + { pairedDeviceId: 'device-a', clientKind: 'runtime' } + ) + ).rejects.toMatchObject({ code: 'invalid_argument' }) + await startup.stop() +}) + +it('sends an unplaced tab to the paired desktop, and refuses as before when none is attached', async () => { + const startup = await startWithoutEngine() + const paired = pairedHost({ desktopAttached: true }) + const created = await createRuntimeBrowserCommands(paired.host).browserTabCreate({ + worktree: 'id:wt-1', + url: 'https://example.org/' + }) + expect(paired.pages.getPage(created.browserPageId)?.placement).toEqual(PLACEMENT) + + const unattached = createRuntimeBrowserCommands(pairedHost({ desktopAttached: false }).host) + expect(() => unattached.browserTabCreate({ worktree: 'id:wt-1' })).toThrow(/unavailable/) + await startup.stop() +}) + +it('advertises client hosting with no engine and keeps the engine’s own cause', async () => { + const startup = await startWithoutEngine() + + expect(runtimeBrowserCommandsFactoryIsAvailable()).toBe(false) + expect(runtimeBrowserCommandsFactorySupportsClientHosting()).toBe(true) + expect(runtimeBrowserUnavailableCause()).toEqual({ reason: 'unconfigured' }) + await startup.stop() +}) diff --git a/src/main/runtime/browser/browser-client-page-opening.ts b/src/main/runtime/browser/browser-client-page-opening.ts new file mode 100644 index 000000000000..8c0da2e32574 --- /dev/null +++ b/src/main/runtime/browser/browser-client-page-opening.ts @@ -0,0 +1,187 @@ +import { randomUUID } from 'node:crypto' +import { BrowserError } from '../../browser/browser-error' +import { browserNetworkExecutionHostKey } from '../../browser/browser-network-execution-route' +import type { RuntimeNavigationTarget } from '../../../shared/runtime-navigation' +import { BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY } from '../../../shared/browser-client-automation-protocol' +import { BROWSER_HOST_WEBVIEW_CAPABILITY } from '../browser-host/browser-host-capability-selection' +import { + publishCreatedBrowserSessionTab, + resolveBrowserTabCreateFocus +} from '../browser-tab-create-publication' +import { + createRuntimeBrowserClientPage, + navigateRuntimeBrowserClientPage +} from '../runtime-browser-client-page-creation' +import type { RuntimeBrowserCommandHost } from './browser-command-contracts' + +// Why no Electron import: a Node host with no browser engine places pages on a paired desktop too. +export type BrowserClientPageOpeningHost = Pick< + RuntimeBrowserCommandHost, + | 'getAgentBrowserBridge' + | 'resolveBrowserWorkspace' + | 'resolveBrowserNetworkExecutionHost' + | 'getBrowserHostLeaseRegistry' + | 'getRuntimeBrowserPageRegistry' + | 'markHeadlessBrowserSessionTabActive' + | 'notifyHeadlessBrowserSessionTabsChanged' +> + +type ResolvedWorkspace = Awaited> + +export type BrowserClientPageCreateParams = { + url?: string + page?: string + activate?: boolean + navigation?: RuntimeNavigationTarget + targetGroupId?: string + placement: { kind: 'client'; browserHostClientId: string } +} + +export type BrowserClientPageCaller = { pairedDeviceId?: string; clientKind?: 'mobile' | 'runtime' } + +export const DEFAULT_BROWSER_PROFILE_ID = 'default' + +export const BROWSER_HOST_UNAVAILABLE_MESSAGE = + "No paired Orca desktop is hosting browser pages for this server. On a desktop paired with it, open a browser tab in one of this server's workspaces, then retry. The desktop needs an Orca version that hosts this server's pages." + +/** Creates a page that renders on a paired desktop; this runtime owns its placement and rows. */ +export async function createBrowserClientPage( + host: BrowserClientPageOpeningHost, + params: BrowserClientPageCreateParams, + context: { + worktree: ResolvedWorkspace | undefined + browserProfileId: string + caller: BrowserClientPageCaller | undefined + } +): Promise<{ browserPageId: string }> { + const { worktree, browserProfileId, caller } = context + if (!caller?.pairedDeviceId) { + throw new BrowserError( + 'forbidden', + 'Client-hosted browser pages require an authenticated paired runtime.' + ) + } + if (!worktree) { + throw new BrowserError( + 'invalid_argument', + 'Client-hosted browser pages require an explicit workspace.' + ) + } + const url = params.url ?? 'about:blank' + const focus = resolveBrowserTabCreateFocus({ + activate: params.activate, + navigation: params.navigation, + clientKind: caller.clientKind + }) + const browserPageId = params.page ?? randomUUID() + const executionHost = await host.resolveBrowserNetworkExecutionHost(worktree) + const authority = host.getBrowserHostLeaseRegistry() + const created = await createRuntimeBrowserClientPage(authority, { + browserPageId, + browserHostClientId: params.placement.browserHostClientId, + pairedDeviceId: caller.pairedDeviceId, + browserProfileId, + executionHost, + workspaceId: worktree.id + }) + const pages = host.getRuntimeBrowserPageRegistry() + pages.publishClientPage({ + browserPageId, + workspaceId: worktree.id, + browserProfileId, + executionHostKey: browserNetworkExecutionHostKey(executionHost), + placement: created.placement, + pairedDeviceId: caller.pairedDeviceId, + url: 'about:blank', + loading: url !== 'about:blank', + active: focus.startsActive + }) + publishCreatedBrowserSessionTab(host, { + placementKind: 'client', + browserPageId, + worktreeId: worktree.id, + focus, + clientNavigationId: caller.pairedDeviceId, + targetGroupId: params.targetGroupId + }) + if (url !== 'about:blank') { + try { + await navigateRuntimeBrowserClientPage(authority, { + browserPageId, + placement: created.placement, + url + }) + pages.updatePage(browserPageId, created.placement, { url, loading: false }) + } catch { + pages.updatePage(browserPageId, created.placement, { loading: false }) + } + host.notifyHeadlessBrowserSessionTabsChanged?.(worktree.id) + } + return { browserPageId } +} + +/** `orca open-url`: opens the page on the one paired desktop hosting this runtime's pages. */ +export function openBrowserUrlOnClient( + host: BrowserClientPageOpeningHost, + params: { url: string; worktree: string } +): Promise<{ browserPageId: string }> { + const protocol = new URL(params.url).protocol + if (protocol !== 'http:' && protocol !== 'https:') { + throw new BrowserError('invalid_argument', 'Only http(s) URLs can be opened on the client.') + } + return createBrowserPageOnPairedHost( + host, + { url: params.url, worktree: params.worktree, activate: true, navigation: 'caller' }, + DEFAULT_BROWSER_PROFILE_ID + ) +} + +/** A page its caller did not place goes to the one paired desktop hosting this runtime's pages. */ +export async function createBrowserPageOnPairedHost( + host: BrowserClientPageOpeningHost, + params: Omit & { worktree?: string }, + browserProfileId: string +): Promise<{ browserPageId: string }> { + const lease = selectPairedPageHost(host) + const { worktree, ...rest } = params + return createBrowserClientPage( + host, + { ...rest, placement: { kind: 'client', browserHostClientId: lease.browserHostClientId } }, + { + worktree: worktree ? await host.resolveBrowserWorkspace(worktree) : undefined, + browserProfileId, + caller: { pairedDeviceId: lease.pairedDeviceId, clientKind: 'runtime' } + } + ) +} + +/** True when a paired desktop could take a page now; an ambiguous pick still has a host. */ +export function hasPairedPageHost( + host: Pick +): boolean { + try { + selectPairedPageHost(host) + return true + } catch (error) { + return error instanceof Error && error.message === 'browser_host_ambiguous' + } +} + +function selectPairedPageHost( + host: Pick +): { browserHostClientId: string; pairedDeviceId: string } { + try { + return host + .getBrowserHostLeaseRegistry() + .select(undefined, [ + BROWSER_HOST_WEBVIEW_CAPABILITY, + BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY + ]) + } catch (error) { + // Why reword only this code: the bare code names no action, the others name their own. + if (error instanceof Error && error.message === 'browser_host_unavailable') { + throw new BrowserError('browser_host_unavailable', BROWSER_HOST_UNAVAILABLE_MESSAGE) + } + throw error + } +} diff --git a/src/main/runtime/browser/browser-tab-creation.ts b/src/main/runtime/browser/browser-tab-creation.ts index 4bd0599a30f4..744995372b08 100644 --- a/src/main/runtime/browser/browser-tab-creation.ts +++ b/src/main/runtime/browser/browser-tab-creation.ts @@ -7,17 +7,10 @@ import { } from '../browser-tab-create-publication' import { browserSessionRegistry } from '../../browser/browser-session-registry' import { BrowserError } from '../../browser/browser-error' -import { randomUUID } from 'node:crypto' -import { - createRuntimeBrowserClientPage, - navigateRuntimeBrowserClientPage -} from '../runtime-browser-client-page-creation' -import { browserNetworkExecutionHostKey } from '../../browser/browser-network-execution-route' import { waitForTabRegistration } from '../../ipc/browser-tab-registration-wait' -import { BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY } from '../../../shared/browser-client-automation-protocol' -import { BROWSER_HOST_WEBVIEW_CAPABILITY } from '../browser-host/browser-host-capability-selection' import type { RuntimeBrowserCommandHost } from './browser-command-contracts' import type { RuntimeBrowserCommandTargets } from './browser-command-targets' +import { createBrowserClientPage, openBrowserUrlOnClient } from './browser-client-page-opening' export class RuntimeBrowserTabCreation { constructor( @@ -74,64 +67,15 @@ export class RuntimeBrowserTabCreation { ) } if (params.placement?.kind === 'client') { - if (!caller?.pairedDeviceId) { - throw new BrowserError( - 'forbidden', - 'Client-hosted browser pages require an authenticated paired runtime.' - ) - } - if (!worktree) { - throw new BrowserError( - 'invalid_argument', - 'Client-hosted browser pages require an explicit workspace.' - ) - } - const browserPageId = params.page ?? randomUUID() - const executionHost = await this.host.resolveBrowserNetworkExecutionHost(worktree) - const authority = this.host.getBrowserHostLeaseRegistry() - const browserProfileId = params.profileId ?? browserSessionRegistry.getDefaultProfile().id - const created = await createRuntimeBrowserClientPage(authority, { - browserPageId, - browserHostClientId: params.placement.browserHostClientId, - pairedDeviceId: caller.pairedDeviceId, - browserProfileId, - executionHost, - workspaceId: worktree.id - }) - const pages = this.host.getRuntimeBrowserPageRegistry() - pages.publishClientPage({ - browserPageId, - workspaceId: worktree.id, - browserProfileId, - executionHostKey: browserNetworkExecutionHostKey(executionHost), - placement: created.placement, - pairedDeviceId: caller.pairedDeviceId, - url: 'about:blank', - loading: url !== 'about:blank', - active: focus.startsActive - }) - publishCreatedBrowserSessionTab(this.host, { - placementKind: 'client', - browserPageId, - worktreeId: worktree.id, - focus, - clientNavigationId: caller.pairedDeviceId, - targetGroupId: params.targetGroupId - }) - if (url !== 'about:blank') { - try { - await navigateRuntimeBrowserClientPage(authority, { - browserPageId, - placement: created.placement, - url - }) - pages.updatePage(browserPageId, created.placement, { url, loading: false }) - } catch { - pages.updatePage(browserPageId, created.placement, { loading: false }) + return createBrowserClientPage( + this.host, + { ...params, placement: params.placement }, + { + worktree, + browserProfileId: params.profileId ?? browserSessionRegistry.getDefaultProfile().id, + caller } - this.host.notifyHeadlessBrowserSessionTabsChanged?.(worktree.id) - } - return { browserPageId } + ) } // Why: headless serve has no renderer , so back the page with a main-process offscreen WebContents instead. if (!this.host.getAvailableAuthoritativeWindow()) { @@ -222,25 +166,6 @@ export class RuntimeBrowserTabCreation { url: string worktree: string }): Promise<{ browserPageId: string }> { - const protocol = new URL(params.url).protocol - if (protocol !== 'http:' && protocol !== 'https:') { - throw new BrowserError('invalid_argument', 'Only http(s) URLs can be opened on the client.') - } - const lease = this.host - .getBrowserHostLeaseRegistry() - .select(undefined, [ - BROWSER_HOST_WEBVIEW_CAPABILITY, - BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY - ]) - return this.browserTabCreate( - { - url: params.url, - worktree: params.worktree, - activate: true, - navigation: 'caller', - placement: { kind: 'client', browserHostClientId: lease.browserHostClientId } - }, - { pairedDeviceId: lease.pairedDeviceId, clientKind: 'runtime' } - ) + return openBrowserUrlOnClient(this.host, params) } } diff --git a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-route.ts b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-route.ts index e9886b6a1bc5..ba04c852feab 100644 --- a/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-route.ts +++ b/src/main/runtime/desktop-browser-bridge/desktop-browser-bridge-route.ts @@ -25,6 +25,7 @@ export async function routeDesktopBrowserBridgeRpc(options: { caller: RpcCallerIdentity | undefined hostEnabled: boolean registry: DesktopBrowserBridgeRegistry + /** This host serves browser commands itself: its own engine, or a desktop hosting its pages. */ localBrowserAvailable: boolean resolveWorkspace(selector: string): Promise<{ id: string }> signal?: AbortSignal diff --git a/src/main/runtime/runtime-browser-commands-factory.ts b/src/main/runtime/runtime-browser-commands-factory.ts index 9e228a8d1e9a..e8e2f4531691 100644 --- a/src/main/runtime/runtime-browser-commands-factory.ts +++ b/src/main/runtime/runtime-browser-commands-factory.ts @@ -30,6 +30,8 @@ export type RuntimeBrowserCommandsFactory = ( export type RuntimeBrowserCommandsFactoryOptions = { /** The provider creates pages against this runtime's client-host lease registry. */ clientHosting?: boolean + /** Client pages live in this runtime's own registries, so they need no healthy engine. */ + clientHostingWithoutEngine?: boolean /** The provider owns browser pages without a renderer window. */ headless?: boolean /** Live health probe; failure must remove advertised capability. */ @@ -62,6 +64,9 @@ export function runtimeBrowserCommandsFactoryIsHeadless(): boolean { } export function runtimeBrowserCommandsFactorySupportsClientHosting(): boolean { + if (currentFactory && currentOptions.clientHostingWithoutEngine === true) { + return true + } return runtimeBrowserCommandsFactoryIsAvailable() && currentOptions.clientHosting === true } @@ -83,17 +88,16 @@ export function setRuntimeBrowserUnavailableCause( } /** - * The cause this process can prove. An installed factory outranks any recorded note: it - * means resolution succeeded, so the failure is later and elsewhere — a dead provider, or - * a renderer-backed factory with no window. Unknown beats guessing. + * The cause this process can prove. A healthy factory outranks any recorded note: the failure + * is a renderer-backed factory with no window. An unhealthy one keeps the resolver's note, since + * a factory serving only client pages stays installed when no engine resolved; with no note the + * engine started and later died. Unknown beats guessing. */ export function runtimeBrowserUnavailableCause(): RuntimeBrowserUnavailableCause { - if (currentFactory) { - return runtimeBrowserCommandsFactoryIsAvailable() - ? { reason: 'desktop_window_unavailable' } - : { reason: 'provider_unhealthy' } + if (currentFactory && runtimeBrowserCommandsFactoryIsAvailable()) { + return { reason: 'desktop_window_unavailable' } } - return unavailableCause ?? { reason: 'unknown' } + return unavailableCause ?? { reason: currentFactory ? 'provider_unhealthy' : 'unknown' } } /** diff --git a/src/main/runtime/session-graph/session-graph-authority.ts b/src/main/runtime/session-graph/session-graph-authority.ts index 03e6595a6eeb..8554d145da66 100644 --- a/src/main/runtime/session-graph/session-graph-authority.ts +++ b/src/main/runtime/session-graph/session-graph-authority.ts @@ -25,6 +25,7 @@ import { isDesktopBrowserBridgeHostEnabled } from '../desktop-browser-bridge/desktop-browser-bridge-registry' import { runtimeBrowserCommandsFactoryIsAvailable } from '../runtime-browser-commands-factory' +import { hasPairedPageHost } from '../browser/browser-client-page-opening' import { resolveRemoteCliBrowserScopeSelector } from '../desktop-browser-bridge/remote-cli-browser-scope' import type { RpcContext } from '../rpc/core' @@ -231,7 +232,10 @@ export abstract class OrcaRuntimeWithTransitionGraphReloadToTerminalState extend signal: context.signal, hostEnabled: isDesktopBrowserBridgeHostEnabled(), registry: getDesktopBrowserBridgeRegistry(this), - localBrowserAvailable: runtimeBrowserCommandsFactoryIsAvailable(), + // Why a desktop hosting this runtime's pages counts: those commands never need the bridge. + localBrowserAvailable: + runtimeBrowserCommandsFactoryIsAvailable() || + hasPairedPageHost({ getBrowserHostLeaseRegistry: () => getBrowserHostLeaseRegistry(this) }), resolveWorkspace: (selector) => this.resolveBrowserWorkspace(selector) }) } diff --git a/src/renderer/src/lib/client-creation-action-policy.ts b/src/renderer/src/lib/client-creation-action-policy.ts index 9933335b2650..e3da640964d4 100644 --- a/src/renderer/src/lib/client-creation-action-policy.ts +++ b/src/renderer/src/lib/client-creation-action-policy.ts @@ -4,6 +4,8 @@ import type { RuntimeStatus } from '../../../shared/runtime-types' import type { AppState } from '@/store/types' import { isPairedWebClientWindow } from './desktop-window-chrome' import { runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from './worktree-runtime-owner' +import { expectsBrowserClientHosting } from '../../../shared/browser-client-hosting-eligibility' +import { getOrcadSshDeployment } from '../../../shared/runtime-environments' export const MANAGED_BROWSER_UNAVAILABLE_MESSAGE = "Managed browser tabs are unavailable because this web client's paired runtime does not support browser streaming." @@ -32,6 +34,8 @@ export function resolveClientCreationActionPolicy(args: { surface: 'electron' | 'paired-web' runtimeStatus: Pick | null floatingWorkspace?: boolean + /** The runtime places its pages on this desktop (see runtimeHostsPagesOnThisDesktop). */ + runtimeHostsPagesOnThisDesktop?: boolean }): ClientCreationActionPolicy { const browserStreamingAvailable = args.runtimeStatus?.capabilities?.includes( BROWSER_SCREENCAST_RUNTIME_CAPABILITY @@ -41,7 +45,10 @@ export function resolveClientCreationActionPolicy(args: { return { 'managed-browser': { state: 'enabled', - provider: browserStreamingAvailable ? 'paired-runtime' : 'local-client' + provider: + browserStreamingAvailable || args.runtimeHostsPagesOnThisDesktop + ? 'paired-runtime' + : 'local-client' }, 'mobile-emulator': { state: 'enabled', provider: 'local-client' } } @@ -75,7 +82,33 @@ export function getClientCreationActionPolicy( return resolveClientCreationActionPolicy({ surface: isPairedWebClientWindow() ? 'paired-web' : 'electron', runtimeStatus, - floatingWorkspace + floatingWorkspace, + runtimeHostsPagesOnThisDesktop: + runtimeEnvironmentId !== null && runtimeHostsPagesOnThisDesktop(state, runtimeEnvironmentId) + }) +} + +/** + * A runtime with no browser of its own (orcad) whose pages this desktop hosts, network tunnelled + * through the runtime. A managed SSH deployment keeps desktop tabs on its SSH browser route. + */ +export function runtimeHostsPagesOnThisDesktop( + state: Pick, + runtimeEnvironmentId: string +): boolean { + if (isPairedWebClientWindow()) { + return false + } + const environment = state.runtimeEnvironments?.find((entry) => entry.id === runtimeEnvironmentId) + if (environment && getOrcadSshDeployment(environment)) { + return false + } + const status = state.runtimeStatusByEnvironmentId?.get(runtimeEnvironmentId)?.status + return expectsBrowserClientHosting({ + enabled: state.settings?.browserClientHostedRemoteEnabled !== false, + preference: undefined, + deviceScope: status?.deviceScope, + capabilities: status?.capabilities }) } @@ -96,7 +129,10 @@ export function assertRuntimeManagedBrowserCreationAvailable( ): void { const capabilities = state.runtimeStatusByEnvironmentId?.get(runtimeEnvironmentId)?.status?.capabilities - if (!capabilities?.includes(BROWSER_SCREENCAST_RUNTIME_CAPABILITY)) { + if ( + !capabilities?.includes(BROWSER_SCREENCAST_RUNTIME_CAPABILITY) && + !runtimeHostsPagesOnThisDesktop(state, runtimeEnvironmentId) + ) { throw new Error(RUNTIME_BROWSER_UNAVAILABLE_MESSAGE) } } diff --git a/src/renderer/src/lib/workspace-browser-tab-open.ts b/src/renderer/src/lib/workspace-browser-tab-open.ts index dd415fb04dd4..614823c20c1d 100644 --- a/src/renderer/src/lib/workspace-browser-tab-open.ts +++ b/src/renderer/src/lib/workspace-browser-tab-open.ts @@ -12,6 +12,7 @@ import { BROWSER_SCREENCAST_RUNTIME_CAPABILITY } from '../../../shared/protocol- import type { BrowserClientHostPlacementPreference } from '../../../shared/browser-client-host-placement' import { getClientCreationActionPolicy, + runtimeHostsPagesOnThisDesktop, type ClientCreationActionAvailability } from './client-creation-action-policy' import { resolveWorktreeOperationRoute } from './worktree-operation-route' @@ -58,7 +59,8 @@ function isExpectedRuntimeBrowserRoute( state.runtimeStatusByEnvironmentId?.get(expectedEnvironmentId)?.status?.capabilities if ( environmentId !== expectedEnvironmentId || - !capabilities?.includes(BROWSER_SCREENCAST_RUNTIME_CAPABILITY) + (!capabilities?.includes(BROWSER_SCREENCAST_RUNTIME_CAPABILITY) && + !runtimeHostsPagesOnThisDesktop(state, expectedEnvironmentId)) ) { return false } From 7226a3afe3293794b2a80f9fa7aa04cad1a8052a Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 19 Oct 2026 07:47:12 -0700 Subject: [PATCH 02/11] wip: tests and renderer placement --- config/scripts/ci-e2e-job-selection.test.mjs | 3 +- config/scripts/pr-e2e-source-routing.mjs | 2 +- .../runtime-composition-parity.test.ts | 22 ++ .../browser-capabilities-part-02.spec.ts | 26 +- .../lib/client-creation-action-policy.test.ts | 53 ++++- .../orcad-serve-open-url-on-desktop.spec.ts | 223 ++++++++++++++++++ .../ssh-orcad-browser-capabilities.spec.ts | 29 ++- 7 files changed, 344 insertions(+), 14 deletions(-) create mode 100644 tests/e2e/orcad-serve-open-url-on-desktop.spec.ts diff --git a/config/scripts/ci-e2e-job-selection.test.mjs b/config/scripts/ci-e2e-job-selection.test.mjs index 9a217768bca2..aff72d372300 100644 --- a/config/scripts/ci-e2e-job-selection.test.mjs +++ b/config/scripts/ci-e2e-job-selection.test.mjs @@ -15,7 +15,8 @@ import { selectPrE2eSpecs } from './pr-e2e-source-routing.mjs' it.each([ 'src/main/runtime/runtime-browser-commands-factory.ts', 'src/main/runtime/orca-runtime-get-status.ts', - 'src/main/orcad/orcad-browser-startup.ts' + 'src/main/orcad/orcad-browser-startup.ts', + 'src/main/runtime/browser/browser-client-page-opening.ts' ])('routes the managed browser capability oracle from %s', (path) => { expect(selectPrE2eSpecs([path])).toContain('tests/e2e/ssh-orcad-browser-capabilities.spec.ts') }) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index f6ef58c1f8d6..56177c370867 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -151,7 +151,7 @@ export const PR_E2E_SOURCE_ROUTES = [ specs: ['tests/e2e/ssh-orcad-browser-capabilities.spec.ts'], matches: (file) => isProductSource(file) && - /^src\/main\/(?:runtime\/(?:runtime-browser-commands-factory|orca-runtime-get-status)|host\/electron-browser-commands|orcad\/orcad-browser-)/.test( + /^src\/main\/(?:runtime\/(?:runtime-browser-commands-factory|orca-runtime-get-status|browser\/browser-client-page-opening)|host\/electron-browser-commands|orcad\/orcad-browser-)/.test( file ) }, diff --git a/src/main/runtime/construction/runtime-composition-parity.test.ts b/src/main/runtime/construction/runtime-composition-parity.test.ts index ac04222c6899..a70ee2c73742 100644 --- a/src/main/runtime/construction/runtime-composition-parity.test.ts +++ b/src/main/runtime/construction/runtime-composition-parity.test.ts @@ -321,6 +321,28 @@ describe('runtime composition parity (Electron vs orcad)', () => { expect(actual).toEqual([...declared].sort()) }) + // #30731: orcad once installed a browser factory that could not place pages on a desktop. + it('installs a browser factory on both roots that hosts pages on a paired desktop', () => { + const installs: Record = { + electron: { + root: 'src/main/startup/main-process-preflight.ts', + call: 'installElectronBrowserCommands(', + file: 'src/main/host/electron-browser-commands.ts', + option: /\bclientHosting: true\b/ + }, + orcad: { + root: 'src/main/orcad/orcad-lifecycle.ts', + call: 'startOrcadBrowserProvider(', + file: 'src/main/orcad/orcad-browser-startup.ts', + option: /\bclientHostingWithoutEngine: true\b/ + } + } + for (const [root, install] of Object.entries(installs)) { + expect(read(install.root), root).toContain(install.call) + expect(read(install.file), root).toMatch(install.option) + } + }) + it('lists each advertised-but-unserved capability against what the host advertises', () => { for (const difference of DECLARED_HOST_DIFFERENCES) { expect(difference.reason, difference.wiring).not.toBe('') diff --git a/src/main/runtime/orca-runtime-tests/browser-capabilities-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/browser-capabilities-part-02.spec.ts index 68adf9e8e35b..a2a9987664b6 100644 --- a/src/main/runtime/orca-runtime-tests/browser-capabilities-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/browser-capabilities-part-02.spec.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from 'vitest' -import { OrcaRuntimeService } from '../orca-runtime-test-mocks.spec' +import { + OrcaRuntimeService, + RuntimeBrowserCommands, + setRuntimeBrowserCommandsFactory, + setRuntimeBrowserUnavailableCause +} from '../orca-runtime-test-mocks.spec' +import { runtimeAdvertisesBrowserClientHosting } from '../../../shared/browser-client-hosting-eligibility' import { TEST_REPO_ID, TEST_WORKTREE_ID, @@ -35,4 +41,22 @@ describe('OrcaRuntimeService', () => { ).rejects.toThrow('selector_not_found') expect(internals.listResolvedWorktrees).not.toHaveBeenCalled() }) + + // #30731: orcad with no engine still hosts pages on a paired desktop, and says why it can't itself. + it('advertises client hosting from an engine-free factory while reporting the engine cause', () => { + setRuntimeBrowserCommandsFactory((host) => new RuntimeBrowserCommands(host), { + headless: true, + clientHostingWithoutEngine: true, + isAvailable: () => false + }) + setRuntimeBrowserUnavailableCause({ reason: 'unconfigured' }) + const status = new OrcaRuntimeService(store as never).getStatus() + expect(runtimeAdvertisesBrowserClientHosting(status.capabilities)).toBe(true) + expect(status.capabilities).toContain('browser.tab-create-known-id.v1') + expect(status.capabilities).not.toContain('browser.headless.v1') + expect(status.degradations).toContainEqual( + expect.objectContaining({ code: 'browser_unavailable', reason: 'unconfigured' }) + ) + setRuntimeBrowserUnavailableCause(null) + }) }) diff --git a/src/renderer/src/lib/client-creation-action-policy.test.ts b/src/renderer/src/lib/client-creation-action-policy.test.ts index b0edc2add676..3ed90a17c747 100644 --- a/src/renderer/src/lib/client-creation-action-policy.test.ts +++ b/src/renderer/src/lib/client-creation-action-policy.test.ts @@ -3,6 +3,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import type { RuntimeStatus } from '../../../shared/runtime-types' import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { BROWSER_CLIENT_HOSTING_RUNTIME_CAPABILITIES } from '../../../shared/browser-client-hosting-eligibility' import { FLOATING_BROWSER_UNAVAILABLE_MESSAGE, LOCAL_BROWSER_UNAVAILABLE_MESSAGE, @@ -10,8 +11,10 @@ import { MOBILE_EMULATOR_UNAVAILABLE_MESSAGE, assertClientCreationActionAvailable, assertManagedBrowserMaterializationAllowed, + assertRuntimeManagedBrowserCreationAvailable, getClientCreationActionPolicy, - resolveClientCreationActionPolicy + resolveClientCreationActionPolicy, + runtimeHostsPagesOnThisDesktop } from './client-creation-action-policy' function runtimeStatus(capabilities?: string[]): RuntimeStatus { @@ -208,3 +211,51 @@ describe('client creation action guards', () => { ).toEqual({ state: 'hidden', reason: MANAGED_BROWSER_UNAVAILABLE_MESSAGE }) }) }) + +describe('a runtime whose pages this desktop hosts', () => { + const CLIENT_HOSTING = [...BROWSER_CLIENT_HOSTING_RUNTIME_CAPABILITIES] + function desktopState(environment: { id: string; orcadDeployment?: unknown }) { + return { + settings: { activeRuntimeEnvironmentId: environment.id }, + runtimeEnvironments: [environment], + runtimeStatusByEnvironmentId: new Map([ + [environment.id, { status: runtimeStatus(CLIENT_HOSTING), checkedAt: 1 }] + ]) + } + } + + it('creates runtime-placed tabs for a paired server with no browser of its own', () => { + const state = desktopState({ id: 'orcad-server' }) + expect(runtimeHostsPagesOnThisDesktop(state as never, 'orcad-server')).toBe(true) + expect(getClientCreationActionPolicy(state as never, null)['managed-browser']).toEqual({ + state: 'enabled', + provider: 'paired-runtime' + }) + expect(() => + assertRuntimeManagedBrowserCreationAvailable(state as never, 'orcad-server') + ).not.toThrow() + }) + + it('keeps a managed SSH deployment on its desktop tabs and SSH browser route', () => { + const state = desktopState({ + id: 'managed-ssh', + orcadDeployment: { kind: 'ssh', sshTargetId: 'target-1', sshTargetGeneration: 1 } + }) + expect(runtimeHostsPagesOnThisDesktop(state as never, 'managed-ssh')).toBe(false) + expect(getClientCreationActionPolicy(state as never, null)['managed-browser']).toEqual({ + state: 'enabled', + provider: 'local-client' + }) + }) + + it('stays off when desktop-hosted remote pages are turned off, and in the web client', () => { + const state = desktopState({ id: 'orcad-server' }) + const disabled = { + ...state, + settings: { ...state.settings, browserClientHostedRemoteEnabled: false } + } + expect(runtimeHostsPagesOnThisDesktop(disabled as never, 'orcad-server')).toBe(false) + vi.stubGlobal('__ORCA_WEB_CLIENT__', true) + expect(runtimeHostsPagesOnThisDesktop(state as never, 'orcad-server')).toBe(false) + }) +}) diff --git a/tests/e2e/orcad-serve-open-url-on-desktop.spec.ts b/tests/e2e/orcad-serve-open-url-on-desktop.spec.ts new file mode 100644 index 000000000000..74c9f53b2292 --- /dev/null +++ b/tests/e2e/orcad-serve-open-url-on-desktop.spec.ts @@ -0,0 +1,223 @@ +/** + * #30731: `orca open-url` on a paired `orca serve` running orcad with no browser engine opens the + * page in the paired desktop's browser, its network routed through the server. + * + * Needs the packaged orcad slot (`pnpm build:orcad`). Background launch only; nothing is focused. + * + * Run: + * ORCA_E2E_ORCAD_SERVE=1 ORCA_BACKGROUND_LAUNCH=1 pnpm exec playwright test \ + * tests/e2e/orcad-serve-open-url-on-desktop.spec.ts --config tests/playwright.config.ts \ + * --project electron-headless --workers=1 + */ +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { runProcess } from '@orca/process-host' +import { RuntimeClient } from '../../src/cli/runtime/client' +import { resolveBundledOrcadRuntime } from '../../src/main/orcad/orcad-bundled-runtime' +import { expect, test } from './helpers/orca-app' +import { cleanupE2EDaemons } from './helpers/electron-process-shutdown' +import { cliServeProfile, spawnUntilReady } from './helpers/orca-serve-cli-host' +import { launchPairedElectronClient } from './helpers/paired-electron-client' + +const RUN = process.env.ORCA_E2E_ORCAD_SERVE === '1' +const slotDir = path.resolve('out/orcad') +const orcadRuntime = RUN && existsSync(slotDir) ? resolveBundledOrcadRuntime(slotDir) : null +const MARKER = 'open-url-desktop-marker' + +let scratch = '' + +test.beforeAll(() => { + test.skip(!RUN, 'Set ORCA_E2E_ORCAD_SERVE=1 (and build out/orcad) to run against orcad serve.') + expect(orcadRuntime, 'pnpm build:orcad must produce a packaged slot in out/orcad').toBeTruthy() + // Short root: macOS caps a unix socket path at 104 bytes, and the daemon's lives in userData. + scratch = mkdtempSync(path.join(process.platform === 'win32' ? os.tmpdir() : '/tmp', 'orca-ou-')) +}) + +test.afterAll(() => { + if (scratch) { + rmSync(scratch, { recursive: true, force: true, maxRetries: 50, retryDelay: 100 }) + } +}) + +/** A page on the server's loopback, as an agent's dev server would be. */ +async function startPageServer(): Promise<{ url: string; server: Server }> { + const server = createServer((_request, response) => { + response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + response.end(`${MARKER}

${MARKER}

`) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + return { url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/`, server } +} + +async function readDesktopWebviewMarker(page: Page, url: string): Promise { + return page.evaluate(async (prefix) => { + for (const candidate of document.querySelectorAll('webview')) { + const webview = candidate as Electron.WebviewTag + try { + if (webview.getURL().startsWith(prefix)) { + return (await webview.executeJavaScript( + 'document.querySelector("#marker")?.textContent ?? null' + )) as string | null + } + } catch { + // The guest may still be attaching. + } + } + return null + }, url) +} + +test('orca open-url on orcad serve opens the page in the paired desktop', async ({ + testRepoPath +}, testInfo) => { + test.setTimeout(300_000) + const profile = cliServeProfile(scratch) + const serve = await spawnUntilReady({ + label: 'orcad serve', + program: orcadRuntime!, + args: [ + path.join(slotDir, 'orcad.js'), + '--bind', + '127.0.0.1', + '--port', + '0', + '--pairing-address', + '127.0.0.1', + '--json' + ], + env: { ...profile.env, ORCA_USER_DATA: profile.userDataDir }, + timeoutMs: 120_000, + parseReady: (stdout) => { + const newline = stdout.indexOf('\n') + return newline === -1 ? null : JSON.parse(stdout.slice(0, newline)) + } + }) + const page = await startPageServer() + const client = new RuntimeClient(profile.userDataDir, 15_000) + let desktop: Awaited> | null = null + try { + const pairingUrl = serve.ready.pairing?.url + expect(pairingUrl, serve.stderr()).toEqual(expect.any(String)) + const status = await client.call<{ capabilities: string[] }>('status.get') + expect(status.result.capabilities).toContain('browser.clientHost.v1') + await client.call('repo.add', { path: testRepoPath, kind: 'git' }) + + const worktree = `path:${testRepoPath}` + const openUrl = (): Promise<{ code: number | null; stdout: string; stderr: string }> => + runProcess({ + program: process.execPath, + args: [ + path.join(process.cwd(), 'out', 'cli', 'index.js'), + 'open-url', + '--url', + page.url, + '--worktree', + worktree, + '--json' + ], + env: { ...profile.env, ORCA_USER_DATA: profile.userDataDir }, + timeoutMs: 90_000 + }) + + // No desktop has taken this server's pages yet: a typed refusal that says what to do. + const refused = await openUrl() + testInfo.attach('open-url-before-desktop.txt', { body: refused.stdout + refused.stderr }) + expect(refused.code).not.toBe(0) + expect(refused.stdout + refused.stderr).toContain('browser_host_unavailable') + + desktop = await launchPairedElectronClient({ pairingUrl }, testInfo, '#30731 desktop') + const worktreeId = await expect + .poll( + () => + desktop!.page.evaluate( + (repoPath) => + window.__store + ?.getState() + .allWorktrees() + .find((entry) => entry.path === repoPath)?.id ?? null, + testRepoPath + ), + { timeout: 60_000 } + ) + .not.toBeNull() + .then(() => + desktop!.page.evaluate( + (repoPath) => + window.__store + ?.getState() + .allWorktrees() + .find((entry) => entry.path === repoPath)?.id ?? '', + testRepoPath + ) + ) + // The user opens a browser tab in the server's workspace, as in the report. + await desktop.page.evaluate( + ({ environmentId, worktreeId }) => { + window.__store?.getState().setActiveWorktree(worktreeId, `runtime:${environmentId}`) + }, + { environmentId: desktop.environmentId, worktreeId } + ) + await desktop.page.evaluate(async () => { + const state = window.__store?.getState() + const groupId = state?.activeWorktreeId + ? state.activeGroupIdByWorktree[state.activeWorktreeId] + : undefined + if (!state || !groupId) { + throw new Error('desktop has no active tab group in the server workspace') + } + state.setBrowserDefaultUrl('about:blank') + await state.openNewBrowserTabInActiveWorkspace(groupId) + }) + + let opened: { code: number | null; stdout: string; stderr: string } | null = null + await expect + .poll( + async () => { + opened = await openUrl() + return opened.code + }, + { timeout: 60_000, intervals: [2_000] } + ) + .toBe(0) + testInfo.attach('open-url-with-desktop.txt', { body: opened!.stdout + opened!.stderr }) + const browserPageId = JSON.parse(opened!.stdout).result.browserPageId + expect(browserPageId).toEqual(expect.any(String)) + + await expect + .poll(() => readDesktopWebviewMarker(desktop!.page, page.url), { + timeout: 60_000, + message: "the desktop never rendered the server's loopback page" + }) + .toBe(MARKER) + // Client placement: the server owns the page and the desktop renders it. + const placement = await desktop.page.evaluate( + ({ remotePageId, worktreeId }) => { + const state = window.__store?.getState() + for (const workspace of state?.browserTabsByWorktree[worktreeId] ?? []) { + for (const browserPage of state?.browserPagesByWorkspace[workspace.id] ?? []) { + const handle = state?.remoteBrowserPageHandlesByPageId[browserPage.id] + if ((handle?.remotePageId ?? browserPage.id) === remotePageId) { + return handle?.placement?.kind ?? null + } + } + } + return null + }, + { remotePageId: browserPageId, worktreeId } + ) + expect(placement).toBe('client') + await testInfo.attach('desktop-with-open-url-page.png', { + body: await desktop.page.screenshot(), + contentType: 'image/png' + }) + } finally { + await desktop?.dispose() + await new Promise((settle) => page.server.close(settle)) + await serve.stop() + await cleanupE2EDaemons(profile.userDataDir) + } +}) diff --git a/tests/e2e/ssh-orcad-browser-capabilities.spec.ts b/tests/e2e/ssh-orcad-browser-capabilities.spec.ts index 0930ef7f7629..807c9fe8a5b5 100644 --- a/tests/e2e/ssh-orcad-browser-capabilities.spec.ts +++ b/tests/e2e/ssh-orcad-browser-capabilities.spec.ts @@ -9,7 +9,7 @@ import { ORCAD_TEMPLATE, skipUnlessManagedDockerHost } from './helpers/orcad-man skipUnlessManagedDockerHost() test.use({ orcaAppExtraEnv: { ORCA_ORCAD_TEMPLATE_PATH: ORCAD_TEMPLATE } }) -test('a backendless managed host does not promise client-hosted browser creation', async ({ +test('a backendless managed host offers desktop-hosted pages and refuses its own engine', async ({ orcaPage: page }, testInfo) => { test.setTimeout(3 * 60_000) @@ -41,20 +41,29 @@ test('a backendless managed host does not promise client-hosted browser creation ) const capabilities = (await status()).capabilities console.log('[managed-browser-capabilities]', JSON.stringify(capabilities)) - expect(runtimeAdvertisesBrowserClientHosting(capabilities)).toBe(false) - expect(capabilities).not.toContain('browser.clientHost.v1') - expect(capabilities).not.toContain('browser.tab-create-known-id.v1') + // #30731: client pages need no engine on the host, so it can create them for a desktop. + expect(runtimeAdvertisesBrowserClientHosting(capabilities)).toBe(true) + expect(capabilities).toContain('browser.tab-create-known-id.v1') expect(capabilities).toContain('network.browserTunnel.v1') - const refused = await page.evaluate( - (selector) => - window.api.runtimeEnvironments.call({ selector, method: 'browser.tabList', params: {} }), - environment.id - ) + const call = (method: string, params: Record) => + page.evaluate((input) => window.api.runtimeEnvironments.call(input), { + selector: environment.id, + method, + params + }) + const refused = await call('browser.tabList', {}) console.log('[managed-browser-command]', JSON.stringify(refused)) expect(refused).toMatchObject({ ok: false, error: { code: 'browser_unavailable' } }) + // A managed SSH desktop keeps its SSH-routed tabs, so it never takes this host's pages. + const openUrl = await call('browser.openUrl', { + url: 'https://example.org/', + worktree: 'active' + }) + console.log('[managed-browser-open-url]', JSON.stringify(openUrl)) + expect(openUrl).toMatchObject({ ok: false, error: { code: 'browser_host_unavailable' } }) await serverCall(page, environment.id, 'repo.list') await reconnect(page, targetId) - expect(runtimeAdvertisesBrowserClientHosting((await status()).capabilities)).toBe(false) + expect(runtimeAdvertisesBrowserClientHosting((await status()).capabilities)).toBe(true) } finally { target.cleanup() } From e9ce16cd7f874afe6858e7dd883edc886ffb9d3f Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 19 Oct 2026 07:56:42 -0700 Subject: [PATCH 03/11] wip: engine-free client tab queries; keep paired server intent --- src/main/orcad/orcad-browser-startup.test.ts | 10 +- src/main/orcad/orcad-browser-startup.ts | 75 +-------- .../orcad/orcad-client-hosted-browser.test.ts | 48 +++++- src/main/orcad/orcad-client-page-commands.ts | 150 ++++++++++++++++++ .../browser/browser-client-page-queries.ts | 124 +++++++++++++++ .../runtime/browser/browser-tab-catalog.ts | 24 +-- .../runtime/browser/browser-tab-queries.ts | 11 +- .../browser-capabilities-part-02.spec.ts | 3 +- .../lib/client-creation-action-policy.test.ts | 67 ++++---- .../src/lib/client-creation-action-policy.ts | 20 ++- .../orcad-serve-open-url-on-desktop.spec.ts | 76 ++++++--- 11 files changed, 455 insertions(+), 153 deletions(-) create mode 100644 src/main/orcad/orcad-client-page-commands.ts create mode 100644 src/main/runtime/browser/browser-client-page-queries.ts diff --git a/src/main/orcad/orcad-browser-startup.test.ts b/src/main/orcad/orcad-browser-startup.test.ts index beef84722a33..91d9d49fbd39 100644 --- a/src/main/orcad/orcad-browser-startup.test.ts +++ b/src/main/orcad/orcad-browser-startup.test.ts @@ -20,7 +20,15 @@ afterEach(() => { setRuntimeBrowserUnavailableCause(null) }) -const host: RuntimeBrowserCommandHost = Object.create(null) +// No desktop hosts pages for this runtime, so only the engine can answer. +const host: RuntimeBrowserCommandHost = Object.assign(Object.create(null), { + getRuntimeBrowserPageRegistry: () => new RuntimeBrowserPageRegistry(), + getBrowserHostLeaseRegistry: () => ({ + select: () => { + throw new Error('browser_host_unavailable') + } + }) +}) function delayedProvider() { const pending = Promise.withResolvers() vi.mocked(resolveOrcadBrowserProvider).mockReturnValueOnce(pending.promise) diff --git a/src/main/orcad/orcad-browser-startup.ts b/src/main/orcad/orcad-browser-startup.ts index e94c695fd222..83c018b7179c 100644 --- a/src/main/orcad/orcad-browser-startup.ts +++ b/src/main/orcad/orcad-browser-startup.ts @@ -5,25 +5,12 @@ import { browserUnavailableError, setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' -import { - closeBrowserPageWithoutEngine, - closeRegisteredBrowserClientPage -} from '../runtime/browser/browser-client-page-close' -import { - createBrowserClientPage, - createBrowserPageOnPairedHost, - DEFAULT_BROWSER_PROFILE_ID, - hasPairedPageHost, - openBrowserUrlOnClient, - type BrowserClientPageCaller, - type BrowserClientPageCreateParams -} from '../runtime/browser/browser-client-page-opening' -import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts' import { resolveOrcadBrowserProvider, type OrcadBrowserProvider, type OrcadBrowserProviderOptions } from './orcad-browser-provider' +import { orcadClientPageCommand } from './orcad-client-page-commands' /** Browser discovery must not hold core RPC readiness hostage to a desktop authorization UI. */ export function startOrcadBrowserProvider(options: OrcadBrowserProviderOptions): { @@ -52,43 +39,10 @@ export function startOrcadBrowserProvider(options: OrcadBrowserProviderOptions): commands ??= provider.factory(host) return callBrowserCommand(commands, property, args) } - if (property === 'browserOpenUrlOnClient') { - // Why: the page renders on a paired desktop, so open-url never needs this host's engine. - return (...args: Parameters) => - openBrowserUrlOnClient(host, args[0]) - } - if (property === 'browserTabCreate') { - return (...args: Parameters) => { - const [params, caller] = args - if (params?.placement?.kind === 'client') { - return createOrcadBrowserClientPage( - host, - { ...params, placement: params.placement }, - caller - ) - } - // Why: with no engine here, a desktop hosting this server's pages is the only browser. - if (!params?.placement && !engineAvailable() && hasPairedPageHost(host)) { - return createBrowserPageOnPairedHost( - host, - params ?? {}, - orcadBrowserProfileId(params?.profileId) - ) - } - return forward(args) - } - } - if (property === 'browserTabClose') { - // Why: client-hosted pages live in this runtime's registry, never in the provider. - return async (...args: Parameters) => { - const params = args[0] ?? {} - if (!engineAvailable()) { - return closeBrowserPageWithoutEngine(host, params, browserUnavailableError) - } - return (await closeRegisteredBrowserClientPage(host, params)) ?? forward(args) - } - } - return (...args: unknown[]) => forward(args) + return ( + orcadClientPageCommand(host, property, forward, engineAvailable) ?? + ((...args: unknown[]) => forward(args)) + ) } }) }, @@ -146,22 +100,3 @@ function callBrowserCommand( } return member.call(commands, ...args) } - -/** A client-placed page on a host with no profile registry. */ -async function createOrcadBrowserClientPage( - host: RuntimeBrowserCommandHost, - params: BrowserClientPageCreateParams & { worktree?: string; profileId?: string }, - caller: BrowserClientPageCaller | undefined -): Promise<{ browserPageId: string }> { - const worktree = params.worktree ? await host.resolveBrowserWorkspace(params.worktree) : undefined - const browserProfileId = orcadBrowserProfileId(params.profileId) - return createBrowserClientPage(host, params, { worktree, browserProfileId, caller }) -} - -// Why only the default: orcad has no profile registry to describe any other to a desktop. -function orcadBrowserProfileId(profileId: string | undefined): string { - if (profileId !== undefined && profileId !== DEFAULT_BROWSER_PROFILE_ID) { - throw new BrowserError('invalid_argument', `Browser profile ${profileId} was not found`) - } - return DEFAULT_BROWSER_PROFILE_ID -} diff --git a/src/main/orcad/orcad-client-hosted-browser.test.ts b/src/main/orcad/orcad-client-hosted-browser.test.ts index 262a1db9722b..05c8fd7b2f6e 100644 --- a/src/main/orcad/orcad-client-hosted-browser.test.ts +++ b/src/main/orcad/orcad-client-hosted-browser.test.ts @@ -47,7 +47,6 @@ function pairedHost(options: { desktopAttached: boolean }) { }) const createClientPage = vi.fn(async () => PLACEMENT) const issueClientPageCommand = vi.fn(() => ({ - event: {} as never, result: Promise.resolve({ status: 'completed' as const }) })) const notifyHeadlessBrowserSessionTabsChanged = vi.fn() @@ -144,20 +143,63 @@ it('creates a client-placed tab without an engine, and only in the default profi await startup.stop() }) -it('sends an unplaced tab to the paired desktop, and refuses as before when none is attached', async () => { +it('sends the CLI’s unplaced tab to the paired desktop, and refuses as before when none is attached', async () => { const startup = await startWithoutEngine() const paired = pairedHost({ desktopAttached: true }) - const created = await createRuntimeBrowserCommands(paired.host).browserTabCreate({ + const commands = createRuntimeBrowserCommands(paired.host) + const created = await commands.browserTabCreate({ worktree: 'id:wt-1', url: 'https://example.org/' }) expect(paired.pages.getPage(created.browserPageId)?.placement).toEqual(PLACEMENT) + // A paired client that left placement out asked for a server page, which this host cannot make. + expect(() => + commands.browserTabCreate({ worktree: 'id:wt-1' }, { pairedDeviceId: 'device-a' }) + ).toThrow(/unavailable/) const unattached = createRuntimeBrowserCommands(pairedHost({ desktopAttached: false }).host) expect(() => unattached.browserTabCreate({ worktree: 'id:wt-1' })).toThrow(/unavailable/) await startup.stop() }) +it('lists, shows and switches desktop-hosted tabs with no engine', async () => { + const startup = await startWithoutEngine() + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + const first = await commands.browserOpenUrlOnClient({ + url: 'https://example.org/a', + worktree: 'id:wt-1' + }) + const second = await commands.browserOpenUrlOnClient({ + url: 'https://example.org/b', + worktree: 'id:wt-1' + }) + + const listed = await commands.browserTabList({ worktree: 'id:wt-1' }) + expect(listed.tabs.map((tab) => [tab.browserPageId, tab.url, tab.profileId])).toEqual([ + [first.browserPageId, 'https://example.org/a', 'default'], + [second.browserPageId, 'https://example.org/b', 'default'] + ]) + await expect(commands.browserTabSwitch({ page: first.browserPageId })).resolves.toEqual({ + switched: 0, + browserPageId: first.browserPageId + }) + await expect(commands.browserTabCurrent({ worktree: 'id:wt-1' })).resolves.toMatchObject({ + tab: { browserPageId: first.browserPageId } + }) + await expect(commands.browserTabShow({ page: second.browserPageId })).resolves.toMatchObject({ + tab: { url: 'https://example.org/b' } + }) + await startup.stop() +}) + +it('still refuses tab queries when neither an engine nor a desktop can serve them', async () => { + const startup = await startWithoutEngine() + const commands = createRuntimeBrowserCommands(pairedHost({ desktopAttached: false }).host) + expect(() => commands.browserTabList({})).toThrow(/unavailable/) + await startup.stop() +}) + it('advertises client hosting with no engine and keeps the engine’s own cause', async () => { const startup = await startWithoutEngine() diff --git a/src/main/orcad/orcad-client-page-commands.ts b/src/main/orcad/orcad-client-page-commands.ts new file mode 100644 index 000000000000..a295b4f56d02 --- /dev/null +++ b/src/main/orcad/orcad-client-page-commands.ts @@ -0,0 +1,150 @@ +/** + * The browser commands orcad serves itself: pages a paired desktop hosts for it live in this + * runtime's lease and page registries, so they need no browser engine on this host. + */ +import { BrowserError } from '../browser/browser-error' +import type { RuntimeBrowserCommands } from '../runtime/browser/browser-commands' +import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts' +import { browserUnavailableError } from '../runtime/runtime-browser-commands-factory' +import { + closeBrowserPageWithoutEngine, + closeRegisteredBrowserClientPage +} from '../runtime/browser/browser-client-page-close' +import { + createBrowserClientPage, + createBrowserPageOnPairedHost, + DEFAULT_BROWSER_PROFILE_ID, + hasPairedPageHost, + openBrowserUrlOnClient +} from '../runtime/browser/browser-client-page-opening' +import { BrowserClientPageQueries } from '../runtime/browser/browser-client-page-queries' + +type Forward = (args: unknown[]) => unknown + +const ENGINE_FREE_TAB_QUERIES = [ + 'browserTabList', + 'browserTabCurrent', + 'browserTabShow', + 'browserTabSwitch' +] as const + +/** The member orcad answers for `name`, or undefined to send it to the engine. */ +export function orcadClientPageCommand( + host: RuntimeBrowserCommandHost, + name: string, + forward: Forward, + engineAvailable: () => boolean +): ((...args: never[]) => unknown) | undefined { + if (name === 'browserOpenUrlOnClient') { + return (...args: Parameters) => + openBrowserUrlOnClient(host, args[0]) + } + if (name === 'browserTabCreate') { + return (...args: Parameters) => + createTab(host, args, forward, engineAvailable) + } + if (name === 'browserTabClose') { + return async (...args: Parameters) => { + const params = args[0] ?? {} + if (!engineAvailable()) { + return closeBrowserPageWithoutEngine(host, params, browserUnavailableError) + } + return (await closeRegisteredBrowserClientPage(host, params)) ?? forward(args) + } + } + const query = ENGINE_FREE_TAB_QUERIES.find((candidate) => candidate === name) + if (query) { + // Why only without an engine: then the desktop-hosted pages are every tab this host has. + return (...args: unknown[]) => + engineAvailable() || !servesClientPages(host) + ? forward(args) + : callTabQuery(new BrowserClientPageQueries(host, orcadProfileLabel), query, args) + } + return undefined +} + +function createTab( + host: RuntimeBrowserCommandHost, + [params, caller]: Parameters, + forward: Forward, + engineAvailable: () => boolean +): unknown { + if (params?.placement?.kind === 'client') { + const placement = params.placement + return (async () => { + const worktree = params.worktree + ? await host.resolveBrowserWorkspace(params.worktree) + : undefined + return createBrowserClientPage( + host, + { ...params, placement }, + { worktree, browserProfileId: orcadBrowserProfileId(params.profileId), caller } + ) + })() + } + // Why only for this host's own CLI: a paired client that left placement out asked for the server. + if ( + !params?.placement && + !caller?.pairedDeviceId && + !engineAvailable() && + hasPairedPageHost(host) + ) { + return createBrowserPageOnPairedHost( + host, + params ?? {}, + orcadBrowserProfileId(params?.profileId) + ) + } + return forward([params, caller]) +} + +function servesClientPages(host: RuntimeBrowserCommandHost): boolean { + return hasPairedPageHost(host) || host.getRuntimeBrowserPageRegistry().listPages().length > 0 +} + +function callTabQuery( + queries: BrowserClientPageQueries, + name: (typeof ENGINE_FREE_TAB_QUERIES)[number], + args: unknown[] +): unknown { + const params: Record = isRecord(args[0]) ? args[0] : {} + const worktree = typeof params.worktree === 'string' ? params.worktree : undefined + const page = typeof params.page === 'string' ? params.page : undefined + switch (name) { + case 'browserTabList': + return queries.browserTabList({ worktree }) + case 'browserTabCurrent': + return queries.browserTabCurrent({ worktree }) + case 'browserTabShow': + if (!page) { + throw new BrowserError('invalid_argument', 'Missing required --page') + } + return queries.browserTabShow({ page, worktree }) + case 'browserTabSwitch': + return queries.browserTabSwitch({ + worktree, + page, + index: typeof params.index === 'number' ? params.index : undefined, + focus: typeof params.focus === 'boolean' ? params.focus : undefined + }) + } +} + +// Why only the default: orcad has no profile registry to describe any other to a desktop. +function orcadBrowserProfileId(profileId: string | undefined): string { + if (profileId !== undefined && profileId !== DEFAULT_BROWSER_PROFILE_ID) { + throw new BrowserError('invalid_argument', `Browser profile ${profileId} was not found`) + } + return DEFAULT_BROWSER_PROFILE_ID +} + +function orcadProfileLabel(browserProfileId: string): { id: string; label: string } { + return { + id: browserProfileId, + label: browserProfileId === DEFAULT_BROWSER_PROFILE_ID ? 'Default' : browserProfileId + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)) +} diff --git a/src/main/runtime/browser/browser-client-page-queries.ts b/src/main/runtime/browser/browser-client-page-queries.ts new file mode 100644 index 000000000000..0b5ebaa382ec --- /dev/null +++ b/src/main/runtime/browser/browser-client-page-queries.ts @@ -0,0 +1,124 @@ +import { BrowserError } from '../../browser/browser-error' +import type { + BrowserTabInfo, + BrowserTabListResult, + BrowserTabShowResult, + BrowserTabSwitchResult +} from '../../../shared/runtime-types' +import { publishSwitchedBrowserSessionTab } from '../browser-tab-create-publication' +import type { RuntimeBrowserClientPage } from '../runtime-browser-page-registry' +import type { RuntimeBrowserCommandHost } from './browser-command-contracts' + +type BrowserProfileLabel = { id: string; label: string } + +/** The tab row a client-hosted page lists as; `index` is its place after any engine tabs. */ +export function clientPageTabRow( + page: RuntimeBrowserClientPage, + index: number, + profile: BrowserProfileLabel +): BrowserTabInfo { + return { + browserPageId: page.browserPageId, + index, + url: page.url, + title: page.title, + active: page.active, + loadError: null, + certificateFailure: null, + worktreeId: page.workspaceId, + profileId: profile.id, + profileLabel: profile.label + } +} + +// Why no Electron import: a host with no engine lists and selects the pages a desktop hosts for it. +export type BrowserClientPageQueryHost = Pick< + RuntimeBrowserCommandHost, + | 'getAgentBrowserBridge' + | 'resolveBrowserWorkspace' + | 'getRuntimeBrowserPageRegistry' + | 'markHeadlessBrowserSessionTabActive' + | 'notifyHeadlessBrowserSessionTabsChanged' +> + +/** Tab commands over client-hosted pages only, for a host whose engine holds no other tabs. */ +export class BrowserClientPageQueries { + constructor( + private readonly host: BrowserClientPageQueryHost, + private readonly profileFor: (browserProfileId: string) => BrowserProfileLabel + ) {} + + async browserTabList(params: { worktree?: string }): Promise { + const workspaceId = params.worktree + ? (await this.host.resolveBrowserWorkspace(params.worktree)).id + : undefined + const pages = this.host.getRuntimeBrowserPageRegistry().listPages(workspaceId) + const tabs = pages.map((page, index) => + clientPageTabRow(page, index, this.profileFor(page.browserProfileId)) + ) + if (tabs.length > 0 && !tabs.some((tab) => tab.active)) { + tabs[0] = { ...tabs[0]!, active: true } + } + return { tabs } + } + + async browserTabCurrent(params: { worktree?: string }): Promise { + const tab = (await this.browserTabList(params)).tabs.find((candidate) => candidate.active) + if (!tab) { + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + } + return { tab } + } + + async browserTabShow(params: { page: string; worktree?: string }): Promise { + const page = this.host.getRuntimeBrowserPageRegistry().getPage(params.page) + const tab = page + ? ( + await this.browserTabList({ worktree: params.worktree ?? `id:${page.workspaceId}` }) + ).tabs.find((candidate) => candidate.browserPageId === page.browserPageId) + : undefined + if (!tab) { + throw new BrowserError('browser_tab_not_found', `Browser page ${params.page} was not found`) + } + return { tab } + } + + async browserTabSwitch(params: { + index?: number + page?: string + worktree?: string + focus?: boolean + }): Promise { + const listed = await this.browserTabList({ worktree: params.worktree }) + const switched = params.page + ? listed.tabs.findIndex((tab) => tab.browserPageId === params.page) + : (params.index ?? -1) + const pages = this.host.getRuntimeBrowserPageRegistry() + const selected = listed.tabs[switched] + const page = selected ? pages.getPage(selected.browserPageId) : undefined + if (!page) { + const label = params.page ? `Browser page ${params.page}` : `Tab index ${params.index}` + throw new BrowserError( + 'browser_tab_not_found', + `${label} out of range (0-${listed.tabs.length - 1})` + ) + } + activateClientPageTab(this.host, page, params.focus) + return { switched, browserPageId: page.browserPageId } + } +} + +/** Makes a client-hosted page its workspace's active tab and publishes the switch. */ +export function activateClientPageTab( + host: BrowserClientPageQueryHost, + page: RuntimeBrowserClientPage, + focus: boolean | undefined +): void { + host.getRuntimeBrowserPageRegistry().activatePage(page.browserPageId, page.placement) + publishSwitchedBrowserSessionTab(host, { + placementKind: 'client', + browserPageId: page.browserPageId, + worktreeId: page.workspaceId, + focus + }) +} diff --git a/src/main/runtime/browser/browser-tab-catalog.ts b/src/main/runtime/browser/browser-tab-catalog.ts index aa173ff4bf46..7f3367ffdbad 100644 --- a/src/main/runtime/browser/browser-tab-catalog.ts +++ b/src/main/runtime/browser/browser-tab-catalog.ts @@ -8,6 +8,7 @@ import type { RuntimeBrowserCommandHost } from './browser-command-contracts' import type { RuntimeBrowserCommandTargets } from './browser-command-targets' +import { clientPageTabRow } from './browser-client-page-queries' export class RuntimeBrowserTabCatalog { constructor( @@ -31,23 +32,14 @@ export class RuntimeBrowserTabCatalog { active: clientPageActive ? false : tab.active })) : [] - const clientTabs = clientPages.map((page, offset) => { - const profile = + const clientTabs = clientPages.map((page, offset) => + clientPageTabRow( + page, + serverTabs.length + offset, browserSessionRegistry.getProfile(page.browserProfileId) ?? - browserSessionRegistry.getDefaultProfile() - return { - browserPageId: page.browserPageId, - index: serverTabs.length + offset, - url: page.url, - title: page.title, - active: page.active, - loadError: null, - certificateFailure: null, - worktreeId: page.workspaceId, - profileId: profile.id, - profileLabel: profile.label - } - }) + browserSessionRegistry.getDefaultProfile() + ) + ) const tabs = [...serverTabs, ...clientTabs] if (tabs.length > 0 && !tabs.some((tab) => tab.active)) { tabs[0] = { ...tabs[0]!, active: true } diff --git a/src/main/runtime/browser/browser-tab-queries.ts b/src/main/runtime/browser/browser-tab-queries.ts index dfb2eba95f20..3f628386e10f 100644 --- a/src/main/runtime/browser/browser-tab-queries.ts +++ b/src/main/runtime/browser/browser-tab-queries.ts @@ -14,6 +14,7 @@ import type { } from './browser-command-contracts' import type { RuntimeBrowserCommandTargets } from './browser-command-targets' import type { RuntimeBrowserTabCatalog } from './browser-tab-catalog' +import { activateClientPageTab } from './browser-client-page-queries' export class RuntimeBrowserTabQueries { constructor( @@ -113,15 +114,7 @@ export class RuntimeBrowserTabQueries { } const clientPage = this.host.getRuntimeBrowserPageRegistry().getPage(selected.browserPageId) if (clientPage) { - this.host - .getRuntimeBrowserPageRegistry() - .activatePage(clientPage.browserPageId, clientPage.placement) - publishSwitchedBrowserSessionTab(this.host, { - placementKind: 'client', - browserPageId: clientPage.browserPageId, - worktreeId: clientPage.workspaceId, - focus: params.focus - }) + activateClientPageTab(this.host, clientPage, params.focus) return { switched: switchedIndex, browserPageId: clientPage.browserPageId } } const bridge = this.targets.requireAgentBrowserBridge() diff --git a/src/main/runtime/orca-runtime-tests/browser-capabilities-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/browser-capabilities-part-02.spec.ts index a2a9987664b6..b674dfa291aa 100644 --- a/src/main/runtime/orca-runtime-tests/browser-capabilities-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/browser-capabilities-part-02.spec.ts @@ -10,6 +10,7 @@ import { TEST_REPO_ID, TEST_WORKTREE_ID, TEST_WORKTREE_PATH, + createRuntime, store } from '../orca-runtime-test-fixtures.spec' @@ -50,7 +51,7 @@ describe('OrcaRuntimeService', () => { isAvailable: () => false }) setRuntimeBrowserUnavailableCause({ reason: 'unconfigured' }) - const status = new OrcaRuntimeService(store as never).getStatus() + const status = createRuntime().getStatus() expect(runtimeAdvertisesBrowserClientHosting(status.capabilities)).toBe(true) expect(status.capabilities).toContain('browser.tab-create-known-id.v1') expect(status.capabilities).not.toContain('browser.headless.v1') diff --git a/src/renderer/src/lib/client-creation-action-policy.test.ts b/src/renderer/src/lib/client-creation-action-policy.test.ts index 3ed90a17c747..4eac22f5ad0d 100644 --- a/src/renderer/src/lib/client-creation-action-policy.test.ts +++ b/src/renderer/src/lib/client-creation-action-policy.test.ts @@ -4,6 +4,7 @@ import type { RuntimeStatus } from '../../../shared/runtime-types' import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../shared/execution-host' import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { BROWSER_CLIENT_HOSTING_RUNTIME_CAPABILITIES } from '../../../shared/browser-client-hosting-eligibility' +import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments' import { FLOATING_BROWSER_UNAVAILABLE_MESSAGE, LOCAL_BROWSER_UNAVAILABLE_MESSAGE, @@ -14,7 +15,8 @@ import { assertRuntimeManagedBrowserCreationAvailable, getClientCreationActionPolicy, resolveClientCreationActionPolicy, - runtimeHostsPagesOnThisDesktop + runtimeHostsPagesOnThisDesktop, + type RuntimeBrowserHostingState } from './client-creation-action-policy' function runtimeStatus(capabilities?: string[]): RuntimeStatus { @@ -213,49 +215,58 @@ describe('client creation action guards', () => { }) describe('a runtime whose pages this desktop hosts', () => { - const CLIENT_HOSTING = [...BROWSER_CLIENT_HOSTING_RUNTIME_CAPABILITIES] - function desktopState(environment: { id: string; orcadDeployment?: unknown }) { + function hostingState( + environment: Pick, + settings: RuntimeBrowserHostingState['settings'] = {} + ): RuntimeBrowserHostingState { return { - settings: { activeRuntimeEnvironmentId: environment.id }, + settings, runtimeEnvironments: [environment], runtimeStatusByEnvironmentId: new Map([ - [environment.id, { status: runtimeStatus(CLIENT_HOSTING), checkedAt: 1 }] + [ + environment.id, + { status: runtimeStatus([...BROWSER_CLIENT_HOSTING_RUNTIME_CAPABILITIES]) } + ] ]) } } it('creates runtime-placed tabs for a paired server with no browser of its own', () => { - const state = desktopState({ id: 'orcad-server' }) - expect(runtimeHostsPagesOnThisDesktop(state as never, 'orcad-server')).toBe(true) - expect(getClientCreationActionPolicy(state as never, null)['managed-browser']).toEqual({ - state: 'enabled', - provider: 'paired-runtime' - }) - expect(() => - assertRuntimeManagedBrowserCreationAvailable(state as never, 'orcad-server') - ).not.toThrow() + const state = hostingState({ id: 'orcad-server' }) + expect(runtimeHostsPagesOnThisDesktop(state, 'orcad-server')).toBe(true) + expect(() => assertRuntimeManagedBrowserCreationAvailable(state, 'orcad-server')).not.toThrow() + expect( + resolveClientCreationActionPolicy({ + surface: 'electron', + runtimeStatus: runtimeStatus(), + runtimeHostsPagesOnThisDesktop: true + })['managed-browser'] + ).toEqual({ state: 'enabled', provider: 'paired-runtime' }) }) it('keeps a managed SSH deployment on its desktop tabs and SSH browser route', () => { - const state = desktopState({ + const state = hostingState({ id: 'managed-ssh', - orcadDeployment: { kind: 'ssh', sshTargetId: 'target-1', sshTargetGeneration: 1 } - }) - expect(runtimeHostsPagesOnThisDesktop(state as never, 'managed-ssh')).toBe(false) - expect(getClientCreationActionPolicy(state as never, null)['managed-browser']).toEqual({ - state: 'enabled', - provider: 'local-client' + orcadDeployment: { + sshTargetId: 'target-1', + sshTargetGeneration: 1, + localPort: 1, + remotePort: 2 + } }) + expect(runtimeHostsPagesOnThisDesktop(state, 'managed-ssh')).toBe(false) + expect(() => assertRuntimeManagedBrowserCreationAvailable(state, 'managed-ssh')).toThrow() }) it('stays off when desktop-hosted remote pages are turned off, and in the web client', () => { - const state = desktopState({ id: 'orcad-server' }) - const disabled = { - ...state, - settings: { ...state.settings, browserClientHostedRemoteEnabled: false } - } - expect(runtimeHostsPagesOnThisDesktop(disabled as never, 'orcad-server')).toBe(false) + const disabled = hostingState( + { id: 'orcad-server' }, + { browserClientHostedRemoteEnabled: false } + ) + expect(runtimeHostsPagesOnThisDesktop(disabled, 'orcad-server')).toBe(false) vi.stubGlobal('__ORCA_WEB_CLIENT__', true) - expect(runtimeHostsPagesOnThisDesktop(state as never, 'orcad-server')).toBe(false) + expect( + runtimeHostsPagesOnThisDesktop(hostingState({ id: 'orcad-server' }), 'orcad-server') + ).toBe(false) }) }) diff --git a/src/renderer/src/lib/client-creation-action-policy.ts b/src/renderer/src/lib/client-creation-action-policy.ts index e3da640964d4..a73564a29085 100644 --- a/src/renderer/src/lib/client-creation-action-policy.ts +++ b/src/renderer/src/lib/client-creation-action-policy.ts @@ -5,7 +5,11 @@ import type { AppState } from '@/store/types' import { isPairedWebClientWindow } from './desktop-window-chrome' import { runtimeEnvironmentIdWithLegacyFallbackUnlessTwin } from './worktree-runtime-owner' import { expectsBrowserClientHosting } from '../../../shared/browser-client-hosting-eligibility' -import { getOrcadSshDeployment } from '../../../shared/runtime-environments' +import { + getOrcadSshDeployment, + type PublicKnownRuntimeEnvironment +} from '../../../shared/runtime-environments' +import type { GlobalSettings } from '../../../shared/global-settings-types' export const MANAGED_BROWSER_UNAVAILABLE_MESSAGE = "Managed browser tabs are unavailable because this web client's paired runtime does not support browser streaming." @@ -88,12 +92,22 @@ export function getClientCreationActionPolicy( }) } +/** The store fields that decide where a runtime's browser pages render. */ +export type RuntimeBrowserHostingState = { + runtimeStatusByEnvironmentId?: ReadonlyMap< + string, + { status: Pick | null } + > + runtimeEnvironments?: readonly Pick[] + settings?: Pick | null +} + /** * A runtime with no browser of its own (orcad) whose pages this desktop hosts, network tunnelled * through the runtime. A managed SSH deployment keeps desktop tabs on its SSH browser route. */ export function runtimeHostsPagesOnThisDesktop( - state: Pick, + state: RuntimeBrowserHostingState, runtimeEnvironmentId: string ): boolean { if (isPairedWebClientWindow()) { @@ -124,7 +138,7 @@ export function assertClientCreationActionAvailable( } export function assertRuntimeManagedBrowserCreationAvailable( - state: AppState, + state: RuntimeBrowserHostingState, runtimeEnvironmentId: string ): void { const capabilities = diff --git a/tests/e2e/orcad-serve-open-url-on-desktop.spec.ts b/tests/e2e/orcad-serve-open-url-on-desktop.spec.ts index 74c9f53b2292..a00e7e560d70 100644 --- a/tests/e2e/orcad-serve-open-url-on-desktop.spec.ts +++ b/tests/e2e/orcad-serve-open-url-on-desktop.spec.ts @@ -9,12 +9,12 @@ * tests/e2e/orcad-serve-open-url-on-desktop.spec.ts --config tests/playwright.config.ts \ * --project electron-headless --workers=1 */ -import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { createServer, type Server } from 'node:http' -import type { AddressInfo } from 'node:net' + import os from 'node:os' import path from 'node:path' -import type { Page } from '@stablyai/playwright-test' +import type { ElectronApplication } from '@stablyai/playwright-test' import { runProcess } from '@orca/process-host' import { RuntimeClient } from '../../src/cli/runtime/client' import { resolveBundledOrcadRuntime } from '../../src/main/orcad/orcad-bundled-runtime' @@ -50,21 +50,25 @@ async function startPageServer(): Promise<{ url: string; server: Server }> { response.end(`${MARKER}

${MARKER}

`) }) await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) - return { url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/`, server } + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('page server has no TCP address') + } + return { url: `http://127.0.0.1:${address.port}/`, server } } -async function readDesktopWebviewMarker(page: Page, url: string): Promise { - return page.evaluate(async (prefix) => { - for (const candidate of document.querySelectorAll('webview')) { - const webview = candidate as Electron.WebviewTag - try { - if (webview.getURL().startsWith(prefix)) { - return (await webview.executeJavaScript( - 'document.querySelector("#marker")?.textContent ?? null' - )) as string | null - } - } catch { - // The guest may still be attaching. +/** The marker the desktop's own browser engine rendered for `url`, read from its guest. */ +async function readDesktopPageMarker( + app: ElectronApplication, + url: string +): Promise { + return app.evaluate(async ({ webContents }, prefix) => { + for (const contents of webContents.getAllWebContents()) { + if (contents.getURL().startsWith(prefix)) { + const marker: unknown = await contents + .executeJavaScript('document.querySelector("#marker")?.textContent ?? null') + .catch(() => null) + return typeof marker === 'string' ? marker : null } } return null @@ -125,7 +129,10 @@ test('orca open-url on orcad serve opens the page in the paired desktop', async // No desktop has taken this server's pages yet: a typed refusal that says what to do. const refused = await openUrl() - testInfo.attach('open-url-before-desktop.txt', { body: refused.stdout + refused.stderr }) + writeFileSync( + testInfo.outputPath('open-url-before-desktop.txt'), + refused.stdout + refused.stderr + ) expect(refused.code).not.toBe(0) expect(refused.stdout + refused.stderr).toContain('browser_host_unavailable') @@ -161,6 +168,16 @@ test('orca open-url on orcad serve opens the page in the paired desktop', async }, { environmentId: desktop.environmentId, worktreeId } ) + await expect + .poll( + () => + desktop!.page.evaluate( + (id) => window.__store?.getState().activeGroupIdByWorktree[id] ?? null, + worktreeId + ), + { timeout: 60_000, message: 'the server workspace never got a tab group on the desktop' } + ) + .not.toBeNull() await desktop.page.evaluate(async () => { const state = window.__store?.getState() const groupId = state?.activeWorktreeId @@ -183,12 +200,12 @@ test('orca open-url on orcad serve opens the page in the paired desktop', async { timeout: 60_000, intervals: [2_000] } ) .toBe(0) - testInfo.attach('open-url-with-desktop.txt', { body: opened!.stdout + opened!.stderr }) + writeFileSync(testInfo.outputPath('open-url-with-desktop.txt'), opened!.stdout + opened!.stderr) const browserPageId = JSON.parse(opened!.stdout).result.browserPageId expect(browserPageId).toEqual(expect.any(String)) await expect - .poll(() => readDesktopWebviewMarker(desktop!.page, page.url), { + .poll(() => readDesktopPageMarker(desktop!.app, page.url), { timeout: 60_000, message: "the desktop never rendered the server's loopback page" }) @@ -210,10 +227,25 @@ test('orca open-url on orcad serve opens the page in the paired desktop', async { remotePageId: browserPageId, worktreeId } ) expect(placement).toBe('client') - await testInfo.attach('desktop-with-open-url-page.png', { - body: await desktop.page.screenshot(), - contentType: 'image/png' + await desktop.page.screenshot({ path: testInfo.outputPath('desktop-with-open-url-page.png') }) + const tabs = await runProcess({ + program: process.execPath, + args: [ + path.join(process.cwd(), 'out', 'cli', 'index.js'), + 'tab', + 'list', + '--worktree', + worktree, + '--json' + ], + env: { ...profile.env, ORCA_USER_DATA: profile.userDataDir }, + timeoutMs: 30_000 }) + writeFileSync(testInfo.outputPath('tab-list-with-desktop.txt'), tabs.stdout + tabs.stderr) + expect(tabs.code, tabs.stderr).toBe(0) + expect(JSON.parse(tabs.stdout).result.tabs).toContainEqual( + expect.objectContaining({ browserPageId, url: page.url }) + ) } finally { await desktop?.dispose() await new Promise((settle) => page.server.close(settle)) From bca1dcda9d0fee87398bebb06d0626ca6fd6e108 Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 19 Oct 2026 08:04:17 -0700 Subject: [PATCH 04/11] wip: one logical tab list for orcad engine tabs and desktop-hosted pages --- src/main/orcad/orcad-browser-startup.ts | 4 +- .../orcad/orcad-client-hosted-browser.test.ts | 82 ++++++++++++++++++- src/main/orcad/orcad-client-page-commands.ts | 77 +++++++++++++---- .../browser/browser-client-page-close.ts | 46 +++++++++++ .../browser/browser-client-page-queries.ts | 77 +++++++++++++---- src/main/runtime/browser/browser-commands.ts | 2 +- src/main/runtime/browser/browser-tab-close.ts | 26 ++---- 7 files changed, 259 insertions(+), 55 deletions(-) diff --git a/src/main/orcad/orcad-browser-startup.ts b/src/main/orcad/orcad-browser-startup.ts index 83c018b7179c..5e2c13ded645 100644 --- a/src/main/orcad/orcad-browser-startup.ts +++ b/src/main/orcad/orcad-browser-startup.ts @@ -32,12 +32,12 @@ export function startOrcadBrowserProvider(options: OrcadBrowserProviderOptions): if (property === 'then' || typeof property !== 'string') { return undefined } - const forward = (args: unknown[]): unknown => { + const forward = (args: unknown[], name: string = property): unknown => { if (!engineAvailable() || !provider) { throw browserUnavailableError() } commands ??= provider.factory(host) - return callBrowserCommand(commands, property, args) + return callBrowserCommand(commands, name, args) } return ( orcadClientPageCommand(host, property, forward, engineAvailable) ?? diff --git a/src/main/orcad/orcad-client-hosted-browser.test.ts b/src/main/orcad/orcad-client-hosted-browser.test.ts index 05c8fd7b2f6e..a63ee64cafd7 100644 --- a/src/main/orcad/orcad-client-hosted-browser.test.ts +++ b/src/main/orcad/orcad-client-hosted-browser.test.ts @@ -64,7 +64,9 @@ function pairedHost(options: { desktopAttached: boolean }) { authorityEpoch: 'epoch-a', select, createClientPage, - issueClientPageCommand + issueClientPageCommand, + // The desktop already let go of the page, so retiring it is only the runtime's record. + getPlacement: () => undefined }), markHeadlessBrowserSessionTabActive: vi.fn(), notifyHeadlessBrowserSessionTabsChanged @@ -208,3 +210,81 @@ it('advertises client hosting with no engine and keeps the engine’s own cause' expect(runtimeBrowserUnavailableCause()).toEqual({ reason: 'unconfigured' }) await startup.stop() }) + +/** orcad with a working engine that holds one tab of its own. */ +async function startWithEngine() { + const engineTab = { browserPageId: 'engine-page', index: 0, url: '', title: '', active: true } + const engine = { + browserTabList: vi.fn(async () => ({ tabs: [engineTab] })), + browserTabSwitch: vi.fn(async () => ({ switched: 0, browserPageId: 'engine-page' })), + browserTabClose: vi.fn(async () => ({ closed: true })) + } + vi.mocked(resolveOrcadBrowserProvider).mockResolvedValueOnce({ + kind: 'chromium', + factory: () => Object.assign(Object.create(null), engine), + isAvailable: () => true, + stop: async () => {} + }) + const startup = startOrcadBrowserProvider({ userDataPath: '/private-fixture' }) + await startup.ready + return { startup, engine } +} + +it('lists desktop-hosted pages after the engine’s tabs, and switches back to an engine tab', async () => { + const { startup, engine } = await startWithEngine() + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + const opened = await commands.browserOpenUrlOnClient({ + url: 'https://example.org/', + worktree: 'id:wt-1' + }) + + const listed = await commands.browserTabList({ worktree: 'id:wt-1' }) + expect(listed.tabs.map((tab) => [tab.browserPageId, tab.index, tab.active])).toEqual([ + ['engine-page', 0, false], + [opened.browserPageId, 1, true] + ]) + await expect(commands.browserTabSwitch({ index: 0, worktree: 'id:wt-1' })).resolves.toEqual({ + switched: 0, + browserPageId: 'engine-page' + }) + expect(engine.browserTabSwitch).toHaveBeenCalledWith({ page: 'engine-page', worktree: 'id:wt-1' }) + expect(paired.pages.getPage(opened.browserPageId)?.active).toBe(false) + await startup.stop() +}) + +it('closes the current desktop-hosted page itself instead of the engine’s current tab', async () => { + const { startup, engine } = await startWithEngine() + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + const opened = await commands.browserOpenUrlOnClient({ + url: 'https://example.org/', + worktree: 'id:wt-1' + }) + + await expect(commands.browserTabClose({ worktree: 'id:wt-1' })).resolves.toEqual({ closed: true }) + expect(paired.pages.getPage(opened.browserPageId)).toBeUndefined() + expect(engine.browserTabClose).not.toHaveBeenCalled() + // With the page gone, the engine's own current tab is the target again. + await commands.browserTabClose({ worktree: 'id:wt-1' }) + expect(engine.browserTabClose).toHaveBeenCalledWith({ worktree: 'id:wt-1' }) + await startup.stop() +}) + +it('closes a desktop-hosted page by index with no engine', async () => { + const startup = await startWithoutEngine() + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + await commands.browserOpenUrlOnClient({ url: 'https://example.org/a', worktree: 'id:wt-1' }) + const second = await commands.browserOpenUrlOnClient({ + url: 'https://example.org/b', + worktree: 'id:wt-1' + }) + + await expect(commands.browserTabClose({ index: 1, worktree: 'id:wt-1' })).resolves.toEqual({ + closed: true + }) + expect(paired.pages.getPage(second.browserPageId)).toBeUndefined() + expect(paired.pages.listPages('wt-1')).toHaveLength(1) + await startup.stop() +}) diff --git a/src/main/orcad/orcad-client-page-commands.ts b/src/main/orcad/orcad-client-page-commands.ts index a295b4f56d02..8e724e3855a1 100644 --- a/src/main/orcad/orcad-client-page-commands.ts +++ b/src/main/orcad/orcad-client-page-commands.ts @@ -8,7 +8,8 @@ import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-comma import { browserUnavailableError } from '../runtime/runtime-browser-commands-factory' import { closeBrowserPageWithoutEngine, - closeRegisteredBrowserClientPage + resolveClientPageToClose, + retireRuntimeBrowserClientPage } from '../runtime/browser/browser-client-page-close' import { createBrowserClientPage, @@ -17,9 +18,14 @@ import { hasPairedPageHost, openBrowserUrlOnClient } from '../runtime/browser/browser-client-page-opening' -import { BrowserClientPageQueries } from '../runtime/browser/browser-client-page-queries' +import { + BrowserClientPageQueries, + type BrowserEngineTabs +} from '../runtime/browser/browser-client-page-queries' +import type { BrowserTabListResult, BrowserTabSwitchResult } from '../../shared/runtime-types' -type Forward = (args: unknown[]) => unknown +/** Sends a call to the engine; `name` defaults to the member the proxy was asked for. */ +type Forward = (args: unknown[], name?: string) => unknown const ENGINE_FREE_TAB_QUERIES = [ 'browserTabList', @@ -43,26 +49,71 @@ export function orcadClientPageCommand( return (...args: Parameters) => createTab(host, args, forward, engineAvailable) } + const logicalTabs = (): BrowserClientPageQueries => + new BrowserClientPageQueries( + host, + orcadProfileLabel, + engineAvailable() ? engineTabs(forward) : null + ) if (name === 'browserTabClose') { return async (...args: Parameters) => { const params = args[0] ?? {} - if (!engineAvailable()) { - return closeBrowserPageWithoutEngine(host, params, browserUnavailableError) + // Why the whole logical list: an index or the current tab may name a desktop-hosted page. + const clientPage = await resolveClientPageToClose(host, logicalTabs(), params) + if (clientPage) { + return retireRuntimeBrowserClientPage(host, clientPage) } - return (await closeRegisteredBrowserClientPage(host, params)) ?? forward(args) + return engineAvailable() + ? forward(args) + : closeBrowserPageWithoutEngine(host, params, browserUnavailableError) } } const query = ENGINE_FREE_TAB_QUERIES.find((candidate) => candidate === name) if (query) { - // Why only without an engine: then the desktop-hosted pages are every tab this host has. - return (...args: unknown[]) => - engineAvailable() || !servesClientPages(host) - ? forward(args) - : callTabQuery(new BrowserClientPageQueries(host, orcadProfileLabel), query, args) + return (...args: unknown[]) => { + const hasClientPages = host.getRuntimeBrowserPageRegistry().listPages().length > 0 + // Why: with nothing desktop-hosted, the engine's answer (or its refusal) is the whole answer. + if (!hasClientPages && (engineAvailable() || !hasPairedPageHost(host))) { + return forward(args) + } + return callTabQuery(logicalTabs(), query, args) + } } return undefined } +/** The engine's own tabs, read back through the same forward the proxy uses. */ +function engineTabs(forward: Forward): BrowserEngineTabs { + return { + list: async (params) => + requireTabResult(await forward([params], 'browserTabList'), isTabListResult), + switchTo: async (params) => + requireTabResult(await forward([params], 'browserTabSwitch'), isTabSwitchResult) + } +} + +function requireTabResult(value: unknown, guard: (candidate: unknown) => candidate is T): T { + if (!guard(value)) { + throw new BrowserError('browser_error', 'The browser engine returned an unreadable tab result.') + } + return value +} + +function isTabListResult(value: unknown): value is BrowserTabListResult { + return ( + isRecord(value) && + Array.isArray(value.tabs) && + value.tabs.every( + (tab: unknown) => + isRecord(tab) && typeof tab.browserPageId === 'string' && typeof tab.active === 'boolean' + ) + ) +} + +function isTabSwitchResult(value: unknown): value is BrowserTabSwitchResult { + return isRecord(value) && typeof value.browserPageId === 'string' +} + function createTab( host: RuntimeBrowserCommandHost, [params, caller]: Parameters, @@ -98,10 +149,6 @@ function createTab( return forward([params, caller]) } -function servesClientPages(host: RuntimeBrowserCommandHost): boolean { - return hasPairedPageHost(host) || host.getRuntimeBrowserPageRegistry().listPages().length > 0 -} - function callTabQuery( queries: BrowserClientPageQueries, name: (typeof ENGINE_FREE_TAB_QUERIES)[number], diff --git a/src/main/runtime/browser/browser-client-page-close.ts b/src/main/runtime/browser/browser-client-page-close.ts index 8ed20d1a631a..ad45e8a4a3ab 100644 --- a/src/main/runtime/browser/browser-client-page-close.ts +++ b/src/main/runtime/browser/browser-client-page-close.ts @@ -41,6 +41,52 @@ export async function retireRuntimeBrowserClientPage( return { closed: true } } +type LogicalTabQueries = { + browserTabList(params: { worktree?: string }): Promise<{ tabs: { browserPageId: string }[] }> + browserTabCurrent(params: { worktree?: string }): Promise<{ tab: { browserPageId: string } }> +} + +/** + * The client-hosted page a close names, by id, index or as the current tab; undefined when the + * target is not one. Index and current resolve against the host's whole logical tab list, so a + * client page that leads it is never mistaken for an engine tab. + */ +export async function resolveClientPageToClose( + host: Pick< + BrowserClientPageCloseHost, + 'getRuntimeBrowserPageRegistry' | 'resolveBrowserWorkspace' + >, + queries: LogicalTabQueries, + params: PageCloseParams & { index?: number } +): Promise { + const pages = host.getRuntimeBrowserPageRegistry() + if (params.page) { + const page = pages.getPage(params.page) + if ( + page && + params.worktree && + (await host.resolveBrowserWorkspace(params.worktree)).id !== page.workspaceId + ) { + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${page.browserPageId} was not found in this worktree` + ) + } + return page + } + const workspaceId = params.worktree + ? (await host.resolveBrowserWorkspace(params.worktree)).id + : undefined + if (pages.listPages(workspaceId).length === 0) { + return undefined + } + const tab = + params.index !== undefined + ? (await queries.browserTabList({ worktree: params.worktree })).tabs[params.index] + : (await queries.browserTabCurrent({ worktree: params.worktree })).tab + return tab ? pages.getPage(tab.browserPageId) : undefined +} + /** Closes a named page this runtime's registry holds; null when the registry has no such page. */ export async function closeRegisteredBrowserClientPage( host: BrowserClientPageCloseHost, diff --git a/src/main/runtime/browser/browser-client-page-queries.ts b/src/main/runtime/browser/browser-client-page-queries.ts index 0b5ebaa382ec..040abd48c621 100644 --- a/src/main/runtime/browser/browser-client-page-queries.ts +++ b/src/main/runtime/browser/browser-client-page-queries.ts @@ -41,11 +41,22 @@ export type BrowserClientPageQueryHost = Pick< | 'notifyHeadlessBrowserSessionTabsChanged' > -/** Tab commands over client-hosted pages only, for a host whose engine holds no other tabs. */ +/** The tabs a host's own engine holds, beside the pages desktops host for it; null without one. */ +export type BrowserEngineTabs = { + list(params: { worktree?: string }): Promise + switchTo(params: { + page: string + worktree?: string + focus?: boolean + }): Promise +} + +/** Tab commands over a host's engine tabs, if any, followed by its client-hosted pages. */ export class BrowserClientPageQueries { constructor( private readonly host: BrowserClientPageQueryHost, - private readonly profileFor: (browserProfileId: string) => BrowserProfileLabel + private readonly profileFor: (browserProfileId: string) => BrowserProfileLabel, + private readonly engine: BrowserEngineTabs | null ) {} async browserTabList(params: { worktree?: string }): Promise { @@ -53,13 +64,23 @@ export class BrowserClientPageQueries { ? (await this.host.resolveBrowserWorkspace(params.worktree)).id : undefined const pages = this.host.getRuntimeBrowserPageRegistry().listPages(workspaceId) - const tabs = pages.map((page, index) => - clientPageTabRow(page, index, this.profileFor(page.browserProfileId)) - ) + const clientPageActive = pages.some((page) => page.active) + const engineTabs = this.engine + ? (await this.engine.list(params)).tabs.map((tab) => ({ + ...tab, + active: clientPageActive ? false : tab.active + })) + : [] + const tabs = [ + ...engineTabs, + ...pages.map((page, offset) => + clientPageTabRow(page, engineTabs.length + offset, this.profileFor(page.browserProfileId)) + ) + ] if (tabs.length > 0 && !tabs.some((tab) => tab.active)) { tabs[0] = { ...tabs[0]!, active: true } } - return { tabs } + return { tabs: tabs.map((tab, index) => ({ ...tab, index })) } } async browserTabCurrent(params: { worktree?: string }): Promise { @@ -72,11 +93,10 @@ export class BrowserClientPageQueries { async browserTabShow(params: { page: string; worktree?: string }): Promise { const page = this.host.getRuntimeBrowserPageRegistry().getPage(params.page) - const tab = page - ? ( - await this.browserTabList({ worktree: params.worktree ?? `id:${page.workspaceId}` }) - ).tabs.find((candidate) => candidate.browserPageId === page.browserPageId) - : undefined + const worktree = params.worktree ?? (page ? `id:${page.workspaceId}` : undefined) + const tab = (await this.browserTabList({ worktree })).tabs.find( + (candidate) => candidate.browserPageId === params.page + ) if (!tab) { throw new BrowserError('browser_tab_not_found', `Browser page ${params.page} was not found`) } @@ -93,18 +113,43 @@ export class BrowserClientPageQueries { const switched = params.page ? listed.tabs.findIndex((tab) => tab.browserPageId === params.page) : (params.index ?? -1) - const pages = this.host.getRuntimeBrowserPageRegistry() const selected = listed.tabs[switched] - const page = selected ? pages.getPage(selected.browserPageId) : undefined - if (!page) { + if (!selected) { const label = params.page ? `Browser page ${params.page}` : `Tab index ${params.index}` throw new BrowserError( 'browser_tab_not_found', `${label} out of range (0-${listed.tabs.length - 1})` ) } - activateClientPageTab(this.host, page, params.focus) - return { switched, browserPageId: page.browserPageId } + const pages = this.host.getRuntimeBrowserPageRegistry() + const page = pages.getPage(selected.browserPageId) + if (page) { + activateClientPageTab(this.host, page, params.focus) + return { switched, browserPageId: page.browserPageId } + } + if (!this.engine) { + throw new BrowserError( + 'browser_tab_not_found', + `Browser page ${selected.browserPageId} was not found` + ) + } + const result = await this.engine.switchTo({ + page: selected.browserPageId, + ...(params.worktree ? { worktree: params.worktree } : {}), + ...(params.focus !== undefined ? { focus: params.focus } : {}) + }) + // Why: an engine tab now leads, so no client page may still claim to be active beside it. + pages.deactivateGlobal() + const workspaceId = + typeof selected.worktreeId === 'string' + ? selected.worktreeId + : params.worktree + ? (await this.host.resolveBrowserWorkspace(params.worktree)).id + : undefined + if (workspaceId) { + pages.deactivateWorkspace(workspaceId) + } + return { switched, browserPageId: result.browserPageId } } } diff --git a/src/main/runtime/browser/browser-commands.ts b/src/main/runtime/browser/browser-commands.ts index 5fbcd73f5f0a..b4ce955719f7 100644 --- a/src/main/runtime/browser/browser-commands.ts +++ b/src/main/runtime/browser/browser-commands.ts @@ -110,7 +110,7 @@ export class RuntimeBrowserCommands { const storage = new RuntimeBrowserStorage(targets) const queries = new RuntimeBrowserTabQueries(host, targets, catalog) const creation = new RuntimeBrowserTabCreation(host, targets) - const close = new RuntimeBrowserTabClose(queries, host, targets, catalog) + const close = new RuntimeBrowserTabClose(queries, host, targets) const profiles = new RuntimeBrowserProfiles(host, targets, catalog) const screencasts = new RuntimeBrowserScreencasts(host, targets, catalog) const dialogs = new RuntimeBrowserDialogs(targets, screencasts) diff --git a/src/main/runtime/browser/browser-tab-close.ts b/src/main/runtime/browser/browser-tab-close.ts index b89dad5fd3a6..c9fcbb47b7a7 100644 --- a/src/main/runtime/browser/browser-tab-close.ts +++ b/src/main/runtime/browser/browser-tab-close.ts @@ -1,11 +1,13 @@ import type { RuntimeBrowserTabQueries } from './browser-tab-queries' -import { retireRuntimeBrowserClientPage } from './browser-client-page-close' +import { + resolveClientPageToClose, + retireRuntimeBrowserClientPage +} from './browser-client-page-close' import { BrowserError } from '../../browser/browser-error' import { randomUUID } from 'node:crypto' import { ipcMain } from 'electron' import type { RuntimeBrowserCommandHost } from './browser-command-contracts' import type { RuntimeBrowserCommandTargets } from './browser-command-targets' -import type { RuntimeBrowserTabCatalog } from './browser-tab-catalog' export class RuntimeBrowserTabClose { constructor( @@ -29,8 +31,7 @@ export class RuntimeBrowserTabClose { private readonly targets: Pick< RuntimeBrowserCommandTargets, 'resolveBrowserWorktreeId' | 'retireGhostBrowserSessionRow' - >, - private readonly catalog: Pick + > ) {} async browserTabClose(params: { @@ -38,22 +39,7 @@ export class RuntimeBrowserTabClose { page?: string worktree?: string }): Promise<{ closed: boolean }> { - const pages = this.host.getRuntimeBrowserPageRegistry() - let clientPage = params.page ? pages.getPage(params.page) : undefined - if (clientPage) { - await this.catalog.assertClientPageWorkspace(clientPage, params.worktree) - } else if (!params.page) { - const workspaceId = params.worktree - ? (await this.host.resolveBrowserWorkspace(params.worktree)).id - : undefined - if (pages.listPages(workspaceId).length > 0) { - const tab = - params.index !== undefined - ? (await this.queries.browserTabList({ worktree: params.worktree })).tabs[params.index] - : (await this.queries.browserTabCurrent({ worktree: params.worktree })).tab - clientPage = tab ? pages.getPage(tab.browserPageId) : undefined - } - } + const clientPage = await resolveClientPageToClose(this.host, this.queries, params) if (clientPage) { return retireRuntimeBrowserClientPage(this.host, clientPage) } From eef43ed5b8eb238bbca25e1867c1fb609cb4c75d Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 19 Oct 2026 08:13:07 -0700 Subject: [PATCH 05/11] wip: one selection rule for engine tabs beside desktop-hosted pages --- .../orcad/orcad-client-hosted-browser.test.ts | 21 ++++++++++++++-- src/main/orcad/orcad-client-page-commands.ts | 16 +++++++++++- .../browser/browser-client-page-queries.ts | 25 ++++++++++++++----- .../runtime/browser/browser-tab-queries.ts | 7 ++---- .../orcad-serve-open-url-on-desktop.spec.ts | 14 ++++++++--- 5 files changed, 66 insertions(+), 17 deletions(-) diff --git a/src/main/orcad/orcad-client-hosted-browser.test.ts b/src/main/orcad/orcad-client-hosted-browser.test.ts index a63ee64cafd7..711b4bd86353 100644 --- a/src/main/orcad/orcad-client-hosted-browser.test.ts +++ b/src/main/orcad/orcad-client-hosted-browser.test.ts @@ -155,9 +155,9 @@ it('sends the CLI’s unplaced tab to the paired desktop, and refuses as before }) expect(paired.pages.getPage(created.browserPageId)?.placement).toEqual(PLACEMENT) // A paired client that left placement out asked for a server page, which this host cannot make. - expect(() => + await expect( commands.browserTabCreate({ worktree: 'id:wt-1' }, { pairedDeviceId: 'device-a' }) - ).toThrow(/unavailable/) + ).rejects.toThrow(/unavailable/) const unattached = createRuntimeBrowserCommands(pairedHost({ desktopAttached: false }).host) expect(() => unattached.browserTabCreate({ worktree: 'id:wt-1' })).toThrow(/unavailable/) @@ -271,6 +271,23 @@ it('closes the current desktop-hosted page itself instead of the engine’s curr await startup.stop() }) +it('hands the workspace’s selection to a new engine tab, so later commands follow it', async () => { + const { startup, engine } = await startWithEngine() + const browserTabCreate = vi.fn(async () => ({ browserPageId: 'engine-page' })) + Object.assign(engine, { browserTabCreate }) + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + const opened = await commands.browserOpenUrlOnClient({ + url: 'https://example.org/', + worktree: 'id:wt-1' + }) + + await commands.browserTabCreate({ worktree: 'id:wt-1', url: 'https://engine.test/' }) + expect(browserTabCreate).toHaveBeenCalled() + expect(paired.pages.getPage(opened.browserPageId)?.active).toBe(false) + await startup.stop() +}) + it('closes a desktop-hosted page by index with no engine', async () => { const startup = await startWithoutEngine() const paired = pairedHost({ desktopAttached: true }) diff --git a/src/main/orcad/orcad-client-page-commands.ts b/src/main/orcad/orcad-client-page-commands.ts index 8e724e3855a1..99320dd033f4 100644 --- a/src/main/orcad/orcad-client-page-commands.ts +++ b/src/main/orcad/orcad-client-page-commands.ts @@ -20,8 +20,10 @@ import { } from '../runtime/browser/browser-client-page-opening' import { BrowserClientPageQueries, + yieldClientPageSelection, type BrowserEngineTabs } from '../runtime/browser/browser-client-page-queries' +import { browserTabCreateClientPageStartsActive } from '../runtime/browser-tab-create-publication' import type { BrowserTabListResult, BrowserTabSwitchResult } from '../../shared/runtime-types' /** Sends a call to the engine; `name` defaults to the member the proxy was asked for. */ @@ -146,7 +148,19 @@ function createTab( orcadBrowserProfileId(params?.profileId) ) } - return forward([params, caller]) + if (host.getRuntimeBrowserPageRegistry().listPages().length === 0) { + return forward([params, caller]) + } + return (async () => { + const created = await forward([params, caller]) + if (browserTabCreateClientPageStartsActive(params?.activate)) { + yieldClientPageSelection( + host, + params?.worktree ? (await host.resolveBrowserWorkspace(params.worktree)).id : undefined + ) + } + return created + })() } function callTabQuery( diff --git a/src/main/runtime/browser/browser-client-page-queries.ts b/src/main/runtime/browser/browser-client-page-queries.ts index 040abd48c621..f6baff69d157 100644 --- a/src/main/runtime/browser/browser-client-page-queries.ts +++ b/src/main/runtime/browser/browser-client-page-queries.ts @@ -138,21 +138,34 @@ export class BrowserClientPageQueries { ...(params.worktree ? { worktree: params.worktree } : {}), ...(params.focus !== undefined ? { focus: params.focus } : {}) }) - // Why: an engine tab now leads, so no client page may still claim to be active beside it. - pages.deactivateGlobal() - const workspaceId = + yieldClientPageSelection( + this.host, typeof selected.worktreeId === 'string' ? selected.worktreeId : params.worktree ? (await this.host.resolveBrowserWorkspace(params.worktree)).id : undefined - if (workspaceId) { - pages.deactivateWorkspace(workspaceId) - } + ) return { switched, browserPageId: result.browserPageId } } } +/** + * The one rule for a workspace's active tab across engine tabs and client-hosted pages: once an + * engine tab is created or selected, no client page may still claim to be active, or workspace + * commands without --page would keep going to the old client page. + */ +export function yieldClientPageSelection( + host: Pick, + workspaceId: string | undefined +): void { + const pages = host.getRuntimeBrowserPageRegistry() + pages.deactivateGlobal() + if (workspaceId) { + pages.deactivateWorkspace(workspaceId) + } +} + /** Makes a client-hosted page its workspace's active tab and publishes the switch. */ export function activateClientPageTab( host: BrowserClientPageQueryHost, diff --git a/src/main/runtime/browser/browser-tab-queries.ts b/src/main/runtime/browser/browser-tab-queries.ts index 3f628386e10f..8ed807938c12 100644 --- a/src/main/runtime/browser/browser-tab-queries.ts +++ b/src/main/runtime/browser/browser-tab-queries.ts @@ -14,7 +14,7 @@ import type { } from './browser-command-contracts' import type { RuntimeBrowserCommandTargets } from './browser-command-targets' import type { RuntimeBrowserTabCatalog } from './browser-tab-catalog' -import { activateClientPageTab } from './browser-client-page-queries' +import { activateClientPageTab, yieldClientPageSelection } from './browser-client-page-queries' export class RuntimeBrowserTabQueries { constructor( @@ -125,10 +125,7 @@ export class RuntimeBrowserTabQueries { ? (await this.host.resolveBrowserWorkspace(params.worktree)).id : undefined const result = await bridge.tabSwitch(undefined, worktreeId, selected.browserPageId) - this.host.getRuntimeBrowserPageRegistry().deactivateGlobal() - if (worktreeId) { - this.host.getRuntimeBrowserPageRegistry().deactivateWorkspace(worktreeId) - } + yieldClientPageSelection(this.host, worktreeId) // Why: scope focus to the tab's owning worktree; the renderer never yanks the user across worktrees on this signal (see focusBrowserTabInWorktree). const focusWorktreeId = worktreeId ?? browserManager.getWorktreeIdForTab(result.browserPageId) ?? undefined diff --git a/tests/e2e/orcad-serve-open-url-on-desktop.spec.ts b/tests/e2e/orcad-serve-open-url-on-desktop.spec.ts index a00e7e560d70..fd040ff0de4a 100644 --- a/tests/e2e/orcad-serve-open-url-on-desktop.spec.ts +++ b/tests/e2e/orcad-serve-open-url-on-desktop.spec.ts @@ -211,14 +211,14 @@ test('orca open-url on orcad serve opens the page in the paired desktop', async }) .toBe(MARKER) // Client placement: the server owns the page and the desktop renders it. - const placement = await desktop.page.evaluate( + const mirrored = await desktop.page.evaluate( ({ remotePageId, worktreeId }) => { const state = window.__store?.getState() for (const workspace of state?.browserTabsByWorktree[worktreeId] ?? []) { for (const browserPage of state?.browserPagesByWorkspace[workspace.id] ?? []) { const handle = state?.remoteBrowserPageHandlesByPageId[browserPage.id] if ((handle?.remotePageId ?? browserPage.id) === remotePageId) { - return handle?.placement?.kind ?? null + return { localPageId: browserPage.id, placement: handle?.placement?.kind ?? null } } } } @@ -226,7 +226,15 @@ test('orca open-url on orcad serve opens the page in the paired desktop', async }, { remotePageId: browserPageId, worktreeId } ) - expect(placement).toBe('client') + expect(mirrored?.placement).toBe('client') + // Only for the evidence screenshot: bring the page's pane forward inside the hidden window. + await desktop.page.evaluate( + ({ localPageId, worktreeId }) => + window.__store + ?.getState() + .focusBrowserTabInWorktree(worktreeId, localPageId, { surfacePane: true }), + { localPageId: mirrored?.localPageId ?? '', worktreeId } + ) await desktop.page.screenshot({ path: testInfo.outputPath('desktop-with-open-url-page.png') }) const tabs = await runProcess({ program: process.execPath, From 5fbfce3df0a4b966892905a9e3f87fd1d550c2c0 Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 19 Oct 2026 08:23:52 -0700 Subject: [PATCH 06/11] refactor: one owner per mode for orcad tab selection; hide reopen-on-server without a server browser --- src/main/orcad/orcad-browser-startup.ts | 4 +- .../orcad/orcad-client-hosted-browser.test.ts | 51 +----- src/main/orcad/orcad-client-page-commands.ts | 138 +++++++---------- .../browser/browser-client-page-queries.ts | 145 +++++------------- .../runtime/browser/browser-tab-catalog.ts | 24 ++- .../runtime/browser/browser-tab-queries.ts | 16 +- ...hosted-browser-unavailable-notice.test.tsx | 22 +++ ...ient-hosted-browser-unavailable-notice.tsx | 23 ++- 8 files changed, 167 insertions(+), 256 deletions(-) diff --git a/src/main/orcad/orcad-browser-startup.ts b/src/main/orcad/orcad-browser-startup.ts index 5e2c13ded645..83c018b7179c 100644 --- a/src/main/orcad/orcad-browser-startup.ts +++ b/src/main/orcad/orcad-browser-startup.ts @@ -32,12 +32,12 @@ export function startOrcadBrowserProvider(options: OrcadBrowserProviderOptions): if (property === 'then' || typeof property !== 'string') { return undefined } - const forward = (args: unknown[], name: string = property): unknown => { + const forward = (args: unknown[]): unknown => { if (!engineAvailable() || !provider) { throw browserUnavailableError() } commands ??= provider.factory(host) - return callBrowserCommand(commands, name, args) + return callBrowserCommand(commands, property, args) } return ( orcadClientPageCommand(host, property, forward, engineAvailable) ?? diff --git a/src/main/orcad/orcad-client-hosted-browser.test.ts b/src/main/orcad/orcad-client-hosted-browser.test.ts index 711b4bd86353..175706378d89 100644 --- a/src/main/orcad/orcad-client-hosted-browser.test.ts +++ b/src/main/orcad/orcad-client-hosted-browser.test.ts @@ -155,9 +155,9 @@ it('sends the CLI’s unplaced tab to the paired desktop, and refuses as before }) expect(paired.pages.getPage(created.browserPageId)?.placement).toEqual(PLACEMENT) // A paired client that left placement out asked for a server page, which this host cannot make. - await expect( + expect(() => commands.browserTabCreate({ worktree: 'id:wt-1' }, { pairedDeviceId: 'device-a' }) - ).rejects.toThrow(/unavailable/) + ).toThrow(/unavailable/) const unattached = createRuntimeBrowserCommands(pairedHost({ desktopAttached: false }).host) expect(() => unattached.browserTabCreate({ worktree: 'id:wt-1' })).toThrow(/unavailable/) @@ -192,6 +192,12 @@ it('lists, shows and switches desktop-hosted tabs with no engine', async () => { await expect(commands.browserTabShow({ page: second.browserPageId })).resolves.toMatchObject({ tab: { url: 'https://example.org/b' } }) + // With the current page closed none is selected, exactly as workspace automation sees it. + await commands.browserTabClose({ worktree: 'id:wt-1' }) + expect(paired.pages.getPage(first.browserPageId)).toBeUndefined() + await expect(commands.browserTabCurrent({ worktree: 'id:wt-1' })).rejects.toMatchObject({ + code: 'browser_no_tab' + }) await startup.stop() }) @@ -216,7 +222,6 @@ async function startWithEngine() { const engineTab = { browserPageId: 'engine-page', index: 0, url: '', title: '', active: true } const engine = { browserTabList: vi.fn(async () => ({ tabs: [engineTab] })), - browserTabSwitch: vi.fn(async () => ({ switched: 0, browserPageId: 'engine-page' })), browserTabClose: vi.fn(async () => ({ closed: true })) } vi.mocked(resolveOrcadBrowserProvider).mockResolvedValueOnce({ @@ -230,29 +235,6 @@ async function startWithEngine() { return { startup, engine } } -it('lists desktop-hosted pages after the engine’s tabs, and switches back to an engine tab', async () => { - const { startup, engine } = await startWithEngine() - const paired = pairedHost({ desktopAttached: true }) - const commands = createRuntimeBrowserCommands(paired.host) - const opened = await commands.browserOpenUrlOnClient({ - url: 'https://example.org/', - worktree: 'id:wt-1' - }) - - const listed = await commands.browserTabList({ worktree: 'id:wt-1' }) - expect(listed.tabs.map((tab) => [tab.browserPageId, tab.index, tab.active])).toEqual([ - ['engine-page', 0, false], - [opened.browserPageId, 1, true] - ]) - await expect(commands.browserTabSwitch({ index: 0, worktree: 'id:wt-1' })).resolves.toEqual({ - switched: 0, - browserPageId: 'engine-page' - }) - expect(engine.browserTabSwitch).toHaveBeenCalledWith({ page: 'engine-page', worktree: 'id:wt-1' }) - expect(paired.pages.getPage(opened.browserPageId)?.active).toBe(false) - await startup.stop() -}) - it('closes the current desktop-hosted page itself instead of the engine’s current tab', async () => { const { startup, engine } = await startWithEngine() const paired = pairedHost({ desktopAttached: true }) @@ -271,23 +253,6 @@ it('closes the current desktop-hosted page itself instead of the engine’s curr await startup.stop() }) -it('hands the workspace’s selection to a new engine tab, so later commands follow it', async () => { - const { startup, engine } = await startWithEngine() - const browserTabCreate = vi.fn(async () => ({ browserPageId: 'engine-page' })) - Object.assign(engine, { browserTabCreate }) - const paired = pairedHost({ desktopAttached: true }) - const commands = createRuntimeBrowserCommands(paired.host) - const opened = await commands.browserOpenUrlOnClient({ - url: 'https://example.org/', - worktree: 'id:wt-1' - }) - - await commands.browserTabCreate({ worktree: 'id:wt-1', url: 'https://engine.test/' }) - expect(browserTabCreate).toHaveBeenCalled() - expect(paired.pages.getPage(opened.browserPageId)?.active).toBe(false) - await startup.stop() -}) - it('closes a desktop-hosted page by index with no engine', async () => { const startup = await startWithoutEngine() const paired = pairedHost({ desktopAttached: true }) diff --git a/src/main/orcad/orcad-client-page-commands.ts b/src/main/orcad/orcad-client-page-commands.ts index 99320dd033f4..1e63b294d9e9 100644 --- a/src/main/orcad/orcad-client-page-commands.ts +++ b/src/main/orcad/orcad-client-page-commands.ts @@ -1,6 +1,8 @@ /** - * The browser commands orcad serves itself: pages a paired desktop hosts for it live in this - * runtime's lease and page registries, so they need no browser engine on this host. + * The browser commands orcad answers itself. Pages a paired desktop hosts for it live in this + * runtime's lease and page registries, so opening one never needs a browser engine here. With no + * engine those pages are every tab this host has, so the tab commands answer from the registry. + * With an engine, tab commands stay the engine's, as before. */ import { BrowserError } from '../browser/browser-error' import type { RuntimeBrowserCommands } from '../runtime/browser/browser-commands' @@ -8,6 +10,7 @@ import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-comma import { browserUnavailableError } from '../runtime/runtime-browser-commands-factory' import { closeBrowserPageWithoutEngine, + closeRegisteredBrowserClientPage, resolveClientPageToClose, retireRuntimeBrowserClientPage } from '../runtime/browser/browser-client-page-close' @@ -18,18 +21,12 @@ import { hasPairedPageHost, openBrowserUrlOnClient } from '../runtime/browser/browser-client-page-opening' -import { - BrowserClientPageQueries, - yieldClientPageSelection, - type BrowserEngineTabs -} from '../runtime/browser/browser-client-page-queries' -import { browserTabCreateClientPageStartsActive } from '../runtime/browser-tab-create-publication' -import type { BrowserTabListResult, BrowserTabSwitchResult } from '../../shared/runtime-types' +import { BrowserClientPageQueries } from '../runtime/browser/browser-client-page-queries' +import type { RuntimeBrowserClientPage } from '../runtime/runtime-browser-page-registry' -/** Sends a call to the engine; `name` defaults to the member the proxy was asked for. */ -type Forward = (args: unknown[], name?: string) => unknown +type Forward = (args: unknown[]) => unknown -const ENGINE_FREE_TAB_QUERIES = [ +const CLIENT_PAGE_TAB_QUERIES = [ 'browserTabList', 'browserTabCurrent', 'browserTabShow', @@ -51,71 +48,36 @@ export function orcadClientPageCommand( return (...args: Parameters) => createTab(host, args, forward, engineAvailable) } - const logicalTabs = (): BrowserClientPageQueries => - new BrowserClientPageQueries( - host, - orcadProfileLabel, - engineAvailable() ? engineTabs(forward) : null - ) if (name === 'browserTabClose') { return async (...args: Parameters) => { const params = args[0] ?? {} - // Why the whole logical list: an index or the current tab may name a desktop-hosted page. - const clientPage = await resolveClientPageToClose(host, logicalTabs(), params) - if (clientPage) { - return retireRuntimeBrowserClientPage(host, clientPage) + if (engineAvailable()) { + // Why: client pages live in this runtime's registry, never in the engine. An active one is + // also what workspace automation without --page drives, so an implicit close takes it. + const active = + params.page || params.index !== undefined + ? undefined + : await activeClientPage(host, params) + return active + ? retireRuntimeBrowserClientPage(host, active) + : ((await closeRegisteredBrowserClientPage(host, params)) ?? forward(args)) } - return engineAvailable() - ? forward(args) + const page = await resolveClientPageToClose(host, clientPageQueries(host), params) + return page + ? retireRuntimeBrowserClientPage(host, page) : closeBrowserPageWithoutEngine(host, params, browserUnavailableError) } } - const query = ENGINE_FREE_TAB_QUERIES.find((candidate) => candidate === name) + const query = CLIENT_PAGE_TAB_QUERIES.find((candidate) => candidate === name) if (query) { - return (...args: unknown[]) => { - const hasClientPages = host.getRuntimeBrowserPageRegistry().listPages().length > 0 - // Why: with nothing desktop-hosted, the engine's answer (or its refusal) is the whole answer. - if (!hasClientPages && (engineAvailable() || !hasPairedPageHost(host))) { - return forward(args) - } - return callTabQuery(logicalTabs(), query, args) - } + return (...args: unknown[]) => + engineAvailable() || !servesClientPages(host) + ? forward(args) + : callTabQuery(clientPageQueries(host), query, args) } return undefined } -/** The engine's own tabs, read back through the same forward the proxy uses. */ -function engineTabs(forward: Forward): BrowserEngineTabs { - return { - list: async (params) => - requireTabResult(await forward([params], 'browserTabList'), isTabListResult), - switchTo: async (params) => - requireTabResult(await forward([params], 'browserTabSwitch'), isTabSwitchResult) - } -} - -function requireTabResult(value: unknown, guard: (candidate: unknown) => candidate is T): T { - if (!guard(value)) { - throw new BrowserError('browser_error', 'The browser engine returned an unreadable tab result.') - } - return value -} - -function isTabListResult(value: unknown): value is BrowserTabListResult { - return ( - isRecord(value) && - Array.isArray(value.tabs) && - value.tabs.every( - (tab: unknown) => - isRecord(tab) && typeof tab.browserPageId === 'string' && typeof tab.active === 'boolean' - ) - ) -} - -function isTabSwitchResult(value: unknown): value is BrowserTabSwitchResult { - return isRecord(value) && typeof value.browserPageId === 'string' -} - function createTab( host: RuntimeBrowserCommandHost, [params, caller]: Parameters, @@ -148,24 +110,37 @@ function createTab( orcadBrowserProfileId(params?.profileId) ) } - if (host.getRuntimeBrowserPageRegistry().listPages().length === 0) { - return forward([params, caller]) + return forward([params, caller]) +} + +async function activeClientPage( + host: RuntimeBrowserCommandHost, + params: { worktree?: string } +): Promise { + if (!params.worktree) { + return undefined } - return (async () => { - const created = await forward([params, caller]) - if (browserTabCreateClientPageStartsActive(params?.activate)) { - yieldClientPageSelection( - host, - params?.worktree ? (await host.resolveBrowserWorkspace(params.worktree)).id : undefined - ) - } - return created - })() + const workspaceId = (await host.resolveBrowserWorkspace(params.worktree)).id + return host + .getRuntimeBrowserPageRegistry() + .listPages(workspaceId) + .find((page) => page.active) +} + +function servesClientPages(host: RuntimeBrowserCommandHost): boolean { + return host.getRuntimeBrowserPageRegistry().listPages().length > 0 || hasPairedPageHost(host) +} + +function clientPageQueries(host: RuntimeBrowserCommandHost): BrowserClientPageQueries { + return new BrowserClientPageQueries(host, (browserProfileId) => ({ + id: browserProfileId, + label: browserProfileId === DEFAULT_BROWSER_PROFILE_ID ? 'Default' : browserProfileId + })) } function callTabQuery( queries: BrowserClientPageQueries, - name: (typeof ENGINE_FREE_TAB_QUERIES)[number], + name: (typeof CLIENT_PAGE_TAB_QUERIES)[number], args: unknown[] ): unknown { const params: Record = isRecord(args[0]) ? args[0] : {} @@ -199,13 +174,6 @@ function orcadBrowserProfileId(profileId: string | undefined): string { return DEFAULT_BROWSER_PROFILE_ID } -function orcadProfileLabel(browserProfileId: string): { id: string; label: string } { - return { - id: browserProfileId, - label: browserProfileId === DEFAULT_BROWSER_PROFILE_ID ? 'Default' : browserProfileId - } -} - function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === 'object' && !Array.isArray(value)) } diff --git a/src/main/runtime/browser/browser-client-page-queries.ts b/src/main/runtime/browser/browser-client-page-queries.ts index f6baff69d157..9c524971261b 100644 --- a/src/main/runtime/browser/browser-client-page-queries.ts +++ b/src/main/runtime/browser/browser-client-page-queries.ts @@ -11,26 +11,6 @@ import type { RuntimeBrowserCommandHost } from './browser-command-contracts' type BrowserProfileLabel = { id: string; label: string } -/** The tab row a client-hosted page lists as; `index` is its place after any engine tabs. */ -export function clientPageTabRow( - page: RuntimeBrowserClientPage, - index: number, - profile: BrowserProfileLabel -): BrowserTabInfo { - return { - browserPageId: page.browserPageId, - index, - url: page.url, - title: page.title, - active: page.active, - loadError: null, - certificateFailure: null, - worktreeId: page.workspaceId, - profileId: profile.id, - profileLabel: profile.label - } -} - // Why no Electron import: a host with no engine lists and selects the pages a desktop hosts for it. export type BrowserClientPageQueryHost = Pick< RuntimeBrowserCommandHost, @@ -41,22 +21,14 @@ export type BrowserClientPageQueryHost = Pick< | 'notifyHeadlessBrowserSessionTabsChanged' > -/** The tabs a host's own engine holds, beside the pages desktops host for it; null without one. */ -export type BrowserEngineTabs = { - list(params: { worktree?: string }): Promise - switchTo(params: { - page: string - worktree?: string - focus?: boolean - }): Promise -} - -/** Tab commands over a host's engine tabs, if any, followed by its client-hosted pages. */ +/** + * Tab commands for a host whose only tabs are the pages desktops host for it. "Current" is the + * page the registry marks active, the same page workspace automation without --page drives. + */ export class BrowserClientPageQueries { constructor( private readonly host: BrowserClientPageQueryHost, - private readonly profileFor: (browserProfileId: string) => BrowserProfileLabel, - private readonly engine: BrowserEngineTabs | null + private readonly profileFor: (browserProfileId: string) => BrowserProfileLabel ) {} async browserTabList(params: { worktree?: string }): Promise { @@ -64,23 +36,7 @@ export class BrowserClientPageQueries { ? (await this.host.resolveBrowserWorkspace(params.worktree)).id : undefined const pages = this.host.getRuntimeBrowserPageRegistry().listPages(workspaceId) - const clientPageActive = pages.some((page) => page.active) - const engineTabs = this.engine - ? (await this.engine.list(params)).tabs.map((tab) => ({ - ...tab, - active: clientPageActive ? false : tab.active - })) - : [] - const tabs = [ - ...engineTabs, - ...pages.map((page, offset) => - clientPageTabRow(page, engineTabs.length + offset, this.profileFor(page.browserProfileId)) - ) - ] - if (tabs.length > 0 && !tabs.some((tab) => tab.active)) { - tabs[0] = { ...tabs[0]!, active: true } - } - return { tabs: tabs.map((tab, index) => ({ ...tab, index })) } + return { tabs: pages.map((page, index) => this.row(page, index)) } } async browserTabCurrent(params: { worktree?: string }): Promise { @@ -93,10 +49,11 @@ export class BrowserClientPageQueries { async browserTabShow(params: { page: string; worktree?: string }): Promise { const page = this.host.getRuntimeBrowserPageRegistry().getPage(params.page) - const worktree = params.worktree ?? (page ? `id:${page.workspaceId}` : undefined) - const tab = (await this.browserTabList({ worktree })).tabs.find( - (candidate) => candidate.browserPageId === params.page - ) + const tab = page + ? ( + await this.browserTabList({ worktree: params.worktree ?? `id:${page.workspaceId}` }) + ).tabs.find((candidate) => candidate.browserPageId === page.browserPageId) + : undefined if (!tab) { throw new BrowserError('browser_tab_not_found', `Browser page ${params.page} was not found`) } @@ -114,69 +71,39 @@ export class BrowserClientPageQueries { ? listed.tabs.findIndex((tab) => tab.browserPageId === params.page) : (params.index ?? -1) const selected = listed.tabs[switched] - if (!selected) { + const page = selected + ? this.host.getRuntimeBrowserPageRegistry().getPage(selected.browserPageId) + : undefined + if (!page) { const label = params.page ? `Browser page ${params.page}` : `Tab index ${params.index}` throw new BrowserError( 'browser_tab_not_found', `${label} out of range (0-${listed.tabs.length - 1})` ) } - const pages = this.host.getRuntimeBrowserPageRegistry() - const page = pages.getPage(selected.browserPageId) - if (page) { - activateClientPageTab(this.host, page, params.focus) - return { switched, browserPageId: page.browserPageId } - } - if (!this.engine) { - throw new BrowserError( - 'browser_tab_not_found', - `Browser page ${selected.browserPageId} was not found` - ) - } - const result = await this.engine.switchTo({ - page: selected.browserPageId, - ...(params.worktree ? { worktree: params.worktree } : {}), - ...(params.focus !== undefined ? { focus: params.focus } : {}) + this.host.getRuntimeBrowserPageRegistry().activatePage(page.browserPageId, page.placement) + publishSwitchedBrowserSessionTab(this.host, { + placementKind: 'client', + browserPageId: page.browserPageId, + worktreeId: page.workspaceId, + focus: params.focus }) - yieldClientPageSelection( - this.host, - typeof selected.worktreeId === 'string' - ? selected.worktreeId - : params.worktree - ? (await this.host.resolveBrowserWorkspace(params.worktree)).id - : undefined - ) - return { switched, browserPageId: result.browserPageId } + return { switched, browserPageId: page.browserPageId } } -} -/** - * The one rule for a workspace's active tab across engine tabs and client-hosted pages: once an - * engine tab is created or selected, no client page may still claim to be active, or workspace - * commands without --page would keep going to the old client page. - */ -export function yieldClientPageSelection( - host: Pick, - workspaceId: string | undefined -): void { - const pages = host.getRuntimeBrowserPageRegistry() - pages.deactivateGlobal() - if (workspaceId) { - pages.deactivateWorkspace(workspaceId) + private row(page: RuntimeBrowserClientPage, index: number): BrowserTabInfo { + const profile = this.profileFor(page.browserProfileId) + return { + browserPageId: page.browserPageId, + index, + url: page.url, + title: page.title, + active: page.active, + loadError: null, + certificateFailure: null, + worktreeId: page.workspaceId, + profileId: profile.id, + profileLabel: profile.label + } } } - -/** Makes a client-hosted page its workspace's active tab and publishes the switch. */ -export function activateClientPageTab( - host: BrowserClientPageQueryHost, - page: RuntimeBrowserClientPage, - focus: boolean | undefined -): void { - host.getRuntimeBrowserPageRegistry().activatePage(page.browserPageId, page.placement) - publishSwitchedBrowserSessionTab(host, { - placementKind: 'client', - browserPageId: page.browserPageId, - worktreeId: page.workspaceId, - focus - }) -} diff --git a/src/main/runtime/browser/browser-tab-catalog.ts b/src/main/runtime/browser/browser-tab-catalog.ts index 7f3367ffdbad..aa173ff4bf46 100644 --- a/src/main/runtime/browser/browser-tab-catalog.ts +++ b/src/main/runtime/browser/browser-tab-catalog.ts @@ -8,7 +8,6 @@ import type { RuntimeBrowserCommandHost } from './browser-command-contracts' import type { RuntimeBrowserCommandTargets } from './browser-command-targets' -import { clientPageTabRow } from './browser-client-page-queries' export class RuntimeBrowserTabCatalog { constructor( @@ -32,14 +31,23 @@ export class RuntimeBrowserTabCatalog { active: clientPageActive ? false : tab.active })) : [] - const clientTabs = clientPages.map((page, offset) => - clientPageTabRow( - page, - serverTabs.length + offset, + const clientTabs = clientPages.map((page, offset) => { + const profile = browserSessionRegistry.getProfile(page.browserProfileId) ?? - browserSessionRegistry.getDefaultProfile() - ) - ) + browserSessionRegistry.getDefaultProfile() + return { + browserPageId: page.browserPageId, + index: serverTabs.length + offset, + url: page.url, + title: page.title, + active: page.active, + loadError: null, + certificateFailure: null, + worktreeId: page.workspaceId, + profileId: profile.id, + profileLabel: profile.label + } + }) const tabs = [...serverTabs, ...clientTabs] if (tabs.length > 0 && !tabs.some((tab) => tab.active)) { tabs[0] = { ...tabs[0]!, active: true } diff --git a/src/main/runtime/browser/browser-tab-queries.ts b/src/main/runtime/browser/browser-tab-queries.ts index 8ed807938c12..dfb2eba95f20 100644 --- a/src/main/runtime/browser/browser-tab-queries.ts +++ b/src/main/runtime/browser/browser-tab-queries.ts @@ -14,7 +14,6 @@ import type { } from './browser-command-contracts' import type { RuntimeBrowserCommandTargets } from './browser-command-targets' import type { RuntimeBrowserTabCatalog } from './browser-tab-catalog' -import { activateClientPageTab, yieldClientPageSelection } from './browser-client-page-queries' export class RuntimeBrowserTabQueries { constructor( @@ -114,7 +113,15 @@ export class RuntimeBrowserTabQueries { } const clientPage = this.host.getRuntimeBrowserPageRegistry().getPage(selected.browserPageId) if (clientPage) { - activateClientPageTab(this.host, clientPage, params.focus) + this.host + .getRuntimeBrowserPageRegistry() + .activatePage(clientPage.browserPageId, clientPage.placement) + publishSwitchedBrowserSessionTab(this.host, { + placementKind: 'client', + browserPageId: clientPage.browserPageId, + worktreeId: clientPage.workspaceId, + focus: params.focus + }) return { switched: switchedIndex, browserPageId: clientPage.browserPageId } } const bridge = this.targets.requireAgentBrowserBridge() @@ -125,7 +132,10 @@ export class RuntimeBrowserTabQueries { ? (await this.host.resolveBrowserWorkspace(params.worktree)).id : undefined const result = await bridge.tabSwitch(undefined, worktreeId, selected.browserPageId) - yieldClientPageSelection(this.host, worktreeId) + this.host.getRuntimeBrowserPageRegistry().deactivateGlobal() + if (worktreeId) { + this.host.getRuntimeBrowserPageRegistry().deactivateWorkspace(worktreeId) + } // Why: scope focus to the tab's owning worktree; the renderer never yanks the user across worktrees on this signal (see focusBrowserTabInWorktree). const focusWorktreeId = worktreeId ?? browserManager.getWorktreeIdForTab(result.browserPageId) ?? undefined diff --git a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx index 5befddce1679..a7e3fda241b4 100644 --- a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx +++ b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.test.tsx @@ -102,4 +102,26 @@ describe('ClientHostedBrowserUnavailableNotice', () => { expect(mocks.ensure).toHaveBeenCalledTimes(1) expect(mocks.ensure).toHaveBeenCalledWith(expect.anything(), 'env-a') }) + + it('offers reopening on the server only when the server has a browser of its own', () => { + renderNotice(live) + expect(screen.getByRole('button', { name: 'Reopen on server' })).toBeInTheDocument() + cleanup() + + const status = live.status + if (!status) { + throw new Error('fixture status missing') + } + // orcad with no engine: it hosts pages on desktops but cannot render one itself. + renderNotice({ + ...live, + status: { + ...status, + capabilities: (status.capabilities ?? []).filter( + (capability) => capability !== 'browser.screencast.v1' + ) + } + }) + expect(screen.queryByRole('button', { name: 'Reopen on server' })).not.toBeInTheDocument() + }) }) diff --git a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx index ca0fdf2aa9ff..e7a7790e2ab5 100644 --- a/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx +++ b/src/renderer/src/components/browser-pane/client-hosted-browser-unavailable-notice.tsx @@ -13,6 +13,7 @@ import { ReopenBrowserPageOnServerButton, reopenOnServerCaveat } from './ReopenBrowserPageOnServerButton' +import { BROWSER_SCREENCAST_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' /** * What a client-hosted page shows when its guest is not here: the page belongs to another desktop, @@ -43,6 +44,12 @@ export function ClientHostedBrowserUnavailableNotice({ ?.name ?? null) : null }) + // Why: a runtime known to have no browser of its own (orcad) cannot take the page back. + const runtimeCanHostPage = useAppStore((s) => { + const capabilities = + s.runtimeStatusByEnvironmentId.get(runtimeEnvironmentId)?.status?.capabilities + return !capabilities || capabilities.includes(BROWSER_SCREENCAST_RUNTIME_CAPABILITY) + }) const ownHostClientId = readBrowserClientHostId() const onOtherDesktop = Boolean( placementHostClientId && ownHostClientId && placementHostClientId !== ownHostClientId @@ -78,12 +85,16 @@ export function ClientHostedBrowserUnavailableNotice({ {translate('browser.clientHosted.unavailableTitle', 'Client-hosted browser unavailable')}
{description}
-
{reopenOnServerCaveat()}
- + {runtimeCanHostPage ? ( + <> +
{reopenOnServerCaveat()}
+ + + ) : null} ) From e7a289bd8a1892f20d3a2794111ec39c465a003f Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 19 Oct 2026 08:27:51 -0700 Subject: [PATCH 07/11] fix: an engine tab create or switch takes workspace selection from desktop-hosted pages --- .../electron-serve-browser-process.test.ts | 7 ++- .../external-chromium-browser-process.test.ts | 6 ++- .../orcad/orcad-client-hosted-browser.test.ts | 24 ++++++++++ src/main/orcad/orcad-client-page-commands.ts | 46 +++++++++++++++++-- 4 files changed, 76 insertions(+), 7 deletions(-) diff --git a/src/main/orcad/electron-serve-browser-process.test.ts b/src/main/orcad/electron-serve-browser-process.test.ts index e4449dc48649..218d886fab60 100644 --- a/src/main/orcad/electron-serve-browser-process.test.ts +++ b/src/main/orcad/electron-serve-browser-process.test.ts @@ -8,6 +8,7 @@ import { z } from 'zod' import type * as RunProcessModule from '@orca/process-host' import type * as FsPromisesModule from 'node:fs/promises' import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts' +import { RuntimeBrowserPageRegistry } from '../runtime/runtime-browser-page-registry' const { spawnProcessMock, lockedRemovalPaths } = vi.hoisted(() => ({ spawnProcessMock: @@ -496,7 +497,11 @@ it('forwards tab commands through startup to the Electron sidecar', async () => const startup = startOrcadBrowserProvider({ userDataPath: harnessRoot }) await startup.ready try { - const commands = createRuntimeBrowserCommands(host) + // The runtime that composes the provider owns a page registry, which startup consults. + const commands = createRuntimeBrowserCommands({ + ...host, + getRuntimeBrowserPageRegistry: () => new RuntimeBrowserPageRegistry() + }) await commands.browserTabCreate({ worktree: 'wt-a' }) await commands.browserTabList({ worktree: 'wt-a' }) expect((await browserRequests()).map((request) => request.method)).toEqual([ diff --git a/src/main/orcad/external-chromium-browser-process.test.ts b/src/main/orcad/external-chromium-browser-process.test.ts index 9c618217f3d9..08bd93be4b83 100644 --- a/src/main/orcad/external-chromium-browser-process.test.ts +++ b/src/main/orcad/external-chromium-browser-process.test.ts @@ -31,6 +31,7 @@ import { setRuntimeBrowserCommandsFactory } from '../runtime/runtime-browser-commands-factory' import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts' +import { RuntimeBrowserPageRegistry } from '../runtime/runtime-browser-page-registry' vi.mock('./orcad-browser-provider', () => ({ resolveOrcadBrowserProvider: vi.fn() })) afterEach(() => setRuntimeBrowserCommandsFactory(null)) @@ -75,7 +76,10 @@ it('forwards tab commands through startup to the Chromium provider', async () => const startup = startOrcadBrowserProvider({ userDataPath: '/state' }) await startup.ready try { - const host: RuntimeBrowserCommandHost = Object.create(null) + // The runtime that composes the provider owns a page registry, which startup consults. + const host: RuntimeBrowserCommandHost = Object.assign(Object.create(null), { + getRuntimeBrowserPageRegistry: () => new RuntimeBrowserPageRegistry() + }) const commands = createRuntimeBrowserCommands(host) const params = { worktree: 'wt-a' } createTab.mockResolvedValueOnce({ browserPageId: 'page-a' }) diff --git a/src/main/orcad/orcad-client-hosted-browser.test.ts b/src/main/orcad/orcad-client-hosted-browser.test.ts index 175706378d89..8bfb89e8bc13 100644 --- a/src/main/orcad/orcad-client-hosted-browser.test.ts +++ b/src/main/orcad/orcad-client-hosted-browser.test.ts @@ -222,6 +222,8 @@ async function startWithEngine() { const engineTab = { browserPageId: 'engine-page', index: 0, url: '', title: '', active: true } const engine = { browserTabList: vi.fn(async () => ({ tabs: [engineTab] })), + browserTabCreate: vi.fn(async () => ({ browserPageId: 'engine-page' })), + browserTabSwitch: vi.fn(async () => ({ switched: 0, browserPageId: 'engine-page' })), browserTabClose: vi.fn(async () => ({ closed: true })) } vi.mocked(resolveOrcadBrowserProvider).mockResolvedValueOnce({ @@ -253,6 +255,28 @@ it('closes the current desktop-hosted page itself instead of the engine’s curr await startup.stop() }) +it('hands workspace commands back to the engine once it creates or selects a tab', async () => { + const { startup, engine } = await startWithEngine() + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + const isActive = (browserPageId: string): boolean | undefined => + paired.pages.getPage(browserPageId)?.active + const opened = await commands.browserOpenUrlOnClient({ + url: 'https://example.org/', + worktree: 'id:wt-1' + }) + expect(isActive(opened.browserPageId)).toBe(true) + + await commands.browserTabCreate({ worktree: 'id:wt-1', url: 'https://engine.test/' }) + expect(engine.browserTabCreate).toHaveBeenCalled() + expect(isActive(opened.browserPageId)).toBe(false) + + await commands.browserTabSwitch({ page: opened.browserPageId, worktree: 'id:wt-1' }) + expect(engine.browserTabSwitch).toHaveBeenCalled() + expect(isActive(opened.browserPageId)).toBe(false) + await startup.stop() +}) + it('closes a desktop-hosted page by index with no engine', async () => { const startup = await startWithoutEngine() const paired = pairedHost({ desktopAttached: true }) diff --git a/src/main/orcad/orcad-client-page-commands.ts b/src/main/orcad/orcad-client-page-commands.ts index 1e63b294d9e9..4d0ed690759a 100644 --- a/src/main/orcad/orcad-client-page-commands.ts +++ b/src/main/orcad/orcad-client-page-commands.ts @@ -23,6 +23,7 @@ import { } from '../runtime/browser/browser-client-page-opening' import { BrowserClientPageQueries } from '../runtime/browser/browser-client-page-queries' import type { RuntimeBrowserClientPage } from '../runtime/runtime-browser-page-registry' +import { browserTabCreateClientPageStartsActive } from '../runtime/browser-tab-create-publication' type Forward = (args: unknown[]) => unknown @@ -70,14 +71,46 @@ export function orcadClientPageCommand( } const query = CLIENT_PAGE_TAB_QUERIES.find((candidate) => candidate === name) if (query) { - return (...args: unknown[]) => - engineAvailable() || !servesClientPages(host) - ? forward(args) - : callTabQuery(clientPageQueries(host), query, args) + return (...args: unknown[]) => { + if (!engineAvailable()) { + return servesClientPages(host) + ? callTabQuery(clientPageQueries(host), query, args) + : forward(args) + } + return query === 'browserTabSwitch' + ? afterEngineSelects(host, args[0], () => forward(args)) + : forward(args) + } } return undefined } +/** + * Runs an engine create or switch, then hands the workspace's selection to the engine tab: the + * dispatcher sends workspace automation without --page to the active client page, so one left + * active would keep taking commands meant for the tab the engine now reports as current. + */ +function afterEngineSelects( + host: RuntimeBrowserCommandHost, + params: unknown, + run: () => unknown +): unknown { + if (host.getRuntimeBrowserPageRegistry().listPages().length === 0) { + return run() + } + return (async () => { + const result = await run() + const worktree = + isRecord(params) && typeof params.worktree === 'string' ? params.worktree : null + const pages = host.getRuntimeBrowserPageRegistry() + pages.deactivateGlobal() + if (worktree) { + pages.deactivateWorkspace((await host.resolveBrowserWorkspace(worktree)).id) + } + return result + })() +} + function createTab( host: RuntimeBrowserCommandHost, [params, caller]: Parameters, @@ -110,7 +143,10 @@ function createTab( orcadBrowserProfileId(params?.profileId) ) } - return forward([params, caller]) + if (!engineAvailable() || !browserTabCreateClientPageStartsActive(params?.activate)) { + return forward([params, caller]) + } + return afterEngineSelects(host, params, () => forward([params, caller])) } async function activeClientPage( From f1cc72136ecc35bac4105bce59876ca9156a3f77 Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 19 Oct 2026 08:35:27 -0700 Subject: [PATCH 08/11] refactor: one logical tab list and one selection rule for orcad engine tabs and desktop-hosted pages --- src/main/orcad/orcad-browser-startup.ts | 4 +- .../orcad/orcad-client-hosted-browser.test.ts | 28 +++- src/main/orcad/orcad-client-page-commands.ts | 150 +++++++++--------- .../browser/browser-client-page-queries.ts | 125 +++++++++++---- 4 files changed, 192 insertions(+), 115 deletions(-) diff --git a/src/main/orcad/orcad-browser-startup.ts b/src/main/orcad/orcad-browser-startup.ts index 83c018b7179c..5e2c13ded645 100644 --- a/src/main/orcad/orcad-browser-startup.ts +++ b/src/main/orcad/orcad-browser-startup.ts @@ -32,12 +32,12 @@ export function startOrcadBrowserProvider(options: OrcadBrowserProviderOptions): if (property === 'then' || typeof property !== 'string') { return undefined } - const forward = (args: unknown[]): unknown => { + const forward = (args: unknown[], name: string = property): unknown => { if (!engineAvailable() || !provider) { throw browserUnavailableError() } commands ??= provider.factory(host) - return callBrowserCommand(commands, property, args) + return callBrowserCommand(commands, name, args) } return ( orcadClientPageCommand(host, property, forward, engineAvailable) ?? diff --git a/src/main/orcad/orcad-client-hosted-browser.test.ts b/src/main/orcad/orcad-client-hosted-browser.test.ts index 8bfb89e8bc13..68603f072f28 100644 --- a/src/main/orcad/orcad-client-hosted-browser.test.ts +++ b/src/main/orcad/orcad-client-hosted-browser.test.ts @@ -255,6 +255,26 @@ it('closes the current desktop-hosted page itself instead of the engine’s curr await startup.stop() }) +it('lists desktop-hosted pages after the engine’s tabs, as automation would pick them', async () => { + const { startup } = await startWithEngine() + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + const opened = await commands.browserOpenUrlOnClient({ + url: 'https://example.org/', + worktree: 'id:wt-1' + }) + + const listed = await commands.browserTabList({ worktree: 'id:wt-1' }) + expect(listed.tabs.map((tab) => [tab.browserPageId, tab.index, tab.active])).toEqual([ + ['engine-page', 0, false], + [opened.browserPageId, 1, true] + ]) + await expect(commands.browserTabShow({ page: opened.browserPageId })).resolves.toMatchObject({ + tab: { browserPageId: opened.browserPageId, index: 1 } + }) + await startup.stop() +}) + it('hands workspace commands back to the engine once it creates or selects a tab', async () => { const { startup, engine } = await startWithEngine() const paired = pairedHost({ desktopAttached: true }) @@ -265,14 +285,18 @@ it('hands workspace commands back to the engine once it creates or selects a tab url: 'https://example.org/', worktree: 'id:wt-1' }) - expect(isActive(opened.browserPageId)).toBe(true) await commands.browserTabCreate({ worktree: 'id:wt-1', url: 'https://engine.test/' }) expect(engine.browserTabCreate).toHaveBeenCalled() expect(isActive(opened.browserPageId)).toBe(false) await commands.browserTabSwitch({ page: opened.browserPageId, worktree: 'id:wt-1' }) - expect(engine.browserTabSwitch).toHaveBeenCalled() + expect(isActive(opened.browserPageId)).toBe(true) + // A switch by page alone: the engine names no workspace, so every client page steps back. + await expect(commands.browserTabSwitch({ page: 'engine-page' })).resolves.toMatchObject({ + browserPageId: 'engine-page' + }) + expect(engine.browserTabSwitch).toHaveBeenCalledWith({ page: 'engine-page' }) expect(isActive(opened.browserPageId)).toBe(false) await startup.stop() }) diff --git a/src/main/orcad/orcad-client-page-commands.ts b/src/main/orcad/orcad-client-page-commands.ts index 4d0ed690759a..b66f5f3de5a5 100644 --- a/src/main/orcad/orcad-client-page-commands.ts +++ b/src/main/orcad/orcad-client-page-commands.ts @@ -1,16 +1,16 @@ /** * The browser commands orcad answers itself. Pages a paired desktop hosts for it live in this - * runtime's lease and page registries, so opening one never needs a browser engine here. With no - * engine those pages are every tab this host has, so the tab commands answer from the registry. - * With an engine, tab commands stay the engine's, as before. + * runtime's lease and page registries, so opening one never needs a browser engine here. Tab + * commands see one logical list: the engine's tabs (if any), then the desktop-hosted pages. */ import { BrowserError } from '../browser/browser-error' import type { RuntimeBrowserCommands } from '../runtime/browser/browser-commands' import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts' +import type { BrowserTabListResult, BrowserTabSwitchResult } from '../../shared/runtime-types' import { browserUnavailableError } from '../runtime/runtime-browser-commands-factory' +import { browserTabCreateClientPageStartsActive } from '../runtime/browser-tab-create-publication' import { closeBrowserPageWithoutEngine, - closeRegisteredBrowserClientPage, resolveClientPageToClose, retireRuntimeBrowserClientPage } from '../runtime/browser/browser-client-page-close' @@ -21,13 +21,16 @@ import { hasPairedPageHost, openBrowserUrlOnClient } from '../runtime/browser/browser-client-page-opening' -import { BrowserClientPageQueries } from '../runtime/browser/browser-client-page-queries' -import type { RuntimeBrowserClientPage } from '../runtime/runtime-browser-page-registry' -import { browserTabCreateClientPageStartsActive } from '../runtime/browser-tab-create-publication' +import { + BrowserClientPageQueries, + yieldClientPageSelection, + type BrowserEngineTabs +} from '../runtime/browser/browser-client-page-queries' -type Forward = (args: unknown[]) => unknown +/** Sends a call to the engine; `name` defaults to the member the proxy was asked for. */ +type Forward = (args: unknown[], name?: string) => unknown -const CLIENT_PAGE_TAB_QUERIES = [ +const LOGICAL_TAB_QUERIES = [ 'browserTabList', 'browserTabCurrent', 'browserTabShow', @@ -41,6 +44,12 @@ export function orcadClientPageCommand( forward: Forward, engineAvailable: () => boolean ): ((...args: never[]) => unknown) | undefined { + const logicalTabs = (): BrowserClientPageQueries => + new BrowserClientPageQueries( + host, + orcadProfileLabel, + engineAvailable() ? engineTabs(forward) : null + ) if (name === 'browserOpenUrlOnClient') { return (...args: Parameters) => openBrowserUrlOnClient(host, args[0]) @@ -52,65 +61,30 @@ export function orcadClientPageCommand( if (name === 'browserTabClose') { return async (...args: Parameters) => { const params = args[0] ?? {} - if (engineAvailable()) { - // Why: client pages live in this runtime's registry, never in the engine. An active one is - // also what workspace automation without --page drives, so an implicit close takes it. - const active = - params.page || params.index !== undefined - ? undefined - : await activeClientPage(host, params) - return active - ? retireRuntimeBrowserClientPage(host, active) - : ((await closeRegisteredBrowserClientPage(host, params)) ?? forward(args)) + // Why the logical list: an index or the current tab may name a desktop-hosted page. + const page = await resolveClientPageToClose(host, logicalTabs(), params) + if (page) { + return retireRuntimeBrowserClientPage(host, page) } - const page = await resolveClientPageToClose(host, clientPageQueries(host), params) - return page - ? retireRuntimeBrowserClientPage(host, page) + return engineAvailable() + ? forward(args) : closeBrowserPageWithoutEngine(host, params, browserUnavailableError) } } - const query = CLIENT_PAGE_TAB_QUERIES.find((candidate) => candidate === name) + const query = LOGICAL_TAB_QUERIES.find((candidate) => candidate === name) if (query) { return (...args: unknown[]) => { - if (!engineAvailable()) { - return servesClientPages(host) - ? callTabQuery(clientPageQueries(host), query, args) - : forward(args) + const hasClientPages = host.getRuntimeBrowserPageRegistry().listPages().length > 0 + // Why: with nothing desktop-hosted, the engine's answer (or its refusal) is the whole answer. + if (!hasClientPages && (engineAvailable() || !hasPairedPageHost(host))) { + return forward(args) } - return query === 'browserTabSwitch' - ? afterEngineSelects(host, args[0], () => forward(args)) - : forward(args) + return callTabQuery(logicalTabs(), query, args) } } return undefined } -/** - * Runs an engine create or switch, then hands the workspace's selection to the engine tab: the - * dispatcher sends workspace automation without --page to the active client page, so one left - * active would keep taking commands meant for the tab the engine now reports as current. - */ -function afterEngineSelects( - host: RuntimeBrowserCommandHost, - params: unknown, - run: () => unknown -): unknown { - if (host.getRuntimeBrowserPageRegistry().listPages().length === 0) { - return run() - } - return (async () => { - const result = await run() - const worktree = - isRecord(params) && typeof params.worktree === 'string' ? params.worktree : null - const pages = host.getRuntimeBrowserPageRegistry() - pages.deactivateGlobal() - if (worktree) { - pages.deactivateWorkspace((await host.resolveBrowserWorkspace(worktree)).id) - } - return result - })() -} - function createTab( host: RuntimeBrowserCommandHost, [params, caller]: Parameters, @@ -143,40 +117,55 @@ function createTab( orcadBrowserProfileId(params?.profileId) ) } - if (!engineAvailable() || !browserTabCreateClientPageStartsActive(params?.activate)) { + if ( + !engineAvailable() || + !browserTabCreateClientPageStartsActive(params?.activate) || + host.getRuntimeBrowserPageRegistry().listPages().length === 0 + ) { return forward([params, caller]) } - return afterEngineSelects(host, params, () => forward([params, caller])) + return (async () => { + const created = await forward([params, caller]) + await yieldClientPageSelection(host, params?.worktree) + return created + })() } -async function activeClientPage( - host: RuntimeBrowserCommandHost, - params: { worktree?: string } -): Promise { - if (!params.worktree) { - return undefined +/** The engine's own tabs, read back through the proxy's forward. */ +function engineTabs(forward: Forward): BrowserEngineTabs { + return { + list: async (params) => + requireTabResult(await forward([params], 'browserTabList'), isTabListResult), + switchTo: async (params) => + requireTabResult(await forward([params], 'browserTabSwitch'), isTabSwitchResult) } - const workspaceId = (await host.resolveBrowserWorkspace(params.worktree)).id - return host - .getRuntimeBrowserPageRegistry() - .listPages(workspaceId) - .find((page) => page.active) } -function servesClientPages(host: RuntimeBrowserCommandHost): boolean { - return host.getRuntimeBrowserPageRegistry().listPages().length > 0 || hasPairedPageHost(host) +function requireTabResult(value: unknown, guard: (candidate: unknown) => candidate is T): T { + if (!guard(value)) { + throw new BrowserError('browser_error', 'The browser engine returned an unreadable tab result.') + } + return value } -function clientPageQueries(host: RuntimeBrowserCommandHost): BrowserClientPageQueries { - return new BrowserClientPageQueries(host, (browserProfileId) => ({ - id: browserProfileId, - label: browserProfileId === DEFAULT_BROWSER_PROFILE_ID ? 'Default' : browserProfileId - })) +function isTabListResult(value: unknown): value is BrowserTabListResult { + return ( + isRecord(value) && + Array.isArray(value.tabs) && + value.tabs.every( + (tab: unknown) => + isRecord(tab) && typeof tab.browserPageId === 'string' && typeof tab.active === 'boolean' + ) + ) +} + +function isTabSwitchResult(value: unknown): value is BrowserTabSwitchResult { + return isRecord(value) && typeof value.browserPageId === 'string' } function callTabQuery( queries: BrowserClientPageQueries, - name: (typeof CLIENT_PAGE_TAB_QUERIES)[number], + name: (typeof LOGICAL_TAB_QUERIES)[number], args: unknown[] ): unknown { const params: Record = isRecord(args[0]) ? args[0] : {} @@ -210,6 +199,13 @@ function orcadBrowserProfileId(profileId: string | undefined): string { return DEFAULT_BROWSER_PROFILE_ID } +function orcadProfileLabel(browserProfileId: string): { id: string; label: string } { + return { + id: browserProfileId, + label: browserProfileId === DEFAULT_BROWSER_PROFILE_ID ? 'Default' : browserProfileId + } +} + function isRecord(value: unknown): value is Record { return Boolean(value && typeof value === 'object' && !Array.isArray(value)) } diff --git a/src/main/runtime/browser/browser-client-page-queries.ts b/src/main/runtime/browser/browser-client-page-queries.ts index 9c524971261b..529ddb8596e3 100644 --- a/src/main/runtime/browser/browser-client-page-queries.ts +++ b/src/main/runtime/browser/browser-client-page-queries.ts @@ -21,14 +21,26 @@ export type BrowserClientPageQueryHost = Pick< | 'notifyHeadlessBrowserSessionTabsChanged' > +/** The tabs a host's own engine holds, beside the pages desktops host for it. */ +export type BrowserEngineTabs = { + list(params: { worktree?: string }): Promise + switchTo(params: { + page: string + worktree?: string + focus?: boolean + }): Promise +} + /** - * Tab commands for a host whose only tabs are the pages desktops host for it. "Current" is the - * page the registry marks active, the same page workspace automation without --page drives. + * One logical tab list per host: its engine's tabs (if any), then the pages desktops host for it. + * "Current" is what workspace automation without --page drives: the active client page, which the + * dispatcher routes to first, else the engine's own active tab. Nothing invents a current tab. */ export class BrowserClientPageQueries { constructor( private readonly host: BrowserClientPageQueryHost, - private readonly profileFor: (browserProfileId: string) => BrowserProfileLabel + private readonly profileFor: (browserProfileId: string) => BrowserProfileLabel, + private readonly engine: BrowserEngineTabs | null ) {} async browserTabList(params: { worktree?: string }): Promise { @@ -36,7 +48,18 @@ export class BrowserClientPageQueries { ? (await this.host.resolveBrowserWorkspace(params.worktree)).id : undefined const pages = this.host.getRuntimeBrowserPageRegistry().listPages(workspaceId) - return { tabs: pages.map((page, index) => this.row(page, index)) } + const clientPageActive = pages.some((page) => page.active) + const engineTabs = this.engine + ? (await this.engine.list(params)).tabs.map((tab) => ({ + ...tab, + active: clientPageActive ? false : tab.active + })) + : [] + const tabs = [ + ...engineTabs, + ...pages.map((page) => clientPageTabRow(page, this.profileFor(page.browserProfileId))) + ] + return { tabs: tabs.map((tab, index) => ({ ...tab, index })) } } async browserTabCurrent(params: { worktree?: string }): Promise { @@ -49,11 +72,10 @@ export class BrowserClientPageQueries { async browserTabShow(params: { page: string; worktree?: string }): Promise { const page = this.host.getRuntimeBrowserPageRegistry().getPage(params.page) - const tab = page - ? ( - await this.browserTabList({ worktree: params.worktree ?? `id:${page.workspaceId}` }) - ).tabs.find((candidate) => candidate.browserPageId === page.browserPageId) - : undefined + const worktree = params.worktree ?? (page ? `id:${page.workspaceId}` : undefined) + const tab = (await this.browserTabList({ worktree })).tabs.find( + (candidate) => candidate.browserPageId === params.page + ) if (!tab) { throw new BrowserError('browser_tab_not_found', `Browser page ${params.page} was not found`) } @@ -71,39 +93,74 @@ export class BrowserClientPageQueries { ? listed.tabs.findIndex((tab) => tab.browserPageId === params.page) : (params.index ?? -1) const selected = listed.tabs[switched] - const page = selected - ? this.host.getRuntimeBrowserPageRegistry().getPage(selected.browserPageId) - : undefined - if (!page) { + const pages = this.host.getRuntimeBrowserPageRegistry() + const page = selected ? pages.getPage(selected.browserPageId) : undefined + if (page) { + pages.activatePage(page.browserPageId, page.placement) + publishSwitchedBrowserSessionTab(this.host, { + placementKind: 'client', + browserPageId: page.browserPageId, + worktreeId: page.workspaceId, + focus: params.focus + }) + return { switched, browserPageId: page.browserPageId } + } + if (!selected || !this.engine) { const label = params.page ? `Browser page ${params.page}` : `Tab index ${params.index}` throw new BrowserError( 'browser_tab_not_found', `${label} out of range (0-${listed.tabs.length - 1})` ) } - this.host.getRuntimeBrowserPageRegistry().activatePage(page.browserPageId, page.placement) - publishSwitchedBrowserSessionTab(this.host, { - placementKind: 'client', - browserPageId: page.browserPageId, - worktreeId: page.workspaceId, - focus: params.focus + const result = await this.engine.switchTo({ + page: selected.browserPageId, + ...(params.worktree ? { worktree: params.worktree } : {}), + ...(params.focus !== undefined ? { focus: params.focus } : {}) }) - return { switched, browserPageId: page.browserPageId } + await yieldClientPageSelection( + this.host, + params.worktree ?? (selected.worktreeId ? `id:${selected.worktreeId}` : undefined) + ) + return { switched, browserPageId: result.browserPageId } } +} - private row(page: RuntimeBrowserClientPage, index: number): BrowserTabInfo { - const profile = this.profileFor(page.browserProfileId) - return { - browserPageId: page.browserPageId, - index, - url: page.url, - title: page.title, - active: page.active, - loadError: null, - certificateFailure: null, - worktreeId: page.workspaceId, - profileId: profile.id, - profileLabel: profile.label - } +function clientPageTabRow( + page: RuntimeBrowserClientPage, + profile: BrowserProfileLabel +): BrowserTabInfo { + return { + browserPageId: page.browserPageId, + index: 0, + url: page.url, + title: page.title, + active: page.active, + loadError: null, + certificateFailure: null, + worktreeId: page.workspaceId, + profileId: profile.id, + profileLabel: profile.label + } +} + +/** + * Once an engine tab is created or selected it leads, so no client page may still claim to be + * active and keep taking workspace automation. With no workspace to scope it to (an engine that + * does not report one), every client page steps back: guessing a workspace would misroute. + */ +export async function yieldClientPageSelection( + host: Pick< + BrowserClientPageQueryHost, + 'getRuntimeBrowserPageRegistry' | 'resolveBrowserWorkspace' + >, + worktree: string | undefined +): Promise { + const pages = host.getRuntimeBrowserPageRegistry() + pages.deactivateGlobal() + const workspaceIds = worktree + ? [(await host.resolveBrowserWorkspace(worktree)).id] + : [...new Set(pages.listPages().map((page) => page.workspaceId))] + for (const workspaceId of workspaceIds) { + pages.deactivateWorkspace(workspaceId) } } From f47d383fc3ae0bf03130f7e395988f4e9df4de04 Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 19 Oct 2026 08:43:34 -0700 Subject: [PATCH 09/11] fix: keep the engine's per-workspace current tab beside desktop-hosted pages --- .../orcad/orcad-client-hosted-browser.test.ts | 14 ++++++++++++++ src/main/orcad/orcad-client-page-commands.ts | 12 +++++++++++- .../browser/browser-client-page-queries.ts | 19 +++++++++++++++---- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/src/main/orcad/orcad-client-hosted-browser.test.ts b/src/main/orcad/orcad-client-hosted-browser.test.ts index 68603f072f28..00fa277f7f52 100644 --- a/src/main/orcad/orcad-client-hosted-browser.test.ts +++ b/src/main/orcad/orcad-client-hosted-browser.test.ts @@ -222,6 +222,7 @@ async function startWithEngine() { const engineTab = { browserPageId: 'engine-page', index: 0, url: '', title: '', active: true } const engine = { browserTabList: vi.fn(async () => ({ tabs: [engineTab] })), + browserTabCurrent: vi.fn(async () => ({ tab: engineTab })), browserTabCreate: vi.fn(async () => ({ browserPageId: 'engine-page' })), browserTabSwitch: vi.fn(async () => ({ switched: 0, browserPageId: 'engine-page' })), browserTabClose: vi.fn(async () => ({ closed: true })) @@ -275,6 +276,19 @@ it('lists desktop-hosted pages after the engine’s tabs, as automation would pi await startup.stop() }) +it('keeps the engine’s own per-workspace current tab where no desktop page is selected', async () => { + const { startup, engine } = await startWithEngine() + const paired = pairedHost({ desktopAttached: true }) + const commands = createRuntimeBrowserCommands(paired.host) + await commands.browserOpenUrlOnClient({ url: 'https://example.org/', worktree: 'id:wt-c' }) + + await expect(commands.browserTabCurrent({ worktree: 'id:wt-a' })).resolves.toMatchObject({ + tab: { browserPageId: 'engine-page' } + }) + expect(engine.browserTabCurrent).toHaveBeenCalledWith({ worktree: 'id:wt-a' }) + await startup.stop() +}) + it('hands workspace commands back to the engine once it creates or selects a tab', async () => { const { startup, engine } = await startWithEngine() const paired = pairedHost({ desktopAttached: true }) diff --git a/src/main/orcad/orcad-client-page-commands.ts b/src/main/orcad/orcad-client-page-commands.ts index b66f5f3de5a5..c5257cc84eca 100644 --- a/src/main/orcad/orcad-client-page-commands.ts +++ b/src/main/orcad/orcad-client-page-commands.ts @@ -6,7 +6,11 @@ import { BrowserError } from '../browser/browser-error' import type { RuntimeBrowserCommands } from '../runtime/browser/browser-commands' import type { RuntimeBrowserCommandHost } from '../runtime/browser/browser-command-contracts' -import type { BrowserTabListResult, BrowserTabSwitchResult } from '../../shared/runtime-types' +import type { + BrowserTabListResult, + BrowserTabShowResult, + BrowserTabSwitchResult +} from '../../shared/runtime-types' import { browserUnavailableError } from '../runtime/runtime-browser-commands-factory' import { browserTabCreateClientPageStartsActive } from '../runtime/browser-tab-create-publication' import { @@ -136,6 +140,8 @@ function engineTabs(forward: Forward): BrowserEngineTabs { return { list: async (params) => requireTabResult(await forward([params], 'browserTabList'), isTabListResult), + current: async (params) => + requireTabResult(await forward([params], 'browserTabCurrent'), isTabShowResult), switchTo: async (params) => requireTabResult(await forward([params], 'browserTabSwitch'), isTabSwitchResult) } @@ -159,6 +165,10 @@ function isTabListResult(value: unknown): value is BrowserTabListResult { ) } +function isTabShowResult(value: unknown): value is BrowserTabShowResult { + return isRecord(value) && isRecord(value.tab) && typeof value.tab.browserPageId === 'string' +} + function isTabSwitchResult(value: unknown): value is BrowserTabSwitchResult { return isRecord(value) && typeof value.browserPageId === 'string' } diff --git a/src/main/runtime/browser/browser-client-page-queries.ts b/src/main/runtime/browser/browser-client-page-queries.ts index 529ddb8596e3..e61fe9b1b11b 100644 --- a/src/main/runtime/browser/browser-client-page-queries.ts +++ b/src/main/runtime/browser/browser-client-page-queries.ts @@ -24,6 +24,8 @@ export type BrowserClientPageQueryHost = Pick< /** The tabs a host's own engine holds, beside the pages desktops host for it. */ export type BrowserEngineTabs = { list(params: { worktree?: string }): Promise + /** The engine's own per-workspace selection, which its list's global flags do not show. */ + current(params: { worktree?: string }): Promise switchTo(params: { page: string worktree?: string @@ -63,11 +65,20 @@ export class BrowserClientPageQueries { } async browserTabCurrent(params: { worktree?: string }): Promise { - const tab = (await this.browserTabList(params)).tabs.find((candidate) => candidate.active) - if (!tab) { - throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') + const listed = await this.browserTabList(params) + const clientTab = listed.tabs.find( + (candidate) => + candidate.active && + this.host.getRuntimeBrowserPageRegistry().getPage(candidate.browserPageId) + ) + if (clientTab) { + return { tab: clientTab } } - return { tab } + if (this.engine) { + // Why the engine's own answer: engine tabs come first, so its index is the logical one. + return this.engine.current(params) + } + throw new BrowserError('browser_no_tab', 'No browser tab open in this worktree') } async browserTabShow(params: { page: string; worktree?: string }): Promise { From 033ca7bcdc8640e2c9e0e95623434762d55527a9 Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 19 Oct 2026 08:55:52 -0700 Subject: [PATCH 10/11] fix: an unscoped current tab matches unscoped automation routing --- src/main/orcad/orcad-client-hosted-browser.test.ts | 2 ++ src/main/runtime/browser/browser-client-page-queries.ts | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/orcad/orcad-client-hosted-browser.test.ts b/src/main/orcad/orcad-client-hosted-browser.test.ts index 00fa277f7f52..03e0b3b64baa 100644 --- a/src/main/orcad/orcad-client-hosted-browser.test.ts +++ b/src/main/orcad/orcad-client-hosted-browser.test.ts @@ -189,6 +189,8 @@ it('lists, shows and switches desktop-hosted tabs with no engine', async () => { await expect(commands.browserTabCurrent({ worktree: 'id:wt-1' })).resolves.toMatchObject({ tab: { browserPageId: first.browserPageId } }) + // Unscoped automation never routes to a client page, so neither does an unscoped current. + await expect(commands.browserTabCurrent({})).rejects.toMatchObject({ code: 'browser_no_tab' }) await expect(commands.browserTabShow({ page: second.browserPageId })).resolves.toMatchObject({ tab: { url: 'https://example.org/b' } }) diff --git a/src/main/runtime/browser/browser-client-page-queries.ts b/src/main/runtime/browser/browser-client-page-queries.ts index e61fe9b1b11b..34ed8cc50535 100644 --- a/src/main/runtime/browser/browser-client-page-queries.ts +++ b/src/main/runtime/browser/browser-client-page-queries.ts @@ -65,7 +65,8 @@ export class BrowserClientPageQueries { } async browserTabCurrent(params: { worktree?: string }): Promise { - const listed = await this.browserTabList(params) + // Why only within a workspace: unscoped automation never routes to a client page either. + const listed = params.worktree ? await this.browserTabList(params) : { tabs: [] } const clientTab = listed.tabs.find( (candidate) => candidate.active && From a3cd947fccc956e4ecc3a5517e558ae6e7fd183c Mon Sep 17 00:00:00 2001 From: Neil Date: Mon, 19 Oct 2026 09:00:38 -0700 Subject: [PATCH 11/11] fix: retained desktop-hosted pages stay listable and closable after their desktop leaves --- src/main/runtime/session-graph/session-graph-authority.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/runtime/session-graph/session-graph-authority.ts b/src/main/runtime/session-graph/session-graph-authority.ts index 8554d145da66..7fdaab0cb1e0 100644 --- a/src/main/runtime/session-graph/session-graph-authority.ts +++ b/src/main/runtime/session-graph/session-graph-authority.ts @@ -232,9 +232,11 @@ export abstract class OrcaRuntimeWithTransitionGraphReloadToTerminalState extend signal: context.signal, hostEnabled: isDesktopBrowserBridgeHostEnabled(), registry: getDesktopBrowserBridgeRegistry(this), - // Why a desktop hosting this runtime's pages counts: those commands never need the bridge. + // Why desktop-hosted pages count, even with their desktop gone: this runtime lists and + // retires them itself, so those commands never need the bridge. localBrowserAvailable: runtimeBrowserCommandsFactoryIsAvailable() || + getRuntimeBrowserPageRegistry(this).listPages().length > 0 || hasPairedPageHost({ getBrowserHostLeaseRegistry: () => getBrowserHostLeaseRegistry(this) }), resolveWorkspace: (selector) => this.resolveBrowserWorkspace(selector) })