fix(mobile): preserve tab selection across worktree rename

This commit is contained in:
Brennan Benson
2026-07-22 12:48:11 -07:00
parent 07cc1486df
commit 58c6cb636d
7 changed files with 136 additions and 58 deletions
+16
View File
@@ -10464,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' })
+6 -1
View File
@@ -78,7 +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 { normalizePersistedMobileClientTabSelections } from './runtime/client-session-tab-selection-persistence'
import { sanitizeWorkspaceSessionTerminalRetirements } from './runtime/mobile-session-terminal-persistence-retirement'
import {
removeRepoFromHostWorkspaceSessions,
@@ -5071,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
}
@@ -5,9 +5,9 @@ import {
activateClientSessionTabSelection,
ClientSessionTabSelectionStore,
deriveClientSessionTabSelection,
normalizePersistedMobileClientTabSelections,
projectClientSessionTabSelection
} from './client-session-tab-selection'
import { normalizePersistedMobileClientTabSelections } from './client-session-tab-selection-persistence'
function snapshot(activeTabId = 'terminal-a::leaf-a'): RuntimeMobileSessionTabsResult {
const tabs = [
@@ -179,6 +179,33 @@ describe('client session-tab selection', () => {
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()
@@ -3,6 +3,7 @@ import type {
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
@@ -127,61 +128,6 @@ export function projectClientSessionTabSelection(
}
}
function normalizeClientSessionTabSelection(raw: unknown): ClientSessionTabSelection | null {
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
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 &&
!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, 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
@@ -299,6 +245,25 @@ export class ClientSessionTabSelectionStore {
}
}
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) {
+1
View File
@@ -23956,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 })
@@ -30,7 +30,10 @@ export type AgentBackgroundSessionTestState = {
{ id: string; repoId: string; projectId: string; path: string; displayName: string }[]
>
tabsByWorktree: Record<string, { id: string; title: string }[]>
terminalLayoutsByTabId: Record<string, { ptyIdsByLeafId?: Record<string, string | undefined> }>
terminalLayoutsByTabId: Record<
string,
{ ptyIdsByLeafId?: Record<string, string | undefined> }
>
ptyIdsByTabId: Record<string, string[]>
sshConnectionStates: Map<string, { status: string }>
transientClearedAgentStatusConnectionIds: Record<string, true>