fix(serve): restore a remote server's browser tabs after it restarts (#28166)

* feat(serve): keep browser tabs across an orca serve restart

A host with no window now saves the browser tabs it opens in the same shape a
desktop window saves its own, and restores them the same way: listed at once
with their last address, each page started when a client first opens or
drives it. A closed tab is forgotten; a page that ends with the process is not
a close.

* test(e2e): the server's published strip now holds its saved browser tab

* fix(serve): save browser addresses cheaply, open once, restore by page id

Review fixes: a page's address is written onto its saved records without
reloading the layout, and only on committed navigations; reopening a saved
page writes nothing; a command naming only the page finds the workspace that
saved it; a refused save no longer throws from a page event or a close. The
browser-tab map is renamed to say it lists saved tabs too.

* fix(serve): list, switch and close saved browser tabs from commands that name no page or worktree

* fix(serve): a tab list does not start saved pages; only a command acting on a page does

* fix(serve): a pageless command starts the browser tab the layout shows, not the first saved one

A host now records the browser tab a window would show: the one it just
opened, and the one a tab switch picks. With nothing running, a command
that names no page starts that tab; when the layout shows none it starts
nothing and reports no browser tab open.

* fix(serve): report the saved active browser tab as current

Claude-Session: p2-headless

* chore(ratchet): count the serve browser-tab save as a main-process layout writer

Claude-Session: p2-headless

* fix(serve): use the window's active browser tab rule, one scope, one active row

Claude-Session: p2-headless

* fix(browser): pick the active tab among tabs this host can start

A desktop document page or a remote page recorded as active no longer leaves a
pageless command with no tab. Adds a test for the first-saved-worktree scope.

Claude-Session: p2-headless

* refactor(browser): restore saved serve tabs through the page wake point

Saved serve browser tabs now start where a window would mount them: the two
existing wake helpers ask one host hook before messaging a window. The offscreen
backend reports opened and moved pages, so the command layer no longer threads
save, list, select or close hooks. No worktree named and no page running reports
no tab, as a window does.

Claude-Session: p2-headless

* fix(browser): only page creation opens a saved serve tab

A navigation now only updates an existing saved tab, so a late load after a close
cannot re-add it; a restored page sends no open, so its saved title stays.

Claude-Session: p2-headless

* refactor(browser): drop the restore mark the open/navigate split made redundant

Claude-Session: p2-headless

* refactor(browser): save serve tabs from the create command, list unstarted ones as tab rows

The create command saves the tab (it holds the group), so the backend reports only
navigations. Unstarted saved tabs are tab-info rows the existing listings take
as-is, the restore dedupe map goes (a created page registers before createTab
returns), and one lookup gives a page's unified tab id.

Claude-Session: p2-headless

* fix(browser): opening a serve browser tab no longer records it as active

Which tab shows is a view's choice; after a restart the active tab is the one a
window recorded, else the first.

Claude-Session: p2-headless

* fix(browser): give the composed browser command owners the saved-tab host member

Completes the forward merge of main's browser command split (#27576).

Claude-Session: p2-headless
This commit is contained in:
Jinwoo Hong
2026-10-14 14:13:11 -04:00
committed by GitHub
parent 3e25fe70f6
commit b85597cbfa
24 changed files with 1104 additions and 24 deletions
+1
View File
@@ -62,6 +62,7 @@
1 src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts
1 src/main/runtime/orca-runtime-pty-foreground-process-reads.ts
3 src/main/runtime/orca-runtime-stop-terminals-for-worktree.ts
1 src/main/runtime/orca-runtime-terminal-drivers.ts
4 src/main/runtime/runtime-legacy-worker-terminal-recovery-persistence.ts
2 src/main/runtime/runtime-pane-admission.ts
7 src/main/runtime/runtime-terminal-orphan-session-adoption.ts
@@ -9,12 +9,22 @@ const mocks = vi.hoisted(() => ({
class MockWebContents extends EventEmitter {
readonly id: number
url = ''
title = ''
constructor(id: number) {
super()
this.id = id
}
getURL(): string {
return this.url
}
getTitle(): string {
return this.title
}
loadURL(): Promise<void> {
if (mocks.finishLoads) {
queueMicrotask(() => this.emit('did-finish-load'))
@@ -127,6 +137,30 @@ describe('OffscreenBrowserBackend lifecycle', () => {
vi.useRealTimers()
})
it('reports each committed navigation, so a saved tab can follow its page', async () => {
const onPageNavigated = vi.fn()
const browserManager = {
registerOffscreenGuest: vi.fn(registerOffscreenGuestLikeBrowserManager),
unregisterGuest: vi.fn()
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: createTab reads only these two BrowserManager members.
const backend = new OffscreenBrowserBackend(browserManager as never, { onPageNavigated })
await backend.createTab({
browserPageId: 'page-1',
url: 'https://example.com/a',
worktreeId: 'wt'
})
const webContents = mocks.windows[0].webContents
webContents.url = 'https://example.com/b'
webContents.title = 'B'
webContents.emit('did-stop-loading')
expect(onPageNavigated.mock.calls).toEqual([
[{ worktreeId: 'wt', pageId: 'page-1', url: 'https://example.com/b', title: 'B' }]
])
})
// Why the unregister assertion and not just the destroy: a refused id is one the document half
// of the registry owns, and the teardown hook would cancel that preview's work on the way out.
it('destroys a window whose registration was refused without unregistering the id', async () => {
@@ -7,6 +7,7 @@ import type { BrowserBackend, BrowserBackendCreateTab } from './browser-backend'
import type { BrowserManager } from './browser-manager'
import type { AgentBrowserBridge } from './agent-browser-bridge'
import { browserSessionRegistry } from './browser-session-registry'
import type { ServeBrowserPageState } from '../runtime/serve-browser-tab-layout'
// Why: headless orca serve has no renderer window to host a <webview>, so each
// browser page is backed by a main-process offscreen BrowserWindow. The window
@@ -31,6 +32,8 @@ export class OffscreenBrowserBackend implements BrowserBackend {
private readonly browserManager: BrowserManager,
private readonly options: {
getAgentBrowserBridge?: () => Pick<AgentBrowserBridge, 'onPageClosed'> | null
/** A page's committed address or title changed, so its saved tab can follow. */
onPageNavigated?: (page: ServeBrowserPageState) => void
} = {}
) {}
@@ -103,6 +106,21 @@ export class OffscreenBrowserBackend implements BrowserBackend {
this.browserManager.unregisterGuest(browserPageId)
})
const { worktreeId } = params
const { onPageNavigated } = this.options
if (worktreeId && onPageNavigated) {
const report = (): void =>
onPageNavigated({
worktreeId,
pageId: browserPageId,
url: win.webContents.getURL(),
title: win.webContents.getTitle()
})
// Committed navigations only: a title a page keeps changing (a counter) is not saved each time.
win.webContents.on('did-navigate-in-page', report)
win.webContents.on('did-stop-loading', report)
}
const url = params.url || 'about:blank'
void this.loadUrl(win, url).catch((error) => {
console.warn(
@@ -6,6 +6,7 @@ import type { RuntimeBrowserPageRegistry } from '../runtime-browser-page-registr
import type { BrowserWindow } from 'electron'
import type { BrowserBackend } from '../../browser/browser-backend'
import type { BrowserSessionTabSelectionOptions } from '../browser-tab-create-publication'
import type { ServeBrowserTabs } from '../serve-browser-tabs'
export type BrowserCommandTargetParams = {
worktree?: string
@@ -54,4 +55,6 @@ export type RuntimeBrowserCommandHost = {
notifyHeadlessBrowserSessionTabsChanged?(worktreeId: string): void
/** True when a runtime-owned session row for that page existed and was retired. */
retireRuntimeOwnedBrowserSessionTab?(worktreeId: string, browserPageId: string): boolean | void
/** Saved browser tabs on a host with no window; their writes and starts do nothing with one. */
serveBrowserTabs?: Pick<ServeBrowserTabs, 'opened' | 'start'>
}
@@ -21,6 +21,7 @@ export class RuntimeBrowserCommandTargets {
| 'resolveWorktreeSelector'
| 'getAuthoritativeWindow'
| 'retireRuntimeOwnedBrowserSessionTab'
| 'serveBrowserTabs'
>
) {}
@@ -152,6 +153,9 @@ export class RuntimeBrowserCommandTargets {
// Why: background-mount the worktree via a hidden visibility lease so the webview guest can register without stealing the user's visible pane.
private async ensureBrowserWorktreeActive(worktreeId: string | undefined): Promise<void> {
if (await this.host.serveBrowserTabs?.start(worktreeId, undefined)) {
return
}
const win = this.host.getAuthoritativeWindow()
win.webContents.send('browser:activateView', worktreeId ? { worktreeId } : {})
// Why: the pane is operable only after the webview mounts and calls registerGuest; wait on that IPC rather than a flaky fixed sleep.
@@ -162,6 +166,9 @@ export class RuntimeBrowserCommandTargets {
worktreeId: string | undefined,
browserPageId: string
): Promise<void> {
if (await this.host.serveBrowserTabs?.start(worktreeId, browserPageId)) {
return
}
const win = this.host.getAuthoritativeWindow()
win.webContents.send(
'browser:activateView',
@@ -33,6 +33,7 @@ export class RuntimeBrowserTabCreation {
| 'getAuthoritativeWindow'
| 'markHeadlessBrowserSessionTabActive'
| 'notifyHeadlessBrowserSessionTabsChanged'
| 'serveBrowserTabs'
>,
private readonly targets: Pick<
RuntimeBrowserCommandTargets,
@@ -145,6 +146,16 @@ export class RuntimeBrowserTabCreation {
profileId: params.profileId,
...(params.page ? { browserPageId: params.page } : {})
})
if (worktreeId) {
this.host.serveBrowserTabs?.opened({
worktreeId,
pageId: created.browserPageId,
url,
title: '',
...(params.profileId ? { profileId: params.profileId } : {}),
...(params.targetGroupId ? { groupId: params.targetGroupId } : {})
})
}
publishCreatedBrowserSessionTab(this.host, {
placementKind: 'offscreen',
browserPageId: created.browserPageId,
@@ -37,12 +37,19 @@ export abstract class OrcaRuntimeWithBuildHeadlessMobileSessionBrowserTabs exten
protected buildHeadlessMobileSessionBrowserTabs(
worktreeId: string
): RuntimeMobileSessionBrowserTab[] {
// Saved tabs whose page starts on first use are listed as running ones are.
const serverTabs =
this.offscreenBrowserBackend && this.agentBrowserBridge?.tabList
? this.agentBrowserBridge.tabList(worktreeId).tabs
? [
...this.agentBrowserBridge.tabList(worktreeId).tabs,
...this.serveBrowserTabs.listUnstarted(worktreeId)
]
: []
const publishedServerTabs = serverTabs.map((tab) => {
const persistedProps = this.getPersistedUnifiedSessionTabProps(worktreeId, tab.browserPageId)
const persistedProps = this.getPersistedUnifiedSessionTabProps(
worktreeId,
this.serveBrowserTabs.unifiedTabId(worktreeId, tab.browserPageId)
)
return {
type: 'browser' as const,
// Why: an offscreen page has no separate workspace identity, so the page id
@@ -154,26 +154,28 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi
!getMobileSessionSnapshotTabIdentityKeys(tab).some((id) =>
accepted.rendererTabIdentityKeys.has(id)
) &&
this.getLiveBrowserTabsByPageId(snapshot.worktree).has(tab.browserPageId)
this.getListedBrowserTabsByPageId(snapshot.worktree).has(tab.browserPageId)
)
}
// Public so runtime-side page release (lease fencing) can prune a tab whose page is gone.
retireRuntimeOwnedBrowserSessionTab(worktreeId: string, browserPageId: string): boolean {
// A saved tab is a row clients list even before its page starts.
const savedTabClosed = this.serveBrowserTabs.closed(worktreeId, browserPageId)
// Why: before the snapshot guard — worktree removal drops the snapshot first, and the host
// rows for its client pages would otherwise be stranded on screen with nothing to retract them.
this.clientHostedBrowserRows.publish(worktreeId)
this.persistClientHostedBrowserPagesForWorktree(worktreeId)
const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId)
if (!snapshot) {
return false
return savedTabClosed
}
const retiredTab = snapshot.tabs.find(
(candidate): candidate is RuntimeMobileSessionBrowserTab =>
candidate.type === 'browser' && candidate.browserPageId === browserPageId
)
if (!retiredTab) {
return false
return savedTabClosed
}
const nextTabs = snapshot.tabs.filter((candidate) => candidate.id !== retiredTab.id)
const active = nextTabs.find((candidate) => candidate.isActive) ?? nextTabs[0] ?? null
@@ -261,6 +261,10 @@ export class OrcaRuntimeWithPerformMobileSessionPtyRecordsRefresh extends OrcaRu
opts.clientNavigationId
)
} else if (tab.type === 'browser') {
// Opening a saved tab is what starts its page, as on a desktop.
if (tab.browserPageId) {
await this.serveBrowserTabs.start(worktreeId, tab.browserPageId)
}
// Why: browser mobile tabs are renderer-owned unified tabs; focusing the
// session tab keeps desktop tab order/group state authoritative.
if (targetsHost) {
@@ -101,7 +101,7 @@ export abstract class OrcaRuntimeWithPersistHeadlessTerminalTitle extends Runtim
if (!snapshot) {
return ids
}
const liveBrowserTabsByPageId = this.getLiveBrowserTabsByPageId(snapshot.worktree)
const liveBrowserTabsByPageId = this.getListedBrowserTabsByPageId(snapshot.worktree)
for (const tab of snapshot.tabs) {
if (tab.type === 'browser') {
const liveTab = tab.browserPageId
@@ -106,7 +106,7 @@ export class OrcaRuntimeWithPruneMobileSessionTabGroupLayout extends OrcaRuntime
tabs: this.tabs,
leaves: this.leaves,
ptysById: this.ptysById,
getLiveBrowserTabs: (worktreeId) => this.getLiveBrowserTabsByPageId(worktreeId),
getLiveBrowserTabs: (worktreeId) => this.getListedBrowserTabsByPageId(worktreeId),
getProviderSessionRows: (paneKey) => this.getAgentProviderSessionRowsForPaneFn?.(paneKey),
getProviderSessionSnapshot: () => this.getAgentProviderSessionSnapshotFn?.() ?? [],
getStatusSnapshot: () => this.getAgentStatusSnapshotFn?.() ?? [],
@@ -168,7 +168,8 @@ export class OrcaRuntimeWithScheduleMobileSessionTabsChanged extends OrcaRuntime
return worktreeId
}
protected getLiveBrowserTabsByPageId(worktreeId: string): Map<string, BrowserTabInfo> {
/** Every browser tab clients see: running pages and saved tabs whose page starts on first use. */
protected getListedBrowserTabsByPageId(worktreeId: string): Map<string, BrowserTabInfo> {
const liveTabs = this.agentBrowserBridge?.tabList?.(worktreeId).tabs ?? []
const byPageId = new Map(liveTabs.map((tab) => [tab.browserPageId, tab]))
for (const [index, page] of getRuntimeBrowserPageRegistry(this)
@@ -184,6 +185,9 @@ export class OrcaRuntimeWithScheduleMobileSessionTabsChanged extends OrcaRuntime
profileId: page.browserProfileId
})
}
for (const tab of this.serveBrowserTabs.listUnstarted(worktreeId)) {
byPageId.set(tab.browserPageId, tab)
}
return byPageId
}
@@ -0,0 +1,359 @@
import './orca-runtime-test-lifecycle.spec'
import type { RuntimeStore } from './runtime-store-contract'
import { describe, expect, it, vi } from 'vitest'
import { OrcaRuntimeService } from './orca-runtime-test-mocks.spec'
import {
TEST_WORKTREE_ID,
makeRuntimeStoreWithWorkspaceSession,
makeWorkspaceSessionWithHeadlessTerminal
} from './orca-runtime-test-fixtures.spec'
import { withOpenedServeBrowserTab } from './serve-browser-tab-layout'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { BLANK_BROWSER_TAB_STATE } from '../../shared/workspace-layout/workspace-layout-beside'
const BROWSER_ONLY_WORKTREE_ID = 'repo-1::/tmp/browser-only'
/** A host with no window whose saved session holds browser tabs from a run before a restart. */
function restartedServe(session: WorkspaceSessionState = savedServeTabs()) {
const saved = session
const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession(saved)
Object.assign(runtimeStore, { getWorkspaceSessionHostIds: () => ['local'] })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: The shared fixture supplies RuntimeStore methods; its legacy Mock return type loses callable signatures.
const runtime = new OrcaRuntimeService(runtimeStore as RuntimeStore)
const livePages: string[] = []
const createTab = vi.fn(async ({ browserPageId }: { browserPageId?: string }) => {
livePages.push(browserPageId!)
return { browserPageId: browserPageId! }
})
const registered = () => new Map(livePages.map((pageId, index) => [pageId, index + 1]))
runtime.setOffscreenBrowserBackend({ createTab, closeTab: vi.fn(async () => {}) })
runtime.setAgentBrowserBridge(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the runtime reads only tabList from the bridge on these paths.
{
tabList: vi.fn(() => ({
tabs: livePages.map((browserPageId, index) => ({
browserPageId,
index,
url: 'https://example.com/',
title: 'Example',
active: false
}))
})),
getRegisteredTabs: vi.fn(() => registered()),
snapshot: vi.fn(async () => ({ snapshot: '' })),
getActivePageId: vi.fn(() => livePages[0] ?? null),
setActiveTab: vi.fn(),
tabSwitch: vi.fn(async (_index: unknown, _worktreeId: unknown, browserPageId: string) => ({
switched: livePages.indexOf(browserPageId),
browserPageId
}))
} as never
)
return { runtime, createTab, getSession }
}
function savedServeTabs(): WorkspaceSessionState {
let session = makeWorkspaceSessionWithHeadlessTerminal()
for (const [worktreeId, pageId] of [
[TEST_WORKTREE_ID, 'page-1'],
[TEST_WORKTREE_ID, 'page-2'],
[BROWSER_ONLY_WORKTREE_ID, 'page-3']
] as const) {
session = withOpenedServeBrowserTab('local', session, {
worktreeId,
pageId,
url: 'https://example.com/',
title: 'Example'
})!
}
// The tab a window last showed, as it records it.
return {
...session,
activeBrowserTabIdByWorktree: {
...session.activeBrowserTabIdByWorktree,
[TEST_WORKTREE_ID]: 'page-2'
}
}
}
/** A tab a window saved: its tab, page and unified tab each have their own id. */
function desktopSavedTab(): WorkspaceSessionState {
const session = makeWorkspaceSessionWithHeadlessTerminal()
const state = { ...BLANK_BROWSER_TAB_STATE, url: 'https://example.com/', title: 'Example' }
return {
...session,
browserTabsByWorktree: {
[TEST_WORKTREE_ID]: [
{
...state,
id: 'tab-desk',
worktreeId: TEST_WORKTREE_ID,
activePageId: 'page-desk',
pageIds: ['page-desk'],
createdAt: 1
}
]
},
browserPagesByWorkspace: {
'tab-desk': [
{
...state,
id: 'page-desk',
workspaceId: 'tab-desk',
worktreeId: TEST_WORKTREE_ID,
createdAt: 1
}
]
},
unifiedTabs: {
[TEST_WORKTREE_ID]: [
...(session.unifiedTabs?.[TEST_WORKTREE_ID] ?? []),
{
id: 'unified-desk',
entityId: 'tab-desk',
groupId: 'group-1',
worktreeId: TEST_WORKTREE_ID,
contentType: 'browser',
label: 'Example',
customLabel: null,
color: '#3b82f6',
isPinned: true,
sortOrder: 9,
createdAt: 1
}
]
}
}
}
describe('a saved browser tab on a host with no window', () => {
it('is listed after a restart without starting its page', async () => {
const { runtime, createTab } = restartedServe()
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(listed.tabs.find((tab) => tab.type === 'browser')).toMatchObject({
id: 'page-1',
browserPageId: 'page-1',
url: 'https://example.com/',
title: 'Example'
})
expect(createTab).not.toHaveBeenCalled()
})
it('starts its page once, at its saved address, when a client opens it', async () => {
const { runtime, createTab } = restartedServe()
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
await Promise.all([
runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'page-1'),
runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'page-1')
])
expect(createTab).toHaveBeenCalledTimes(1)
expect(createTab).toHaveBeenCalledWith(
expect.objectContaining({
browserPageId: 'page-1',
url: 'https://example.com/',
worktreeId: TEST_WORKTREE_ID
})
)
})
it('is forgotten when a client closes it', async () => {
const { runtime, getSession } = restartedServe()
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'page-1')
const saved = getSession().browserTabsByWorktree?.[TEST_WORKTREE_ID] ?? []
expect(saved.map((tab) => tab.id)).toEqual(['page-2'])
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(listed.tabs.some((tab) => tab.id === 'page-1')).toBe(false)
})
it('is saved, without becoming the recorded active tab, when a client creates it', async () => {
const { runtime, getSession } = restartedServe(makeWorkspaceSessionWithHeadlessTerminal())
await runtime.browserTabCreate({
worktree: `id:${TEST_WORKTREE_ID}`,
url: 'https://example.com/new',
page: 'page-new'
})
const saved = getSession().browserTabsByWorktree?.[TEST_WORKTREE_ID] ?? []
expect(saved.map((tab) => [tab.id, tab.url])).toEqual([['page-new', 'https://example.com/new']])
// Which tab shows is a view's choice; opening one records none.
expect(getSession().activeBrowserTabIdByWorktree?.[TEST_WORKTREE_ID]).toBeUndefined()
})
it('keeps its saved title when its page is restored', async () => {
const { runtime, createTab, getSession } = restartedServe()
await runtime['serveBrowserTabs'].start(TEST_WORKTREE_ID, 'page-1')
expect(createTab).toHaveBeenCalledTimes(1)
const saved = getSession().browserTabsByWorktree?.[TEST_WORKTREE_ID] ?? []
expect(saved.map((tab) => [tab.id, tab.title])).toEqual([
['page-1', 'Example'],
['page-2', 'Example']
])
})
it('stays closed when its page reports a navigation after the close', async () => {
const { runtime, getSession } = restartedServe()
await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
await runtime.closeMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'page-1')
runtime.serveBrowserPageNavigated({
worktreeId: TEST_WORKTREE_ID,
pageId: 'page-1',
url: 'https://example.com/late',
title: 'Late'
})
const saved = getSession().browserTabsByWorktree?.[TEST_WORKTREE_ID] ?? []
expect(saved.map((tab) => tab.id)).toEqual(['page-2'])
// What a restart reads back.
const restarted = restartedServe(getSession())
const listed = await restarted.runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
expect(listed.tabs.some((tab) => tab.id === 'page-1')).toBe(false)
})
it('starts its page for a command that names only the page', async () => {
const { runtime, createTab } = restartedServe()
expect(await runtime['serveBrowserTabs'].start(undefined, 'page-1')).toBe(true)
expect(createTab).toHaveBeenCalledWith(
expect.objectContaining({ browserPageId: 'page-1', worktreeId: TEST_WORKTREE_ID })
)
})
it('starts the active saved tab for a tab list or a pageless command, as a window mounts it', async () => {
const { runtime, createTab } = restartedServe()
const listed = await runtime.browserTabList({ worktree: `id:${TEST_WORKTREE_ID}` })
await runtime.browserSnapshot({ worktree: `id:${TEST_WORKTREE_ID}` })
// The last tab opened is the one the layout shows, as a window shows it.
expect(listed.tabs.map((tab) => tab.browserPageId)).toEqual(['page-2'])
expect(createTab).toHaveBeenCalledTimes(1)
expect(createTab).toHaveBeenCalledWith(expect.objectContaining({ browserPageId: 'page-2' }))
})
it('starts the first saved tab when the layout records none, as a window does', async () => {
const saved = savedServeTabs()
const { runtime, createTab } = restartedServe({ ...saved, activeBrowserTabIdByWorktree: {} })
const current = await runtime.browserTabCurrent({ worktree: `id:${TEST_WORKTREE_ID}` })
expect(current.tab.browserPageId).toBe('page-1')
await runtime.browserSnapshot({ worktree: `id:${TEST_WORKTREE_ID}` })
expect(createTab).toHaveBeenCalledWith(expect.objectContaining({ browserPageId: 'page-1' }))
})
it('falls back to the first saved tab once the active one is closed', async () => {
const { runtime, createTab, getSession } = restartedServe()
await runtime.browserTabClose({ worktree: `id:${TEST_WORKTREE_ID}`, page: 'page-2' })
expect(getSession().activeBrowserTabIdByWorktree?.[TEST_WORKTREE_ID]).toBe('page-2')
const current = await runtime.browserTabCurrent({ worktree: `id:${TEST_WORKTREE_ID}` })
expect(current.tab.browserPageId).toBe('page-1')
await runtime.browserSnapshot({ worktree: `id:${TEST_WORKTREE_ID}` })
expect(createTab).toHaveBeenCalledWith(expect.objectContaining({ browserPageId: 'page-1' }))
})
it('starts the first tab this host can show when the active one is a desktop document page', async () => {
const saved = savedServeTabs()
const docLocation = {
kind: 'workspace-doc' as const,
worktreeId: TEST_WORKTREE_ID,
filePath: 'a.md'
}
const { runtime, createTab } = restartedServe({
...saved,
browserPagesByWorkspace: Object.fromEntries(
Object.entries(saved.browserPagesByWorkspace ?? {}).map(([tabId, pages]) => [
tabId,
pages.map((page) => (page.id === 'page-2' ? { ...page, docLocation } : page))
])
)
})
const current = await runtime.browserTabCurrent({ worktree: `id:${TEST_WORKTREE_ID}` })
expect(current.tab.browserPageId).toBe('page-1')
await runtime.browserSnapshot({ worktree: `id:${TEST_WORKTREE_ID}` })
expect(createTab).toHaveBeenCalledWith(expect.objectContaining({ browserPageId: 'page-1' }))
})
it('reports no browser tab only when the worktree saves none', async () => {
const { runtime, createTab } = restartedServe(makeWorkspaceSessionWithHeadlessTerminal())
await expect(runtime.browserTabCurrent({ worktree: `id:${TEST_WORKTREE_ID}` })).rejects.toThrow(
'No browser tab open in this worktree'
)
expect(createTab).not.toHaveBeenCalled()
})
it('reports no tab when no worktree is named and no page runs, as a window does', async () => {
const { runtime, createTab } = restartedServe()
await expect(runtime.browserTabCurrent({})).rejects.toThrow('No browser tab open')
expect(createTab).not.toHaveBeenCalled()
})
it('saves a re-created page whose id the layout still records as active', () => {
const session = {
...makeWorkspaceSessionWithHeadlessTerminal(),
activeBrowserTabIdByWorktree: { [TEST_WORKTREE_ID]: 'page-9' }
}
const next = withOpenedServeBrowserTab('local', session, {
worktreeId: TEST_WORKTREE_ID,
pageId: 'page-9',
url: 'https://example.com/',
title: 'Example'
})
expect(next?.browserTabsByWorktree?.[TEST_WORKTREE_ID]?.map((tab) => tab.id)).toEqual([
'page-9'
])
})
it('starts the named page for a tab switch', async () => {
const { runtime, createTab } = restartedServe()
const switched = await runtime.browserTabSwitch({
worktree: `id:${TEST_WORKTREE_ID}`,
page: 'page-2'
})
expect(switched.browserPageId).toBe('page-2')
expect(createTab).toHaveBeenCalledWith(expect.objectContaining({ browserPageId: 'page-2' }))
})
it('is found by page alone in a workspace that holds only browser tabs', async () => {
const { runtime, createTab } = restartedServe()
expect(await runtime['serveBrowserTabs'].start(undefined, 'page-3')).toBe(true)
expect(createTab).toHaveBeenCalledWith(
expect.objectContaining({ browserPageId: 'page-3', worktreeId: BROWSER_ONLY_WORKTREE_ID })
)
})
it("keeps a window-saved tab's pin and colour once its page starts", async () => {
const { runtime } = restartedServe(desktopSavedTab())
const browserRow = async () =>
(await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)).tabs.find(
(tab) => tab.type === 'browser'
)
expect(await browserRow()).toMatchObject({ color: '#3b82f6', isPinned: true })
await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'page-desk')
expect(await browserRow()).toMatchObject({
browserPageId: 'page-desk',
color: '#3b82f6',
isPinned: true
})
})
})
@@ -73,7 +73,7 @@ export class OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab extends Orc
this.isHeadlessBuiltMobileSessionPublicationBase(snapshot.publicationEpoch) ||
(snapshot.publicationEpoch.includes(':headless-merge:') &&
typeof tab.browserPageId === 'string' &&
this.getLiveBrowserTabsByPageId(snapshot.worktree).has(tab.browserPageId))
this.getListedBrowserTabsByPageId(snapshot.worktree).has(tab.browserPageId))
)
}
if (tab.type !== 'terminal') {
@@ -20,6 +20,8 @@ import type { StatsCollector } from '../stats/collector'
import { RuntimeRemoteFetchController } from './runtime-remote-fetch-controller'
import { RuntimeWorktreeBaseReconciliation } from './runtime-worktree-base-reconciliation'
import { RuntimeWorktreeRemovalInFlight } from './runtime-worktree-removal-in-flight'
import { ServeBrowserTabs } from './serve-browser-tabs'
import type { ServeBrowserPageState } from './serve-browser-tab-layout'
export abstract class OrcaRuntimeWithTerminalDrivers extends OrcaRuntimeWithFitOverrideListeners {
abstract getDriver(ptyId: string): DriverState
@@ -54,6 +56,25 @@ export abstract class OrcaRuntimeWithTerminalDrivers extends OrcaRuntimeWithFitO
this.mobileTookFloor(ptyId, clientId, previousFloor, isCurrent)
})
protected readonly serveBrowserTabs = new ServeBrowserTabs({
backend: () => (this.getAvailableAuthoritativeWindow() ? null : this.offscreenBrowserBackend),
livePageIds: (worktreeId) =>
new Set(
(this.agentBrowserBridge?.tabList?.(worktreeId).tabs ?? []).map((tab) => tab.browserPageId)
),
savedWorktreeIds: () =>
(this.store?.getWorkspaceSessionHostIds?.() ?? []).flatMap((hostId) =>
Object.keys(this.store?.getWorkspaceSession?.(hostId)?.browserTabsByWorktree ?? {})
),
session: (worktreeId) => this.getWorkspaceSessionForWorktree(worktreeId),
hostId: (worktreeId) => this.getWorkspaceSessionHostIdForWorktree(worktreeId),
save: (worktreeId, session) => this.setWorkspaceSessionForWorktree(worktreeId, session)
})
serveBrowserPageNavigated(page: ServeBrowserPageState): void {
this.serveBrowserTabs.navigated(page)
}
protected readonly edgeCommands = new RuntimeEdgeCommandController({
jiraHost: {
getSettings: () => this.requireStore().getSettings(),
@@ -74,7 +95,8 @@ export abstract class OrcaRuntimeWithTerminalDrivers extends OrcaRuntimeWithFitO
notifyHeadlessBrowserSessionTabsChanged: (worktreeId) =>
this.notifyMobileSessionTabsChanged(worktreeId),
retireRuntimeOwnedBrowserSessionTab: (worktreeId, browserPageId) =>
this.retireRuntimeOwnedBrowserSessionTab(worktreeId, browserPageId)
this.retireRuntimeOwnedBrowserSessionTab(worktreeId, browserPageId),
serveBrowserTabs: this.serveBrowserTabs
},
screencast: {
registerSubscriptionCleanup: (subscriptionId, cleanup, connectionId) =>
@@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import { workspaceSessionStateSchema } from '../../shared/workspace-session-schema'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import {
findSavedBrowserPage,
withClosedServeBrowserTab,
withOpenedServeBrowserTab,
withServeBrowserPageState
} from './serve-browser-tab-layout'
const WT = 'repo-1::/repo'
const HOST = 'local'
function sessionWithTerminal(): WorkspaceSessionState {
return {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[WT]: [
{
id: 'term-1',
ptyId: null,
worktreeId: WT,
title: 'Terminal 1',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
unifiedTabs: {
[WT]: [
{
id: 'term-1',
entityId: 'term-1',
groupId: 'group-1',
worktreeId: WT,
contentType: 'terminal',
label: 'Terminal 1',
customLabel: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
tabGroups: {
[WT]: [{ id: 'group-1', worktreeId: WT, activeTabId: 'term-1', tabOrder: ['term-1'] }]
}
}
}
const opened = (session: WorkspaceSessionState) =>
withOpenedServeBrowserTab(HOST, session, {
worktreeId: WT,
pageId: 'page-1',
url: 'https://example.com/',
title: 'Example'
})!
describe('a browser tab a host with no window saves', () => {
it('is written in the shape a window writes, after the tabs before it', () => {
const saved = opened(sessionWithTerminal())
expect(workspaceSessionStateSchema.safeParse(saved).success).toBe(true)
expect(saved.tabGroups?.[WT]?.[0]?.tabOrder).toEqual(['term-1', 'page-1'])
expect(saved.unifiedTabs?.[WT]?.find((tab) => tab.id === 'page-1')).toMatchObject({
entityId: 'page-1',
contentType: 'browser',
groupId: 'group-1'
})
expect(saved.browserTabsByWorktree?.[WT]).toEqual([
expect.objectContaining({
id: 'page-1',
worktreeId: WT,
pageIds: ['page-1'],
url: 'https://example.com/',
title: 'Example'
})
])
expect(saved.browserPagesByWorkspace?.['page-1']).toEqual([
expect.objectContaining({
id: 'page-1',
workspaceId: 'page-1',
worktreeId: WT,
url: 'https://example.com/',
title: 'Example'
})
])
})
it('keeps its page last address', () => {
const saved = withServeBrowserPageState(opened(sessionWithTerminal()), {
worktreeId: WT,
pageId: 'page-1',
url: 'https://example.com/next',
title: 'Next'
})!
expect(findSavedBrowserPage(saved, WT, 'page-1')?.page).toMatchObject({
url: 'https://example.com/next',
title: 'Next'
})
expect(saved.browserTabsByWorktree?.[WT]?.[0]).toMatchObject({
url: 'https://example.com/next',
title: 'Next'
})
})
it('leaves no trace once closed', () => {
const before = sessionWithTerminal()
const saved = withClosedServeBrowserTab(HOST, opened(before), WT, 'page-1')!
expect(saved.tabGroups?.[WT]?.[0]?.tabOrder).toEqual(['term-1'])
expect(saved.unifiedTabs?.[WT]?.map((tab) => tab.id)).toEqual(['term-1'])
expect(saved.browserTabsByWorktree?.[WT] ?? []).toEqual([])
expect(saved.browserPagesByWorkspace?.['page-1']).toBeUndefined()
})
it('updates only the page of a window-saved tab that is not its active one', () => {
const session = opened(sessionWithTerminal())
const windowSaved: WorkspaceSessionState = {
...session,
browserTabsByWorktree: {
[WT]: [{ ...session.browserTabsByWorktree![WT]![0]!, pageIds: ['page-1', 'page-2'] }]
},
browserPagesByWorkspace: {
'page-1': [
...session.browserPagesByWorkspace!['page-1']!,
{ ...session.browserPagesByWorkspace!['page-1']![0]!, id: 'page-2', url: 'https://b/' }
]
}
}
windowSaved.browserTabsByWorktree![WT]![0]!.activePageId = 'page-1'
const saved = withServeBrowserPageState(windowSaved, {
worktreeId: WT,
pageId: 'page-2',
url: 'https://b/next',
title: 'B'
})!
expect(findSavedBrowserPage(saved, WT, 'page-2')?.page.url).toBe('https://b/next')
expect(saved.browserTabsByWorktree?.[WT]?.[0]?.url).toBe('https://example.com/')
})
it('is saved once when its page is opened again', () => {
const saved = opened(sessionWithTerminal())
expect(
withOpenedServeBrowserTab(HOST, saved, {
worktreeId: WT,
pageId: 'page-1',
url: 'https://example.com/',
title: 'Example'
})
).toBeNull()
})
})
@@ -0,0 +1,145 @@
// A browser tab a host with no window runs, saved as a window saves its own: the tab, its page and
// the page's last address. A restarted host lists it and starts the page when something first
// uses it, as a window restores its browser tabs.
import type { BrowserPage } from '../../shared/browser-workspace-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import { applyLayoutCommand } from '../../shared/workspace-layout/workspace-layout-commands'
import { BLANK_BROWSER_TAB_STATE } from '../../shared/workspace-layout/workspace-layout-beside'
import { loadWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-load'
import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-layout-minted-ids'
import { saveWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-save'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { randomUUID } from 'node:crypto'
export type ServeBrowserPageState = {
worktreeId: string
pageId: string
url: string
title: string
}
/** The saved browser tab showing this page, and the page; a host's own tab is named by its page. */
export function findSavedBrowserPage(
session: WorkspaceSessionState,
worktreeId: string,
pageId: string
): { tabId: string; page: BrowserPage } | null {
for (const tab of session.browserTabsByWorktree?.[worktreeId] ?? []) {
const page = session.browserPagesByWorkspace?.[tab.id]?.find((entry) => entry.id === pageId)
if (page) {
return { tabId: tab.id, page }
}
}
return null
}
/** The partition with a just-created page's tab, or null when it is saved already or refused. */
export function withOpenedServeBrowserTab(
hostId: ExecutionHostId,
session: WorkspaceSessionState,
page: ServeBrowserPageState & { profileId?: string; groupId?: string }
): WorkspaceSessionState | null {
if (findSavedBrowserPage(session, page.worktreeId, page.pageId)) {
return null
}
const loaded = loadWorkspaceLayout(hostId, session, nameBasedLoadContext())
const now = Date.now()
const applied = applyLayoutCommand(
loaded.layout,
{
type: 'openBrowserTab',
workspace: page.worktreeId,
pageId: page.pageId,
...(page.profileId ? { profileId: page.profileId } : {}),
...(page.groupId ? { groupId: page.groupId } : {})
},
{ mintId: randomUUID, mintLeafId: randomUUID, now: () => now }
)
if (!applied.ok) {
return null
}
const { facts } = loaded
const state = { ...BLANK_BROWSER_TAB_STATE, url: page.url, title: page.title }
const savedPage: BrowserPage = {
...state,
id: page.pageId,
workspaceId: page.pageId,
worktreeId: page.worktreeId,
createdAt: now
}
return saveWorkspaceLayout({
...loaded,
layout: applied.model,
facts: {
...facts,
browserTabs: {
...facts.browserTabs,
[page.worktreeId]: { ...facts.browserTabs[page.worktreeId], [page.pageId]: state }
},
browserPagesByWorkspace: { ...facts.browserPagesByWorkspace, [page.pageId]: [savedPage] }
}
})
}
/**
* The partition with the page's last address, or null when no saved tab shows the page or it is
* unchanged. Only address fields change, so the layout is not reloaded.
*/
export function withServeBrowserPageState(
session: WorkspaceSessionState,
page: ServeBrowserPageState
): WorkspaceSessionState | null {
const saved = findSavedBrowserPage(session, page.worktreeId, page.pageId)
if (!saved || (saved.page.url === page.url && saved.page.title === page.title)) {
return null
}
const state = { url: page.url, title: page.title }
const tabs = session.browserTabsByWorktree?.[page.worktreeId] ?? []
return {
...session,
browserTabsByWorktree: {
...session.browserTabsByWorktree,
// The tab mirrors its active page only.
[page.worktreeId]: tabs.map((tab) =>
tab.id === saved.tabId && (!tab.activePageId || tab.activePageId === page.pageId)
? { ...tab, ...state }
: tab
)
},
browserPagesByWorkspace: {
...session.browserPagesByWorkspace,
[saved.tabId]: (session.browserPagesByWorkspace?.[saved.tabId] ?? []).map((entry) =>
entry.id === page.pageId ? { ...entry, ...state } : entry
)
}
}
}
/** The partition without the tab showing the page, or null when no saved tab shows it. */
export function withClosedServeBrowserTab(
hostId: ExecutionHostId,
session: WorkspaceSessionState,
worktreeId: string,
pageId: string
): WorkspaceSessionState | null {
const saved = findSavedBrowserPage(session, worktreeId, pageId)
if (!saved) {
return null
}
const loaded = loadWorkspaceLayout(hostId, session, nameBasedLoadContext())
const applied = applyLayoutCommand(
loaded.layout,
{ type: 'closeTabs', workspace: worktreeId, tabIds: [saved.tabId], force: true },
{ mintId: randomUUID, mintLeafId: randomUUID, now: Date.now }
)
if (!applied.ok) {
return null
}
const { [saved.tabId]: _closed, ...pages } = loaded.facts.browserPagesByWorkspace ?? {}
return saveWorkspaceLayout({
...loaded,
layout: applied.model,
facts: { ...loaded.facts, browserPagesByWorkspace: pages }
})
}
+174
View File
@@ -0,0 +1,174 @@
// Browser tabs on a host with no window: saved as a window saves its own, and restored as a window
// restores them, lazily. A saved tab is listed to clients at once; its page starts when something
// first uses it (a tap, a command), so a restart opens no pages by itself.
import type { BrowserBackend } from '../browser/browser-backend'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { BrowserTabInfo } from '../../shared/runtime-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import {
findSavedBrowserPage,
withClosedServeBrowserTab,
withOpenedServeBrowserTab,
withServeBrowserPageState,
type ServeBrowserPageState
} from './serve-browser-tab-layout'
type RestorableBrowserPage = {
/** The saved tab; a host's own tab is named by its page, a window's by its own id. */
tabId: string
pageId: string
url: string
title: string
profileId: string | null
worktreeId: string
}
export class ServeBrowserTabs {
constructor(
private readonly deps: {
/** The page backend while no window hosts the browser tabs, else null. */
backend: () => BrowserBackend | null
livePageIds: (worktreeId: string) => ReadonlySet<string>
/** Every workspace that holds a saved browser tab, whatever else it holds. */
savedWorktreeIds: () => Iterable<string>
session: (worktreeId: string) => WorkspaceSessionState | null
hostId: (worktreeId: string) => ExecutionHostId
save: (worktreeId: string, session: WorkspaceSessionState) => void
}
) {}
opened(page: ServeBrowserPageState & { profileId?: string; groupId?: string }): void {
this.write(page.worktreeId, (hostId, session) =>
withOpenedServeBrowserTab(hostId, session, page)
)
}
/** A saved tab follows its page; a page whose tab was closed is not saved again. */
navigated(page: ServeBrowserPageState): void {
this.write(page.worktreeId, (_hostId, session) => withServeBrowserPageState(session, page))
}
/** A closed page's tab, true when one was saved; a page destroyed with the process is not a close. */
closed(worktreeId: string, pageId: string): boolean {
return this.write(worktreeId, (hostId, session) =>
withClosedServeBrowserTab(hostId, session, worktreeId, pageId)
)
}
/** The unified tab showing a page, which a window keys pin and colour by: the saved one, else the page's. */
unifiedTabId(worktreeId: string, pageId: string): string {
const session = this.deps.session(worktreeId)
return (session && findSavedBrowserPage(session, worktreeId, pageId)?.tabId) ?? pageId
}
/** Saved tabs whose page has not started, listed as running tabs are. */
listUnstarted(worktreeId: string): BrowserTabInfo[] {
return this.listRestorable(worktreeId).map((page, index) => ({
browserPageId: page.pageId,
index,
url: page.url,
title: page.title,
active: false,
worktreeId,
...(page.profileId ? { profileId: page.profileId } : {})
}))
}
/** Saved tabs whose page has not started; every worktree's when none is named. */
private listRestorable(worktreeId: string | undefined): RestorableBrowserPage[] {
if (!this.deps.backend()) {
return []
}
const worktreeIds = worktreeId ? [worktreeId] : new Set(this.deps.savedWorktreeIds())
return [...worktreeIds].flatMap((id) => {
const live = this.deps.livePageIds(id)
return this.startableIn(id).filter((page) => !live.has(page.pageId))
})
}
/**
* Starts a saved tab's page where a window would mount it: the named page, else the worktree's
* active tab. False when there is none, so the caller reports no tab as a window does.
*/
async start(worktreeId: string | undefined, pageId: string | undefined): Promise<boolean> {
const found = pageId
? this.listRestorable(worktreeId).find((page) => page.pageId === pageId)
: worktreeId
? this.activeIn(worktreeId)
: undefined
return found ? this.restore(found) : false
}
/** The tab a window shows for a worktree with no page running: the recorded one, else the first. */
private activeIn(worktreeId: string): RestorableBrowserPage | undefined {
if (this.deps.livePageIds(worktreeId).size > 0) {
return undefined
}
const tabs = this.startableIn(worktreeId)
const recorded = this.deps.session(worktreeId)?.activeBrowserTabIdByWorktree?.[worktreeId]
return tabs.find((page) => page.tabId === recorded) ?? tabs[0]
}
private startableIn(worktreeId: string): RestorableBrowserPage[] {
const session = this.deps.session(worktreeId)
if (!session) {
return []
}
return (session.browserTabsByWorktree?.[worktreeId] ?? []).flatMap((tab) => {
const pageId = tab.activePageId ?? tab.pageIds?.[0]
const page = pageId ? findSavedBrowserPage(session, worktreeId, pageId)?.page : undefined
// A document page is one desktop's; a remote page belongs to the host that ran it.
if (!page || page.docLocation || page.remoteBrowserPageId) {
return []
}
return [
{
tabId: tab.id,
pageId: page.id,
url: page.url,
title: page.title,
profileId: tab.sessionProfileId ?? null,
worktreeId
}
]
})
}
// The backend registers the page before it returns, so a second caller finds it running.
private async restore(page: RestorableBrowserPage): Promise<boolean> {
const backend = this.deps.backend()
if (!backend) {
return false
}
await backend.createTab({
browserPageId: page.pageId,
url: page.url,
worktreeId: page.worktreeId,
...(page.profileId ? { profileId: page.profileId } : {})
})
return true
}
private write(
worktreeId: string,
change: (
hostId: ExecutionHostId,
session: WorkspaceSessionState
) => WorkspaceSessionState | null
): boolean {
const session = this.deps.backend() ? this.deps.session(worktreeId) : null
const next = session ? change(this.deps.hostId(worktreeId), session) : null
if (!next) {
return false
}
// Called from page events and closes; a refused write (writes frozen) must not throw there.
try {
this.deps.save(worktreeId, next)
return true
} catch (error) {
console.warn('[serve-browser-tabs] could not save a browser tab:', error)
return false
}
}
}
@@ -39,12 +39,14 @@ export abstract class RuntimeHeadlessTabProperties extends OrcaRuntimeWithCloseH
}
const unifiedTabs = session.unifiedTabs?.[worktreeId]
if (unifiedTabs?.some((tab) => tab.id === tabId || tab.entityId === tabId)) {
// A browser row is named by its page; a window keys the tab's props by the saved tab.
const unifiedId = this.serveBrowserTabs.unifiedTabId(worktreeId, tabId)
if (unifiedTabs?.some((tab) => tab.id === unifiedId || tab.entityId === unifiedId)) {
changed = true
nextSession.unifiedTabs = {
...session.unifiedTabs,
[worktreeId]: unifiedTabs.map((tab) =>
tab.id === tabId || tab.entityId === tabId
tab.id === unifiedId || tab.entityId === unifiedId
? {
...tab,
...(props.color !== undefined ? { color: props.color } : {}),
@@ -159,7 +159,8 @@ async function launchServeMode(
if (state.headlessBrowserDisplayAvailable) {
runtime.setOffscreenBrowserBackend(
new OffscreenBrowserBackend(browserManager, {
getAgentBrowserBridge: () => state.agentBrowserBridge
getAgentBrowserBridge: () => state.agentBrowserBridge,
onPageNavigated: (page) => runtime.serveBrowserPageNavigated(page)
})
)
}
@@ -88,7 +88,13 @@ export type LayoutCommand =
dirtyTabIds?: string[]
})
| (On & { type: 'promotePreviewTab'; tabId: string })
| (On & { type: 'openBrowserTab'; profileId?: string | null; groupId?: string })
| (On & {
type: 'openBrowserTab'
profileId?: string | null
groupId?: string
/** The id of a page that already runs; it names the tab too, as a host's page names its row. */
pageId?: string
})
| (On & {
type: 'openAgentSessionTab'
/** The chat's own tab id, which status and attention address; minted when absent. */
@@ -76,8 +76,8 @@ export function openBrowserTab(
): Applied {
const key = command.workspace
// The tab-bar id and the browser tab record share one id, as the window mints them.
const browserTabId = context.mintId()
const pageId = context.mintId()
const browserTabId = command.pageId ?? context.mintId()
const pageId = command.pageId ?? context.mintId()
const tab = contentTab(context, {
kind: 'browser',
id: browserTabId,
@@ -0,0 +1,124 @@
/**
* A browser tab on `orca serve` survives a serve restart, as a desktop's browser tabs survive a
* relaunch: the restarted server lists it with its last address, and starts its page only when a
* client opens or drives it. A closed tab stays closed.
*/
import type { RuntimeClient } from '../../src/cli/runtime/client'
import type { RuntimeMobileSessionTabsResult } from '../../src/shared/runtime-session-contracts'
import { createServer, type Server } from 'node:http'
import { expect, test } from './helpers/orca-app'
import {
addRepoWorktree,
cliClient,
pairedClient,
startHeadlessOracleHost
} from './helpers/headless-layout-oracle-hosts'
/** A page server that counts loads per path: a page that has not started has loaded nothing. */
async function startPage(): Promise<{ url: string; server: Server; loads: Map<string, number> }> {
const loads = new Map<string, number>()
const server = createServer((request, response) => {
loads.set(request.url ?? '', (loads.get(request.url ?? '') ?? 0) + 1)
response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
response.end(`<!doctype html><title>${request.url === '/next' ? 'next' : 'first'}</title>`)
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('page server has no port')
}
return { url: `http://127.0.0.1:${address.port}`, server, loads }
}
async function browserRows(client: RuntimeClient, worktree: string) {
const { result } = await client.call<RuntimeMobileSessionTabsResult>('session.tabs.list', {
worktree
})
return result.tabs.flatMap((tab) =>
tab.type === 'browser' ? [{ id: tab.id, url: tab.url, title: tab.title }] : []
)
}
async function listedTabs(client: RuntimeClient, worktree: string) {
const listed = await client.call<{ tabs: { browserPageId: string; url: string }[] }>(
'browser.tabList',
{ worktree }
)
return listed.result.tabs
}
test('a serve browser tab survives a restart and starts when opened', async () => {
test.setTimeout(300_000)
const page = await startPage()
const host = await startHeadlessOracleHost('electron')
try {
const cli = cliClient(host)
const phone = pairedClient(host, 'phone')
const worktree = `id:${await addRepoWorktree(host, cli)}`
const created = await phone.call<{ browserPageId: string }>('browser.tabCreate', {
worktree,
url: `${page.url}/first`
})
const pageId = created.result.browserPageId
await phone.call('browser.goto', { worktree, page: pageId, url: `${page.url}/next` })
await expect
.poll(async () => (await browserRows(phone, worktree)).find((row) => row.id === pageId)?.url)
.toBe(`${page.url}/next`)
await host.restart()
const loadsBeforeOpen = page.loads.get('/next')
// Listed at its last address before anything opens it, with no page running yet.
await expect
.poll(async () => browserRows(phone, worktree), { timeout: 30_000 })
.toEqual([{ id: pageId, url: `${page.url}/next`, title: 'next' }])
expect(page.loads.get('/next')).toBe(loadsBeforeOpen)
// Driving it by page id, as an old client's stream does, starts it at that address.
await phone.call('browser.snapshot', { worktree, page: pageId })
await expect.poll(() => page.loads.get('/next')).toBe((loadsBeforeOpen ?? 0) + 1)
await expect
.poll(
async () =>
(await listedTabs(cli, worktree)).find((tab) => tab.browserPageId === pageId)?.url
)
.toBe(`${page.url}/next`)
await phone.call('session.tabs.close', { worktree, tabId: pageId, reason: 'user' })
await expect.poll(async () => browserRows(phone, worktree)).toEqual([])
await host.restart()
await expect
.poll(async () => (await browserRows(phone, worktree)).length, { timeout: 15_000 })
.toBe(0)
} finally {
await host.dispose()
await new Promise((resolve) => page.server.close(resolve))
}
})
test('tapping a restored serve browser tab starts its page', async () => {
test.setTimeout(300_000)
const page = await startPage()
const host = await startHeadlessOracleHost('electron')
try {
const cli = cliClient(host)
const phone = pairedClient(host, 'phone')
const worktree = `id:${await addRepoWorktree(host, cli)}`
const created = await phone.call<{ browserPageId: string }>('browser.tabCreate', {
worktree,
url: `${page.url}/first`
})
const pageId = created.result.browserPageId
await expect.poll(() => page.loads.get('/first')).toBe(1)
await host.restart()
await expect.poll(async () => (await browserRows(phone, worktree)).length).toBe(1)
expect(page.loads.get('/first')).toBe(1)
await phone.call('session.tabs.activate', { worktree, tabId: pageId })
await expect.poll(() => page.loads.get('/first')).toBe(2)
} finally {
await host.dispose()
await new Promise((resolve) => page.server.close(resolve))
}
})
@@ -207,15 +207,11 @@ test('phone shows the desktop tab strip of a server workspace, also with the win
const arranged = await desktopStrip(desktop.page, desktopWorktree.id)
await expect.poll(phoneStrip, { timeout: 30_000 }).toEqual(arranged)
expect(await serverStrip()).toEqual(arranged)
// BASELINE (main): the headless server's published layout leaves out its own browser page;
// the rework publishes it (one layout), so this then compares with `arranged` as is.
const browserTabIds = new Set(
// The server saves its own browser page's tab, so its published layout holds the same strip.
expect(
(await phoneTabs()).tabs.filter((tab) => tab.type === 'browser').map((tab) => tab.id)
)
expect(browserTabIds.size).toBe(1)
expect(await publishedStrip()).toEqual(
arranged.map((group) => group.filter((id) => !browserTabIds.has(id)))
)
).toHaveLength(1)
expect(await publishedStrip()).toEqual(arranged)
// Only the server holds this workspace's tabs; the desktop's own answer has none of them.
const untargeted = await call(socket, 'session.tabs.list', { worktree }, SessionTabsSchema)
expect(untargeted.tabs).toEqual([])