From 3ad90f7293ba54099c69ec43531a6590cb832fb3 Mon Sep 17 00:00:00 2001 From: Cole Goldsmith Date: Tue, 18 Nov 2025 14:21:09 -0600 Subject: [PATCH] use react dropzone on chat input --- frontend/app/chat/_components/chat-input.tsx | 701 ++++++++++--------- frontend/components/ui/dropzone.tsx | 202 ------ frontend/hooks/use-file-drag.ts | 53 ++ 3 files changed, 442 insertions(+), 514 deletions(-) delete mode 100644 frontend/components/ui/dropzone.tsx create mode 100644 frontend/hooks/use-file-drag.ts diff --git a/frontend/app/chat/_components/chat-input.tsx b/frontend/app/chat/_components/chat-input.tsx index aeb44073..242f19d2 100644 --- a/frontend/app/chat/_components/chat-input.tsx +++ b/frontend/app/chat/_components/chat-input.tsx @@ -1,339 +1,416 @@ import { ArrowRight, Check, Funnel, Loader2, Plus } from "lucide-react"; +import { AnimatePresence, motion } from "motion/react"; import { forwardRef, useImperativeHandle, useRef, useState } from "react"; +import { useDropzone } from "react-dropzone"; import TextareaAutosize from "react-textarea-autosize"; import type { FilterColor } from "@/components/filter-icon-popover"; import { Button } from "@/components/ui/button"; import { - Popover, - PopoverAnchor, - PopoverContent, + Popover, + PopoverAnchor, + PopoverContent, } from "@/components/ui/popover"; +import { useFileDrag } from "@/hooks/use-file-drag"; +import { cn } from "@/lib/utils"; import type { KnowledgeFilterData } from "../_types/types"; import { FilePreview } from "./file-preview"; import { SelectedKnowledgeFilter } from "./selected-knowledge-filter"; export interface ChatInputHandle { - focusInput: () => void; - clickFileInput: () => void; + focusInput: () => void; + clickFileInput: () => void; } interface ChatInputProps { - input: string; - loading: boolean; - isUploading: boolean; - selectedFilter: KnowledgeFilterData | null; - isFilterDropdownOpen: boolean; - availableFilters: KnowledgeFilterData[]; - filterSearchTerm: string; - selectedFilterIndex: number; - anchorPosition: { x: number; y: number } | null; - parsedFilterData: { color?: FilterColor } | null; - uploadedFile: File | null; - onSubmit: (e: React.FormEvent) => void; - onChange: (e: React.ChangeEvent) => void; - onKeyDown: (e: React.KeyboardEvent) => void; - onFilterSelect: (filter: KnowledgeFilterData | null) => void; - onAtClick: () => void; - onFilePickerClick: () => void; - setSelectedFilter: (filter: KnowledgeFilterData | null) => void; - setIsFilterHighlighted: (highlighted: boolean) => void; - setIsFilterDropdownOpen: (open: boolean) => void; - onFileSelected: (file: File | null) => void; + input: string; + loading: boolean; + isUploading: boolean; + selectedFilter: KnowledgeFilterData | null; + isFilterDropdownOpen: boolean; + availableFilters: KnowledgeFilterData[]; + filterSearchTerm: string; + selectedFilterIndex: number; + anchorPosition: { x: number; y: number } | null; + parsedFilterData: { color?: FilterColor } | null; + uploadedFile: File | null; + onSubmit: (e: React.FormEvent) => void; + onChange: (e: React.ChangeEvent) => void; + onKeyDown: (e: React.KeyboardEvent) => void; + onFilterSelect: (filter: KnowledgeFilterData | null) => void; + onAtClick: () => void; + onFilePickerClick: () => void; + setSelectedFilter: (filter: KnowledgeFilterData | null) => void; + setIsFilterHighlighted: (highlighted: boolean) => void; + setIsFilterDropdownOpen: (open: boolean) => void; + onFileSelected: (file: File | null) => void; } export const ChatInput = forwardRef( - ( - { - input, - loading, - isUploading, - selectedFilter, - isFilterDropdownOpen, - availableFilters, - filterSearchTerm, - selectedFilterIndex, - anchorPosition, - parsedFilterData, - uploadedFile, - onSubmit, - onChange, - onKeyDown, - onFilterSelect, - onAtClick, - onFilePickerClick, - setSelectedFilter, - setIsFilterHighlighted, - setIsFilterDropdownOpen, - onFileSelected, - }, - ref, - ) => { - const inputRef = useRef(null); - const fileInputRef = useRef(null); - const [textareaHeight, setTextareaHeight] = useState(0); + ( + { + input, + loading, + isUploading, + selectedFilter, + isFilterDropdownOpen, + availableFilters, + filterSearchTerm, + selectedFilterIndex, + anchorPosition, + parsedFilterData, + uploadedFile, + onSubmit, + onChange, + onKeyDown, + onFilterSelect, + onAtClick, + onFilePickerClick, + setSelectedFilter, + setIsFilterHighlighted, + setIsFilterDropdownOpen, + onFileSelected, + }, + ref, + ) => { + const inputRef = useRef(null); + const fileInputRef = useRef(null); + const [textareaHeight, setTextareaHeight] = useState(0); + const [fileUploadError, setFileUploadError] = useState(null); + const isDragging = useFileDrag(); - useImperativeHandle(ref, () => ({ - focusInput: () => { - inputRef.current?.focus(); - }, - clickFileInput: () => { - fileInputRef.current?.click(); - }, - })); + 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) => { + setFileUploadError(null); + if (fileRejections.length > 0) { + console.log(fileRejections); + const message = fileRejections.at(0)?.errors.at(0)?.message; + setFileUploadError(new Error(message)); + return; + } + onFileSelected(acceptedFiles[0]); + }, + }); - const handleFilePickerChange = (e: React.ChangeEvent) => { - const files = e.target.files; - if (files && files.length > 0) { - onFileSelected(files[0]); - } else { - onFileSelected(null); - } - }; + useImperativeHandle(ref, () => ({ + focusInput: () => { + inputRef.current?.focus(); + }, + clickFileInput: () => { + fileInputRef.current?.click(); + }, + })); - return ( -
-
- {/* Outer container - flex-col to stack file preview above input */} -
- {/* File Preview Section - Always above */} - {uploadedFile && ( - { - onFileSelected(null); - }} - /> - )} + const handleFilePickerChange = (e: React.ChangeEvent) => { + setFileUploadError(null); + const files = e.target.files; + if (files && files.length > 0) { + onFileSelected(files[0]); + } else { + onFileSelected(null); + } + }; - {/* 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} - /> -
-
+ return ( +
+ + {/* Outer container - flex-col to stack file preview above input */} +
+ + {/* File Preview Section - Always above */} + + {uploadedFile && ( + + { + onFileSelected(null); + }} + /> + + )} + + + {fileUploadError && ( + + {fileUploadError.message} + + )} + + + {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); - }} - /> - ) : ( - - ))} -
- - -
-
-
-
- + {/* 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} -
- )} - {availableFilters.length === 0 ? ( -
- No knowledge filters available -
- ) : ( - <> - {!filterSearchTerm && ( - - )} - {availableFilters - .filter((filter) => - filter.name - .toLowerCase() - .includes(filterSearchTerm.toLowerCase()), - ) - .map((filter, index) => ( - - ))} - {availableFilters.filter((filter) => - filter.name - .toLowerCase() - .includes(filterSearchTerm.toLowerCase()), - ).length === 0 && - filterSearchTerm && ( -
- No filters match "{filterSearchTerm}" -
- )} - - )} -
-
- - -
- ); - }, + { + setIsFilterDropdownOpen(open); + }} + > + {anchorPosition && ( + +
+ + )} + { + // Prevent auto focus on the popover content + e.preventDefault(); + // Keep focus on the input + }} + > +
+ {filterSearchTerm && ( +
+ Searching: @{filterSearchTerm} +
+ )} + {availableFilters.length === 0 ? ( +
+ No knowledge filters available +
+ ) : ( + <> + {!filterSearchTerm && ( + + )} + {availableFilters + .filter((filter) => + filter.name + .toLowerCase() + .includes(filterSearchTerm.toLowerCase()), + ) + .map((filter, index) => ( + + ))} + {availableFilters.filter((filter) => + filter.name + .toLowerCase() + .includes(filterSearchTerm.toLowerCase()), + ).length === 0 && + filterSearchTerm && ( +
+ No filters match "{filterSearchTerm}" +
+ )} + + )} +
+
+ + +
+ ); + }, ); ChatInput.displayName = "ChatInput"; diff --git a/frontend/components/ui/dropzone.tsx b/frontend/components/ui/dropzone.tsx deleted file mode 100644 index 0d74a783..00000000 --- a/frontend/components/ui/dropzone.tsx +++ /dev/null @@ -1,202 +0,0 @@ -'use client'; - -import { UploadIcon } from 'lucide-react'; -import type { ReactNode } from 'react'; -import { createContext, useContext } from 'react'; -import type { DropEvent, DropzoneOptions, FileRejection } from 'react-dropzone'; -import { useDropzone } from 'react-dropzone'; -import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; - -type DropzoneContextType = { - src?: File[]; - accept?: DropzoneOptions['accept']; - maxSize?: DropzoneOptions['maxSize']; - minSize?: DropzoneOptions['minSize']; - maxFiles?: DropzoneOptions['maxFiles']; -}; - -const renderBytes = (bytes: number) => { - const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; - let size = bytes; - let unitIndex = 0; - - while (size >= 1024 && unitIndex < units.length - 1) { - size /= 1024; - unitIndex++; - } - - return `${size.toFixed(2)}${units[unitIndex]}`; -}; - -const DropzoneContext = createContext( - undefined -); - -export type DropzoneProps = Omit & { - src?: File[]; - className?: string; - onDrop?: ( - acceptedFiles: File[], - fileRejections: FileRejection[], - event: DropEvent - ) => void; - children?: ReactNode; -}; - -export const Dropzone = ({ - accept, - maxFiles = 1, - maxSize, - minSize, - onDrop, - onError, - disabled, - src, - className, - children, - ...props -}: DropzoneProps) => { - const { getRootProps, getInputProps, isDragActive } = useDropzone({ - accept, - maxFiles, - maxSize, - minSize, - onError, - disabled, - onDrop: (acceptedFiles, fileRejections, event) => { - if (fileRejections.length > 0) { - const message = fileRejections.at(0)?.errors.at(0)?.message; - onError?.(new Error(message)); - return; - } - - onDrop?.(acceptedFiles, fileRejections, event); - }, - ...props, - }); - - return ( - - - - ); -}; - -const useDropzoneContext = () => { - const context = useContext(DropzoneContext); - - if (!context) { - throw new Error('useDropzoneContext must be used within a Dropzone'); - } - - return context; -}; - -export type DropzoneContentProps = { - children?: ReactNode; - className?: string; -}; - -const maxLabelItems = 3; - -export const DropzoneContent = ({ - children, - className, -}: DropzoneContentProps) => { - const { src } = useDropzoneContext(); - - if (!src) { - return null; - } - - if (children) { - return children; - } - - return ( -
-
- -
-

- {src.length > maxLabelItems - ? `${new Intl.ListFormat('en').format( - src.slice(0, maxLabelItems).map((file) => file.name) - )} and ${src.length - maxLabelItems} more` - : new Intl.ListFormat('en').format(src.map((file) => file.name))} -

-

- Drag and drop or click to replace -

-
- ); -}; - -export type DropzoneEmptyStateProps = { - children?: ReactNode; - className?: string; -}; - -export const DropzoneEmptyState = ({ - children, - className, -}: DropzoneEmptyStateProps) => { - const { src, accept, maxSize, minSize, maxFiles } = useDropzoneContext(); - - if (src) { - return null; - } - - if (children) { - return children; - } - - let caption = ''; - - if (accept) { - caption += 'Accepts '; - caption += new Intl.ListFormat('en').format(Object.keys(accept)); - } - - if (minSize && maxSize) { - caption += ` between ${renderBytes(minSize)} and ${renderBytes(maxSize)}`; - } else if (minSize) { - caption += ` at least ${renderBytes(minSize)}`; - } else if (maxSize) { - caption += ` less than ${renderBytes(maxSize)}`; - } - - return ( -
-
- -
-

- Upload {maxFiles === 1 ? 'a file' : 'files'} -

-

- Drag and drop or click to upload -

- {caption && ( -

{caption}.

- )} -
- ); -}; diff --git a/frontend/hooks/use-file-drag.ts b/frontend/hooks/use-file-drag.ts new file mode 100644 index 00000000..8e7fa1cd --- /dev/null +++ b/frontend/hooks/use-file-drag.ts @@ -0,0 +1,53 @@ +import { useEffect, useState } from "react"; + +/** + * Hook to detect when files are being dragged into the browser window + * @returns isDragging - true when files are being dragged over the window + */ +export function useFileDrag() { + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + let dragCounter = 0; + + const handleDragEnter = (e: DragEvent) => { + // Only detect file drags + if (e.dataTransfer?.types.includes("Files")) { + dragCounter++; + if (dragCounter === 1) { + setIsDragging(true); + } + } + }; + + const handleDragLeave = () => { + dragCounter--; + if (dragCounter === 0) { + setIsDragging(false); + } + }; + + const handleDragOver = (e: DragEvent) => { + e.preventDefault(); + }; + + const handleDrop = () => { + dragCounter = 0; + setIsDragging(false); + }; + + window.addEventListener("dragenter", handleDragEnter); + window.addEventListener("dragleave", handleDragLeave); + window.addEventListener("dragover", handleDragOver); + window.addEventListener("drop", handleDrop); + + return () => { + window.removeEventListener("dragenter", handleDragEnter); + window.removeEventListener("dragleave", handleDragLeave); + window.removeEventListener("dragover", handleDragOver); + window.removeEventListener("drop", handleDrop); + }; + }, []); + + return isDragging; +}