From 173c4c086763a0d11b09cc7edcf873e930eecb85 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 23 Sep 2026 22:33:25 -0700 Subject: [PATCH 01/10] Decode render modes from one browser-provider registry Every per-provider fact now comes from one table instead of ~13 separate enumerations of the mode pair and scattered provider ternaries. - dor-lib-common's `browser-providers.ts` (renamed from `agent-browser.ts`) holds `BROWSER_PROVIDERS`: each provider's persisted render modes, CLI alias, binary override and default name, install hint and allowlist predicate. `parseRenderMode` decodes a mode to `{provider, presentation}` (anything else is the embed) and `renderModeFor` inverts it. dor's `SurfaceRenderMode` and `BrowserAutomationProvider` derive from it. - lib's `BROWSER_PROVIDER_GUI` holds the GUI half: label, CLI, device presets, viewport hint. `RenderMode`, `BrowserDisplayMode` and its labels and glyphs, the context menu's port targets, the Display modal's provider rows and devices, and the binary gate all read from the two tables. - The context menu's capability is now the list of providers the host can launch, so both of a provider's targets name it when it is missing, and agent-browser's targets read like Playwright's. Persisted `renderMode` strings are unchanged: they are the public `render_mode` and `dormouse.yml` `render` values. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01NCV5Uq6FeZbBfzfpk3vrpe --- docs/specs/dor-browser.md | 24 ++++- docs/specs/dor-cli.md | 2 +- docs/specs/standalone.md | 2 +- dor-lib-common/package.json | 6 +- ...{agent-browser.ts => browser-providers.ts} | 95 +++++++++++++++++- dor-lib-common/src/index.ts | 14 ++- ...er.test.mjs => browser-providers.test.mjs} | 19 +++- dor/src/commands/types.ts | 6 +- lib/.storybook/main.ts | 2 +- lib/src/components/Wall.test.tsx | 2 +- lib/src/components/Wall.tsx | 13 +-- lib/src/components/wall/AgentBrowserPanel.tsx | 4 +- .../wall/AgentBrowserScreenModal.tsx | 39 +++----- .../components/wall/BrowserDisplayIcon.tsx | 42 ++++---- .../components/wall/TerminalContext.test.tsx | 9 +- lib/src/components/wall/TerminalContext.tsx | 3 +- .../components/wall/TerminalContextView.tsx | 29 ++++-- .../components/wall/agent-browser-screen.ts | 23 +++-- .../wall/agent-browser-surface-controller.ts | 41 +++++--- .../wall/browser-automation.test.ts | 33 ++++--- lib/src/components/wall/browser-automation.ts | 98 +++++++++---------- lib/src/components/wall/browser-surface.ts | 12 +-- lib/src/components/wall/use-dor-control.ts | 19 ++-- lib/src/lib/agent-browser-binary.ts | 10 +- lib/src/stories/TerminalContext.stories.tsx | 2 +- lib/vite.config.ts | 2 +- scripts/spec-word-budgets.json | 2 +- standalone/scripts/dev-agent-browser.mjs | 2 +- standalone/scripts/dev-agent-browser.test.mjs | 2 +- standalone/tsconfig.json | 2 +- 30 files changed, 359 insertions(+), 200 deletions(-) rename dor-lib-common/src/{agent-browser.ts => browser-providers.ts} (59%) rename dor-lib-common/test/{agent-browser.test.mjs => browser-providers.test.mjs} (60%) diff --git a/docs/specs/dor-browser.md b/docs/specs/dor-browser.md index ef0733b96..52495e876 100644 --- a/docs/specs/dor-browser.md +++ b/docs/specs/dor-browser.md @@ -31,6 +31,26 @@ Source of truth: `lib/src/components/wall/BrowserPanel.tsx`, (`BODY_COMPONENTS`), `lib/src/components/Wall.tsx` (`surfaceRenderModeFromParams`, `createContentSurface`). +## Providers + +An automated renderer belongs to one **provider**, the CLI that drives its +browser. **Must read every per-provider fact from the one registry** — render +modes, CLI, binary for `dor`, the hosts and the webview; label, device presets +and viewport hint for the GUI — never a ternary on the provider or a mode +prefix. **Never change a persisted mode string**: they are the public +`render_mode` and `dormouse.yml` `render` values. `parseRenderMode` decodes one +to its provider and presentation (`screencast` / `popout`), reading anything +else as `iframe`. + +| Provider | CLI | Render modes | Binary override / name | Install | +| --- | --- | --- | --- | --- | +| agent-browser | `dor ab` | `ab-screencast`, `ab-popout` | `DORMOUSE_AGENT_BROWSER_BIN` / `agent-browser` | `npm i -g agent-browser` | +| Playwright | `dor pw` | `pw-screencast`, `pw-popout` | `DORMOUSE_PLAYWRIGHT_BIN` / `playwright-cli` | `npm i -g @playwright/cli` | + +Source of truth: `BROWSER_PROVIDERS` and `parseRenderMode` in +`dor-lib-common/src/browser-providers.ts`; `BROWSER_PROVIDER_GUI` in +`lib/src/components/wall/browser-automation.ts`. + ## Canonical Params Invariants on the flat persisted `BrowserPanelParams`: @@ -262,7 +282,7 @@ Spawning External Binaries). or render-swapped mid-command leaves the trailing request to mint a fresh pane (rationale). -Source of truth: `sessionForKey` in `dor-lib-common/src/agent-browser.ts`, +Source of truth: `sessionForKey` in `dor-lib-common/src/browser-providers.ts`, `resolveSession` in `dor/src/commands/agent-browser.ts`, `dor/src/commands/types.ts` (`AgentBrowserSurfaceRequest`, `ResolveAgentBrowserSessionRequest`), `lib/src/components/Wall.tsx` / `lib/src/components/wall/use-dor-control.ts` (`findAgentBrowserSurface`, `surface.agentBrowser`, @@ -469,7 +489,7 @@ header; standalone connects directly. Source of truth: `lib/src/host/agent-browser-host.ts` (`runWithBinaryFallback`), `lib/src/host/browser-host-shared.ts` (`parseWebviewCommand`, `isAgentBrowserSession`, `isPlaywrightSession`), -`dor-lib-common/src/agent-browser.ts` (`isAllowedAgentBrowserBinary`), +`dor-lib-common/src/browser-providers.ts` (`isAllowedAgentBrowserBinary`), `lib/src/host/private-capture-dir.ts`, `lib/src/host/browser-stream-guard.ts`, `vscode-ext/src/agent-browser-host.ts`, `vscode-ext/src/webview-html.ts`, `standalone/src/tauri-adapter.ts`, `standalone/src-tauri/src/lib.rs`, diff --git a/docs/specs/dor-cli.md b/docs/specs/dor-cli.md index c5c0cdd57..06d923440 100644 --- a/docs/specs/dor-cli.md +++ b/docs/specs/dor-cli.md @@ -571,7 +571,7 @@ in `dor/src/protocol.ts`, the `surface.resolveOpen` handler in **Never spawn a bound executable the host's Playwright allowlist refuses**, since the binding comes back off persisted params; run the caller's own resolution instead. The caller's `DORMOUSE_PLAYWRIGHT_BIN` is the exact-match override. **Must run the caller's own executable, with a stderr warning, when the bound one is gone**, and **must fail naming a bound cwd that no longer exists** rather than report playwright-cli missing. -Source of truth: `runPlaywrightCli` and `resolveBinding` in `dor/src/commands/playwright.ts`; `BrowserBinding` in `dor/src/commands/types.ts`; `isAllowedPlaywrightBinary` in `dor-lib-common/src/agent-browser.ts`; `spawnAndCapture` in `dor-lib-common/src/spawn.ts`. Pinned by `dor/test/playwright.test.mjs`. +Source of truth: `runPlaywrightCli` and `resolveBinding` in `dor/src/commands/playwright.ts`; `BrowserBinding` in `dor/src/commands/types.ts`; `isAllowedPlaywrightBinary` in `dor-lib-common/src/browser-providers.ts`; `spawnAndCapture` in `dor-lib-common/src/spawn.ts`. Pinned by `dor/test/playwright.test.mjs`. ## Agent-Browser Surface Addressing diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 1e24915ed..913ca723c 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -1351,4 +1351,4 @@ The bridge is a transport shim over the same sidecar protocol, not a second PTY The harness **may omit** native-only desktop chrome (window controls, update checks) but **must preserve** every `PlatformAdapter` contract the app uses — PTY, control-request, clipboard, iframe-proxy, Burrow, agent-browser, Playwright, and the sidecar's alerts (`alert_command` in, stamped with the one window label the harness simulates, `main`; their events back; §Alerts) — so a rule that only holds across the host boundary is exercised rather than answered by a private copy. **`BrowserSidecarHost.init()` resolves on the SSE stream being open, not on its construction**, so a seed cannot precede the stream that carries its reply; **must let retryable connection failures reconnect within the open timeout**; after a reconnect the adapter sends `sync`, since whatever the sidecar sent while the stream was down is gone (`resolves on the stream's open event, not on construction` in `standalone/src/browser-sidecar-host.test.ts`; `asks the sidecar to sync when the event stream reconnects` in `standalone/src/browser-sidecar-adapter.test.ts`). Fatal startup handling follows §Boot sequence. It **must mirror** standalone's Session-persistence answer (`docs/specs/transport.md` → "The governing rule"): one `PersistedWindow` per window, in `localStorage` rather than the Rust file store, and the same agent-recovery *claim* against a per-run temp state directory. **The harness must never capture**: a reload there is a live resume over PTYs that survive it, and capture is a quit-only step (§Agent recovery). **Tauri APIs must not be required at static module-evaluation time** when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set — a normal browser loads the page, not the Tauri WebView. -Source of truth: `standalone/scripts/dev-agent-browser.mjs`, `standalone/scripts/dev-run.mjs`, `standalone/scripts/dev-host-guard.mjs`, `standalone/src/browser-sidecar-host.ts`, `standalone/src/browser-sidecar-adapter.ts`; `stepBurrow` in `scripts/pairing-walkthrough/steps.mjs`; `sessionForKey` in `dor-lib-common/src/agent-browser.ts`. +Source of truth: `standalone/scripts/dev-agent-browser.mjs`, `standalone/scripts/dev-run.mjs`, `standalone/scripts/dev-host-guard.mjs`, `standalone/src/browser-sidecar-host.ts`, `standalone/src/browser-sidecar-adapter.ts`; `stepBurrow` in `scripts/pairing-walkthrough/steps.mjs`; `sessionForKey` in `dor-lib-common/src/browser-providers.ts`. diff --git a/dor-lib-common/package.json b/dor-lib-common/package.json index 2515c1c22..f5098a08a 100644 --- a/dor-lib-common/package.json +++ b/dor-lib-common/package.json @@ -9,9 +9,9 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, - "./agent-browser": { - "types": "./dist/agent-browser.d.ts", - "default": "./dist/agent-browser.js" + "./browser-providers": { + "types": "./dist/browser-providers.d.ts", + "default": "./dist/browser-providers.js" } }, "scripts": { diff --git a/dor-lib-common/src/agent-browser.ts b/dor-lib-common/src/browser-providers.ts similarity index 59% rename from dor-lib-common/src/agent-browser.ts rename to dor-lib-common/src/browser-providers.ts index 41079358b..a53d1e935 100644 --- a/dor-lib-common/src/agent-browser.ts +++ b/dor-lib-common/src/browser-providers.ts @@ -1,3 +1,21 @@ +/** + * The browser-automation providers, as `dor`, the Node hosts and the webview + * all need them: each one's persisted render modes, its CLI, and what may be + * spawned as that CLI (docs/specs/dor-browser.md → "Providers"). The GUI half + * of the registry — labels, device lists — lives in lib + * (`lib/src/components/wall/browser-automation.ts`). + * + * Free of Node dependencies (no `node:path`), so the same module runs in the + * webview, the Node hosts, and `dor`. + */ + +/** A browser-automation provider: whose CLI drives a browser Surface. */ +export type BrowserAutomationProvider = 'agent-browser' | 'playwright'; + +/** Where an automated browser is shown: streamed into the pane, or as its own + * headed OS window. */ +export type BrowserPresentation = 'screencast' | 'popout'; + // The scope a Window with one implicit Workspace answers with: a bare Wall (VS // Code, the website, Pocket) has no Workspace id of its own, so its keys keep // the names they have always had. Private: callers build session names through @@ -39,9 +57,9 @@ export const DEFAULT_PLAYWRIGHT_BIN = 'playwright-cli'; * Everything else is refused and the spawner falls through to its own * candidates. * - * Free of Node dependencies (no `node:path`) so the same predicate runs in the - * webview — which validates persisted params before they are ever sent — in - * the Node hosts, which validate again at the spawn, and in `dor`. + * The same predicate runs in the webview — which validates persisted params + * before they are ever sent — in the Node hosts, which validate again at the + * spawn, and in `dor`. */ // The Windows PATH shims npm/vfox install alongside the POSIX executable. @@ -88,6 +106,77 @@ function isAllowedBrowserBinary(candidate: unknown, configuredPath: string | und return filename.test(segments[segments.length - 1] ?? ''); } +/** One provider's row. `alias` is its short `dor` command and the prefix of + * its render modes; `modes` are the persisted `renderMode` strings, which are + * also the public `render_mode` and `dormouse.yml` `render` values. */ +interface BrowserProviderSpec { + command: string; + alias: string; + modes: Readonly>; + binEnv: string; + defaultBin: string; + installHint: string; + isAllowedBinary(candidate: unknown, configuredPath?: string): candidate is string; +} + +export const BROWSER_PROVIDERS = { + 'agent-browser': { + command: 'agent-browser', + alias: 'ab', + modes: { screencast: 'ab-screencast', popout: 'ab-popout' }, + binEnv: AGENT_BROWSER_BIN_ENV, + defaultBin: DEFAULT_AGENT_BROWSER_BIN, + installHint: 'npm i -g agent-browser', + isAllowedBinary: isAllowedAgentBrowserBinary, + }, + playwright: { + command: 'playwright', + alias: 'pw', + modes: { screencast: 'pw-screencast', popout: 'pw-popout' }, + binEnv: PLAYWRIGHT_BIN_ENV, + defaultBin: DEFAULT_PLAYWRIGHT_BIN, + installHint: 'npm i -g @playwright/cli', + isAllowedBinary: isAllowedPlaywrightBinary, + }, +} as const satisfies Record; + +/** Every provider, in the order the GUI lists them. */ +export const BROWSER_PROVIDER_IDS = Object.keys(BROWSER_PROVIDERS) as BrowserAutomationProvider[]; + +/** An automated render mode: a provider's screencast or popout. */ +export type AutomatedRenderMode = (typeof BROWSER_PROVIDERS)[BrowserAutomationProvider]['modes'][BrowserPresentation]; + +/** Every render mode a browser Surface can take; `iframe` is the embed. */ +export type SurfaceRenderMode = 'iframe' | AutomatedRenderMode; + +/** A render mode decoded: its provider and presentation, or the embed. */ +export type ParsedRenderMode = + | { provider: BrowserAutomationProvider; presentation: BrowserPresentation; mode: AutomatedRenderMode } + | { provider: null; presentation: 'iframe'; mode: 'iframe' }; + +const PARSED_MODES = new Map(BROWSER_PROVIDER_IDS.flatMap((provider) => + (Object.keys(BROWSER_PROVIDERS[provider].modes) as BrowserPresentation[]).map((presentation) => { + const mode = BROWSER_PROVIDERS[provider].modes[presentation]; + return [mode, { provider, presentation, mode }] as const; + }))); +const EMBED: ParsedRenderMode = { provider: null, presentation: 'iframe', mode: 'iframe' }; + +/** Decode a render mode. Anything but an automated mode — `iframe`, an absent + * one, or an unknown persisted string — is the embed. */ +export function parseRenderMode(mode: unknown): ParsedRenderMode { + return (typeof mode === 'string' && PARSED_MODES.get(mode)) || EMBED; +} + +/** The render mode showing `provider`'s browser as `presentation`. */ +export function renderModeFor(provider: BrowserAutomationProvider, presentation: BrowserPresentation): AutomatedRenderMode { + return BROWSER_PROVIDERS[provider].modes[presentation]; +} + +/** Whether `value` names a provider. */ +export function isBrowserProvider(value: unknown): value is BrowserAutomationProvider { + return typeof value === 'string' && Object.prototype.hasOwnProperty.call(BROWSER_PROVIDERS, value); +} + /** argv for `agent-browser stream status --json` against a session — the command * whose output {@link parseStreamPort} reads. */ export function streamStatusArgs(session: string): string[] { diff --git a/dor-lib-common/src/index.ts b/dor-lib-common/src/index.ts index dac70e510..1ac33617f 100644 --- a/dor-lib-common/src/index.ts +++ b/dor-lib-common/src/index.ts @@ -7,13 +7,25 @@ export { resolveBinaryPath, } from './resolve-binary.js'; export { + BROWSER_PROVIDER_IDS, + BROWSER_PROVIDERS, isAllowedAgentBrowserBinary, isAllowedPlaywrightBinary, + isBrowserProvider, + parseRenderMode, parseStreamPort, + renderModeFor, sessionForKey, streamStatusArgs, AGENT_BROWSER_BIN_ENV, DEFAULT_AGENT_BROWSER_BIN, PLAYWRIGHT_BIN_ENV, DEFAULT_PLAYWRIGHT_BIN, -} from './agent-browser.js'; +} from './browser-providers.js'; +export type { + AutomatedRenderMode, + BrowserAutomationProvider, + BrowserPresentation, + ParsedRenderMode, + SurfaceRenderMode, +} from './browser-providers.js'; diff --git a/dor-lib-common/test/agent-browser.test.mjs b/dor-lib-common/test/browser-providers.test.mjs similarity index 60% rename from dor-lib-common/test/agent-browser.test.mjs rename to dor-lib-common/test/browser-providers.test.mjs index 2a71c15e5..9bd07cb4f 100644 --- a/dor-lib-common/test/agent-browser.test.mjs +++ b/dor-lib-common/test/browser-providers.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { parseStreamPort, sessionForKey } from '../dist/index.js'; +import { BROWSER_PROVIDER_IDS, parseRenderMode, parseStreamPort, renderModeFor, sessionForKey } from '../dist/index.js'; test('sessionForKey namespaces a key under the workspace', () => { assert.equal(sessionForKey('default'), 'dormouse.1.default'); @@ -29,3 +29,20 @@ test('parseStreamPort returns undefined for malformed or portless output', () => assert.equal(parseStreamPort(JSON.stringify({ data: {} })), undefined); assert.equal(parseStreamPort(JSON.stringify({ port: 'nope' })), undefined); }); + +test('parseRenderMode decodes every automated mode and renderModeFor inverts it', () => { + for (const provider of BROWSER_PROVIDER_IDS) { + for (const presentation of ['screencast', 'popout']) { + const mode = renderModeFor(provider, presentation); + assert.deepEqual(parseRenderMode(mode), { provider, presentation, mode }); + } + } + assert.equal(renderModeFor('agent-browser', 'screencast'), 'ab-screencast'); + assert.equal(renderModeFor('playwright', 'popout'), 'pw-popout'); +}); + +test('parseRenderMode reads anything else as the embed, inherited names included', () => { + for (const mode of ['iframe', undefined, null, 'constructor', 'toString', 'ab-', 7]) { + assert.deepEqual(parseRenderMode(mode), { provider: null, presentation: 'iframe', mode: 'iframe' }); + } +}); diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index ec5a91ea4..e5eb0f651 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -3,12 +3,14 @@ import type { CommandContext, StricliProcess, } from '@stricli/core'; +import type { BrowserAutomationProvider, SurfaceRenderMode } from 'dor-lib-common/browser-providers'; + +export type { BrowserAutomationProvider, SurfaceRenderMode }; export type IdFormat = 'refs' | 'ids' | 'both'; export type SplitDirection = 'left' | 'right' | 'up' | 'down' | 'auto'; export type ResolvedSplitDirection = 'left' | 'right' | 'up' | 'down'; export type SurfaceKind = 'terminal' | 'browser' | 'tool'; -export type SurfaceRenderMode = 'iframe' | 'ab-screencast' | 'ab-popout' | 'pw-screencast' | 'pw-popout'; /** What each kind is backed by (`docs/specs/glossary.md` → Panes and Surfaces). * The single source of capability gating; kind switches elsewhere go through @@ -434,8 +436,6 @@ export interface ResolveAgentBrowserSessionResponse { session: string; } -export type BrowserAutomationProvider = 'agent-browser' | 'playwright'; - /** What a provider's CLI command runs with: its native session, the project * directory it runs in, and the executable. */ export interface BrowserBinding { diff --git a/lib/.storybook/main.ts b/lib/.storybook/main.ts index 0ee90894e..4daa9fc92 100644 --- a/lib/.storybook/main.ts +++ b/lib/.storybook/main.ts @@ -50,7 +50,7 @@ const config: StorybookConfig = { // specifier to source too. 'remote-lib-common': path.resolve(here, '..', '..', 'remote-lib-common', 'src'), // And `Wall` → `useDorControl` → `connect-port` imports - // `dor-lib-common/agent-browser`, whose `exports` point at the same kind of + // `dor-lib-common/browser-providers`, whose `exports` point at the same kind of // unbuilt `dist`. The directory alias covers the subpath and the bare // specifier both. 'dor-lib-common': path.resolve(here, '..', '..', 'dor-lib-common', 'src'), diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index c504e2b6b..825be18f8 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -10,7 +10,7 @@ import { act } from 'react'; import { type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SURFACE_CONTROL_METHODS } from 'dor/protocol'; -import { sessionForKey } from 'dor-lib-common/agent-browser'; +import { sessionForKey } from 'dor-lib-common/browser-providers'; import { Wall } from './Wall'; import * as helpers from '../lib/helper-terminal'; import * as agentBrowserScreen from './wall/agent-browser-screen'; diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 83aba5e0e..79248a2c9 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -24,7 +24,8 @@ const RemotePairingModalHost = lazy(() => })), ); import { getAgentBrowserScreenController } from './wall/agent-browser-screen'; -import { automationProvider, browserPlatform, PROVIDER_LABEL } from './wall/browser-automation'; +import { BROWSER_PROVIDER_GUI, browserPlatform } from './wall/browser-automation'; +import { parseRenderMode } from 'dor-lib-common/browser-providers'; import { isToolRender } from '../lib/platform/tool-types'; import { closeBrowserSurface, requestBrowserRenderMode, whenBrowserLaunched } from './wall/agent-browser-surface-controller'; import { KILL_CONFIRM_MS, KILL_SHAKE_MS, KillConfirmOverlay, randomKillChar, type ConfirmKill } from './KillConfirm'; @@ -2014,8 +2015,8 @@ export function Wall({ // the browser at the URL — headed for a popout, so it mounts already // popped out — and binds the session it answers with // (docs/specs/dor-browser.md → "Display Modal And Render Swaps"). - const provider = automationProvider(mode); - const currentProvider = automationProvider(currentRenderMode); + const provider = parseRenderMode(mode).provider; + const currentProvider = parseRenderMode(currentRenderMode).provider; if (currentRenderMode !== null && provider !== null && provider !== currentProvider) { const chromeUrl = getAgentBrowserScreenController(id)?.chrome().url; const rawUrl = (typeof chromeUrl === 'string' && chromeUrl) @@ -2032,7 +2033,7 @@ export function Wall({ const url = browserSurfaceUrl(rawUrl); if (!url) { const why = rawUrl ? `'${rawUrl}' is not an http(s) URL` : 'no URL observed yet'; - console.warn(`[dormouse] cannot swap surface '${id}' to ${PROVIDER_LABEL[provider]}: ${why}`); + console.warn(`[dormouse] cannot swap surface '${id}' to ${BROWSER_PROVIDER_GUI[provider].label}: ${why}`); return; } if (!browserPlatform(provider, cwd).agentBrowserOpen) return; @@ -2101,7 +2102,7 @@ export function Wall({ if (mode === 'system') { getPlatform().openExternal?.(entry.url); return; } const cwd = getTerminalPaneState(id)?.cwd?.path; // Null for the iframe embed, which launches no browser. - const provider = automationProvider(mode); + const provider = parseRenderMode(mode).provider; const platform = provider ? browserPlatform(provider, cwd) : null; // Persisted as `contextPortKey`: agent-browser keeps the `agent` it had // before Playwright, so a restored pane is still found and revealed. @@ -2121,7 +2122,7 @@ export function Wall({ else updateSurfaceParams(existing.id, { url: entry.url }); return; } - if (provider && !platform?.agentBrowserOpen) throw new Error(`${PROVIDER_LABEL[provider]} is unavailable on this host`); + if (provider && !platform?.agentBrowserOpen) throw new Error(`${BROWSER_PROVIDER_GUI[provider].label} is unavailable on this host`); const created = createContentSurface({ minimized: false, reference, preserveSource: true, params: { surfaceType: 'browser', renderMode: mode, url: entry.url, cwd, syncEngaged: true, contextPortKey: key, diff --git a/lib/src/components/wall/AgentBrowserPanel.tsx b/lib/src/components/wall/AgentBrowserPanel.tsx index 71f96927c..bbc20c095 100644 --- a/lib/src/components/wall/AgentBrowserPanel.tsx +++ b/lib/src/components/wall/AgentBrowserPanel.tsx @@ -7,7 +7,7 @@ import { isEditableTarget } from '../../lib/dom'; import type { RenderMode } from './agent-browser-screen'; import { tabDisplayTitle } from './browser-url'; import { resolveRenderMode } from './browser-surface'; -import { automationCli, surfaceProvider } from './browser-automation'; +import { BROWSER_PROVIDER_GUI, surfaceProvider } from './browser-automation'; import { MOUSE_BUTTONS, MOUSE_BUTTON_MASKS, modifiers } from './agent-browser-input'; import { acquireAgentBrowserSurfaceController, @@ -55,7 +55,7 @@ export function AgentBrowserPanel({ id, params: rawParams, parked, renderMode: r // back to resolving it from params for a direct mount (tests) / legacy blob. const seededMode = renderModeProp ?? resolveRenderMode(params); const provider = surfaceProvider(seededMode); - const cli = automationCli(provider); + const cli = BROWSER_PROVIDER_GUI[provider].cli; // The surface-scoped controller: get-or-create, keyed by surface id. Survives // this component's unmount (minimize, layout churn, StrictMode). Keyed by diff --git a/lib/src/components/wall/AgentBrowserScreenModal.tsx b/lib/src/components/wall/AgentBrowserScreenModal.tsx index 63c4b2e70..b2b9b5c33 100644 --- a/lib/src/components/wall/AgentBrowserScreenModal.tsx +++ b/lib/src/components/wall/AgentBrowserScreenModal.tsx @@ -29,7 +29,8 @@ import { } from '../design'; import type { RenderMode, ScreenController, ScreenSnapshot } from './agent-browser-screen'; import { browserDisplayMode, useAgentBrowserChromeSnapshot, useAgentBrowserScreenSnapshot } from './agent-browser-screen'; -import { AUTOMATION_PROVIDERS, automationMode, automationProvider, isScreencast, PROVIDER_LABEL } from './browser-automation'; +import { BROWSER_PROVIDER_IDS, parseRenderMode, renderModeFor } from 'dor-lib-common/browser-providers'; +import { BROWSER_PROVIDER_GUI } from './browser-automation'; import { iframeRefusal } from './browser-url'; import { AgentRobotIcon, @@ -38,21 +39,6 @@ import { BrowserPresentationIcon, } from './BrowserDisplayIcon'; -// Fixed registry — the CLI's own device set. No custom descriptors; touch + -// mobile UA come only bundled inside `set device` (verified against 0.27.0). -const DEVICES = [ - 'iPhone 15', - 'iPhone 16', - 'iPhone 16 Pro', - 'iPhone 17', - 'iPad', - 'iPad Pro', - 'Pixel 9', - 'Galaxy S25', -] as const; - -const PLAYWRIGHT_DEVICES = ['iPhone 15', 'iPhone 16', 'iPhone 16 Pro', 'iPhone 17', 'iPad (gen 11)', 'iPad Pro 11', 'Pixel 9', 'Galaxy S24']; - type Target = 'sync' | 'device' | 'custom'; export function AgentBrowserScreenModal({ @@ -80,7 +66,7 @@ export function AgentBrowserScreenModal({ // A fixed device can't be pre-matched — the CLI exposes no dims map. const initialTarget: Target = initial?.syncEngaged ? 'sync' : 'custom'; const [target, setTarget] = useState(initialTarget); - const [device, setDevice] = useState(DEVICES[1]); // iPhone 16 + const [device, setDevice] = useState('iPhone 16'); const [customW, setCustomW] = useState(String(initial?.viewport.w ?? 1280)); const [customH, setCustomH] = useState(String(initial?.viewport.h ?? 720)); const [customDpi, setCustomDpi] = useState(String(initial?.viewport.dpr ?? 1)); @@ -97,7 +83,10 @@ export function AgentBrowserScreenModal({ const embedRefusal = currentMode === 'iframe' ? null : iframeRefusal(chrome?.url ?? ''); // Only the screencast backend has a Dormouse-settable viewport; pop-out is a // native OS window and embed renders at the pane size, so both grey it out. - const viewportDisabled = !isScreencast(renderMode); + const selected = parseRenderMode(renderMode); + const viewportDisabled = selected.presentation !== 'screencast'; + // Each screencast's device presets are its own provider's. + const devices = BROWSER_PROVIDER_GUI[selected.provider ?? 'agent-browser'].devices; // Whether Apply changes the render backend (vs only tweaking the current // screencast's viewport). A swap is gated on whether its option is shown, not // on the viewport-drive capability below. @@ -135,7 +124,7 @@ export function AgentBrowserScreenModal({ // A mode swap; the viewport sub-controls don't apply to the outgoing // surface (and are inert on embed/popout controllers anyway). controller.actions.setRenderMode?.(renderMode); - } else if (isScreencast(renderMode)) { + } else if (!viewportDisabled) { if (target === 'sync') controller.actions.engageSync(); else if (target === 'device') controller.actions.applyDevice(device); else controller.actions.applyViewport(Number(customW), Number(customH), Number(customDpi)); @@ -195,7 +184,7 @@ export function AgentBrowserScreenModal({ className="rounded border border-border bg-app-bg px-1.5 py-1 font-mono text-foreground outline-none focus:border-focus-ring" > - {(automationProvider(renderMode) === 'playwright' ? PLAYWRIGHT_DEVICES : DEVICES).map((name) => ( + {devices.map((name) => ( ))} @@ -230,9 +219,9 @@ export function AgentBrowserScreenModal({
{/* Screencast owns the robot capability glyph; its nested resolution modes append the presentation glyph. */} - {AUTOMATION_PROVIDERS.map((provider) => { - const screencast = automationMode(provider, false); - const popout = automationMode(provider, true); + {BROWSER_PROVIDER_IDS.map((provider) => { + const screencast = renderModeFor(provider, 'screencast'); + const popout = renderModeFor(provider, 'popout'); if (!offered(screencast) && !offered(popout)) return null; const popoutDisplay = browserDisplayMode({ renderMode: popout, syncEngaged: false }); return ( @@ -242,7 +231,7 @@ export function AgentBrowserScreenModal({ checked={renderMode === screencast} onSelect={() => setRenderMode(screencast)} icon={} - label={`${PROVIDER_LABEL[provider]} screencast`} + label={`${BROWSER_PROVIDER_GUI[provider].label} screencast`} features={[[true, 'agents can read/write'], [true, 'any URL'], [false, 'laggy for humans']]} > {renderMode === screencast &&
{viewportControls}
} @@ -278,7 +267,7 @@ export function AgentBrowserScreenModal({ {!hostCapable && !viewportDisabled && !switchingMode && (

- This host can't drive the browser viewport; run {automationProvider(currentMode) === 'playwright' ? 'dor pw resize …' : 'dor ab set …'} from a + This host can't drive the browser viewport; run {BROWSER_PROVIDER_GUI[parseRenderMode(currentMode).provider ?? 'agent-browser'].viewportHint} from a terminal instead.

)} diff --git a/lib/src/components/wall/BrowserDisplayIcon.tsx b/lib/src/components/wall/BrowserDisplayIcon.tsx index 16f8799db..9f7cf41c8 100644 --- a/lib/src/components/wall/BrowserDisplayIcon.tsx +++ b/lib/src/components/wall/BrowserDisplayIcon.tsx @@ -5,30 +5,32 @@ import { type Icon, PictureInPictureIcon, } from '@phosphor-icons/react'; +import { BROWSER_PROVIDER_IDS, BROWSER_PROVIDERS } from 'dor-lib-common/browser-providers'; import type { BrowserDisplayMode } from './agent-browser-screen'; +import { BROWSER_PROVIDER_GUI } from './browser-automation'; -export const BROWSER_DISPLAY_LABEL: Record = { - 'pw-resize': 'Playwright resizes with pane', - 'pw-fixed': 'Playwright fixed size', - 'pw-popout': 'Playwright popout', - 'ab-resize': 'agent-browser resizes with pane', - 'ab-fixed': 'agent-browser fixed size', - 'ab-popout': 'agent-browser popout', - iframe: 'iframe embed', -}; - -/** How the human view is presented, keyed like `BROWSER_DISPLAY_LABEL` so a new - * mode is a compile error in both rather than a silent fall-through. */ -const PRESENTATION_ICON: Record = { - 'pw-resize': FrameCornersIcon, - 'pw-fixed': PictureInPictureIcon, - 'pw-popout': ArrowSquareOutIcon, - 'ab-resize': FrameCornersIcon, - 'ab-fixed': PictureInPictureIcon, - 'ab-popout': ArrowSquareOutIcon, +type AutomatedView = 'resize' | 'fixed' | 'popout'; +const VIEW_LABEL: Record = { resize: 'resizes with pane', fixed: 'fixed size', popout: 'popout' }; +/** How the human view is presented: one glyph per view, the embed framed like + * a pane-sized screencast. */ +const VIEW_ICON: Record = { + resize: FrameCornersIcon, + fixed: PictureInPictureIcon, + popout: ArrowSquareOutIcon, iframe: FrameCornersIcon, }; +/** Every display mode's label: ` `, and the embed. */ +export const BROWSER_DISPLAY_LABEL = Object.fromEntries([ + ...BROWSER_PROVIDER_IDS.flatMap((provider) => (Object.keys(VIEW_LABEL) as AutomatedView[]).map((view) => + [`${BROWSER_PROVIDERS[provider].alias}-${view}`, `${BROWSER_PROVIDER_GUI[provider].label} ${VIEW_LABEL[view]}`])), + ['iframe', 'iframe embed'], +]) as Record; + +function viewOf(mode: BrowserDisplayMode): AutomatedView | 'iframe' { + return mode === 'iframe' ? 'iframe' : mode.slice(mode.indexOf('-') + 1) as AutomatedView; +} + /** Compact custom robot whose wide silhouette survives the 12–14px chrome. */ export function AgentRobotIcon({ size, @@ -68,7 +70,7 @@ export function BrowserPresentationIcon({ size: number; className?: string; }) { - const Glyph = PRESENTATION_ICON[mode]; + const Glyph = VIEW_ICON[viewOf(mode)]; return ; } diff --git a/lib/src/components/wall/TerminalContext.test.tsx b/lib/src/components/wall/TerminalContext.test.tsx index d76e7a22a..f766113da 100644 --- a/lib/src/components/wall/TerminalContext.test.tsx +++ b/lib/src/components/wall/TerminalContext.test.tsx @@ -28,7 +28,7 @@ beforeEach(() => { setPlatform(new FakePtyAdapter()); ensureResizeObserver(); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); props = { title: 'pnpm dev', surfaceRef: 'surface:3', cwd: '~/repo', titleSources: [{ source: 'OSC 2', value: 'pnpm dev', note: 'Used' }], scan: { status: 'loaded', entries: [port(5173)] }, - watchRule: 'pnpm', watching: false, todo: false, status: 'completed', command: 'git status', explorerLabel: 'Open in Finder', canExplore: true, canAgent: true, canIframe: true, + watchRule: 'pnpm', watching: false, todo: false, status: 'completed', command: 'git status', explorerLabel: 'Open in Finder', canExplore: true, browserProviders: ['agent-browser', 'playwright'], canIframe: true, onClose: vi.fn(), onCopyRef: vi.fn(), onCopyPath: vi.fn(), onExplore: vi.fn(), onWatch: vi.fn(), onTodo: vi.fn(), onPort: vi.fn(), onModify: vi.fn(async () => {}), onReset: vi.fn(async () => {}), onPromote: vi.fn(async () => {}), children: