Files
orca/mobile/src/session/MobileSessionCommandDock.tsx
2026-08-31 23:35:08 -07:00

390 lines
15 KiB
TypeScript

import { View, Text, ScrollView, TextInput, Pressable, Platform } from 'react-native'
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 { isTerminalPhoneDisplayMode } from './mobile-session-route-helpers'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-session-styles'
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,
liveInputRef,
commandInputRef,
handleLiveInputChange,
handleLiveInputKeyPress,
handleLiveInputSubmit,
getLiveInteractionGeneration,
getSendCompletionGeneration,
dismissKeyboardAfterAgentSend,
activeSessionTab,
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
return (
!activeMarkdownTab &&
!activeFileTab &&
!activeBrowserTab &&
!showNativeChat && (
<View
style={[
styles.commandDock,
{ paddingBottom: insets.bottom, transform: [{ translateY: -keyboardLift }] }
]}
>
{/* Accessory keys */}
<View 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))
}}
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={liveInputRef}
style={styles.liveInputCapture}
value={liveInputCapture}
onChange={handleLiveInputChange}
onKeyPress={handleLiveInputKeyPress}
onSubmitEditing={() => {
const submit = handleLiveInputSubmit()
const sendOrigin = {
tab: activeSessionTab,
generation: getSendCompletionGeneration(),
interaction: getLiveInteractionGeneration()
}
void submit.then((accepted) =>
dismissKeyboardAfterAgentSend(
sendOrigin,
accepted && sendOrigin.interaction === getLiveInteractionGeneration()
)
)
}}
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(Platform.OS)}
returnKeyType="default"
blurOnSubmit={false}
editable={canSend}
importantForAutofill="no"
/>
</View>
) : (
<View style={styles.inputBar}>
<TextInput
ref={commandInputRef}
// Why: Android caches IME inputType at mount, so toggling autocomplete must remount there; iOS updates in place.
key={
Platform.OS === '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(Platform.OS, 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>
)
)
}