fix(mobile): restore the last-open tab when returning to a worktree on mobile (#9801)
* fix(mobile): persist per-device tab selection so worktree return restores the last open tab A phone's tab selection lived only in the host's in-memory ClientSessionTabSelectionStore. Any host restart wiped it, and the per-device projection then fell back to deterministic topology, so returning to a worktree on mobile always landed on the first tab instead of the tab last opened on the phone. Persist the per-device selections in the Store (keyed deviceId -> worktreeId), hydrate them when the runtime constructs, and guard projection so an early empty snapshot after restart cannot wipe a hydrated selection before tabs arrive. Selections are pruned with the worktree/repo and on device revoke, and malformed persisted payloads degrade to empty instead of throwing. * fix(mobile): harden persisted tab selection cleanup * fix(mobile): preserve tab selection across worktree rename
This commit is contained in:
parent
0121f571e4
commit
24b8fcc918
|
|
@ -9703,6 +9703,110 @@ describe('Store', () => {
|
|||
|
||||
// ── Live Claude PTY session ids (STA-1246) ─────────────────────────
|
||||
|
||||
describe('mobileClientTabSelectionsByDeviceId', () => {
|
||||
it('persists device tab selections across reloads and drops malformed payloads', async () => {
|
||||
const store = await createStore()
|
||||
store.setMobileClientTabSelections({
|
||||
'device-a': {
|
||||
'repo-1::/tmp/wt': { activeTabId: 'tab-1', activeGroupId: 'g1', activeTabIdByGroupId: {} }
|
||||
}
|
||||
})
|
||||
store.flush()
|
||||
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getMobileClientTabSelections()['device-a']?.['repo-1::/tmp/wt']).toEqual({
|
||||
activeTabId: 'tab-1',
|
||||
activeGroupId: 'g1',
|
||||
activeTabIdByGroupId: {}
|
||||
})
|
||||
|
||||
writeDataFile({ mobileClientTabSelectionsByDeviceId: { 'device-a': 'corrupt' } })
|
||||
const corrupted = await createStore()
|
||||
expect(corrupted.getMobileClientTabSelections()).toEqual({})
|
||||
})
|
||||
|
||||
it('prunes selections for a removed repo worktree', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
store.setMobileClientTabSelections({
|
||||
'device-a': {
|
||||
'r1::/tmp/wt': {
|
||||
activeTabId: 'tab-1',
|
||||
activeGroupId: null,
|
||||
activeTabIdByGroupId: {}
|
||||
},
|
||||
'other-repo::/tmp/wt': {
|
||||
activeTabId: 'tab-2',
|
||||
activeGroupId: null,
|
||||
activeTabIdByGroupId: {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
store.removeProject('r1')
|
||||
store.flush()
|
||||
|
||||
expect(store.getMobileClientTabSelections()['device-a']).toEqual({
|
||||
'other-repo::/tmp/wt': {
|
||||
activeTabId: 'tab-2',
|
||||
activeGroupId: null,
|
||||
activeTabIdByGroupId: {}
|
||||
}
|
||||
})
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getMobileClientTabSelections()['device-a']).toEqual({
|
||||
'other-repo::/tmp/wt': {
|
||||
activeTabId: 'tab-2',
|
||||
activeGroupId: null,
|
||||
activeTabIdByGroupId: {}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('prunes selections when a folder workspace is removed directly or with its group', async () => {
|
||||
const store = await createStore()
|
||||
const directGroup = store.createProjectGroup({
|
||||
name: 'Direct',
|
||||
parentPath: '/tmp/direct',
|
||||
createdFrom: 'manual'
|
||||
})
|
||||
const directWorkspace = store.createFolderWorkspace({
|
||||
projectGroupId: directGroup.id,
|
||||
name: 'Direct workspace'
|
||||
})
|
||||
const cascadeGroup = store.createProjectGroup({
|
||||
name: 'Cascade',
|
||||
parentPath: '/tmp/cascade',
|
||||
createdFrom: 'manual'
|
||||
})
|
||||
const cascadeWorkspace = store.createFolderWorkspace({
|
||||
projectGroupId: cascadeGroup.id,
|
||||
name: 'Cascade workspace'
|
||||
})
|
||||
store.setMobileClientTabSelections({
|
||||
'device-a': {
|
||||
[folderWorkspaceKey(directWorkspace.id)]: {
|
||||
activeTabId: 'tab-direct',
|
||||
activeGroupId: null,
|
||||
activeTabIdByGroupId: {}
|
||||
},
|
||||
[folderWorkspaceKey(cascadeWorkspace.id)]: {
|
||||
activeTabId: 'tab-cascade',
|
||||
activeGroupId: null,
|
||||
activeTabIdByGroupId: {}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
store.removeFolderWorkspace(directWorkspace.id)
|
||||
store.deleteProjectGroup(cascadeGroup.id)
|
||||
store.flush()
|
||||
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getMobileClientTabSelections()).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe('claudeLivePtySessionIds', () => {
|
||||
it('persists added ids across reloads and removes them durably', async () => {
|
||||
const store = await createStore()
|
||||
|
|
@ -10360,6 +10464,22 @@ describe('Store.migrateWorktreeIdentity', () => {
|
|||
expect(store.getWorktreeLineage(CHILD)?.parentWorktreeId).toBe(NEW)
|
||||
})
|
||||
|
||||
it('moves persisted mobile selections across reloads', async () => {
|
||||
const store = await createStore()
|
||||
store.setMobileClientTabSelections({
|
||||
'device-a': {
|
||||
[OLD]: { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} }
|
||||
}
|
||||
})
|
||||
|
||||
store.migrateWorktreeIdentity(OLD, NEW)
|
||||
store.flush()
|
||||
|
||||
expect(store.getMobileClientTabSelections()['device-a']?.[OLD]).toBeUndefined()
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getMobileClientTabSelections()['device-a']?.[NEW]?.activeTabId).toBe('tab-1')
|
||||
})
|
||||
|
||||
it('accumulates prior ids across chained renames', async () => {
|
||||
const store = await createStore()
|
||||
store.setWorktreeMeta(OLD, { displayName: 'Cunner' })
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import type {
|
|||
ProjectGroup,
|
||||
FolderWorkspace,
|
||||
SparsePreset,
|
||||
PersistedMobileClientTabSelections,
|
||||
WorktreeMeta,
|
||||
WorktreeLineage,
|
||||
WorkspaceLineage,
|
||||
|
|
@ -77,6 +78,7 @@ import {
|
|||
} from '../shared/task-source-context'
|
||||
import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types'
|
||||
import { MOBILE_PAIRING_USERDATA_FILES } from './runtime/mobile-pairing-files'
|
||||
import { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence'
|
||||
import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement'
|
||||
import {
|
||||
removeRepoFromHostWorkspaceSessions,
|
||||
|
|
@ -3034,6 +3036,9 @@ export class Store {
|
|||
normalizedProjectGroups
|
||||
),
|
||||
worktreeLineageById: parsed.worktreeLineageById ?? {},
|
||||
mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections(
|
||||
parsed.mobileClientTabSelectionsByDeviceId
|
||||
),
|
||||
workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey(
|
||||
parsed.workspaceLineageByChildKey
|
||||
),
|
||||
|
|
@ -3910,8 +3915,10 @@ export class Store {
|
|||
? { ...repo, projectGroupId: null }
|
||||
: repo
|
||||
)
|
||||
const removedFolderWorkspaceKeys = new Set<string>()
|
||||
for (const workspace of this.state.folderWorkspaces ?? []) {
|
||||
if (deletedGroupIds.has(workspace.projectGroupId)) {
|
||||
removedFolderWorkspaceKeys.add(folderWorkspaceKey(workspace.id))
|
||||
this.state.workspaceSession = removeWorkspaceSessionOwner(
|
||||
this.state.workspaceSession,
|
||||
folderWorkspaceKey(workspace.id)
|
||||
|
|
@ -3922,6 +3929,7 @@ export class Store {
|
|||
this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter(
|
||||
(workspace) => !deletedGroupIds.has(workspace.projectGroupId)
|
||||
)
|
||||
this.pruneMobileClientTabSelections((worktreeId) => removedFolderWorkspaceKeys.has(worktreeId))
|
||||
this.scheduleSave()
|
||||
return true
|
||||
}
|
||||
|
|
@ -4071,6 +4079,7 @@ export class Store {
|
|||
folderWorkspaceKey(id)
|
||||
)!
|
||||
this.removeWorkspaceLineageForFolderParent(id)
|
||||
this.pruneMobileClientTabSelections((worktreeId) => worktreeId === folderWorkspaceKey(id))
|
||||
this.scheduleSave()
|
||||
return true
|
||||
}
|
||||
|
|
@ -4249,6 +4258,22 @@ export class Store {
|
|||
delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey]
|
||||
}
|
||||
}
|
||||
this.pruneMobileClientTabSelections(belongsToHost)
|
||||
}
|
||||
|
||||
private pruneMobileClientTabSelections(matchesWorktreeId: (worktreeId: string) => boolean): void {
|
||||
for (const [clientNavigationId, selectionsByWorktree] of Object.entries(
|
||||
this.state.mobileClientTabSelectionsByDeviceId ?? {}
|
||||
)) {
|
||||
for (const worktreeId of Object.keys(selectionsByWorktree)) {
|
||||
if (matchesWorktreeId(worktreeId)) {
|
||||
delete selectionsByWorktree[worktreeId]
|
||||
}
|
||||
}
|
||||
if (Object.keys(selectionsByWorktree).length === 0) {
|
||||
delete this.state.mobileClientTabSelectionsByDeviceId?.[clientNavigationId]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateRepo(
|
||||
|
|
@ -4488,6 +4513,17 @@ export class Store {
|
|||
|
||||
// ── Sparse Presets ─────────────────────────────────────────────────
|
||||
|
||||
// ── Mobile client tab selections ──────────────────────────────────
|
||||
|
||||
getMobileClientTabSelections(): PersistedMobileClientTabSelections {
|
||||
return this.state.mobileClientTabSelectionsByDeviceId ?? {}
|
||||
}
|
||||
|
||||
setMobileClientTabSelections(next: PersistedMobileClientTabSelections): void {
|
||||
this.state.mobileClientTabSelectionsByDeviceId = next
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
getSparsePresets(repoId: string): SparsePreset[] {
|
||||
return [...(this.state.sparsePresetsByRepo[repoId] ?? [])].sort((left, right) =>
|
||||
left.name.localeCompare(right.name)
|
||||
|
|
@ -5035,6 +5071,11 @@ export class Store {
|
|||
for (const session of Object.values(this.state.workspaceSessionsByHostId ?? {})) {
|
||||
changed = migrateSession(session) || changed
|
||||
}
|
||||
for (const selectionsByWorktree of Object.values(
|
||||
this.state.mobileClientTabSelectionsByDeviceId ?? {}
|
||||
)) {
|
||||
changed = moveKey(selectionsByWorktree) || changed
|
||||
}
|
||||
const showDotfiles = this.state.ui?.showDotfilesByWorktree
|
||||
if (showDotfiles) {
|
||||
changed = moveKey(showDotfiles) || changed
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import type {
|
||||
PersistedMobileClientTabSelection,
|
||||
PersistedMobileClientTabSelections
|
||||
} from '../../shared/types'
|
||||
|
||||
function normalizeClientSessionTabSelection(
|
||||
raw: unknown
|
||||
): PersistedMobileClientTabSelection | null {
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||||
return null
|
||||
}
|
||||
const candidate = raw as Partial<PersistedMobileClientTabSelection>
|
||||
const activeTabId = typeof candidate.activeTabId === 'string' ? candidate.activeTabId : null
|
||||
const activeGroupId = typeof candidate.activeGroupId === 'string' ? candidate.activeGroupId : null
|
||||
const activeTabIdByGroupId: Record<string, string> = {}
|
||||
if (
|
||||
typeof candidate.activeTabIdByGroupId === 'object' &&
|
||||
candidate.activeTabIdByGroupId &&
|
||||
!Array.isArray(candidate.activeTabIdByGroupId)
|
||||
) {
|
||||
for (const [groupId, tabId] of Object.entries(candidate.activeTabIdByGroupId)) {
|
||||
if (typeof tabId === 'string') {
|
||||
activeTabIdByGroupId[groupId] = tabId
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!activeTabId && !activeGroupId && Object.keys(activeTabIdByGroupId).length === 0) {
|
||||
return null
|
||||
}
|
||||
return { activeTabId, activeGroupId, activeTabIdByGroupId }
|
||||
}
|
||||
|
||||
// Why: this state comes off disk (and, for remote runtimes, another machine); a bad payload must degrade to "no selection", not throw.
|
||||
export function normalizePersistedMobileClientTabSelections(
|
||||
raw: unknown
|
||||
): PersistedMobileClientTabSelections {
|
||||
const normalized: PersistedMobileClientTabSelections = {}
|
||||
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
||||
return normalized
|
||||
}
|
||||
for (const [clientNavigationId, selectionsByWorktree] of Object.entries(raw)) {
|
||||
if (
|
||||
typeof selectionsByWorktree !== 'object' ||
|
||||
selectionsByWorktree === null ||
|
||||
Array.isArray(selectionsByWorktree)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const entries: Record<string, PersistedMobileClientTabSelection> = {}
|
||||
for (const [worktreeId, selection] of Object.entries(selectionsByWorktree)) {
|
||||
const normalizedSelection = normalizeClientSessionTabSelection(selection)
|
||||
if (normalizedSelection) {
|
||||
entries[worktreeId] = normalizedSelection
|
||||
}
|
||||
}
|
||||
if (Object.keys(entries).length > 0) {
|
||||
normalized[clientNavigationId] = entries
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
|
||||
import type { PersistedMobileClientTabSelections } from '../../shared/types'
|
||||
import {
|
||||
activateClientSessionTabSelection,
|
||||
ClientSessionTabSelectionStore,
|
||||
deriveClientSessionTabSelection,
|
||||
projectClientSessionTabSelection
|
||||
} from './client-session-tab-selection'
|
||||
import { normalizePersistedMobileClientTabSelections } from './client-session-tab-selection-persistence'
|
||||
|
||||
function snapshot(activeTabId = 'terminal-a::leaf-a'): RuntimeMobileSessionTabsResult {
|
||||
const tabs = [
|
||||
|
|
@ -138,4 +140,147 @@ describe('client session-tab selection', () => {
|
|||
expect(projected.activeGroupId).toBe('group-left')
|
||||
expect(projected.tabs.find((tab) => tab.isActive)?.id).toBe('terminal-a::leaf-a')
|
||||
})
|
||||
|
||||
it('persists activations and restores them across a store rebuild (host restart)', () => {
|
||||
const persisted: PersistedMobileClientTabSelections[] = []
|
||||
const store = new ClientSessionTabSelectionStore()
|
||||
store.setPersistListener((state) => persisted.push(state))
|
||||
|
||||
store.activate(snapshot(), 'device-a', 'browser-unified')
|
||||
|
||||
expect(persisted).toHaveLength(1)
|
||||
expect(persisted[0]?.['device-a']?.['wt-1']?.activeTabId).toBe('browser-unified')
|
||||
|
||||
const restarted = new ClientSessionTabSelectionStore()
|
||||
restarted.hydrate(persisted[0]!)
|
||||
const projected = restarted.project(snapshot(), 'device-a')
|
||||
|
||||
expect(projected.activeTabId).toBe('browser-unified')
|
||||
expect(projected.tabs.find((tab) => tab.isActive)?.id).toBe('browser-unified')
|
||||
expect(restarted.project(snapshot(), 'device-b').activeTabId).toBe('terminal-a::leaf-a')
|
||||
})
|
||||
|
||||
it('persists forgetClient and forgetWorktree removals', () => {
|
||||
const persisted: PersistedMobileClientTabSelections[] = []
|
||||
const store = new ClientSessionTabSelectionStore()
|
||||
store.activate(snapshot(), 'device-a', 'browser-unified')
|
||||
store.setPersistListener((state) => persisted.push(state))
|
||||
|
||||
store.forgetWorktree('wt-1')
|
||||
expect(persisted.at(-1)).toEqual({})
|
||||
|
||||
store.activate(snapshot(), 'device-a', 'browser-unified')
|
||||
store.forgetClient('device-a')
|
||||
expect(persisted.at(-1)).toEqual({})
|
||||
// Why: forgetting state that is already gone must not rewrite the persisted file.
|
||||
const writes = persisted.length
|
||||
store.forgetClient('device-a')
|
||||
store.forgetWorktree('wt-1')
|
||||
expect(persisted.length).toBe(writes)
|
||||
})
|
||||
|
||||
it('moves persisted selections when a worktree identity changes', () => {
|
||||
const persisted: PersistedMobileClientTabSelections[] = []
|
||||
const store = new ClientSessionTabSelectionStore()
|
||||
store.activate(snapshot(), 'device-a', 'browser-unified')
|
||||
store.setPersistListener((state) => persisted.push(state))
|
||||
|
||||
store.migrateWorktree('wt-1', 'wt-renamed')
|
||||
|
||||
expect(persisted).toEqual([
|
||||
{
|
||||
'device-a': {
|
||||
'wt-renamed': {
|
||||
activeTabId: 'browser-unified',
|
||||
activeGroupId: 'group-right',
|
||||
activeTabIdByGroupId: {
|
||||
'group-left': 'terminal-a',
|
||||
'group-right': 'browser-unified'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
expect(store.project({ ...snapshot(), worktree: 'wt-renamed' }, 'device-a').activeTabId).toBe(
|
||||
'browser-unified'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not persist topology-only projections from unrelated worktrees', () => {
|
||||
const persisted: PersistedMobileClientTabSelections[] = []
|
||||
const store = new ClientSessionTabSelectionStore()
|
||||
store.setPersistListener((state) => persisted.push(state))
|
||||
|
||||
store.project({ ...snapshot(), worktree: 'listed-only' }, 'device-a')
|
||||
store.activate(snapshot(), 'device-a', 'browser-unified')
|
||||
|
||||
expect(persisted).toEqual([
|
||||
{
|
||||
'device-a': {
|
||||
'wt-1': {
|
||||
activeTabId: 'browser-unified',
|
||||
activeGroupId: 'group-right',
|
||||
activeTabIdByGroupId: { 'group-right': 'browser-unified' }
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
store.forgetWorktree('listed-only')
|
||||
expect(persisted).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not let an empty snapshot wipe a hydrated selection before tabs arrive', () => {
|
||||
const store = new ClientSessionTabSelectionStore()
|
||||
store.hydrate({
|
||||
'device-a': {
|
||||
'wt-1': { activeTabId: 'browser-unified', activeGroupId: null, activeTabIdByGroupId: {} }
|
||||
}
|
||||
})
|
||||
|
||||
const empty = {
|
||||
...snapshot(),
|
||||
activeGroupId: null,
|
||||
activeTabId: null,
|
||||
activeTabType: null,
|
||||
tabGroups: [],
|
||||
tabs: []
|
||||
}
|
||||
expect(store.project(empty, 'device-a').activeTabId).toBeNull()
|
||||
|
||||
expect(store.project(snapshot(), 'device-a').activeTabId).toBe('browser-unified')
|
||||
})
|
||||
|
||||
it('drops malformed persisted payloads instead of hydrating them', () => {
|
||||
expect(
|
||||
normalizePersistedMobileClientTabSelections({
|
||||
'device-a': {
|
||||
'wt-1': { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: { g: 'tab' } },
|
||||
'wt-bad': { activeTabId: 42, activeGroupId: null, activeTabIdByGroupId: { g: 7 } }
|
||||
},
|
||||
'device-bad': 'nope',
|
||||
'device-empty': {}
|
||||
})
|
||||
).toEqual({
|
||||
'device-a': {
|
||||
'wt-1': { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: { g: 'tab' } }
|
||||
}
|
||||
})
|
||||
expect(normalizePersistedMobileClientTabSelections(null)).toEqual({})
|
||||
expect(normalizePersistedMobileClientTabSelections('garbage')).toEqual({})
|
||||
expect(normalizePersistedMobileClientTabSelections([{ 'wt-1': {} }])).toEqual({})
|
||||
expect(
|
||||
normalizePersistedMobileClientTabSelections({
|
||||
'device-array': [{ activeTabId: 'tab-1' }],
|
||||
'device-selection-array': { 'wt-1': ['tab-1'] },
|
||||
'device-group-array': {
|
||||
'wt-1': { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: ['tab-1'] }
|
||||
}
|
||||
})
|
||||
).toEqual({
|
||||
'device-group-array': {
|
||||
'wt-1': { activeTabId: 'tab-1', activeGroupId: null, activeTabIdByGroupId: {} }
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import type {
|
|||
RuntimeMobileSessionClientTab,
|
||||
RuntimeMobileSessionTabsResult
|
||||
} from '../../shared/runtime-types'
|
||||
import type { PersistedMobileClientTabSelections } from '../../shared/types'
|
||||
import { normalizePersistedMobileClientTabSelections } from './client-session-tab-selection-persistence'
|
||||
|
||||
export type ClientSessionTabSelection = {
|
||||
activeTabId: string | null
|
||||
|
|
@ -12,6 +14,8 @@ export type ClientSessionTabSelection = {
|
|||
type StoredClientSessionTabSelection = {
|
||||
selection: ClientSessionTabSelection
|
||||
revision: number
|
||||
// Why: listAll projects every worktree; only hydrated or user-activated selections belong on disk.
|
||||
shouldPersist: boolean
|
||||
}
|
||||
|
||||
function emptyClientSessionTabSelection(): ClientSessionTabSelection {
|
||||
|
|
@ -126,6 +130,43 @@ export function projectClientSessionTabSelection(
|
|||
|
||||
export class ClientSessionTabSelectionStore {
|
||||
private statesByClient = new Map<string, Map<string, StoredClientSessionTabSelection>>()
|
||||
private persistListener: ((state: PersistedMobileClientTabSelections) => void) | null = null
|
||||
|
||||
// Why: selections previously died with the process, so a host restart snapped every phone back to the first tab (deterministic-topology fallback).
|
||||
hydrate(persisted: PersistedMobileClientTabSelections): void {
|
||||
for (const [clientNavigationId, selectionsByWorktree] of Object.entries(
|
||||
normalizePersistedMobileClientTabSelections(persisted)
|
||||
)) {
|
||||
const statesByWorktree = this.getStatesByWorktree(clientNavigationId)
|
||||
for (const [worktreeId, selection] of Object.entries(selectionsByWorktree)) {
|
||||
statesByWorktree.set(worktreeId, { selection, revision: 0, shouldPersist: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setPersistListener(listener: (state: PersistedMobileClientTabSelections) => void): void {
|
||||
this.persistListener = listener
|
||||
}
|
||||
|
||||
serialize(): PersistedMobileClientTabSelections {
|
||||
const persisted: PersistedMobileClientTabSelections = {}
|
||||
for (const [clientNavigationId, statesByWorktree] of this.statesByClient) {
|
||||
const entries: Record<string, ClientSessionTabSelection> = {}
|
||||
for (const [worktreeId, state] of statesByWorktree) {
|
||||
if (state.shouldPersist) {
|
||||
entries[worktreeId] = state.selection
|
||||
}
|
||||
}
|
||||
if (Object.keys(entries).length > 0) {
|
||||
persisted[clientNavigationId] = entries
|
||||
}
|
||||
}
|
||||
return persisted
|
||||
}
|
||||
|
||||
private persistNow(): void {
|
||||
this.persistListener?.(this.serialize())
|
||||
}
|
||||
|
||||
private getStatesByWorktree(
|
||||
clientNavigationId: string
|
||||
|
|
@ -149,12 +190,22 @@ export class ClientSessionTabSelectionStore {
|
|||
const state = statesByWorktree.get(snapshot.worktree) ?? {
|
||||
// Why: host focus is private navigation; a new paired device starts from deterministic topology instead of inheriting it.
|
||||
selection: emptyClientSessionTabSelection(),
|
||||
revision: 0
|
||||
revision: 0,
|
||||
shouldPersist: false
|
||||
}
|
||||
if (snapshot.tabs.length === 0) {
|
||||
// Why: an empty snapshot has no topology to project; writing it back would wipe a restart-hydrated selection before tabs arrive.
|
||||
return {
|
||||
...snapshot,
|
||||
publicationEpoch: `${snapshot.publicationEpoch}:client-navigation`,
|
||||
snapshotVersion: snapshot.snapshotVersion + state.revision
|
||||
}
|
||||
}
|
||||
const projected = projectClientSessionTabSelection(snapshot, state.selection)
|
||||
statesByWorktree.set(snapshot.worktree, {
|
||||
selection: projected.selection,
|
||||
revision: state.revision
|
||||
revision: state.revision,
|
||||
shouldPersist: state.shouldPersist
|
||||
})
|
||||
return {
|
||||
...projected.snapshot,
|
||||
|
|
@ -171,25 +222,60 @@ export class ClientSessionTabSelectionStore {
|
|||
const statesByWorktree = this.getStatesByWorktree(clientNavigationId)
|
||||
const state = statesByWorktree.get(snapshot.worktree) ?? {
|
||||
selection: emptyClientSessionTabSelection(),
|
||||
revision: 0
|
||||
revision: 0,
|
||||
shouldPersist: false
|
||||
}
|
||||
const nextSelection = activateClientSessionTabSelection(snapshot, state.selection, activeTabId)
|
||||
statesByWorktree.set(snapshot.worktree, {
|
||||
selection: activateClientSessionTabSelection(snapshot, state.selection, activeTabId),
|
||||
revision: state.revision + 1
|
||||
selection: nextSelection,
|
||||
revision: state.revision + 1,
|
||||
shouldPersist: true
|
||||
})
|
||||
this.persistNow()
|
||||
return this.project(snapshot, clientNavigationId)
|
||||
}
|
||||
|
||||
forgetClient(clientNavigationId: string): void {
|
||||
this.statesByClient.delete(clientNavigationId)
|
||||
const statesByWorktree = this.statesByClient.get(clientNavigationId)
|
||||
const hadPersistedState = [...(statesByWorktree?.values() ?? [])].some(
|
||||
(state) => state.shouldPersist
|
||||
)
|
||||
if (this.statesByClient.delete(clientNavigationId) && hadPersistedState) {
|
||||
this.persistNow()
|
||||
}
|
||||
}
|
||||
|
||||
migrateWorktree(oldWorktreeId: string, newWorktreeId: string): void {
|
||||
if (oldWorktreeId === newWorktreeId) {
|
||||
return
|
||||
}
|
||||
let changed = false
|
||||
for (const statesByWorktree of this.statesByClient.values()) {
|
||||
const state = statesByWorktree.get(oldWorktreeId)
|
||||
if (!state) {
|
||||
continue
|
||||
}
|
||||
statesByWorktree.set(newWorktreeId, state)
|
||||
statesByWorktree.delete(oldWorktreeId)
|
||||
changed = state.shouldPersist || changed
|
||||
}
|
||||
if (changed) {
|
||||
this.persistNow()
|
||||
}
|
||||
}
|
||||
|
||||
forgetWorktree(worktreeId: string): void {
|
||||
let changed = false
|
||||
for (const [clientNavigationId, statesByWorktree] of this.statesByClient) {
|
||||
const state = statesByWorktree.get(worktreeId)
|
||||
changed = Boolean(state?.shouldPersist) || changed
|
||||
statesByWorktree.delete(worktreeId)
|
||||
if (statesByWorktree.size === 0) {
|
||||
this.statesByClient.delete(clientNavigationId)
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this.persistNow()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|||
import WebSocket from 'ws'
|
||||
import { parsePairingCode } from '../../shared/pairing'
|
||||
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
|
||||
import type { PersistedMobileClientTabSelections } from '../../shared/types'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto'
|
||||
import { OrcaRuntimeRpcServer } from './runtime-rpc'
|
||||
|
|
@ -578,4 +579,43 @@ describe('paired runtime navigation isolation', () => {
|
|||
expect(serverOne.hostSelections.tabId).toBe('host-tab')
|
||||
expect(serverTwo.hostSelections.tabId).toBe('host-tab')
|
||||
})
|
||||
|
||||
it('restores a device tab selection after a runtime restart', async () => {
|
||||
const persisted: { state: PersistedMobileClientTabSelections } = { state: {} }
|
||||
const makeStoreWithSelections = () => ({
|
||||
...makeStore(),
|
||||
getMobileClientTabSelections: () => persisted.state,
|
||||
setMobileClientTabSelections: (next: PersistedMobileClientTabSelections) => {
|
||||
persisted.state = next
|
||||
}
|
||||
})
|
||||
|
||||
const first = new OrcaRuntimeService(makeStoreWithSelections() as never)
|
||||
first.attachWindow(1)
|
||||
first.markGraphReady(1)
|
||||
seedSessionTabs(first)
|
||||
await first.activateMobileSessionTab(`id:${SESSION_WORKTREE_ID}`, 'client-a-tab', undefined, {
|
||||
notifyClients: false,
|
||||
clientNavigationId: 'device-a',
|
||||
navigation: 'caller'
|
||||
})
|
||||
expect(persisted.state['device-a']?.[SESSION_WORKTREE_ID]?.activeTabId).toBe('client-a-tab')
|
||||
|
||||
const restarted = new OrcaRuntimeService(makeStoreWithSelections() as never)
|
||||
restarted.attachWindow(1)
|
||||
restarted.markGraphReady(1)
|
||||
seedSessionTabs(restarted)
|
||||
const remembered = await restarted.listMobileSessionTabs(
|
||||
`id:${SESSION_WORKTREE_ID}`,
|
||||
'device-a'
|
||||
)
|
||||
expect(remembered.activeTabId).toBe('client-a-tab')
|
||||
expect(remembered.tabs.find((tab) => tab.isActive)?.id).toBe('client-a-tab')
|
||||
// Why: an unknown device must still start from deterministic topology, not inherit another device's restored state.
|
||||
const freshDevice = await restarted.listMobileSessionTabs(
|
||||
`id:${SESSION_WORKTREE_ID}`,
|
||||
'device-b'
|
||||
)
|
||||
expect(freshDevice.activeTabId).toBe('host-tab')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -952,6 +952,8 @@ type RuntimeStore = {
|
|||
deleteAutomation?: Store['deleteAutomation']
|
||||
getSparsePresets?: Store['getSparsePresets']
|
||||
saveSparsePreset?: Store['saveSparsePreset']
|
||||
getMobileClientTabSelections?: Store['getMobileClientTabSelections']
|
||||
setMobileClientTabSelections?: Store['setMobileClientTabSelections']
|
||||
getSettings(): {
|
||||
workspaceDir: string
|
||||
nestWorkspaces: boolean
|
||||
|
|
@ -2879,6 +2881,14 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
) {
|
||||
this.store = store
|
||||
// Why: per-device tab selections must survive host restarts, or every phone snaps back to the first tab on return.
|
||||
const persistedClientTabSelections = store?.getMobileClientTabSelections?.()
|
||||
if (persistedClientTabSelections) {
|
||||
this.clientSessionTabSelections.hydrate(persistedClientTabSelections)
|
||||
}
|
||||
this.clientSessionTabSelections.setPersistListener((state) => {
|
||||
this.store?.setMobileClientTabSelections?.(state)
|
||||
})
|
||||
if (stats) {
|
||||
this.stats = stats
|
||||
this.agentDetector = new AgentDetector(stats)
|
||||
|
|
@ -23946,6 +23956,7 @@ export class OrcaRuntimeService {
|
|||
|
||||
/** Like {@link notifyBranchRenamed} but carries old->new worktree id so the renderer re-keys instead of treating the id change as a deletion. */
|
||||
notifyWorktreeFolderRenamed(repoId: string, oldWorktreeId: string, newWorktreeId: string): void {
|
||||
this.clientSessionTabSelections.migrateWorktree(oldWorktreeId, newWorktreeId)
|
||||
this.invalidateResolvedWorktreeCache()
|
||||
this.invalidateWorktreeScanCacheForRepo(repoId)
|
||||
this.notifier?.worktreesChanged(repoId, { oldWorktreeId, newWorktreeId })
|
||||
|
|
|
|||
|
|
@ -3419,6 +3419,19 @@ export type LegacyPaneKeyAliasEntry = {
|
|||
updatedAt: number
|
||||
}
|
||||
|
||||
/** Last tab selection a paired client made in a worktree; restores phone navigation across host restarts. */
|
||||
export type PersistedMobileClientTabSelection = {
|
||||
activeTabId: string | null
|
||||
activeGroupId: string | null
|
||||
activeTabIdByGroupId: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
/** deviceId → worktreeId → selection. */
|
||||
export type PersistedMobileClientTabSelections = Record<
|
||||
string,
|
||||
Record<string, PersistedMobileClientTabSelection>
|
||||
>
|
||||
|
||||
// ─── Persistence shape ──────────────────────────────────────────────
|
||||
export type PersistedState = {
|
||||
schemaVersion: number
|
||||
|
|
@ -3429,6 +3442,8 @@ export type PersistedState = {
|
|||
folderWorkspaces: FolderWorkspace[]
|
||||
/** Sparse-checkout presets keyed by repoId. */
|
||||
sparsePresetsByRepo: Record<string, SparsePreset[]>
|
||||
/** Per paired device last tab selection by worktree; keeps mobile navigation across host restarts. */
|
||||
mobileClientTabSelectionsByDeviceId?: PersistedMobileClientTabSelections
|
||||
worktreeMeta: Record<string, WorktreeMeta>
|
||||
worktreeLineageById: Record<string, WorktreeLineage>
|
||||
workspaceLineageByChildKey: Record<WorkspaceKey, WorkspaceLineage>
|
||||
|
|
|
|||
Loading…
Reference in New Issue