mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user