From bbb934ade42aa0b1cb91c0bfd8f0677ba7b2465b Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Wed, 2 Sep 2026 17:44:12 -0700 Subject: [PATCH] refactor(native-chat): unify host snapshot versioning --- ...ime-apply-mobile-session-tab-navigation.ts | 2 +- ...time-close-headless-mobile-terminal-tab.ts | 2 +- ...time-close-structured-agent-session-tab.ts | 8 ++-- ...e-runtime-owned-mobile-session-terminal.ts | 2 +- ...act-persisted-terminal-surface-identity.ts | 2 +- ...ile-session-tabs-from-workspace-session.ts | 2 +- ...untime-move-headless-mobile-session-tab.ts | 6 +-- ...time-persist-headless-session-tab-props.ts | 4 +- ...me-persist-terminal-surface-retirements.ts | 2 +- ...lish-pty-backed-mobile-session-terminal.ts | 2 +- ...le-headless-mobile-session-browser-tabs.ts | 2 +- ...renderer-session-owned-mobile-terminals.ts | 2 +- ...tore-structured-agent-session-tabs-once.ts | 4 +- src/main/runtime/orca-runtime-runtime-id.ts | 15 ++++++ .../orca-runtime-stop-requested-pty-ids.ts | 2 +- ...mobile-snapshot-has-stale-preserved-tab.ts | 20 ++------ .../orca-runtime-sync-mobile-session-tabs.ts | 4 +- .../runtime/orca-runtime-sync-window-graph.ts | 2 +- .../mobile-session-tabs-part-06.spec.ts | 2 +- ...-touch-mobile-session-tabs-for-worktree.ts | 2 +- ...local-structured-session-tabs-sync.test.ts | 47 ++++++++++++++++++- .../local-structured-session-tabs-sync.ts | 22 ++++++--- 22 files changed, 108 insertions(+), 48 deletions(-) diff --git a/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts b/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts index 912a38b5abc..a2552ef12c3 100644 --- a/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts +++ b/src/main/runtime/orca-runtime-apply-mobile-session-tab-navigation.ts @@ -142,7 +142,7 @@ export class OrcaRuntimeWithApplyMobileSessionTabNavigation extends OrcaRuntimeW tabs } this.persistHeadlessTerminalActiveLeaf(worktreeId, activeTab) - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) } diff --git a/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts b/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts index 2786f8ac8a4..e0ac1bed9d0 100644 --- a/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts +++ b/src/main/runtime/orca-runtime-close-headless-mobile-terminal-tab.ts @@ -107,7 +107,7 @@ export class OrcaRuntimeWithCloseHeadlessMobileTerminalTab extends OrcaRuntimeWi : {}), tabs: nextTabs } - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) } 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 57703367a52..9c6e5cda532 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 @@ -39,7 +39,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi })), tabs: nextTabs } - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) } @@ -49,7 +49,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi protected republishMobileSessionTabsSnapshot(worktreeId: string): void { const snapshot = this.mobileSessionTabsByWorktree.get(worktreeId) if (snapshot) { - this.mobileSessionTabsByWorktree.set(worktreeId, { + this.storeMobileSessionSnapshot(worktreeId, { ...snapshot, snapshotVersion: snapshot.snapshotVersion + 1 }) @@ -161,7 +161,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi })), tabs: nextTabs } - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) return true } @@ -203,7 +203,7 @@ export class OrcaRuntimeWithCloseStructuredAgentSessionTab extends OrcaRuntimeWi focusesHost, publicationEpoch: `headless:${Date.now().toString(36)}` }) - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) // Why: browser group membership is otherwise live-only; persist it so a // later rebuild keeps the browser in its group instead of coalescing left. if (placedInTargetGroup && nextSnapshot.tabGroupLayout) { diff --git a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts index ed9c9cec036..ee0b87693a8 100644 --- a/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-create-runtime-owned-mobile-session-terminal.ts @@ -139,7 +139,7 @@ export class OrcaRuntimeWithCreateRuntimeOwnedMobileSessionTerminal extends Orca ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), tabs } - this.mobileSessionTabsByWorktree.set(worktreeId, next) + this.storeMobileSessionSnapshot(worktreeId, next) const result = this.toMobileSessionTabsResult(next) const changeSequence = ++this.mobileSessionTabsChangeSequence for (const subscription of this.mobileSessionTabListeners) { diff --git a/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts b/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts index e71b0cb8010..6956644c748 100644 --- a/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts +++ b/src/main/runtime/orca-runtime-has-exact-persisted-terminal-surface-identity.ts @@ -65,7 +65,7 @@ export class OrcaRuntimeWithHasExactPersistedTerminalSurfaceIdentity extends Orc exactOnly: true }) if (retired) { - this.mobileSessionTabsByWorktree.set(candidate.worktreeId, retired.snapshot) + this.storeMobileSessionSnapshot(candidate.worktreeId, retired.snapshot) this.notifyMobileSessionTabsChanged(candidate.worktreeId) } } diff --git a/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts b/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts index 1a356c0cdb8..140093fc204 100644 --- a/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts +++ b/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs-from-workspace-session.ts @@ -236,7 +236,7 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession if (existing && headlessMobileSnapshotContentUnchanged(existing, nextSnapshot)) { continue } - this.mobileSessionTabsByWorktree.set(entryWorktreeId, nextSnapshot) + this.storeMobileSessionSnapshot(entryWorktreeId, nextSnapshot) } return reconciledWorktreeIds } diff --git a/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts b/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts index 19a584fbe17..caaf892e65b 100644 --- a/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts +++ b/src/main/runtime/orca-runtime-move-headless-mobile-session-tab.ts @@ -72,7 +72,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith if (nextGroups.length > 1 && snapshot.tabGroupLayout) { this.persistHeadlessTabGroups(worktreeId, nextGroups, snapshot.tabGroupLayout) } - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) return { moved: true } } @@ -111,7 +111,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith tabGroupLayout: split.layout } this.persistHeadlessTabGroups(worktreeId, split.groups, split.layout) - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) return { moved: true } } @@ -147,7 +147,7 @@ export class OrcaRuntimeWithMoveHeadlessMobileSessionTab extends OrcaRuntimeWith tabGroupLayout: layout } this.persistHeadlessTabGroups(worktreeId, moved.groups, layout) - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) return { moved: true } } diff --git a/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts b/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts index e8eee89a1db..10489c6c701 100644 --- a/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts +++ b/src/main/runtime/orca-runtime-persist-headless-session-tab-props.ts @@ -95,7 +95,7 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi snapshotVersion: snapshot.snapshotVersion + 1, tabs } - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) } @@ -181,7 +181,7 @@ export class OrcaRuntimeWithPersistHeadlessSessionTabProps extends OrcaRuntimeWi snapshotVersion: snapshot.snapshotVersion + 1, tabs } - this.mobileSessionTabsByWorktree.set(worktreeId, nextSnapshot) + this.storeMobileSessionSnapshot(worktreeId, nextSnapshot) this.emitMobileSessionTabsSnapshot(nextSnapshot) } } diff --git a/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts b/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts index 1d275a91865..5c70a9e518a 100644 --- a/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts +++ b/src/main/runtime/orca-runtime-persist-terminal-surface-retirements.ts @@ -173,7 +173,7 @@ export class OrcaRuntimeWithPersistTerminalSurfaceRetirements extends OrcaRuntim : {}) }) if (retired) { - this.mobileSessionTabsByWorktree.set(worktreeId, retired.snapshot) + this.storeMobileSessionSnapshot(worktreeId, retired.snapshot) this.notifyMobileSessionTabsChanged(worktreeId) } } diff --git a/src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts b/src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts index b1ee888a65f..baad15289d1 100644 --- a/src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts +++ b/src/main/runtime/orca-runtime-publish-pty-backed-mobile-session-terminal.ts @@ -142,7 +142,7 @@ export class OrcaRuntimeWithPublishPtyBackedMobileSessionTerminal extends OrcaRu ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), tabs } - this.mobileSessionTabsByWorktree.set(worktreeId, next) + this.storeMobileSessionSnapshot(worktreeId, next) if (args.notify !== false) { this.notifyMobileSessionTabsChanged(worktreeId) } 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 a97746eaa30..6c4606ec5aa 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 @@ -47,7 +47,7 @@ export class OrcaRuntimeWithReconcileHeadlessMobileSessionBrowserTabs extends Or const active = activeStillPresent ? null : (nextTabs.find((tab) => tab.isActive) ?? nextTabs[0] ?? null) - this.mobileSessionTabsByWorktree.set(worktreeId, { + this.storeMobileSessionSnapshot(worktreeId, { ...existing, publicationEpoch: `headless-hydrated:${Date.now().toString(36)}`, snapshotVersion: existing.snapshotVersion + 1, diff --git a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts index b5efd465589..bfab8f0ba9a 100644 --- a/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts +++ b/src/main/runtime/orca-runtime-restore-live-paired-renderer-session-owned-mobile-terminals.ts @@ -39,7 +39,7 @@ export class OrcaRuntimeWithRestoreLivePairedRendererSessionOwnedMobileTerminals continue } if (!existing) { - this.mobileSessionTabsByWorktree.set(targetWorktreeId, { + this.storeMobileSessionSnapshot(targetWorktreeId, { worktree: targetWorktreeId, publicationEpoch: `renderer-rescue:${Date.now().toString(36)}`, snapshotVersion: 0, diff --git a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts index be255d723c1..36b5f65b12e 100644 --- a/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts +++ b/src/main/runtime/orca-runtime-restore-structured-agent-session-tabs-once.ts @@ -94,7 +94,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu ), tabs: existing.tabs.map((tab) => ({ ...tab, isActive: tab.id === id })) } - this.mobileSessionTabsByWorktree.set(input.workspaceId, snapshot) + this.storeMobileSessionSnapshot(input.workspaceId, snapshot) if (input.notify !== false) { this.emitMobileSessionTabsSnapshot(snapshot) } @@ -143,7 +143,7 @@ export class OrcaRuntimeWithRestoreStructuredAgentSessionTabsOnce extends OrcaRu ...(existing?.tabGroupLayout ? { tabGroupLayout: existing.tabGroupLayout } : {}), tabs } - this.mobileSessionTabsByWorktree.set(input.workspaceId, snapshot) + this.storeMobileSessionSnapshot(input.workspaceId, snapshot) if (input.notify !== false) { this.emitMobileSessionTabsSnapshot(snapshot) } diff --git a/src/main/runtime/orca-runtime-runtime-id.ts b/src/main/runtime/orca-runtime-runtime-id.ts index da55f2229b6..298cc2b7cb7 100644 --- a/src/main/runtime/orca-runtime-runtime-id.ts +++ b/src/main/runtime/orca-runtime-runtime-id.ts @@ -96,6 +96,21 @@ export class OrcaRuntimeWithRuntimeId { protected mobileSessionTabsByWorktree = new Map() + /** Single host writer for mobile session snapshots; versions are total-order stamps. */ + protected storeMobileSessionSnapshot( + worktreeId: string, + snapshot: RuntimeMobileSessionTabsSnapshot + ): RuntimeMobileSessionTabsSnapshot { + const existing = this.mobileSessionTabsByWorktree.get(worktreeId) + const snapshotVersion = existing + ? Math.max(snapshot.snapshotVersion, existing.snapshotVersion + 1) + : snapshot.snapshotVersion + const stamped = + snapshotVersion === snapshot.snapshotVersion ? snapshot : { ...snapshot, snapshotVersion } + this.mobileSessionTabsByWorktree.set(worktreeId, stamped) + return stamped + } + protected structuredAgentSessionTabRestorePromise: Promise | null = null protected structuredAgentSessionStartupRestorePromise: Promise | null = null diff --git a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts index 233e739911b..278ccd28a74 100644 --- a/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts +++ b/src/main/runtime/orca-runtime-stop-requested-pty-ids.ts @@ -57,7 +57,7 @@ export class OrcaRuntimeWithStopRequestedPtyIds extends OrcaRuntimeWithRuntimeId resolveOwner: (handle) => this.resolveNativeChatLaunchDraftOwner(handle), listMobileSnapshots: () => this.mobileSessionTabsByWorktree, setMobileSnapshot: (worktreeId, snapshot) => - this.mobileSessionTabsByWorktree.set(worktreeId, snapshot), + this.storeMobileSessionSnapshot(worktreeId, snapshot), scheduleMobileSnapshot: (worktreeId) => this.scheduleMobileSessionTabsChanged(worktreeId), notifyResolved: (tabId, resolution, event) => { this.notifier?.nativeChatLaunchDraftResolved?.(tabId, resolution) 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 524018d889f..c32349352a1 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 @@ -8,7 +8,6 @@ import type { import { getMobileSessionSnapshotTabIdentityKeys } from './mobile-session-tab-merge' import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' import { sameRuntimeBrowserPlacement } from '../../shared/runtime-browser-placement' -import { createHash } from 'node:crypto' import type { ClientHostedBrowserRowsEvent } from '../../shared/client-hosted-browser-rows' export class OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab extends OrcaRuntimeWithMergePreservedHeadlessMobileSessionTabs { @@ -115,23 +114,14 @@ export class OrcaRuntimeWithStoredMobileSnapshotHasStalePreservedTab extends Orc protected getMergedMobileSessionPublicationEpoch( snapshot: RuntimeMobileSessionTabsSnapshot, - preservedTabs: readonly RuntimeMobileSessionSnapshotTab[] + _preservedTabs: readonly RuntimeMobileSessionSnapshotTab[] ): string { // Why: preserved snapshots can merge repeatedly; strip the prior merge suffix first so the publication epoch stays idempotent. const normalizedPublicationEpoch = snapshot.publicationEpoch.split(':headless-merge:')[0] - const signature = createHash('sha1') - .update( - preservedTabs - .map((tab) => - tab.type === 'terminal' - ? `${tab.id}:${tab.parentTabId}:${tab.ptyId ?? ''}:${tab.leafId}` - : tab.id - ) - .join('|') - ) - .digest('hex') - .slice(0, 12) - return `${normalizedPublicationEpoch}:headless-merge:${signature}` + // The epoch identifies the publisher generation, not the merged content. + // Content changes are ordered by snapshotVersion, so encoding a merge hash + // here would make the identity oscillate and permanently fence later rows. + return normalizedPublicationEpoch } /** Serves a hydrating host renderer; the publisher counts this as a delivery, not a read. */ diff --git a/src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts b/src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts index 4d54a322f3e..51997577179 100644 --- a/src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts +++ b/src/main/runtime/orca-runtime-sync-mobile-session-tabs.ts @@ -167,7 +167,7 @@ export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOr const storedVersion = existing ? Math.max(nextSnapshot.snapshotVersion, existing.snapshotVersion + 1) : nextSnapshot.snapshotVersion - this.mobileSessionTabsByWorktree.set( + this.storeMobileSessionSnapshot( snapshot.worktree, storedVersion === nextSnapshot.snapshotVersion ? nextSnapshot @@ -195,7 +195,7 @@ export class OrcaRuntimeWithSyncMobileSessionTabs extends OrcaRuntimeWithWriteOr preserved.tabs.length === existing.tabs.length && preserved.tabs.every((tab, index) => tab === existing.tabs[index]) if (!preservedIsNoOp) { - this.mobileSessionTabsByWorktree.set(worktreeId, preserved) + this.storeMobileSessionSnapshot(worktreeId, preserved) } // Why: the stored entry is no longer the renderer's publication, so a // future renderer frame must be re-merged even if it reuses the pair. diff --git a/src/main/runtime/orca-runtime-sync-window-graph.ts b/src/main/runtime/orca-runtime-sync-window-graph.ts index f9c6ab358e3..7d44d4af2c1 100644 --- a/src/main/runtime/orca-runtime-sync-window-graph.ts +++ b/src/main/runtime/orca-runtime-sync-window-graph.ts @@ -238,7 +238,7 @@ export class OrcaRuntimeWithSyncWindowGraph extends OrcaRuntimeWithAttachWindow // the PTY touch path does) or the re-emitted payload — e.g. the // pending-handle → ready flip — is discarded and the client stays stale. // The accepted-renderer tracking is untouched: this is a main-local bump. - this.mobileSessionTabsByWorktree.set(worktreeId, { + this.storeMobileSessionSnapshot(worktreeId, { ...stored, snapshotVersion: stored.snapshotVersion + 1 }) diff --git a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts index 27e052e1594..be1d4c8175c 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-session-tabs-part-06.spec.ts @@ -595,7 +595,7 @@ describe('OrcaRuntimeService', () => { const secondMerge = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`) expect(secondMerge.publicationEpoch).toBe(firstMerge.publicationEpoch) - expect(secondMerge.publicationEpoch.match(/:headless-merge:/g) ?? []).toHaveLength(1) + expect(secondMerge.publicationEpoch).toBe('headless:stable-epoch') }) it('keeps the graph ready when a mobile snapshot references a removed folder workspace', () => { diff --git a/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts b/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts index 1481709ee66..b7ca09af705 100644 --- a/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts +++ b/src/main/runtime/orca-runtime-touch-mobile-session-tabs-for-worktree.ts @@ -21,7 +21,7 @@ export class OrcaRuntimeWithTouchMobileSessionTabsForWorktree extends OrcaRuntim if (!snapshot) { return } - this.mobileSessionTabsByWorktree.set(worktreeId, { + this.storeMobileSessionSnapshot(worktreeId, { ...snapshot, snapshotVersion: snapshot.snapshotVersion + 1 }) diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts index 4bd9a2e6d45..9420d771530 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.test.ts @@ -6,7 +6,8 @@ import { buildPersistedUnifiedTabSessionData } from '../lib/workspace-session-un import { buildHydratedTabState } from '../store/slices/tabs-hydration' import { applyLocalStructuredSessionTabSnapshots, - projectLocalStructuredSessionTabs + projectLocalStructuredSessionTabs, + resetLocalStructuredSessionVersionForTests } from './local-structured-session-tabs-sync' import { applyWebSessionTabsSnapshot, @@ -25,6 +26,7 @@ const PRIMARY_GROUP = 'primary-group' const SECONDARY_GROUP = 'secondary-group' afterEach(() => { + resetLocalStructuredSessionVersionForTests() resetWebSessionFocusIntentForTests() resetWebSessionTabsSnapshotFreshnessForTests() }) @@ -148,6 +150,49 @@ function expectExactSplit(state: { } describe('local structured session tab projection', () => { + it('accepts a newer session after merged content returns to the base epoch', () => { + const state = createSnapshot() + const base = { + ...({ + worktree: WORKTREE_ID, + publicationEpoch: 'renderer:generation-1', + snapshotVersion: 4, + activeGroupId: null, + activeTabId: null, + activeTabType: null, + tabs: [] + } satisfies RuntimeMobileSessionTabsResult) + } + const withChat = { + ...base, + snapshotVersion: 5, + tabs: [ + { + type: 'agent-session' as const, + id: STRUCTURED_ID, + title: 'Codex Chat', + sessionId: 'codex-1', + agent: 'codex' as const, + isActive: true + } + ] + } + const afterClose = { ...base, snapshotVersion: 6 } + const next = applyLocalStructuredSessionTabSnapshots( + state, + [ + base, + withChat, + afterClose, + { ...base, snapshotVersion: 7, tabs: [{ ...withChat.tabs[0], isActive: true }] } + ], + 'local-structured-session' + ) + expect(next.unifiedTabsByWorktree[WORKTREE_ID]).toEqual( + expect.arrayContaining([expect.objectContaining({ contentType: 'agent-session' })]) + ) + }) + it('drops terminal topology while retaining structured tabs', () => { const snapshot = { worktree: 'workspace-1', diff --git a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts index 7d8ee6aa626..a18bc8d7bda 100644 --- a/src/renderer/src/runtime/local-structured-session-tabs-sync.ts +++ b/src/renderer/src/runtime/local-structured-session-tabs-sync.ts @@ -4,15 +4,19 @@ import type { RuntimeMobileSessionTabsResult } from '../../../shared/runtime-typ import { useAppStore } from '../store' import type { WorktreeRuntimeOwnerState } from '../lib/worktree-runtime-owner' import { getExecutionHostIdForWorktree } from '../lib/worktree-runtime-owner' -import { - applyWebSessionTabsSnapshot, - applyWebSessionTabsStorePatch, - decideWebSessionTabsSnapshot -} from './web-session-tabs-sync' +import { applyWebSessionTabsSnapshot, applyWebSessionTabsStorePatch } from './web-session-tabs-sync' import type { WebSessionTabsSyncState } from './web-session-tabs-sync' export const LOCAL_STRUCTURED_SESSION_OWNER = 'local-structured-session' let localStructuredSessionTabsRestorePromise: Promise | null = null +const localStructuredSessionVersionByWorktree = new Map< + string, + { publicationEpoch: string; snapshotVersion: number } +>() + +export function resetLocalStructuredSessionVersionForTests(): void { + localStructuredSessionVersionByWorktree.clear() +} type SessionTabsEvent = | (RuntimeMobileSessionTabsResult & { type: 'snapshot' | 'updated' }) @@ -79,7 +83,9 @@ export function applyLocalStructuredSessionTabSnapshots< if (getExecutionHostIdForWorktree(next, snapshot.worktree) !== 'local') { continue } - if (!decideWebSessionTabsSnapshot(snapshot, owner).apply) { + const prior = localStructuredSessionVersionByWorktree.get(snapshot.worktree) + const isNewerPublisher = prior?.publicationEpoch !== snapshot.publicationEpoch + if (prior && !isNewerPublisher && snapshot.snapshotVersion <= prior.snapshotVersion) { continue } const patch = applyWebSessionTabsSnapshot( @@ -94,6 +100,10 @@ export function applyLocalStructuredSessionTabSnapshots< } ) next = patch === next ? next : ({ ...next, ...patch } as State) + localStructuredSessionVersionByWorktree.set(snapshot.worktree, { + publicationEpoch: snapshot.publicationEpoch, + snapshotVersion: snapshot.snapshotVersion + }) } return next }