mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 01:27:35 +00:00
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.
378 lines
14 KiB
TypeScript
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>
|
|
)
|
|
)
|
|
}
|