From 0ba7f8dc8d2dca757e51d4e4c25ff3539fc3eb4d Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Mon, 7 Sep 2026 04:42:59 -0400 Subject: [PATCH] feat(mobile): draw the last known tab strip while a session reconnects (#19258) * feat(mobile): draw the last known tab strip while a session reconnects Reopening a workspace the phone has already visited threw away everything it knew. The route clears its tabs on mount, so until the reconnect lands and the first snapshot is applied the session screen has an empty header and a bare spinner, even though the strip it is about to be handed is the one it drew a minute ago. Persist the four fields the strip actually draws -- id, type, title, agent -- per host and workspace, and add a reconnecting-with-cache shape to the route state so those rows render immediately, disabled, under the ids the live snapshot will reuse. Live tabs always outrank the cache, so a mid-session drop keeps its mounted terminals; an exhausted retry loop or a rejected pairing outranks it the other way, because a strip the user cannot reach is worse than the existing offline affordance. With nothing cached the screen behaves exactly as before. The body stays a placeholder. Replaying stored scrollback into the terminal WebView would double-render the same rows once the live stream replays them, so the strip is the cached content and the body waits for the stream. * fix(mobile): keep shell titles and unpaired hosts out of the cached tab strip Review of the reconnect strip cache found two ways it leaked. A terminal's title is whatever the shell last set, which is routinely the command line: a psql URL with an inline password, a curl with a bearer token. Both fit well inside the 64-character cap and both were written to plaintext AsyncStorage verbatim. Browser tabs carried their page title the same way. Terminals and browsers now collapse to a fixed label, with a resolved agent naming itself because that lookup is a closed enum. The rule lives in the storage module rather than its caller, so it holds for entries an older build already wrote, and a tab type this build cannot draw is dropped instead of having its title trusted. The cache also survived forgetting a host. Nothing expired an entry, and the module-global memory map meant a later save from any surviving host serialized the forgotten host's rows straight back to disk. Both cleanup paths now evict by host, dropping the in-memory rows and rewriting storage, with a pending debounced write cancelled so it cannot restore them. Also: the storage key digests the workspace id, which ended in a filesystem path, and cached rows carry the same de-emphasis as the disabled tab-bar buttons beside them, so an inert row does not pass for a live one. --- .../src/cache/session-tab-strip-cache.test.ts | 282 ++++++++++++++++++ mobile/src/cache/session-tab-strip-cache.ts | 228 ++++++++++++++ .../session/MobileSessionActiveContent.tsx | 11 +- mobile/src/session/MobileSessionHeader.tsx | 59 ++-- .../session/mobile-session-frame-styles.ts | 5 + ...obile-session-reconnect-view-state.test.ts | 155 ++++++++++ .../mobile-session-reconnect-view-state.ts | 61 ++++ .../mobile-session-route-parity.test.ts | 27 +- ...ession-route-source-family.test-support.ts | 1 + .../mobile-session-tab-strip-entries.ts | 116 +++++++ .../session/use-mobile-session-controller.ts | 4 +- .../use-mobile-session-presentation.ts | 29 +- .../use-mobile-session-tab-strip-cache.ts | 66 ++++ .../transport/host-removal-lifecycle.test.ts | 28 ++ .../src/transport/host-removal-lifecycle.ts | 4 + .../unpaired-host-credential-deletion.test.ts | 82 +++++ .../unpaired-host-credential-deletion.ts | 8 + 17 files changed, 1119 insertions(+), 47 deletions(-) create mode 100644 mobile/src/cache/session-tab-strip-cache.test.ts create mode 100644 mobile/src/cache/session-tab-strip-cache.ts create mode 100644 mobile/src/session/mobile-session-reconnect-view-state.test.ts create mode 100644 mobile/src/session/mobile-session-reconnect-view-state.ts create mode 100644 mobile/src/session/mobile-session-tab-strip-entries.ts create mode 100644 mobile/src/session/use-mobile-session-tab-strip-cache.ts create mode 100644 mobile/src/transport/unpaired-host-credential-deletion.test.ts 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) }