mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(mobile): preserve terminal input mode on reentry (#7129)
Fixes #6972.\n\nPreserves mobile terminal buffered/live input mode across Android terminal re-entry and session refreshes. Includes follow-up hardening for pre-hydration preference edits and failed storage reads.
This commit is contained in:
@@ -96,9 +96,7 @@ import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/termi
|
||||
import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target'
|
||||
import {
|
||||
clearTerminalLiveInputFocusTimer,
|
||||
defaultTerminalLiveInputHandles,
|
||||
isTerminalLiveInputWithinByteLimit,
|
||||
pruneTerminalLiveInputHandles,
|
||||
scheduleTerminalLiveInputFocus
|
||||
} from '../../../../src/terminal/terminal-live-input'
|
||||
import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender'
|
||||
@@ -165,6 +163,7 @@ import {
|
||||
} from '../../../../src/session/mobile-new-tab-agent-options'
|
||||
import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
|
||||
import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
|
||||
import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference'
|
||||
import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus'
|
||||
import { MobileTerminalInputActions } from '../../../../src/session/MobileTerminalInputActions'
|
||||
import { classifyMobileArtifact } from '../../../../src/session/mobile-artifact-kind'
|
||||
@@ -896,11 +895,14 @@ export default function SessionScreen() {
|
||||
const [terminalLinkOpenMode, setTerminalLinkOpenMode] =
|
||||
useState<MobileTerminalLinkOpenMode>('orca-browser')
|
||||
const [liveInputCapture, setLiveInputCapture] = useState('')
|
||||
const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const liveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const defaultedLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const {
|
||||
clearTerminalLiveInputDefault,
|
||||
defaultTerminalHandlesToLiveInput,
|
||||
liveInputTerminalHandles,
|
||||
liveInputTerminalHandlesRef,
|
||||
pruneTerminalHandlesFromLiveInput,
|
||||
toggleTerminalLiveInput
|
||||
} = useTerminalLiveInputModePreference({ hostId, worktreeId })
|
||||
const [activeHandle, setActiveHandle] = useState<string | null>(null)
|
||||
const [activeSessionTabId, setActiveSessionTabId] = useState<string | null>(null)
|
||||
const activeSessionTabIdRef = useRef<string | null>(null)
|
||||
@@ -1104,7 +1106,6 @@ export default function SessionScreen() {
|
||||
sessionTabsRef.current = sessionTabs
|
||||
activeSessionTabIdRef.current = activeSessionTabId
|
||||
markdownDocsRef.current = markdownDocs
|
||||
liveInputTerminalHandlesRef.current = liveInputTerminalHandles
|
||||
const reconciledCreateWarningState = reconcileMobileSessionCreateWarningState(
|
||||
createWarningState,
|
||||
initialCreateWarning
|
||||
@@ -1170,52 +1171,6 @@ export default function SessionScreen() {
|
||||
[clearToastHideTimer]
|
||||
)
|
||||
|
||||
// Why: direct input is now the mobile default, but only once per discovered
|
||||
// handle so a user's buffered-mode toggle survives tab/list refreshes.
|
||||
const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => {
|
||||
const result = defaultTerminalLiveInputHandles(
|
||||
liveInputTerminalHandlesRef.current,
|
||||
defaultedLiveInputTerminalHandlesRef.current,
|
||||
handles
|
||||
)
|
||||
if (!result.changed) {
|
||||
return
|
||||
}
|
||||
const nextEnabledHandles = new Set(result.enabledHandles)
|
||||
const nextDefaultedHandles = new Set(result.defaultedHandles)
|
||||
liveInputTerminalHandlesRef.current = nextEnabledHandles
|
||||
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
|
||||
setLiveInputTerminalHandles(nextEnabledHandles)
|
||||
}, [])
|
||||
|
||||
const pruneTerminalHandlesFromLiveInput = useCallback((liveHandles: ReadonlySet<string>) => {
|
||||
const result = pruneTerminalLiveInputHandles(
|
||||
liveInputTerminalHandlesRef.current,
|
||||
defaultedLiveInputTerminalHandlesRef.current,
|
||||
liveHandles
|
||||
)
|
||||
if (!result.changed) {
|
||||
return
|
||||
}
|
||||
const nextEnabledHandles = new Set(result.enabledHandles)
|
||||
const nextDefaultedHandles = new Set(result.defaultedHandles)
|
||||
liveInputTerminalHandlesRef.current = nextEnabledHandles
|
||||
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
|
||||
setLiveInputTerminalHandles(nextEnabledHandles)
|
||||
}, [])
|
||||
|
||||
const clearTerminalLiveInputDefault = useCallback(
|
||||
(handle: string) => {
|
||||
const liveHandles = new Set([
|
||||
...liveInputTerminalHandlesRef.current,
|
||||
...defaultedLiveInputTerminalHandlesRef.current
|
||||
])
|
||||
liveHandles.delete(handle)
|
||||
pruneTerminalHandlesFromLiveInput(liveHandles)
|
||||
},
|
||||
[pruneTerminalHandlesFromLiveInput]
|
||||
)
|
||||
|
||||
const dictation = useMobileDictation({
|
||||
client,
|
||||
enabled: canSend,
|
||||
@@ -2685,9 +2640,6 @@ export default function SessionScreen() {
|
||||
setSessionTabs([])
|
||||
setActiveSessionTabId(null)
|
||||
clearPendingLiveInputCommit()
|
||||
liveInputTerminalHandlesRef.current = new Set()
|
||||
defaultedLiveInputTerminalHandlesRef.current = new Set()
|
||||
setLiveInputTerminalHandles(new Set())
|
||||
setMarkdownDocs(new Map())
|
||||
setFileDocs(new Map())
|
||||
clearDelayedActionTimers()
|
||||
@@ -3362,17 +3314,7 @@ export default function SessionScreen() {
|
||||
if (!activeHandle) {
|
||||
return
|
||||
}
|
||||
const nextEnabled = !liveInputTerminalHandles.has(activeHandle)
|
||||
setLiveInputTerminalHandles((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (nextEnabled) {
|
||||
next.add(activeHandle)
|
||||
} else {
|
||||
next.delete(activeHandle)
|
||||
}
|
||||
liveInputTerminalHandlesRef.current = next
|
||||
return next
|
||||
})
|
||||
const nextEnabled = toggleTerminalLiveInput(activeHandle)
|
||||
clearPendingLiveInputCommit()
|
||||
if (nextEnabled) {
|
||||
scheduleTerminalLiveInputFocus(liveInputFocusTimerRef, () => liveInputRef.current?.focus())
|
||||
@@ -3380,7 +3322,7 @@ export default function SessionScreen() {
|
||||
clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef)
|
||||
liveInputRef.current?.blur()
|
||||
}
|
||||
}, [activeHandle, clearPendingLiveInputCommit, liveInputTerminalHandles])
|
||||
}, [activeHandle, clearPendingLiveInputCommit, toggleTerminalLiveInput])
|
||||
|
||||
const allowTerminalGestureInput = useCallback(
|
||||
(handle: string, sequenceCount: number): boolean => {
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
readDisabledTerminalLiveInputHandlesPreference,
|
||||
saveDisabledTerminalLiveInputHandles,
|
||||
type DisabledTerminalLiveInputHandlesPreference
|
||||
} from '../storage/preferences'
|
||||
import { useTerminalLiveInputModePreference } from './use-terminal-live-input-mode-preference'
|
||||
|
||||
vi.mock('../storage/preferences', () => ({
|
||||
readDisabledTerminalLiveInputHandlesPreference: vi.fn(),
|
||||
saveDisabledTerminalLiveInputHandles: vi.fn()
|
||||
}))
|
||||
|
||||
type TerminalLiveInputModePreferenceHarness = {
|
||||
readonly current: ReturnType<typeof useTerminalLiveInputModePreference>
|
||||
readonly unmount: () => void
|
||||
}
|
||||
|
||||
type Deferred<T> = {
|
||||
readonly promise: Promise<T>
|
||||
readonly resolve: (value: T) => void
|
||||
}
|
||||
|
||||
function createDeferred<T>(): Deferred<T> {
|
||||
let resolve: ((value: T) => void) | null = null
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve
|
||||
})
|
||||
if (!resolve) {
|
||||
throw new Error('deferred resolver was not initialized')
|
||||
}
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
const firstArg = args[0]
|
||||
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
originalConsoleError(...args)
|
||||
})
|
||||
return () => consoleErrorSpy.mockRestore()
|
||||
}
|
||||
|
||||
function createTerminalLiveInputModePreferenceHarness(): TerminalLiveInputModePreferenceHarness {
|
||||
let current: ReturnType<typeof useTerminalLiveInputModePreference> | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function Harness(): null {
|
||||
current = useTerminalLiveInputModePreference({
|
||||
hostId: 'host-1',
|
||||
worktreeId: 'worktree-1'
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
}
|
||||
if (!current || !renderer) {
|
||||
throw new Error('terminal live input mode preference hook did not render')
|
||||
}
|
||||
|
||||
return {
|
||||
get current() {
|
||||
if (!current) {
|
||||
throw new Error('terminal live input mode preference hook is not mounted')
|
||||
}
|
||||
return current
|
||||
},
|
||||
unmount: () => {
|
||||
act(() => renderer?.unmount())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal live input mode preference hook', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReset()
|
||||
vi.mocked(saveDisabledTerminalLiveInputHandles).mockReset()
|
||||
vi.mocked(saveDisabledTerminalLiveInputHandles).mockResolvedValue()
|
||||
})
|
||||
|
||||
it('merges pre-hydration edits with loaded disabled handles', async () => {
|
||||
const load = createDeferred<DisabledTerminalLiveInputHandlesPreference>()
|
||||
vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise)
|
||||
const harness = createTerminalLiveInputModePreferenceHarness()
|
||||
|
||||
act(() => {
|
||||
harness.current.defaultTerminalHandlesToLiveInput(['pty-1', 'pty-2'])
|
||||
})
|
||||
act(() => {
|
||||
expect(harness.current.toggleTerminalLiveInput('pty-1')).toBe(true)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
load.resolve({ handles: new Set(['pty-2']), loaded: true })
|
||||
await load.promise
|
||||
})
|
||||
|
||||
expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1'])
|
||||
expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledTimes(1)
|
||||
expect(saveDisabledTerminalLiveInputHandles).toHaveBeenCalledWith(
|
||||
'host-1',
|
||||
'worktree-1',
|
||||
new Set(['pty-2'])
|
||||
)
|
||||
harness.unmount()
|
||||
})
|
||||
|
||||
it('does not persist fallback-empty storage reads during clean hydration', async () => {
|
||||
const load = createDeferred<DisabledTerminalLiveInputHandlesPreference>()
|
||||
vi.mocked(readDisabledTerminalLiveInputHandlesPreference).mockReturnValue(load.promise)
|
||||
const harness = createTerminalLiveInputModePreferenceHarness()
|
||||
|
||||
act(() => {
|
||||
harness.current.defaultTerminalHandlesToLiveInput(['pty-1'])
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
load.resolve({ handles: new Set(), loaded: false })
|
||||
await load.promise
|
||||
})
|
||||
|
||||
expect([...harness.current.liveInputTerminalHandles]).toEqual(['pty-1'])
|
||||
expect(saveDisabledTerminalLiveInputHandles).not.toHaveBeenCalled()
|
||||
harness.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,212 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
readDisabledTerminalLiveInputHandlesPreference,
|
||||
saveDisabledTerminalLiveInputHandles
|
||||
} from '../storage/preferences'
|
||||
import {
|
||||
applyDisabledTerminalLiveInputHandles,
|
||||
defaultTerminalLiveInputHandles,
|
||||
filterTerminalLiveInputDefaultCandidates,
|
||||
pruneTerminalLiveInputHandles
|
||||
} from '../terminal/terminal-live-input'
|
||||
|
||||
type UseTerminalLiveInputModePreferenceOptions = {
|
||||
readonly hostId: string
|
||||
readonly worktreeId: string
|
||||
}
|
||||
|
||||
export function useTerminalLiveInputModePreference({
|
||||
hostId,
|
||||
worktreeId
|
||||
}: UseTerminalLiveInputModePreferenceOptions) {
|
||||
const [liveInputTerminalHandles, setLiveInputTerminalHandles] = useState<Set<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
const liveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const defaultedLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const disabledLiveInputTerminalHandlesRef = useRef<Set<string>>(new Set())
|
||||
const disabledLiveInputHydratedRef = useRef(false)
|
||||
const pendingDisabledLiveInputHydrationEditsRef = useRef<Map<string, boolean>>(new Map())
|
||||
const pendingLiveInputDefaultHandlesRef = useRef<Set<string>>(new Set())
|
||||
|
||||
const defaultTerminalHandlesToLiveInput = useCallback((handles: readonly string[]) => {
|
||||
// Why: terminal discovery (tab snapshots, list poll, create) can arrive
|
||||
// before the async persisted-disabled load on worktree re-entry.
|
||||
if (!disabledLiveInputHydratedRef.current) {
|
||||
for (const handle of handles) {
|
||||
pendingLiveInputDefaultHandlesRef.current.add(handle)
|
||||
}
|
||||
return
|
||||
}
|
||||
const defaultableHandles = filterTerminalLiveInputDefaultCandidates(
|
||||
handles,
|
||||
disabledLiveInputTerminalHandlesRef.current
|
||||
)
|
||||
const result = defaultTerminalLiveInputHandles(
|
||||
liveInputTerminalHandlesRef.current,
|
||||
defaultedLiveInputTerminalHandlesRef.current,
|
||||
defaultableHandles
|
||||
)
|
||||
if (!result.changed) {
|
||||
return
|
||||
}
|
||||
const nextEnabledHandles = new Set(result.enabledHandles)
|
||||
const nextDefaultedHandles = new Set(result.defaultedHandles)
|
||||
liveInputTerminalHandlesRef.current = nextEnabledHandles
|
||||
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
|
||||
setLiveInputTerminalHandles(nextEnabledHandles)
|
||||
}, [])
|
||||
|
||||
const persistDisabledLiveInputHandles = useCallback(() => {
|
||||
void saveDisabledTerminalLiveInputHandles(
|
||||
hostId,
|
||||
worktreeId,
|
||||
disabledLiveInputTerminalHandlesRef.current
|
||||
).catch(() => {})
|
||||
}, [hostId, worktreeId])
|
||||
|
||||
const pruneTerminalHandlesFromLiveInput = useCallback(
|
||||
(liveHandles: ReadonlySet<string>) => {
|
||||
const result = pruneTerminalLiveInputHandles(
|
||||
liveInputTerminalHandlesRef.current,
|
||||
defaultedLiveInputTerminalHandlesRef.current,
|
||||
liveHandles
|
||||
)
|
||||
let prunedDisabledHandles = false
|
||||
for (const handle of disabledLiveInputTerminalHandlesRef.current) {
|
||||
if (liveHandles.has(handle)) {
|
||||
continue
|
||||
}
|
||||
disabledLiveInputTerminalHandlesRef.current.delete(handle)
|
||||
if (!disabledLiveInputHydratedRef.current) {
|
||||
pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false)
|
||||
}
|
||||
prunedDisabledHandles = true
|
||||
}
|
||||
if (prunedDisabledHandles && disabledLiveInputHydratedRef.current) {
|
||||
persistDisabledLiveInputHandles()
|
||||
}
|
||||
if (!result.changed) {
|
||||
return
|
||||
}
|
||||
const nextEnabledHandles = new Set(result.enabledHandles)
|
||||
const nextDefaultedHandles = new Set(result.defaultedHandles)
|
||||
liveInputTerminalHandlesRef.current = nextEnabledHandles
|
||||
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
|
||||
setLiveInputTerminalHandles(nextEnabledHandles)
|
||||
},
|
||||
[persistDisabledLiveInputHandles]
|
||||
)
|
||||
|
||||
const clearTerminalLiveInputDefault = useCallback(
|
||||
(handle: string) => {
|
||||
const liveHandles = new Set([
|
||||
...liveInputTerminalHandlesRef.current,
|
||||
...defaultedLiveInputTerminalHandlesRef.current
|
||||
])
|
||||
liveHandles.delete(handle)
|
||||
if (!disabledLiveInputHydratedRef.current) {
|
||||
pendingDisabledLiveInputHydrationEditsRef.current.set(handle, false)
|
||||
}
|
||||
if (disabledLiveInputTerminalHandlesRef.current.delete(handle)) {
|
||||
if (disabledLiveInputHydratedRef.current) {
|
||||
persistDisabledLiveInputHandles()
|
||||
}
|
||||
}
|
||||
pruneTerminalHandlesFromLiveInput(liveHandles)
|
||||
},
|
||||
[persistDisabledLiveInputHandles, pruneTerminalHandlesFromLiveInput]
|
||||
)
|
||||
|
||||
const toggleTerminalLiveInput = useCallback(
|
||||
(handle: string): boolean => {
|
||||
const nextEnabled = !liveInputTerminalHandlesRef.current.has(handle)
|
||||
if (nextEnabled) {
|
||||
disabledLiveInputTerminalHandlesRef.current.delete(handle)
|
||||
} else {
|
||||
disabledLiveInputTerminalHandlesRef.current.add(handle)
|
||||
}
|
||||
// Why: pre-hydration edits must patch the loaded set per handle; replacing
|
||||
// the loaded set would erase other persisted opt-outs for this worktree.
|
||||
if (!disabledLiveInputHydratedRef.current) {
|
||||
pendingDisabledLiveInputHydrationEditsRef.current.set(handle, !nextEnabled)
|
||||
}
|
||||
// Why: only persist after hydration; an earlier write would use the
|
||||
// reset-empty ref and overwrite other handles' opt-outs for the worktree.
|
||||
if (disabledLiveInputHydratedRef.current) {
|
||||
persistDisabledLiveInputHandles()
|
||||
}
|
||||
setLiveInputTerminalHandles((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (nextEnabled) {
|
||||
next.add(handle)
|
||||
} else {
|
||||
next.delete(handle)
|
||||
}
|
||||
liveInputTerminalHandlesRef.current = next
|
||||
return next
|
||||
})
|
||||
return nextEnabled
|
||||
},
|
||||
[persistDisabledLiveInputHandles]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
liveInputTerminalHandlesRef.current = new Set()
|
||||
defaultedLiveInputTerminalHandlesRef.current = new Set()
|
||||
disabledLiveInputTerminalHandlesRef.current = new Set()
|
||||
disabledLiveInputHydratedRef.current = false
|
||||
pendingDisabledLiveInputHydrationEditsRef.current = new Map()
|
||||
pendingLiveInputDefaultHandlesRef.current = new Set()
|
||||
setLiveInputTerminalHandles(new Set())
|
||||
|
||||
let disposed = false
|
||||
// Why: load the persisted opt-outs first so defaulting logic (which can
|
||||
// fire immediately from subscriptions) respects prior user choices.
|
||||
void readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId).then((preference) => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
const pendingEdits = pendingDisabledLiveInputHydrationEditsRef.current
|
||||
const hydratedDisabledHandles = new Set(preference.handles)
|
||||
for (const [handle, disabled] of pendingEdits) {
|
||||
if (disabled) {
|
||||
hydratedDisabledHandles.add(handle)
|
||||
} else {
|
||||
hydratedDisabledHandles.delete(handle)
|
||||
}
|
||||
}
|
||||
disabledLiveInputTerminalHandlesRef.current = hydratedDisabledHandles
|
||||
disabledLiveInputHydratedRef.current = true
|
||||
pendingDisabledLiveInputHydrationEditsRef.current = new Map()
|
||||
if (preference.loaded && pendingEdits.size > 0) {
|
||||
persistDisabledLiveInputHandles()
|
||||
}
|
||||
const result = applyDisabledTerminalLiveInputHandles(
|
||||
liveInputTerminalHandlesRef.current,
|
||||
defaultedLiveInputTerminalHandlesRef.current,
|
||||
hydratedDisabledHandles
|
||||
)
|
||||
const nextEnabledHandles = new Set(result.enabledHandles)
|
||||
const nextDefaultedHandles = new Set(result.defaultedHandles)
|
||||
liveInputTerminalHandlesRef.current = nextEnabledHandles
|
||||
defaultedLiveInputTerminalHandlesRef.current = nextDefaultedHandles
|
||||
setLiveInputTerminalHandles(nextEnabledHandles)
|
||||
const pendingDefaultHandles = [...pendingLiveInputDefaultHandlesRef.current]
|
||||
pendingLiveInputDefaultHandlesRef.current.clear()
|
||||
defaultTerminalHandlesToLiveInput(pendingDefaultHandles)
|
||||
})
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [defaultTerminalHandlesToLiveInput, hostId, persistDisabledLiveInputHandles, worktreeId])
|
||||
|
||||
return {
|
||||
clearTerminalLiveInputDefault,
|
||||
defaultTerminalHandlesToLiveInput,
|
||||
liveInputTerminalHandles,
|
||||
liveInputTerminalHandlesRef,
|
||||
pruneTerminalHandlesFromLiveInput,
|
||||
toggleTerminalLiveInput
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
HOST_SIDEBAR_MIN_WIDTH,
|
||||
clampHostDockWidth,
|
||||
clampHostSidebarWidth,
|
||||
loadDisabledTerminalLiveInputHandles,
|
||||
loadHostSidebarWidth,
|
||||
loadTerminalAutocompleteEnabled,
|
||||
loadTerminalLinkOpenMode,
|
||||
readDisabledTerminalLiveInputHandlesPreference,
|
||||
saveDisabledTerminalLiveInputHandles,
|
||||
saveHostSidebarWidth,
|
||||
saveTerminalAutocompleteEnabled,
|
||||
saveTerminalLinkOpenMode
|
||||
@@ -63,6 +66,62 @@ describe('terminal autocomplete preference', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal live input disabled handles preference', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(AsyncStorage.getItem).mockReset()
|
||||
vi.mocked(AsyncStorage.setItem).mockReset()
|
||||
})
|
||||
|
||||
it('defaults to no disabled handles when unset', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(null)
|
||||
|
||||
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
|
||||
new Set()
|
||||
)
|
||||
await expect(
|
||||
readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1')
|
||||
).resolves.toEqual({ handles: new Set(), loaded: true })
|
||||
})
|
||||
|
||||
it('loads only string terminal handles from storage', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['pty-1', 42, 'pty-2']))
|
||||
|
||||
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
|
||||
new Set(['pty-1', 'pty-2'])
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to no disabled handles for invalid or unreadable storage', async () => {
|
||||
vi.mocked(AsyncStorage.getItem).mockResolvedValue('not-json')
|
||||
|
||||
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
|
||||
new Set()
|
||||
)
|
||||
|
||||
vi.mocked(AsyncStorage.getItem).mockRejectedValue(new Error('storage unavailable'))
|
||||
|
||||
await expect(loadDisabledTerminalLiveInputHandles('host-1', 'worktree-1')).resolves.toEqual(
|
||||
new Set()
|
||||
)
|
||||
await expect(
|
||||
readDisabledTerminalLiveInputHandlesPreference('host-1', 'worktree-1')
|
||||
).resolves.toEqual({ handles: new Set(), loaded: false })
|
||||
})
|
||||
|
||||
it('persists disabled handles per host and worktree', async () => {
|
||||
await saveDisabledTerminalLiveInputHandles(
|
||||
'host/one',
|
||||
'folder:C:\\repo',
|
||||
new Set(['pty-2', 'pty-1'])
|
||||
)
|
||||
|
||||
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
|
||||
'orca:terminalLiveInputDisabled:host%2Fone:folder%3AC%3A%5Crepo',
|
||||
JSON.stringify(['pty-2', 'pty-1'])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('host sidebar width preference', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(AsyncStorage.getItem).mockReset()
|
||||
|
||||
@@ -73,6 +73,53 @@ export async function saveTerminalAutocompleteEnabled(enabled: boolean): Promise
|
||||
await AsyncStorage.setItem(AUTOCOMPLETE_KEY, String(enabled))
|
||||
}
|
||||
|
||||
const TERMINAL_LIVE_INPUT_DISABLED_PREFIX = 'orca:terminalLiveInputDisabled:'
|
||||
|
||||
export type DisabledTerminalLiveInputHandlesPreference = {
|
||||
readonly handles: Set<string>
|
||||
readonly loaded: boolean
|
||||
}
|
||||
|
||||
function terminalLiveInputDisabledKey(hostId: string, worktreeId: string): string {
|
||||
return `${TERMINAL_LIVE_INPUT_DISABLED_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(
|
||||
worktreeId
|
||||
)}`
|
||||
}
|
||||
|
||||
export async function readDisabledTerminalLiveInputHandlesPreference(
|
||||
hostId: string,
|
||||
worktreeId: string
|
||||
): Promise<DisabledTerminalLiveInputHandlesPreference> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(terminalLiveInputDisabledKey(hostId, worktreeId))
|
||||
if (!raw) {
|
||||
return { handles: new Set(), loaded: true }
|
||||
}
|
||||
return { handles: new Set(stringArray(JSON.parse(raw))), loaded: true }
|
||||
} catch {
|
||||
return { handles: new Set(), loaded: false }
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadDisabledTerminalLiveInputHandles(
|
||||
hostId: string,
|
||||
worktreeId: string
|
||||
): Promise<Set<string>> {
|
||||
const preference = await readDisabledTerminalLiveInputHandlesPreference(hostId, worktreeId)
|
||||
return preference.handles
|
||||
}
|
||||
|
||||
export async function saveDisabledTerminalLiveInputHandles(
|
||||
hostId: string,
|
||||
worktreeId: string,
|
||||
handles: ReadonlySet<string>
|
||||
): Promise<void> {
|
||||
await AsyncStorage.setItem(
|
||||
terminalLiveInputDisabledKey(hostId, worktreeId),
|
||||
JSON.stringify([...handles])
|
||||
)
|
||||
}
|
||||
|
||||
const SIDEBAR_WIDTH_KEY = 'orca:hostSidebarWidth'
|
||||
|
||||
// Bounds for the draggable host worktree-list sidebar on tablet/foldable
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
TERMINAL_LIVE_INPUT_MAX_BYTES,
|
||||
applyDisabledTerminalLiveInputHandles,
|
||||
clearTerminalLiveInputFocusTimer,
|
||||
defaultTerminalLiveInputHandles,
|
||||
filterTerminalLiveInputDefaultCandidates,
|
||||
getTerminalLiveSpecialKeyBytes,
|
||||
isTerminalLiveInputWithinByteLimit,
|
||||
pruneTerminalLiveInputHandles,
|
||||
@@ -101,6 +103,35 @@ describe('terminal live input', () => {
|
||||
expect(result.defaultedHandles).toBe(defaulted)
|
||||
})
|
||||
|
||||
it('does not default persisted buffered-mode handles back to live input on reentry', () => {
|
||||
const defaultableHandles = filterTerminalLiveInputDefaultCandidates(
|
||||
['pty-1', 'pty-2'],
|
||||
new Set(['pty-1'])
|
||||
)
|
||||
|
||||
const result = defaultTerminalLiveInputHandles(
|
||||
new Set(),
|
||||
new Set(['pty-1']),
|
||||
defaultableHandles
|
||||
)
|
||||
|
||||
expect(defaultableHandles).toEqual(['pty-2'])
|
||||
expect([...result.enabledHandles]).toEqual(['pty-2'])
|
||||
expect([...result.defaultedHandles]).toEqual(['pty-1', 'pty-2'])
|
||||
})
|
||||
|
||||
it('reconciles persisted buffered-mode handles with currently enabled live input', () => {
|
||||
const result = applyDisabledTerminalLiveInputHandles(
|
||||
new Set(['pty-1', 'pty-2']),
|
||||
new Set(['pty-2']),
|
||||
new Set(['pty-1'])
|
||||
)
|
||||
|
||||
expect(result.changed).toBe(true)
|
||||
expect([...result.enabledHandles]).toEqual(['pty-2'])
|
||||
expect([...result.defaultedHandles]).toEqual(['pty-2', 'pty-1'])
|
||||
})
|
||||
|
||||
it('prunes terminal handles that disappear from session snapshots', () => {
|
||||
const result = pruneTerminalLiveInputHandles(
|
||||
new Set(['pty-1', 'pty-stale']),
|
||||
|
||||
@@ -118,6 +118,48 @@ export function defaultTerminalLiveInputHandles(
|
||||
}
|
||||
}
|
||||
|
||||
export function filterTerminalLiveInputDefaultCandidates(
|
||||
terminalHandles: readonly string[],
|
||||
disabledHandles: ReadonlySet<string>
|
||||
): string[] {
|
||||
return terminalHandles.filter((handle) => !disabledHandles.has(handle))
|
||||
}
|
||||
|
||||
export function applyDisabledTerminalLiveInputHandles(
|
||||
enabledHandles: ReadonlySet<string>,
|
||||
defaultedHandles: ReadonlySet<string>,
|
||||
disabledHandles: ReadonlySet<string>
|
||||
): TerminalLiveInputDefaultResult {
|
||||
let nextEnabledHandles: Set<string> | null = null
|
||||
let nextDefaultedHandles: Set<string> | null = null
|
||||
|
||||
for (const handle of enabledHandles) {
|
||||
if (!disabledHandles.has(handle)) {
|
||||
continue
|
||||
}
|
||||
nextEnabledHandles ??= new Set(enabledHandles)
|
||||
nextEnabledHandles.delete(handle)
|
||||
}
|
||||
|
||||
for (const handle of disabledHandles) {
|
||||
if (defaultedHandles.has(handle)) {
|
||||
continue
|
||||
}
|
||||
nextDefaultedHandles ??= new Set(defaultedHandles)
|
||||
nextDefaultedHandles.add(handle)
|
||||
}
|
||||
|
||||
if (!nextEnabledHandles && !nextDefaultedHandles) {
|
||||
return { enabledHandles, defaultedHandles, changed: false }
|
||||
}
|
||||
|
||||
return {
|
||||
enabledHandles: nextEnabledHandles ?? enabledHandles,
|
||||
defaultedHandles: nextDefaultedHandles ?? defaultedHandles,
|
||||
changed: true
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneTerminalLiveInputHandles(
|
||||
enabledHandles: ReadonlySet<string>,
|
||||
defaultedHandles: ReadonlySet<string>,
|
||||
|
||||
Reference in New Issue
Block a user