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.
This commit is contained in:
Brennan Benson
2026-07-21 17:17:53 -07:00
parent 71bbfa022d
commit a26fd44d7e
7 changed files with 315 additions and 2 deletions
+52
View File
@@ -9377,6 +9377,58 @@ 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')
expect(store.getMobileClientTabSelections()['device-a']).toEqual({
'other-repo::/tmp/wt': {
activeTabId: 'tab-2',
activeGroupId: null,
activeTabIdByGroupId: {}
}
})
})
})
describe('claudeLivePtySessionIds', () => {
it('persists added ids across reloads and removes them durably', async () => {
const store = await createStore()
+25
View File
@@ -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'
import { hardenExistingSecureFile } from '../shared/secure-file'
import {
LEGACY_DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
@@ -3004,6 +3006,9 @@ export class Store {
normalizedProjectGroups
),
worktreeLineageById: parsed.worktreeLineageById ?? {},
mobileClientTabSelectionsByDeviceId: normalizePersistedMobileClientTabSelections(
parsed.mobileClientTabSelectionsByDeviceId
),
workspaceLineageByChildKey: normalizeWorkspaceLineageByChildKey(
parsed.workspaceLineageByChildKey
),
@@ -4199,6 +4204,15 @@ export class Store {
delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey]
}
}
for (const selectionsByWorktree of Object.values(
this.state.mobileClientTabSelectionsByDeviceId ?? {}
)) {
for (const key of Object.keys(selectionsByWorktree)) {
if (belongsToHost(key)) {
delete selectionsByWorktree[key]
}
}
}
}
updateRepo(
@@ -4438,6 +4452,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)
@@ -1,9 +1,11 @@
import { describe, expect, it } from 'vitest'
import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types'
import type { PersistedMobileClientTabSelections } from '../../shared/types'
import {
activateClientSessionTabSelection,
ClientSessionTabSelectionStore,
deriveClientSessionTabSelection,
normalizePersistedMobileClientTabSelections,
projectClientSessionTabSelection
} from './client-session-tab-selection'
@@ -138,4 +140,75 @@ 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('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(), tabGroups: [], tabs: [] }
expect(store.project(empty, 'device-a').activeTabId).toBe('terminal-a::leaf-a')
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({})
})
})
@@ -2,6 +2,7 @@ import type {
RuntimeMobileSessionClientTab,
RuntimeMobileSessionTabsResult
} from '../../shared/runtime-types'
import type { PersistedMobileClientTabSelections } from '../../shared/types'
export type ClientSessionTabSelection = {
activeTabId: string | null
@@ -124,8 +125,90 @@ export function projectClientSessionTabSelection(
}
}
function normalizeClientSessionTabSelection(raw: unknown): ClientSessionTabSelection | null {
if (typeof raw !== 'object' || raw === null) {
return null
}
const candidate = raw as Partial<ClientSessionTabSelection>
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) {
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) {
return normalized
}
for (const [clientNavigationId, selectionsByWorktree] of Object.entries(raw)) {
if (typeof selectionsByWorktree !== 'object' || selectionsByWorktree === null) {
continue
}
const entries: Record<string, ClientSessionTabSelection> = {}
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
}
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 })
}
}
}
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) {
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
@@ -151,6 +234,14 @@ export class ClientSessionTabSelectionStore {
selection: emptyClientSessionTabSelection(),
revision: 0
}
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,
@@ -177,19 +268,26 @@ export class ClientSessionTabSelectionStore {
selection: activateClientSessionTabSelection(snapshot, state.selection, activeTabId),
revision: state.revision + 1
})
this.persistNow()
return this.project(snapshot, clientNavigationId)
}
forgetClient(clientNavigationId: string): void {
this.statesByClient.delete(clientNavigationId)
if (this.statesByClient.delete(clientNavigationId)) {
this.persistNow()
}
}
forgetWorktree(worktreeId: string): void {
let changed = false
for (const [clientNavigationId, statesByWorktree] of this.statesByClient) {
statesByWorktree.delete(worktreeId)
changed = statesByWorktree.delete(worktreeId) || changed
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')
})
})
+10
View File
@@ -913,6 +913,8 @@ type RuntimeStore = {
deleteAutomation?: Store['deleteAutomation']
getSparsePresets?: Store['getSparsePresets']
saveSparsePreset?: Store['saveSparsePreset']
getMobileClientTabSelections?: Store['getMobileClientTabSelections']
setMobileClientTabSelections?: Store['setMobileClientTabSelections']
getSettings(): {
workspaceDir: string
nestWorkspaces: boolean
@@ -2766,6 +2768,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)
+15
View File
@@ -3403,6 +3403,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
@@ -3413,6 +3426,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>