mirror of
https://github.com/stablyai/orca.git
synced 2026-10-05 16:02:22 +00:00
Revert "feat(mobile): draw the last known tab strip while a session reconnects (#19258)"
This reverts commit 0ba7f8dc8d.
This commit is contained in:
-282
@@ -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
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user