Files
paseo/packages/app/src/components/message-input.tsx
T

1324 lines
43 KiB
TypeScript

import {
View,
Text,
TextInput,
Pressable,
ActivityIndicator,
NativeSyntheticEvent,
TextInputContentSizeChangeEventData,
TextInputKeyPressEventData,
TextInputSelectionChangeEventData,
Image,
Platform,
BackHandler,
} from 'react-native'
import { useState, useRef, useCallback, useEffect, useImperativeHandle, forwardRef } from 'react'
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { Mic, MicOff, ArrowUp, Paperclip, Plus, X, Square } from 'lucide-react-native'
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from 'react-native-reanimated'
import { useDictation } from '@/hooks/use-dictation'
import { DictationOverlay } from './dictation-controls'
import { RealtimeVoiceOverlay } from './realtime-voice-overlay'
import type { DaemonClient } from '@server/client/daemon-client'
import { useSessionStore } from '@/stores/session-store'
import { useVoiceOptional } from '@/contexts/voice-context'
import { useToast } from '@/contexts/toast-context'
import { resolveVoiceUnavailableMessage } from '@/utils/server-info-capabilities'
import {
collectImageFilesFromClipboardData,
filesToImageAttachments,
} from '@/utils/image-attachments-from-files'
import type { AttachmentMetadata } from '@/attachments/types'
import { useAttachmentPreviewUrl } from '@/attachments/use-attachment-preview-url'
import { focusWithRetries } from '@/utils/web-focus'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Shortcut } from '@/components/ui/shortcut'
import type { MessageInputKeyboardActionKind } from '@/keyboard/actions'
import {
markScrollInvestigationEvent,
markScrollInvestigationRender,
} from '@/utils/scroll-jank-investigation'
export type ImageAttachment = AttachmentMetadata
export interface MessagePayload {
text: string
images?: ImageAttachment[]
/** When true, bypasses queue and sends immediately even if agent is running */
forceSend?: boolean
}
export interface MessageInputProps {
value: string
onChangeText: (text: string) => void
onSubmit: (payload: MessagePayload) => void
isSubmitDisabled?: boolean
isSubmitLoading?: boolean
images?: ImageAttachment[]
onPickImages?: () => void
onAddImages?: (images: ImageAttachment[]) => void
onRemoveImage?: (index: number) => void
client: DaemonClient | null
/** Dictation start gate from host runtime (socket connected + directory ready). */
isReadyForDictation?: boolean
placeholder?: string
autoFocus?: boolean
autoFocusKey?: string
disabled?: boolean
/** True when the containing screen is focused (React Navigation). Used to disable global hotkeys and cancel dictation when unfocused. */
isScreenFocused?: boolean
/** Content to render on the left side of the button row (e.g., AgentStatusBar) */
leftContent?: React.ReactNode
/** Content to render on the right side after voice button (e.g., realtime button, cancel button) */
rightContent?: React.ReactNode
voiceServerId?: string
voiceAgentId?: string
/** When true and there's sendable content, calls onQueue instead of onSubmit */
isAgentRunning?: boolean
/** Callback for queue button when agent is running */
onQueue?: (payload: MessagePayload) => void
/** Optional handler used when submit button is in loading state. */
onSubmitLoadingPress?: () => void
/** Intercept key press events before default handling. Return true to prevent default. */
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean
/** Reports cursor selection updates from the underlying input. */
onSelectionChange?: (selection: { start: number; end: number }) => void
onFocusChange?: (focused: boolean) => void
onHeightChange?: (height: number) => void
}
export interface MessageInputRef {
focus: () => void
blur: () => void
runKeyboardAction: (action: MessageInputKeyboardActionKind) => void
/**
* Web-only: return the underlying DOM element for focus assertions/retries.
* May return null if not mounted or on native.
*/
getNativeElement?: () => HTMLElement | null
}
const MIN_INPUT_HEIGHT = 30
const MAX_INPUT_HEIGHT = 160
const IS_WEB = Platform.OS === 'web'
type WebTextInputKeyPressEvent = NativeSyntheticEvent<
TextInputKeyPressEventData & {
metaKey?: boolean
ctrlKey?: boolean
shiftKey?: boolean
}
>
type TextAreaHandle = {
scrollHeight?: number
clientHeight?: number
offsetHeight?: number
scrollTop?: number
selectionStart?: number | null
selectionEnd?: number | null
style?: {
height?: string
overflowY?: string
} & Record<string, unknown>
}
function logWebStickyBottom(
_event: string,
_details: Record<string, unknown>
): void {
// Intentionally disabled: this path is too noisy during voice debugging.
}
function getDebugNow(): number | null {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
return Number(performance.now().toFixed(3))
}
return null
}
function getElementDescriptor(element: HTMLElement | null): string | null {
if (!element) return null
const tag = element.tagName?.toLowerCase() ?? 'unknown'
const id = element.id ? `#${element.id}` : ''
const testId = element.getAttribute?.('data-testid')
const label = element.getAttribute?.('aria-label')
const suffix = testId
? `[data-testid="${testId}"]`
: label
? `[aria-label="${label}"]`
: ''
return `${tag}${id}${suffix}`
}
function getScrollableAncestorChain(element: HTMLElement | null): string[] {
if (!element || typeof window === 'undefined') {
return []
}
const results: string[] = []
let current = element.parentElement
while (current) {
const style = window.getComputedStyle(current)
const overflowY = style.overflowY
const canScroll =
(overflowY === 'auto' || overflowY === 'scroll' || overflowY === 'overlay') &&
current.scrollHeight > current.clientHeight
if (canScroll) {
results.push(getElementDescriptor(current) ?? current.tagName.toLowerCase())
}
current = current.parentElement
}
return results
}
function ImageAttachmentThumbnail({ image }: { image: ImageAttachment }) {
const uri = useAttachmentPreviewUrl(image)
if (!uri) {
return <View style={styles.imageThumbnailPlaceholder} />
}
return <Image source={{ uri }} style={styles.imageThumbnail} />
}
export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(function MessageInput(
{
value,
onChangeText,
onSubmit,
isSubmitDisabled = false,
isSubmitLoading = false,
images = [],
onPickImages,
onAddImages,
onRemoveImage,
client,
isReadyForDictation,
placeholder = 'Message...',
autoFocus = false,
autoFocusKey,
disabled = false,
isScreenFocused = true,
leftContent,
rightContent,
voiceServerId,
voiceAgentId,
isAgentRunning = false,
onQueue,
onSubmitLoadingPress,
onKeyPress: onKeyPressCallback,
onSelectionChange: onSelectionChangeCallback,
onFocusChange,
onHeightChange,
},
ref
) {
const { theme } = useUnistyles()
const investigationComponentId = `MessageInput:${voiceServerId ?? 'unknown-server'}:${voiceAgentId ?? 'unknown-agent'}`
markScrollInvestigationRender(investigationComponentId)
const toast = useToast()
const voice = useVoiceOptional()
console.log("[MessageInput] render", {
voiceServerId: voiceServerId ?? null,
voiceAgentId: voiceAgentId ?? null,
hasVoice: Boolean(voice),
isVoiceMode: voice?.isVoiceMode ?? false,
isVoiceSwitching: voice?.isVoiceSwitching ?? false,
isMuted: voice?.isMuted ?? false,
disabled,
isSubmitLoading,
valueLength: value.length,
imageCount: images.length,
})
const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT)
const rootRef = useRef<View | null>(null)
const inputWrapperRef = useRef<View | null>(null)
const textInputRef = useRef<TextInput | (TextInput & { getNativeRef?: () => unknown }) | null>(
null
)
const isInputFocusedRef = useRef(false)
useImperativeHandle(ref, () => ({
focus: () => {
textInputRef.current?.focus()
},
blur: () => {
textInputRef.current?.blur?.()
},
runKeyboardAction: (action) => {
if (action === 'focus') {
textInputRef.current?.focus()
return
}
if (action === 'voice-toggle') {
handleToggleRealtimeVoiceShortcut()
return
}
if (action === 'voice-mute-toggle') {
if (isRealtimeVoiceForCurrentAgent) {
voice?.toggleMute()
}
return
}
if (action === 'dictation-cancel') {
if (isDictatingRef.current) {
cancelDictation()
}
return
}
if (action === 'dictation-toggle') {
if (isDictatingRef.current) {
sendAfterTranscriptRef.current = true
confirmDictation()
} else {
void startDictationIfAvailable()
}
}
},
getNativeElement: () => {
if (!IS_WEB) return null
const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null
const native = typeof current?.getNativeRef === 'function' ? current.getNativeRef() : current
return native instanceof HTMLElement ? native : null
},
}))
const inputHeightRef = useRef(MIN_INPUT_HEIGHT)
const baselineInputHeightRef = useRef<number | null>(null)
const overlayTransition = useSharedValue(0)
const sendAfterTranscriptRef = useRef(false)
const valueRef = useRef(value)
const serverInfo = useSessionStore(
useCallback(
(state) => {
if (!voiceServerId) {
return null
}
return state.sessions[voiceServerId]?.serverInfo ?? null
},
[voiceServerId]
)
)
useEffect(() => {
console.log("[MessageInput] mount", {
voiceServerId: voiceServerId ?? null,
voiceAgentId: voiceAgentId ?? null,
})
return () => {
console.log("[MessageInput] unmount", {
voiceServerId: voiceServerId ?? null,
voiceAgentId: voiceAgentId ?? null,
})
}
}, [voiceAgentId, voiceServerId])
useEffect(() => {
valueRef.current = value
}, [value])
useEffect(() => {
return () => {
onFocusChange?.(false)
}
}, [onFocusChange])
// Autofocus on web when autoFocus is true, and re-run when focus key changes.
useEffect(() => {
if (!IS_WEB || !autoFocus) return
return focusWithRetries({
focus: () => textInputRef.current?.focus(),
isFocused: () => {
const current = textInputRef.current as (TextInput & { getNativeRef?: () => unknown }) | null
const native = typeof current?.getNativeRef === 'function' ? current.getNativeRef() : current
const element = native instanceof HTMLElement ? native : null
const active = typeof document !== 'undefined' ? document.activeElement : null
return Boolean(element) && active === element
},
})
}, [autoFocus, autoFocusKey])
const handleDictationTranscript = useCallback(
(text: string, _meta: { requestId: string }) => {
if (!text) return
const current = valueRef.current
const shouldPad = current.length > 0 && !/\s$/.test(current)
const nextValue = `${current}${shouldPad ? ' ' : ''}${text}`
const shouldAutoSend = sendAfterTranscriptRef.current
sendAfterTranscriptRef.current = false
if (shouldAutoSend) {
const imageAttachments = images.length > 0 ? images : undefined
onSubmit({
text: nextValue,
images: imageAttachments,
forceSend: isAgentRunning || undefined,
})
} else {
onChangeText(nextValue)
}
if (IS_WEB && typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => {
measureWebInputHeight('dictation')
})
}
},
[onChangeText, onSubmit, images, isAgentRunning]
)
const handleDictationError = useCallback((error: Error) => {
console.error('[MessageInput] Dictation error:', error)
}, [])
const dictationUnavailableMessage = resolveVoiceUnavailableMessage({
serverInfo,
mode: 'dictation',
})
const canStartDictation = useCallback(() => {
const socketConnected = client?.isConnected ?? false
const readyForDictation = isReadyForDictation ?? socketConnected
return socketConnected && readyForDictation && !disabled && !dictationUnavailableMessage
}, [client, disabled, dictationUnavailableMessage, isReadyForDictation])
const canConfirmDictation = useCallback(() => {
const socketConnected = client?.isConnected ?? false
return socketConnected
}, [client])
const isConnected = client?.isConnected ?? false
const isDictationStartEnabled = (isReadyForDictation ?? isConnected) && !disabled
const {
isRecording: isDictating,
isProcessing: isDictationProcessing,
partialTranscript: dictationPartialTranscript,
volume: dictationVolume,
duration: dictationDuration,
error: dictationError,
status: dictationStatus,
startDictation,
cancelDictation,
confirmDictation,
retryFailedDictation,
discardFailedDictation,
} = useDictation({
client,
onTranscript: handleDictationTranscript,
onError: handleDictationError,
canStart: canStartDictation,
canConfirm: canConfirmDictation,
autoStopWhenHidden: { isVisible: isScreenFocused },
enableDuration: true,
})
const isDictatingRef = useRef(isDictating)
useEffect(() => {
isDictatingRef.current = isDictating
}, [isDictating])
const isRealtimeVoiceForCurrentAgent =
!!voice &&
!!voiceServerId &&
!!voiceAgentId &&
voice.isVoiceModeForAgent(voiceServerId, voiceAgentId)
const showDictationOverlay = isDictating || isDictationProcessing || dictationStatus === 'failed'
const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent
const showOverlay = showDictationOverlay || showRealtimeOverlay
useEffect(() => {
console.log("[MessageInput] overlay_state", {
showDictationOverlay,
showRealtimeOverlay,
showOverlay,
isDictating,
isDictationProcessing,
dictationStatus,
isRealtimeVoiceForCurrentAgent,
})
}, [
dictationStatus,
isDictating,
isDictationProcessing,
isRealtimeVoiceForCurrentAgent,
showDictationOverlay,
showOverlay,
showRealtimeOverlay,
])
useEffect(() => {
if (isDictating || isDictationProcessing) {
return
}
sendAfterTranscriptRef.current = false
}, [dictationStatus, isDictating, isDictationProcessing])
const startDictationIfAvailable = useCallback(async () => {
if (dictationUnavailableMessage) {
isDictatingRef.current = false
toast.error(dictationUnavailableMessage)
return
}
// Keep hotkey toggling deterministic between the async start call and the
// state-ref sync effect, so a rapid second toggle routes to confirm.
isDictatingRef.current = true
await startDictation()
}, [dictationUnavailableMessage, startDictation, toast])
// Animate overlay
useEffect(() => {
overlayTransition.value = withTiming(showOverlay ? 1 : 0, {
duration: 200,
})
}, [overlayTransition, showOverlay])
const overlayAnimatedStyle = useAnimatedStyle(() => ({
opacity: overlayTransition.value,
pointerEvents: overlayTransition.value > 0.5 ? 'auto' : 'none',
}))
const inputAnimatedStyle = useAnimatedStyle(() => ({
opacity: 1 - overlayTransition.value,
}))
const handleVoicePress = useCallback(async () => {
console.log("[MessageInput] handleVoicePress", {
isRealtimeVoiceForCurrentAgent,
isDictating,
hasVoice: Boolean(voice),
})
if (isRealtimeVoiceForCurrentAgent && voice) {
voice.toggleMute()
return
}
if (isDictating) {
await cancelDictation()
} else {
await startDictationIfAvailable()
}
}, [
cancelDictation,
isDictating,
isRealtimeVoiceForCurrentAgent,
startDictationIfAvailable,
voice,
])
const handleCancelRecording = useCallback(async () => {
console.log("[MessageInput] handleCancelRecording")
await cancelDictation()
}, [cancelDictation])
const handleAcceptRecording = useCallback(async () => {
console.log("[MessageInput] handleAcceptRecording")
sendAfterTranscriptRef.current = false
await confirmDictation()
}, [confirmDictation])
const handleAcceptAndSendRecording = useCallback(async () => {
console.log("[MessageInput] handleAcceptAndSendRecording")
sendAfterTranscriptRef.current = true
await confirmDictation()
}, [confirmDictation])
const handleRetryFailedRecording = useCallback(() => {
console.log("[MessageInput] handleRetryFailedRecording")
void retryFailedDictation()
}, [retryFailedDictation])
const handleDiscardFailedRecording = useCallback(() => {
console.log("[MessageInput] handleDiscardFailedRecording")
discardFailedDictation()
}, [discardFailedDictation])
const handleStopRealtimeVoice = useCallback(async () => {
console.log("[MessageInput] handleStopRealtimeVoice", {
hasVoice: Boolean(voice),
isRealtimeVoiceForCurrentAgent,
isAgentRunning,
voiceAgentId: voiceAgentId ?? null,
})
if (!voice || !isRealtimeVoiceForCurrentAgent) {
return
}
const tasks: Promise<unknown>[] = []
if (isAgentRunning && client && voiceAgentId) {
tasks.push(client.cancelAgent(voiceAgentId))
}
tasks.push(voice.stopVoice())
const results = await Promise.allSettled(tasks)
results.forEach((result) => {
if (result.status === 'rejected') {
console.error('[MessageInput] Failed to stop realtime voice', result.reason)
}
})
}, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, voice, voiceAgentId])
const handleToggleRealtimeVoiceShortcut = useCallback(() => {
console.log("[MessageInput] handleToggleRealtimeVoiceShortcut", {
hasVoice: Boolean(voice),
voiceServerId: voiceServerId ?? null,
voiceAgentId: voiceAgentId ?? null,
isConnected,
disabled,
isVoiceSwitching: voice?.isVoiceSwitching ?? false,
isVoiceModeForAgent:
voice && voiceServerId && voiceAgentId
? voice.isVoiceModeForAgent(voiceServerId, voiceAgentId)
: false,
})
if (!voice || !voiceServerId || !voiceAgentId || !isConnected || disabled) {
return
}
if (voice.isVoiceSwitching) {
return
}
if (voice.isVoiceModeForAgent(voiceServerId, voiceAgentId)) {
void handleStopRealtimeVoice()
return
}
void voice.startVoice(voiceServerId, voiceAgentId).catch((error) => {
console.error('[MessageInput] Failed to start realtime voice', error)
const message =
error instanceof Error ? error.message : typeof error === 'string' ? error : null
if (message && message.trim().length > 0) {
toast.error(message)
}
})
}, [disabled, handleStopRealtimeVoice, isConnected, toast, voice, voiceAgentId, voiceServerId])
const handleSendMessage = useCallback(() => {
console.log("[MessageInput] handleSendMessage", {
valueLength: value.length,
imageCount: images.length,
isAgentRunning,
})
const trimmed = value.trim()
if (!trimmed && images.length === 0) return
const payload = {
text: trimmed,
images: images.length > 0 ? images : undefined,
forceSend: isAgentRunning || undefined,
}
onSubmit(payload)
inputHeightRef.current = MIN_INPUT_HEIGHT
setInputHeight(MIN_INPUT_HEIGHT)
onHeightChange?.(MIN_INPUT_HEIGHT)
}, [value, images, onSubmit, isAgentRunning, onHeightChange])
const handleQueueMessage = useCallback(() => {
if (!onQueue) return
const trimmed = value.trim()
if (!trimmed && images.length === 0) return
const payload = {
text: trimmed,
images: images.length > 0 ? images : undefined,
}
onQueue(payload)
onChangeText('')
inputHeightRef.current = MIN_INPUT_HEIGHT
setInputHeight(MIN_INPUT_HEIGHT)
onHeightChange?.(MIN_INPUT_HEIGHT)
}, [value, images, onQueue, onChangeText, onHeightChange])
// Web input height measurement
function isTextAreaLike(v: unknown): v is TextAreaHandle {
return typeof v === 'object' && v !== null && 'scrollHeight' in v
}
const getWebTextArea = useCallback((): TextAreaHandle | null => {
const ref = textInputRef.current
if (!ref) return null
if (typeof (ref as any).getNativeRef === 'function') {
const native = (ref as any).getNativeRef()
if (isTextAreaLike(native)) return native
}
if (isTextAreaLike(ref)) return ref
return null
}, [])
const getWebElement = useCallback((target: 'root' | 'wrapper'): HTMLElement | null => {
const ref = target === 'root' ? rootRef.current : inputWrapperRef.current
if (!ref) return null
return ref instanceof HTMLElement ? ref : ((ref as unknown as { getBoundingClientRect?: () => DOMRect }).getBoundingClientRect ? (ref as unknown as HTMLElement) : null)
}, [])
useEffect(() => {
if (!IS_WEB || !onAddImages) {
return
}
const textarea = getWebTextArea()
if (
!textarea ||
typeof (textarea as any).addEventListener !== 'function' ||
typeof (textarea as any).removeEventListener !== 'function'
) {
return
}
let disposed = false
const handlePaste = (event: ClipboardEvent) => {
if (!isConnected || disabled || isDictating || isRealtimeVoiceForCurrentAgent) {
return
}
const imageFiles = collectImageFilesFromClipboardData(event.clipboardData)
if (imageFiles.length === 0) {
return
}
event.preventDefault()
void filesToImageAttachments(imageFiles)
.then((attachments) => {
if (disposed || attachments.length === 0) {
return
}
onAddImages(attachments)
})
.catch((error) => {
console.error('[MessageInput] Failed to process pasted images:', error)
})
}
;(textarea as any).addEventListener('paste', handlePaste)
return () => {
disposed = true
;(textarea as any).removeEventListener('paste', handlePaste)
}
}, [
disabled,
getWebTextArea,
isConnected,
isDictating,
isRealtimeVoiceForCurrentAgent,
onAddImages,
])
useEffect(() => {
if (!IS_WEB || typeof ResizeObserver === 'undefined') {
return
}
const textarea = getWebTextArea()
const root = getWebElement('root')
const wrapper = getWebElement('wrapper')
const observed = [
{ name: 'composer_root', element: root },
{ name: 'composer_wrapper', element: wrapper },
{ name: 'composer_textarea', element: textarea as unknown as HTMLElement | null },
].filter((entry): entry is { name: string; element: HTMLElement } => entry.element instanceof HTMLElement)
if (observed.length === 0) {
return
}
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const target = entry.target as HTMLElement
const match = observed.find((item) => item.element === target)
if (!match) {
continue
}
const textareaNode = getWebTextArea()
logWebStickyBottom('composer_element_resized', {
target: match.name,
width: target.clientWidth,
height: target.clientHeight,
offsetHeight: target.offsetHeight,
scrollHeight: target.scrollHeight,
textareaClientHeight: textareaNode?.clientHeight ?? null,
textareaOffsetHeight: textareaNode?.offsetHeight ?? null,
textareaScrollHeight: textareaNode?.scrollHeight ?? null,
textareaScrollTop: (textareaNode as unknown as HTMLTextAreaElement | null)?.scrollTop ?? null,
valueLength: valueRef.current.length,
})
}
})
for (const entry of observed) {
observer.observe(entry.element)
}
return () => {
observer.disconnect()
}
}, [getWebElement, getWebTextArea])
useEffect(() => {
if (!IS_WEB) {
return
}
const textarea = getWebTextArea() as (HTMLTextAreaElement & TextAreaHandle) | null
if (!textarea || typeof textarea.addEventListener !== 'function') {
return
}
const handleScroll = () => {
const textareaElement = textarea as unknown as HTMLElement
const chatScroller =
typeof document !== 'undefined'
? (document.querySelector('[data-testid="agent-chat-scroll"]') as HTMLElement | null)
: null
logWebStickyBottom('composer_textarea_scrolled', {
now: getDebugNow(),
scrollTop: textarea.scrollTop,
clientHeight: textarea.clientHeight ?? null,
scrollHeight: textarea.scrollHeight ?? null,
selectionStart: textarea.selectionStart ?? null,
selectionEnd: textarea.selectionEnd ?? null,
textareaDescriptor: getElementDescriptor(textareaElement),
chatScrollerDescriptor: getElementDescriptor(chatScroller),
chatScrollerContainsTextarea: Boolean(chatScroller && textareaElement && chatScroller.contains(textareaElement)),
textareaScrollableAncestors: getScrollableAncestorChain(textareaElement),
valueLength: valueRef.current.length,
})
}
textarea.addEventListener('scroll', handleScroll, { passive: true })
return () => {
textarea.removeEventListener('scroll', handleScroll)
}
}, [getWebTextArea])
function measureWebInputHeight(source: string): boolean {
if (!IS_WEB) return false
const textarea = getWebTextArea()
if (!textarea || typeof textarea.scrollHeight !== 'number') return false
const scrollHeight = textarea.scrollHeight ?? 0
if (baselineInputHeightRef.current === null && scrollHeight > 0) {
baselineInputHeightRef.current = scrollHeight
logWebStickyBottom('composer_baseline_measured', {
source,
baseline: scrollHeight,
})
}
const baseline = baselineInputHeightRef.current ?? MIN_INPUT_HEIGHT
const rawTarget = scrollHeight > 0 ? scrollHeight : baseline
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, rawTarget))
const previousHeight = inputHeightRef.current
if (Math.abs(previousHeight - bounded) >= 1) {
inputHeightRef.current = bounded
setInputHeight(bounded)
onHeightChange?.(bounded)
logWebStickyBottom('composer_height_changed', {
source,
previousHeight,
nextHeight: bounded,
scrollHeight,
clientHeight: textarea.clientHeight ?? null,
offsetHeight: textarea.offsetHeight ?? null,
baseline,
rawTarget,
})
return true
}
return false
}
function setBoundedInputHeight(nextHeight: number) {
const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(MAX_INPUT_HEIGHT, nextHeight))
if (Math.abs(inputHeightRef.current - bounded) < 1) return
const previousHeight = inputHeightRef.current
inputHeightRef.current = bounded
setInputHeight(bounded)
onHeightChange?.(bounded)
logWebStickyBottom('composer_height_changed_native', {
previousHeight,
nextHeight: bounded,
})
}
function handleContentSizeChange(
event: NativeSyntheticEvent<TextInputContentSizeChangeEventData>
) {
const contentHeight = event.nativeEvent.contentSize.height
if (IS_WEB) {
logWebStickyBottom('composer_content_size_change', {
reportedHeight: contentHeight,
})
if (baselineInputHeightRef.current === null && contentHeight > 0) {
baselineInputHeightRef.current = contentHeight
logWebStickyBottom('composer_baseline_measured', {
source: 'contentSizeChange',
baseline: contentHeight,
})
}
setBoundedInputHeight(contentHeight)
return
}
setBoundedInputHeight(contentHeight)
}
function handleSelectionChange(event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) {
const start = event.nativeEvent.selection?.start ?? 0
const end = event.nativeEvent.selection?.end ?? start
if (IS_WEB) {
const textarea = getWebTextArea()
logWebStickyBottom('composer_selection_changed', {
now: getDebugNow(),
start,
end,
textareaScrollTop: textarea?.scrollTop ?? null,
textareaClientHeight: textarea?.clientHeight ?? null,
textareaScrollHeight: textarea?.scrollHeight ?? null,
})
}
onSelectionChangeCallback?.({ start, end })
}
const shouldHandleDesktopSubmit = IS_WEB
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
markScrollInvestigationEvent(investigationComponentId, 'keyPress')
if (!shouldHandleDesktopSubmit) return
// Allow parent to intercept key events (e.g., for autocomplete navigation)
if (onKeyPressCallback) {
const handled = onKeyPressCallback({
key: event.nativeEvent.key,
preventDefault: () => event.preventDefault(),
})
if (handled) return
}
const { shiftKey, metaKey, ctrlKey } = event.nativeEvent
if (event.nativeEvent.key !== 'Enter') return
// Shift+Enter: add newline (default behavior, don't intercept)
if (shiftKey) return
// Cmd+Enter (Mac) or Ctrl+Enter (Windows/Linux): queue when agent is running
if ((metaKey || ctrlKey) && isAgentRunning && onQueue) {
if (isSubmitDisabled || isSubmitLoading || disabled) return
event.preventDefault()
handleQueueMessage()
return
}
// Enter: send (interrupts agent if running)
if (isSubmitDisabled || isSubmitLoading || disabled) return
event.preventDefault()
handleSendMessage()
}
const hasImages = images.length > 0
const hasSendableContent = value.trim().length > 0 || hasImages
const shouldShowSendButton = hasSendableContent || isSubmitLoading
const canPressLoadingButton = isSubmitLoading && typeof onSubmitLoadingPress === 'function'
const isSendButtonDisabled =
disabled || (!canPressLoadingButton && (isSubmitDisabled || isSubmitLoading))
const submitAccessibilityLabel = canPressLoadingButton
? 'Interrupt agent'
: isAgentRunning
? 'Send and interrupt'
: 'Send message'
const handleInputChange = useCallback(
(nextValue: string) => {
markScrollInvestigationEvent(investigationComponentId, 'inputChange')
onChangeText(nextValue)
if (IS_WEB) {
logWebStickyBottom('composer_text_changed', {
valueLength: nextValue.length,
lineCount: nextValue.split('\n').length,
})
}
},
[investigationComponentId, onChangeText]
)
return (
<View ref={rootRef} style={styles.container} testID="message-input-root">
{/* Regular input */}
<Animated.View ref={inputWrapperRef} style={[styles.inputWrapper, inputAnimatedStyle]}>
{/* Image preview pills */}
{hasImages && (
<View style={styles.imagePreviewContainer} testID="message-input-image-preview">
{images.map((image, index) => (
<Pressable
key={`${image.id}-${index}`}
testID="message-input-image-pill"
style={styles.imagePill}
onPress={onRemoveImage ? () => onRemoveImage(index) : undefined}
>
{({ hovered }) => (
<>
<ImageAttachmentThumbnail image={image} />
{onRemoveImage && (
<View
style={[
styles.removeImageButton,
(hovered || !IS_WEB) && styles.removeImageButtonVisible,
]}
>
<X size={theme.iconSize.md} color="white" />
</View>
)}
</>
)}
</Pressable>
))}
</View>
)}
{/* Text input */}
<TextInput
ref={textInputRef}
value={value}
onChangeText={handleInputChange}
placeholder={placeholder}
placeholderTextColor={theme.colors.surface4}
accessibilityLabel="Message agent..."
onFocus={() => {
isInputFocusedRef.current = true
onFocusChange?.(true)
}}
onBlur={() => {
isInputFocusedRef.current = false
onFocusChange?.(false)
}}
style={[
styles.textInput,
IS_WEB
? {
height: inputHeight,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
}
: {
minHeight: MIN_INPUT_HEIGHT,
maxHeight: MAX_INPUT_HEIGHT,
},
]}
multiline
scrollEnabled={IS_WEB ? inputHeight >= MAX_INPUT_HEIGHT : true}
onContentSizeChange={handleContentSizeChange}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
onKeyPress={shouldHandleDesktopSubmit ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
autoFocus={IS_WEB && autoFocus}
/>
{/* Button row */}
<View style={styles.buttonRow}>
{/* Left: attachment button + leftContent slot */}
<View style={styles.leftButtonGroup}>
{onPickImages && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={onPickImages}
disabled={!isConnected || disabled}
accessibilityLabel="Attach images"
accessibilityRole="button"
style={[styles.attachButton, (!isConnected || disabled) && styles.buttonDisabled]}
>
<Paperclip size={theme.iconSize.lg} color={theme.colors.foreground} />
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Attach images</Text>
</TooltipContent>
</Tooltip>
)}
{leftContent}
</View>
{/* Right: voice button, contextual button (realtime/send/cancel) */}
<View style={styles.rightButtonGroup}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleVoicePress}
disabled={!isDictationStartEnabled}
accessibilityRole="button"
accessibilityLabel={
isRealtimeVoiceForCurrentAgent
? voice?.isMuted
? 'Unmute Voice mode'
: 'Mute Voice mode'
: isDictating
? 'Stop dictation'
: 'Start dictation'
}
style={[
styles.voiceButton,
(!isDictationStartEnabled) && styles.buttonDisabled,
isDictating && styles.voiceButtonRecording,
]}
>
{isDictating ? (
<Square size={theme.iconSize.lg} color="white" fill="white" />
) : isRealtimeVoiceForCurrentAgent && voice?.isMuted ? (
<MicOff size={theme.iconSize.lg} color={theme.colors.foreground} />
) : (
<Mic size={theme.iconSize.lg} color={theme.colors.foreground} />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>
{isRealtimeVoiceForCurrentAgent
? voice?.isMuted
? 'Unmute voice'
: 'Mute voice'
: 'Dictation'}
</Text>
<Shortcut
keys={isRealtimeVoiceForCurrentAgent ? ['Space'] : ['mod', 'D']}
style={styles.tooltipShortcut}
/>
</View>
</TooltipContent>
</Tooltip>
{rightContent}
{hasSendableContent && isAgentRunning && onQueue && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={handleQueueMessage}
disabled={!isConnected || disabled}
accessibilityLabel="Queue message"
accessibilityRole="button"
style={[styles.queueButton, (!isConnected || disabled) && styles.buttonDisabled]}
>
<Plus size={theme.iconSize.lg} color="white" />
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Queue</Text>
<Shortcut keys={['mod', 'Enter']} style={styles.tooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
)}
{shouldShowSendButton && (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
onPress={canPressLoadingButton ? onSubmitLoadingPress : handleSendMessage}
disabled={isSendButtonDisabled}
accessibilityLabel={submitAccessibilityLabel}
accessibilityRole="button"
style={[styles.sendButton, isSendButtonDisabled && styles.buttonDisabled]}
>
{isSubmitLoading ? (
<ActivityIndicator size="small" color="white" />
) : (
<ArrowUp size={theme.iconSize.lg} color="white" />
)}
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<View style={styles.tooltipRow}>
<Text style={styles.tooltipText}>Send</Text>
<Shortcut keys={['Enter']} style={styles.tooltipShortcut} />
</View>
</TooltipContent>
</Tooltip>
)}
</View>
</View>
</Animated.View>
{/* Dictation overlay */}
<Animated.View style={[styles.overlayContainer, overlayAnimatedStyle]}>
{showDictationOverlay ? (
<DictationOverlay
volume={dictationVolume}
duration={dictationDuration}
isRecording={isDictating}
isProcessing={isDictationProcessing}
status={dictationStatus}
errorText={dictationStatus === 'failed' ? (dictationError ?? undefined) : undefined}
onCancel={handleCancelRecording}
onAccept={handleAcceptRecording}
onAcceptAndSend={handleAcceptAndSendRecording}
onRetry={dictationStatus === 'failed' ? handleRetryFailedRecording : undefined}
onDiscard={dictationStatus === 'failed' ? handleDiscardFailedRecording : undefined}
/>
) : showRealtimeOverlay && voice ? (
<RealtimeVoiceOverlay
isMuted={voice.isMuted}
isSwitching={voice.isVoiceSwitching}
onToggleMute={voice.toggleMute}
onStop={() => {
void handleStopRealtimeVoice()
}}
/>
) : null}
</Animated.View>
</View>
)
})
const styles = StyleSheet.create(((theme: any) => ({
container: {
position: 'relative',
},
inputWrapper: {
flexDirection: 'column',
gap: theme.spacing[3],
backgroundColor: theme.colors.surface1,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.borderAccent,
borderRadius: theme.borderRadius['2xl'],
paddingVertical: {
xs: theme.spacing[2],
md: theme.spacing[4],
},
paddingHorizontal: {
xs: theme.spacing[3],
md: theme.spacing[4],
},
...(IS_WEB
? {
transitionProperty: 'border-color',
transitionDuration: '200ms',
transitionTimingFunction: 'ease-in-out',
}
: {}),
},
imagePreviewContainer: {
flexDirection: 'row',
gap: theme.spacing[2],
flexWrap: 'wrap',
},
imagePill: {
position: 'relative',
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.borderAccent,
overflow: 'hidden',
...(IS_WEB
? {
cursor: 'pointer',
}
: {}),
},
imageThumbnail: {
width: 48,
height: 48,
},
imageThumbnailPlaceholder: {
width: 48,
height: 48,
backgroundColor: theme.colors.surface2,
},
removeImageButton: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.5)',
opacity: 0,
...(IS_WEB
? {
transitionProperty: 'opacity',
transitionDuration: '150ms',
}
: {}),
},
removeImageButtonVisible: {
opacity: 1,
},
textInput: {
width: '100%',
color: theme.colors.foreground,
fontSize: theme.fontSize.lg,
fontWeight: theme.fontWeight.normal,
lineHeight: theme.fontSize.lg * 1.4,
...(IS_WEB
? {
outlineStyle: 'none' as const,
outlineWidth: 0,
outlineColor: 'transparent',
}
: {}),
},
buttonRow: {
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
},
leftButtonGroup: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
},
rightButtonGroup: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
},
attachButton: {
width: 34,
height: 34,
borderRadius: theme.borderRadius.full,
alignItems: 'center',
justifyContent: 'center',
},
voiceButton: {
width: 34,
height: 34,
borderRadius: theme.borderRadius.full,
alignItems: 'center',
justifyContent: 'center',
},
voiceButtonRecording: {
backgroundColor: theme.colors.destructive,
},
queueButton: {
width: 34,
height: 34,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface1,
alignItems: 'center',
justifyContent: 'center',
},
sendButton: {
width: 34,
height: 34,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.accent,
alignItems: 'center',
justifyContent: 'center',
},
tooltipRow: {
flexDirection: 'row',
alignItems: 'center',
gap: theme.spacing[2],
},
tooltipText: {
fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
tooltipShortcut: {
backgroundColor: theme.colors.surface3,
borderColor: theme.colors.borderAccent,
},
buttonDisabled: {
opacity: 0.5,
},
overlayContainer: {
position: 'absolute',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
top: 0,
left: 0,
width: '100%',
height: '100%',
right: 0,
bottom: 0,
},
})) as any) as Record<string, any>