File size: 7,184 Bytes
5f43c7d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
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>
  );
}