import { useCallback, useEffect, useRef, useState } from "react"; import { Send, Loader2, Paperclip, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ChatSlashCommandPopover } from "./ChatSlashCommandPopover"; import { ChatMentionPopover } from "./ChatMentionPopover"; import { ChatFileDropZone } from "./ChatFileDropZone"; import { VoiceRecordButton } from "./VoiceRecordButton"; import { cn } from "../lib/utils"; import type { Agent } from "@paperclipai/shared"; import type { PendingFile } from "../hooks/useChatFileUpload"; interface ChatInputProps { onSend: (content: string) => void; isSubmitting?: boolean; disabled?: boolean; placeholder?: string; // Popover support agents?: Agent[]; agentsLoading?: boolean; // File upload support onFilesPicked?: (files: File[]) => void; pendingFiles?: PendingFile[]; onRemoveFile?: (id: string) => void; // Voice input support enableVoiceInput?: boolean; } export function ChatInput({ onSend, isSubmitting = false, disabled = false, placeholder = "Message your agent...", agents = [], agentsLoading = false, onFilesPicked, pendingFiles, onRemoveFile, enableVoiceInput = false, }: ChatInputProps) { const [value, setValue] = useState(""); const textareaRef = useRef(null); // Slash command popover state const [slashOpen, setSlashOpen] = useState(false); const [slashQuery, setSlashQuery] = useState(""); // @mention popover state const [mentionOpen, setMentionOpen] = useState(false); const [mentionQuery, setMentionQuery] = useState(""); // Auto-resize fallback for browsers without field-sizing: content support useEffect(() => { const el = textareaRef.current; if (!el) return; el.style.height = "auto"; el.style.height = `${el.scrollHeight}px`; }, [value]); function submit() { const trimmed = value.trim(); if (!trimmed || isSubmitting || disabled) return; onSend(trimmed); setValue(""); setSlashOpen(false); setMentionOpen(false); if (textareaRef.current) { textareaRef.current.style.height = "auto"; } } function handleChange(e: React.ChangeEvent) { const val = e.target.value; setValue(val); // Slash command: opens when / is the first character if (val.startsWith("/")) { setSlashOpen(true); setSlashQuery(val); } else { setSlashOpen(false); } // @mention: opens when @ appears with a word boundary before it const mentionMatch = val.match(/@(\w*)$/); if (mentionMatch) { setMentionOpen(true); setMentionQuery(mentionMatch[1] ?? ""); } else { setMentionOpen(false); } } function handleSlashSelect(command: string) { setValue(command + " "); setSlashOpen(false); textareaRef.current?.focus(); } function handleMentionSelect(agentName: string) { // Replace the @query with @agentName const val = value.replace(/@\w*$/, `@${agentName} `); setValue(val); setMentionOpen(false); textareaRef.current?.focus(); } const handleTranscription = useCallback((text: string) => { setValue((current) => (current ? `${current} ${text}` : text)); textareaRef.current?.focus(); }, []); function handleKeyDown(e: React.KeyboardEvent) { if (e.key === "Escape") { if (slashOpen) { setSlashOpen(false); return; } if (mentionOpen) { setMentionOpen(false); return; } e.preventDefault(); setValue(""); if (textareaRef.current) { textareaRef.current.style.height = "auto"; } return; } if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); submit(); } // Shift+Enter falls through to default behavior (inserts newline) } function handlePaste(e: React.ClipboardEvent) { const files = Array.from(e.clipboardData.files); if (files.length > 0) { e.preventDefault(); onFilesPicked?.(files); } // If no files, allow default paste behavior (text) } const isEmpty = value.trim().length === 0; const textarea = (