diff --git a/config/main-layout-writer-baseline.txt b/config/main-layout-writer-baseline.txt index eb751849f65..7da39e86bb9 100644 --- a/config/main-layout-writer-baseline.txt +++ b/config/main-layout-writer-baseline.txt @@ -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 diff --git a/src/main/browser/offscreen-browser-backend-lifecycle.test.ts b/src/main/browser/offscreen-browser-backend-lifecycle.test.ts index 381489963a3..38f3fa020f4 100644 --- a/src/main/browser/offscreen-browser-backend-lifecycle.test.ts +++ b/src/main/browser/offscreen-browser-backend-lifecycle.test.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 { 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 () => { diff --git a/src/main/browser/offscreen-browser-backend.ts b/src/main/browser/offscreen-browser-backend.ts index 039ed02b4bf..fd3e3b35173 100644 --- a/src/main/browser/offscreen-browser-backend.ts +++ b/src/main/browser/offscreen-browser-backend.ts @@ -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 , 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 | 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( diff --git a/src/main/runtime/browser/browser-command-contracts.ts b/src/main/runtime/browser/browser-command-contracts.ts index a958b9d39fd..045b843df30 100644 --- a/src/main/runtime/browser/browser-command-contracts.ts +++ b/src/main/runtime/browser/browser-command-contracts.ts @@ -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 } diff --git a/src/main/runtime/browser/browser-command-targets.ts b/src/main/runtime/browser/browser-command-targets.ts index f5e700ac343..2af20c2bfbb 100644 --- a/src/main/runtime/browser/browser-command-targets.ts +++ b/src/main/runtime/browser/browser-command-targets.ts @@ -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 { + 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 { + if (await this.host.serveBrowserTabs?.start(worktreeId, browserPageId)) { + return + } const win = this.host.getAuthoritativeWindow() win.webContents.send( 'browser:activateView', diff --git a/src/main/runtime/browser/browser-tab-creation.ts b/src/main/runtime/browser/browser-tab-creation.ts index 58b025e7fe9..34dd5628865 100644 --- a/src/main/runtime/browser/browser-tab-creation.ts +++ b/src/main/runtime/browser/browser-tab-creation.ts @@ -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, diff --git a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts index 83b76f47594..f74c1154008 100644 --- a/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-build-headless-mobile-session-browser-tabs.ts @@ -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 diff --git a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts index 88d2837f664..eafb9f55883 100644 --- a/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts +++ b/src/main/runtime/orca-runtime-close-structured-agent-session-tab.ts @@ -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 diff --git a/src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts b/src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts index d362bf7a62f..5c9a2111534 100644 --- a/src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts +++ b/src/main/runtime/orca-runtime-perform-mobile-session-pty-records-refresh.ts @@ -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) { diff --git a/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts b/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts index 568b193b302..301b2a35d77 100644 --- a/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts +++ b/src/main/runtime/orca-runtime-persist-headless-terminal-title.ts @@ -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 diff --git a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts index 7e8c67b2fff..38eca5e6efa 100644 --- a/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts +++ b/src/main/runtime/orca-runtime-prune-mobile-session-tab-group-layout.ts @@ -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?.() ?? [], diff --git a/src/main/runtime/orca-runtime-schedule-mobile-session-tabs-changed.ts b/src/main/runtime/orca-runtime-schedule-mobile-session-tabs-changed.ts index c050f659ed5..17e0e2872c3 100644 --- a/src/main/runtime/orca-runtime-schedule-mobile-session-tabs-changed.ts +++ b/src/main/runtime/orca-runtime-schedule-mobile-session-tabs-changed.ts @@ -168,7 +168,8 @@ export class OrcaRuntimeWithScheduleMobileSessionTabsChanged extends OrcaRuntime return worktreeId } - protected getLiveBrowserTabsByPageId(worktreeId: string): Map { + /** Every browser tab clients see: running pages and saved tabs whose page starts on first use. */ + protected getListedBrowserTabsByPageId(worktreeId: string): Map { 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 } diff --git a/src/main/runtime/orca-runtime-serve-browser-restore.test.ts b/src/main/runtime/orca-runtime-serve-browser-restore.test.ts new file mode 100644 index 00000000000..ba4cf19857a --- /dev/null +++ b/src/main/runtime/orca-runtime-serve-browser-restore.test.ts @@ -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 + }) + }) +}) diff --git a/src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts b/src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts index c32349352a1..726d3973aef 100644 --- a/src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts +++ b/src/main/runtime/orca-runtime-stored-mobile-snapshot-has-stale-preserved-tab.ts @@ -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') { diff --git a/src/main/runtime/orca-runtime-terminal-drivers.ts b/src/main/runtime/orca-runtime-terminal-drivers.ts index 14f29fcc82c..81904825649 100644 --- a/src/main/runtime/orca-runtime-terminal-drivers.ts +++ b/src/main/runtime/orca-runtime-terminal-drivers.ts @@ -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) => diff --git a/src/main/runtime/serve-browser-tab-layout.test.ts b/src/main/runtime/serve-browser-tab-layout.test.ts new file mode 100644 index 00000000000..d76eec740f5 --- /dev/null +++ b/src/main/runtime/serve-browser-tab-layout.test.ts @@ -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() + }) +}) diff --git a/src/main/runtime/serve-browser-tab-layout.ts b/src/main/runtime/serve-browser-tab-layout.ts new file mode 100644 index 00000000000..57f920aff49 --- /dev/null +++ b/src/main/runtime/serve-browser-tab-layout.ts @@ -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 } + }) +} diff --git a/src/main/runtime/serve-browser-tabs.ts b/src/main/runtime/serve-browser-tabs.ts new file mode 100644 index 00000000000..52dcc533049 --- /dev/null +++ b/src/main/runtime/serve-browser-tabs.ts @@ -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 + /** Every workspace that holds a saved browser tab, whatever else it holds. */ + savedWorktreeIds: () => Iterable + 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 { + 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 { + 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 + } + } +} diff --git a/src/main/runtime/session-tabs/headless-tab-properties.ts b/src/main/runtime/session-tabs/headless-tab-properties.ts index 60f78ea0e8c..d4ae383e615 100644 --- a/src/main/runtime/session-tabs/headless-tab-properties.ts +++ b/src/main/runtime/session-tabs/headless-tab-properties.ts @@ -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 } : {}), diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 1003828c398..78f3e9b7fbb 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -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) }) ) } diff --git a/src/shared/workspace-layout/workspace-layout-command-types.ts b/src/shared/workspace-layout/workspace-layout-command-types.ts index 6c35d7b8443..168cb891af1 100644 --- a/src/shared/workspace-layout/workspace-layout-command-types.ts +++ b/src/shared/workspace-layout/workspace-layout-command-types.ts @@ -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. */ diff --git a/src/shared/workspace-layout/workspace-layout-content-commands.ts b/src/shared/workspace-layout/workspace-layout-content-commands.ts index 25221a53932..5931a74566e 100644 --- a/src/shared/workspace-layout/workspace-layout-content-commands.ts +++ b/src/shared/workspace-layout/workspace-layout-content-commands.ts @@ -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, diff --git a/tests/e2e/headless-serve-browser-tab-restart.spec.ts b/tests/e2e/headless-serve-browser-tab-restart.spec.ts new file mode 100644 index 00000000000..fe289d14d13 --- /dev/null +++ b/tests/e2e/headless-serve-browser-tab-restart.spec.ts @@ -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 }> { + const loads = new Map() + 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(`${request.url === '/next' ? 'next' : 'first'}`) + }) + await new Promise((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('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)) + } +}) diff --git a/tests/e2e/paired-mobile-desktop-tab-composition.spec.ts b/tests/e2e/paired-mobile-desktop-tab-composition.spec.ts index 4ce792afa4f..99863137bd8 100644 --- a/tests/e2e/paired-mobile-desktop-tab-composition.spec.ts +++ b/tests/e2e/paired-mobile-desktop-tab-composition.spec.ts @@ -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([])