diff --git a/mobile/src/mobile-web-shell/page-storage-keys.test.ts b/mobile/src/mobile-web-shell/page-storage-keys.test.ts index 50be71525e3..103a74cc280 100644 --- a/mobile/src/mobile-web-shell/page-storage-keys.test.ts +++ b/mobile/src/mobile-web-shell/page-storage-keys.test.ts @@ -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) diff --git a/mobile/src/mobile-web-shell/page-storage-keys.ts b/mobile/src/mobile-web-shell/page-storage-keys.ts index 8c86a9047c5..c9e1bbbaf42 100644 --- a/mobile/src/mobile-web-shell/page-storage-keys.ts +++ b/mobile/src/mobile-web-shell/page-storage-keys.ts @@ -45,7 +45,9 @@ export const PAGE_STORAGE_KEY_PREFIXES = [ /** `orca:nativeChatTabs::`: which tabs of one workspace show the chat. */ 'orca:nativeChatTabs:', /** `orca:terminalLiveInputDisabled::`: 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)}` } diff --git a/mobile/src/session/use-mobile-session-terminal-runtime.ts b/mobile/src/session/use-mobile-session-terminal-runtime.ts index f6305969960..eaf3cd9f01d 100644 --- a/mobile/src/session/use-mobile-session-terminal-runtime.ts +++ b/mobile/src/session/use-mobile-session-terminal-runtime.ts @@ -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>(new Set()) const activeHandleRef = useRef(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(null) const pendingSelectionRef = useRef(null) diff --git a/mobile/src/terminal/use-buffered-terminal-drafts-storage.test.tsx b/mobile/src/terminal/use-buffered-terminal-drafts-storage.test.tsx new file mode 100644 index 00000000000..198457353a0 --- /dev/null +++ b/mobile/src/terminal/use-buffered-terminal-drafts-storage.test.tsx @@ -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()) +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 | null = null +let tree: ReactTestRenderer | null = null +function Probe({ scope = 'paired', handle = 'terminal-a' }: { scope?: string; handle?: string }) { + const activeHandleRef = useRef(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('') +}) diff --git a/mobile/src/terminal/use-buffered-terminal-drafts.ts b/mobile/src/terminal/use-buffered-terminal-drafts.ts index 58c154fb4c3..c39da6ed1cf 100644 --- a/mobile/src/terminal/use-buffered-terminal-drafts.ts +++ b/mobile/src/terminal/use-buffered-terminal-drafts.ts @@ -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 } @@ -42,13 +46,23 @@ function getBufferedTerminalDraftSurfaceKey(tab: BufferedTerminalDraftTab): stri export function useBufferedTerminalDrafts({ activeHandle, - activeHandleRef + activeHandleRef, + storageKey, + storageScope, + onStorageError }: UseBufferedTerminalDraftsOptions) { - const [drafts, setDrafts] = useState>({}) const pendingRestorationsRef = useRef>( new Map() ) const handlesBySurfaceRef = useRef>(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 : {})) }, []) diff --git a/mobile/src/terminal/use-stored-buffered-terminal-drafts.ts b/mobile/src/terminal/use-stored-buffered-terminal-drafts.ts new file mode 100644 index 00000000000..93962ab5d47 --- /dev/null +++ b/mobile/src/terminal/use-stored-buffered-terminal-drafts.ts @@ -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 +type StoredDrafts = { drafts: Drafts; surfaces: Record } +const writes = new Map>() + +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>, + reconciled: RefObject, + 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) => { + 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 +}