SPB Git
5.5 KB · 68 lines tsx
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
Files · main
Menu

Theme