spb/chat-spboucher-ai
Public
1"use client";2import { useEffect, useMemo, useRef, useState } from "react";3import { useRouter } from "next/navigation";4import useSWR from "swr";5import { Search, MessageSquare, Plus, Columns2, Telescope, FlaskConical, Brain, BarChart3, Settings, CornerDownLeft, Sparkles } from "lucide-react";6import { fetcher } from "@/lib/client/fetcher";7import type { ConversationDTO } from "@/lib/client/types";8import { Dialog, cn, Kbd, Spinner } from "./ui";9import { resetDraft } from "@/lib/client/use-chat";1011interface Result { id: string; conversation_id: string; role: string; model_id: string | null; created_at: string; title: string; snippet: string }1213export function CommandPalette({ open, onClose }: { open: boolean; onClose: () => void }) {14 const router = useRouter(); const [q, setQ] = useState(""); const [idx, setIdx] = useState(0); const [debounced, setDebounced] = useState(""); const inputRef = useRef<HTMLInputElement>(null);15 useEffect(() => { const t = setTimeout(() => setDebounced(q.trim()), 220); return () => clearTimeout(t); }, [q]);16 useEffect(() => { if (open) { setQ(""); setIdx(0); setTimeout(() => inputRef.current?.focus(), 30); } }, [open]);17 const { data: convs } = useSWR<{ conversations: ConversationDTO[] }>(open ? "/api/conversations?limit=60" : null, fetcher);18 const { data: search, isLoading } = useSWR<{ results: Result[] }>(open && debounced.length >= 2 ? `/api/search?q=${encodeURIComponent(debounced)}` : null, fetcher, { keepPreviousData: true });1920 const actions = useMemo(() => [21 { id: "new", label: "Nouvelle conversation", icon: Plus, run: () => { resetDraft(); router.push("/"); } },22 { id: "compare", label: "Comparer des modèles", icon: Columns2, run: () => router.push("/compare") },23 { id: "research", label: "Recherche approfondie", icon: Telescope, run: () => router.push("/research") },24 { id: "models", label: "Laboratoire de modèles", icon: FlaskConical, run: () => router.push("/models") },25 { id: "memory", label: "Mémoire", icon: Brain, run: () => router.push("/memory") },26 { id: "usage", label: "Coûts & usage", icon: BarChart3, run: () => router.push("/usage") },27 { id: "settings", label: "Réglages", icon: Settings, run: () => router.push("/settings") },28 { id: "theme", label: "Basculer le thème", icon: Sparkles, run: () => { const cur = document.documentElement.dataset.theme; import("@/lib/client/prefs").then(m => m.setPrefs({ theme: cur === "light" ? "dark" : "light" })); } },29 ], [router]);3031 const items = useMemo(() => {32 const ql = q.trim().toLowerCase();33 const acts = actions.filter(a => !ql || a.label.toLowerCase().includes(ql)).map(a => ({ kind: "action" as const, key: `a:${a.id}`, label: a.label, icon: a.icon, run: a.run }));34 const cs = (convs?.conversations ?? []).filter(c => !ql || c.title.toLowerCase().includes(ql)).slice(0, ql ? 8 : 6).map(c => ({ kind: "conv" as const, key: `c:${c.id}`, label: c.title, sub: c.project_name ?? undefined, icon: MessageSquare, run: () => router.push(`/c/${c.id}`) }));35 const ms = (search?.results ?? []).map(r => ({ kind: "msg" as const, key: `m:${r.id}`, label: r.title, sub: r.snippet.replace(/<\/?b>/g, ""), meta: r.model_id ?? r.role, icon: Search, run: () => router.push(`/c/${r.conversation_id}#${r.id}`) }));36 return [...(ql ? [] : acts.slice(0, 4)), ...cs, ...ms, ...(ql ? acts : [])];37 }, [q, actions, convs, search, router]);3839 useEffect(() => { setIdx(0); }, [items.length, q]);40 function onKey(e: React.KeyboardEvent) {41 if (e.key === "ArrowDown") { e.preventDefault(); setIdx(i => Math.min(items.length - 1, i + 1)); }42 else if (e.key === "ArrowUp") { e.preventDefault(); setIdx(i => Math.max(0, i - 1)); }43 else if (e.key === "Enter") { e.preventDefault(); const it = items[idx]; if (it) { it.run(); onClose(); } }44 }45 return (46 <Dialog open={open} onClose={onClose} width="max-w-xl" className="!mt-[10vh] md:!mt-[14vh] !mb-auto">47 <div className="flex items-center gap-2 border-b border-border px-4 py-3">48 <Search className="h-4 w-4 text-muted" />49 <input ref={inputRef} value={q} onChange={e => setQ(e.target.value)} onKeyDown={onKey} placeholder="Rechercher des conversations, des messages, des actions…" className="flex-1 bg-transparent text-[15px] outline-none placeholder:text-muted" />50 {isLoading ? <Spinner /> : <Kbd>esc</Kbd>}51 </div>52 <div className="max-h-[60dvh] overflow-y-auto p-2">53 {items.length === 0 && <div className="px-3 py-8 text-center text-sm text-muted">{debounced.length >= 2 ? "Aucun résultat" : "Tape pour rechercher (texte intégral + sémantique)"}</div>}54 {items.map((it, i) => (55 <button key={it.key} onMouseEnter={() => setIdx(i)} onClick={() => { it.run(); onClose(); }} className={cn("flex w-full items-start gap-3 rounded-lg px-3 py-2 text-left", i === idx ? "bg-surface-2" : "hover:bg-surface-2/60")}>56 <it.icon className={cn("mt-0.5 h-4 w-4 shrink-0", it.kind === "action" ? "text-accent" : "text-muted")} />57 <div className="min-w-0 flex-1">58 <div className="flex items-center gap-2 text-sm"><span className="truncate">{it.label}</span>{"meta" in it && it.meta && <span className="shrink-0 rounded bg-surface px-1 text-[10px] text-muted">{it.meta}</span>}</div>59 {"sub" in it && it.sub && <div className="truncate-2 text-[12px] text-muted" dangerouslySetInnerHTML={{ __html: it.sub }} />}60 </div>61 {i === idx && <CornerDownLeft className="mt-1 h-3.5 w-3.5 shrink-0 text-muted" />}62 </button>63 ))}64 </div>65 </Dialog>66 );67}68