import Fuse from "fuse.js"; import { ArrowRight, Check, Funnel, Loader2, Plus } from "lucide-react"; import { AnimatePresence, motion } from "motion/react"; import { forwardRef, useImperativeHandle, useMemo, useRef, useState, } from "react"; import { useDropzone } from "react-dropzone"; import TextareaAutosize from "react-textarea-autosize"; import { toast } from "sonner"; import type { FilterColor } from "@/components/filter-icon-popover"; import { Button } from "@/components/ui/button"; import { Popover, PopoverAnchor, PopoverContent, } from "@/components/ui/popover"; import { useFileDrag } from "@/hooks/use-file-drag"; import { cn } from "@/lib/utils"; import { useGetAllFiltersQuery } from "../../api/queries/useGetAllFiltersQuery"; import type { KnowledgeFilterData } from "../_types/types"; import { FilePreview } from "./file-preview"; import { SelectedKnowledgeFilter } from "./selected-knowledge-filter"; export interface ChatInputHandle { focusInput: () => void; clickFileInput: () => void; } interface ChatInputProps { input: string; loading: boolean; isUploading: boolean; selectedFilter: KnowledgeFilterData | null; parsedFilterData: { color?: FilterColor } | null; uploadedFile: File | null; onSubmit: (e: React.FormEvent) => void; onChange: (value: string) => void; onKeyDown: (e: React.KeyboardEvent) => void; onFilterSelect: (filter: KnowledgeFilterData | null) => void; onFilePickerClick: () => void; setSelectedFilter: (filter: KnowledgeFilterData | null) => void; setIsFilterHighlighted: (highlighted: boolean) => void; onFileSelected: (file: File | null) => void; } export const ChatInput = forwardRef( ( { input, loading, isUploading, selectedFilter, parsedFilterData, uploadedFile, onSubmit, onChange, onKeyDown, onFilterSelect, onFilePickerClick, setSelectedFilter, setIsFilterHighlighted, onFileSelected, }, ref, ) => { const inputRef = useRef(null); const fileInputRef = useRef(null); const [textareaHeight, setTextareaHeight] = useState(0); const isDragging = useFileDrag(); // Internal state for filter dropdown const [isFilterDropdownOpen, setIsFilterDropdownOpen] = useState(false); const [filterSearchTerm, setFilterSearchTerm] = useState(""); const [selectedFilterIndex, setSelectedFilterIndex] = useState(0); const [anchorPosition, setAnchorPosition] = useState<{ x: number; y: number; } | null>(null); // Fetch all filters once when dropdown opens const { data: allFilters = [] } = useGetAllFiltersQuery({ enabled: isFilterDropdownOpen, }); // Use fuse.js for fuzzy search on client side const filteredFilters = useMemo(() => { if (!filterSearchTerm) { return allFilters.slice(0, 20); // Return first 20 when no search term } const fuse = new Fuse(allFilters, { keys: ["name", "description"], threshold: 0.3, // 0.0 = perfect match, 1.0 = match anything includeScore: true, minMatchCharLength: 1, }); const results = fuse.search(filterSearchTerm); return results.map((result) => result.item).slice(0, 20); }, [allFilters, filterSearchTerm]); const { getRootProps, getInputProps } = useDropzone({ accept: { "application/pdf": [".pdf"], "application/msword": [".doc"], "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [".docx"], "text/markdown": [".md"], }, maxFiles: 1, disabled: !isDragging, onDrop: (acceptedFiles, fileRejections) => { if (fileRejections.length > 0) { const message = fileRejections.at(0)?.errors.at(0)?.message; toast.error(message || "Failed to upload file"); return; } onFileSelected(acceptedFiles[0]); }, }); useImperativeHandle(ref, () => ({ focusInput: () => { inputRef.current?.focus(); }, clickFileInput: () => { fileInputRef.current?.click(); }, })); const handleFilePickerChange = (e: React.ChangeEvent) => { const files = e.target.files; if (files && files.length > 0) { onFileSelected(files[0]); } else { onFileSelected(null); } }; const onAtClick = () => { if (!isFilterDropdownOpen) { setIsFilterDropdownOpen(true); setFilterSearchTerm(""); setSelectedFilterIndex(0); // Get button position for popover anchoring const button = document.querySelector( "[data-filter-button]", ) as HTMLElement; if (button) { const rect = button.getBoundingClientRect(); setAnchorPosition({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 - 12, }); } } else { setIsFilterDropdownOpen(false); setAnchorPosition(null); } }; const handleFilterSelect = (filter: KnowledgeFilterData | null) => { onFilterSelect(filter); // Remove the @searchTerm from the input const words = input.split(" "); const lastWord = words[words.length - 1]; if (lastWord.startsWith("@")) { // Remove the @search term words.pop(); onChange(words.join(" ") + (words.length > 0 ? " " : "")); } setIsFilterDropdownOpen(false); setFilterSearchTerm(""); setSelectedFilterIndex(0); }; const handleChange = (e: React.ChangeEvent) => { const newValue = e.target.value; onChange(newValue); // Call parent's onChange with the string value // Find if there's an @ at the start of the last word const words = newValue.split(" "); const lastWord = words[words.length - 1]; if (lastWord.startsWith("@")) { const searchTerm = lastWord.slice(1); // Remove the @ setFilterSearchTerm(searchTerm); setSelectedFilterIndex(0); // Only set anchor position when @ is first detected (search term is empty) if (searchTerm === "") { const getCursorPosition = (textarea: HTMLTextAreaElement) => { // Create a hidden div with the same styles as the textarea const div = document.createElement("div"); const computedStyle = getComputedStyle(textarea); // Copy all computed styles to the hidden div for (const style of computedStyle) { (div.style as unknown as Record)[style] = computedStyle.getPropertyValue(style); } // Set the div to be hidden but not un-rendered div.style.position = "absolute"; div.style.visibility = "hidden"; div.style.whiteSpace = "pre-wrap"; div.style.wordWrap = "break-word"; div.style.overflow = "hidden"; div.style.height = "auto"; div.style.width = `${textarea.getBoundingClientRect().width}px`; // Get the text up to the cursor position const cursorPos = textarea.selectionStart || 0; const textBeforeCursor = textarea.value.substring(0, cursorPos); // Add the text before cursor div.textContent = textBeforeCursor; // Create a span to mark the end position const span = document.createElement("span"); span.textContent = "|"; // Cursor marker div.appendChild(span); // Add the text after cursor to handle word wrapping const textAfterCursor = textarea.value.substring(cursorPos); div.appendChild(document.createTextNode(textAfterCursor)); // Add the div to the document temporarily document.body.appendChild(div); // Get positions const inputRect = textarea.getBoundingClientRect(); const divRect = div.getBoundingClientRect(); const spanRect = span.getBoundingClientRect(); // Calculate the cursor position relative to the input const x = inputRect.left + (spanRect.left - divRect.left); const y = inputRect.top + (spanRect.top - divRect.top); // Clean up document.body.removeChild(div); return { x, y }; }; const pos = getCursorPosition(e.target); setAnchorPosition(pos); } if (!isFilterDropdownOpen) { setIsFilterDropdownOpen(true); } } else if (isFilterDropdownOpen) { // Close dropdown if @ is no longer present setIsFilterDropdownOpen(false); setFilterSearchTerm(""); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (isFilterDropdownOpen) { if (e.key === "Escape") { e.preventDefault(); setIsFilterDropdownOpen(false); setFilterSearchTerm(""); setSelectedFilterIndex(0); inputRef.current?.focus(); return; } if (e.key === "ArrowDown") { e.preventDefault(); setSelectedFilterIndex((prev) => prev < filteredFilters.length - 1 ? prev + 1 : 0, ); return; } if (e.key === "ArrowUp") { e.preventDefault(); setSelectedFilterIndex((prev) => prev > 0 ? prev - 1 : filteredFilters.length - 1, ); return; } if (e.key === "Enter") { // Check if we're at the end of an @ mention const cursorPos = e.currentTarget.selectionStart || 0; const textBeforeCursor = input.slice(0, cursorPos); const words = textBeforeCursor.split(" "); const lastWord = words[words.length - 1]; if ( lastWord.startsWith("@") && filteredFilters[selectedFilterIndex] ) { e.preventDefault(); handleFilterSelect(filteredFilters[selectedFilterIndex]); return; } } if (e.key === " ") { // Select filter on space if we're typing an @ mention const cursorPos = e.currentTarget.selectionStart || 0; const textBeforeCursor = input.slice(0, cursorPos); const words = textBeforeCursor.split(" "); const lastWord = words[words.length - 1]; if ( lastWord.startsWith("@") && filteredFilters[selectedFilterIndex] ) { e.preventDefault(); handleFilterSelect(filteredFilters[selectedFilterIndex]); return; } } } // Pass through to parent onKeyDown for other key handling onKeyDown(e); }; return (
{/* Outer container - flex-col to stack file preview above input */}
{/* File Preview Section - Always above */} {uploadedFile && ( { onFileSelected(null); }} isUploading={isUploading} /> )} {isDragging && (

Add files to conversation

Text formats and image files.{" "} 10 files per chat,{" "} 150 MB each.

)}
{/* Main Input Container - flex-row or flex-col based on textarea height */}
40 ? "flex-col" : "flex-row items-center" }`} > {/* Filter + Textarea Section */}
40 ? "w-full" : "flex-1"}`} > {textareaHeight <= 40 && (selectedFilter ? ( { setSelectedFilter(null); setIsFilterHighlighted(false); }} /> ) : ( ))}
setTextareaHeight(height)} maxRows={7} autoComplete="off" minRows={1} placeholder="Ask a question..." disabled={loading} className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`} rows={1} />
{/* Action Buttons Section */}
40 ? "justify-between w-full" : ""}`} > {textareaHeight > 40 && (selectedFilter ? ( { setSelectedFilter(null); setIsFilterHighlighted(false); }} /> ) : ( ))}
{ setIsFilterDropdownOpen(open); }} > {anchorPosition && (
)} { // Prevent auto focus on the popover content e.preventDefault(); // Keep focus on the input }} >
{filterSearchTerm && (
Searching: @{filterSearchTerm}
)} {allFilters.length === 0 ? (
No knowledge filters available
) : ( <> {!filterSearchTerm && ( )} {filteredFilters.map((filter, index) => ( ))} {filteredFilters.length === 0 && filterSearchTerm && (
No filters match "{filterSearchTerm}"
)} )}
); }, ); ChatInput.displayName = "ChatInput";