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:
Brennan Benson 2026-07-22 13:12:38 -07:00 committed by GitHub
parent 0121f571e4
commit 24b8fcc918
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 525 additions and 6 deletions

View File

@ -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' })

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-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

View File

@ -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
}

View File

@ -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: {} }
}
})
})
})

View File

@ -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()
}
}
}

View File

@ -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')
})
})

View File

@ -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 })

View File

@ -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>