mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user