diff --git a/mobile/src/cache/session-tab-strip-cache.test.ts b/mobile/src/cache/session-tab-strip-cache.test.ts deleted file mode 100644 index fa1ed188edc..00000000000 --- a/mobile/src/cache/session-tab-strip-cache.test.ts +++ /dev/null @@ -1,282 +0,0 @@ -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 deleted file mode 100644 index 222e2c3fd27..00000000000 --- a/mobile/src/cache/session-tab-strip-cache.ts +++ /dev/null @@ -1,228 +0,0 @@ -// 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 00c852dbf01..019e83c6a99 100644 --- a/mobile/src/session/MobileSessionActiveContent.tsx +++ b/mobile/src/session/MobileSessionActiveContent.tsx @@ -74,7 +74,6 @@ export function MobileSessionActiveContent({ activePendingTerminalTab, isPendingTerminalRecoveryParked, retryPendingTerminalRecovery, - reconnectViewState, showLoadingState, showEmptyState, keyboardLift, @@ -82,15 +81,7 @@ export function MobileSessionActiveContent({ toastAnimatedStyle, createTabBusy } = controller - // 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 ? ( + return showLoadingState ? ( diff --git a/mobile/src/session/MobileSessionHeader.tsx b/mobile/src/session/MobileSessionHeader.tsx index a23c216c729..552f507a787 100644 --- a/mobile/src/session/MobileSessionHeader.tsx +++ b/mobile/src/session/MobileSessionHeader.tsx @@ -14,6 +14,10 @@ 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' @@ -28,6 +32,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC forceReconnectHost, worktreeName, activePanel, + activeSessionTabId, activeSessionTabIdRef, tabStripRef, tabStripOffsetRef, @@ -47,7 +52,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC scrollActiveTabIntoView, switchSessionTab, openSessionTabActionSheetAfterKeyboardDismiss, - tabStripRows, + visibleTabs, showConnectionRetry, terminalSummary, handlePanelTap, @@ -112,7 +117,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC ) : null} - {tabStripRows.length > 0 && ( + {visibleTabs.length > 0 && ( {/* Why: tab taps must register on first press with the keyboard open instead of being eaten by dismissal (#5106). */} - {tabStripRows.map(({ entry, isActive, tab }) => ( + {visibleTabs.map((t) => ( { const { x, width } = e.nativeEvent.layout - tabLayoutsRef.current.set(entry.id, { x, width }) - if (entry.id === activeSessionTabIdRef.current) { - scrollActiveTabIntoView(entry.id, false) + tabLayoutsRef.current.set(t.id, { x, width }) + if (t.id === activeSessionTabIdRef.current) { + scrollActiveTabIntoView(t.id, false) } }} - // 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) - } - } + onPress={() => switchSessionTab(t)} + onLongPress={() => { + triggerMediumImpact() + openSessionTabActionSheetAfterKeyboardDismiss(t) + }} delayLongPress={400} > - {entry.type === 'browser' && ( + {t.type === 'browser' && ( )} - {entry.type === 'markdown' && ( + {t.type === 'markdown' && ( )} - {entry.type === 'file' && ( + {t.type === 'file' && ( )} - {entry.agentId !== null && } + {t.type === 'agent-session' && } + {t.type === 'terminal' && + (() => { + const agentId = resolveMobileTerminalTabAgentId(t) + return agentId ? : null + })()} - {entry.title} + {getMobileSessionTabTitle(t)} diff --git a/mobile/src/session/mobile-session-frame-styles.ts b/mobile/src/session/mobile-session-frame-styles.ts index 22d3c6e76cc..a02c14be014 100644 --- a/mobile/src/session/mobile-session-frame-styles.ts +++ b/mobile/src/session/mobile-session-frame-styles.ts @@ -102,11 +102,6 @@ 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 deleted file mode 100644 index 09f9bbb8447..00000000000 --- a/mobile/src/session/mobile-session-reconnect-view-state.test.ts +++ /dev/null @@ -1,155 +0,0 @@ -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 deleted file mode 100644 index fe980676408..00000000000 --- a/mobile/src/session/mobile-session-reconnect-view-state.ts +++ /dev/null @@ -1,61 +0,0 @@ -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 1455765771f..bc951bfa206 100644 --- a/mobile/src/session/mobile-session-route-parity.test.ts +++ b/mobile/src/session/mobile-session-route-parity.test.ts @@ -37,7 +37,6 @@ const LOGIC_EXPANSION_NAMES = new Set([ 'useMobileSessionContentCreateActions', 'useMobileSessionCloseActions', 'useMobileSessionBulkClose', - 'useMobileSessionTabStripCache', 'useMobileSessionPresentation', 'useMobileSessionPanelRouteActions' ]) @@ -63,12 +62,12 @@ const HOST_COMPONENT_NAMES = new Set([ 'View' ]) -const HEAD_MAIN_HOOK_SHA256 = '1b539cb02e2b6a3ea906b3c23050b8ed072e01e86ff64b3fde37c0643e9ea008' -const HEAD_HOOK_BINDING_SHA256 = 'fb32bba96822e00df7e451751101784839683c7b31e50e3ee871e13cddabe619' +const HEAD_MAIN_HOOK_SHA256 = '10071240ef9edafc2b9c8bed73be83dceaf7828e3b29f17dab55da020a7697a6' +const HEAD_HOOK_BINDING_SHA256 = '1dadb8c3dc0573ea20659ce7251629669e618dd0effaeac3a4536b29c2e865a1' const HEAD_CALLBACK_IDENTITY_SHA256 = '2a9e4825df007f6ef53b81aa5004991d6318eee7507b44d625c07e630be432eb' const HEAD_CALLBACK_BODY_SHA256 = '22103ba85a86e3a3fcb80a7509c7a455d79863010cde3af02db6565b55e3ebe9' -const HEAD_EFFECT_SHA256 = '016d046a108bd5b44ffcf0d277d5c64bb10657e13d79f9d37b91c056eef743df' +const HEAD_EFFECT_SHA256 = 'd9ebfaabc1e79773cdada7ab370b20459ed972f1f8edce1652199f4d0391cd13' const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581' const HEAD_NESTED_FUNCTION_SHA256 = '536c72b233c813bb0cea164b090bdce5406ceb965bbc5b83c1f89b89b46f3821' @@ -80,11 +79,11 @@ const HEAD_TIMER_CREATION_SHA256 = '1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b' const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116' const HEAD_RUNTIME_STRING_SHA256 = - '0ad9a4e8b336b9f10db4d39553bc1880f00c164d575766fe31f6e92cc1cccd25' -const HEAD_HOST_JSX_SHA256 = 'd2ebf1684d3ea579707e545334f9abbc4977552bf5322df11765b4f974d7078e' -const HEAD_LEAF_JSX_SHA256 = '9d6f8e326f69ddda44855c4af988bfdfadce34fe47c47946fbbc2eb3cb0b8782' + '31951b0b83be01ebfa659c4b94df9ad7eaff6404df5338fbade89eb7473a3cb4' +const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5' +const HEAD_LEAF_JSX_SHA256 = '21dba981875e173f692590bf910d60964660c5f4cbb79f3a377c7e54f6a1f016' const HEAD_STYLE_REFERENCE_SHA256 = - 'e12ba3494873d828d84ea4d2cc6ce8ee3414cec7f371e00eef8cb18cb3cc7a3b' + '295a3501c2c6d7bea7c8bbf38b3f3534f01344cd7e1b91bb8e07c040821d596a' const HEAD_IDENTITY_FIELD_SHA256 = '91146853930a34dd1f3d80e5c97fbacd7cf19fb93dd26fe8fc6f29169622f9d6' const HEAD_NAVIGATION_SHA256 = '9d96f5dad7de555d6553eac39c0fab00efad507470fd562cb9beaa32db16f512' @@ -473,13 +472,13 @@ describe('mobile session route extraction parity', () => { const contentBindings = CONTENT_COMPONENT_NAMES.flatMap( (name) => readHookFacts(name, definitions).bindings ) - expect(main.hooks).toHaveLength(269) + expect(main.hooks).toHaveLength(266) 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(26) + expect(main.effects).toHaveLength(24) expect(hash(main.effects)).toBe(HEAD_EFFECT_SHA256) expect(contentBindings).toHaveLength(14) expect(hash(contentBindings)).toBe(HEAD_CONTENT_HOOK_SHA256) @@ -518,14 +517,14 @@ describe('mobile session route extraction parity', () => { it('preserves runtime strings, styles, and the expanded JSX tree', () => { const strings = readRuntimeStrings() - expect(strings).toHaveLength(548) + expect(strings).toHaveLength(546) expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256) const jsx = readJsxFacts(readDefinitions()) - expect(jsx.host).toHaveLength(127) + expect(jsx.host).toHaveLength(124) expect(hash(jsx.host)).toBe(HEAD_HOST_JSX_SHA256) - expect(jsx.leaf).toHaveLength(60) + expect(jsx.leaf).toHaveLength(61) expect(hash(jsx.leaf)).toBe(HEAD_LEAF_JSX_SHA256) - expect(jsx.styleReferences).toHaveLength(175) + expect(jsx.styleReferences).toHaveLength(172) 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 acb2bef34a8..41f2d8b9c2f 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,7 +33,6 @@ 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 deleted file mode 100644 index 5f4569403b0..00000000000 --- a/mobile/src/session/mobile-session-tab-strip-entries.ts +++ /dev/null @@ -1,116 +0,0 @@ -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 b2427f806c2..f188b30b17a 100644 --- a/mobile/src/session/use-mobile-session-controller.ts +++ b/mobile/src/session/use-mobile-session-controller.ts @@ -27,7 +27,6 @@ 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' @@ -114,8 +113,7 @@ export function useMobileSessionController() { useMobileSessionCloseActions(contentCreateActions) ) const bulkClose = Object.assign(closeActions, useMobileSessionBulkClose(closeActions)) - const tabStripCache = Object.assign(bulkClose, useMobileSessionTabStripCache(bulkClose)) - const presentation = Object.assign(tabStripCache, useMobileSessionPresentation(tabStripCache)) + const presentation = Object.assign(bulkClose, useMobileSessionPresentation(bulkClose)) 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 e43b59cabef..2565f729940 100644 --- a/mobile/src/session/use-mobile-session-presentation.ts +++ b/mobile/src/session/use-mobile-session-presentation.ts @@ -3,11 +3,9 @@ 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 { 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' +import type { MobileSessionBulkCloseModel } from './use-mobile-session-bulk-close' -export function useMobileSessionPresentation(scope: MobileSessionTabStripCacheModel) { +export function useMobileSessionPresentation(scope: MobileSessionBulkCloseModel) { const { created, worktreeId, @@ -26,8 +24,6 @@ export function useMobileSessionPresentation(scope: MobileSessionTabStripCacheMo terminalKeyboardMetrics, toastOpacityRef, hostEndpoint, - activeSessionTabId, - cachedTabStrip, initialSessionAutoCreateRef, terminalFrameHeightRef, handleCreateTerminal, @@ -62,23 +58,6 @@ export function useMobileSessionPresentation(scope: MobileSessionTabStripCacheMo 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 @@ -109,8 +88,6 @@ export function useMobileSessionPresentation(scope: MobileSessionTabStripCacheMo return { showLoadingState, showEmptyState, - reconnectViewState, - tabStripRows, connectionVerdict, showConnectionRetry, terminalSummary, @@ -120,5 +97,5 @@ export function useMobileSessionPresentation(scope: MobileSessionTabStripCacheMo } } -export type MobileSessionPresentationModel = MobileSessionTabStripCacheModel & +export type MobileSessionPresentationModel = MobileSessionBulkCloseModel & 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 deleted file mode 100644 index d0207afd83c..00000000000 --- a/mobile/src/session/use-mobile-session-tab-strip-cache.ts +++ /dev/null @@ -1,66 +0,0 @@ -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 3dca9514362..6c96ef1c446 100644 --- a/mobile/src/transport/host-removal-lifecycle.test.ts +++ b/mobile/src/transport/host-removal-lifecycle.test.ts @@ -17,12 +17,6 @@ vi.mock('./host-store', () => ({ })) import { removeHostAndCloseClient } from './host-removal-lifecycle' -import { - getSessionTabStripCacheKey, - readCachedSessionTabStrip, - resetSessionTabStripCacheForTests, - saveCachedSessionTabStrip -} from '../cache/session-tab-strip-cache' import { getHostNotificationSession, resetHostNotificationSessionsForTests @@ -33,7 +27,6 @@ describe('host removal lifecycle', () => { removeHostMock.mockReset() asyncStorage.removeItem.mockClear() resetHostNotificationSessionsForTests() - resetSessionTabStripCacheForTests() }) it('closes the client only after metadata removal commits', async () => { @@ -95,25 +88,4 @@ 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 3883cfb9140..cd0a09cb67e 100644 --- a/mobile/src/transport/host-removal-lifecycle.ts +++ b/mobile/src/transport/host-removal-lifecycle.ts @@ -1,4 +1,3 @@ -import { deleteCachedSessionTabStripForHost } from '../cache/session-tab-strip-cache' import { clearWatermark, forgetHostNotificationSession @@ -18,7 +17,4 @@ 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 deleted file mode 100644 index cd6ebe4a2fd..00000000000 --- a/mobile/src/transport/unpaired-host-credential-deletion.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -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 06220824b78..cc9c27e49ad 100644 --- a/mobile/src/transport/unpaired-host-credential-deletion.ts +++ b/mobile/src/transport/unpaired-host-credential-deletion.ts @@ -1,4 +1,3 @@ -import { deleteCachedSessionTabStripForHost } from '../cache/session-tab-strip-cache' import { deleteHostDeviceToken } from './host-device-token-store' import { clearHostCredentialWriteRevision, @@ -53,13 +52,6 @@ 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) }