import React, { useRef, useState, useEffect } from "react"; import { Send, Sparkles, CornerDownRight, Loader2, ShieldCheck } from "lucide-react"; import { C, FD, FM } from "../theme.js"; import { askTrace } from "../useAnalysis.js"; // "Ask the trace" — a chat bound to ONE session. The local server does // deterministic retrieval over the engine output, the LOCAL model writes the // answer (grounded, suggest-never-assert, cites turns). Every answer can open // the cited turn in the middle pane (Mode B). Nothing leaves the machine. const SUGGESTIONS = [ "Why was this session so expensive?", "What drove turn 9?", "Were there any errors or retries?", "Where did the agent act on its own?", ]; export default function ChatPanel({ sessionPath, llama, onFocusTurn, sessionLabel }) { const [msgs, setMsgs] = useState([]); const [q, setQ] = useState(""); const [busy, setBusy] = useState(false); const scroller = useRef(null); useEffect(() => { // reset the conversation when the session changes — answers are session-bound setMsgs([]); }, [sessionPath]); useEffect(() => { if (scroller.current) scroller.current.scrollTop = scroller.current.scrollHeight; }, [msgs, busy]); async function ask(text) { const question = (text ?? q).trim(); if (!question || busy) return; setQ(""); setMsgs((m) => [...m, { role: "user", text: question }]); setBusy(true); try { const res = await askTrace(question, sessionPath); setMsgs((m) => [...m, { role: "assistant", ...res }]); if (res.focusTurn != null) onFocusTurn(res.focusTurn, res.focusTool ?? null); } catch (e) { setMsgs((m) => [...m, { role: "assistant", answer: `Couldn't answer: ${String(e)}`, citedTurns: [], error: true }]); } finally { setBusy(false); } } return (
{/* header */}
Ask Her
{llama ? "LOCAL MODEL" : "MODEL OFF"}
Her reads ONLY this session's trace · cites turns · suggests, never asserts
{/* messages */}
{msgs.length === 0 && (
Try asking:
{SUGGESTIONS.map((s) => (
ask(s)} style={{ cursor: "pointer", fontFamily: FM, fontSize: 12, color: C.text2, border: `1px solid ${C.borderSoft}`, borderRadius: 8, padding: "9px 11px", marginBottom: 7, display: "flex", gap: 7, alignItems: "center" }}> {s}
))}
)} {msgs.map((m, i) => m.role === "user" ? (
{m.text}
) : (
{m.error ? "ERROR" : `GENERATED${m.model ? " · " + m.model.split("/").pop().slice(0, 22) : ""} · reads the trace`}
{m.answer} {(() => { const chips = (m.citations && m.citations.length) ? m.citations : (m.citedTurns || []).map((tn) => ({ turn: tn, tool: null, label: `turn ${tn}` })); return chips.length > 0 ? (
{chips.map((c) => ( onFocusTurn(c.turn, c.tool)} title={c.tool != null ? "opens the turn and highlights this tool" : "opens this turn"} style={{ cursor: "pointer", fontFamily: FM, fontSize: 10.5, color: C.orange, background: C.black, border: `1px solid ${C.orangeBd}`, borderRadius: 6, padding: "2px 8px", display: "inline-flex", alignItems: "center", gap: 4 }}> {c.label} → ))}
) : null; })()}
) )} {busy && (
retrieving from the trace + asking the local model…
)}
{/* input */}
setQ(e.target.value)} onKeyDown={(e) => e.key === "Enter" && ask()} placeholder="why did this happen?" style={{ flex: 1, background: "transparent", border: "none", outline: "none", color: C.text, fontFamily: FM, fontSize: 12.5 }} />
ask()} className="lift" style={{ cursor: busy ? "default" : "pointer", width: 30, height: 30, borderRadius: 7, background: q.trim() && !busy ? C.orange : C.elevated, display: "flex", alignItems: "center", justifyContent: "center" }}>
); }