changed chat input design

This commit is contained in:
Lucas Oliveira 2025-10-21 18:11:24 -03:00 committed by Mike Fortman
parent c36fb44ce6
commit 3d56fd4932

View file

@ -1,282 +1,284 @@
import { Check, Funnel, Loader2, Plus, X } from "lucide-react"; import { Check, Funnel, Loader2, Plus, X } from "lucide-react";
import TextareaAutosize from "react-textarea-autosize";
import { forwardRef, useImperativeHandle, useRef } from "react"; import { forwardRef, useImperativeHandle, useRef } from "react";
import TextareaAutosize from "react-textarea-autosize";
import type { FilterColor } from "@/components/filter-icon-popover";
import { filterAccentClasses } from "@/components/knowledge-filter-panel"; import { filterAccentClasses } from "@/components/knowledge-filter-panel";
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 type { KnowledgeFilterData } from "../types"; import type { KnowledgeFilterData } from "../types";
import { FilterColor } from "@/components/filter-icon-popover";
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;
textareaHeight: number; textareaHeight: number;
parsedFilterData: { color?: FilterColor } | null; parsedFilterData: { color?: FilterColor } | 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;
onHeightChange: (height: number) => void; onHeightChange: (height: number) => void;
onFilterSelect: (filter: KnowledgeFilterData | null) => void; onFilterSelect: (filter: KnowledgeFilterData | null) => void;
onAtClick: () => void; onAtClick: () => void;
onFilePickerChange: (e: React.ChangeEvent<HTMLInputElement>) => void; onFilePickerChange: (e: React.ChangeEvent<HTMLInputElement>) => 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;
} }
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(( export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
{ (
input, {
loading, input,
isUploading, loading,
selectedFilter, isUploading,
isFilterDropdownOpen, selectedFilter,
availableFilters, isFilterDropdownOpen,
filterSearchTerm, availableFilters,
selectedFilterIndex, filterSearchTerm,
anchorPosition, selectedFilterIndex,
textareaHeight, anchorPosition,
parsedFilterData, textareaHeight,
onSubmit, parsedFilterData,
onChange, onSubmit,
onKeyDown, onChange,
onHeightChange, onKeyDown,
onFilterSelect, onHeightChange,
onAtClick, onFilterSelect,
onFilePickerChange, onAtClick,
onFilePickerClick, onFilePickerChange,
setSelectedFilter, onFilePickerClick,
setIsFilterHighlighted, setSelectedFilter,
setIsFilterDropdownOpen, setIsFilterHighlighted,
}, setIsFilterDropdownOpen,
ref },
) => { ref,
const inputRef = useRef<HTMLTextAreaElement>(null); ) => {
const fileInputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
focusInput: () => { focusInput: () => {
inputRef.current?.focus(); inputRef.current?.focus();
}, },
clickFileInput: () => { clickFileInput: () => {
fileInputRef.current?.click(); fileInputRef.current?.click();
}, },
})); }));
return ( return (
<div className="pb-8 pt-4 flex px-6"> <div className="pb-8 flex px-6">
<div className="w-full"> <div className="w-full">
<form onSubmit={onSubmit} className="relative"> <form onSubmit={onSubmit} className="relative">
<div className="relative w-full bg-muted/20 rounded-lg border border-border/50 focus-within:ring-1 focus-within:ring-ring"> <div className="relative w-full bg-muted/20 rounded-lg border border-border/50 focus-within:ring-1 focus-within:ring-ring">
{selectedFilter && ( {selectedFilter && (
<div className="flex items-center gap-2 px-4 pt-3 pb-1"> <div className="flex items-center gap-2 px-4 pt-3 pb-1">
<span <span
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-colors ${ className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-colors ${
filterAccentClasses[parsedFilterData?.color || "zinc"] filterAccentClasses[parsedFilterData?.color || "zinc"]
}`} }`}
> >
@filter:{selectedFilter.name} @filter:{selectedFilter.name}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
setSelectedFilter(null); setSelectedFilter(null);
setIsFilterHighlighted(false); setIsFilterHighlighted(false);
}} }}
className="ml-1 rounded-full p-0.5" className="ml-1 rounded-full p-0.5"
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>
</span> </span>
</div> </div>
)} )}
<div <div
className="relative" className="relative"
style={{ height: `${textareaHeight + 60}px` }} style={{ height: `${textareaHeight + 60}px` }}
> >
<TextareaAutosize <TextareaAutosize
ref={inputRef} ref={inputRef}
value={input} value={input}
onChange={onChange} onChange={onChange}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onHeightChange={onHeightChange} onHeightChange={onHeightChange}
maxRows={7} maxRows={7}
minRows={2} minRows={2}
placeholder="Type to ask a question..." placeholder="Type to ask a question..."
disabled={loading} disabled={loading}
className={`w-full bg-transparent px-4 ${ className={`w-full bg-transparent px-4 ${
selectedFilter ? "pt-2" : "pt-4" selectedFilter ? "pt-2" : "pt-4"
} focus-visible:outline-none resize-none`} } focus-visible:outline-none resize-none`}
rows={2} rows={2}
/> />
{/* Safe area at bottom for buttons */} {/* Safe area at bottom for buttons */}
<div <div
className="absolute bottom-0 left-0 right-0 bg-transparent pointer-events-none" className="absolute bottom-0 left-0 right-0 bg-transparent pointer-events-none"
style={{ height: "60px" }} style={{ height: "60px" }}
/> />
</div> </div>
</div> </div>
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
onChange={onFilePickerChange} onChange={onFilePickerChange}
className="hidden" className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt" accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
/> />
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="iconSm" size="iconSm"
className="absolute bottom-3 left-3 h-8 w-8 p-0 rounded-full hover:bg-muted/50" className="absolute bottom-3 left-3 h-8 w-8 p-0 rounded-full 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>
<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>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
size="iconSm" size="iconSm"
onClick={onFilePickerClick} onClick={onFilePickerClick}
disabled={isUploading} disabled={isUploading}
className="absolute bottom-3 left-12 h-8 w-8 p-0 rounded-full hover:bg-muted/50" className="absolute bottom-3 left-12 h-8 w-8 p-0 rounded-full hover:bg-muted/50"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={!input.trim() || loading} disabled={!input.trim() || loading}
className="absolute bottom-3 right-3 rounded-lg h-10 px-4" className="absolute bottom-3 right-3 rounded-lg h-10 px-4"
> >
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Send"} {loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Send"}
</Button> </Button>
</form> </form>
</div> </div>
</div> </div>
); );
}); },
);
ChatInput.displayName = "ChatInput"; ChatInput.displayName = "ChatInput";