openrag/frontend/app/chat/_components/chat-input.tsx
Cole Goldsmith d47038e097
Reorganize folders within frontend (#407)
* reorganize folder structure

* move folders from merge

* fix import issue

* run format

* update configs
2025-11-17 08:23:23 -06:00

339 lines
12 KiB
TypeScript

import { ArrowRight, Check, Funnel, Loader2, Plus } from "lucide-react";
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import TextareaAutosize from "react-textarea-autosize";
import type { FilterColor } from "@/components/filter-icon-popover";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "@/components/ui/popover";
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;
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<HTMLTextAreaElement>) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => 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<ChatInputHandle, ChatInputProps>(
(
{
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<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [textareaHeight, setTextareaHeight] = useState(0);
useImperativeHandle(ref, () => ({
focusInput: () => {
inputRef.current?.focus();
},
clickFileInput: () => {
fileInputRef.current?.click();
},
}));
const handleFilePickerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
onFileSelected(files[0]);
} else {
onFileSelected(null);
}
};
return (
<div className="w-full">
<form onSubmit={onSubmit} className="relative">
{/* Outer container - flex-col to stack file preview above input */}
<div className="flex flex-col w-full gap-2 rounded-xl border border-input hover:[&:not(:focus-within)]:border-muted-foreground focus-within:border-foreground p-2 transition-colors">
{/* File Preview Section - Always above */}
{uploadedFile && (
<FilePreview
uploadedFile={uploadedFile}
onClear={() => {
onFileSelected(null);
}}
/>
)}
{/* Main Input Container - flex-row or flex-col based on textarea height */}
<div
className={`relative flex w-full gap-2 ${
textareaHeight > 40 ? "flex-col" : "flex-row items-center"
}`}
>
{/* Filter + Textarea Section */}
<div
className={`flex items-center gap-2 ${textareaHeight > 40 ? "w-full" : "flex-1"}`}
>
{textareaHeight <= 40 &&
(selectedFilter ? (
<SelectedKnowledgeFilter
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
onClear={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
/>
) : (
<Button
type="button"
variant="ghost"
size="iconSm"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={onAtClick}
data-filter-button
>
<Funnel className="h-4 w-4" />
</Button>
))}
<div
className="relative flex-1"
style={{ height: `${textareaHeight}px` }}
>
<TextareaAutosize
ref={inputRef}
value={input}
onChange={onChange}
onKeyDown={onKeyDown}
onHeightChange={(height) => 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}
/>
</div>
</div>
{/* Action Buttons Section */}
<div
className={`flex items-center gap-2 ${textareaHeight > 40 ? "justify-between w-full" : ""}`}
>
{textareaHeight > 40 &&
(selectedFilter ? (
<SelectedKnowledgeFilter
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
onClear={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
/>
) : (
<Button
type="button"
variant="ghost"
size="iconSm"
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
onMouseDown={(e) => {
e.preventDefault();
}}
onClick={onAtClick}
data-filter-button
>
<Funnel className="h-4 w-4" />
</Button>
))}
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="iconSm"
onClick={onFilePickerClick}
disabled={isUploading}
className="h-8 w-8 p-0 !rounded-md hover:bg-muted/50"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="default"
type="submit"
size="iconSm"
disabled={(!input.trim() && !uploadedFile) || loading}
className="!rounded-md h-8 w-8 p-0"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<ArrowRight className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
</div>
<input
ref={fileInputRef}
type="file"
onChange={handleFilePickerChange}
className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
/>
<Popover
open={isFilterDropdownOpen}
onOpenChange={(open) => {
setIsFilterDropdownOpen(open);
}}
>
{anchorPosition && (
<PopoverAnchor
asChild
style={{
position: "fixed",
left: anchorPosition.x,
top: anchorPosition.y,
width: 1,
height: 1,
pointerEvents: "none",
}}
>
<div />
</PopoverAnchor>
)}
<PopoverContent
className="w-64 p-2"
side="top"
align="start"
sideOffset={6}
alignOffset={-18}
onOpenAutoFocus={(e) => {
// Prevent auto focus on the popover content
e.preventDefault();
// Keep focus on the input
}}
>
<div className="space-y-1">
{filterSearchTerm && (
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Searching: @{filterSearchTerm}
</div>
)}
{availableFilters.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground">
No knowledge filters available
</div>
) : (
<>
{!filterSearchTerm && (
<button
type="button"
onClick={() => onFilterSelect(null)}
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" : ""
}`}
>
<span>No knowledge filter</span>
{!selectedFilter && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
)}
{availableFilters
.filter((filter) =>
filter.name
.toLowerCase()
.includes(filterSearchTerm.toLowerCase()),
)
.map((filter, index) => (
<button
key={filter.id}
type="button"
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 ${
index === selectedFilterIndex ? "bg-muted/50" : ""
}`}
>
<div className="overflow-hidden">
<div className="font-medium truncate">
{filter.name}
</div>
{filter.description && (
<div className="text-xs text-muted-foreground truncate">
{filter.description}
</div>
)}
</div>
{selectedFilter?.id === filter.id && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
))}
{availableFilters.filter((filter) =>
filter.name
.toLowerCase()
.includes(filterSearchTerm.toLowerCase()),
).length === 0 &&
filterSearchTerm && (
<div className="px-2 py-3 text-sm text-muted-foreground">
No filters match &quot;{filterSearchTerm}&quot;
</div>
)}
</>
)}
</div>
</PopoverContent>
</Popover>
</form>
</div>
);
},
);
ChatInput.displayName = "ChatInput";