import { Button, Spinner, Modal, ModalBody, ModalTitle, VTooltip, cn, Textarea } from "@vertesia/ui/core"; import { Activity, FileTextIcon, HelpCircleIcon, PaperclipIcon, SendIcon, StopCircleIcon, UploadIcon, XIcon } from "lucide-react"; import React, { useCallback, useEffect, useRef, useState } from "react"; import { ConversationFile, FileProcessingStatus } from "@vertesia/common"; import { SelectDocument } from "../../../store"; import { useUITranslation } from "../../../../i18n/index.js"; /** Represents an uploaded file attachment */ export interface UploadedFile { id: string; name: string; type?: string; size?: number; /** Optional preview URL for images */ previewUrl?: string; /** Artifact path where file is stored (e.g., "files/image.png") */ artifact_path?: string; } /** Represents a selected document from search */ export interface SelectedDocument { id: string; name: string; } interface MessageInputProps { onSend: (message: string) => void; onStop?: () => void; disabled?: boolean; isSending?: boolean; isStopping?: boolean; isStreaming?: boolean; isCompleted?: boolean; activeTaskCount?: number; placeholder?: string; // File upload props /** Called when files are dropped/pasted/selected */ onFilesSelected?: (files: File[]) => void; /** Currently uploaded files to display */ uploadedFiles?: UploadedFile[]; /** Called when user removes an uploaded file */ onRemoveFile?: (fileId: string) => void; /** Accepted file types (e.g., ".pdf,.doc,.png") */ acceptedFileTypes?: string; /** Max number of files allowed */ maxFiles?: number; /** Files being processed by the workflow */ processingFiles?: Map; /** Whether any files are still uploading or processing */ hasProcessingFiles?: boolean; // Document search props (render prop for custom search UI) /** Render custom document search UI - if provided, shows search button */ renderDocumentSearch?: (props: { isOpen: boolean; onClose: () => void; onSelect: (doc: SelectedDocument) => void; }) => React.ReactNode; /** Currently selected documents from search */ selectedDocuments?: SelectedDocument[]; /** Called when user removes a selected document */ onRemoveDocument?: (docId: string) => void; // Hide the default object linking (for apps that don't use it) hideObjectLinking?: boolean; // Hide file upload (for apps that don't use it) hideFileUpload?: boolean; // Styling props for Tailwind customization /** Additional className for the container */ className?: string; /** Additional className for the input field */ inputClassName?: string; } export default function MessageInput({ onSend, onStop, disabled = false, isSending = false, isStopping = false, isStreaming = false, isCompleted = false, activeTaskCount = 0, placeholder, // File upload props onFilesSelected, uploadedFiles = [], onRemoveFile, acceptedFileTypes, maxFiles = 5, processingFiles, hasProcessingFiles = false, // Document search props renderDocumentSearch, selectedDocuments = [], onRemoveDocument, // Object linking hideObjectLinking = false, // File upload hideFileUpload = false, // Styling props className, }: MessageInputProps) { const { t } = useUITranslation(); const resolvedPlaceholder = placeholder ?? t('agent.typeYourMessage'); const ref = useRef(null); const fileInputRef = useRef(null); const [value, setValue] = useState(""); const [isObjectModalOpen, setIsObjectModalOpen] = useState(false); const [isDocSearchOpen, setIsDocSearchOpen] = useState(false); const [isDragOver, setIsDragOver] = useState(false); useEffect(() => { if (!disabled && isCompleted) ref.current?.focus(); }, [disabled, isCompleted]); // File handling const handleFiles = useCallback((files: FileList | File[]) => { if (!onFilesSelected) return; const fileArray = Array.from(files); const remainingSlots = maxFiles - uploadedFiles.length; const filesToAdd = fileArray.slice(0, remainingSlots); if (filesToAdd.length > 0) { onFilesSelected(filesToAdd); } }, [onFilesSelected, maxFiles, uploadedFiles.length]); // Drag and drop handlers const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (onFilesSelected) { setIsDragOver(true); } }, [onFilesSelected]); const handleDragLeave = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); }, []); const handleDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragOver(false); if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { handleFiles(e.dataTransfer.files); } }, [handleFiles]); // Paste handler for files const handlePaste = useCallback((e: React.ClipboardEvent) => { if (!onFilesSelected) return; const items = e.clipboardData?.items; if (!items) return; const files: File[] = []; for (let i = 0; i < items.length; i++) { const item = items[i]; if (item.kind === 'file') { const file = item.getAsFile(); if (file) { // If it's an image without a proper name, generate one if (item.type.startsWith('image/') && (!file.name || file.name === 'image.png')) { const extension = item.type.split('/')[1] || 'png'; const namedFile = new File([file], `pasted-image-${Date.now()}.${extension}`, { type: file.type, }); files.push(namedFile); } else { files.push(file); } } } } if (files.length > 0) { handleFiles(files); } }, [onFilesSelected, handleFiles]); // File input change handler const handleFileInputChange = useCallback((e: React.ChangeEvent) => { if (e.target.files && e.target.files.length > 0) { handleFiles(e.target.files); // Reset input so same file can be selected again e.target.value = ''; } }, [handleFiles]); const openFileDialog = useCallback(() => { fileInputRef.current?.click(); }, []); // // Document search handlers // const handleDocumentSelect = useCallback((doc: SelectedDocument) => { // // Insert document reference into message // const markdownLink = `[📄 ${doc.name}](doc:${doc.id})`; // const currentValue = value || ''; // const cursorPos = ref.current?.selectionStart || currentValue.length; // const newValue = currentValue.substring(0, cursorPos) + markdownLink + currentValue.substring(cursorPos); // setValue(newValue); // setIsDocSearchOpen(false); // }, [value]); const handleDocSearchClose = useCallback(() => setIsDocSearchOpen(false), []); const handleDocSearchSelect = useCallback((_doc: SelectedDocument) => setIsDocSearchOpen(false), []); const handleSend = () => { const message = value.trim(); if (!message || disabled || isSending) return; onSend(message); setValue(""); }; const handleStop = () => { if (onStop && !isStopping) { onStop(); } }; // Track Escape key presses for double-tap to stop const lastEscapeRef = useRef(0); const keyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); const hasMessage = value.trim().length > 0; if (hasMessage) { // If there's a message, send it (this will interrupt + send message via UserInput signal) handleSend(); } // Enter with no text does nothing (don't stop, don't send) } // Double Escape to stop the agent if (e.key === "Escape" && isStreaming && onStop) { const now = Date.now(); if (now - lastEscapeRef.current < 500) { // Double Escape within 500ms - stop the agent handleStop(); lastEscapeRef.current = 0; } else { lastEscapeRef.current = now; } } // Shift+Enter allows newline (default textarea behavior) }; // Auto-resize textarea as content grows const adjustTextareaHeight = useCallback(() => { const textarea = ref.current; if (textarea) { textarea.style.height = 'auto'; textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`; } }, []); useEffect(() => { adjustTextareaHeight(); }, [value, adjustTextareaHeight]); const handleObjectSelect = (object: any) => { // Create a markdown link with the object title and ID const objectTitle = object.properties?.title || object.name || 'Object'; const objectId = object.id; const markdownLink = `[${objectTitle}](store:${objectId})`; // Insert the link at cursor position or append to end const currentValue = value || ''; const cursorPos = ref.current?.selectionStart || currentValue.length; const newValue = currentValue.substring(0, cursorPos) + markdownLink + currentValue.substring(cursorPos); // Update the input value setValue(newValue); // Close the modal setIsObjectModalOpen(false); // Focus back on the input setTimeout(() => { if (ref.current) { ref.current.focus(); // Place cursor after the inserted link const newCursorPos = cursorPos + markdownLink.length; ref.current.setSelectionRange(newCursorPos, newCursorPos); } }, 100); }; return (
{/* Drag overlay */} {isDragOver && (
{t('agent.dropFilesToUpload')}
)} {/* Hidden file input */} {onFilesSelected && ( )} {/* Uploaded files preview */} {!hideFileUpload && (uploadedFiles.length > 0 || (processingFiles && processingFiles.size > 0)) && (
{t('agent.uploadedFiles')}
{/* Processing files (uploading/processing/error) */} {processingFiles && Array.from(processingFiles.values()).map((file) => (
{file.name} {file.status === FileProcessingStatus.UPLOADING ? t('agent.uploading') : file.status === FileProcessingStatus.PROCESSING ? t('agent.processing') : file.status === FileProcessingStatus.ERROR ? t('agent.error') : file.status === FileProcessingStatus.READY ? t('agent.ready') : file.status}
))} {/* Uploaded files (with remove button) */} {uploadedFiles.map((file) => (
{file.name} {onRemoveFile && ( )}
))}
)} {/* Selected documents section — always visible regardless of hideFileUpload */} {selectedDocuments.length > 0 && (
{t('agent.documentAttachments')}
{selectedDocuments.map((doc) => (
{doc.name} {onRemoveDocument && ( )}
))}
)} {/* Action buttons row */} {(onFilesSelected || renderDocumentSearch) && (
{onFilesSelected && ( )} {renderDocumentSearch && ( )}
)} {/* Input row */}