mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(mobile): closing the tab a phone shows returns it to the tab it viewed before (#31365)
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user