Files
orca/mobile/src/cache/session-tab-strip-cache.ts
T
Jinwoo Hong 0ba7f8dc8d 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.
2026-09-07 04:42:59 -04:00

229 lines
7.7 KiB
TypeScript

// 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<string, MobileSessionTabStripPreview> | null = null
let loadPromise: Promise<Map<string, MobileSessionTabStripPreview>> | null = null
let writeTimer: ReturnType<typeof setTimeout> | 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<MobileSessionTabStripPreview | null> {
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<void> {
// 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<Map<string, MobileSessionTabStripPreview>> {
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<string, MobileSessionTabStripPreview>()
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<StoredWorkspace[]> {
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<string, MobileSessionTabStripPreview>): void {
if (writeTimer) {
clearTimeout(writeTimer)
}
writeTimer = setTimeout(() => {
writeTimer = null
void writeFile(cache)
}, WRITE_DEBOUNCE_MS)
}
async function writeFile(cache: Map<string, MobileSessionTabStripPreview>): Promise<void> {
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 }
}