fix(mobile): closing the tab a phone shows returns it to the tab it viewed before (#31365)

This commit is contained in:
Kelvin Amoaba
2026-10-21 18:28:38 +00:00
committed by GitHub
parent 22154de422
commit 52f6960751
12 changed files with 420 additions and 145 deletions
@@ -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'
@@ -221,6 +221,7 @@ function representableMobileSelection(
): PersistedMobileClientTabSelection | null {
const tabIds = eligible?.tabIds ?? new Set<string>()
const groupIds = eligible?.groupIds ?? new Set<string>()
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 &&
@@ -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<string>
/** 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
@@ -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.
@@ -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<Record<string, string>>
/** 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<string, string> = {}
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 }))
}
}
}
@@ -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({
+19 -119
View File
@@ -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<Record<string, string>>
}
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<string, string> = {}
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<string, Map<string, StoredClientSessionTabSelection>>()
@@ -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,
@@ -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,
@@ -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' },
{
@@ -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')
+2
View File
@@ -45,6 +45,8 @@ export type PersistedMobileClientTabSelection = {
activeTabId: string | null
activeGroupId: string | null
activeTabIdByGroupId: Readonly<Record<string, string>>
/** Top-level tabs the client showed, most recent last. */
recentTabIds?: string[]
}
/** deviceId → worktreeId → selection. */
@@ -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<GroupView[]> {
/**
* 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<string, unknown> & { tabId: string },
expectedOrders: string[][]
expectedOrders: string[][],
shown: string[] = []
): Promise<GroupView[]> {
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(