import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { View, Text, TextInput, useWindowDimensions, NativeSyntheticEvent, TextInputContentSizeChangeEventData, TextInputKeyPressEventData, TextInputSelectionChangeEventData, } from "react-native"; import { useState, useRef, useCallback, useEffect, useLayoutEffect, useImperativeHandle, useMemo, forwardRef, } from "react"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { useTranslation } from "react-i18next"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; import { useDictation } from "@/hooks/use-dictation"; import { DictationOverlay } from "@/components/dictation-controls"; import { RealtimeVoiceOverlay } from "@/components/realtime-voice-overlay"; import type { DaemonClient } from "@getpaseo/client/internal/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 { ComposerAttachment } from "@/attachments/types"; import type { ImageAttachment, MessagePayload } from "@/composer/types"; import { focusWithRetries } from "@/utils/web-focus"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Shortcut } from "@/components/ui/shortcut"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; import { useIosHardwareKeyboardSubmit } from "@/hooks/use-ios-hardware-keyboard-submit"; import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; import type { MessageInputKeyboardActionKind } from "@/keyboard/actions"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { isWeb } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useComposerKeyboardScope } from "@/composer/keyboard-scope"; import { useComposerHeightMirror } from "./height-mirror"; import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode"; import type { NativePastedFile } from "@/composer/native-pasted-image"; import { ComposerTextInput } from "./text-input"; import type { ComposerTextInputHandle } from "./text-input-types"; import { resolveSendTooltipLabel, resolveSubmitAccessibilityLabel, resolveVoiceAccessibilityLabel, resolveVoiceTooltipText, } from "./labels"; import { applyDictationTranscript, computeCanStartDictation, resolveComposerSurfacePresentation, runAlternateSendAction, runDefaultSendAction, runMessageInputKeyboardAction, stopRealtimeVoice, } from "./state"; const DEFAULT_SEND_KEYS: ShortcutKey[][] = [["Enter"]]; const COMPOSER_INPUT_DATASET = { composerInput: "" } as const; export interface AttachmentMenuItem { id: string; label: string; onSelect: () => void; disabled?: boolean; icon?: React.ReactElement | null; } export interface MessageInputProps { value: string; onChangeText: (text: string) => void; onSubmit: (payload: MessagePayload) => void; /** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */ hasExternalContent?: boolean; /** When true, the submit button stays visible and can submit even with no content. */ allowEmptySubmit?: boolean; /** Optional accessibility label for the primary submit button. */ submitButtonAccessibilityLabel?: string; /** Optional testID for the primary submit button. */ submitButtonTestID?: string; submitIcon?: "arrow" | "return"; isSubmitDisabled?: boolean; isSubmitLoading?: boolean; /** When true, keep the grown input height after submit (text is preserved, not cleared). */ preserveHeightOnSubmit?: boolean; attachments: ComposerAttachment[]; cwd: string; attachmentMenuItems: AttachmentMenuItem[]; onAttachButtonRef?: (node: View | null) => void; onAddImages?: (images: ImageAttachment[]) => void; onPasteImages?: (files: readonly NativePastedFile[]) => void; client: DaemonClient | null; /** Dictation start gate from host runtime (socket connected + directory ready). */ isReadyForDictation?: boolean; placeholder?: string; autoFocus?: boolean; autoFocusKey?: string; disabled?: boolean; /** Content to render on the left side of the composer toolbar (e.g., AgentControls) */ leftContent?: React.ReactNode; /** Content to render on the right side before the voice button (e.g., context window meter) */ beforeVoiceContent?: React.ReactNode; /** Auxiliary content to render on the right side after the voice button. */ rightContent?: React.ReactNode; /** Primary action to render when the agent is active and the composer has no sendable content. */ activeActionContent?: React.ReactNode; voiceServerId?: string; voiceAgentId?: string; /** When true and there's sendable content, calls onQueue instead of onSubmit */ isAgentRunning?: boolean; /** Controls what the default send action (Enter, send button, dictation) does * when the agent is running. "interrupt" sends immediately, "queue" queues. */ defaultSendBehavior?: "interrupt" | "queue"; /** 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; /** Extra styles merged onto the input wrapper (e.g. elevated background). */ inputWrapperStyle?: import("react-native").ViewStyle; /** Content rendered inside the bordered input surface, above the text input (e.g. attachment pills). */ attachmentSlot?: React.ReactNode; /** What this composer is for. See `@/composer/input-mode` for what each mode implies. */ inputMode?: ComposerInputMode; /** Renders `value` as static text on the same surface, for content there is nothing to type into. */ readOnly?: boolean; /** Changes only when application state must replace native-owned text. */ textReplacementKey: string; /** Replaces the submit icon with this label, still inside the composer's own toolbar row. */ submitLabel?: string; } export interface MessageInputRef { focus: () => void; blur: () => void; getText: () => string; replaceText: (text: string, selection?: { start: number; end: number }) => void; runKeyboardAction: (action: MessageInputKeyboardActionKind) => boolean; /** * 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_MOBILE = 30; const MIN_INPUT_HEIGHT_DESKTOP = 46; const DEFAULT_MAX_INPUT_HEIGHT = 160; const MAX_INPUT_VIEWPORT_RATIO = 0.5; const MIN_INPUT_HEIGHT = isWeb ? MIN_INPUT_HEIGHT_DESKTOP : MIN_INPUT_HEIGHT_MOBILE; type WebTextInputKeyPressEvent = NativeSyntheticEvent< TextInputKeyPressEventData & { metaKey?: boolean; ctrlKey?: boolean; shiftKey?: boolean; // Web-only: present on DOM KeyboardEvent during IME composition (CJK input). isComposing?: boolean; keyCode?: number; } >; interface TextAreaHandle { scrollHeight?: number; clientHeight?: number; offsetHeight?: number; scrollTop?: number; selectionStart?: number | null; selectionEnd?: number | null; style?: { height?: string; overflowY?: string; } & Record; } function AttachButtonIcon({ hovered, onAttachButtonRef, buttonIconSize, }: { hovered: boolean; onAttachButtonRef: ((node: View | null) => void) | undefined; buttonIconSize: number; }) { const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; return ( ); } function AttachmentMenuList({ items }: { items: AttachmentMenuItem[] }) { return ( <> {items.map((item) => ( {item.label} ))} ); } function AttachmentDropdown({ visible, isConnected, disabled, attachButtonStyle, renderAttachButtonIcon, attachmentMenuItems, addAttachmentLabel, }: { visible: boolean; isConnected: boolean; disabled: boolean; attachButtonStyle: React.ComponentProps["style"]; renderAttachButtonIcon: (input: { hovered?: boolean }) => React.ReactElement; attachmentMenuItems: AttachmentMenuItem[]; addAttachmentLabel: string; }) { const isButtonDisabled = !isConnected || disabled; if (!visible) return null; return ( {renderAttachButtonIcon} {addAttachmentLabel} ); } function VoiceButtonIcon({ hovered, isDictating, isMutedRealtime, buttonIconSize, }: { hovered: boolean; isDictating: boolean; isMutedRealtime: boolean; buttonIconSize: number; }) { if (isDictating) { return ; } const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; if (isMutedRealtime) { return ; } return ; } type ShortcutChord = NonNullable["chord"]>; function VoiceTooltipBody({ voiceTooltipText, shortcut, }: { voiceTooltipText: string; shortcut: ShortcutChord | null | undefined; }) { return ( {voiceTooltipText} {shortcut ? : null} ); } function SendTooltipBody({ label, sendKeys, }: { label: string; sendKeys: ShortcutChord | null | undefined; }) { return ( {label} {sendKeys ? : null} ); } function SendButtonContent({ isSubmitLoading, submitIcon, submitLabel, buttonIconSize, }: { isSubmitLoading: boolean; submitIcon: "arrow" | "return"; submitLabel: string | undefined; buttonIconSize: number; }) { if (isSubmitLoading) { return ; } if (submitLabel) { return {submitLabel}; } if (submitIcon === "return") { return ; } return ; } interface DesktopKeyPressContext { onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined; submitOnEnter: boolean; isAgentRunning: boolean; onQueue: ((payload: MessagePayload) => void) | undefined; isSubmitDisabled: boolean; isSubmitLoading: boolean; disabled: boolean; handleAlternateSendAction: () => void; handleDefaultSendAction: () => void; } function handleDesktopKeyPressImpl( event: WebTextInputKeyPressEvent, ctx: DesktopKeyPressContext, ): void { if (isImeComposingKeyboardEvent(event.nativeEvent)) return; if (ctx.onKeyPressCallback) { const handled = ctx.onKeyPressCallback({ key: event.nativeEvent.key, preventDefault: () => event.preventDefault(), }); if (handled) return; } const { shiftKey, metaKey, ctrlKey } = event.nativeEvent; if (event.nativeEvent.key !== "Enter") return; if (!ctx.submitOnEnter) return; if (shiftKey) return; if ((metaKey || ctrlKey) && ctx.isAgentRunning && ctx.onQueue) { if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return; event.preventDefault(); ctx.handleAlternateSendAction(); return; } if (ctx.isSubmitDisabled || ctx.isSubmitLoading || ctx.disabled) return; event.preventDefault(); ctx.handleDefaultSendAction(); } function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTMLElement | null { if (!current) return null; const native = typeof current.getNativeRef === "function" ? current.getNativeRef() : current; return native instanceof HTMLElement ? native : null; } interface PasteImagesEffectArgs { getWebTextArea: () => TextAreaHandle | null; isConnected: boolean; disabled: boolean; isDictating: boolean; isRealtimeVoiceForCurrentAgent: boolean; onAddImages: ((images: ImageAttachment[]) => void) | undefined; } function usePasteImagesEffect(args: PasteImagesEffectArgs): void { const { getWebTextArea, isConnected, disabled, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages, } = args; useEffect(() => { if (!isWeb || !onAddImages) return; const textarea = getWebTextArea() as | (TextAreaHandle & { addEventListener?: (type: string, listener: (e: ClipboardEvent) => void) => void; removeEventListener?: (type: string, listener: (e: ClipboardEvent) => void) => void; }) | null; if ( !textarea || typeof textarea.addEventListener !== "function" || typeof textarea.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((pastedAttachments) => { if (disposed || pastedAttachments.length === 0) return; onAddImages(pastedAttachments); return; }) .catch((error) => { console.error("[MessageInput] Failed to process pasted images:", error); }); }; textarea.addEventListener("paste", handlePaste); return () => { disposed = true; textarea.removeEventListener?.("paste", handlePaste); }; }, [ disabled, getWebTextArea, isConnected, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages, ]); } function useAutoFocusOnWebEffect( textInputRef: React.MutableRefObject, autoFocus: boolean, autoFocusKey: string | undefined, ): void { useEffect(() => { if (!isWeb || !autoFocus) return; return focusWithRetries({ focus: () => textInputRef.current?.focus(), isFocused: () => { const element = getTextInputNativeElement(textInputRef.current); const active = typeof document !== "undefined" ? document.activeElement : null; return Boolean(element) && active === element; }, deferInitialAttempt: true, }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoFocus, autoFocusKey]); } function MessageInputAutoFocus({ enabled, autoFocusKey, textInputRef, }: { enabled: boolean; autoFocusKey: string | undefined; textInputRef: React.MutableRefObject; }) { const { isActiveComposer } = useComposerKeyboardScope(); useAutoFocusOnWebEffect(textInputRef, enabled && isActiveComposer, autoFocusKey); return null; } function MessageInputOverlay({ showDictationOverlay, showRealtimeOverlay, voice, dictationVolume, dictationDuration, isDictating, isDictationProcessing, dictationStatus, dictationError, onCancelRecording, onAcceptRecording, onAcceptAndSendRecording, onRetryFailedRecording, onDiscardFailedRecording, onRealtimeVoiceStop, }: { showDictationOverlay: boolean; showRealtimeOverlay: boolean; voice: | { isMuted: boolean; isVoiceSwitching: boolean; toggleMute: () => void; } | null | undefined; dictationVolume: number; dictationDuration: number; isDictating: boolean; isDictationProcessing: boolean; dictationStatus: React.ComponentProps["status"]; dictationError: string | null; onCancelRecording: () => Promise; onAcceptRecording: () => Promise; onAcceptAndSendRecording: () => Promise; onRetryFailedRecording: () => void; onDiscardFailedRecording: () => void; onRealtimeVoiceStop: () => void; }) { if (showDictationOverlay) { return ( ); } if (showRealtimeOverlay && voice) { return ( ); } return null; } function FocusHint({ visible, focusInputKeys, label, }: { visible: boolean; focusInputKeys: ShortcutChord | null | undefined; label: string; }) { const { isActiveComposer } = useComposerKeyboardScope(); if (!isActiveComposer || !visible || !focusInputKeys || !label.trim()) return null; return ( {label} ); } interface ComposerTextSurfaceProps { readOnly: boolean; value: string; textInputRef: React.Ref; textInputStyle: React.ComponentProps["style"]; readOnlyTextStyle: React.ComponentProps["style"]; placeholder: string; accessibilityLabel: string; onChangeText: (text: string) => void; onFocus: () => void; onBlur: () => void; editable: boolean; scrollEnabled: boolean; autoFocus: boolean; onContentSizeChange: (event: NativeSyntheticEvent) => void; onKeyPress: ((event: WebTextInputKeyPressEvent) => void) | undefined; onSelectionChange: (event: NativeSyntheticEvent) => void; onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined; onPasteError: (message: string) => void; focusHintVisible: boolean; focusInputKeys: ShortcutChord | null | undefined; focusHintLabel: string; } /** * The composer's content: an editable input, or static text when there is * nothing to type. Both sit in the same bordered surface, so read-only is a * state of this composer rather than a second one. */ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElement { if (props.readOnly) { return ( {props.value} ); } return ( ); } function VoiceButtonTooltip({ visible, onVoicePress, isDictationStartEnabled, voiceButtonAccessibilityLabel, voiceButtonStyle, renderVoiceButtonIcon, voiceTooltipText, isRealtimeVoiceForCurrentAgent, voiceMuteToggleKeys, dictationToggleKeys, }: { visible: boolean; onVoicePress: () => void; isDictationStartEnabled: boolean; voiceButtonAccessibilityLabel: string; voiceButtonStyle: React.ComponentProps["style"]; renderVoiceButtonIcon: (input: { hovered?: boolean }) => React.ReactElement; voiceTooltipText: string; isRealtimeVoiceForCurrentAgent: boolean; voiceMuteToggleKeys: ShortcutChord | null | undefined; dictationToggleKeys: ShortcutChord | null | undefined; }) { const shortcut = isRealtimeVoiceForCurrentAgent ? voiceMuteToggleKeys : dictationToggleKeys; if (!visible) return null; return ( {renderVoiceButtonIcon} ); } function SendButtonTooltip({ shouldShow, canPressLoadingButton, onSubmitLoadingPress, onDefaultSendAction, isSendButtonDisabled, submitAccessibilityLabel, sendButtonCombinedStyle, isSubmitLoading, submitIcon, submitLabel, submitButtonTestID, buttonIconSize, sendKeys, sendTooltipLabel, }: { shouldShow: boolean; canPressLoadingButton: boolean; onSubmitLoadingPress: (() => void) | undefined; onDefaultSendAction: () => void; isSendButtonDisabled: boolean; submitAccessibilityLabel: string; sendButtonCombinedStyle: React.ComponentProps["style"]; isSubmitLoading: boolean; submitIcon: "arrow" | "return"; submitLabel: string | undefined; submitButtonTestID: string | undefined; buttonIconSize: number; sendKeys: ShortcutChord | null | undefined; sendTooltipLabel: string; }) { if (!shouldShow) return null; return ( ); } type PrimaryActionKind = "send" | "active" | "none"; function hasSendableComposerContent(input: { value: string; attachments: readonly ComposerAttachment[]; hasExternalContent: boolean; }): boolean { return input.value.trim().length > 0 || input.attachments.length > 0 || input.hasExternalContent; } function resolvePrimaryActionKind(input: { hasSendableContent: boolean; allowEmptySubmit: boolean; isAgentRunning: boolean; isSubmitLoading: boolean; }): PrimaryActionKind { if (input.hasSendableContent || input.allowEmptySubmit) return "send"; if (input.isAgentRunning) return "active"; if (input.isSubmitLoading) return "send"; return "none"; } function PrimaryAction({ kind, activeActionContent, ...sendButtonProps }: { kind: PrimaryActionKind; activeActionContent: React.ReactNode; } & React.ComponentProps) { if (kind === "active") return activeActionContent; if (kind === "send") return ; return null; } interface ToggleRealtimeVoiceContext { voice: | { isVoiceSwitching: boolean; isVoiceModeForAgent: (serverId: string, agentId: string) => boolean; startVoice: (serverId: string, agentId: string) => Promise; } | null | undefined; voiceServerId: string | undefined; voiceAgentId: string | undefined; isConnected: boolean; disabled: boolean; isAgentRunning: boolean; handleStopRealtimeVoice: () => Promise | void; toast: { error: (msg: string) => void }; interruptBeforeVoiceMessage: string; } function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void { if (!ctx.voice || !ctx.voiceServerId || !ctx.voiceAgentId || !ctx.isConnected || ctx.disabled) { return; } if (ctx.voice.isVoiceSwitching) return; if (ctx.voice.isVoiceModeForAgent(ctx.voiceServerId, ctx.voiceAgentId)) { void ctx.handleStopRealtimeVoice(); return; } if (ctx.isAgentRunning) { ctx.toast.error(ctx.interruptBeforeVoiceMessage); return; } void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => { console.error("[MessageInput] Failed to start realtime voice", error); const message = extractErrorMessage(error); if (message && message.trim().length > 0) { ctx.toast.error(message); } }); } interface StartDictationContext { dictationUnavailableMessage: string | null | undefined; canStartDictation: () => boolean; toast: { error: (msg: string) => void }; startDictation: () => Promise; } async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise { if (ctx.dictationUnavailableMessage) { ctx.toast.error(ctx.dictationUnavailableMessage); return; } if (!ctx.canStartDictation()) { return; } await ctx.startDictation(); } interface VoicePressContext { isRealtimeVoiceForCurrentAgent: boolean; voice: { toggleMute: () => void } | null | undefined; isDictating: boolean; cancelDictation: () => Promise | void; startDictationIfAvailable: () => Promise; } async function handleVoicePressImpl(ctx: VoicePressContext): Promise { if (ctx.isRealtimeVoiceForCurrentAgent && ctx.voice) { ctx.voice.toggleMute(); return; } if (ctx.isDictating) { await ctx.cancelDictation(); return; } await ctx.startDictationIfAvailable(); } interface SendMessageContext { value: string; attachments: ComposerAttachment[]; hasExternalContent: boolean; allowEmptySubmit: boolean; cwd: string; isAgentRunning: boolean; onSubmit: (payload: MessagePayload) => void; onMinimizeHeight: () => void; preserveHeightOnSubmit: boolean; } function sendMessageImpl(ctx: SendMessageContext): void { const trimmed = ctx.value.trim(); if ( !trimmed && ctx.attachments.length === 0 && !ctx.hasExternalContent && !ctx.allowEmptySubmit ) { return; } ctx.onSubmit({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd, forceSend: ctx.isAgentRunning || undefined, }); // When the host preserves and locks the composer (e.g. new-workspace creation), // the text stays put — collapsing the height would clip it. Keep it grown. if (!ctx.preserveHeightOnSubmit) { ctx.onMinimizeHeight(); } } interface QueueMessageContext { value: string; attachments: ComposerAttachment[]; cwd: string; onQueue: ((payload: MessagePayload) => void) | undefined; replaceText: (text: string) => void; onMinimizeHeight: () => void; } function queueMessageImpl(ctx: QueueMessageContext): void { if (!ctx.onQueue) return; const trimmed = ctx.value.trim(); if (!trimmed && ctx.attachments.length === 0) return; ctx.onQueue({ text: trimmed, attachments: ctx.attachments, cwd: ctx.cwd }); ctx.replaceText(""); ctx.onMinimizeHeight(); } function computeIsRealtimeVoiceForAgent( voice: { isVoiceModeForAgent: (serverId: string, agentId: string) => boolean } | null | undefined, voiceServerId: string | undefined, voiceAgentId: string | undefined, ): boolean { if (!voice || !voiceServerId || !voiceAgentId) return false; return voice.isVoiceModeForAgent(voiceServerId, voiceAgentId); } function computeShouldShowDictationOverlay( isDictating: boolean, isDictationProcessing: boolean, dictationStatus: string, ): boolean { return isDictating || isDictationProcessing || dictationStatus === "failed"; } function computeIsDictationStartEnabled( isReadyForDictation: boolean | undefined, isConnected: boolean, disabled: boolean, ): boolean { return (isReadyForDictation ?? isConnected) && !disabled; } function resolveMaxInputHeight(windowHeight: number): number { if (!Number.isFinite(windowHeight) || windowHeight <= 0) return DEFAULT_MAX_INPUT_HEIGHT; return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO)); } function computeTextInputHeightStyle(inputHeight: number, maxInputHeight: number) { if (isWeb) { return { height: inputHeight, minHeight: MIN_INPUT_HEIGHT, maxHeight: maxInputHeight, }; } return { minHeight: MIN_INPUT_HEIGHT, maxHeight: maxInputHeight, }; } function isTextAreaLike(v: unknown): v is TextAreaHandle { return typeof v === "object" && v !== null && "scrollHeight" in v; } function getWebTextAreaImpl(current: ComposerTextInputHandle | null): TextAreaHandle | null { if (!current) return null; const candidate = current as { getNativeRef?: () => unknown }; if (typeof candidate.getNativeRef === "function") { const native = candidate.getNativeRef(); if (isTextAreaLike(native)) return native; } if (isTextAreaLike(current)) return current; return null; } interface SendButtonStateInput { disabled: boolean; isSubmitDisabled: boolean; isSubmitLoading: boolean; onSubmitLoadingPress: (() => void) | undefined; defaultSendBehavior: "interrupt" | "queue"; isAgentRunning: boolean; } interface SendButtonStateOutput { canPressLoadingButton: boolean; isSendButtonDisabled: boolean; defaultActionQueues: boolean; } function computeSendButtonState(input: SendButtonStateInput): SendButtonStateOutput { const canPressLoadingButton = input.isSubmitLoading && typeof input.onSubmitLoadingPress === "function"; const isSendButtonDisabled = input.disabled || (!canPressLoadingButton && (input.isSubmitDisabled || input.isSubmitLoading)); const defaultActionQueues = input.defaultSendBehavior === "queue" && input.isAgentRunning; return { canPressLoadingButton, isSendButtonDisabled, defaultActionQueues }; } interface ResolvedMessageInputProps { value: string; onChangeText: (text: string) => void; onSubmit: (payload: MessagePayload) => void; hasExternalContent: boolean; allowEmptySubmit: boolean; submitButtonAccessibilityLabel: string | undefined; submitButtonTestID: string | undefined; submitIcon: "arrow" | "return"; isSubmitDisabled: boolean; isSubmitLoading: boolean; preserveHeightOnSubmit: boolean; attachments: ComposerAttachment[]; cwd: string; attachmentMenuItems: AttachmentMenuItem[]; onAttachButtonRef: ((node: View | null) => void) | undefined; onAddImages: ((images: ImageAttachment[]) => void) | undefined; onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined; client: DaemonClient | null; isReadyForDictation: boolean | undefined; placeholder: string | undefined; autoFocus: boolean; autoFocusKey: string | undefined; disabled: boolean; leftContent: React.ReactNode; beforeVoiceContent: React.ReactNode; rightContent: React.ReactNode; activeActionContent: React.ReactNode; voiceServerId: string | undefined; voiceAgentId: string | undefined; isAgentRunning: boolean; defaultSendBehavior: "interrupt" | "queue"; onQueue: ((payload: MessagePayload) => void) | undefined; onSubmitLoadingPress: (() => void) | undefined; onKeyPressCallback: ((event: { key: string; preventDefault: () => void }) => boolean) | undefined; onSelectionChangeCallback: ((selection: { start: number; end: number }) => void) | undefined; onFocusChange: ((focused: boolean) => void) | undefined; onHeightChange: ((height: number) => void) | undefined; inputWrapperStyle: import("react-native").ViewStyle | undefined; attachmentSlot: React.ReactNode; inputMode: ComposerInputMode; readOnly: boolean; textReplacementKey: string; submitLabel: string | undefined; } function resolveMessageInputProps(props: MessageInputProps): ResolvedMessageInputProps { return { value: props.value, onChangeText: props.onChangeText, onSubmit: props.onSubmit, hasExternalContent: props.hasExternalContent ?? false, allowEmptySubmit: props.allowEmptySubmit ?? false, submitButtonAccessibilityLabel: props.submitButtonAccessibilityLabel, submitButtonTestID: props.submitButtonTestID, submitIcon: props.submitIcon ?? "arrow", isSubmitDisabled: props.isSubmitDisabled ?? false, isSubmitLoading: props.isSubmitLoading ?? false, preserveHeightOnSubmit: props.preserveHeightOnSubmit ?? false, attachments: props.attachments, cwd: props.cwd, attachmentMenuItems: props.attachmentMenuItems, onAttachButtonRef: props.onAttachButtonRef, onAddImages: props.onAddImages, onPasteImages: props.onPasteImages, client: props.client, isReadyForDictation: props.isReadyForDictation, placeholder: props.placeholder, autoFocus: props.autoFocus ?? false, autoFocusKey: props.autoFocusKey, disabled: props.disabled ?? false, leftContent: props.leftContent, beforeVoiceContent: props.beforeVoiceContent, rightContent: props.rightContent, activeActionContent: props.activeActionContent, voiceServerId: props.voiceServerId, voiceAgentId: props.voiceAgentId, isAgentRunning: props.isAgentRunning ?? false, defaultSendBehavior: props.defaultSendBehavior ?? "interrupt", onQueue: props.onQueue, onSubmitLoadingPress: props.onSubmitLoadingPress, onKeyPressCallback: props.onKeyPress, onSelectionChangeCallback: props.onSelectionChange, onFocusChange: props.onFocusChange, onHeightChange: props.onHeightChange, inputWrapperStyle: props.inputWrapperStyle, attachmentSlot: props.attachmentSlot, inputMode: props.inputMode ?? "chat", readOnly: props.readOnly ?? false, textReplacementKey: props.textReplacementKey, submitLabel: props.submitLabel, }; } function extractErrorMessage(error: unknown): string | null { if (error instanceof Error) return error.message; if (typeof error === "string") return error; return null; } export const MessageInput = forwardRef( function MessageInput(props, ref) { const { value, onChangeText, onSubmit, hasExternalContent, allowEmptySubmit, submitButtonAccessibilityLabel, submitButtonTestID, submitIcon, isSubmitDisabled, isSubmitLoading, preserveHeightOnSubmit, attachments, cwd, attachmentMenuItems, onAttachButtonRef, onAddImages, onPasteImages, client, isReadyForDictation, placeholder, autoFocus, autoFocusKey, disabled, leftContent, beforeVoiceContent, rightContent, activeActionContent, voiceServerId, voiceAgentId, isAgentRunning, defaultSendBehavior, onQueue, onSubmitLoadingPress, onKeyPressCallback, onSelectionChangeCallback, onFocusChange, onHeightChange, inputWrapperStyle, attachmentSlot, inputMode, readOnly, textReplacementKey, submitLabel, } = resolveMessageInputProps(props); const mode = resolveComposerInputMode(inputMode); const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); const { height: windowHeight } = useWindowDimensions(); const maxInputHeight = resolveMaxInputHeight(windowHeight); const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg; const toast = useToast(); const voice = useVoiceOptional(); const voiceMuteToggleKeys = useShortcutKeys("voice-mute-toggle"); const dictationToggleKeys = useShortcutKeys("dictation-toggle"); const focusInputKeys = useShortcutKeys("focus-message-input"); const [inputHeight, setInputHeight] = useState(MIN_INPUT_HEIGHT); const [isInputFocused, setIsInputFocused] = useState(false); const rootRef = useRef(null); const inputWrapperRef = useRef(null); const textInputRef = useRef(null); const isInputFocusedRef = useRef(false); const valueRef = useRef(value); const appliedTextReplacementKeyRef = useRef(textReplacementKey); const replaceText = useCallback( (nextText: string, selection?: { start: number; end: number }) => { valueRef.current = nextText; textInputRef.current?.replaceText(nextText, selection); onChangeText(nextText); }, [onChangeText], ); useImperativeHandle(ref, () => ({ focus: () => { textInputRef.current?.focus(); }, blur: () => { textInputRef.current?.blur(); }, getText: () => textInputRef.current?.getText() ?? valueRef.current, replaceText, runKeyboardAction: (action) => runMessageInputKeyboardAction(action, { focusInput: () => textInputRef.current?.focus(), isDictationRecording: isDictationActive, markTranscriptForSend: () => { sendAfterTranscriptRef.current = true; }, confirmDictation, cancelDictation, startDictation: startDictationIfAvailable, toggleRealtimeVoice: handleToggleRealtimeVoiceShortcut, isRealtimeVoiceActive: isRealtimeVoiceForCurrentAgent, toggleRealtimeVoiceMute: () => voice?.toggleMute(), }), getNativeElement: () => (isWeb ? getTextInputNativeElement(textInputRef.current) : null), })); const inputHeightRef = useRef(MIN_INPUT_HEIGHT); const sendAfterTranscriptRef = useRef(false); const serverInfo = useSessionStore( useCallback( (state) => { if (!voiceServerId) { return null; } return state.sessions[voiceServerId]?.serverInfo ?? null; }, [voiceServerId], ), ); useEffect(() => { if (appliedTextReplacementKeyRef.current === textReplacementKey) return; appliedTextReplacementKeyRef.current = textReplacementKey; valueRef.current = value; textInputRef.current?.replaceText(value); }, [textReplacementKey, value]); useEffect(() => { return () => { onFocusChange?.(false); }; }, [onFocusChange]); const handleDictationTranscript = useCallback( (text: string, _meta: { requestId: string }) => { const autoSend = sendAfterTranscriptRef.current; sendAfterTranscriptRef.current = false; applyDictationTranscript(text, { value: valueRef.current, defaultSendBehavior, isAgentRunning, onQueue, onSubmit, replaceText, attachments, cwd, autoSend, }); }, [replaceText, onSubmit, onQueue, attachments, cwd, isAgentRunning, defaultSendBehavior], ); const handleDictationError = useCallback( (error: Error) => { console.error("[MessageInput] Dictation error:", error); toast.error(error.message); }, [toast], ); const dictationUnavailableMessage = resolveVoiceUnavailableMessage({ serverInfo, mode: "dictation", }); const canStartDictation = useCallback( () => computeCanStartDictation({ client, isReadyForDictation, disabled, dictationUnavailableMessage, }), [client, disabled, dictationUnavailableMessage, isReadyForDictation], ); const canConfirmDictation = useCallback(() => client?.isConnected ?? false, [client]); const isConnected = client?.isConnected ?? false; const isDictationStartEnabled = computeIsDictationStartEnabled( isReadyForDictation, isConnected, disabled, ); const { isRecording: isDictating, isRecordingActive: isDictationActive, 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, enableDuration: true, }); const isRealtimeVoiceForCurrentAgent = computeIsRealtimeVoiceForAgent( voice, voiceServerId, voiceAgentId, ); const showDictationOverlay = computeShouldShowDictationOverlay( isDictating, isDictationProcessing, dictationStatus, ); const showRealtimeOverlay = isRealtimeVoiceForCurrentAgent; const showOverlay = showDictationOverlay || showRealtimeOverlay; const surfacePresentation = resolveComposerSurfacePresentation(showOverlay); useEffect(() => { if (isDictating || isDictationProcessing) { return; } sendAfterTranscriptRef.current = false; }, [dictationStatus, isDictating, isDictationProcessing]); const startDictationIfAvailable = useCallback( () => startDictationIfAvailableImpl({ dictationUnavailableMessage, canStartDictation, toast, startDictation, }), [canStartDictation, dictationUnavailableMessage, startDictation, toast], ); const handleVoicePress = useCallback( () => handleVoicePressImpl({ isRealtimeVoiceForCurrentAgent, voice, isDictating, cancelDictation, startDictationIfAvailable, }), [ cancelDictation, isDictating, isRealtimeVoiceForCurrentAgent, startDictationIfAvailable, voice, ], ); const handleCancelRecording = useCallback(async () => { await cancelDictation(); }, [cancelDictation]); const handleAcceptRecording = useCallback(async () => { sendAfterTranscriptRef.current = false; await confirmDictation(); }, [confirmDictation]); const handleAcceptAndSendRecording = useCallback(async () => { sendAfterTranscriptRef.current = true; await confirmDictation(); }, [confirmDictation]); const handleRetryFailedRecording = useCallback(() => { void retryFailedDictation(); }, [retryFailedDictation]); const handleDiscardFailedRecording = useCallback(() => { discardFailedDictation(); }, [discardFailedDictation]); const handleStopRealtimeVoice = useCallback(async () => { try { await stopRealtimeVoice({ voice, isRealtimeVoiceForCurrentAgent, isAgentRunning, client, voiceAgentId, }); } catch (error) { console.error("[MessageInput] Failed to stop realtime voice", error); const message = extractErrorMessage(error); if (message && message.trim().length > 0) { toast.error(message); } } }, [client, isAgentRunning, isRealtimeVoiceForCurrentAgent, toast, voice, voiceAgentId]); const handleToggleRealtimeVoiceShortcut = useCallback(() => { toggleRealtimeVoiceImpl({ voice, voiceServerId, voiceAgentId, isConnected, disabled, isAgentRunning, handleStopRealtimeVoice, toast, interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"), }); }, [ disabled, handleStopRealtimeVoice, isAgentRunning, isConnected, t, toast, voice, voiceAgentId, voiceServerId, ]); const minimizeInputHeight = useCallback(() => { inputHeightRef.current = MIN_INPUT_HEIGHT; setInputHeight(MIN_INPUT_HEIGHT); onHeightChange?.(MIN_INPUT_HEIGHT); }, [onHeightChange]); const handleSendMessage = useCallback( () => sendMessageImpl({ value: textInputRef.current?.getText() ?? valueRef.current, attachments, hasExternalContent, allowEmptySubmit, cwd, isAgentRunning, onSubmit, onMinimizeHeight: minimizeInputHeight, preserveHeightOnSubmit, }), [ allowEmptySubmit, attachments, cwd, onSubmit, isAgentRunning, hasExternalContent, minimizeInputHeight, preserveHeightOnSubmit, ], ); const handleQueueMessage = useCallback( () => queueMessageImpl({ value: textInputRef.current?.getText() ?? valueRef.current, attachments, cwd, onQueue, replaceText, onMinimizeHeight: minimizeInputHeight, }), [attachments, cwd, onQueue, replaceText, minimizeInputHeight], ); const handleDefaultSendAction = useCallback(() => { runDefaultSendAction({ defaultSendBehavior, isAgentRunning, onQueue, handleSendMessage, handleQueueMessage, }); }, [defaultSendBehavior, isAgentRunning, onQueue, handleQueueMessage, handleSendMessage]); const handleAlternateSendAction = useCallback(() => { runAlternateSendAction({ defaultSendBehavior, isAgentRunning, onQueue, handleSendMessage, handleQueueMessage, }); }, [defaultSendBehavior, isAgentRunning, handleSendMessage, handleQueueMessage, onQueue]); const getWebTextArea = useCallback( (): TextAreaHandle | null => getWebTextAreaImpl(textInputRef.current), [], ); const webTextareaRef = useRef(null); useLayoutEffect(() => { if (isWeb) { webTextareaRef.current = getWebTextArea() as HTMLElement | null; } }, [getWebTextArea]); usePasteImagesEffect({ getWebTextArea, isConnected, disabled, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages, }); const setBoundedInputHeight = useCallback( (nextHeight: number) => { const bounded = Math.max(MIN_INPUT_HEIGHT, Math.min(maxInputHeight, nextHeight)); if (Math.abs(inputHeightRef.current - bounded) < 1) return; inputHeightRef.current = bounded; setInputHeight(bounded); onHeightChange?.(bounded); }, [maxInputHeight, onHeightChange], ); useEffect(() => { setBoundedInputHeight(inputHeightRef.current); }, [setBoundedInputHeight]); useComposerHeightMirror({ value, textareaRef: webTextareaRef, minHeight: MIN_INPUT_HEIGHT, maxHeight: maxInputHeight, onHeight: setBoundedInputHeight, }); const handleContentSizeChange = useCallback( (event: NativeSyntheticEvent) => { if (isWeb) return; setBoundedInputHeight(event.nativeEvent.contentSize.height); }, [setBoundedInputHeight], ); const handleSelectionChange = useCallback( (event: NativeSyntheticEvent) => { const start = event.nativeEvent.selection?.start ?? 0; const end = event.nativeEvent.selection?.end ?? start; onSelectionChangeCallback?.({ start, end }); }, [onSelectionChangeCallback], ); const shouldHandleWebKeyPress = isWeb; const shouldSubmitOnEnter = isWeb && !isCompact; function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) { if (!shouldHandleWebKeyPress) return; handleDesktopKeyPressImpl(event, { onKeyPressCallback, submitOnEnter: shouldSubmitOnEnter, isAgentRunning, onQueue, isSubmitDisabled, isSubmitLoading, disabled, handleAlternateSendAction, handleDefaultSendAction, }); } const primaryActionKind = resolvePrimaryActionKind({ hasSendableContent: hasSendableComposerContent({ value, attachments, hasExternalContent, }), allowEmptySubmit, isAgentRunning, isSubmitLoading, }); const { canPressLoadingButton, isSendButtonDisabled, defaultActionQueues } = computeSendButtonState({ disabled, isSubmitDisabled, isSubmitLoading, onSubmitLoadingPress, defaultSendBehavior, isAgentRunning, }); useIosHardwareKeyboardSubmit({ isEnabled: isInputFocused && !isSendButtonDisabled, onSubmit: handleDefaultSendAction, }); const submitAccessibilityLabel = resolveSubmitAccessibilityLabel({ submitButtonAccessibilityLabel, canPressLoadingButton, defaultActionQueues, isAgentRunning, t, }); const voiceButtonAccessibilityLabel = resolveVoiceAccessibilityLabel({ isRealtimeVoiceForCurrentAgent, isMuted: Boolean(voice?.isMuted), isDictating, t, }); const voiceTooltipText = resolveVoiceTooltipText({ isRealtimeVoiceForCurrentAgent, isMuted: Boolean(voice?.isMuted), t, }); const sendTooltipLabel = resolveSendTooltipLabel({ submitButtonAccessibilityLabel, defaultActionQueues, t, }); const handleInputChange = useCallback( (nextValue: string) => { valueRef.current = nextValue; onChangeText(nextValue); }, [onChangeText], ); const handleInputFocus = useCallback(() => { isInputFocusedRef.current = true; setIsInputFocused(true); onFocusChange?.(true); }, [onFocusChange]); const handleInputBlur = useCallback(() => { isInputFocusedRef.current = false; setIsInputFocused(false); onFocusChange?.(false); }, [onFocusChange]); const handlePasteError = useCallback( (message: string) => { console.error("[MessageInput] Native paste failed:", message); toast.error(t("composer.errors.pasteImageFailed")); }, [t, toast], ); const attachButtonStyle = useCallback( ({ hovered }: { hovered?: boolean }) => [ styles.attachButton, Boolean(hovered) && styles.iconButtonHovered, (!isConnected || disabled) && styles.buttonDisabled, ], [isConnected, disabled], ); const voiceButtonStyle = useCallback( ({ hovered }: { hovered?: boolean }) => [ styles.voiceButton, Boolean(hovered) && !isDictating && styles.iconButtonHovered, !isDictationStartEnabled && styles.buttonDisabled, isDictating && styles.voiceButtonRecording, ], [isDictating, isDictationStartEnabled], ); const handleRealtimeVoiceStop = useCallback(() => { void handleStopRealtimeVoice(); }, [handleStopRealtimeVoice]); const inputWrapperCombinedStyle = useMemo( () => [ styles.inputWrapper, readOnly && styles.inputWrapperReadOnly, inputWrapperStyle, { opacity: surfacePresentation.input.opacity }, ], [inputWrapperStyle, readOnly, surfacePresentation.input.opacity], ); // `withUnistyles` maps this component's `style` into a `.hash > *` child // rule, which ties on specificity with react-native-web's own // `.css-textinput-*` class and loses on source order — so a themed // `fontFamily` here is silently dropped while every other property lands. // An inline style outranks both classes. See docs/unistyles.md. const textInputStyle = useMemo( () => [ styles.textInput, mode.isMonospace && styles.textInputMonospace, computeTextInputHeightStyle(inputHeight, maxInputHeight), ], [inputHeight, maxInputHeight, mode.isMonospace], ); // Static content has no textarea to mirror, so it grows with its own text // instead of the measured input height. const readOnlyTextStyle = useMemo( () => [styles.textInput, mode.isMonospace && styles.textInputMonospace, styles.readOnlyText], [mode.isMonospace], ); const sendButtonCombinedStyle = useMemo( () => [ styles.sendButton, submitLabel ? styles.sendButtonLabeled : undefined, isSendButtonDisabled && styles.buttonDisabled, ], [isSendButtonDisabled, submitLabel], ); const overlayContainerStyle = useMemo( () => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }], [surfacePresentation.overlay.opacity], ); const renderAttachButtonIcon = useCallback( ({ hovered }: { hovered?: boolean }) => ( ), [onAttachButtonRef, buttonIconSize], ); const renderVoiceButtonIcon = useCallback( ({ hovered }: { hovered?: boolean }) => ( ), [isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize], ); return ( {/* Regular input */} {attachmentSlot} {/* Text input */} = maxInputHeight : true} autoFocus={false} onContentSizeChange={handleContentSizeChange} onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined} onSelectionChange={handleSelectionChange} onPasteImages={onPasteImages} onPasteError={handlePasteError} focusHintVisible={isWeb && !isInputFocused && !value} focusInputKeys={focusInputKeys} focusHintLabel={t("composer.input.focusHint", { shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "", })} /> {/* Button row */} {/* Toolbar left: attachment button + agent controls */} {leftContent} {/* Right: voice button, contextual button (realtime/send/cancel) */} {beforeVoiceContent} {rightContent} ); }, ); const styles = StyleSheet.create((theme: Theme) => ({ 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], }, ...(isWeb ? { transitionProperty: "border-color", transitionDuration: "200ms", transitionTimingFunction: "ease-in-out", } : {}), }, // Dotted says "this surface is the same box, but there is nothing to type // into it" without swapping the border colour, which reads as an error state. inputWrapperReadOnly: { borderStyle: "dotted", }, textInputScrollWrapper: { position: "relative", }, focusHintText: { position: "absolute", top: 0, right: 0, fontSize: theme.fontSize.xs, color: theme.colors.foregroundMuted, opacity: 0.5, }, textInput: { width: "100%", color: theme.colors.foreground, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, lineHeight: theme.fontSize.base * 1.4, ...(isWeb ? ({ outlineStyle: "none", outlineWidth: 0, outlineColor: "transparent", } as object) : {}), }, textInputMonospace: { fontFamily: theme.fontFamily.mono, }, readOnlyText: { minHeight: MIN_INPUT_HEIGHT, color: theme.colors.foregroundMuted, }, buttonRow: { flexDirection: "row", alignItems: "flex-end", justifyContent: "space-between", marginHorizontal: -6, }, leftButtonGroup: { minWidth: 0, flexShrink: 1, flexGrow: 1, flexDirection: "row", alignItems: "flex-end", gap: theme.spacing[0], }, rightButtonGroup: { flexShrink: 0, flexDirection: "row", alignItems: "center", gap: theme.spacing[1], }, attachButton: { width: 28, height: 28, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, attachButtonAnchor: { width: 28, height: 28, alignItems: "center", justifyContent: "center", }, voiceButton: { width: 28, height: 28, borderRadius: theme.borderRadius.full, alignItems: "center", justifyContent: "center", }, voiceButtonRecording: { backgroundColor: theme.colors.destructive, }, sendButton: { width: 28, height: 28, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.accent, alignItems: "center", justifyContent: "center", marginLeft: theme.spacing[1], }, sendButtonLabeled: { width: "auto", minWidth: 28, paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.full, }, sendButtonLabel: { fontSize: theme.fontSize.sm, fontWeight: theme.fontWeight.medium, color: theme.colors.accentForeground, }, iconButtonHovered: { backgroundColor: theme.colors.surface2, }, tooltipRow: { flexDirection: "row", alignItems: "center", gap: theme.spacing[2], }, tooltipText: { fontSize: theme.fontSize.sm, color: theme.colors.popoverForeground, }, 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 unknown as Record; const ThemedPlus = withUnistyles(Plus); const ThemedMic = withUnistyles(Mic); const ThemedMicOff = withUnistyles(MicOff); const ThemedArrowUp = withUnistyles(ArrowUp); const ThemedCornerDownLeft = withUnistyles(CornerDownLeft); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); const iconForegroundMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const iconForegroundMutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const iconAccentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });