fix(mobile): preserve unsent terminal commands across workspace navigation (#31314)

Persist unsent buffered terminal commands per paired host, workspace, execution host, and surface across navigation. Preserve newer typing during storage hydration and clear successfully sent drafts.

Validation: draft/storage/bridge tests, signed iPhone simulator build, and all CI checks passed.
This commit is contained in:
Neil
2026-10-20 19:31:05 -07:00
committed by GitHub
parent c6becfaf7a
commit 513c24bc16
6 changed files with 423 additions and 10 deletions
@@ -57,11 +57,15 @@ describe('the keys a page may read and write', () => {
}
})
it("adds the session route's two workspace-scoped keys, and only for it", () => {
it("adds the session route's workspace-scoped keys, and only for it", () => {
const session = pageStorageKeysForRoute('host-1', SESSION_ROUTE)
expect(
session.filter((key) => !pageStorageKeysForRoute('host-1', HOST_ROUTE).includes(key))
).toEqual(['orca:nativeChatTabs:host-1:wt-1', 'orca:terminalLiveInputDisabled:host-1:wt-1'])
).toEqual([
'orca:nativeChatTabs:host-1:wt-1',
'orca:terminalLiveInputDisabled:host-1:wt-1',
'orca:terminalDrafts:host-1:wt-1'
])
// A route that carries a worktree segment and declares no key of its own gets none.
expect(pageStorageKeysForRoute('host-1', '/h/host-1/files/wt-1')).toEqual(
pageStorageKeysForRoute('host-1', HOST_ROUTE)
@@ -45,7 +45,9 @@ export const PAGE_STORAGE_KEY_PREFIXES = [
/** `orca:nativeChatTabs:<hostId>:<worktreeId>`: which tabs of one workspace show the chat. */
'orca:nativeChatTabs:',
/** `orca:terminalLiveInputDisabled:<hostId>:<worktreeId>`: the handles typing goes around. */
'orca:terminalLiveInputDisabled:'
'orca:terminalLiveInputDisabled:',
/** Unsent command drafts, scoped to a host and workspace. */
'orca:terminalDrafts:'
] as const
/** Long enough for a host's pinned ids, and far short of what a quota refuses. */
@@ -98,9 +100,13 @@ export function pageRouteWorkspace(
}
}
const WORKSPACE_KEY_PREFIXES = ['orca:nativeChatTabs:', 'orca:terminalLiveInputDisabled:'] as const
const WORKSPACE_KEY_PREFIXES = [
'orca:nativeChatTabs:',
'orca:terminalLiveInputDisabled:',
'orca:terminalDrafts:'
] as const
/** Both workspace-scoped keys are built the same way, so the shape is written once. */
/** Workspace-scoped keys are built the same way, so the shape is written once. */
function workspaceScopedKey(prefix: string, hostId: string, worktreeId: string): string {
return `${prefix}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}`
}
@@ -1,7 +1,7 @@
import type { PendingSessionSelection } from './pending-session-selection'
import { useState, useRef, useCallback } from 'react'
import type { TerminalFrame } from '../terminal/terminal-webview-messages'
import type { Keyboard, TextInput } from 'react-native'
import { Alert, type Keyboard, type TextInput } from 'react-native'
import { useFocusEffect } from 'expo-router'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
@@ -14,6 +14,7 @@ import { resolveMobileTerminalInputGate } from '../terminal/terminal-input-conne
import { createInitialSessionAutoCreateState } from './use-initial-session-terminal-autocreate'
import { TerminalViewportResubscribeBudget } from './mobile-terminal-viewport-resubscribe'
import { MobileTerminalDiagnostics } from './mobile-terminal-diagnostics'
import { useWorkspaceExecutionHost } from '../navigation/workspace-execution-host'
import { useBufferedTerminalDrafts } from '../terminal/use-buffered-terminal-drafts'
import { useMobileTerminalInventoryRecoveryBridge } from './use-mobile-terminal-inventory-recovery'
import type {
@@ -80,7 +81,21 @@ export function useMobileSessionTerminalRuntime(scope: MobileSessionScreenStateM
// Why: don't subscribe until the WebView fires web-ready — iOS may defer JS in hidden WebViews and init() messages would queue unrendered.
const webReadyHandlesRef = useRef<Set<string>>(new Set())
const activeHandleRef = useRef<string | null>(null)
const bufferedTerminalDraftState = useBufferedTerminalDrafts({ activeHandle, activeHandleRef })
const executionHost = useWorkspaceExecutionHost()
const bufferedTerminalDraftState = useBufferedTerminalDrafts({
activeHandle,
activeHandleRef,
storageKey:
hostId && worktreeId
? `orca:terminalDrafts:${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}`
: undefined,
storageScope: executionHost,
onStorageError: () =>
Alert.alert(
'Draft could not be saved',
'Keep this screen open to avoid losing the command draft.'
)
})
const reconcileBufferedDraftsRef = useRef(bufferedTerminalDraftState.reconcileTerminalTabs)
const activeSessionTabTypeRef = useRef<MobileSessionTabType | null>(null)
const pendingSelectionRef = useRef<PendingSessionSelection | null>(null)
@@ -0,0 +1,159 @@
import { createElement, useEffect, useRef } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, expect, it, vi } from 'vitest'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { useBufferedTerminalDrafts } from './use-buffered-terminal-drafts'
const disk = vi.hoisted(() => new Map<string, string>())
vi.mock('@react-native-async-storage/async-storage', () => ({
default: {
getItem: vi.fn(async (key: string) => disk.get(key) ?? null),
setItem: vi.fn(async (key: string, value: string) => {
disk.set(key, value)
}),
removeItem: vi.fn(async (key: string) => {
disk.delete(key)
})
}
}))
const key = 'orca:terminalDrafts:host:folder-workspace'
let current: ReturnType<typeof useBufferedTerminalDrafts> | null = null
let tree: ReactTestRenderer | null = null
function Probe({ scope = 'paired', handle = 'terminal-a' }: { scope?: string; handle?: string }) {
const activeHandleRef = useRef<string | null>(handle)
useEffect(() => {
activeHandleRef.current = handle
}, [handle])
current = useBufferedTerminalDrafts({
activeHandle: handle,
activeHandleRef,
storageKey: key,
storageScope: scope
})
useEffect(() => {
current?.resetDrafts()
}, [])
return null
}
function hook() {
if (!current) {
throw new Error('Missing probe')
}
return current
}
async function mount(scope = 'paired', handle = 'terminal-a') {
await act(async () => {
tree = create(createElement(Probe, { scope, handle }))
})
await act(async () =>
hook().reconcileTerminalTabs([], [{ id: 'tab-a', leafId: 'leaf-a', terminal: handle }])
)
}
async function leave() {
await act(async () => {
tree?.unmount()
tree = null
})
}
afterEach(async () => {
await leave()
current = null
disk.clear()
vi.clearAllMocks()
})
it('restores an unsent command after leaving and remounting the workspace', async () => {
await mount()
await act(async () => hook().setInput('Review release plan'))
await leave()
await mount()
expect(hook().input).toBe('Review release plan')
})
it('restores to the same surface when a terminal handle has been reminted', async () => {
await mount()
await act(async () => hook().setInput('Keep this draft'))
await leave()
await mount('paired', 'terminal-new')
expect(hook().input).toBe('Keep this draft')
})
it('isolates execution hosts and preserves both drafts in the same workspace key', async () => {
await mount('ssh-one')
await act(async () => hook().setInput('First host draft'))
await leave()
await mount('ssh-two')
expect(hook().input).toBe('')
await act(async () => hook().setInput('Second host draft'))
await leave()
await mount('ssh-one')
expect(hook().input).toBe('First host draft')
await leave()
await mount('ssh-two')
expect(hook().input).toBe('Second host draft')
})
it('does not resurrect a sent command after a remount', async () => {
await mount()
await act(async () => hook().setInput('echo sent'))
await act(async () => {
const send = hook().beginBufferedTerminalDraftSend('terminal-a', hook().input)
hook().settleBufferedTerminalDraftSend(send)
})
await leave()
await mount()
expect(hook().input).toBe('')
})
it('keeps a rejected send available after leaving the workspace', async () => {
await mount()
await act(async () => hook().setInput('echo retry'))
await act(async () => {
const send = hook().beginBufferedTerminalDraftSend('terminal-a', hook().input)
hook().restoreRejectedDraft(send)
})
await leave()
await mount()
expect(hook().input).toBe('echo retry')
})
it('does not replace input typed while storage hydration is still pending', async () => {
disk.set(
key,
JSON.stringify({
drafts: { 'paired:old': 'Stale text' },
surfaces: { 'paired:leaf:leaf-a': 'old' }
})
)
let resolveRead: ((value: string | null) => void) | undefined
vi.mocked(AsyncStorage.getItem).mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRead = resolve
})
)
act(() => {
tree = create(createElement(Probe, {}))
})
await act(async () => {})
act(() => {
hook().reconcileTerminalTabs([], [{ id: 'tab-a', leafId: 'leaf-a', terminal: 'terminal-a' }])
hook().setInput('New text')
})
await act(async () => {
resolveRead?.(disk.get(key) ?? null)
})
expect(hook().input).toBe('New text')
})
it('prunes a closed tab rather than restoring its draft on another terminal', async () => {
await mount()
await act(async () => hook().setInput('Closed tab draft'))
await act(async () =>
hook().reconcileTerminalTabs([{ id: 'tab-a', leafId: 'leaf-a', terminal: 'terminal-a' }], [])
)
await leave()
await mount()
expect(hook().input).toBe('')
})
@@ -1,4 +1,5 @@
import { useCallback, useRef, useState } from 'react'
import { useStoredBufferedTerminalDrafts } from './use-stored-buffered-terminal-drafts'
import { useCallback, useRef } from 'react'
import type { RefObject } from 'react'
import {
type BufferedTerminalDraftRestorationToken,
@@ -21,6 +22,9 @@ interface BufferedTerminalDraftSend {
}
interface UseBufferedTerminalDraftsOptions {
readonly storageScope?: string
readonly storageKey?: string
readonly onStorageError?: () => void
readonly activeHandle: string | null
readonly activeHandleRef: RefObject<string | null>
}
@@ -42,13 +46,23 @@ function getBufferedTerminalDraftSurfaceKey(tab: BufferedTerminalDraftTab): stri
export function useBufferedTerminalDrafts({
activeHandle,
activeHandleRef
activeHandleRef,
storageKey,
storageScope,
onStorageError
}: UseBufferedTerminalDraftsOptions) {
const [drafts, setDrafts] = useState<Record<string, string>>({})
const pendingRestorationsRef = useRef<Map<string, BufferedTerminalDraftRestorationToken>>(
new Map()
)
const handlesBySurfaceRef = useRef<Map<string, string>>(new Map())
const reconciledRef = useRef(false)
const [drafts, setDrafts] = useStoredBufferedTerminalDrafts(
storageKey,
handlesBySurfaceRef,
reconciledRef,
onStorageError,
storageScope
)
const input = activeHandle ? (drafts[activeHandle] ?? '') : ''
const setInput = useCallback(
@@ -112,6 +126,7 @@ export function useBufferedTerminalDrafts({
nextTabs: readonly BufferedTerminalDraftTab[],
{ retainMissingSurfaces = false }: ReconcileBufferedTerminalDraftTabsOptions = {}
): void => {
reconciledRef.current = true
const handlesBySurface = handlesBySurfaceRef.current
for (const tab of previousTabs) {
if (tab.type && tab.type !== 'terminal') {
@@ -175,6 +190,7 @@ export function useBufferedTerminalDrafts({
const resetDrafts = useCallback((): void => {
pendingRestorationsRef.current.clear()
handlesBySurfaceRef.current.clear()
reconciledRef.current = false
setDrafts((current) => (Object.keys(current).length === 0 ? current : {}))
}, [])
@@ -0,0 +1,213 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import {
useCallback,
useEffect,
useRef,
useState,
type RefObject,
type SetStateAction
} from 'react'
import { persistMirrored } from '../storage/mirrored-storage-keys'
type Drafts = Record<string, string>
type StoredDrafts = { drafts: Drafts; surfaces: Record<string, string> }
const writes = new Map<string, Promise<void>>()
function parseStoredDrafts(raw: string | null): StoredDrafts {
try {
const value: unknown = JSON.parse(raw ?? '{}')
if (typeof value !== 'object' || value === null) {
return { drafts: {}, surfaces: {} }
}
const record = (field: unknown): Drafts =>
typeof field === 'object' && field !== null
? Object.fromEntries(
Object.entries(field).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string'
)
)
: {}
return {
drafts: record('drafts' in value ? value.drafts : null),
surfaces: record('surfaces' in value ? value.surfaces : null)
}
} catch {
return { drafts: {}, surfaces: {} }
}
}
export function useStoredBufferedTerminalDrafts(
storageKey: string | undefined,
surfaces: RefObject<Map<string, string>>,
reconciled: RefObject<boolean>,
onStorageError?: () => void,
storageScope = 'paired'
) {
const identity = `${storageKey ?? ''}:${encodeURIComponent(storageScope)}`
const [state, setState] = useState<{
key: string | undefined
drafts: Drafts
loaded: boolean
edited: string[]
}>({
key: identity,
drafts: {},
loaded: !storageKey,
edited: []
})
const scopePrefix = `${encodeURIComponent(storageScope)}:`
const warned = useRef(false)
const errorRef = useRef(onStorageError)
const reportError = useCallback(() => {
if (!warned.current) {
warned.current = true
errorRef.current?.()
}
}, [])
useEffect(() => {
errorRef.current = onStorageError
}, [onStorageError])
useEffect(() => {
surfaces.current.clear()
reconciled.current = false
warned.current = false
}, [identity, surfaces, reconciled])
if (state.key !== identity) {
setState({ key: identity, drafts: {}, loaded: !storageKey, edited: [] })
}
const setDrafts = useCallback((update: SetStateAction<Drafts>) => {
setState((current) => {
const next = typeof update === 'function' ? update(current.drafts) : update
if (next === current.drafts) {
return current
}
const changed = [...new Set([...Object.keys(current.drafts), ...Object.keys(next)])].filter(
(handle) => current.drafts[handle] !== next[handle]
)
return { ...current, drafts: next, edited: [...new Set([...current.edited, ...changed])] }
})
}, [])
useEffect(() => {
if (!storageKey) {
return
}
let cancelled = false
void (writes.get(storageKey) ?? Promise.resolve())
.then(() => AsyncStorage.getItem(storageKey))
.then((raw) => {
if (cancelled) {
return
}
const stored = parseStoredDrafts(raw)
const scopeDrafts = Object.fromEntries(
Object.entries(stored.drafts)
.filter(([handle]) => handle.startsWith(scopePrefix))
.map(([handle, text]) => [handle.slice(scopePrefix.length), text])
)
const scopeSurfaces = Object.fromEntries(
Object.entries(stored.surfaces)
.filter(([surface]) => surface.startsWith(scopePrefix))
.map(([surface, handle]) => [surface.slice(scopePrefix.length), handle])
)
const restored: Drafts = {}
for (const [surface, handle] of Object.entries(scopeSurfaces)) {
const target = surfaces.current.get(surface)
if (!target && reconciled.current) {
continue
}
if (!target) {
surfaces.current.set(surface, handle)
}
const nextHandle = target ?? handle
if (scopeDrafts[handle]) {
restored[nextHandle] = scopeDrafts[handle]
}
}
setState((current) => {
const edited = new Set(current.edited)
return current.key !== identity
? current
: {
...current,
loaded: true,
drafts: {
...Object.fromEntries(
Object.entries(restored).filter(([handle]) => !edited.has(handle))
),
...current.drafts
}
}
})
})
.catch(() => {
if (!cancelled) {
reportError()
}
})
return () => {
cancelled = true
}
}, [storageKey, surfaces, reconciled, scopePrefix, reportError, identity])
useEffect(() => {
if (!storageKey || state.key !== identity || !state.loaded) {
return
}
const drafts = Object.fromEntries(
Object.entries(state.drafts).filter(([, text]) => text.length > 0)
)
const surfaceEntries = Object.fromEntries(
[...surfaces.current].filter(([, handle]) => Object.hasOwn(drafts, handle))
)
const write = (writes.get(storageKey) ?? Promise.resolve()).then(async () => {
const previousRaw = await AsyncStorage.getItem(storageKey)
const stored = parseStoredDrafts(previousRaw)
const otherDrafts = Object.fromEntries(
Object.entries(stored.drafts).filter(([handle]) => !handle.startsWith(scopePrefix))
)
const otherSurfaces = Object.fromEntries(
Object.entries(stored.surfaces).filter(([surface]) => !surface.startsWith(scopePrefix))
)
const raw = JSON.stringify({
drafts: {
...otherDrafts,
...Object.fromEntries(
Object.entries(drafts).map(([handle, text]) => [scopePrefix + handle, text])
)
},
surfaces: {
...otherSurfaces,
...Object.fromEntries(
Object.entries(surfaceEntries).map(([surface, handle]) => [
scopePrefix + surface,
handle
])
)
}
})
if (
Object.keys(drafts).length === 0 &&
Object.keys(otherDrafts).length === 0 &&
previousRaw === null
) {
return
}
if (raw !== previousRaw) {
await persistMirrored(storageKey, raw)
if ((await AsyncStorage.getItem(storageKey)) !== raw) {
throw new Error('Command draft storage refused the write')
}
}
})
const barrier = write.catch(reportError)
writes.set(storageKey, barrier)
void barrier.then(() => {
if (writes.get(storageKey) === barrier) {
writes.delete(storageKey)
}
})
}, [storageKey, state, surfaces, scopePrefix, identity, reportError])
return [state.key === identity ? state.drafts : {}, setDrafts] as const
}