import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import ReactDOM from 'react-dom'; import TextareaAutosize from 'react-textarea-autosize'; import { Button } from './ui/button'; import { ChatInputContainer, ButtonFooter, AttachButton, RecordButton } from './ChatInput'; import ModelPickerModal from './ModelPicker'; import { SendHorizontal, X, Loader2, AlertCircle, Square, FileAudio, File, Brain, } from 'lucide-react'; import { Alert, AlertDescription } from './ui/alert'; import { useChatContext } from './hooks/ChatContext'; import { useFontsReady } from './hooks/useFontsReady'; import { cn, filterAndSortResources } from '../lib/utils'; import { useCurrentSessionId, useSessionProcessing } from '@/lib/stores'; import { useCurrentLLM } from './hooks/useCurrentLLM'; import ResourceAutocomplete from './ResourceAutocomplete'; import type { ResourceMetadata as UIResourceMetadata } from '@dexto/core'; import { useResources } from './hooks/useResources'; import SlashCommandAutocomplete from './SlashCommandAutocomplete'; import { isTextPart, isImagePart, isFilePart } from '../types'; import CreatePromptModal from './CreatePromptModal'; import CreateMemoryModal from './CreateMemoryModal'; import { parseSlashInput, splitKeyValueAndPositional } from '../lib/parseSlash'; import { useAnalytics } from '@/lib/analytics/index.js'; import { useQueryClient } from '@tanstack/react-query'; import { queryKeys } from '@/lib/queryKeys'; import { useModelCapabilities } from './hooks/useLLM'; import { useResolvePrompt } from './hooks/usePrompts'; import { useInputHistory } from './hooks/useInputHistory'; import { useQueuedMessages, useRemoveQueuedMessage, useQueueMessage } from './hooks/useQueue'; import { QueuedMessagesDisplay } from './QueuedMessagesDisplay'; interface InputAreaProps { onSend: ( content: string, imageData?: { image: string; mimeType: string }, fileData?: { data: string; mimeType: string; filename?: string } ) => void; isSending?: boolean; variant?: 'welcome' | 'chat'; isSessionsPanelOpen?: boolean; } export default function InputArea({ onSend, isSending, isSessionsPanelOpen = false, }: InputAreaProps) { const queryClient = useQueryClient(); const [text, setText] = useState(''); const textareaRef = useRef(null); const [imageData, setImageData] = useState<{ image: string; mimeType: string } | null>(null); const [fileData, setFileData] = useState<{ data: string; mimeType: string; filename?: string; } | null>(null); const fileInputRef = useRef(null); const pdfInputRef = useRef(null); const audioInputRef = useRef(null); // TODO(unify-fonts): Defer autosize until fonts are ready to avoid // initial one-line height jump due to font swap metrics. Remove this // once the app uses a single font pipeline without swap. // Currently it looks like only 'Welcome to Dexto' is using the older font - (checked with chrome dev tools) const fontsReady = useFontsReady(); // Audio recording state const [isRecording, setIsRecording] = useState(false); const mediaRecorderRef = useRef(null); // Get state from centralized selectors const currentSessionId = useCurrentSessionId(); const processing = useSessionProcessing(currentSessionId); // Get actions from ChatContext const { cancel } = useChatContext(); // Get state from stores and hooks const { data: currentLLM } = useCurrentLLM(currentSessionId); // Input history for Up/Down navigation const { invalidateHistory, navigateUp, navigateDown, resetCursor, isBrowsing } = useInputHistory(currentSessionId); // Queue management const { data: queueData } = useQueuedMessages(currentSessionId); const { mutate: removeQueuedMessage } = useRemoveQueuedMessage(); const { mutate: queueMessage } = useQueueMessage(); const queuedMessages = useMemo(() => queueData?.messages ?? [], [queueData?.messages]); // Analytics tracking const analytics = useAnalytics(); const analyticsRef = useRef(analytics); // Keep analytics ref up to date to avoid stale closure issues useEffect(() => { analyticsRef.current = analytics; }, [analytics]); // LLM selector state const [modelSwitchError, setModelSwitchError] = useState(null); const [fileUploadError, setFileUploadError] = useState(null); const [supportedFileTypes, setSupportedFileTypes] = useState([]); // Resources (for @ mention autocomplete) const { resources, loading: resourcesLoading, refresh: refreshResources } = useResources(); const [mentionQuery, setMentionQuery] = useState(''); const [showMention, setShowMention] = useState(false); const [mentionIndex, setMentionIndex] = useState(0); const [dropdownStyle, setDropdownStyle] = useState(null); // Memoize filtered resources to avoid re-sorting on every keypress const filteredResources = useMemo( () => filterAndSortResources(resources, mentionQuery), [resources, mentionQuery] ); const findActiveAtIndex = (value: string, caret: number) => { // Walk backwards from caret to find an '@' // @ is only valid if: // 1. At the start of the message (i === 0), OR // 2. Preceded by whitespace for (let i = caret - 1; i >= 0; i--) { const ch = value[i]; if (ch === '@') { // Check if @ is at start or preceded by whitespace if (i === 0) { return i; // @ at start is valid } const prev = value[i - 1]; if (/\s/.test(prev)) { return i; // @ after whitespace is valid } return -1; // @ in middle of text (like email) - ignore } if (/\s/.test(ch)) break; // stop at whitespace } return -1; }; // File size limit (64MB) const MAX_FILE_SIZE = 64 * 1024 * 1024; // 64MB in bytes // Slash command state const [showSlashCommands, setShowSlashCommands] = useState(false); const [showCreatePromptModal, setShowCreatePromptModal] = useState(false); const [slashRefreshKey, setSlashRefreshKey] = useState(0); // Memory state const [showCreateMemoryModal, setShowCreateMemoryModal] = useState(false); // Prompt resolution mutation const resolvePromptMutation = useResolvePrompt(); const showUserError = (message: string) => { setFileUploadError(message); // Auto-clear error after 5 seconds setTimeout(() => setFileUploadError(null), 5000); }; const openCreatePromptModal = React.useCallback(() => { setShowSlashCommands(false); setShowCreatePromptModal(true); }, []); const handlePromptCreated = React.useCallback( (prompt: { name: string; arguments?: Array<{ name: string; required?: boolean }> }) => { // Manual cache invalidation needed for custom prompt creation (not triggered by SSE events) queryClient.invalidateQueries({ queryKey: queryKeys.prompts.all }); setShowCreatePromptModal(false); setSlashRefreshKey((prev) => prev + 1); const slashCommand = `/${prompt.name}`; setText(slashCommand); if (textareaRef.current) { textareaRef.current.focus(); textareaRef.current.setSelectionRange(slashCommand.length, slashCommand.length); } }, [queryClient] ); const handleCloseCreatePrompt = React.useCallback(() => { setShowCreatePromptModal(false); if (text === '/') { setText(''); } }, [text]); // Fetch model capabilities (supported file types) via dedicated endpoint // This handles gateway providers (dexto, openrouter) by resolving to underlying model capabilities const { data: capabilities } = useModelCapabilities(currentLLM?.provider, currentLLM?.model); // Extract supported file types from capabilities useEffect(() => { if (capabilities?.supportedFileTypes) { setSupportedFileTypes(capabilities.supportedFileTypes); } else { setSupportedFileTypes([]); } }, [capabilities]); // NOTE: We intentionally do not manually resize the textarea. We rely on // CSS max-height + overflow to keep layout stable. const handleSend = async () => { let trimmed = text.trim(); // Allow sending if we have text OR any attachment if (!trimmed && !imageData && !fileData) return; // If slash command typed, resolve to full prompt content at send time if (trimmed === '/') { openCreatePromptModal(); return; } else if (trimmed.startsWith('/')) { const parsed = parseSlashInput(trimmed); const name = parsed.command; // Preserve original suffix including quotes/spacing (trim only leading space) const originalArgsText = trimmed.slice(1 + name.length).trimStart(); if (name) { try { const result = await resolvePromptMutation.mutateAsync({ name, context: originalArgsText || undefined, args: parsed.argsArray && parsed.argsArray.length > 0 ? (() => { const { keyValues, positional } = splitKeyValueAndPositional( parsed.argsArray ); const argsPayload: Record = { ...keyValues }; if (positional.length > 0) argsPayload._positional = positional; return Object.keys(argsPayload).length > 0 ? JSON.stringify(argsPayload) : undefined; })() : undefined, }); if (result.text.trim()) { trimmed = result.text; } } catch { // keep original } } } // Auto-queue: if session is busy processing, queue the message instead of sending if (processing && currentSessionId) { queueMessage({ sessionId: currentSessionId, message: trimmed || undefined, imageData: imageData ?? undefined, fileData: fileData ?? undefined, }); // Invalidate history cache so it refetches with new message invalidateHistory(); setText(''); setImageData(null); setFileData(null); setShowSlashCommands(false); // Keep focus in input for quick follow-up messages textareaRef.current?.focus(); return; } onSend(trimmed, imageData ?? undefined, fileData ?? undefined); // Invalidate history cache so it refetches with new message invalidateHistory(); setText(''); setImageData(null); setFileData(null); // Ensure guidance window closes after submit setShowSlashCommands(false); // Keep focus in input for quick follow-up messages textareaRef.current?.focus(); }; const applyMentionSelection = (index: number, selectedResource?: UIResourceMetadata) => { if (!selectedResource && filteredResources.length === 0) return; const selected = selectedResource ?? filteredResources[Math.max(0, Math.min(index, filteredResources.length - 1))]; const ta = textareaRef.current; if (!ta) return; const caret = ta.selectionStart ?? text.length; const atIndex = findActiveAtIndex(text, caret); if (atIndex === -1) return; const before = text.slice(0, atIndex); const after = text.slice(caret); // Mask input with readable name, rely on runtime resolver for expansion const name = selected.name || selected.uri.split('/').pop() || selected.uri; const insertion = selected.serverName ? `@${selected.serverName}:${name}` : `@${name}`; const next = before + insertion + after; setText(next); setShowMention(false); setMentionQuery(''); setMentionIndex(0); // Restore caret after inserted mention requestAnimationFrame(() => { const pos = (before + insertion).length; ta.setSelectionRange(pos, pos); ta.focus(); }); }; // Edit a queued message: remove from queue and load into input const handleEditQueuedMessage = useCallback( (message: (typeof queuedMessages)[number]) => { if (!currentSessionId) return; // Extract text content from message const textContent = message.content .filter(isTextPart) .map((part) => part.text) .join('\n'); // Extract image attachment if present const imagePart = message.content.find(isImagePart); // Extract file attachment if present const filePart = message.content.find(isFilePart); // Load into input setText(textContent); setImageData( imagePart ? { image: imagePart.image, mimeType: imagePart.mimeType ?? 'image/jpeg' } : null ); setFileData( filePart ? { data: filePart.data, mimeType: filePart.mimeType, filename: filePart.filename, } : null ); // Remove from queue removeQueuedMessage({ sessionId: currentSessionId, messageId: message.id }); // Focus textarea textareaRef.current?.focus(); }, [currentSessionId, removeQueuedMessage] ); // Handle Up arrow to edit most recent queued message (when input empty/on first line) const handleEditLastQueued = useCallback(() => { if (queuedMessages.length === 0) return false; // Get the most recently queued message (last in array) const lastMessage = queuedMessages[queuedMessages.length - 1]; if (lastMessage) { handleEditQueuedMessage(lastMessage); return true; } return false; }, [queuedMessages, handleEditQueuedMessage]); const handleKeyDown = (e: React.KeyboardEvent) => { // If mention menu open, handle navigation if (showMention) { if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex((prev) => (prev + 1) % Math.max(1, filteredResources.length)); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex( (prev) => (prev - 1 + Math.max(1, filteredResources.length)) % Math.max(1, filteredResources.length) ); return; } if (e.key === 'Enter') { e.preventDefault(); applyMentionSelection(mentionIndex); return; } if (e.key === 'Tab') { e.preventDefault(); applyMentionSelection(mentionIndex); return; } if (e.key === 'Escape') { setShowMention(false); return; } } // Up: First check queue, then fall back to input history // Only handle when cursor is on first line (no newline before cursor) if (e.key === 'ArrowUp' && !e.altKey && !e.ctrlKey && !e.metaKey) { const cursorPos = textareaRef.current?.selectionStart ?? 0; const textBeforeCursor = text.slice(0, cursorPos); const isOnFirstLine = !textBeforeCursor.includes('\n'); if (isOnFirstLine) { e.preventDefault(); // First priority: pop from queue if available if (queuedMessages.length > 0) { handleEditLastQueued(); return; } // Second priority: navigate input history const historyText = navigateUp(text); if (historyText !== null) { setText(historyText); // Move cursor to end requestAnimationFrame(() => { const len = historyText.length; textareaRef.current?.setSelectionRange(len, len); }); } return; } } if (e.key === 'ArrowDown' && !e.altKey && !e.ctrlKey && !e.metaKey) { if (isBrowsing) { e.preventDefault(); const historyText = navigateDown(); if (historyText !== null) { setText(historyText); // Move cursor to end requestAnimationFrame(() => { const len = historyText.length; textareaRef.current?.setSelectionRange(len, len); }); } return; } } // If memory hint is showing, handle Escape to dismiss if (showMemoryHint && e.key === 'Escape') { e.preventDefault(); setShowMemoryHint(false); setText(''); return; } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); // Check if user typed `#` to create a memory if (text.trim() === '#') { setText(''); setShowMemoryHint(false); setShowCreateMemoryModal(true); return; } handleSend(); } }; // Memory hint state const [showMemoryHint, setShowMemoryHint] = useState(false); const [memoryHintStyle, setMemoryHintStyle] = useState(null); // Handle slash command input const handleInputChange = (e: React.ChangeEvent) => { const value = e.target.value; setText(value); // Reset history browsing when user types resetCursor(); // Guidance UX: keep slash guidance window open while the user is constructing // a slash command (i.e., as long as the input starts with '/' and has no newline). // This lets users see argument hints while typing positional/named args. if (value.startsWith('/') && !value.includes('\n')) { setShowSlashCommands(true); } else if (showSlashCommands) { setShowSlashCommands(false); } // Show memory hint when user types exactly '#' if (value.trim() === '#') { setShowMemoryHint(true); // Position hint below textarea const ta = textareaRef.current; if (ta) { const anchor = ta.getBoundingClientRect(); const margin = 16; const left = Math.max(8, anchor.left + window.scrollX + margin); const maxWidth = Math.max(280, anchor.width - margin * 2); const bottomOffset = 64; const bottom = Math.max( 80, window.innerHeight - (anchor.bottom + window.scrollY) + bottomOffset ); setMemoryHintStyle({ position: 'fixed', left, bottom, width: maxWidth, zIndex: 9999, }); } } else { setShowMemoryHint(false); setMemoryHintStyle(null); } }; // Handle prompt selection const handlePromptSelect = (prompt: { name: string; arguments?: Array<{ name: string; required?: boolean }>; }) => { const slash = `/${prompt.name}`; setText(slash); setShowSlashCommands(false); if (textareaRef.current) { textareaRef.current.focus(); textareaRef.current.setSelectionRange(slash.length, slash.length); } }; const closeSlashCommands = () => { setShowSlashCommands(false); }; // Detect @mention context on text change and caret move useEffect(() => { const ta = textareaRef.current; const caret = ta ? (ta.selectionStart ?? text.length) : text.length; const atIndex = findActiveAtIndex(text, caret); if (atIndex >= 0) { const q = text.slice(atIndex + 1, caret); setMentionQuery(q); setShowMention(true); setMentionIndex(0); // Compute dropdown viewport position via textarea's bounding rect const anchor = ta?.getBoundingClientRect(); if (anchor) { const margin = 16; // inner padding from InputArea const left = Math.max(8, anchor.left + window.scrollX + margin); const maxWidth = Math.max(280, anchor.width - margin * 2); const bottomOffset = 64; // keep above footer area const bottom = Math.max( 80, window.innerHeight - (anchor.bottom + window.scrollY) + bottomOffset ); setDropdownStyle({ position: 'fixed', left, bottom, width: maxWidth, zIndex: 9999, }); } } else { setShowMention(false); setMentionQuery(''); setDropdownStyle(null); } }, [text]); const mentionActiveRef = React.useRef(false); useEffect(() => { if (showMention) { if (!mentionActiveRef.current) { mentionActiveRef.current = true; void refreshResources(); } } else { mentionActiveRef.current = false; } }, [showMention, refreshResources]); // Large paste guard to prevent layout from exploding with very large text const LARGE_PASTE_THRESHOLD = 20000; // characters const toBase64 = (str: string) => { try { return btoa(unescape(encodeURIComponent(str))); } catch { return btoa(str); } }; const handlePaste = (e: React.ClipboardEvent) => { const pasted = e.clipboardData.getData('text/plain'); if (!pasted) return; if (pasted.length <= LARGE_PASTE_THRESHOLD) return; e.preventDefault(); const attach = window.confirm( 'Large text detected. Attach as a file instead of inflating the input?\n(OK = attach as file, Cancel = paste truncated preview)' ); if (attach) { setFileData({ data: toBase64(pasted), mimeType: 'text/plain', filename: 'pasted.txt', }); } else { const preview = pasted.slice(0, LARGE_PASTE_THRESHOLD); setText((prev) => prev + preview); } }; const handlePdfChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; // File size validation if (file.size > MAX_FILE_SIZE) { showUserError('PDF file too large. Maximum size is 64MB.'); e.target.value = ''; return; } if (file.type !== 'application/pdf') { showUserError('Please select a valid PDF file.'); e.target.value = ''; return; } const reader = new FileReader(); reader.onloadend = () => { try { const result = reader.result as string; const commaIndex = result.indexOf(','); const data = result.substring(commaIndex + 1); setFileData({ data, mimeType: 'application/pdf', filename: file.name }); setFileUploadError(null); // Clear any previous errors // Track file upload if (currentSessionId) { analyticsRef.current.trackFileAttached({ fileType: 'application/pdf', fileSizeBytes: file.size, sessionId: currentSessionId, }); } } catch { showUserError('Failed to process PDF file. Please try again.'); setFileData(null); } }; reader.onerror = () => { showUserError('Failed to read PDF file. Please try again.'); setFileData(null); }; reader.readAsDataURL(file); e.target.value = ''; }; // Audio Recording Handlers const startRecording = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mediaRecorder = new MediaRecorder(stream); mediaRecorderRef.current = mediaRecorder; const chunks: BlobPart[] = []; mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) chunks.push(event.data); }; mediaRecorder.onstop = () => { const blob = new Blob(chunks, { type: mediaRecorder.mimeType }); const reader = new FileReader(); reader.onloadend = () => { try { const result = reader.result as string; const commaIndex = result.indexOf(','); const data = result.substring(commaIndex + 1); // Preserve original MIME type and determine appropriate extension const mimeType = mediaRecorder.mimeType || 'audio/webm'; const getExtensionFromMime = (mime: string): string => { const mimeToExt: Record = { 'audio/mp3': 'mp3', 'audio/mpeg': 'mp3', 'audio/wav': 'wav', 'audio/x-wav': 'wav', 'audio/wave': 'wav', 'audio/webm': 'webm', 'audio/ogg': 'ogg', 'audio/m4a': 'm4a', 'audio/aac': 'aac', }; return mimeToExt[mime] || mime.split('/')[1] || 'webm'; }; const ext = getExtensionFromMime(mimeType); setFileData({ data, mimeType: mimeType, filename: `recording.${ext}`, }); // Track audio recording upload if (currentSessionId) { analyticsRef.current.trackFileAttached({ fileType: mimeType, fileSizeBytes: blob.size, sessionId: currentSessionId, }); } } catch { showUserError('Failed to process audio recording. Please try again.'); setFileData(null); } }; reader.readAsDataURL(blob); // Stop all tracks to release microphone stream.getTracks().forEach((track) => track.stop()); }; mediaRecorder.start(); setIsRecording(true); } catch { showUserError('Failed to start audio recording. Please check microphone permissions.'); } }; const stopRecording = () => { mediaRecorderRef.current?.stop(); setIsRecording(false); }; const handleImageChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; // File size validation if (file.size > MAX_FILE_SIZE) { showUserError('Image file too large. Maximum size is 64MB.'); e.target.value = ''; return; } if (!file.type.startsWith('image/')) { showUserError('Please select a valid image file.'); e.target.value = ''; return; } const reader = new FileReader(); reader.onloadend = () => { try { const result = reader.result as string; const commaIndex = result.indexOf(','); if (commaIndex === -1) throw new Error('Invalid Data URL format'); const meta = result.substring(0, commaIndex); const image = result.substring(commaIndex + 1); const mimeMatch = meta.match(/data:(.*);base64/); const mimeType = mimeMatch ? mimeMatch[1] : file.type; if (!mimeType) throw new Error('Could not determine MIME type'); setImageData({ image, mimeType }); setFileUploadError(null); // Clear any previous errors // Track image upload if (currentSessionId) { analyticsRef.current.trackImageAttached({ imageType: mimeType, imageSizeBytes: file.size, sessionId: currentSessionId, }); } } catch { showUserError('Failed to process image file. Please try again.'); setImageData(null); } }; reader.onerror = () => { showUserError('Failed to read image file. Please try again.'); setImageData(null); }; reader.readAsDataURL(file); e.target.value = ''; }; const removeImage = () => setImageData(null); const triggerFileInput = () => fileInputRef.current?.click(); const triggerPdfInput = () => pdfInputRef.current?.click(); const triggerAudioInput = () => audioInputRef.current?.click(); // Clear model switch error when user starts typing useEffect(() => { if (text && modelSwitchError) { setModelSwitchError(null); } if (text && fileUploadError) { setFileUploadError(null); } }, [text, modelSwitchError, fileUploadError]); const handleAudioFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; // File size validation if (file.size > MAX_FILE_SIZE) { showUserError('Audio file too large. Maximum size is 64MB.'); e.target.value = ''; return; } if (!file.type.startsWith('audio/')) { showUserError('Please select a valid audio file.'); e.target.value = ''; return; } const reader = new FileReader(); reader.onloadend = () => { try { const result = reader.result as string; const commaIndex = result.indexOf(','); const data = result.substring(commaIndex + 1); // Preserve original MIME type from file setFileData({ data, mimeType: file.type, filename: file.name }); setFileUploadError(null); // Clear any previous errors // Track file upload if (currentSessionId) { analyticsRef.current.trackFileAttached({ fileType: file.type, fileSizeBytes: file.size, sessionId: currentSessionId, }); } } catch { showUserError('Failed to process audio file. Please try again.'); setFileData(null); } }; reader.onerror = () => { showUserError('Failed to read audio file. Please try again.'); setFileData(null); }; reader.readAsDataURL(file); e.target.value = ''; }; // Unified input panel: use the same full-featured chat composer in both welcome and chat states // Chat variant - full featured input area return (
{/* Model Switch Error Alert */} {modelSwitchError && ( {modelSwitchError} )} {/* File Upload Error Alert */} {fileUploadError && ( {fileUploadError} )}
{/* Unified pill input with send button */}
{ e.preventDefault(); handleSend(); }} > {/* Queued messages display (shows when messages are pending) */} {queuedMessages.length > 0 && ( { if (currentSessionId) { removeQueuedMessage({ sessionId: currentSessionId, messageId, }); } }} /> )} {/* Attachments strip (inside bubble, above editor) */} {(imageData || fileData) && (
{imageData && (
preview
)} {fileData && (
{fileData.mimeType.startsWith('audio') ? ( <>
)}
)} {/* Editor area: scrollable, independent from footer */}
{fontsReady ? ( ) : (