Revert "feat(mobile): draw the last known tab strip while a session reconnects (#19258)"

This reverts commit 0ba7f8dc8d.
This commit is contained in:
Jinwoo-H
2026-09-07 04:50:16 -04:00
parent 0ba7f8dc8d
commit 64af88ea9c
17 changed files with 47 additions and 1119 deletions
-282
View File
@@ -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([])
})
})
-228
View File
@@ -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<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 }
}
@@ -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' ? (
<View style={styles.emptyState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
<Text style={styles.emptyText}>{reconnectViewState.label}</Text>
</View>
) : showLoadingState ? (
return showLoadingState ? (
<View style={styles.emptyState}>
<ActivityIndicator size="small" color={colors.textSecondary} />
</View>
+29 -30
View File
@@ -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}
</View>
{tabStripRows.length > 0 && (
{visibleTabs.length > 0 && (
<View style={styles.tabBar}>
{/* Why: tab taps must register on first press with the keyboard open instead of being eaten by dismissal (#5106). */}
<ScrollView
@@ -135,51 +140,45 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC
scrollActiveTabIntoView(activeSessionTabIdRef.current, false)
}}
>
{tabStripRows.map(({ entry, isActive, tab }) => (
{visibleTabs.map((t) => (
<Pressable
key={entry.id}
style={[
styles.tab,
isActive && styles.tabActive,
tab === null && styles.tabPreview
]}
key={t.id}
style={[styles.tab, t.id === activeSessionTabId && styles.tabActive]}
onLayout={(e) => {
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}
>
<View style={styles.tabLabelRow}>
{entry.type === 'browser' && (
{t.type === 'browser' && (
<Globe size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{entry.type === 'markdown' && (
{t.type === 'markdown' && (
<FileText size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{entry.type === 'file' && (
{t.type === 'file' && (
<File size={13} color={colors.textSecondary} strokeWidth={2.1} />
)}
{entry.agentId !== null && <MobileAgentIcon agentId={entry.agentId} size={13} />}
{t.type === 'agent-session' && <MobileAgentIcon agentId={t.agent} size={13} />}
{t.type === 'terminal' &&
(() => {
const agentId = resolveMobileTerminalTabAgentId(t)
return agentId ? <MobileAgentIcon agentId={agentId} size={13} /> : null
})()}
<Text
style={[styles.tabText, isActive && styles.tabTextActive]}
style={[styles.tabText, t.id === activeSessionTabId && styles.tabTextActive]}
numberOfLines={1}
>
{entry.title}
{getMobileSessionTabTitle(t)}
</Text>
</View>
</Pressable>
@@ -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.
@@ -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')
})
})
@@ -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…'
}
@@ -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)
})
})
@@ -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',
@@ -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<string>([
'terminal',
'markdown',
'file',
'browser',
'agent-session'
] satisfies readonly MobileSessionTabType[])
export function isDrawableTabStripType(type: string): type is MobileSessionTabType {
return drawableTabTypes.has(type)
}
const agentDisplayNames: Readonly<Record<string, string>> = 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<MobileSessionTabStripEntry, 'type' | 'title' | 'agentId'>
): 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
}))
}
@@ -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)
@@ -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<typeof useMobileSessionPresentation>
@@ -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<typeof useMobileSessionTabStripCache>
@@ -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)
})
})
@@ -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)
}
@@ -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()
})
})
@@ -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)
}