SPB Git

Repository Public MIT

composer.tsx 11.3 KB 265 lines tsx

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
Files main
Menu

Theme