fix: preserve renderer browser publication during client-hosted page updates (#18961)

* fix: preserve renderer browser publication during client-hosted page updates

* refactor: drop the now-dead publicationEpoch selection argument

applyBrowserSessionTabSelection took a publicationEpoch and wrote it over
the epoch the spread snapshot already carried. Its only production caller
now passes snapshot.publicationEpoch, so the parameter is a no-op whose
only remaining power is to reintroduce the epoch rotation this PR fixes.

Remove it, and collapse the repeated prototype-cast boilerplate in the new
reconciliation test into one helper.

No behavior change.

* fix: keep reconcile from publishing a browser row twice

The retention filter partitioned existing rows by placement kind, so its
disjointness from the live build relied on a non-local invariant: that the
page registry only ever stores client placements and that server tabs are
empty while no offscreen backend exists. Drop ids the live build already
published instead, so a duplicate row is impossible by construction rather
than by coincidence.

* fix: stop the browser reconcile republishing on a pure reordering

headlessBrowserTabsUnchanged compares by array index, so rebuilding the live
list renderer-first read an interleaved snapshot as changed and republished
with a bumped version and rebuilt tab groups for no semantic change - the
same churn this branch exists to remove.

Key the live set by id and emit it in the order the snapshot already had.
Keying also makes uniqueness unconditional rather than resting on the page
registry only ever storing client placements.
This commit is contained in:
Neil
2026-09-06 18:29:22 -07:00
committed by GitHub
parent 225a47533d
commit b497f15b53
5 changed files with 178 additions and 13 deletions
@@ -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)
}
})
@@ -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
@@ -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
@@ -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
? {}
@@ -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
)
})