diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts index 6790c12ba35..b5c4e154ad0 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.test.ts @@ -11,6 +11,7 @@ import { import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { collectOrcadMigrationSourceClientState } from './orcad-source-client-state' import { ORCAD_MIGRATION_UI_ROUTING_MERGE } from './orcad-destination-client-state' +import { parseMobileSelections } from '../../../shared/orcad-migration/orcad-migration-client-state-parsing' const source = { sshTargetId: 'target-1', @@ -140,6 +141,47 @@ describe('source client-state migration', () => { }) }) + it("moves a device's tab history with the tabs that move", () => { + const current = state() + current.mobileClientTabSelectionsByDeviceId = { + phone: { + 'ssh:target-1|repo-1::/srv/worktree': { + activeTabId: 'tab-2', + activeGroupId: null, + activeTabIdByGroupId: {}, + recentTabIds: ['tab-1', 'tab-unmoved', 'tab-2'] + } + } + } + const scoped = session({ + tabsByWorktree: { + 'ssh:target-1|repo-1::/srv/worktree': [ + terminalTab('tab-1', 'ssh:target-1|repo-1::/srv/worktree'), + terminalTab('tab-2', 'ssh:target-1|repo-1::/srv/worktree') + ] + } + }) + + const result = collectOrcadMigrationSourceClientState( + current, + source, + catalog, + 'environment-1', + scoped + ) + + const moved = result.payload?.mobileClientTabSelectionsByDeviceId + expect(moved?.phone?.['repo-1::/srv/worktree']?.recentTabIds).toEqual(['tab-1', 'tab-2']) + expect(parseMobileSelections(moved)).toEqual(moved) + expect(() => + parseMobileSelections({ + phone: { + wt: { activeTabId: 'a', activeGroupId: null, activeTabIdByGroupId: {}, recentTabIds: [7] } + } + }) + ).toThrow('orcad_migration_dormant_mobile_selection_tabs_invalid') + }) + it("never sends the desktop's sidebar filters, which an older server refuses when they differ", () => { const current = state() current.ui.workspaceHostScope = 'ssh:target-1' diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts index 92b0dddcbac..f9603316149 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-client-state.ts @@ -221,6 +221,7 @@ function representableMobileSelection( ): PersistedMobileClientTabSelection | null { const tabIds = eligible?.tabIds ?? new Set() const groupIds = eligible?.groupIds ?? new Set() + const recentTabIds = selection.recentTabIds?.filter((tabId) => tabIds.has(tabId)) ?? [] const next: PersistedMobileClientTabSelection = { activeTabId: selection.activeTabId !== null && movedMobileTab(selection.activeTabId, tabIds) @@ -234,7 +235,8 @@ function representableMobileSelection( Object.entries(selection.activeTabIdByGroupId).filter( ([groupId, tabId]) => groupIds.has(groupId) && tabIds.has(tabId) ) - ) + ), + ...(recentTabIds.length > 0 ? { recentTabIds } : {}) } return next.activeTabId === null && next.activeGroupId === null && diff --git a/src/main/runtime/client-session-tab-closed-selection.ts b/src/main/runtime/client-session-tab-closed-selection.ts index d8d100c5698..f6671e2c631 100644 --- a/src/main/runtime/client-session-tab-closed-selection.ts +++ b/src/main/runtime/client-session-tab-closed-selection.ts @@ -2,13 +2,18 @@ import type { RuntimeMobileSessionClientTab, RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' -import type { ClientSessionTabSelection } from './client-session-tab-selection' +import type { + ClientSessionTabSelection, + ClientSessionTabTopology +} from './client-session-tab-selection-projection' export type StoredClientSessionTabSelection = { selection: ClientSessionTabSelection revision: number shouldPersist: boolean closedTabIds: ReadonlySet + /** What this client was shown while its pick was still there; not persisted. */ + topologyBeforeClose?: ClientSessionTabTopology } function topLevelTabId(tab: RuntimeMobileSessionClientTab): string { @@ -61,18 +66,11 @@ export function forgetClosedClientSessionSelection( const closedTabIds = new Set([...existingClosedTabIds, ...forgotten]) const activeTabId = selection.activeTabId && forgotten.has(selection.activeTabId) ? null : selection.activeTabId - const activeTabIdByGroupId = Object.fromEntries( - Object.entries(selection.activeTabIdByGroupId).filter( - ([, selectedTabId]) => !forgotten.has(selectedTabId) - ) - ) + // Why: the group picks stay so the next projection moves them by the close rule. return { - selection: { ...selection, activeTabId, activeTabIdByGroupId }, + selection: { ...selection, activeTabId }, closedTabIds, - selectionChanged: - activeTabId !== selection.activeTabId || - Object.keys(activeTabIdByGroupId).length !== - Object.keys(selection.activeTabIdByGroupId).length, + selectionChanged: activeTabId !== selection.activeTabId, closureChanged: closedTabIds.size !== existingClosedTabIds.size } } @@ -93,9 +91,9 @@ export function forgetClosedClientSessionTabsByWorktree( continue } statesByWorktree.set(worktreeId, { + ...state, selection: closed.selection, revision: state.revision + 1, - shouldPersist: state.shouldPersist, closedTabIds: closed.closedTabIds }) persistedSelectionChanged ||= state.shouldPersist && closed.selectionChanged diff --git a/src/main/runtime/client-session-tab-selection-persistence.ts b/src/main/runtime/client-session-tab-selection-persistence.ts index 7ae391a040d..3b9a4260822 100644 --- a/src/main/runtime/client-session-tab-selection-persistence.ts +++ b/src/main/runtime/client-session-tab-selection-persistence.ts @@ -27,7 +27,15 @@ function normalizeClientSessionTabSelection( if (!activeTabId && !activeGroupId && Object.keys(activeTabIdByGroupId).length === 0) { return null } - return { activeTabId, activeGroupId, activeTabIdByGroupId } + const recentTabIds = Array.isArray(candidate.recentTabIds) + ? candidate.recentTabIds.filter((tabId): tabId is string => typeof tabId === 'string') + : [] + return { + activeTabId, + activeGroupId, + activeTabIdByGroupId, + ...(recentTabIds.length > 0 ? { recentTabIds } : {}) + } } // Why: this state comes off disk (and, for remote runtimes, another machine); a bad payload must degrade to "no selection", not throw. diff --git a/src/main/runtime/client-session-tab-selection-projection.ts b/src/main/runtime/client-session-tab-selection-projection.ts new file mode 100644 index 00000000000..88586196b92 --- /dev/null +++ b/src/main/runtime/client-session-tab-selection-projection.ts @@ -0,0 +1,161 @@ +import type { + RuntimeMobileSessionClientTab, + RuntimeMobileSessionTabsResult +} from '../../shared/runtime-types' +import { pickNextActiveTabAfterClose } from '../../shared/workspace-session-terminal-tab-close' +import { findSiblingGroupId } from '../../shared/workspace-layout/tab-group-layout-tree' + +export type ClientSessionTabSelection = { + activeTabId: string | null + activeGroupId: string | null + activeTabIdByGroupId: Readonly> + /** Top-level tabs this client showed, most recent last; picks its next tab when a shown one closes. */ + recentTabIds?: string[] +} + +/** The groups and split a client was shown; a close reads where its tab and group sat. */ +export type ClientSessionTabTopology = Pick< + RuntimeMobileSessionTabsResult, + 'tabGroups' | 'tabGroupLayout' +> + +export function emptyClientSessionTabSelection(): ClientSessionTabSelection { + return { activeTabId: null, activeGroupId: null, activeTabIdByGroupId: {} } +} + +export function topLevelTabId(tab: RuntimeMobileSessionClientTab): string { + if (tab.type === 'terminal') { + return tab.parentTabId + } + return tab.id +} + +function findTabByTopLevelId( + snapshot: RuntimeMobileSessionTabsResult, + topLevelId: string | null | undefined +): RuntimeMobileSessionClientTab | null { + if (!topLevelId) { + return null + } + return snapshot.tabs.find((tab) => topLevelTabId(tab) === topLevelId) ?? null +} + +export function deriveClientSessionTabSelection( + snapshot: RuntimeMobileSessionTabsResult +): ClientSessionTabSelection { + return { + activeTabId: snapshot.activeTabId, + activeGroupId: snapshot.activeGroupId, + activeTabIdByGroupId: Object.fromEntries( + snapshot.tabGroups?.flatMap((group) => + group.activeTabId ? [[group.id, group.activeTabId] as const] : [] + ) ?? [] + ), + // Why: until the client picks a tab, it has shown what the host showed. + recentTabIds: snapshot.tabGroups?.flatMap((group) => group.recentTabIds ?? []) ?? [] + } +} + +export function activateClientSessionTabSelection( + snapshot: RuntimeMobileSessionTabsResult, + selection: ClientSessionTabSelection, + activeTabId: string +): ClientSessionTabSelection { + const activeTab = snapshot.tabs.find((tab) => tab.id === activeTabId) + if (!activeTab) { + return selection + } + const activeTopLevelTabId = topLevelTabId(activeTab) + const activeGroup = snapshot.tabGroups?.find((group) => + group.tabOrder.includes(activeTopLevelTabId) + ) + const presentTabIds = new Set(snapshot.tabs.map(topLevelTabId)) + return { + activeTabId, + activeGroupId: activeGroup?.id ?? selection.activeGroupId, + activeTabIdByGroupId: activeGroup + ? { ...selection.activeTabIdByGroupId, [activeGroup.id]: activeTopLevelTabId } + : selection.activeTabIdByGroupId, + recentTabIds: [ + ...(selection.recentTabIds ?? []).filter( + (id) => id !== activeTopLevelTabId && presentTabIds.has(id) + ), + activeTopLevelTabId + ] + } +} + +export function projectClientSessionTabSelection( + snapshot: RuntimeMobileSessionTabsResult, + selection: ClientSessionTabSelection, + before?: ClientSessionTabTopology +): { snapshot: RuntimeMobileSessionTabsResult; selection: ClientSessionTabSelection } { + const activeTabIdByGroupId: Record = {} + const tabGroups = snapshot.tabGroups?.map((group) => { + const selected = selection.activeTabIdByGroupId[group.id] + // Why: a pick that left its group moves on by this client's recency, as desktop close and move do. + const activeTabId = + selected && !group.tabOrder.includes(selected) + ? pickNextActiveTabAfterClose( + { + tabOrder: + before?.tabGroups?.find((candidate) => candidate.id === group.id)?.tabOrder ?? + group.tabOrder, + // Why: a selection saved before clients kept recency falls back to the host's, as desktop's. + recentTabIds: selection.recentTabIds ?? group.recentTabIds + }, + new Set([selected]), + group.tabOrder, + new Set(group.tabOrder) + ) + : (selected ?? group.tabOrder[0] ?? null) + if (activeTabId) { + activeTabIdByGroupId[group.id] = activeTabId + } + return { ...group, activeTabId } + }) + const shownGroups = tabGroups?.filter((group) => group.tabOrder.length > 0) + // Why: as the desktop's collapseGroupLayout, an emptied group hands over to its split sibling. + const siblingGroupId = + selection.activeGroupId && before?.tabGroupLayout + ? findSiblingGroupId(before.tabGroupLayout, selection.activeGroupId) + : null + const selectedGroup = + shownGroups?.find((group) => group.id === selection.activeGroupId) ?? + shownGroups?.find((group) => group.id === siblingGroupId) ?? + shownGroups?.[0] + // Why: preserve the client's leaf and group choices before falling back to shared snapshot order. + const activeTab = + snapshot.tabs.find((tab) => tab.id === selection.activeTabId) ?? + findTabByTopLevelId(snapshot, selectedGroup?.activeTabId) ?? + findTabByTopLevelId(snapshot, selectedGroup?.tabOrder[0]) ?? + snapshot.tabs[0] ?? + null + const activeTopLevelTabId = activeTab ? topLevelTabId(activeTab) : null + const activeGroupId = + tabGroups?.find((group) => + activeTopLevelTabId ? group.tabOrder.includes(activeTopLevelTabId) : false + )?.id ?? + (selection.activeGroupId && tabGroups?.some((group) => group.id === selection.activeGroupId) + ? selection.activeGroupId + : null) ?? + tabGroups?.[0]?.id ?? + null + const nextSelection: ClientSessionTabSelection = { + activeTabId: activeTab?.id ?? null, + activeGroupId, + activeTabIdByGroupId, + ...(selection.recentTabIds ? { recentTabIds: selection.recentTabIds } : {}) + } + return { + selection: nextSelection, + snapshot: { + ...snapshot, + activeGroupId, + activeTabId: activeTab?.id ?? null, + activeTabType: activeTab?.type ?? null, + ...(tabGroups ? { tabGroups } : {}), + tabs: snapshot.tabs.map((tab) => ({ ...tab, isActive: tab.id === activeTab?.id })) + } + } +} diff --git a/src/main/runtime/client-session-tab-selection.test.ts b/src/main/runtime/client-session-tab-selection.test.ts index 3b7d6771245..9fdaeff82a1 100644 --- a/src/main/runtime/client-session-tab-selection.test.ts +++ b/src/main/runtime/client-session-tab-selection.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from 'vitest' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' import type { PersistedMobileClientTabSelections } from '../../shared/persisted-state-types' +import { ClientSessionTabSelectionStore } from './client-session-tab-selection' import { activateClientSessionTabSelection, - ClientSessionTabSelectionStore, deriveClientSessionTabSelection, projectClientSessionTabSelection -} from './client-session-tab-selection' +} from './client-session-tab-selection-projection' import { normalizePersistedMobileClientTabSelections } from './client-session-tab-selection-persistence' function snapshot(activeTabId = 'terminal-a::leaf-a'): RuntimeMobileSessionTabsResult { @@ -59,6 +59,63 @@ function snapshot(activeTabId = 'terminal-a::leaf-a'): RuntimeMobileSessionTabsR } } +function browserStrip( + tabOrder: string[], + options: { hostActiveTabId?: string; hostRecentTabIds?: string[] } = {} +): RuntimeMobileSessionTabsResult { + const hostActiveTabId = options.hostActiveTabId ?? tabOrder[0]! + return { + worktree: 'wt-1', + publicationEpoch: 'renderer:1', + snapshotVersion: 1, + activeGroupId: 'group', + activeTabId: hostActiveTabId, + activeTabType: 'browser', + tabGroups: [ + { + id: 'group', + activeTabId: hostActiveTabId, + tabOrder, + ...(options.hostRecentTabIds ? { recentTabIds: options.hostRecentTabIds } : {}) + } + ], + tabs: tabOrder.map((id) => ({ + type: 'browser' as const, + id, + browserWorkspaceId: `workspace-${id}`, + browserPageId: `page-${id}`, + title: id, + url: 'about:blank', + loading: false, + canGoBack: false, + canGoForward: false, + isActive: id === hostActiveTabId + })) + } +} + +function splitBrowserStrips(left: string[], right: string[]): RuntimeMobileSessionTabsResult { + const leftStrip = browserStrip(left) + const rightStrip = right.length > 0 ? browserStrip(right) : null + return { + ...leftStrip, + activeGroupId: 'left', + tabGroups: [ + { ...leftStrip.tabGroups![0]!, id: 'left' }, + ...(rightStrip ? [{ ...rightStrip.tabGroups![0]!, id: 'right' }] : []) + ], + tabGroupLayout: rightStrip + ? { + type: 'split', + direction: 'horizontal', + first: { type: 'leaf', groupId: 'left' }, + second: { type: 'leaf', groupId: 'right' } + } + : { type: 'leaf', groupId: 'left' }, + tabs: [...leftStrip.tabs, ...(rightStrip?.tabs ?? [])] + } +} + describe('client session-tab selection', () => { it('keeps a client selection when a later host snapshot activates another tab', () => { const initial = snapshot() @@ -208,7 +265,8 @@ describe('client session-tab selection', () => { activeTabIdByGroupId: { 'group-left': 'terminal-a', 'group-right': 'browser-unified' - } + }, + recentTabIds: ['browser-unified'] } } } @@ -232,7 +290,8 @@ describe('client session-tab selection', () => { 'wt-1': { activeTabId: 'browser-unified', activeGroupId: 'group-right', - activeTabIdByGroupId: { 'group-right': 'browser-unified' } + activeTabIdByGroupId: { 'group-right': 'browser-unified' }, + recentTabIds: ['browser-unified'] } } } @@ -319,6 +378,78 @@ describe('client session-tab selection', () => { expect(reopened.tabs.some((tab) => tab.id === 'browser-unified')).toBe(true) }) + it('returns a device to the tab it viewed before when its shown tab closes on the desktop', () => { + const store = new ClientSessionTabSelectionStore() + const full = browserStrip(['a', 'b', 'c', 'd'], { hostActiveTabId: 'd' }) + for (const tabId of ['a', 'b', 'c']) { + store.activate(full, 'device-a', tabId) + } + + const closed = browserStrip(['a', 'b', 'd'], { hostActiveTabId: 'd' }) + + expect(store.project(closed, 'device-a').activeTabId).toBe('b') + expect(store.project(closed, 'device-a').activeTabId).toBe('b') + }) + + it('returns a device to the tab it viewed before when it closes its shown tab', () => { + const store = new ClientSessionTabSelectionStore() + const full = browserStrip(['a', 'b', 'c', 'd'], { hostActiveTabId: 'd' }) + for (const tabId of ['a', 'b', 'c']) { + store.activate(full, 'device-a', tabId) + } + + store.forgetTabs('wt-1', ['c']) + + expect(store.project(full, 'device-a').activeTabId).toBe('b') + expect(store.project(browserStrip(['a', 'b', 'd']), 'device-a').activeTabId).toBe('b') + }) + + it("falls back to the closed tab's right neighbour when the device viewed no other tab", () => { + const store = new ClientSessionTabSelectionStore() + const full = browserStrip(['a', 'b', 'c', 'd']) + store.project(full, 'device-a') + store.activate(full, 'device-a', 'c') + + expect(store.project(browserStrip(['a', 'b', 'd']), 'device-a').activeTabId).toBe('d') + }) + + it('keeps the recency across a host restart', () => { + const persisted: PersistedMobileClientTabSelections[] = [] + const store = new ClientSessionTabSelectionStore() + store.setPersistListener((state) => persisted.push(state)) + const full = browserStrip(['a', 'b', 'c', 'd'], { hostActiveTabId: 'd' }) + for (const tabId of ['a', 'b', 'c']) { + store.activate(full, 'device-a', tabId) + } + + const restarted = new ClientSessionTabSelectionStore() + restarted.hydrate(persisted.at(-1)!) + + expect(restarted.project(browserStrip(['a', 'b', 'd']), 'device-a').activeTabId).toBe('b') + }) + + it('moves a device to the split sibling when its shown group empties, as the desktop does', () => { + const store = new ClientSessionTabSelectionStore() + const full = splitBrowserStrips(['a', 'b', 'c'], ['doc']) + store.activate(full, 'device-a', 'c') + store.activate(full, 'device-a', 'doc') + + expect(store.project(splitBrowserStrips(['a', 'b', 'c'], []), 'device-a').activeTabId).toBe('c') + }) + + it("uses the host's recency for a selection saved before devices kept their own", () => { + const store = new ClientSessionTabSelectionStore() + store.hydrate({ + 'device-a': { + 'wt-1': { activeTabId: 'c', activeGroupId: 'group', activeTabIdByGroupId: { group: 'c' } } + } + }) + + const closed = browserStrip(['a', 'b', 'd'], { hostRecentTabIds: ['a', 'd', 'b'] }) + + expect(store.project(closed, 'device-a').activeTabId).toBe('b') + }) + it('drops malformed persisted payloads instead of hydrating them', () => { expect( normalizePersistedMobileClientTabSelections({ diff --git a/src/main/runtime/client-session-tab-selection.ts b/src/main/runtime/client-session-tab-selection.ts index d40dca54a3d..84369144547 100644 --- a/src/main/runtime/client-session-tab-selection.ts +++ b/src/main/runtime/client-session-tab-selection.ts @@ -1,6 +1,5 @@ import { CLIENT_NAVIGATION_PUBLICATION_EPOCH_SUFFIX, - type RuntimeMobileSessionClientTab, type RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' import type { PersistedMobileClientTabSelections } from '../../shared/persisted-state-types' @@ -10,122 +9,14 @@ import { } from './client-session-tab-closed-selection' import type { StoredClientSessionTabSelection } from './client-session-tab-closed-selection' import { normalizePersistedMobileClientTabSelections } from './client-session-tab-selection-persistence' - -export type ClientSessionTabSelection = { - activeTabId: string | null - activeGroupId: string | null - activeTabIdByGroupId: Readonly> -} - -function emptyClientSessionTabSelection(): ClientSessionTabSelection { - return { activeTabId: null, activeGroupId: null, activeTabIdByGroupId: {} } -} - -function topLevelTabId(tab: RuntimeMobileSessionClientTab): string { - if (tab.type === 'terminal') { - return tab.parentTabId - } - return tab.id -} - -function findTabByTopLevelId( - snapshot: RuntimeMobileSessionTabsResult, - topLevelId: string | null | undefined -): RuntimeMobileSessionClientTab | null { - if (!topLevelId) { - return null - } - return snapshot.tabs.find((tab) => topLevelTabId(tab) === topLevelId) ?? null -} - -export function deriveClientSessionTabSelection( - snapshot: RuntimeMobileSessionTabsResult -): ClientSessionTabSelection { - return { - activeTabId: snapshot.activeTabId, - activeGroupId: snapshot.activeGroupId, - activeTabIdByGroupId: Object.fromEntries( - snapshot.tabGroups?.flatMap((group) => - group.activeTabId ? [[group.id, group.activeTabId] as const] : [] - ) ?? [] - ) - } -} - -export function activateClientSessionTabSelection( - snapshot: RuntimeMobileSessionTabsResult, - selection: ClientSessionTabSelection, - activeTabId: string -): ClientSessionTabSelection { - const activeTab = snapshot.tabs.find((tab) => tab.id === activeTabId) - if (!activeTab) { - return selection - } - const activeTopLevelTabId = topLevelTabId(activeTab) - const activeGroup = snapshot.tabGroups?.find((group) => - group.tabOrder.includes(activeTopLevelTabId) - ) - return { - activeTabId, - activeGroupId: activeGroup?.id ?? selection.activeGroupId, - activeTabIdByGroupId: activeGroup - ? { ...selection.activeTabIdByGroupId, [activeGroup.id]: activeTopLevelTabId } - : selection.activeTabIdByGroupId - } -} - -export function projectClientSessionTabSelection( - snapshot: RuntimeMobileSessionTabsResult, - selection: ClientSessionTabSelection -): { snapshot: RuntimeMobileSessionTabsResult; selection: ClientSessionTabSelection } { - const selectedGroup = snapshot.tabGroups?.find((group) => group.id === selection.activeGroupId) - // Why: preserve the client's leaf and group choices before falling back to shared snapshot order. - const activeTab = - snapshot.tabs.find((tab) => tab.id === selection.activeTabId) ?? - findTabByTopLevelId( - snapshot, - selectedGroup ? selection.activeTabIdByGroupId[selectedGroup.id] : null - ) ?? - findTabByTopLevelId(snapshot, selectedGroup?.tabOrder[0]) ?? - snapshot.tabs[0] ?? - null - const activeTopLevelTabId = activeTab ? topLevelTabId(activeTab) : null - const activeTabIdByGroupId: Record = {} - const tabGroups = snapshot.tabGroups?.map((group) => { - const selected = selection.activeTabIdByGroupId[group.id] - const activeTabId = - (selected && group.tabOrder.includes(selected) ? selected : null) ?? group.tabOrder[0] ?? null - if (activeTabId) { - activeTabIdByGroupId[group.id] = activeTabId - } - return { ...group, activeTabId } - }) - const activeGroupId = - tabGroups?.find((group) => - activeTopLevelTabId ? group.tabOrder.includes(activeTopLevelTabId) : false - )?.id ?? - (selection.activeGroupId && tabGroups?.some((group) => group.id === selection.activeGroupId) - ? selection.activeGroupId - : null) ?? - tabGroups?.[0]?.id ?? - null - const nextSelection: ClientSessionTabSelection = { - activeTabId: activeTab?.id ?? null, - activeGroupId, - activeTabIdByGroupId - } - return { - selection: nextSelection, - snapshot: { - ...snapshot, - activeGroupId, - activeTabId: activeTab?.id ?? null, - activeTabType: activeTab?.type ?? null, - ...(tabGroups ? { tabGroups } : {}), - tabs: snapshot.tabs.map((tab) => ({ ...tab, isActive: tab.id === activeTab?.id })) - } - } -} +import { + deriveClientSessionTabSelection, + emptyClientSessionTabSelection, + projectClientSessionTabSelection, + activateClientSessionTabSelection, + topLevelTabId, + type ClientSessionTabSelection +} from './client-session-tab-selection-projection' export class ClientSessionTabSelectionStore { private statesByClient = new Map>() @@ -218,7 +109,11 @@ export class ClientSessionTabSelectionStore { const selection = state.shouldPersist ? state.selection : deriveClientSessionTabSelection(closed.snapshot) - const projected = projectClientSessionTabSelection(closed.snapshot, selection) + const projected = projectClientSessionTabSelection( + closed.snapshot, + selection, + state.topologyBeforeClose + ) // Why: a browser guest process swap drops its tab for one snapshot; the topology fallback // must not overwrite (and persist) the device's explicit pick, or focus never returns. const selectionSurvived = @@ -227,7 +122,11 @@ export class ClientSessionTabSelectionStore { selection: selectionSurvived ? projected.selection : selection, revision: state.revision, shouldPersist: state.shouldPersist, - closedTabIds: closed.retainedClosedTabIds + closedTabIds: closed.retainedClosedTabIds, + // Why: frozen while the pick is gone, so a close's fallback holds across snapshots. + topologyBeforeClose: selectionSurvived + ? { tabGroups: closed.snapshot.tabGroups, tabGroupLayout: closed.snapshot.tabGroupLayout } + : state.topologyBeforeClose }) return { ...projected.snapshot, @@ -256,6 +155,7 @@ export class ClientSessionTabSelectionStore { closedTabIds.delete(topLevelTabId(activeTab)) } statesByWorktree.set(snapshot.worktree, { + ...state, selection: nextSelection, revision: state.revision + 1, shouldPersist: true, diff --git a/src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts b/src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts index 774a2791332..9c4fff48e71 100644 --- a/src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts +++ b/src/main/runtime/session-lifecycle/projection/apply-mobile-session-tab-navigation.ts @@ -12,7 +12,7 @@ import { activateClientSessionTabSelection, deriveClientSessionTabSelection, projectClientSessionTabSelection -} from '../../client-session-tab-selection' +} from '../../client-session-tab-selection-projection' import { makePaneKey } from '../../../../shared/stable-pane-id' import { agentProviderSessionsEqual, diff --git a/src/main/ssh/orcad-migration-path-fields.ts b/src/main/ssh/orcad-migration-path-fields.ts index 46fedfd4301..49e7824f647 100644 --- a/src/main/ssh/orcad-migration-path-fields.ts +++ b/src/main/ssh/orcad-migration-path-fields.ts @@ -170,6 +170,10 @@ export const ORCAD_MIGRATION_PATH_FIELDS: readonly { at: 'dormantState.clientState.mobileClientTabSelectionsByDeviceId{}{}.activeTabIdByGroupId{}', kind: 'editor-file-id' }, + { + at: 'dormantState.clientState.mobileClientTabSelectionsByDeviceId{}{}.recentTabIds[]', + kind: 'editor-file-id' + }, { at: 'dormantState.clientState.uiRouting.lastActiveWorktreeId', kind: 'worktree-id' }, { at: 'dormantState.clientState.uiRouting.showDotfilesByWorktree{key}', kind: 'owner-key' }, { diff --git a/src/shared/orcad-migration/orcad-migration-client-state-parsing.ts b/src/shared/orcad-migration/orcad-migration-client-state-parsing.ts index fd4c52884f5..31b10c3b4fa 100644 --- a/src/shared/orcad-migration/orcad-migration-client-state-parsing.ts +++ b/src/shared/orcad-migration/orcad-migration-client-state-parsing.ts @@ -95,10 +95,25 @@ export function parseMobileSelections(value: unknown): PersistedMobileClientTabS } tabs[groupId] = tabId } + const recentTabIds = rawSelection.recentTabIds + if ( + recentTabIds !== undefined && + !( + Array.isArray(recentTabIds) && + recentTabIds.every((tabId): tabId is string => typeof tabId === 'string' && !!tabId) + ) + ) { + throw new Error('orcad_migration_dormant_mobile_selection_tabs_invalid') + } if (!activeTabId && !activeGroupId && Object.keys(tabs).length === 0) { continue } - entries[worktreeId] = { activeTabId, activeGroupId, activeTabIdByGroupId: tabs } + entries[worktreeId] = { + activeTabId, + activeGroupId, + activeTabIdByGroupId: tabs, + ...(recentTabIds ? { recentTabIds } : {}) + } count += 1 if (count > MAX_ORCAD_MIGRATION_CLIENT_SELECTIONS) { throw new Error('orcad_migration_dormant_mobile_selections_too_many') diff --git a/src/shared/persisted-state-types.ts b/src/shared/persisted-state-types.ts index 18414cc3965..4b425864fbb 100644 --- a/src/shared/persisted-state-types.ts +++ b/src/shared/persisted-state-types.ts @@ -45,6 +45,8 @@ export type PersistedMobileClientTabSelection = { activeTabId: string | null activeGroupId: string | null activeTabIdByGroupId: Readonly> + /** Top-level tabs the client showed, most recent last. */ + recentTabIds?: string[] } /** deviceId → worktreeId → selection. */ diff --git a/tests/e2e/workspace-layout-oracle-headless.spec.ts b/tests/e2e/workspace-layout-oracle-headless.spec.ts index a24ec2b68ab..3bfb21e5498 100644 --- a/tests/e2e/workspace-layout-oracle-headless.spec.ts +++ b/tests/e2e/workspace-layout-oracle-headless.spec.ts @@ -25,6 +25,7 @@ import type { RuntimeTerminalSplit } from '../../src/shared/runtime-terminal-contracts' import { expect, test } from './helpers/orca-app' +import { pickNextActiveTabAfterClose } from '../../src/shared/workspace-session-terminal-tab-close' import { addRepoWorktree, cliClient, @@ -157,13 +158,14 @@ async function groupsOf(run: Run): Promise { /** * Moves a tab through the phone/CLI path, then checks the groups' tab orders and selection: the * moved tab is selected where it lands; any other group keeps its selection while that tab stays, - * else falls back to its first tab. + * else moves on as the desktop's move does, by the tabs client A showed (`shown`, most recent last). */ async function moveTab( run: Run, step: string, move: Record & { tabId: string }, - expectedOrders: string[][] + expectedOrders: string[][], + shown: string[] = [] ): Promise { const before = await groupsOf(run) await attempt(run, step, () => @@ -188,12 +190,20 @@ async function moveTab( : [`groups ${orders}, expected ${JSON.stringify(expectedOrders)}`] ) const selection = after.flatMap((group) => { - const kept = before.find((candidate) => candidate.id === group.id)?.activeTabId + const was = before.find((candidate) => candidate.id === group.id) + const kept = was?.activeTabId const expected = group.tabOrder.includes(move.tabId) ? move.tabId : kept && group.tabOrder.includes(kept) ? kept - : (group.tabOrder[0] ?? null) + : kept && was + ? pickNextActiveTabAfterClose( + { tabOrder: was.tabOrder, recentTabIds: shown }, + new Set([kept]), + group.tabOrder, + new Set(group.tabOrder) + ) + : (group.tabOrder[0] ?? null) return group.activeTabId === expected ? [] : [`group ${group.id} selects ${group.activeTabId}, expected ${expected}`] @@ -360,13 +370,15 @@ const SCENARIOS: Scenario[] = [ run, 'split third tab right', { kind: 'split', tabId: third, targetGroupId: source!.id, splitDirection: 'right' }, - [[first, second], [third]] + [[first, second], [third]], + [first, second, third] ) await moveTab( run, 'move second tab into the new group', { kind: 'move-to-group', tabId: second, targetGroupId: target!.id, index: 0 }, - [[first], [second, third]] + [[first], [second, third]], + [first, second, third] ) await run.check('move to group', [1, 1, 1]) await moveTab(