Files
orca/src/shared/runtime-browser-contracts.ts
T
Kelvin Amoabaandfanzzzd 0a12f82757 fix(browser): list browser tabs restored after a restart in tab list and load them on switch (#35563)
* feat(browser): list restored tabs in tab list and load them on demand

After a restart Orca restores browser tabs lazily: a tab gets a page only once
the user opens it. tab list only reported tabs with a live page, so an agent in
a worktree with 26 restored tabs saw one and concluded there were none.

tab list now reads the tab bar from the session snapshot the renderer already
publishes on every tab change, and reports every tab in tab-bar order. Tabs
without a page carry loaded: false. tab switch and tab close --index resolve
indexes against the same list, and switching to an unloaded tab loads it in
the background first. Waking a page now also waits for its first load to
finish, so the next command does not read a blank page.

Headless serve keeps every page live, so it lists as before.

* fix(browser): list the tab bar even when Orca has no window open

With the window closed the app keeps running and its restored tabs still exist,
so tab list should report them. Headless snapshots are built from live pages
only, and the merge dedupes those, so dropping the window check lists nothing
extra there.

* fix(browser): list an id held by a live page and a client page once

* fix(browser): wait for the first load of a tab headless serve wakes

Headless serve starts a restored page and returned at once, so the next
command could read a blank page mid-load. It now waits for the first load,
as the window path already did.

* refactor(browser): keep the tab listing beside the browser tab catalog

Main moved the runtime browser commands into runtime/browser/ since this was
written. Also drops a window lookup the catalog no longer uses, and corrects the
headless note: those snapshots list saved pages that have not started yet.

* fix(browser): keep paired-server mirrors out of tab list and close indexed rows like --page

A window also publishes the browser tabs it mirrors from paired runtimes,
which this host can never load, so the tab bar is read only for workspaces
this host owns. tab close --index now closes the listed row exactly as
--page does, keeping its worktree, so an unstarted headless tab is retired
instead of reported closed and left saved. With nothing active, the first
live page is marked current again, matching where a pageless command goes.

* fix(browser): wait for first load only on tab switch, and show tab indexes from the listing

Every --page command wakes an unloaded page, including the tab show a
remote browser pane awaits before streaming, so waiting up to 10 s for the
first load there held the stream back. Only tab switch waits now. tab show
reports a tab's index from the same listing tab list and close use.

* fix(browser): list restored tabs in an SSH host's bridged workspace, and reject fractional close indexes

An SSH host's workspace can have no repo row on the desktop, yet its pages
render in the desktop window, so the bridge's scoped workspace counts as
this host's own. A non-integer --index no longer falls through to closing
the active tab.

* test(browser): give the restored-tabs e2e's timeout and lease wait their reasons

* test(browser): read restored-tabs RPC results through the shared e2e reader

Reuses the browser e2e rpc helper instead of a local Reflect.get copy, and
types the listing test's snapshot rows.

* fix(browser): index tab show in the caller's scope, as tab list does

* fix(browser): report an out-of-range close index as browser_tab_not_found

Matches tab switch, so agents can recover by re-listing. The restored-tabs
e2e now asserts the raw loaded value instead of coercing a missing one.

---------

Co-authored-by: fanzzzd <fanzzzd@outlook.com>
2026-10-30 16:57:48 +00:00

255 lines
8.2 KiB
TypeScript

import type {
BrowserCertificateFailure,
BrowserCookieImportResult,
BrowserLoadError,
BrowserSessionProfile,
BrowserSessionProfileSource
} from './browser-workspace-types'
import type { BROWSER_UNAVAILABLE_ERROR_CODE } from './runtime-session-contracts'
export type BrowserSnapshotRef = { ref: string; role: string; name: string }
// agent-browser's `snapshot --delta` answer; `full` carries the tree, the others change the last one.
export type BrowserSnapshotDelta =
| { kind: 'full'; revision: number; tree: string }
| { kind: 'unchanged'; revision: number; baseRevision: number }
| {
kind: 'delta'
revision: number
baseRevision: number
treeChange?: { startLine: number; deleteCount: number; lines: string[] }
}
export type BrowserSnapshotResult = {
browserPageId: string
snapshot: string | BrowserSnapshotDelta
refs: BrowserSnapshotRef[]
url: string
title: string
}
/** What an agent's page action caused; absent for people's input and from runtimes that predate it. */
export type BrowserActionEffects = {
/** Set only when the action changed them. */
url?: string
title?: string
/** A navigation was still loading at the settle cap, so no page changes are listed. */
loading?: boolean
loadError?: string
/** No browserPageId: the tab was requested but had not opened by the settle cap. */
newTabs?: { browserPageId?: string; url: string }[]
popups?: { action: 'blocked' | 'opened-external' | 'window'; origin: string }[]
downloads?: { filename: string; origin: string }[]
consoleErrors?: string[]
/** Snapshot lines changed since the last reading; `full` is the whole new tree. Absent when unread. */
page?: 'unchanged' | { lines: string[]; removedLines: number; full: boolean; truncated: boolean }
/** False when the page was still changing at the settle cap. */
settled: boolean
}
export type BrowserClickResult = { clicked: string }
export type BrowserGotoResult = { url: string; title: string }
export type BrowserFillResult = { filled: string }
export type BrowserTypeResult = { typed: boolean }
export type BrowserSelectResult = { selected: string }
export type BrowserScrollResult = { scrolled: 'up' | 'down' }
export type BrowserBackResult = { url: string; title: string }
export type BrowserReloadResult = { url: string; title: string }
export type BrowserScreenshotAnnotation = {
ref: string
number: number
role: string
name?: string
box: { x: number; y: number; width: number; height: number }
}
export type BrowserScreenshotResult = {
data: string
format: 'png' | 'jpeg'
// Set only for --if-changed; false means nothing was captured and data is empty.
changed?: boolean
annotations?: BrowserScreenshotAnnotation[]
}
export type BrowserScreencastReadyResult = {
type: 'ready'
subscriptionId: string
browserPageId: string
format: 'jpeg' | 'png'
tab: BrowserTabInfo
}
export type BrowserScreencastEndResult = { type: 'end'; subscriptionId: string }
export type BrowserScreencastDialogResult = { type: 'dialog'; dialogType: string; message: string }
export type BrowserScreencastDialogClosedResult = { type: 'dialogClosed' }
export type BrowserScreencastErrorResult = { type: 'error'; message: string }
export type BrowserScreencastResult =
| BrowserScreencastReadyResult
| BrowserScreencastEndResult
| BrowserScreencastDialogResult
| BrowserScreencastDialogClosedResult
| BrowserScreencastErrorResult
export type BrowserEvalResult = { result: string; origin: string }
export type BrowserTabInfo = {
browserPageId: string
index: number
url: string
title: string
active: boolean
loadError?: BrowserLoadError | null
certificateFailure?: BrowserCertificateFailure | null
worktreeId?: string | null
profileId?: string | null
profileLabel?: string | null
/** Set on a popup window (OAuth, payment): the tab whose page opened it. */
openerBrowserPageId?: string
/** False for a restored tab whose page has not loaded; a command aimed at it loads it. */
loaded?: boolean
}
export type BrowserTabListResult = { tabs: BrowserTabInfo[] }
export type BrowserTabSwitchResult = { switched: number; browserPageId: string }
export type BrowserTabSetProfileResult = {
browserPageId: string
profileId: string | null
profileLabel: string | null
}
export type BrowserTabShowResult = { tab: BrowserTabInfo }
export type BrowserTabCurrentResult = { tab: BrowserTabInfo }
export type BrowserTabProfileShowResult = {
browserPageId: string
worktreeId: string | null
profileId: string | null
profileLabel: string | null
}
export type BrowserTabProfileCloneResult = {
browserPageId: string
sourceBrowserPageId: string
profileId: string | null
profileLabel: string | null
}
export type BrowserProfileListResult = {
profiles: BrowserSessionProfile[]
executorRuntimeId?: string
}
export type BrowserProfileCreateResult = { profile: BrowserSessionProfile | null }
export type BrowserProfileDeleteResult = { deleted: boolean; profileId: string }
export type BrowserDetectedProfileInfo = { name: string; directory: string }
export type BrowserDetectedInfo = {
family: BrowserSessionProfileSource['browserFamily']
label: string
profiles: BrowserDetectedProfileInfo[]
selectedProfile: string
}
export type BrowserDetectProfilesResult = { browsers: BrowserDetectedInfo[] }
export type BrowserProfileImportFromBrowserResult = BrowserCookieImportResult & {
executorRuntimeId?: string
}
export type BrowserProfileClearDefaultCookiesResult = { cleared: boolean }
export type BrowserHoverResult = { hovered: string }
export type BrowserDragResult = { dragged: { from: string; to: string } }
// Older hosts and server-hosted pages report only the count.
export type BrowserUploadResult = { uploaded: number; frameUrl?: string }
export type BrowserWaitResult = { waited: boolean }
export type BrowserCheckResult = { checked: boolean }
export type BrowserFocusResult = { focused: string }
export type BrowserClearResult = { cleared: string }
export type BrowserSelectAllResult = { selected: string }
export type BrowserKeypressResult = { pressed: string }
export type BrowserPdfResult = { data: string }
export type BrowserCookie = {
name: string
value: string
domain: string
path: string
expires: number
httpOnly: boolean
secure: boolean
sameSite: string
}
export type BrowserCookieGetResult = { cookies: BrowserCookie[] }
export type BrowserCookieSetResult = { success: boolean }
export type BrowserCookieDeleteResult = { deleted: boolean }
export type BrowserViewportResult = {
width: number
height: number
deviceScaleFactor: number
mobile: boolean
}
export type BrowserGeolocationResult = {
latitude: number
longitude: number
accuracy: number
}
export type BrowserInterceptedRequest = {
id: string
url: string
method: string
headers: Record<string, string>
resourceType: string
}
export type BrowserInterceptEnableResult = { enabled: boolean; patterns: string[] }
export type BrowserInterceptDisableResult = { disabled: boolean }
export type BrowserConsoleEntry = {
level: string
text: string
timestamp?: number
url?: string
line?: number
}
export type BrowserConsoleResult = { entries: BrowserConsoleEntry[]; truncated: boolean }
export type BrowserNetworkEntry = {
url: string
method: string
// Absent while a request is pending or after it failed.
status?: number
mimeType?: string
size?: number
timestamp?: number
}
export type BrowserNetworkLogResult = { entries: BrowserNetworkEntry[]; truncated: boolean }
export type BrowserCaptureStartResult = { capturing: boolean }
export type BrowserCaptureStopResult = { stopped: boolean }
export type BrowserTabCreateResult = { browserPageId: string }
export type BrowserErrorCode =
| typeof BROWSER_UNAVAILABLE_ERROR_CODE
| 'browser_command_unavailable'
| 'browser_profile_unavailable'
| 'browser_screencast_unavailable'
| 'browser_certificate_trust_unavailable'
| 'browser_no_tab'
| 'browser_tab_not_found'
| 'browser_tab_closed'
| 'browser_tab_changed'
| 'browser_owner_unavailable'
| 'browser_stale_ref'
| 'browser_ref_not_found'
| 'browser_navigation_failed'
| 'browser_element_not_interactable'
| 'browser_eval_error'
| 'browser_cdp_error'
| 'browser_debugger_detached'
| 'browser_timeout'
| 'browser_error'