This commit is contained in:
Cole Goldsmith 2025-11-18 14:34:46 -06:00
parent 6df076379c
commit 08428bc12d
2 changed files with 1725 additions and 1725 deletions

View file

@ -6,9 +6,9 @@ import TextareaAutosize from "react-textarea-autosize";
import type { FilterColor } from "@/components/filter-icon-popover"; import type { FilterColor } from "@/components/filter-icon-popover";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Popover, Popover,
PopoverAnchor, PopoverAnchor,
PopoverContent, PopoverContent,
} from "@/components/ui/popover"; } from "@/components/ui/popover";
import { useFileDrag } from "@/hooks/use-file-drag"; import { useFileDrag } from "@/hooks/use-file-drag";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -17,400 +17,400 @@ import { FilePreview } from "./file-preview";
import { SelectedKnowledgeFilter } from "./selected-knowledge-filter"; import { SelectedKnowledgeFilter } from "./selected-knowledge-filter";
export interface ChatInputHandle { export interface ChatInputHandle {
focusInput: () => void; focusInput: () => void;
clickFileInput: () => void; clickFileInput: () => void;
} }
interface ChatInputProps { interface ChatInputProps {
input: string; input: string;
loading: boolean; loading: boolean;
isUploading: boolean; isUploading: boolean;
selectedFilter: KnowledgeFilterData | null; selectedFilter: KnowledgeFilterData | null;
isFilterDropdownOpen: boolean; isFilterDropdownOpen: boolean;
availableFilters: KnowledgeFilterData[]; availableFilters: KnowledgeFilterData[];
filterSearchTerm: string; filterSearchTerm: string;
selectedFilterIndex: number; selectedFilterIndex: number;
anchorPosition: { x: number; y: number } | null; anchorPosition: { x: number; y: number } | null;
parsedFilterData: { color?: FilterColor } | null; parsedFilterData: { color?: FilterColor } | null;
uploadedFile: File | null; uploadedFile: File | null;
onSubmit: (e: React.FormEvent) => void; onSubmit: (e: React.FormEvent) => void;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void; onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void; onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onFilterSelect: (filter: KnowledgeFilterData | null) => void; onFilterSelect: (filter: KnowledgeFilterData | null) => void;
onAtClick: () => void; onAtClick: () => void;
onFilePickerClick: () => void; onFilePickerClick: () => void;
setSelectedFilter: (filter: KnowledgeFilterData | null) => void; setSelectedFilter: (filter: KnowledgeFilterData | null) => void;
setIsFilterHighlighted: (highlighted: boolean) => void; setIsFilterHighlighted: (highlighted: boolean) => void;
setIsFilterDropdownOpen: (open: boolean) => void; setIsFilterDropdownOpen: (open: boolean) => void;
onFileSelected: (file: File | null) => void; onFileSelected: (file: File | null) => void;
} }
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>( export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
( (
{ {
input, input,
loading, loading,
isUploading, isUploading,
selectedFilter, selectedFilter,
isFilterDropdownOpen, isFilterDropdownOpen,
availableFilters, availableFilters,
filterSearchTerm, filterSearchTerm,
selectedFilterIndex, selectedFilterIndex,
anchorPosition, anchorPosition,
parsedFilterData, parsedFilterData,
uploadedFile, uploadedFile,
onSubmit, onSubmit,
onChange, onChange,
onKeyDown, onKeyDown,
onFilterSelect, onFilterSelect,
onAtClick, onAtClick,
onFilePickerClick, onFilePickerClick,
setSelectedFilter, setSelectedFilter,
setIsFilterHighlighted, setIsFilterHighlighted,
setIsFilterDropdownOpen, setIsFilterDropdownOpen,
onFileSelected, onFileSelected,
}, },
ref, ref,
) => { ) => {
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const [textareaHeight, setTextareaHeight] = useState(0); const [textareaHeight, setTextareaHeight] = useState(0);
const [fileUploadError, setFileUploadError] = useState<Error | null>(null); const [fileUploadError, setFileUploadError] = useState<Error | null>(null);
const isDragging = useFileDrag(); const isDragging = useFileDrag();
const { getRootProps, getInputProps } = useDropzone({ const { getRootProps, getInputProps } = useDropzone({
accept: { accept: {
"application/pdf": [".pdf"], "application/pdf": [".pdf"],
"application/msword": [".doc"], "application/msword": [".doc"],
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
[".docx"], [".docx"],
"text/markdown": [".md"], "text/markdown": [".md"],
}, },
maxFiles: 1, maxFiles: 1,
disabled: !isDragging, disabled: !isDragging,
onDrop: (acceptedFiles, fileRejections) => { onDrop: (acceptedFiles, fileRejections) => {
setFileUploadError(null); setFileUploadError(null);
if (fileRejections.length > 0) { if (fileRejections.length > 0) {
console.log(fileRejections); console.log(fileRejections);
const message = fileRejections.at(0)?.errors.at(0)?.message; const message = fileRejections.at(0)?.errors.at(0)?.message;
setFileUploadError(new Error(message)); setFileUploadError(new Error(message));
return; return;
} }
onFileSelected(acceptedFiles[0]); onFileSelected(acceptedFiles[0]);
}, },
}); });
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
focusInput: () => { focusInput: () => {
inputRef.current?.focus(); inputRef.current?.focus();
}, },
clickFileInput: () => { clickFileInput: () => {
fileInputRef.current?.click(); fileInputRef.current?.click();
}, },
})); }));
const handleFilePickerChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFilePickerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFileUploadError(null); setFileUploadError(null);
const files = e.target.files; const files = e.target.files;
if (files && files.length > 0) { if (files && files.length > 0) {
onFileSelected(files[0]); onFileSelected(files[0]);
} else { } else {
onFileSelected(null); onFileSelected(null);
} }
}; };
return ( return (
<div className="w-full"> <div className="w-full">
<form onSubmit={onSubmit} className="relative"> <form onSubmit={onSubmit} className="relative">
{/* Outer container - flex-col to stack file preview above input */} {/* Outer container - flex-col to stack file preview above input */}
<div <div
{...getRootProps()} {...getRootProps()}
className={cn( className={cn(
"flex flex-col w-full p-2 rounded-xl border border-input transition-all", "flex flex-col w-full p-2 rounded-xl border border-input transition-all",
!isDragging && !isDragging &&
"hover:[&:not(:focus-within)]:border-muted-foreground focus-within:border-foreground", "hover:[&:not(:focus-within)]:border-muted-foreground focus-within:border-foreground",
isDragging && "border-dashed", isDragging && "border-dashed",
)} )}
> >
<input {...getInputProps()} /> <input {...getInputProps()} />
{/* File Preview Section - Always above */} {/* File Preview Section - Always above */}
<AnimatePresence> <AnimatePresence>
{uploadedFile && ( {uploadedFile && (
<motion.div <motion.div
initial={{ opacity: 0, height: 0, marginBottom: 0 }} initial={{ opacity: 0, height: 0, marginBottom: 0 }}
animate={{ opacity: 1, height: "auto", marginBottom: 8 }} animate={{ opacity: 1, height: "auto", marginBottom: 8 }}
exit={{ opacity: 0, height: 0, marginBottom: 0 }} exit={{ opacity: 0, height: 0, marginBottom: 0 }}
className="overflow-hidden" className="overflow-hidden"
> >
<FilePreview <FilePreview
uploadedFile={uploadedFile} uploadedFile={uploadedFile}
onClear={() => { onClear={() => {
onFileSelected(null); onFileSelected(null);
}} }}
/> />
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
<AnimatePresence> <AnimatePresence>
{fileUploadError && ( {fileUploadError && (
<motion.p <motion.p
initial={{ opacity: 0, height: 0 }} initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }} animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }} exit={{ opacity: 0, height: 0 }}
className="text-sm text-destructive overflow-hidden" className="text-sm text-destructive overflow-hidden"
> >
{fileUploadError.message} {fileUploadError.message}
</motion.p> </motion.p>
)} )}
</AnimatePresence> </AnimatePresence>
<AnimatePresence> <AnimatePresence>
{isDragging && ( {isDragging && (
<motion.div <motion.div
initial={{ opacity: 0, height: 0 }} initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 100 }} animate={{ opacity: 1, height: 100 }}
exit={{ opacity: 0, height: 0 }} exit={{ opacity: 0, height: 0 }}
className="overflow-hidden w-full flex flex-col items-center justify-center gap-2" className="overflow-hidden w-full flex flex-col items-center justify-center gap-2"
> >
<p className="text-md font-medium text-primary"> <p className="text-md font-medium text-primary">
Add files to conversation Add files to conversation
</p> </p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Text formats and image files.{" "} Text formats and image files.{" "}
<span className="font-semibold">10</span> files per chat,{" "} <span className="font-semibold">10</span> files per chat,{" "}
<span className="font-semibold">150 MB</span> each. <span className="font-semibold">150 MB</span> each.
</p> </p>
</motion.div> </motion.div>
)} )}
</AnimatePresence> </AnimatePresence>
{/* Main Input Container - flex-row or flex-col based on textarea height */} {/* Main Input Container - flex-row or flex-col based on textarea height */}
<div <div
className={`relative flex w-full gap-2 ${ className={`relative flex w-full gap-2 ${
textareaHeight > 40 ? "flex-col" : "flex-row items-center" textareaHeight > 40 ? "flex-col" : "flex-row items-center"
}`} }`}
> >
{/* Filter + Textarea Section */} {/* Filter + Textarea Section */}
<div <div
className={`flex items-center gap-2 ${textareaHeight > 40 ? "w-full" : "flex-1"}`} className={`flex items-center gap-2 ${textareaHeight > 40 ? "w-full" : "flex-1"}`}
> >
{textareaHeight <= 40 && {textareaHeight <= 40 &&
(selectedFilter ? ( (selectedFilter ? (
<SelectedKnowledgeFilter <SelectedKnowledgeFilter
selectedFilter={selectedFilter} selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData} parsedFilterData={parsedFilterData}
onClear={() => { onClear={() => {
setSelectedFilter(null); setSelectedFilter(null);
setIsFilterHighlighted(false); setIsFilterHighlighted(false);
}} }}
/> />
) : ( ) : (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="iconSm" size="iconSm"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50" className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
}} }}
onClick={onAtClick} onClick={onAtClick}
data-filter-button data-filter-button
> >
<Funnel className="h-4 w-4" /> <Funnel className="h-4 w-4" />
</Button> </Button>
))} ))}
<div <div
className="relative flex-1" className="relative flex-1"
style={{ height: `${textareaHeight}px` }} style={{ height: `${textareaHeight}px` }}
> >
<TextareaAutosize <TextareaAutosize
ref={inputRef} ref={inputRef}
value={input} value={input}
onChange={onChange} onChange={onChange}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onHeightChange={(height) => setTextareaHeight(height)} onHeightChange={(height) => setTextareaHeight(height)}
maxRows={7} maxRows={7}
autoComplete="off" autoComplete="off"
minRows={1} minRows={1}
placeholder="Ask a question..." placeholder="Ask a question..."
disabled={loading} disabled={loading}
className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`} className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`}
rows={1} rows={1}
/> />
</div> </div>
</div> </div>
{/* Action Buttons Section */} {/* Action Buttons Section */}
<div <div
className={`flex items-center gap-2 ${textareaHeight > 40 ? "justify-between w-full" : ""}`} className={`flex items-center gap-2 ${textareaHeight > 40 ? "justify-between w-full" : ""}`}
> >
{textareaHeight > 40 && {textareaHeight > 40 &&
(selectedFilter ? ( (selectedFilter ? (
<SelectedKnowledgeFilter <SelectedKnowledgeFilter
selectedFilter={selectedFilter} selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData} parsedFilterData={parsedFilterData}
onClear={() => { onClear={() => {
setSelectedFilter(null); setSelectedFilter(null);
setIsFilterHighlighted(false); setIsFilterHighlighted(false);
}} }}
/> />
) : ( ) : (
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="iconSm" size="iconSm"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50" className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => { onMouseDown={(e) => {
e.preventDefault(); e.preventDefault();
}} }}
onClick={onAtClick} onClick={onAtClick}
data-filter-button data-filter-button
> >
<Funnel className="h-4 w-4" /> <Funnel className="h-4 w-4" />
</Button> </Button>
))} ))}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="iconSm" size="iconSm"
onClick={onFilePickerClick} onClick={onFilePickerClick}
disabled={isUploading} disabled={isUploading}
className="h-8 w-8 p-0 !rounded-md hover:bg-muted/50" className="h-8 w-8 p-0 !rounded-md hover:bg-muted/50"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
<Button <Button
variant="default" variant="default"
type="submit" type="submit"
size="iconSm" size="iconSm"
disabled={(!input.trim() && !uploadedFile) || loading} disabled={(!input.trim() && !uploadedFile) || loading}
className="!rounded-md h-8 w-8 p-0" className="!rounded-md h-8 w-8 p-0"
> >
{loading ? ( {loading ? (
<Loader2 className="h-4 w-4 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<ArrowRight className="h-4 w-4" /> <ArrowRight className="h-4 w-4" />
)} )}
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
onChange={handleFilePickerChange} onChange={handleFilePickerChange}
className="hidden" className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt" accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
/> />
<Popover <Popover
open={isFilterDropdownOpen} open={isFilterDropdownOpen}
onOpenChange={(open) => { onOpenChange={(open) => {
setIsFilterDropdownOpen(open); setIsFilterDropdownOpen(open);
}} }}
> >
{anchorPosition && ( {anchorPosition && (
<PopoverAnchor <PopoverAnchor
asChild asChild
style={{ style={{
position: "fixed", position: "fixed",
left: anchorPosition.x, left: anchorPosition.x,
top: anchorPosition.y, top: anchorPosition.y,
width: 1, width: 1,
height: 1, height: 1,
pointerEvents: "none", pointerEvents: "none",
}} }}
> >
<div /> <div />
</PopoverAnchor> </PopoverAnchor>
)} )}
<PopoverContent <PopoverContent
className="w-64 p-2" className="w-64 p-2"
side="top" side="top"
align="start" align="start"
sideOffset={6} sideOffset={6}
alignOffset={-18} alignOffset={-18}
onOpenAutoFocus={(e) => { onOpenAutoFocus={(e) => {
// Prevent auto focus on the popover content // Prevent auto focus on the popover content
e.preventDefault(); e.preventDefault();
// Keep focus on the input // Keep focus on the input
}} }}
> >
<div className="space-y-1"> <div className="space-y-1">
{filterSearchTerm && ( {filterSearchTerm && (
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground"> <div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Searching: @{filterSearchTerm} Searching: @{filterSearchTerm}
</div> </div>
)} )}
{availableFilters.length === 0 ? ( {availableFilters.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground"> <div className="px-2 py-3 text-sm text-muted-foreground">
No knowledge filters available No knowledge filters available
</div> </div>
) : ( ) : (
<> <>
{!filterSearchTerm && ( {!filterSearchTerm && (
<button <button
type="button" type="button"
onClick={() => onFilterSelect(null)} onClick={() => onFilterSelect(null)}
className={`w-full text-left px-2 py-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${ className={`w-full text-left px-2 py-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
selectedFilterIndex === -1 ? "bg-muted/50" : "" selectedFilterIndex === -1 ? "bg-muted/50" : ""
}`} }`}
> >
<span>No knowledge filter</span> <span>No knowledge filter</span>
{!selectedFilter && ( {!selectedFilter && (
<Check className="h-4 w-4 shrink-0" /> <Check className="h-4 w-4 shrink-0" />
)} )}
</button> </button>
)} )}
{availableFilters {availableFilters
.filter((filter) => .filter((filter) =>
filter.name filter.name
.toLowerCase() .toLowerCase()
.includes(filterSearchTerm.toLowerCase()), .includes(filterSearchTerm.toLowerCase()),
) )
.map((filter, index) => ( .map((filter, index) => (
<button <button
key={filter.id} key={filter.id}
type="button" type="button"
onClick={() => onFilterSelect(filter)} onClick={() => onFilterSelect(filter)}
className={`w-full overflow-hidden text-left px-2 py-2 gap-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${ className={`w-full overflow-hidden text-left px-2 py-2 gap-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
index === selectedFilterIndex ? "bg-muted/50" : "" index === selectedFilterIndex ? "bg-muted/50" : ""
}`} }`}
> >
<div className="overflow-hidden"> <div className="overflow-hidden">
<div className="font-medium truncate"> <div className="font-medium truncate">
{filter.name} {filter.name}
</div> </div>
{filter.description && ( {filter.description && (
<div className="text-xs text-muted-foreground truncate"> <div className="text-xs text-muted-foreground truncate">
{filter.description} {filter.description}
</div> </div>
)} )}
</div> </div>
{selectedFilter?.id === filter.id && ( {selectedFilter?.id === filter.id && (
<Check className="h-4 w-4 shrink-0" /> <Check className="h-4 w-4 shrink-0" />
)} )}
</button> </button>
))} ))}
{availableFilters.filter((filter) => {availableFilters.filter((filter) =>
filter.name filter.name
.toLowerCase() .toLowerCase()
.includes(filterSearchTerm.toLowerCase()), .includes(filterSearchTerm.toLowerCase()),
).length === 0 && ).length === 0 &&
filterSearchTerm && ( filterSearchTerm && (
<div className="px-2 py-3 text-sm text-muted-foreground"> <div className="px-2 py-3 text-sm text-muted-foreground">
No filters match &quot;{filterSearchTerm}&quot; No filters match &quot;{filterSearchTerm}&quot;
</div> </div>
)} )}
</> </>
)} )}
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</form> </form>
</div> </div>
); );
}, },
); );
ChatInput.displayName = "ChatInput"; ChatInput.displayName = "ChatInput";

File diff suppressed because it is too large Load diff