Repository Public MIT
1import { useEffect, useRef, useState, type FormEvent, type KeyboardEvent } from "react";2import { ArrowUp, ImagePlus, Loader2, X, ChevronDown, Check } from "lucide-react";3import type { ClaudeModel } from "@/hooks/useChatSession";4import { getModel, groupModelsByProvider } from "@/lib/models";5import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";6import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";7import { cn } from "@/lib/utils";89export interface ComposerProps {10 onSearch: (query: string, imageData?: string, imageMimeType?: string) => void;11 isLoading?: boolean;12 placeholder?: string;13 selectedModel?: ClaudeModel;14 onModelChange?: (model: ClaudeModel) => void;15 /** variante « héros » (accueil) : plus grande, ombre marquée */16 hero?: boolean;17 autoFocus?: boolean;18 statusMessage?: string;19 className?: string;20}2122const MAX_IMAGE_BYTES = 5 * 1024 * 1024;2324export function ModelPicker({ value, onChange, compact }: { value?: ClaudeModel; onChange?: (m: ClaudeModel) => void; compact?: boolean }) {25 const [open, setOpen] = useState(false);26 const current = getModel(value);27 const Icon = current.icon;28 const groups = groupModelsByProvider();2930 return (31 <Popover open={open} onOpenChange={setOpen} modal={false}>32 <PopoverTrigger asChild>33 <button34 type="button"35 data-testid="model-picker"36 className={cn(37 "inline-flex items-center gap-1.5 rounded-full border border-border bg-background/70 text-foreground/90 hover:bg-accent transition-colors",38 compact ? "h-7 px-2 text-[12px]" : "h-8 px-2.5 text-[12.5px]"39 )}40 aria-label={`Modèle : ${current.label}`}41 >42 <Icon className="h-3.5 w-3.5 text-primary" />43 <span className="font-medium">{current.label}</span>44 <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />45 </button>46 </PopoverTrigger>47 <PopoverContent align="start" side="top" sideOffset={10} collisionPadding={12} className="w-[min(92vw,420px)] p-0 rounded-2xl overflow-hidden">48 <div className="overflow-y-auto p-2" style={{ maxHeight: "min(60vh, calc(var(--radix-popover-content-available-height) - 12px))" }}>49 {groups.map((g) => (50 <div key={g.provider} className="mb-1.5 last:mb-0">51 <div className="px-2.5 pt-2 pb-1 text-[10.5px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">{g.label}</div>52 <div className="grid grid-cols-1 sm:grid-cols-2 gap-0.5">53 {g.models.map((m) => {54 const MIcon = m.icon;55 const active = m.value === current.value;56 return (57 <button58 key={m.value}59 type="button"60 disabled={m.disabled}61 onClick={() => { onChange?.(m.value); setOpen(false); }}62 className={cn(63 "flex items-center gap-2.5 rounded-xl px-2.5 py-2 text-left transition-colors disabled:opacity-40",64 active ? "bg-primary/10" : "hover:bg-accent"65 )}66 >67 <span className={cn("grid h-7 w-7 shrink-0 place-items-center rounded-lg", active ? "bg-brand text-white" : "bg-muted text-primary")}>68 <MIcon className="h-3.5 w-3.5" />69 </span>70 <span className="min-w-0 flex-1">71 <span className="flex items-center gap-1.5 text-[13px] font-medium leading-tight">72 <span className="truncate">{m.label}</span>73 {m.badge && <span className="rounded-full bg-primary/15 px-1.5 py-px text-[9.5px] font-semibold text-primary">{m.badge}</span>}74 </span>75 <span className="block truncate text-[11px] text-muted-foreground">{m.sub}{m.context ? ` · ${m.context}` : ""}</span>76 </span>77 {active && <Check className="h-4 w-4 text-primary shrink-0" />}78 </button>79 );80 })}81 </div>82 </div>83 ))}84 </div>85 </PopoverContent>86 </Popover>87 );88}8990export function Composer({91 onSearch,92 isLoading = false,93 placeholder = "Posez votre question sur les marchés financiers…",94 selectedModel,95 onModelChange,96 hero = false,97 autoFocus = false,98 statusMessage,99 className,100}: ComposerProps) {101 const [query, setQuery] = useState("");102 const [focused, setFocused] = useState(false);103 const [image, setImage] = useState<{ data: string; mime: string; name: string } | null>(null);104 const [imageError, setImageError] = useState<string | null>(null);105 const taRef = useRef<HTMLTextAreaElement>(null);106 const fileRef = useRef<HTMLInputElement>(null);107108 // auto-grow109 useEffect(() => {110 const ta = taRef.current;111 if (!ta) return;112 ta.style.height = "0px";113 const max = hero ? 220 : 180;114 ta.style.height = Math.min(ta.scrollHeight, max) + "px";115 }, [query, hero]);116117 useEffect(() => {118 if (autoFocus) taRef.current?.focus();119 }, [autoFocus]);120121 // Pré-remplissage (ex. « Analyser avec l'IA » depuis la page Marchés)122 useEffect(() => {123 try {124 const pre = sessionStorage.getItem("vquant-prefill");125 if (pre && hero) {126 sessionStorage.removeItem("vquant-prefill");127 setQuery(pre);128 taRef.current?.focus();129 }130 } catch { /* ignore */ }131 }, [hero]);132133 const canSubmit = query.trim().length > 0 && !isLoading;134135 const submit = () => {136 if (!canSubmit) return;137 onSearch(query.trim(), image?.data, image?.mime);138 setQuery("");139 setImage(null);140 };141142 const handleSubmit = (e: FormEvent) => {143 e.preventDefault();144 submit();145 };146147 const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {148 if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {149 e.preventDefault();150 submit();151 }152 };153154 const pickImage = (file: File | undefined | null) => {155 setImageError(null);156 if (!file) return;157 if (!file.type.startsWith("image/")) { setImageError("Seules les images sont acceptées."); return; }158 if (file.size > MAX_IMAGE_BYTES) { setImageError("Image trop lourde (max 5 Mo)."); return; }159 const reader = new FileReader();160 reader.onload = () => {161 const result = String(reader.result || "");162 const base64 = result.split(",")[1] || "";163 setImage({ data: base64, mime: file.type, name: file.name });164 };165 reader.readAsDataURL(file);166 };167168 return (169 <div className={cn("w-full", className)} data-testid="search-bar-container">170 <form onSubmit={handleSubmit} data-testid="form-search" className="relative">171 <div172 data-testid="search-input-wrapper"173 className={cn(174 "relative rounded-[1.35rem] border bg-card transition-all duration-200",175 hero ? "shadow-lg" : "shadow-sm",176 focused ? "border-primary/60 ring-4 ring-primary/10" : "border-border hover:border-foreground/20",177 isLoading && "opacity-95"178 )}179 onPaste={(e) => {180 const item = Array.from(e.clipboardData.items).find((i) => i.type.startsWith("image/"));181 if (item) pickImage(item.getAsFile());182 }}183 >184 {image && (185 <div className="flex items-center gap-2 px-4 pt-3">186 <span className="relative inline-flex">187 <img src={`data:${image.mime};base64,${image.data}`} alt={image.name} className="h-14 w-14 rounded-xl object-cover border border-border" />188 <button type="button" onClick={() => setImage(null)} className="absolute -right-1.5 -top-1.5 grid h-5 w-5 place-items-center rounded-full bg-foreground text-background shadow" aria-label="Retirer l'image">189 <X className="h-3 w-3" />190 </button>191 </span>192 <span className="text-xs text-muted-foreground truncate">{image.name}</span>193 </div>194 )}195196 <textarea197 ref={taRef}198 value={query}199 onChange={(e) => setQuery(e.target.value)}200 onKeyDown={onKeyDown}201 onFocus={() => setFocused(true)}202 onBlur={() => setFocused(false)}203 placeholder={placeholder}204 disabled={isLoading}205 rows={1}206 data-testid="input-search-query"207 aria-label="Votre question"208 className={cn(209 "block w-full resize-none bg-transparent outline-none placeholder:text-muted-foreground/80 disabled:cursor-not-allowed",210 hero ? "px-5 pt-4 pb-2 text-[17px] leading-relaxed" : "px-4 pt-3.5 pb-1.5 text-[15px] leading-relaxed"211 )}212 />213214 <div className={cn("flex items-center gap-1.5", hero ? "px-3 pb-3 pt-1" : "px-2.5 pb-2.5 pt-0.5")}>215 <ModelPicker value={selectedModel} onChange={onModelChange} compact={!hero} />216217 <Tooltip>218 <TooltipTrigger asChild>219 <button220 type="button"221 onClick={() => fileRef.current?.click()}222 disabled={isLoading}223 className="grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-40"224 aria-label="Joindre une image"225 >226 <ImagePlus className="h-4 w-4" />227 </button>228 </TooltipTrigger>229 <TooltipContent side="top">Joindre une image (graphique, tableau…)</TooltipContent>230 </Tooltip>231 <input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={(e) => pickImage(e.target.files?.[0])} />232233 <div className="ml-auto flex items-center gap-2.5">234 {isLoading ? (235 <span className="hidden sm:inline-flex items-center gap-1.5 text-[12px] text-muted-foreground max-w-[260px] truncate">236 <Loader2 className="h-3.5 w-3.5 animate-spin text-primary shrink-0" />237 <span className="truncate">{statusMessage || "Analyse en cours…"}</span>238 </span>239 ) : (240 <span className="hidden sm:inline-flex items-center gap-1 text-[11px] text-muted-foreground">241 <kbd className="kbd">↵</kbd> envoyer · <kbd className="kbd">⇧↵</kbd> nouvelle ligne242 </span>243 )}244 <button245 type="submit"246 disabled={!canSubmit}247 data-testid="button-submit"248 aria-label="Envoyer"249 className={cn(250 "grid place-items-center rounded-full transition-all",251 hero ? "h-10 w-10" : "h-9 w-9",252 canSubmit ? "bg-brand text-white shadow-brand hover:brightness-105 active:scale-95" : "bg-muted text-muted-foreground cursor-not-allowed"253 )}254 >255 {isLoading ? <Loader2 className="h-4 w-4 animate-spin" data-testid="icon-search-loading" /> : <ArrowUp className="h-[18px] w-[18px]" strokeWidth={2.4} />}256 </button>257 </div>258 </div>259 </div>260 {imageError && <p className="mt-1.5 px-2 text-xs text-destructive">{imageError}</p>}261 </form>262 </div>263 );264}265