Files
orca/mobile/src/session/MobileSessionCommandDock.tsx
T
Jinwoo Hong e9ec63168f fix(mobile): hold-to-dictate, repeat keys and the browser long-press survive the page's long-press (#24277)
On the OTA page a held press died ~500 ms in: the WebView's long-press selected nearby text and that selection's selectionchange/touchcancel ended the press. Page text is now unselectable unless it opts in (as native), hold surfaces declare onLongPress, the browser pane refuses contextmenu termination, and the chat mic's swapped icons no longer steal the touch target.
2026-10-01 01:18:24 -04:00

378 lines
14 KiB
TypeScript

import { View, Text, ScrollView, TextInput, Pressable } from 'react-native'
import { hostOs } from '../platform/host-os'
import {
ArrowUp,
ChevronDown,
ChevronsRight,
Keyboard as KeyboardIcon,
Monitor,
Plus,
Smartphone
} from 'lucide-react-native'
import { triggerMediumImpact } from '../platform/haptics'
import { createTerminalLiveAccessoryInput } from '../terminal/terminal-live-accessory-input'
import {
getTerminalCommandKeyboardType,
getTerminalLiveInputKeyboardType
} from '../terminal/terminal-keyboard-type'
import { MobileTerminalLiveInputStatus } from './MobileTerminalLiveInputStatus'
import { MobileTerminalInputActions } from './MobileTerminalInputActions'
import { keepHeldPressThroughLongPress } from './held-press-long-press'
import { isTerminalPhoneDisplayMode } from './mobile-session-route-helpers'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-session-styles'
import { useKeyboardPersistingTaps } from '../platform/keyboard-persisting-taps'
import type { MobileSessionController } from './use-mobile-session-controller'
export function MobileSessionCommandDock({ controller }: { controller: MobileSessionController }) {
const {
insets,
bufferedTerminalDraftState,
autocompleteEnabled,
liveInputCapture,
activeHandle,
customKeys,
setShowCustomKeyModal,
setDeleteKeyTarget,
visibleBuiltInAccessoryKeys,
terminalModes,
canPaste,
dictationMode,
bindCommandField,
handleLiveInputChange,
handleLiveInputKeyPress,
bindLiveInputField,
submitLiveInput,
canSend,
canCompose,
liveInputEnabled,
focusLiveInput,
showNativeChat,
dictation,
cancelDictation,
handleDictationToggle,
handleDictationPressIn,
handleDictationPressOut,
toggleDisplayMode,
handleSend,
handleAccessoryKey,
dismissSoftwareKeyboard,
toggleLiveInput,
stopAccessoryRepeat,
startAccessoryRepeat,
handlePaste,
isAttaching,
attachImage,
activeMarkdownTab,
activeFileTab,
activeBrowserTab,
keyboardLift
} = controller
const accessoryBarKeepsKeyboard = useKeyboardPersistingTaps('always')
return (
!activeMarkdownTab &&
!activeFileTab &&
!activeBrowserTab &&
!showNativeChat && (
<View
style={[
styles.commandDock,
{ paddingBottom: insets.bottom, transform: [{ translateY: -keyboardLift }] }
]}
>
{/* Accessory keys */}
<View ref={accessoryBarKeepsKeyboard} style={styles.accessoryBar}>
{/* Why: fixed keyboard escape hatch; outside ScrollView + shortcut path so it can't scroll away or be hidden (#5106). */}
{keyboardLift > 0 && (
<Pressable
style={({ pressed }) => [
styles.keyboardDismissKey,
pressed && styles.accessoryKeyPressed
]}
onPress={dismissSoftwareKeyboard}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel="Dismiss keyboard"
accessibilityHint="Hides the software keyboard and keeps the current terminal session open."
>
<View style={styles.keyboardDismissGlyph}>
<KeyboardIcon size={15} color={colors.textSecondary} strokeWidth={2} />
<ChevronDown
size={10}
color={colors.textSecondary}
strokeWidth={2.5}
style={styles.keyboardDismissChevron}
/>
</View>
</Pressable>
)}
{/* Why: default tap handling makes the first accessory-key tap dismiss the keyboard and get swallowed (#5106). */}
<ScrollView
style={styles.accessoryScroll}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.accessoryContent}
keyboardShouldPersistTaps="always"
>
<Pressable
style={({ pressed }) => [
styles.accessoryKey,
pressed && styles.accessoryKeyPressed,
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPress={() => {
if (activeHandle) {
void toggleDisplayMode(activeHandle)
}
}}
accessibilityLabel={
isTerminalPhoneDisplayMode(activeHandle, terminalModes)
? 'Switch to desktop mode'
: 'Switch to phone mode'
}
>
{isTerminalPhoneDisplayMode(activeHandle, terminalModes) ? (
<Monitor size={14} color={canSend ? colors.textSecondary : colors.textMuted} />
) : (
<Smartphone size={14} color={canSend ? colors.textSecondary : colors.textMuted} />
)}
</Pressable>
<Pressable
style={({ pressed }) => [
styles.accessoryKey,
liveInputEnabled && styles.accessoryKeyActive,
pressed && styles.accessoryKeyPressed,
!canCompose && styles.accessoryKeyDisabled
]}
// Why: offline, live mode is dead but the buffered box still composes — keep the escape hatch tappable (#6713).
disabled={!canCompose}
onPress={toggleLiveInput}
accessibilityLabel={
liveInputEnabled
? 'Switch to buffered command input'
: 'Switch to live terminal input'
}
>
<ChevronsRight
size={14}
color={
liveInputEnabled
? colors.bgBase
: canCompose
? colors.textSecondary
: colors.textMuted
}
/>
</Pressable>
{canPaste && (
<Pressable
style={({ pressed }) => [
styles.accessoryKey,
pressed && styles.accessoryKeyPressed,
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPress={() => void handlePaste()}
accessibilityLabel="Paste from clipboard"
>
<Text
style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}
>
Paste
</Text>
</Pressable>
)}
{visibleBuiltInAccessoryKeys.map((key) => (
<Pressable
key={key.id}
style={({ pressed }) => [
styles.accessoryKey,
pressed && styles.accessoryKeyPressed,
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPressIn={() => {
if (!key.repeatable) {
return
}
const input = createTerminalLiveAccessoryInput(key)
void handleAccessoryKey(input)
startAccessoryRepeat(input)
}}
onPressOut={() => {
if (key.repeatable) {
stopAccessoryRepeat()
}
}}
onPress={() => {
if (key.repeatable) {
return
}
void handleAccessoryKey(createTerminalLiveAccessoryInput(key))
}}
onLongPress={key.repeatable ? keepHeldPressThroughLongPress : undefined}
accessibilityLabel={key.accessibilityLabel ?? `Send ${key.label}`}
>
<Text
style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}
>
{key.label}
</Text>
</Pressable>
))}
{customKeys.map((key) => (
<Pressable
key={key.id}
style={({ pressed }) => [
styles.accessoryKey,
styles.customAccessoryKey,
pressed && styles.accessoryKeyPressed,
!canSend && styles.accessoryKeyDisabled
]}
disabled={!canSend}
onPress={() => void handleAccessoryKey({ bytes: key.bytes })}
onLongPress={() => {
triggerMediumImpact()
setDeleteKeyTarget(key)
}}
delayLongPress={400}
accessibilityLabel={`Send ${key.label}`}
>
<Text
style={[styles.accessoryKeyText, !canSend && styles.accessoryKeyTextDisabled]}
>
{key.label}
</Text>
</Pressable>
))}
<Pressable
style={({ pressed }) => [styles.accessoryKey, pressed && styles.accessoryKeyPressed]}
onPress={() => setShowCustomKeyModal(true)}
accessibilityLabel="Add custom shortcut"
>
<Plus size={14} color={colors.textSecondary} strokeWidth={2.2} />
</Pressable>
</ScrollView>
</View>
{/* Input bar */}
{liveInputEnabled ? (
<View style={[styles.inputBar, styles.liveInputBar]}>
<Pressable
style={({ pressed }) => [
styles.liveInputFocusTarget,
pressed && styles.liveInputFocusTargetPressed,
!canSend && styles.liveInputFocusTargetDisabled
]}
disabled={!canSend}
onPress={focusLiveInput}
accessibilityRole="button"
accessibilityLabel="Show keyboard for live terminal input"
accessibilityHint="Typed text is sent directly to the active terminal"
>
<KeyboardIcon size={16} color={colors.textSecondary} strokeWidth={2} />
<MobileTerminalLiveInputStatus
dictation={dictation}
isAttaching={isAttaching}
liveInputText={liveInputCapture}
/>
</Pressable>
<MobileTerminalInputActions
canSend={canSend}
isAttaching={isAttaching}
dictation={dictation}
dictationMode={dictationMode}
buttonStyle={styles.dictationButton}
activeButtonStyle={styles.dictationButtonActive}
disabledButtonStyle={styles.sendButtonDisabled}
onAttachImage={() => void attachImage('library')}
onAttachFile={() => void attachImage('files')}
onDictationToggle={handleDictationToggle}
onDictationPressIn={handleDictationPressIn}
onDictationPressOut={handleDictationPressOut}
onDictationCancel={cancelDictation}
/>
<TextInput
ref={bindLiveInputField}
style={styles.liveInputCapture}
value={liveInputCapture}
onChange={handleLiveInputChange}
onKeyPress={handleLiveInputKeyPress}
onSubmitEditing={submitLiveInput}
placeholder=""
showSoftInputOnFocus
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
smartInsertDelete={false}
// Why: iOS textContentType overrides autoComplete and can narrow the keyboard; keep IME switching available.
autoComplete="off"
keyboardType={getTerminalLiveInputKeyboardType(hostOs())}
returnKeyType="default"
blurOnSubmit={false}
editable={canSend}
importantForAutofill="no"
/>
</View>
) : (
<View style={styles.inputBar}>
<TextInput
ref={bindCommandField}
// Why: Android caches IME inputType at mount, so toggling autocomplete must remount there; iOS updates in place.
key={
hostOs() === 'android'
? autocompleteEnabled
? 'cmd-input-ac-on'
: 'cmd-input-ac-off'
: 'cmd-input'
}
style={styles.textInput}
value={bufferedTerminalDraftState.input}
// Why: iOS kills active dictation/IME if JS writes a value differing from native text; store raw, normalize at send.
onChangeText={bufferedTerminalDraftState.setInput}
placeholder="Type a command…"
placeholderTextColor={colors.textMuted}
autoCapitalize="none"
autoCorrect={autocompleteEnabled}
spellCheck={autocompleteEnabled}
smartInsertDelete={false}
// Why: not autofill content, but keyboard must stay default so non-Latin IMEs remain selectable.
autoComplete="off"
keyboardType={getTerminalCommandKeyboardType(hostOs(), autocompleteEnabled)}
returnKeyType="send"
blurOnSubmit={false}
// Why: composing is local — an outage must not lock the field or discard typed text (#6713).
editable={canCompose}
onSubmitEditing={() => void handleSend()}
/>
<MobileTerminalInputActions
canSend={canSend}
isAttaching={isAttaching}
dictation={dictation}
dictationMode={dictationMode}
buttonStyle={styles.dictationButton}
activeButtonStyle={styles.dictationButtonActive}
disabledButtonStyle={styles.sendButtonDisabled}
onAttachImage={() => void attachImage('library')}
onAttachFile={() => void attachImage('files')}
onDictationToggle={handleDictationToggle}
onDictationPressIn={handleDictationPressIn}
onDictationPressOut={handleDictationPressOut}
onDictationCancel={cancelDictation}
/>
<Pressable
style={[styles.sendButton, !canSend && styles.sendButtonDisabled]}
disabled={!canSend}
onPress={() => void handleSend()}
accessibilityLabel="Send command"
>
<ArrowUp size={18} color={colors.textSecondary} strokeWidth={2.5} />
</Pressable>
</View>
)}
</View>
)
)
}