1324 lines
43 KiB
TypeScript
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>
|