Repository Public MIT
1import { useEffect, useMemo, useState } from "react";2import { AnimatePresence, motion } from "framer-motion";3import { Brain, Check, ChevronDown, CircleAlert, ExternalLink, Loader2, Search, Sparkles } from "lucide-react";4import type { AgentStep } from "./agent-steps";5import type { SearchQuery } from "@/hooks/useChatSession";6import { summarizeToolInput, toolMeta } from "@/lib/tools-meta";7import { fmtDuration, hostnameOf } from "@/lib/format";8import { cn } from "@/lib/utils";910interface AgentActivityProps {11 steps: AgentStep[];12 thinking?: string;13 searchQueries?: SearchQuery[];14 statusMessage?: string;15 currentSearchIndex?: number;16 isStreaming: boolean;17 hasAnswer: boolean;18}1920/**21 * Panneau « Travail de l'agent » : raisonnement, recherches web et outils appelés,22 * en direct pendant la génération, replié en résumé une fois la réponse terminée.23 */24export function AgentActivity({ steps, thinking = "", searchQueries = [], statusMessage = "", currentSearchIndex = 0, isStreaming, hasAnswer }: AgentActivityProps) {25 const [open, setOpen] = useState(true);26 const [thinkingOpen, setThinkingOpen] = useState(false);2728 // Ouvert pendant le travail, replié dès que la réponse commence à s'écrire.29 useEffect(() => {30 if (isStreaming && !hasAnswer) setOpen(true);31 if (hasAnswer) setOpen(false);32 }, [isStreaming, hasAnswer]);3334 const running = steps.filter((s) => s.status === "running").length;35 const errors = steps.filter((s) => s.status === "error").length;36 const done = steps.filter((s) => s.status === "completed").length;37 const families = useMemo(() => {38 const set = new Map<string, { label: string; dot: string; n: number }>();39 for (const s of steps) {40 const m = toolMeta(s.tool);41 const cur = set.get(m.family);42 set.set(m.family, { label: m.label, dot: m.dot, n: (cur?.n ?? 0) + 1 });43 }44 return Array.from(set.values());45 }, [steps]);4647 if (steps.length === 0 && !thinking && searchQueries.length === 0 && !statusMessage) return null;4849 const title = isStreaming && !hasAnswer50 ? (running > 0 ? "L'agent interroge les données…" : thinking && steps.length === 0 ? "L'agent réfléchit…" : "Analyse en cours…")51 : `Travail de l'agent`;5253 const summary = [54 steps.length > 0 && `${steps.length} outil${steps.length > 1 ? "s" : ""}`,55 searchQueries.length > 0 && `${searchQueries.length} recherche${searchQueries.length > 1 ? "s" : ""}`,56 thinking && "raisonnement",57 errors > 0 && `${errors} erreur${errors > 1 ? "s" : ""}`,58 ].filter(Boolean).join(" · ");5960 return (61 <motion.section62 initial={{ opacity: 0, y: 6 }}63 animate={{ opacity: 1, y: 0 }}64 className="surface overflow-hidden"65 data-testid="agent-activity"66 aria-live="polite"67 >68 <button69 type="button"70 onClick={() => setOpen((o) => !o)}71 className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-accent/50 transition-colors"72 aria-expanded={open}73 >74 <span className={cn("grid h-8 w-8 shrink-0 place-items-center rounded-xl", isStreaming && !hasAnswer ? "bg-brand text-white animate-pulse-ring" : "bg-brand-soft text-primary")}>75 {isStreaming && !hasAnswer ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}76 </span>77 <span className="min-w-0 flex-1">78 <span className="block text-sm font-semibold leading-tight">{title}</span>79 <span className="block truncate text-[12px] text-muted-foreground">80 {isStreaming && !hasAnswer && statusMessage ? statusMessage : summary || "Détails de l'exécution"}81 </span>82 </span>83 {!open && steps.length > 0 && (84 <span className="hidden sm:flex items-center -space-x-1">85 {families.slice(0, 6).map((f) => (86 <span key={f.label} className={cn("h-2.5 w-2.5 rounded-full ring-2 ring-card", f.dot)} title={f.label} />87 ))}88 </span>89 )}90 <span className="ml-1 hidden sm:inline-flex items-center gap-1 text-[12px] tnum text-muted-foreground">91 {done > 0 && <><Check className="h-3.5 w-3.5 text-success" /> {done}</>}92 {errors > 0 && <><CircleAlert className="h-3.5 w-3.5 text-destructive ml-1.5" /> {errors}</>}93 </span>94 <ChevronDown className={cn("h-4 w-4 text-muted-foreground transition-transform", open && "rotate-180")} />95 </button>9697 <AnimatePresence initial={false}>98 {open && (99 <motion.div100 key="body"101 initial={{ height: 0, opacity: 0 }}102 animate={{ height: "auto", opacity: 1 }}103 exit={{ height: 0, opacity: 0 }}104 transition={{ duration: 0.22, ease: [0.2, 0.7, 0.2, 1] }}105 className="overflow-hidden"106 >107 <div className="border-t border-border px-4 py-3 space-y-3">108 {/* Raisonnement */}109 {thinking && (110 <div className="rounded-xl border border-violet-500/20 bg-violet-500/5">111 <button type="button" onClick={() => setThinkingOpen((o) => !o)} className="flex w-full items-center gap-2 px-3 py-2 text-left" aria-expanded={thinkingOpen}>112 <Brain className={cn("h-4 w-4 text-violet-500", isStreaming && !hasAnswer && "animate-pulse")} />113 <span className="text-[13px] font-medium">Raisonnement</span>114 <span className="ml-auto text-[11px] text-muted-foreground tnum">{Math.round(thinking.length / 5)} mots</span>115 <ChevronDown className={cn("h-3.5 w-3.5 text-muted-foreground transition-transform", thinkingOpen && "rotate-180")} />116 </button>117 {thinkingOpen && (118 <pre className="max-h-64 overflow-y-auto whitespace-pre-wrap break-words border-t border-violet-500/15 px-3 py-2.5 font-sans text-[12.5px] leading-relaxed text-muted-foreground">119 {thinking}120 </pre>121 )}122 </div>123 )}124125 {/* Recherches web */}126 {searchQueries.length > 0 && (127 <ol className="space-y-1.5" data-testid="search-queries-section">128 {searchQueries.map((q, i) => {129 const done = !!q.results;130 const active = !done && currentSearchIndex === i + 1;131 return (132 <li key={i} className="flex items-start gap-2.5 rounded-lg px-2 py-1.5" data-testid={`search-query-${i}`}>133 <span className={cn("mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full", done ? "bg-success/15 text-success" : active ? "bg-primary/15 text-primary" : "bg-muted text-muted-foreground")}>134 {done ? <Check className="h-3 w-3" /> : active ? <Loader2 className="h-3 w-3 animate-spin" /> : <Search className="h-3 w-3" />}135 </span>136 <span className="min-w-0 flex-1">137 <span className="block text-[13px] leading-snug">{q.query}</span>138 {q.results && q.results.length > 0 && (139 <span className="mt-1 flex flex-wrap gap-1">140 {q.results.slice(0, 4).map((r, j) => (141 <a key={j} href={r.url} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 rounded-full border border-border bg-background px-2 py-0.5 text-[11px] text-muted-foreground hover:text-primary hover:border-primary/40" data-testid={`search-result-link-${i}-${j}`}>142 <ExternalLink className="h-2.5 w-2.5" />143 {hostnameOf(r.url)}144 </a>145 ))}146 </span>147 )}148 </span>149 </li>150 );151 })}152 </ol>153 )}154155 {/* Outils */}156 {steps.length > 0 && (157 <ol className="space-y-1">158 {steps.map((s, i) => {159 const m = toolMeta(s.tool);160 const Icon = m.icon;161 const input = summarizeToolInput(s.input);162 return (163 <li key={`${s.tool}-${i}`} className="flex items-center gap-2.5 rounded-lg px-2 py-1.5 hover:bg-accent/40">164 <span className={cn("grid h-6 w-6 shrink-0 place-items-center rounded-md border text-[11px]", m.chip)}>165 <Icon className="h-3.5 w-3.5" />166 </span>167 <span className="min-w-0 flex-1 flex items-baseline gap-2">168 <span className="text-[13px] font-medium leading-tight">{m.label}</span>169 {input && <span className="truncate font-mono text-[11px] text-muted-foreground">{input}</span>}170 </span>171 <span className="shrink-0 inline-flex items-center gap-1.5 text-[11px] tnum text-muted-foreground">172 {s.status === "completed" && s.duration != null && <span>{fmtDuration(s.duration)}</span>}173 {s.status === "running" && <Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />}174 {s.status === "completed" && <Check className="h-3.5 w-3.5 text-success" />}175 {s.status === "error" && (176 <span className="inline-flex items-center gap-1 text-destructive" title={s.error}>177 <CircleAlert className="h-3.5 w-3.5" /> échec178 </span>179 )}180 {s.status === "pending" && <span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" />}181 </span>182 </li>183 );184 })}185 </ol>186 )}187188 {statusMessage && isStreaming && (189 <p className="flex items-center gap-2 text-[12px] text-muted-foreground" data-testid="status-message">190 <Loader2 className="h-3 w-3 animate-spin" /> {statusMessage}191 </p>192 )}193 </div>194 </motion.div>195 )}196 </AnimatePresence>197 </motion.section>198 );199}200