diff --git a/mobile/src/cache/session-tab-strip-cache.test.ts b/mobile/src/cache/session-tab-strip-cache.test.ts new file mode 100644 index 00000000000..fa1ed188edc --- /dev/null +++ b/mobile/src/cache/session-tab-strip-cache.test.ts @@ -0,0 +1,282 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn() +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) + +import { + deleteCachedSessionTabStripForHost, + getSessionTabStripCacheKey, + loadCachedSessionTabStrip, + readCachedSessionTabStrip, + resetSessionTabStripCacheForTests, + saveCachedSessionTabStrip +} from './session-tab-strip-cache' +import type { MobileSessionTabStripPreview } from '../session/mobile-session-tab-strip-entries' + +const STORAGE_KEY = 'orca:session-tab-strip:v1' + +function preview(...ids: string[]): MobileSessionTabStripPreview { + return { + tabs: ids.map((id) => ({ id, type: 'terminal' as const, title: id, agentId: null })), + activeTabId: ids[0] ?? null + } +} + +function lastWrittenFile(): { workspaces: { key: string }[] } { + const call = asyncStorage.setItem.mock.calls.at(-1) + return JSON.parse(String(call?.[1])) +} + +beforeEach(() => { + vi.useFakeTimers() + asyncStorage.getItem.mockReset().mockResolvedValue(null) + asyncStorage.setItem.mockReset().mockResolvedValue(undefined) + resetSessionTabStripCacheForTests() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('getSessionTabStripCacheKey', () => { + it('digests the workspace id so no filesystem path reaches the key', () => { + const path = '/Users/someone/private-client/worktrees/acquisition' + const key = getSessionTabStripCacheKey('host-1', `repo::${path}`) + + expect(key).not.toContain(path) + expect(key).not.toContain('someone') + expect(key).toMatch(/^\["host-1","[0-9a-f]{32}"\]$/) + }) + + it('joins the two ids unambiguously, whatever a worktree path contains', () => { + expect(getSessionTabStripCacheKey('host', 'a\nb')).not.toBe( + getSessionTabStripCacheKey('host\na', 'b') + ) + expect(getSessionTabStripCacheKey('host-1', 'wt-1')).not.toBe( + getSessionTabStripCacheKey('host-1', 'wt-2') + ) + }) + + it('needs both a host and a workspace', () => { + expect(getSessionTabStripCacheKey(undefined, 'wt-1')).toBeNull() + expect(getSessionTabStripCacheKey('host-1', undefined)).toBeNull() + }) +}) + +describe('session tab strip cache', () => { + it('serves a save back synchronously and persists it once the write settles', async () => { + const key = getSessionTabStripCacheKey('host-1', 'wt-1') + saveCachedSessionTabStrip(key, preview('tab-1', 'tab-2')) + + expect(readCachedSessionTabStrip(key)?.tabs.map((tab) => tab.id)).toEqual(['tab-1', 'tab-2']) + expect(asyncStorage.setItem).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(300) + + expect(asyncStorage.setItem.mock.calls[0]?.[0]).toBe(STORAGE_KEY) + expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([key]) + }) + + it('reads nothing synchronously before the stored file is loaded', async () => { + const key = getSessionTabStripCacheKey('host-1', 'wt-1') + asyncStorage.getItem.mockResolvedValue( + JSON.stringify({ workspaces: [{ key, preview: preview('tab-1') }] }) + ) + + expect(readCachedSessionTabStrip(key)).toBeNull() + expect((await loadCachedSessionTabStrip(key))?.tabs.map((tab) => tab.id)).toEqual(['tab-1']) + expect(readCachedSessionTabStrip(key)?.tabs).toHaveLength(1) + }) + + it('returns null for a workspace with no stored strip', async () => { + expect(await loadCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', 'wt-9'))).toBeNull() + expect(await loadCachedSessionTabStrip(null)).toBeNull() + }) + + it('survives unreadable storage', async () => { + asyncStorage.getItem.mockResolvedValue('{not json') + + expect(await loadCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', 'wt-1'))).toBeNull() + }) + + it('evicts the least recently written workspace past the cap', async () => { + for (let i = 0; i < 14; i++) { + saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', `wt-${i}`), preview('tab-1')) + } + await vi.advanceTimersByTimeAsync(300) + + const keys = lastWrittenFile().workspaces.map((w) => w.key) + expect(keys).toHaveLength(12) + expect(keys).not.toContain(getSessionTabStripCacheKey('host-1', 'wt-0')) + expect(keys.at(-1)).toBe(getSessionTabStripCacheKey('host-1', 'wt-13')) + }) + + it('re-writing a workspace makes it the newest, not the oldest', async () => { + for (let i = 0; i < 12; i++) { + saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', `wt-${i}`), preview('tab-1')) + } + saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', 'wt-0'), preview('tab-2')) + saveCachedSessionTabStrip(getSessionTabStripCacheKey('host-1', 'wt-99'), preview('tab-1')) + await vi.advanceTimersByTimeAsync(300) + + const keys = lastWrittenFile().workspaces.map((w) => w.key) + expect(keys).toContain(getSessionTabStripCacheKey('host-1', 'wt-0')) + expect(keys).not.toContain(getSessionTabStripCacheKey('host-1', 'wt-1')) + }) + + it('records a workspace the host has emptied, so a stale strip cannot outlive it', async () => { + const key = getSessionTabStripCacheKey('host-1', 'wt-1') + saveCachedSessionTabStrip(key, preview('tab-1')) + saveCachedSessionTabStrip(key, { tabs: [], activeTabId: null }) + + expect(readCachedSessionTabStrip(key)).toEqual({ tabs: [], activeTabId: null }) + }) + + it('caps tabs per workspace and title length, and drops an unmatched active id', async () => { + const key = getSessionTabStripCacheKey('host-1', 'wt-1') + saveCachedSessionTabStrip(key, { + // A file tab, because the titles that survive redaction at all are the ones the cap has + // to bound. + tabs: Array.from({ length: 30 }, (_, i) => ({ + id: `tab-${i}`, + type: 'file' as const, + title: 'x'.repeat(200), + agentId: null + })), + activeTabId: 'tab-29' + }) + + const stored = readCachedSessionTabStrip(key) + expect(stored?.tabs).toHaveLength(24) + expect(stored?.tabs[0]?.title).toHaveLength(64) + expect(stored?.activeTabId).toBeNull() + }) + + it('drops fields a future tab type might smuggle into storage', async () => { + const key = getSessionTabStripCacheKey('host-1', 'wt-1') + saveCachedSessionTabStrip(key, { + tabs: [ + { + id: 'tab-1', + type: 'file', + title: 'notes.md', + agentId: null, + filePath: '/Users/someone/secret/notes.md' + } as never + ], + activeTabId: 'tab-1' + }) + await vi.advanceTimersByTimeAsync(300) + + expect(String(asyncStorage.setItem.mock.calls.at(-1)?.[1])).not.toContain('/Users/someone') + }) + + it('drops a stored entry naming a tab type this build cannot draw', async () => { + const key = getSessionTabStripCacheKey('host-1', 'wt-1') + saveCachedSessionTabStrip(key, { + tabs: [ + { id: 'tab-1', type: 'from-a-newer-build', title: 'raw title', agentId: null } as never, + { id: 'tab-2', type: 'file', title: 'notes.md', agentId: null } + ], + activeTabId: 'tab-2' + }) + + expect(readCachedSessionTabStrip(key)?.tabs.map((tab) => tab.id)).toEqual(['tab-2']) + }) + + it('never writes a shell-controlled terminal title, however it arrives', async () => { + const secret = 'psql postgres://admin:hunter2@db.internal/prod' + const key = getSessionTabStripCacheKey('host-1', 'wt-1') + saveCachedSessionTabStrip(key, { + tabs: [ + { id: 'tab-1', type: 'terminal', title: secret, agentId: null }, + { id: 'tab-2', type: 'terminal', title: secret, agentId: 'claude' }, + { id: 'tab-3', type: 'terminal', title: secret, agentId: 'not-a-known-agent' }, + { id: 'tab-4', type: 'browser', title: 'Acme Corp — Q3 layoffs memo', agentId: null } + ], + activeTabId: 'tab-1' + }) + await vi.advanceTimersByTimeAsync(300) + + expect(readCachedSessionTabStrip(key)?.tabs.map((tab) => tab.title)).toEqual([ + 'Terminal', + 'Claude', + 'Terminal', + 'Browser' + ]) + const written = String(asyncStorage.setItem.mock.calls.at(-1)?.[1]) + expect(written).not.toContain('hunter2') + expect(written).not.toContain('postgres://') + expect(written).not.toContain('layoffs') + }) + + it('scrubs a stored title written by an older build on the way back out', async () => { + const key = getSessionTabStripCacheKey('host-1', 'wt-1') + asyncStorage.getItem.mockResolvedValue( + JSON.stringify({ + workspaces: [ + { + key, + preview: { + tabs: [{ id: 'tab-1', type: 'terminal', title: 'curl -H token', agentId: null }], + activeTabId: 'tab-1' + } + } + ] + }) + ) + + expect((await loadCachedSessionTabStrip(key))?.tabs[0]?.title).toBe('Terminal') + }) + + it('forgets an unpaired host and cannot resurrect it from a later save', async () => { + const hostA = getSessionTabStripCacheKey('host-a', 'wt-1') + const hostB = getSessionTabStripCacheKey('host-b', 'wt-1') + saveCachedSessionTabStrip(hostA, preview('tab-a')) + saveCachedSessionTabStrip(hostB, preview('tab-b')) + await vi.advanceTimersByTimeAsync(300) + + await deleteCachedSessionTabStripForHost('host-a') + + expect(readCachedSessionTabStrip(hostA)).toBeNull() + expect(readCachedSessionTabStrip(hostB)?.tabs).toHaveLength(1) + expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([hostB]) + + saveCachedSessionTabStrip(hostB, preview('tab-b2')) + await vi.advanceTimersByTimeAsync(300) + + expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([hostB]) + }) + + it('forgets a host whose rows are only on disk, never read this session', async () => { + const hostA = getSessionTabStripCacheKey('host-a', 'wt-1') + const hostB = getSessionTabStripCacheKey('host-b', 'wt-1') + asyncStorage.getItem.mockResolvedValue( + JSON.stringify({ + workspaces: [ + { key: hostA, preview: preview('tab-a') }, + { key: hostB, preview: preview('tab-b') } + ] + }) + ) + + await deleteCachedSessionTabStripForHost('host-a') + + expect(lastWrittenFile().workspaces.map((w) => w.key)).toEqual([hostB]) + }) + + it('drops a pending debounced write so it cannot restore the forgotten host', async () => { + const hostA = getSessionTabStripCacheKey('host-a', 'wt-1') + saveCachedSessionTabStrip(hostA, preview('tab-a')) + + await deleteCachedSessionTabStripForHost('host-a') + await vi.advanceTimersByTimeAsync(300) + + expect(lastWrittenFile().workspaces).toEqual([]) + }) +}) diff --git a/mobile/src/cache/session-tab-strip-cache.ts b/mobile/src/cache/session-tab-strip-cache.ts new file mode 100644 index 00000000000..222e2c3fd27 --- /dev/null +++ b/mobile/src/cache/session-tab-strip-cache.ts @@ -0,0 +1,228 @@ +// Why: reconnecting to a workspace the phone opened a minute ago tears the session screen back +// to an empty strip and a spinner, even though the tab list it is about to be handed is the one +// it just displayed. Persist the shape of the strip per workspace so a reconnect paints the +// known tabs immediately and swaps in live rows under the same keys. +// +// This file is the authority on what reaches plaintext storage, not its callers: every entry is +// rebuilt field by field on the way in, and shell-controlled titles are replaced with fixed +// labels here rather than trusted to have been scrubbed upstream. +import AsyncStorage from '@react-native-async-storage/async-storage' +import { sha256 } from '@noble/hashes/sha256' +import { + getPersistableTabStripTitle, + isDrawableTabStripType, + type MobileSessionTabStripEntry, + type MobileSessionTabStripPreview +} from '../session/mobile-session-tab-strip-entries' + +const STORAGE_KEY = 'orca:session-tab-strip:v1' +// A phone realistically revisits a handful of workspaces; the caps bound both the stored blob +// and the cost of a single write. +const MAX_WORKSPACES = 12 +const MAX_TABS_PER_WORKSPACE = 24 +const MAX_TITLE_LENGTH = 64 +const WRITE_DEBOUNCE_MS = 250 +// 128 bits of a digest: far past collision range for a dozen workspaces, and short enough that +// the stored blob stays small. +const WORKSPACE_DIGEST_LENGTH = 32 + +type StoredWorkspace = { key: string; preview: MobileSessionTabStripPreview } +type StoredFile = { workspaces: StoredWorkspace[] } + +// Insertion-ordered, so the first key is the least recently written one to evict. +let memoryCache: Map | null = null +let loadPromise: Promise> | null = null +let writeTimer: ReturnType | null = null + +/** + * A workspace id ends in a filesystem path, so it is digested rather than stored. The host id + * stays readable because forgetting a host has to be able to find that host's rows, and because + * host ids already key several other entries in this store. + */ +export function getSessionTabStripCacheKey( + hostId: string | undefined, + worktreeId: string | undefined +): string | null { + if (!hostId || !worktreeId) { + return null + } + return JSON.stringify([hostId, digestWorkspaceId(worktreeId)]) +} + +/** Whatever this process already knows, with no await — so a revisit paints on the first frame. */ +export function readCachedSessionTabStrip(key: string | null): MobileSessionTabStripPreview | null { + if (!key || !memoryCache) { + return null + } + return memoryCache.get(key) ?? null +} + +export async function loadCachedSessionTabStrip( + key: string | null +): Promise { + if (!key) { + return null + } + const cache = await loadFile() + return cache.get(key) ?? null +} + +export function saveCachedSessionTabStrip( + key: string | null, + preview: MobileSessionTabStripPreview +): void { + if (!key) { + return + } + const redacted = redactPreview(preview) + const cache = memoryCache ?? new Map() + memoryCache = cache + // Map.set on an existing key keeps its original iteration position, so delete first to make + // the re-inserted key the newest and give the cap true LRU eviction. + cache.delete(key) + cache.set(key, redacted) + while (cache.size > MAX_WORKSPACES) { + const oldest = cache.keys().next().value + if (oldest === undefined) { + break + } + cache.delete(oldest) + } + scheduleWrite(cache) +} + +/** + * Drop every workspace belonging to a host the user has unpaired. Both the in-memory rows and + * the stored blob have to go: leaving either behind means the next save for any other host + * serializes the forgotten host's tabs straight back to disk. + */ +export async function deleteCachedSessionTabStripForHost(hostId: string): Promise { + // Load first so the rewrite below preserves other hosts. If storage is unreadable we still + // rewrite, which can cost another host its rows — the wrong direction for a cache, the right + // one for a deletion the user asked for. + const cache = await loadFile() + // Deleting the entry the iterator is standing on is well-defined for a Map. + for (const key of cache.keys()) { + if (readHostIdFromKey(key) === hostId) { + cache.delete(key) + } + } + if (writeTimer) { + clearTimeout(writeTimer) + writeTimer = null + } + await writeFile(cache) +} + +export function resetSessionTabStripCacheForTests(): void { + if (writeTimer) { + clearTimeout(writeTimer) + writeTimer = null + } + memoryCache = null + loadPromise = null +} + +function digestWorkspaceId(worktreeId: string): string { + const digest = sha256(new TextEncoder().encode(worktreeId)) + let hex = '' + for (const byte of digest) { + hex += byte.toString(16).padStart(2, '0') + } + return hex.slice(0, WORKSPACE_DIGEST_LENGTH) +} + +function readHostIdFromKey(key: string): string | null { + try { + const parsed = JSON.parse(key) as unknown + return Array.isArray(parsed) && typeof parsed[0] === 'string' ? parsed[0] : null + } catch { + return null + } +} + +async function loadFile(): Promise> { + if (memoryCache) { + return memoryCache + } + loadPromise ??= (async () => { + const parsed = await readStoredFile() + // A save that landed while the read was in flight owns the newer truth. + const cache = memoryCache ?? new Map() + for (const workspace of parsed) { + if (!cache.has(workspace.key)) { + cache.set(workspace.key, workspace.preview) + } + } + memoryCache = cache + return cache + })() + return loadPromise +} + +async function readStoredFile(): Promise { + try { + const raw = await AsyncStorage.getItem(STORAGE_KEY) + if (!raw) { + return [] + } + const parsed = JSON.parse(raw) as StoredFile + if (typeof parsed !== 'object' || parsed === null || !Array.isArray(parsed.workspaces)) { + return [] + } + return parsed.workspaces.flatMap((workspace) => { + if (typeof workspace?.key !== 'string' || !Array.isArray(workspace.preview?.tabs)) { + return [] + } + return [{ key: workspace.key, preview: redactPreview(workspace.preview) }] + }) + } catch { + return [] + } +} + +// Why: a flurry of snapshots (one per desktop republication) must not hammer AsyncStorage. +function scheduleWrite(cache: Map): void { + if (writeTimer) { + clearTimeout(writeTimer) + } + writeTimer = setTimeout(() => { + writeTimer = null + void writeFile(cache) + }, WRITE_DEBOUNCE_MS) +} + +async function writeFile(cache: Map): Promise { + const workspaces: StoredWorkspace[] = [...cache].map(([key, preview]) => ({ key, preview })) + await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ workspaces })).catch(() => {}) +} + +// Rebuilt field by field so a field later added to the live tab type cannot ride into storage +// without someone deciding it belongs there. +function redactPreview(preview: MobileSessionTabStripPreview): MobileSessionTabStripPreview { + const tabs: MobileSessionTabStripEntry[] = [] + for (const tab of preview.tabs ?? []) { + if (typeof tab?.id !== 'string' || !isDrawableTabStripType(tab.type)) { + continue + } + const agentId = typeof tab.agentId === 'string' ? tab.agentId : null + const title = typeof tab.title === 'string' ? tab.title : '' + tabs.push({ + id: tab.id, + type: tab.type, + title: getPersistableTabStripTitle({ type: tab.type, title, agentId }).slice( + 0, + MAX_TITLE_LENGTH + ), + agentId + }) + if (tabs.length === MAX_TABS_PER_WORKSPACE) { + break + } + } + const activeTabId = + typeof preview.activeTabId === 'string' && tabs.some((tab) => tab.id === preview.activeTabId) + ? preview.activeTabId + : null + return { tabs, activeTabId } +} diff --git a/mobile/src/session/MobileSessionActiveContent.tsx b/mobile/src/session/MobileSessionActiveContent.tsx index 019e83c6a99..00c852dbf01 100644 --- a/mobile/src/session/MobileSessionActiveContent.tsx +++ b/mobile/src/session/MobileSessionActiveContent.tsx @@ -74,6 +74,7 @@ export function MobileSessionActiveContent({ activePendingTerminalTab, isPendingTerminalRecoveryParked, retryPendingTerminalRecovery, + reconnectViewState, showLoadingState, showEmptyState, keyboardLift, @@ -81,7 +82,15 @@ export function MobileSessionActiveContent({ toastAnimatedStyle, createTabBusy } = controller - return showLoadingState ? ( + // Why: the cached strip in the header is the content during a reconnect; the terminal body + // cannot be, because replaying stored scrollback into the WebView would double-render once the + // live stream replays the same rows. See mobile-session-reconnect-view-state. + return reconnectViewState.kind === 'reconnecting-with-cache' ? ( + + + {reconnectViewState.label} + + ) : showLoadingState ? ( diff --git a/mobile/src/session/MobileSessionHeader.tsx b/mobile/src/session/MobileSessionHeader.tsx index 552f507a787..a23c216c729 100644 --- a/mobile/src/session/MobileSessionHeader.tsx +++ b/mobile/src/session/MobileSessionHeader.tsx @@ -14,10 +14,6 @@ import { MobileSessionHeaderIconButton } from './MobileSessionHeaderIconButton' import { triggerMediumImpact } from '../platform/haptics' import { StatusDot } from '../components/StatusDot' import { MobileAgentIcon } from '../components/MobileAgentIcon' -import { - getMobileSessionTabTitle, - resolveMobileTerminalTabAgentId -} from './mobile-terminal-tab-agent' import { colors } from '../theme/mobile-theme' import { QuickCommandsTabButton } from './QuickCommandsTabButton' import { styles } from './mobile-session-styles' @@ -32,7 +28,6 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC forceReconnectHost, worktreeName, activePanel, - activeSessionTabId, activeSessionTabIdRef, tabStripRef, tabStripOffsetRef, @@ -52,7 +47,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC scrollActiveTabIntoView, switchSessionTab, openSessionTabActionSheetAfterKeyboardDismiss, - visibleTabs, + tabStripRows, showConnectionRetry, terminalSummary, handlePanelTap, @@ -117,7 +112,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC ) : null} - {visibleTabs.length > 0 && ( + {tabStripRows.length > 0 && ( {/* Why: tab taps must register on first press with the keyboard open instead of being eaten by dismissal (#5106). */} - {visibleTabs.map((t) => ( + {tabStripRows.map(({ entry, isActive, tab }) => ( { const { x, width } = e.nativeEvent.layout - tabLayoutsRef.current.set(t.id, { x, width }) - if (t.id === activeSessionTabIdRef.current) { - scrollActiveTabIntoView(t.id, false) + tabLayoutsRef.current.set(entry.id, { x, width }) + if (entry.id === activeSessionTabIdRef.current) { + scrollActiveTabIntoView(entry.id, false) } }} - onPress={() => switchSessionTab(t)} - onLongPress={() => { - triggerMediumImpact() - openSessionTabActionSheetAfterKeyboardDismiss(t) - }} + // A cached preview row has no live tab behind it, so both gestures need the + // reconnect to land first. + disabled={tab === null} + onPress={tab === null ? undefined : () => switchSessionTab(tab)} + onLongPress={ + tab === null + ? undefined + : () => { + triggerMediumImpact() + openSessionTabActionSheetAfterKeyboardDismiss(tab) + } + } delayLongPress={400} > - {t.type === 'browser' && ( + {entry.type === 'browser' && ( )} - {t.type === 'markdown' && ( + {entry.type === 'markdown' && ( )} - {t.type === 'file' && ( + {entry.type === 'file' && ( )} - {t.type === 'agent-session' && } - {t.type === 'terminal' && - (() => { - const agentId = resolveMobileTerminalTabAgentId(t) - return agentId ? : null - })()} + {entry.agentId !== null && } - {getMobileSessionTabTitle(t)} + {entry.title} diff --git a/mobile/src/session/mobile-session-frame-styles.ts b/mobile/src/session/mobile-session-frame-styles.ts index a02c14be014..22d3c6e76cc 100644 --- a/mobile/src/session/mobile-session-frame-styles.ts +++ b/mobile/src/session/mobile-session-frame-styles.ts @@ -102,6 +102,11 @@ export const mobileSessionFrameStyles = StyleSheet.create({ borderBottomWidth: 2, borderBottomColor: 'transparent' }, + // Why: a cached row is inert until the reconnect lands, so it carries the same de-emphasis as + // the disabled tab-bar buttons beside it rather than passing for a live tab. + tabPreview: { + opacity: 0.45 + }, tabActive: { // Neutral grey underline, matching the desktop terminal tab's active // indicator (a muted foreground/card mix), not a blue accent. diff --git a/mobile/src/session/mobile-session-reconnect-view-state.test.ts b/mobile/src/session/mobile-session-reconnect-view-state.test.ts new file mode 100644 index 00000000000..09f9bbb8447 --- /dev/null +++ b/mobile/src/session/mobile-session-reconnect-view-state.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest' +import { selectMobileSessionReconnectViewState } from './mobile-session-reconnect-view-state' +import { + getMobileSessionTabStripRows, + toMobileSessionTabStripPreview, + type MobileSessionTabStripPreview +} from './mobile-session-tab-strip-entries' +import type { MobileSessionTab } from './mobile-session-route-types' + +function terminalTab(id: string, title: string, isActive = false): MobileSessionTab { + return { type: 'terminal', id, title, terminal: `h-${id}`, isActive } +} + +const cachedPreview: MobileSessionTabStripPreview = { + tabs: [ + { id: 'tab-1', type: 'terminal', title: 'claude', agentId: 'claude' }, + { id: 'tab-2', type: 'terminal', title: 'shell', agentId: null } + ], + activeTabId: 'tab-1' +} + +const base = { + connState: 'reconnecting', + verdictKind: 'normal', + terminalsLoaded: false, + liveTabCount: 0, + activeHandle: null, + cachedPreview: null +} as const + +describe('selectMobileSessionReconnectViewState', () => { + it('renders the cached strip with a progress label while reconnecting', () => { + const state = selectMobileSessionReconnectViewState({ ...base, cachedPreview }) + + expect(state).toEqual({ + kind: 'reconnecting-with-cache', + preview: cachedPreview, + label: 'Reconnecting…' + }) + }) + + it('labels the post-connect hydration gap as loading, not reconnecting', () => { + const state = selectMobileSessionReconnectViewState({ + ...base, + connState: 'connected', + cachedPreview + }) + + expect(state.kind === 'reconnecting-with-cache' && state.label).toBe('Loading tabs…') + }) + + it('blocks when nothing is cached for this workspace', () => { + expect(selectMobileSessionReconnectViewState(base)).toEqual({ kind: 'blocking' }) + expect( + selectMobileSessionReconnectViewState({ + ...base, + cachedPreview: { tabs: [], activeTabId: null } + }) + ).toEqual({ kind: 'blocking' }) + }) + + it('keeps mounted live content instead of swapping in its own cached snapshot', () => { + expect( + selectMobileSessionReconnectViewState({ ...base, liveTabCount: 2, cachedPreview }) + ).toEqual({ kind: 'live' }) + expect( + selectMobileSessionReconnectViewState({ ...base, activeHandle: 'h-1', cachedPreview }) + ).toEqual({ kind: 'live' }) + }) + + it('treats a host-confirmed empty workspace as live', () => { + expect( + selectMobileSessionReconnectViewState({ + ...base, + connState: 'connected', + terminalsLoaded: true, + cachedPreview + }) + ).toEqual({ kind: 'live' }) + }) + + it('falls back to the offline state once the retry loop or the pairing has failed', () => { + expect( + selectMobileSessionReconnectViewState({ ...base, verdictKind: 'unreachable', cachedPreview }) + ).toEqual({ kind: 'offline' }) + expect( + selectMobileSessionReconnectViewState({ ...base, verdictKind: 'auth-failed', cachedPreview }) + ).toEqual({ kind: 'offline' }) + }) + + it('keeps showing the cache through a transient warning verdict', () => { + expect( + selectMobileSessionReconnectViewState({ ...base, verdictKind: 'warning', cachedPreview }).kind + ).toBe('reconnecting-with-cache') + }) +}) + +describe('getMobileSessionTabStripRows', () => { + it('draws disabled preview rows while reconnecting, then the live tabs under the same keys', () => { + const preview = selectMobileSessionReconnectViewState({ ...base, cachedPreview }) + const previewRows = getMobileSessionTabStripRows({ + liveTabs: [], + activeSessionTabId: null, + preview: preview.kind === 'reconnecting-with-cache' ? preview.preview : null + }) + + expect(previewRows.map((row) => row.entry.id)).toEqual(['tab-1', 'tab-2']) + expect(previewRows.map((row) => row.tab)).toEqual([null, null]) + expect(previewRows.map((row) => row.isActive)).toEqual([true, false]) + + const liveTabs = [terminalTab('tab-1', 'claude', true), terminalTab('tab-2', 'shell')] + const liveRows = getMobileSessionTabStripRows({ + liveTabs, + activeSessionTabId: 'tab-1', + preview: null + }) + + expect(liveRows.map((row) => row.entry.id)).toEqual(previewRows.map((row) => row.entry.id)) + expect(liveRows.map((row) => row.isActive)).toEqual(previewRows.map((row) => row.isActive)) + expect(liveRows.every((row) => row.tab !== null)).toBe(true) + }) + + it('prefers live tabs over a preview that is still present', () => { + const rows = getMobileSessionTabStripRows({ + liveTabs: [terminalTab('tab-9', 'fresh', true)], + activeSessionTabId: 'tab-9', + preview: cachedPreview + }) + + expect(rows.map((row) => row.entry.id)).toEqual(['tab-9']) + }) + + it('keeps only the drawn fields when projecting a preview to persist', () => { + const preview = toMobileSessionTabStripPreview( + [ + { + type: 'terminal', + id: 'tab-1', + title: 'claude', + terminal: 'h-1', + launchAgent: 'claude', + launchDraft: 'unsent secret prompt', + isActive: true + } + ], + 'tab-1' + ) + + expect(preview).toEqual({ + tabs: [{ id: 'tab-1', type: 'terminal', title: 'claude', agentId: 'claude' }], + activeTabId: 'tab-1' + }) + expect(JSON.stringify(preview)).not.toContain('unsent secret prompt') + }) +}) diff --git a/mobile/src/session/mobile-session-reconnect-view-state.ts b/mobile/src/session/mobile-session-reconnect-view-state.ts new file mode 100644 index 00000000000..fe980676408 --- /dev/null +++ b/mobile/src/session/mobile-session-reconnect-view-state.ts @@ -0,0 +1,61 @@ +import type { ConnectionVerdict } from '../transport/connection-health' +import type { ConnectionState } from '../transport/types' +import type { MobileSessionTabStripPreview } from './mobile-session-tab-strip-entries' + +/** + * What the session screen should draw while the phone is not yet serving live tabs. + * + * - `live`: real tabs are mounted (or the host has confirmed there are none). The existing + * loading/empty/content branches own the screen. + * - `reconnecting-with-cache`: nothing live yet, but this workspace's last strip is on the + * device. Draw it, disabled, with a compact progress line instead of a bare spinner. + * - `offline`: the retry loop has given up or the pairing is rejected. A stale strip would + * imply a session we cannot reach, so fall back to the existing offline affordance. + * - `blocking`: nothing live and nothing cached. Unchanged from before this state existed. + */ +export type MobileSessionReconnectViewState = + | { kind: 'live' } + | { kind: 'reconnecting-with-cache'; preview: MobileSessionTabStripPreview; label: string } + | { kind: 'offline' } + | { kind: 'blocking' } + +export function selectMobileSessionReconnectViewState(args: { + connState: ConnectionState + verdictKind: ConnectionVerdict['kind'] + terminalsLoaded: boolean + liveTabCount: number + activeHandle: string | null + cachedPreview: MobileSessionTabStripPreview | null +}): MobileSessionReconnectViewState { + const { connState, verdictKind, terminalsLoaded, liveTabCount, activeHandle, cachedPreview } = + args + // A mounted terminal or tab is the real thing; a mid-session drop must never trade it for a + // snapshot of itself, however the connection is faring. + if (liveTabCount > 0 || activeHandle !== null) { + return { kind: 'live' } + } + // The host has answered and said this workspace is empty — that is live truth, not a gap. + if (connState === 'connected' && terminalsLoaded) { + return { kind: 'live' } + } + if (verdictKind === 'unreachable' || verdictKind === 'auth-failed') { + return { kind: 'offline' } + } + if (cachedPreview && cachedPreview.tabs.length > 0) { + return { + kind: 'reconnecting-with-cache', + preview: cachedPreview, + label: reconnectProgressLabel(connState) + } + } + return { kind: 'blocking' } +} + +function reconnectProgressLabel(connState: ConnectionState): string { + if (connState === 'connected') { + return 'Loading tabs…' + } + return connState === 'reconnecting' || connState === 'disconnected' + ? 'Reconnecting…' + : 'Connecting…' +} diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts index bc951bfa206..1455765771f 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -37,6 +37,7 @@ const LOGIC_EXPANSION_NAMES = new Set([ 'useMobileSessionContentCreateActions', 'useMobileSessionCloseActions', 'useMobileSessionBulkClose', + 'useMobileSessionTabStripCache', 'useMobileSessionPresentation', 'useMobileSessionPanelRouteActions' ]) @@ -62,12 +63,12 @@ const HOST_COMPONENT_NAMES = new Set([ 'View' ]) -const HEAD_MAIN_HOOK_SHA256 = '10071240ef9edafc2b9c8bed73be83dceaf7828e3b29f17dab55da020a7697a6' -const HEAD_HOOK_BINDING_SHA256 = '1dadb8c3dc0573ea20659ce7251629669e618dd0effaeac3a4536b29c2e865a1' +const HEAD_MAIN_HOOK_SHA256 = '1b539cb02e2b6a3ea906b3c23050b8ed072e01e86ff64b3fde37c0643e9ea008' +const HEAD_HOOK_BINDING_SHA256 = 'fb32bba96822e00df7e451751101784839683c7b31e50e3ee871e13cddabe619' const HEAD_CALLBACK_IDENTITY_SHA256 = '2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb' const HEAD_CALLBACK_BODY_SHA256 = '22103ba85a86e3a3fcb80a7509c7a455d79863010cde3af02db6565b55e3ebe9' -const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13' +const HEAD_EFFECT_SHA256 = '016d046a108bd5b44ffcf0d277d5c64bb10657e13d79f9d37b91c056eef743df' const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581' const HEAD_NESTED_FUNCTION_SHA256 = '536c72b233c813bb0cea164b090bdce5406ceb965bbc5b83c1f89b89b46f3821' @@ -79,11 +80,11 @@ const HEAD_TIMER_CREATION_SHA256 = '1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b' const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116' const HEAD_RUNTIME_STRING_SHA256 = - '31951b0b83be01ebfa659c4b94df9ad7eaff6404df5338fbade89eb7473a3cb4' -const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5' -const HEAD_LEAF_JSX_SHA256 = '21dba981875e173f692590bf910d60964660c5f4cbb79f3a377c7e54f6a1f016' + '0ad9a4e8b336b9f10db4d39553bc1880f00c164d575766fe31f6e92cc1cccd25' +const HEAD_HOST_JSX_SHA256 = 'd2ebf1684d3ea579707e545334f9abbc4977552bf5322df11765b4f974d7078e' +const HEAD_LEAF_JSX_SHA256 = '9d6f8e326f69ddda44855c4af988bfdfadce34fe47c47946fbbc2eb3cb0b8782' const HEAD_STYLE_REFERENCE_SHA256 = - '295a3501c2c6d7bea7c8bbf38b3f3534f01344cd7e1b91bb8e07c040821d596a' + 'e12ba3494873d828d84ea4d2cc6ce8ee3414cec7f371e00eef8cb18cb3cc7a3b' const HEAD_IDENTITY_FIELD_SHA256 = '91146853930a34dd1f3d80e5c97fbacd7cf19fb93dd26fe8fc6f29169622f9d6' const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512' @@ -472,13 +473,13 @@ describe('mobile session route extraction parity', () => { const contentBindings = CONTENT_COMPONENT_NAMES.flatMap( (name) => readHookFacts(name, definitions).bindings ) - expect(main.hooks).toHaveLength(266) + expect(main.hooks).toHaveLength(269) expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256) expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256) expect(main.callbacks).toHaveLength(77) expect(hash(main.callbacks)).toBe(HEAD_CALLBACK_IDENTITY_SHA256) expect(hash(main.callbackBodies)).toBe(HEAD_CALLBACK_BODY_SHA256) - expect(main.effects).toHaveLength(24) + expect(main.effects).toHaveLength(26) expect(hash(main.effects)).toBe(HEAD_EFFECT_SHA256) expect(contentBindings).toHaveLength(14) expect(hash(contentBindings)).toBe(HEAD_CONTENT_HOOK_SHA256) @@ -517,14 +518,14 @@ describe('mobile session route extraction parity', () => { it('preserves runtime strings, styles, and the expanded JSX tree', () => { const strings = readRuntimeStrings() - expect(strings).toHaveLength(546) + expect(strings).toHaveLength(548) expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256) const jsx = readJsxFacts(readDefinitions()) - expect(jsx.host).toHaveLength(124) + expect(jsx.host).toHaveLength(127) expect(hash(jsx.host)).toBe(HEAD_HOST_JSX_SHA256) - expect(jsx.leaf).toHaveLength(61) + expect(jsx.leaf).toHaveLength(60) expect(hash(jsx.leaf)).toBe(HEAD_LEAF_JSX_SHA256) - expect(jsx.styleReferences).toHaveLength(172) + expect(jsx.styleReferences).toHaveLength(175) expect(hash(jsx.styleReferences)).toBe(HEAD_STYLE_REFERENCE_SHA256) }) }) diff --git a/mobile/src/session/mobile-session-route-source-family.test-support.ts b/mobile/src/session/mobile-session-route-source-family.test-support.ts index 41f2d8b9c2f..acb2bef34a8 100644 --- a/mobile/src/session/mobile-session-route-source-family.test-support.ts +++ b/mobile/src/session/mobile-session-route-source-family.test-support.ts @@ -33,6 +33,7 @@ export const MOBILE_SESSION_ROUTE_SOURCE_FILES = [ './use-mobile-session-content-create-actions.ts', './use-mobile-session-close-actions.ts', './use-mobile-session-bulk-close.ts', + './use-mobile-session-tab-strip-cache.ts', './use-mobile-session-presentation.ts', './use-mobile-session-panel-route-actions.tsx', './MobileSessionMarkdownReader.tsx', diff --git a/mobile/src/session/mobile-session-tab-strip-entries.ts b/mobile/src/session/mobile-session-tab-strip-entries.ts new file mode 100644 index 00000000000..5f4569403b0 --- /dev/null +++ b/mobile/src/session/mobile-session-tab-strip-entries.ts @@ -0,0 +1,116 @@ +import { TUI_AGENT_DISPLAY_NAMES } from '../../../src/shared/tui-agent-display-names' +import type { MobileSessionTab, MobileSessionTabType } from './mobile-session-route-types' +import { + getMobileSessionTabTitle, + resolveMobileTerminalTabAgentId +} from './mobile-terminal-tab-agent' + +/** + * The only session-tab fields the tab strip draws. Everything else the live tab carries (unsent + * launch drafts, absolute file paths, browser URLs, agent session ids) stays on the wire. + */ +export type MobileSessionTabStripEntry = { + id: string + type: MobileSessionTabType + title: string + agentId: string | null +} + +export type MobileSessionTabStripPreview = { + tabs: readonly MobileSessionTabStripEntry[] + activeTabId: string | null +} + +export type MobileSessionTabStripRow = { + entry: MobileSessionTabStripEntry + isActive: boolean + /** null on a preview row: switching to that tab needs a live connection. */ + tab: MobileSessionTab | null +} + +export function toMobileSessionTabStripEntry(tab: MobileSessionTab): MobileSessionTabStripEntry { + return { + id: tab.id, + type: tab.type, + title: getMobileSessionTabTitle(tab), + agentId: + tab.type === 'agent-session' + ? tab.agent + : tab.type === 'terminal' + ? resolveMobileTerminalTabAgentId(tab) + : null + } +} + +/** + * Every tab type the strip knows how to draw. A stored entry naming anything else is dropped + * rather than trusted, so a type added later fails closed: its rows go missing from the preview + * instead of carrying an unreviewed title into storage. + */ +const drawableTabTypes = new Set([ + 'terminal', + 'markdown', + 'file', + 'browser', + 'agent-session' +] satisfies readonly MobileSessionTabType[]) + +export function isDrawableTabStripType(type: string): type is MobileSessionTabType { + return drawableTabTypes.has(type) +} + +const agentDisplayNames: Readonly> = TUI_AGENT_DISPLAY_NAMES + +/** + * The title a strip entry may be written to disk under. + * + * A terminal's title is whatever the shell last set, which is routinely the command line — + * `psql postgres://user:password@host/db`, `curl -H "Authorization: Bearer ..."`. None of that + * belongs in plaintext storage, and a browser tab's page title is no better. Both collapse to a + * fixed label, so what survives is the shape of the strip, not its contents. A resolved agent + * still names itself, because that lookup is a closed enum: an unrecognised id yields the + * generic label rather than passing text through. + */ +export function getPersistableTabStripTitle( + entry: Pick +): string { + if (entry.type === 'terminal') { + const agentLabel = entry.agentId === null ? undefined : agentDisplayNames[entry.agentId] + return agentLabel ?? 'Terminal' + } + if (entry.type === 'browser') { + return 'Browser' + } + return entry.title +} + +export function toMobileSessionTabStripPreview( + tabs: readonly MobileSessionTab[], + activeTabId: string | null +): MobileSessionTabStripPreview { + return { tabs: tabs.map(toMobileSessionTabStripEntry), activeTabId } +} + +/** + * Rows for the header strip. Live tabs always win; the preview only fills a strip that has no + * live rows yet, and its ids are the live ids, so the swap reuses the same React keys. + */ +export function getMobileSessionTabStripRows(args: { + liveTabs: readonly MobileSessionTab[] + activeSessionTabId: string | null + preview: MobileSessionTabStripPreview | null +}): MobileSessionTabStripRow[] { + const { liveTabs, activeSessionTabId, preview } = args + if (liveTabs.length > 0 || !preview) { + return liveTabs.map((tab) => ({ + entry: toMobileSessionTabStripEntry(tab), + isActive: tab.id === activeSessionTabId, + tab + })) + } + return preview.tabs.map((entry) => ({ + entry, + isActive: entry.id === preview.activeTabId, + tab: null + })) +} diff --git a/mobile/src/session/use-mobile-session-controller.ts b/mobile/src/session/use-mobile-session-controller.ts index f188b30b17a..b2427f806c2 100644 --- a/mobile/src/session/use-mobile-session-controller.ts +++ b/mobile/src/session/use-mobile-session-controller.ts @@ -27,6 +27,7 @@ import { useMobileSessionTerminalCreateActions } from './use-mobile-session-term import { useMobileSessionContentCreateActions } from './use-mobile-session-content-create-actions' import { useMobileSessionCloseActions } from './use-mobile-session-close-actions' import { useMobileSessionBulkClose } from './use-mobile-session-bulk-close' +import { useMobileSessionTabStripCache } from './use-mobile-session-tab-strip-cache' import { useMobileSessionPresentation } from './use-mobile-session-presentation' import { useMobileSessionPanelRouteActions } from './use-mobile-session-panel-route-actions' @@ -113,7 +114,8 @@ export function useMobileSessionController() { useMobileSessionCloseActions(contentCreateActions) ) const bulkClose = Object.assign(closeActions, useMobileSessionBulkClose(closeActions)) - const presentation = Object.assign(bulkClose, useMobileSessionPresentation(bulkClose)) + const tabStripCache = Object.assign(bulkClose, useMobileSessionTabStripCache(bulkClose)) + const presentation = Object.assign(tabStripCache, useMobileSessionPresentation(tabStripCache)) const panelRouteActions = Object.assign( presentation, useMobileSessionPanelRouteActions(presentation) diff --git a/mobile/src/session/use-mobile-session-presentation.ts b/mobile/src/session/use-mobile-session-presentation.ts index 2565f729940..e43b59cabef 100644 --- a/mobile/src/session/use-mobile-session-presentation.ts +++ b/mobile/src/session/use-mobile-session-presentation.ts @@ -3,9 +3,11 @@ import { classifyConnection, verdictDisplayLabel } from '../transport/connection import { computeActiveTerminalKeyboardLift } from '../terminal/terminal-keyboard-avoidance-lift' import { useInitialSessionTerminalAutoCreate } from './use-initial-session-terminal-autocreate' import { MOBILE_SESSION_STATUS_LABELS } from './mobile-session-route-helpers' -import type { MobileSessionBulkCloseModel } from './use-mobile-session-bulk-close' +import { selectMobileSessionReconnectViewState } from './mobile-session-reconnect-view-state' +import { getMobileSessionTabStripRows } from './mobile-session-tab-strip-entries' +import type { MobileSessionTabStripCacheModel } from './use-mobile-session-tab-strip-cache' -export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel) { +export function useMobileSessionPresentation(scope: MobileSessionTabStripCacheModel) { const { created, worktreeId, @@ -24,6 +26,8 @@ export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel) terminalKeyboardMetrics, toastOpacityRef, hostEndpoint, + activeSessionTabId, + cachedTabStrip, initialSessionAutoCreateRef, terminalFrameHeightRef, handleCreateTerminal, @@ -58,6 +62,23 @@ export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel) const showConnectionRetry = connectionVerdict.kind === 'warning' || connectionVerdict.kind === 'unreachable' + // Why: a reconnect to a workspace this phone has already drawn should re-draw it, not blank + // the screen while the RPCs land. See mobile-session-reconnect-view-state. + const reconnectViewState = selectMobileSessionReconnectViewState({ + connState, + verdictKind: connectionVerdict.kind, + terminalsLoaded, + liveTabCount: visibleTabs.length, + activeHandle, + cachedPreview: cachedTabStrip + }) + const tabStripRows = getMobileSessionTabStripRows({ + liveTabs: visibleTabs, + activeSessionTabId, + preview: + reconnectViewState.kind === 'reconnecting-with-cache' ? reconnectViewState.preview : null + }) + const terminalSummary = connState === 'connected' ? showLoadingState @@ -88,6 +109,8 @@ export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel) return { showLoadingState, showEmptyState, + reconnectViewState, + tabStripRows, connectionVerdict, showConnectionRetry, terminalSummary, @@ -97,5 +120,5 @@ export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel) } } -export type MobileSessionPresentationModel = MobileSessionBulkCloseModel & +export type MobileSessionPresentationModel = MobileSessionTabStripCacheModel & ReturnType diff --git a/mobile/src/session/use-mobile-session-tab-strip-cache.ts b/mobile/src/session/use-mobile-session-tab-strip-cache.ts new file mode 100644 index 00000000000..d0207afd83c --- /dev/null +++ b/mobile/src/session/use-mobile-session-tab-strip-cache.ts @@ -0,0 +1,66 @@ +import { useEffect, useState } from 'react' +import { + getSessionTabStripCacheKey, + loadCachedSessionTabStrip, + readCachedSessionTabStrip, + saveCachedSessionTabStrip +} from '../cache/session-tab-strip-cache' +import { + toMobileSessionTabStripPreview, + type MobileSessionTabStripPreview +} from './mobile-session-tab-strip-entries' +import type { MobileSessionBulkCloseModel } from './use-mobile-session-bulk-close' + +/** + * Keeps the last drawn tab strip for this workspace on the device, so a reconnect has something + * to render before the first snapshot lands. See mobile-session-reconnect-view-state. + */ +export function useMobileSessionTabStripCache(scope: MobileSessionBulkCloseModel) { + const { hostId, worktreeId, connState, terminalsLoaded } = scope + const { visibleTabs, activeSessionTabId, activeHandle } = scope + const cacheKey = getSessionTabStripCacheKey(hostId, worktreeId) + // Why: state settles a commit behind the key it was read for, so carry the key with it — + // otherwise the first render after a workspace switch draws the previous workspace's strip. + const [loaded, setLoaded] = useState<{ + key: string | null + preview: MobileSessionTabStripPreview | null + }>(() => ({ key: cacheKey, preview: readCachedSessionTabStrip(cacheKey) })) + + useEffect(() => { + // Synchronous first, so an in-session revisit never blinks through the uncached branch. + setLoaded({ key: cacheKey, preview: readCachedSessionTabStrip(cacheKey) }) + let disposed = false + void loadCachedSessionTabStrip(cacheKey).then((preview) => { + if (!disposed) { + setLoaded({ key: cacheKey, preview }) + } + }) + return () => { + disposed = true + } + }, [cacheKey]) + const cachedTabStrip = loaded.key === cacheKey ? loaded.preview : null + + // Only a host-confirmed strip is worth persisting, and an emptied workspace has to be written + // too — skipping it would leave yesterday's tabs to be drawn over a session that no longer has + // them. The one reading we do not trust is a live terminal with no tab record behind it, which + // is the same case the empty state refuses to claim (use-mobile-session-presentation). + // react-doctor-disable-next-line react-doctor/effect-needs-cleanup + useEffect(() => { + if (connState !== 'connected' || !terminalsLoaded) { + return + } + if (visibleTabs.length === 0 && activeHandle !== null) { + return + } + saveCachedSessionTabStrip( + cacheKey, + toMobileSessionTabStripPreview(visibleTabs, activeSessionTabId) + ) + }, [activeHandle, activeSessionTabId, cacheKey, connState, terminalsLoaded, visibleTabs]) + + return { cachedTabStrip } +} + +export type MobileSessionTabStripCacheModel = MobileSessionBulkCloseModel & + ReturnType diff --git a/mobile/src/transport/host-removal-lifecycle.test.ts b/mobile/src/transport/host-removal-lifecycle.test.ts index 6c96ef1c446..3dca9514362 100644 --- a/mobile/src/transport/host-removal-lifecycle.test.ts +++ b/mobile/src/transport/host-removal-lifecycle.test.ts @@ -17,6 +17,12 @@ vi.mock('./host-store', () => ({ })) import { removeHostAndCloseClient } from './host-removal-lifecycle' +import { + getSessionTabStripCacheKey, + readCachedSessionTabStrip, + resetSessionTabStripCacheForTests, + saveCachedSessionTabStrip +} from '../cache/session-tab-strip-cache' import { getHostNotificationSession, resetHostNotificationSessionsForTests @@ -27,6 +33,7 @@ describe('host removal lifecycle', () => { removeHostMock.mockReset() asyncStorage.removeItem.mockClear() resetHostNotificationSessionsForTests() + resetSessionTabStripCacheForTests() }) it('closes the client only after metadata removal commits', async () => { @@ -88,4 +95,25 @@ describe('host removal lifecycle', () => { expect(asyncStorage.removeItem).toHaveBeenCalledWith('orca:mobileNotificationsWatermark:host-1') }) + + it('drops the removed host cached tab strip and keeps every other host', async () => { + // Why: the strip is plaintext and nothing else in the app ever expires an entry, so a + // forgotten host would keep its tab titles on disk and get them rewritten by the next + // save for any surviving host. + removeHostMock.mockResolvedValue(undefined) + const removed = getSessionTabStripCacheKey('host-1', 'wt-1') + const kept = getSessionTabStripCacheKey('host-2', 'wt-1') + const strip = { + tabs: [{ id: 'tab-1', type: 'terminal' as const, title: 'Terminal', agentId: null }], + activeTabId: 'tab-1' + } + saveCachedSessionTabStrip(removed, strip) + saveCachedSessionTabStrip(kept, strip) + + await removeHostAndCloseClient('host-1', vi.fn()) + // Fire-and-forget, like clearWatermark above; let its microtasks land. + await vi.waitFor(() => expect(readCachedSessionTabStrip(removed)).toBeNull()) + + expect(readCachedSessionTabStrip(kept)?.tabs).toHaveLength(1) + }) }) diff --git a/mobile/src/transport/host-removal-lifecycle.ts b/mobile/src/transport/host-removal-lifecycle.ts index cd0a09cb67e..3883cfb9140 100644 --- a/mobile/src/transport/host-removal-lifecycle.ts +++ b/mobile/src/transport/host-removal-lifecycle.ts @@ -1,3 +1,4 @@ +import { deleteCachedSessionTabStripForHost } from '../cache/session-tab-strip-cache' import { clearWatermark, forgetHostNotificationSession @@ -17,4 +18,7 @@ export async function removeHostAndCloseClient( // re-pair of the same host would inherit a watermark for a counter it never saw. forgetHostNotificationSession(hostId) void clearWatermark(hostId) + // Why: the cached tab strip is plaintext and host-scoped, so forgetting the host has to drop + // it here too — nothing else in the app ever expires an entry. + void deleteCachedSessionTabStripForHost(hostId) } diff --git a/mobile/src/transport/unpaired-host-credential-deletion.test.ts b/mobile/src/transport/unpaired-host-credential-deletion.test.ts new file mode 100644 index 00000000000..cd6ebe4a2fd --- /dev/null +++ b/mobile/src/transport/unpaired-host-credential-deletion.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const asyncStorage = vi.hoisted(() => ({ + getItem: vi.fn(async () => null), + setItem: vi.fn(async () => undefined), + removeItem: vi.fn(async () => undefined) +})) +const deletions = vi.hoisted(() => ({ + deviceToken: vi.fn(async () => undefined), + credentialBundle: vi.fn(async () => undefined), + directUpgradeJournal: vi.fn(async () => undefined), + clearWriteRevision: vi.fn() +})) + +vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorage })) +vi.mock('./host-device-token-store', () => ({ deleteHostDeviceToken: deletions.deviceToken })) +vi.mock('./mobile-relay-credential-bundle', () => ({ + deleteMobileRelayCredentialBundle: deletions.credentialBundle +})) +vi.mock('./mobile-relay-direct-upgrade-journal', () => ({ + deleteMobileRelayDirectUpgradeJournal: deletions.directUpgradeJournal +})) +vi.mock('./host-credential-write-revision', () => ({ + clearHostCredentialWriteRevision: deletions.clearWriteRevision, + getHostCredentialWriteRevision: () => 0 +})) + +import { createUnpairedHostCredentialDeletion } from './unpaired-host-credential-deletion' +import { + getSessionTabStripCacheKey, + readCachedSessionTabStrip, + resetSessionTabStripCacheForTests, + saveCachedSessionTabStrip +} from '../cache/session-tab-strip-cache' + +const strip = { + tabs: [{ id: 'tab-1', type: 'terminal' as const, title: 'Terminal', agentId: null }], + activeTabId: 'tab-1' +} + +function createDeletion(storedHostIds: string[] = []) { + return createUnpairedHostCredentialDeletion({ + waitForHostMutations: async () => undefined, + hasStoredHost: async (hostId) => storedHostIds.includes(hostId), + onDeleted: vi.fn() + }) +} + +beforeEach(() => { + asyncStorage.getItem.mockClear() + asyncStorage.setItem.mockClear() + for (const mock of Object.values(deletions)) { + mock.mockClear() + } + resetSessionTabStripCacheForTests() +}) + +describe('unpaired host credential deletion', () => { + it('takes the cached tab strip with the credentials, leaving other hosts alone', async () => { + // Why: the strip is not a credential, but it is host-scoped plaintext written from the + // session screen. Without this sweep it outlives the pairing that produced it. + const unpaired = getSessionTabStripCacheKey('host-1', 'wt-1') + const other = getSessionTabStripCacheKey('host-2', 'wt-1') + saveCachedSessionTabStrip(unpaired, strip) + saveCachedSessionTabStrip(other, strip) + + await createDeletion()('host-1', 0) + + expect(readCachedSessionTabStrip(unpaired)).toBeNull() + expect(readCachedSessionTabStrip(other)?.tabs).toHaveLength(1) + }) + + it('leaves the strip alone when the host turned out to still be paired', async () => { + const stillPaired = getSessionTabStripCacheKey('host-1', 'wt-1') + saveCachedSessionTabStrip(stillPaired, strip) + + await createDeletion(['host-1'])('host-1', 0) + + expect(readCachedSessionTabStrip(stillPaired)?.tabs).toHaveLength(1) + expect(deletions.deviceToken).not.toHaveBeenCalled() + }) +}) diff --git a/mobile/src/transport/unpaired-host-credential-deletion.ts b/mobile/src/transport/unpaired-host-credential-deletion.ts index cc9c27e49ad..06220824b78 100644 --- a/mobile/src/transport/unpaired-host-credential-deletion.ts +++ b/mobile/src/transport/unpaired-host-credential-deletion.ts @@ -1,3 +1,4 @@ +import { deleteCachedSessionTabStripForHost } from '../cache/session-tab-strip-cache' import { deleteHostDeviceToken } from './host-device-token-store' import { clearHostCredentialWriteRevision, @@ -52,6 +53,13 @@ export function createUnpairedHostCredentialDeletion(dependencies: DeletionDepen return } assertWriteRevisionUnchanged(hostId, writeRevision) + // The cached tab strip is not a credential, but it is host-scoped plaintext that outlives + // the pairing unless this sweep takes it too. + await deleteCachedSessionTabStripForHost(hostId) + if (await shouldSkip(hostId, writeRevision)) { + return + } + assertWriteRevisionUnchanged(hostId, writeRevision) clearHostCredentialWriteRevision(hostId) dependencies.onDeleted(hostId) }