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 34ce3bfdc27..65985be3bef 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 @@ -1,6 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithWaitForSessionTabsInventoryPublication } from './orca-runtime-wait-for-session-tabs-inventory-publication' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' +import type { TabGroup } from '../../shared/tab-types' import { getRuntimeBrowserPageRegistry } from './runtime-browser-page-registry' import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' import { buildHeadlessMobileSessionTerminalTabs } from './mobile-session-terminal-projection' @@ -28,6 +29,17 @@ import { } from './mobile-session-browser-group-projection' import { headlessMobileSnapshotContentUnchanged } from './mobile-session-snapshot-equality' +// Why: a persisted TabGroup carries `worktreeId`, which the published group +// shape does not — spreading one onto the wire leaks it to every client. +function toRuntimeMobileSessionTabGroup(group: TabGroup): RuntimeMobileSessionTabGroup { + return { + id: group.id, + activeTabId: group.activeTabId, + tabOrder: [...group.tabOrder], + ...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {}) + } +} + export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession extends OrcaRuntimeWithWaitForSessionTabsInventoryPublication { protected hydrateHeadlessMobileSessionTabsFromWorkspaceSession( worktreeId?: string, @@ -142,12 +154,18 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession ...browserTabs.map((tab) => tab.id) ] const groupId = getHeadlessMobileSessionGroupId(entryWorktreeId) + // Why: `tabs` already carries every live browser, so merging the existing + // browser entries back in resurrects a page that closed while the + // snapshot was chat-only (the skip branch's reconcile used to prune it). + const existingMergeBaseTabs = preserveNonTerminalSnapshot + ? existing.tabs.filter((tab) => tab.type !== 'browser') + : existing?.tabs const mergedTabs = (options.onlyRuntimeOwnedTerminals === true || preserveNonTerminalSnapshot) && existing - ? mergeMobileSessionSnapshotTabs(existing.tabs, tabs) + ? mergeMobileSessionSnapshotTabs(existingMergeBaseTabs, tabs) : tabs const mergedActiveTab = - existing?.tabs.find((tab) => tab.id === existing.activeTabId) ?? + existingMergeBaseTabs?.find((tab) => tab.id === existing?.activeTabId) ?? activeTab ?? mergedTabs[0] ?? null @@ -177,17 +195,12 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession entryWorktreeId, mergedTabs, mergedActiveTab, - existing.tabGroups ?? persistedGroups + existing.tabGroups ?? persistedGroups?.map(toRuntimeMobileSessionTabGroup) ) : hasPersistedSplit ? appendBrowserTabOrder( distributeHeadlessTabsAcrossGroups( - persistedGroups.map((group) => ({ - id: group.id, - activeTabId: group.activeTabId, - tabOrder: [...group.tabOrder], - ...(group.recentTabIds ? { recentTabIds: [...group.recentTabIds] } : {}) - })), + persistedGroups.map(toRuntimeMobileSessionTabGroup), collectHeadlessParentTabOrder(mergedTerminalTabs), activeTopLevelId ), @@ -216,22 +229,31 @@ export class OrcaRuntimeWithHydrateHeadlessMobileSessionTabsFromWorkspaceSession tabOrder } ] - // Why: merging runtime tabs INTO a renderer publication must not reclass - // the snapshot as headless-built — the preservation predicate would then - // treat the renderer's own tabs as runtime-owned and resurrect tabs the - // renderer later closes. Keep the renderer base epoch with a merge suffix - // (idempotent) so ownership stays derivable from the epoch. + // Why: merging INTO a non-headless publication must not reclass the + // snapshot as headless-built — the preservation predicate would then treat + // that publisher's own tabs as runtime-owned and resurrect tabs it later + // closes. Applies to both merge paths: the chat-only fall-through also + // merges into an existing snapshot, whose base epoch can be a renderer's. const mergedIntoRendererPublication = - options.onlyRuntimeOwnedTerminals === true && + (options.onlyRuntimeOwnedTerminals === true || preserveNonTerminalSnapshot) && existing !== undefined && !this.isHeadlessBuiltMobileSessionPublicationBase(existing.publicationEpoch) + // Why: a group whose last tab is gone is dropped above, so a carried-over + // activeGroupId can name a group that no longer exists — reseat it on the + // group holding the active tab rather than publishing a dangling id. + const preservedActiveGroupId = existing?.activeGroupId ?? groupId + const nextActiveGroupId = nextTabGroups.some((group) => group.id === preservedActiveGroupId) + ? preservedActiveGroupId + : (nextTabGroups.find((group) => group.tabOrder.includes(activeTopLevelId))?.id ?? + nextTabGroups[0]?.id ?? + preservedActiveGroupId) const nextSnapshot: RuntimeMobileSessionTabsSnapshot = { worktree: existing?.worktree ?? entryWorktreeId, publicationEpoch: mergedIntoRendererPublication ? this.getMergedMobileSessionPublicationEpoch(existing, tabs) : `headless-hydrated:${Date.now().toString(36)}`, snapshotVersion: (existing?.snapshotVersion ?? 0) + 1, - activeGroupId: existing?.activeGroupId ?? groupId, + activeGroupId: nextActiveGroupId, activeTabId: mergedActiveTab?.id ?? null, activeTabType: mergedActiveTab?.type ?? null, tabGroups: nextTabGroups, diff --git a/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs.test.ts b/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs.test.ts index 6f1804ad940..fe4281bc70f 100644 --- a/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs.test.ts +++ b/src/main/runtime/orca-runtime-hydrate-headless-mobile-session-tabs.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getDefaultWorkspaceSession } from '../../shared/constants' -import type { RuntimeMobileSessionTabsSnapshot } from '../../shared/runtime-types' +import type { + RuntimeMobileSessionSnapshotTab, + RuntimeMobileSessionTabsSnapshot +} from '../../shared/runtime-types' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import * as terminalProjection from './mobile-session-terminal-projection' import { OrcaRuntimeService } from './orca-runtime' @@ -12,10 +15,15 @@ type RuntimeInternals = { getAvailableAuthoritativeWindow(): unknown getWorkspaceSessionForWorktree(worktreeId: string): WorkspaceSessionState mobileSessionTabsByWorktree: Map - buildHeadlessMobileSessionBrowserTabs: () => never[] + buildHeadlessMobileSessionBrowserTabs: () => RuntimeMobileSessionSnapshotTab[] reconcileHeadlessMobileSessionBrowserTabs: () => void hasServeOrSshOwnedBinding(tab: { ptyId?: string }): boolean hasRecentExpiredSshLeasePane(): boolean + isHeadlessBuiltMobileSessionPublicationBase(publicationEpoch: string): boolean + shouldPreserveHeadlessMobileSessionTab( + snapshot: RuntimeMobileSessionTabsSnapshot, + tab: RuntimeMobileSessionSnapshotTab + ): boolean hydrateHeadlessMobileSessionTabsFromWorkspaceSession( worktreeId: string, options: { @@ -27,6 +35,21 @@ type RuntimeInternals = { ): Set } +function browserTab(): RuntimeMobileSessionSnapshotTab { + return { + type: 'browser', + id: 'browser', + browserWorkspaceId: 'page', + browserPageId: 'browser', + loading: false, + canGoBack: false, + canGoForward: false, + title: 'Page', + url: 'about:blank', + isActive: false + } +} + function setup() { const runtime = new OrcaRuntimeService() as unknown as RuntimeInternals const session: WorkspaceSessionState = { @@ -81,18 +104,10 @@ describe('persisted terminal hydration behind non-terminal snapshots', () => { it.each([false, true])('preserves the active chat and its group (browser split: %s)', (split) => { const { runtime, session, snapshot } = setup() if (split) { - snapshot.tabs.push({ - type: 'browser', - id: 'browser', - browserWorkspaceId: 'page', - browserPageId: 'browser', - loading: false, - canGoBack: false, - canGoForward: false, - title: 'Page', - url: 'about:blank', - isActive: false - }) + const browser = browserTab() + snapshot.tabs.push(browser) + // The page is still live, so the rebuild republishes it. + runtime.buildHeadlessMobileSessionBrowserTabs = vi.fn(() => [browser]) snapshot.tabGroups!.unshift({ id: 'browser-group', activeTabId: 'browser', @@ -213,9 +228,99 @@ describe('persisted terminal hydration behind non-terminal snapshots', () => { allowAttachedWindow: true, force: true }) - expect(runtime.mobileSessionTabsByWorktree.get(WORKTREE)!.tabs.map((tab) => tab.type)).toEqual([ - 'terminal', - 'terminal' - ]) + const result = runtime.mobileSessionTabsByWorktree.get(WORKTREE)! + expect(result.tabs.map((tab) => tab.type)).toEqual(['terminal', 'terminal']) + // Guards the epoch fix below from over-correcting: a plain rebuild that + // merges into nothing is headless-built and must say so. + expect(result.publicationEpoch.startsWith('headless-hydrated:')).toBe(true) + }) +}) + +describe('chat-only fall-through hygiene', () => { + const RENDERER_EPOCH = 'renderer:6b1f0f5c-0b6a-4d31-9d1f-6a0f1d2c3b4e' + + it('carries a renderer base epoch forward instead of reclassing as headless-built', () => { + const { runtime, snapshot } = setup() + snapshot.publicationEpoch = RENDERER_EPOCH + + runtime.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WORKTREE, { + allowAttachedWindow: true + }) + + const result = runtime.mobileSessionTabsByWorktree.get(WORKTREE)! + expect(result.publicationEpoch).toBe(RENDERER_EPOCH) + expect(runtime.isHeadlessBuiltMobileSessionPublicationBase(result.publicationEpoch)).toBe(false) + const hydrated = result.tabs.find((tab) => tab.type === 'terminal')! + expect(runtime.shouldPreserveHeadlessMobileSessionTab(result, hydrated)).toBe(false) + }) + + it('keeps a headless base epoch headless-built', () => { + const { runtime, snapshot } = setup() + snapshot.publicationEpoch = 'headless:seed' + + runtime.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WORKTREE, { + allowAttachedWindow: true + }) + + const result = runtime.mobileSessionTabsByWorktree.get(WORKTREE)! + expect(result.publicationEpoch.startsWith('headless-hydrated:')).toBe(true) + expect(runtime.isHeadlessBuiltMobileSessionPublicationBase(result.publicationEpoch)).toBe(true) + }) + + it('publishes persisted groups in the wire shape, without worktreeId', () => { + const { runtime, session, snapshot } = setup() + delete snapshot.tabGroups + session.tabGroups = { + [WORKTREE]: ['left', 'right'].map((id) => ({ + id, + worktreeId: WORKTREE, + activeTabId: null, + tabOrder: [], + recentTabIds: [] + })) + } + + runtime.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WORKTREE, { + allowAttachedWindow: true + }) + + const result = runtime.mobileSessionTabsByWorktree.get(WORKTREE)! + expect(result.tabGroups!.length).toBeGreaterThan(0) + for (const group of result.tabGroups!) { + expect(Object.keys(group)).not.toContain('worktreeId') + } + }) + + it('drops a browser tab whose page is gone', () => { + const { runtime, snapshot } = setup() + snapshot.tabs.push(browserTab()) + snapshot.tabGroups![0]!.tabOrder.push('browser') + + runtime.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WORKTREE, { + allowAttachedWindow: true + }) + + const result = runtime.mobileSessionTabsByWorktree.get(WORKTREE)! + expect(result.tabs.map((tab) => tab.id)).not.toContain('browser') + expect(result.tabs.some((tab) => tab.id === CHAT)).toBe(true) + expect(result.tabGroups!.flatMap((group) => group.tabOrder)).not.toContain('browser') + }) + + it('reseats the active group when the stale browser emptied it', () => { + const { runtime, snapshot } = setup() + snapshot.tabs.push(browserTab()) + snapshot.tabGroups!.push({ id: 'browser-group', activeTabId: 'browser', tabOrder: ['browser'] }) + snapshot.activeGroupId = 'browser-group' + snapshot.activeTabId = 'browser' + snapshot.activeTabType = 'browser' + + runtime.hydrateHeadlessMobileSessionTabsFromWorkspaceSession(WORKTREE, { + allowAttachedWindow: true + }) + + const result = runtime.mobileSessionTabsByWorktree.get(WORKTREE)! + expect(result.tabGroups!.map((group) => group.id)).not.toContain('browser-group') + expect(result.tabGroups!.some((group) => group.id === result.activeGroupId)).toBe(true) + expect(result.tabs.some((tab) => tab.id === result.activeTabId)).toBe(true) }) })