From 276ceb225d5277f98d311bca5dfa18db9786b914 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:26:36 -0700 Subject: [PATCH] fix: preserve renderer browser publication during client-hosted page updates --- ...time-close-structured-agent-session-tab.ts | 2 +- ...le-headless-mobile-session-browser-tabs.ts | 13 +- ...rer-browser-session-reconciliation.test.ts | 148 ++++++++++++++++++ 3 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 src/main/runtime/renderer-browser-session-reconciliation.test.ts 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..24e5f5bdcfa 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 @@ -207,7 +207,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi tabId: tab.id, ...(targetGroupId !== undefined ? { targetGroupId } : {}), focusesHost, - publicationEpoch: `headless:${Date.now().toString(36)}` + publicationEpoch: snapshot.publicationEpoch }) 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..de20df946b3 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,19 @@ 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' ) + // 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') + : [] + const liveBrowserTabs = [ + ...rendererBrowserTabs, + ...this.buildHeadlessMobileSessionBrowserTabs(worktreeId) + ] + const liveIds = liveBrowserTabs.map((tab) => tab.id) const existingBrowserIds = existingBrowserTabs.map((tab) => tab.id) if (headlessBrowserTabsUnchanged(liveBrowserTabs, existingBrowserTabs)) { return @@ -53,7 +61,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..019dba2c36a --- /dev/null +++ b/src/main/runtime/renderer-browser-session-reconciliation.test.ts @@ -0,0 +1,148 @@ +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 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'] }] +} + +it('keeps renderer-owned browser pages when refreshing client-hosted pages on an attached desktop', () => { + const storeMobileSessionSnapshot = vi.fn() + const reconcile = + OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs.prototype as unknown as { + reconcileHeadlessMobileSessionBrowserTabs( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot + ): void + } + reconcile.reconcileHeadlessMobileSessionBrowserTabs.call( + { + buildHeadlessMobileSessionBrowserTabs: () => [], + getAvailableAuthoritativeWindow: () => ({}), + offscreenBrowserBackend: null, + storeMobileSessionSnapshot + }, + 'wt', + snapshot + ) + const published = storeMobileSessionSnapshot.mock.calls[0]?.[1] ?? 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) => { + const storeMobileSessionSnapshot = vi.fn() + const reconcile = + OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs.prototype as unknown as { + reconcileHeadlessMobileSessionBrowserTabs( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot + ): void + } + reconcile.reconcileHeadlessMobileSessionBrowserTabs.call( + { + buildHeadlessMobileSessionBrowserTabs: () => [], + getAvailableAuthoritativeWindow: () => (attached ? {} : null), + offscreenBrowserBackend: {}, + storeMobileSessionSnapshot + }, + 'wt', + snapshot + ) + expect(storeMobileSessionSnapshot.mock.calls[0]?.[1].tabs).toEqual([]) +}) + +it('removes retired client pages and publishes live ones while retaining renderer rows and group order', () => { + const clientPage: RuntimeMobileSessionBrowserTab = { + ...rendererPage, + id: 'client', + browserWorkspaceId: 'client', + browserPageId: 'client', + placement: { + kind: 'client', + browserHostClientId: 'host', + browserHostGeneration: 1, + pageHostGeneration: 1 + } + } + const livePage = { ...clientPage, id: 'live', browserWorkspaceId: 'live', browserPageId: 'live' } + const storeMobileSessionSnapshot = vi.fn() + const reconcile = + OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs.prototype as unknown as { + reconcileHeadlessMobileSessionBrowserTabs( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot + ): void + } + reconcile.reconcileHeadlessMobileSessionBrowserTabs.call( + { + buildHeadlessMobileSessionBrowserTabs: () => [livePage], + getAvailableAuthoritativeWindow: () => ({}), + offscreenBrowserBackend: null, + storeMobileSessionSnapshot + }, + 'wt', + { + ...snapshot, + tabs: [rendererPage, clientPage], + tabGroups: [ + { id: 'group', activeTabId: 'renderer-tab', tabOrder: ['renderer-tab', 'client'] } + ] + } + ) + const published = storeMobileSessionSnapshot.mock.calls[0]?.[1] + 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('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 + ) +})