From b03d23bf58bbcb6f43528e05c0d3e6e8c3981131 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:16:36 +0000 Subject: [PATCH 1/3] fix(mobile): closing a device's shown tab returns it to the tab it viewed before The host kept only each paired device's current tab, so when that tab closed (from the phone, the desktop, the CLI or a process exit) the device fell back to its group's first tab. The per-device selection now keeps the tabs that device showed and moves a closed pick with the desktop's close rule, pickNextActiveTabAfterClose. --- .../client-session-tab-closed-selection.ts | 19 +-- ...lient-session-tab-selection-persistence.ts | 10 +- ...client-session-tab-selection-projection.ts | 142 ++++++++++++++++++ .../client-session-tab-selection.test.ts | 95 +++++++++++- .../runtime/client-session-tab-selection.ts | 140 +++-------------- .../apply-mobile-session-tab-navigation.ts | 2 +- src/shared/persisted-state-types.ts | 2 + 7 files changed, 273 insertions(+), 137 deletions(-) create mode 100644 src/main/runtime/client-session-tab-selection-projection.ts diff --git a/src/main/runtime/client-session-tab-closed-selection.ts b/src/main/runtime/client-session-tab-closed-selection.ts index d8d100c5698b..af6b669410e2 100644 --- a/src/main/runtime/client-session-tab-closed-selection.ts +++ b/src/main/runtime/client-session-tab-closed-selection.ts @@ -2,13 +2,15 @@ import type { RuntimeMobileSessionClientTab, RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' -import type { ClientSessionTabSelection } from './client-session-tab-selection' +import type { ClientSessionTabSelection } from './client-session-tab-selection-projection' export type StoredClientSessionTabSelection = { selection: ClientSessionTabSelection revision: number shouldPersist: boolean closedTabIds: ReadonlySet + /** Group id → its tab order when this client last saw its pick; not persisted. */ + tabOrderByGroupId?: Readonly> } function topLevelTabId(tab: RuntimeMobileSessionClientTab): string { @@ -61,18 +63,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 +88,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 7ae391a040d7..3b9a42608222 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 000000000000..6f5c60a73c46 --- /dev/null +++ b/src/main/runtime/client-session-tab-selection-projection.ts @@ -0,0 +1,142 @@ +import type { + RuntimeMobileSessionClientTab, + RuntimeMobileSessionTabsResult +} from '../../shared/runtime-types' +import { pickNextActiveTabAfterClose } from '../../shared/workspace-session-terminal-tab-close' + +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[] +} + +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, + tabOrderBeforeByGroupId: Readonly> = {} +): { snapshot: RuntimeMobileSessionTabsResult; selection: ClientSessionTabSelection } { + const activeTabIdByGroupId: Record = {} + const tabGroups = snapshot.tabGroups?.map((group) => { + const selected = selection.activeTabIdByGroupId[group.id] + // Why: a closed pick moves as the desktop's close does, by this client's own recency. + const activeTabId = + selected && !group.tabOrder.includes(selected) + ? pickNextActiveTabAfterClose( + { + tabOrder: tabOrderBeforeByGroupId[group.id] ?? group.tabOrder, + recentTabIds: selection.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 selectedGroup = 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?.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 3b7d6771245b..306321deebff 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,41 @@ 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 + })) + } +} + describe('client session-tab selection', () => { it('keeps a client selection when a later host snapshot activates another tab', () => { const initial = snapshot() @@ -208,7 +243,8 @@ describe('client session-tab selection', () => { activeTabIdByGroupId: { 'group-left': 'terminal-a', 'group-right': 'browser-unified' - } + }, + recentTabIds: ['browser-unified'] } } } @@ -232,7 +268,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 +356,56 @@ 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('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 d40dca54a3d2..36ad08f1bace 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.tabOrderByGroupId + ) // 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,13 @@ 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 neighbour fallback holds across snapshots. + tabOrderByGroupId: selectionSurvived + ? Object.fromEntries( + closed.snapshot.tabGroups?.map((group) => [group.id, group.tabOrder]) ?? [] + ) + : state.tabOrderByGroupId }) return { ...projected.snapshot, @@ -256,6 +157,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 774a2791332e..9c4fff48e711 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/shared/persisted-state-types.ts b/src/shared/persisted-state-types.ts index 18414cc39655..4b425864fbb9 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. */ From e759ed9530975d548e106a3059529451f27fe5e0 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:29:17 +0000 Subject: [PATCH 2/3] fix(mobile): an emptied group and an older saved selection follow the desktop's close too When a device's shown group empties, it moves to the group's split sibling, as the desktop's collapseGroupLayout does. A selection saved before devices kept their own recency uses the host group's. The orcad migration keeps a device's recency for the tabs that move. --- .../orcad-source-client-state.test.ts | 42 ++++++++++++++++++ .../orcad-source-client-state.ts | 4 +- .../client-session-tab-closed-selection.ts | 9 ++-- ...client-session-tab-selection-projection.ts | 27 ++++++++++-- .../client-session-tab-selection.test.ts | 44 +++++++++++++++++++ .../runtime/client-session-tab-selection.ts | 12 +++-- src/main/ssh/orcad-migration-path-fields.ts | 4 ++ .../orcad-migration-client-state-parsing.ts | 17 ++++++- 8 files changed, 143 insertions(+), 16 deletions(-) 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 6790c12ba35c..b5c4e154ad0d 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 92b0dddcbac2..f96033161492 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 af6b669410e2..f6671e2c631d 100644 --- a/src/main/runtime/client-session-tab-closed-selection.ts +++ b/src/main/runtime/client-session-tab-closed-selection.ts @@ -2,15 +2,18 @@ import type { RuntimeMobileSessionClientTab, RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' -import type { ClientSessionTabSelection } from './client-session-tab-selection-projection' +import type { + ClientSessionTabSelection, + ClientSessionTabTopology +} from './client-session-tab-selection-projection' export type StoredClientSessionTabSelection = { selection: ClientSessionTabSelection revision: number shouldPersist: boolean closedTabIds: ReadonlySet - /** Group id → its tab order when this client last saw its pick; not persisted. */ - tabOrderByGroupId?: Readonly> + /** What this client was shown while its pick was still there; not persisted. */ + topologyBeforeClose?: ClientSessionTabTopology } function topLevelTabId(tab: RuntimeMobileSessionClientTab): string { diff --git a/src/main/runtime/client-session-tab-selection-projection.ts b/src/main/runtime/client-session-tab-selection-projection.ts index 6f5c60a73c46..7ca95fbebe66 100644 --- a/src/main/runtime/client-session-tab-selection-projection.ts +++ b/src/main/runtime/client-session-tab-selection-projection.ts @@ -3,6 +3,7 @@ import type { 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 @@ -12,6 +13,12 @@ export type ClientSessionTabSelection = { 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: {} } } @@ -81,7 +88,7 @@ export function activateClientSessionTabSelection( export function projectClientSessionTabSelection( snapshot: RuntimeMobileSessionTabsResult, selection: ClientSessionTabSelection, - tabOrderBeforeByGroupId: Readonly> = {} + before?: ClientSessionTabTopology ): { snapshot: RuntimeMobileSessionTabsResult; selection: ClientSessionTabSelection } { const activeTabIdByGroupId: Record = {} const tabGroups = snapshot.tabGroups?.map((group) => { @@ -91,8 +98,11 @@ export function projectClientSessionTabSelection( selected && !group.tabOrder.includes(selected) ? pickNextActiveTabAfterClose( { - tabOrder: tabOrderBeforeByGroupId[group.id] ?? group.tabOrder, - recentTabIds: selection.recentTabIds + 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, @@ -104,7 +114,16 @@ export function projectClientSessionTabSelection( } return { ...group, activeTabId } }) - const selectedGroup = tabGroups?.find((group) => group.id === selection.activeGroupId) + 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) ?? diff --git a/src/main/runtime/client-session-tab-selection.test.ts b/src/main/runtime/client-session-tab-selection.test.ts index 306321deebff..9fdaeff82a10 100644 --- a/src/main/runtime/client-session-tab-selection.test.ts +++ b/src/main/runtime/client-session-tab-selection.test.ts @@ -94,6 +94,28 @@ function browserStrip( } } +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() @@ -406,6 +428,28 @@ describe('client session-tab selection', () => { 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 36ad08f1bace..843691445475 100644 --- a/src/main/runtime/client-session-tab-selection.ts +++ b/src/main/runtime/client-session-tab-selection.ts @@ -112,7 +112,7 @@ export class ClientSessionTabSelectionStore { const projected = projectClientSessionTabSelection( closed.snapshot, selection, - state.tabOrderByGroupId + 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. @@ -123,12 +123,10 @@ export class ClientSessionTabSelectionStore { revision: state.revision, shouldPersist: state.shouldPersist, closedTabIds: closed.retainedClosedTabIds, - // Why: frozen while the pick is gone, so a close's neighbour fallback holds across snapshots. - tabOrderByGroupId: selectionSurvived - ? Object.fromEntries( - closed.snapshot.tabGroups?.map((group) => [group.id, group.tabOrder]) ?? [] - ) - : state.tabOrderByGroupId + // 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, diff --git a/src/main/ssh/orcad-migration-path-fields.ts b/src/main/ssh/orcad-migration-path-fields.ts index 46fedfd43014..49e7824f6472 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 fd4c52884f50..31b10c3b4faf 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') From 95d134ac93854554054c0cc14d874087217ae0e0 Mon Sep 17 00:00:00 2001 From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Date: Wed, 21 Oct 2026 06:19:33 +0000 Subject: [PATCH 3/3] test(e2e): layout oracle expects a moved-away pick to follow the desktop's move rule A paired client's group now moves on by that client's recency when its shown tab leaves, as the desktop's tab move does, instead of falling back to the group's first tab. --- ...client-session-tab-selection-projection.ts | 2 +- .../workspace-layout-oracle-headless.spec.ts | 24 ++++++++++++++----- 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/main/runtime/client-session-tab-selection-projection.ts b/src/main/runtime/client-session-tab-selection-projection.ts index 7ca95fbebe66..88586196b924 100644 --- a/src/main/runtime/client-session-tab-selection-projection.ts +++ b/src/main/runtime/client-session-tab-selection-projection.ts @@ -93,7 +93,7 @@ export function projectClientSessionTabSelection( const activeTabIdByGroupId: Record = {} const tabGroups = snapshot.tabGroups?.map((group) => { const selected = selection.activeTabIdByGroupId[group.id] - // Why: a closed pick moves as the desktop's close does, by this client's own recency. + // 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( diff --git a/tests/e2e/workspace-layout-oracle-headless.spec.ts b/tests/e2e/workspace-layout-oracle-headless.spec.ts index a24ec2b68ab0..3bfb21e5498e 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(