diff --git a/src/main/runtime/browser-session-tab-selection-snapshot.test.ts b/src/main/runtime/browser-session-tab-selection-snapshot.test.ts index a96a5617d3b..f553650323c 100644 --- a/src/main/runtime/browser-session-tab-selection-snapshot.test.ts +++ b/src/main/runtime/browser-session-tab-selection-snapshot.test.ts @@ -2,8 +2,6 @@ import { describe, expect, it } from 'vitest' import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' import { applyBrowserSessionTabSelection } from './browser-session-tab-selection-snapshot' -const EPOCH = 'headless:test' - function makeSnapshot(): RuntimeMobileSessionTabsSnapshot { return { worktree: 'wt-1', @@ -46,8 +44,7 @@ function select(overrides: { focusesHost: boolean; targetGroupId?: string }) { snapshot: makeSnapshot(), tabId: 'page-new', focusesHost: overrides.focusesHost, - ...(overrides.targetGroupId ? { targetGroupId: overrides.targetGroupId } : {}), - publicationEpoch: EPOCH + ...(overrides.targetGroupId ? { targetGroupId: overrides.targetGroupId } : {}) }) } @@ -115,10 +112,11 @@ describe('applyBrowserSessionTabSelection', () => { expect(snapshot.activeGroupId).toBe('group-left') }) - it('republishes under a fresh epoch and a newer version either way', () => { + // Rotating the epoch here retires the renderer's own publication client-side. + it('keeps the publication epoch and advances the version either way', () => { for (const focusesHost of [true, false]) { const { snapshot } = select({ focusesHost }) - expect(snapshot.publicationEpoch).toBe(EPOCH) + expect(snapshot.publicationEpoch).toBe('headless:before') expect(snapshot.snapshotVersion).toBe(5) } }) diff --git a/src/main/runtime/browser-session-tab-selection-snapshot.ts b/src/main/runtime/browser-session-tab-selection-snapshot.ts index aa95c9f2685..e5862e1926d 100644 --- a/src/main/runtime/browser-session-tab-selection-snapshot.ts +++ b/src/main/runtime/browser-session-tab-selection-snapshot.ts @@ -22,7 +22,6 @@ export function applyBrowserSessionTabSelection(args: { tabId: string targetGroupId?: string focusesHost: boolean - publicationEpoch: string }): BrowserSessionTabSelectionResult { const { snapshot, tabId, targetGroupId, focusesHost } = args const groups = snapshot.tabGroups ?? [] @@ -56,7 +55,6 @@ export function applyBrowserSessionTabSelection(args: { placedInTargetGroup, snapshot: { ...snapshot, - publicationEpoch: args.publicationEpoch, snapshotVersion: snapshot.snapshotVersion + 1, ...(placedInTargetGroup && focusesHost ? { activeGroupId: targetGroupId } : {}), ...(focusesHost 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 bd282b6575d..ba762ef17d8 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 @@ -206,8 +206,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi snapshot, tabId: tab.id, ...(targetGroupId !== undefined ? { targetGroupId } : {}), - focusesHost, - publicationEpoch: `headless:${Date.now().toString(36)}` + focusesHost }) this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) // Why: browser group membership is otherwise live-only; persist it so a diff --git a/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts b/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts index a9f377e5a7b..ddb74df4dc8 100644 --- a/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts +++ b/src/main/runtime/orca-runtime-reconcile-headless-mobile-session-browser-tabs.ts @@ -25,11 +25,28 @@ export class OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs extends Or worktreeId: string, existing: RuntimeMobileSessionTabsSnapshot ): void { - const liveBrowserTabs = this.buildHeadlessMobileSessionBrowserTabs(worktreeId) - const liveIds = liveBrowserTabs.map((tab) => tab.id) const existingBrowserTabs = existing.tabs.filter( (tab): tab is RuntimeMobileSessionBrowserTab => tab.type === 'browser' ) + const publishedBrowserTabs = this.buildHeadlessMobileSessionBrowserTabs(worktreeId) + // An attached renderer owns its browser rows; the client-page registry cannot retire them. + const rendererBrowserTabs = + this.getAvailableAuthoritativeWindow() && !this.offscreenBrowserBackend + ? existingBrowserTabs.filter((tab) => tab.placement?.kind !== 'client') + : [] + // Keyed by id so no row can publish twice whatever the two sources overlap on; a freshly + // built row wins over the retained one it replaces. + const liveById = new Map( + [...rendererBrowserTabs, ...publishedBrowserTabs].map((tab) => [tab.id, tab]) + ) + // Emit in the order the snapshot already had, because the equality check below compares by + // index: rebuilding renderer-first would read a pure reordering as a change and republish. + const retainedInOrder = existingBrowserTabs.flatMap((tab) => { + const live = liveById.get(tab.id) + return live && liveById.delete(tab.id) ? [live] : [] + }) + const liveBrowserTabs = [...retainedInOrder, ...liveById.values()] + const liveIds = liveBrowserTabs.map((tab) => tab.id) const existingBrowserIds = existingBrowserTabs.map((tab) => tab.id) if (headlessBrowserTabsUnchanged(liveBrowserTabs, existingBrowserTabs)) { return @@ -53,7 +70,6 @@ export class OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs extends Or : (nextTabs.find((tab) => tab.isActive) ?? nextTabs[0] ?? null) this.storeMobileSessionSnapshot(worktreeId, { ...existing, - publicationEpoch: `headless-hydrated:${Date.now().toString(36)}`, snapshotVersion: existing.snapshotVersion + 1, ...(activeStillPresent ? {} diff --git a/src/main/runtime/renderer-browser-session-reconciliation.test.ts b/src/main/runtime/renderer-browser-session-reconciliation.test.ts new file mode 100644 index 00000000000..7dc929c231d --- /dev/null +++ b/src/main/runtime/renderer-browser-session-reconciliation.test.ts @@ -0,0 +1,154 @@ +import { expect, it, vi } from 'vitest' +import type { + RuntimeMobileSessionBrowserTab, + RuntimeMobileSessionTabsSnapshot +} from '../../shared/runtime-types' +import { OrcaRuntimeWithCloseStructuredAgentSessionTab } from './orca-runtime-close-structured-agent-session-tab' +import { OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs } from './orca-runtime-reconcile-headless-mobile-session-browser-tabs' + +const rendererPage: RuntimeMobileSessionBrowserTab = { + type: 'browser', + id: 'renderer-tab', + browserWorkspaceId: 'renderer-workspace', + browserPageId: 'renderer-page', + title: 'Server page', + url: 'https://example.com/server', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: false +} +const clientPage: RuntimeMobileSessionBrowserTab = { + ...rendererPage, + id: 'client', + browserWorkspaceId: 'client', + browserPageId: 'client', + placement: { + kind: 'client', + browserHostClientId: 'host', + browserHostGeneration: 1, + pageHostGeneration: 1 + } +} +const snapshot: RuntimeMobileSessionTabsSnapshot = { + worktree: 'wt', + publicationEpoch: 'renderer:1', + snapshotVersion: 1, + activeGroupId: 'group', + activeTabId: 'renderer-tab', + activeTabType: 'browser', + tabs: [rendererPage], + tabGroups: [{ id: 'group', activeTabId: 'renderer-tab', tabOrder: ['renderer-tab'] }] +} + +/** Drives the reconcile against a stub host and returns the published snapshot, if any. */ +function reconcile( + host: { + live?: RuntimeMobileSessionBrowserTab[] + attached?: boolean + offscreen?: boolean + }, + existing: RuntimeMobileSessionTabsSnapshot = snapshot +): RuntimeMobileSessionTabsSnapshot | undefined { + const storeMobileSessionSnapshot = vi.fn() + const runtime = OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs.prototype as unknown as { + reconcileHeadlessMobileSessionBrowserTabs( + worktreeId: string, + existing: RuntimeMobileSessionTabsSnapshot + ): void + } + runtime.reconcileHeadlessMobileSessionBrowserTabs.call( + { + buildHeadlessMobileSessionBrowserTabs: () => host.live ?? [], + getAvailableAuthoritativeWindow: () => (host.attached === false ? null : {}), + offscreenBrowserBackend: host.offscreen === true ? {} : null, + storeMobileSessionSnapshot + }, + 'wt', + existing + ) + return storeMobileSessionSnapshot.mock.calls[0]?.[1] +} + +it('keeps renderer-owned browser pages when refreshing client-hosted pages on an attached desktop', () => { + const published = reconcile({}) ?? snapshot + + expect(published.tabs).toContainEqual(rendererPage) + expect(published.tabGroups?.[0].tabOrder).toContain('renderer-tab') +}) + +it.each([false, true])('retires absent offscreen pages when attached=%s', (attached) => { + expect(reconcile({ attached, offscreen: true })?.tabs).toEqual([]) +}) + +it('removes retired client pages and publishes live ones while retaining renderer rows and group order', () => { + const livePage = { ...clientPage, id: 'live', browserWorkspaceId: 'live', browserPageId: 'live' } + + const published = reconcile( + { live: [livePage] }, + { + ...snapshot, + tabs: [rendererPage, clientPage], + tabGroups: [ + { id: 'group', activeTabId: 'renderer-tab', tabOrder: ['renderer-tab', 'client'] } + ] + } + ) + + expect(published?.tabs).toEqual([rendererPage, livePage]) + expect(published?.tabGroups?.[0].tabOrder).toEqual(['renderer-tab', 'live']) + expect(published?.activeTabId).toBe('renderer-tab') + expect(published?.publicationEpoch).toBe(snapshot.publicationEpoch) + expect(published?.snapshotVersion).toBe(snapshot.snapshotVersion + 1) +}) + +it('never publishes a row twice when the live build reclaims a renderer-owned id', () => { + const reclaimed = { + ...clientPage, + id: rendererPage.id, + browserPageId: rendererPage.browserPageId + } + + const published = reconcile({ live: [reclaimed] }) + + expect(published?.tabs).toEqual([reclaimed]) + expect(published?.tabGroups?.[0].tabOrder).toEqual([rendererPage.id]) +}) + +it('does not republish when a client row merely sits before a renderer row', () => { + const interleaved = { + ...snapshot, + tabs: [clientPage, rendererPage], + tabGroups: [{ id: 'group', activeTabId: 'renderer-tab', tabOrder: ['client', 'renderer-tab'] }] + } + + expect(reconcile({ live: [clientPage] }, interleaved)).toBeUndefined() +}) + +it('keeps the renderer publication epoch when selecting a client-hosted browser tab', () => { + const storeMobileSessionSnapshot = vi.fn() + const runtime = OrcaRuntimeWithCloseStructuredAgentSessionTab.prototype as unknown as { + markHeadlessBrowserSessionTabActive( + worktreeId: string, + browserPageId: string, + options: { focusesHost: boolean } + ): void + } + + runtime.markHeadlessBrowserSessionTabActive.call( + { + offscreenBrowserBackend: {}, + hydrateHeadlessMobileSessionTabsFromWorkspaceSession: () => undefined, + mobileSessionTabsByWorktree: new Map([['wt', snapshot]]), + storeMobileSessionSnapshot, + emitMobileSessionTabsSnapshot: vi.fn() + }, + 'wt', + 'renderer-page', + { focusesHost: false } + ) + + expect(storeMobileSessionSnapshot.mock.calls[0]?.[1].publicationEpoch).toBe( + snapshot.publicationEpoch + ) +})