her / ui /src /components /ChatPanel.jsx
geekwrestler's picture
Squash history (purge pre-scrub demo session blobs)
5f43c7d
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 (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
{/* header */}
<div style={{ padding: "13px 15px 11px", borderBottom: `1px solid ${C.borderSoft}` }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Sparkles size={15} color={C.orange} />
<span style={{ fontFamily: FD, fontWeight: 700, fontSize: 14 }}>Ask <span style={{ color: C.orange }}>Her</span></span>
<div style={{ flex: 1 }} />
<span style={{ fontFamily: FM, fontSize: 9, color: llama ? C.cyan : C.muted, border: `1px solid ${llama ? C.cyan : C.border}`, borderRadius: 5, padding: "1px 6px", display: "flex", alignItems: "center", gap: 3 }}>
<ShieldCheck size={10} /> {llama ? "LOCAL MODEL" : "MODEL OFF"}
</span>
</div>
<div style={{ fontFamily: FM, fontSize: 10, color: C.muted, marginTop: 6, lineHeight: 1.5 }}>
Her reads ONLY this session's trace · cites turns · suggests, never asserts
</div>
</div>
{/* messages */}
<div ref={scroller} style={{ flex: 1, overflowY: "auto", padding: "12px 13px" }}>
{msgs.length === 0 && (
<div>
<div style={{ fontFamily: FM, fontSize: 11, color: C.muted, marginBottom: 9 }}>Try asking:</div>
{SUGGESTIONS.map((s) => (
<div key={s} className="row lift" onClick={() => 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" }}>
<CornerDownRight size={12} color={C.orange} /> {s}
</div>
))}
</div>
)}
{msgs.map((m, i) =>
m.role === "user" ? (
<div key={i} style={{ display: "flex", justifyContent: "flex-end", marginBottom: 10 }}>
<div style={{ maxWidth: "88%", background: C.orangeMut, border: `1px solid ${C.orangeBd}`, borderRadius: "10px 10px 2px 10px", padding: "8px 11px", fontSize: 12.5, color: C.text, lineHeight: 1.5 }}>{m.text}</div>
</div>
) : (
<div key={i} style={{ marginBottom: 14 }}>
<div style={{ display: "flex", gap: 7, alignItems: "center", marginBottom: 5 }}>
<Sparkles size={11} color={m.error ? C.red : C.orange} />
<span style={{ fontFamily: FM, fontSize: 9, letterSpacing: 0.4, color: C.muted }}>
{m.error ? "ERROR" : `GENERATED${m.model ? " · " + m.model.split("/").pop().slice(0, 22) : ""} · reads the trace`}
</span>
</div>
<div style={{ fontSize: 13, color: C.text2, lineHeight: 1.6, background: `linear-gradient(135deg,${C.card},${C.panel})`, border: `1px solid ${C.borderSoft}`, borderRadius: "2px 10px 10px 10px", padding: "10px 12px" }}>
{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 ? (
<div style={{ display: "flex", flexWrap: "wrap", gap: 6, marginTop: 10 }}>
{chips.map((c) => (
<span key={`${c.turn}-${c.tool}`} className="lift" onClick={() => 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} →
</span>
))}
</div>
) : null;
})()}
</div>
</div>
)
)}
{busy && (
<div style={{ display: "flex", gap: 7, alignItems: "center", color: C.muted, fontFamily: FM, fontSize: 11 }}>
<Loader2 size={13} className="spin" /> retrieving from the trace + asking the local model…
</div>
)}
</div>
{/* input */}
<div style={{ padding: "10px 12px", borderTop: `1px solid ${C.borderSoft}` }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, background: C.black, border: `1px solid ${C.borderSoft}`, borderRadius: 9, padding: "7px 9px 7px 12px" }}>
<input
value={q}
onChange={(e) => 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 }}
/>
<div onClick={() => 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" }}>
<Send size={14} color={q.trim() && !busy ? "#fff" : C.muted} />
</div>
</div>
</div>
</div>
);
}