her / ui /src /components /SessionGraph.jsx
geekwrestler's picture
Squash history (purge pre-scrub demo session blobs)
5f43c7d
import React from "react";
import { Lightbulb, Sparkles, CheckCircle2, AlertTriangle, ArrowRight, ScrollText } from "lucide-react";
import { C, FD, FM, fmt, intentOf, intentIcon, turnSeverity, turnToolDimmed } from "../theme.js";
import { SecHead, Hero, GeneratedTag } from "./Primitives.jsx";
// MODE A centre: the JOURNEY. One node per turn (query). Node size proportional
// to COST (Anthropic token consumption — what you pay for, not raw cacheRead);
// the heaviest glow orange; over-budget turns get an amber ring; an amber tip
// badge where a guide exists. Sequence edges connect turns. Click a node -> Mode B.
//
// Topology truth: a Claude Code session is a linear spine per turn, NOT a process
// tree. So the journey is honestly drawn as a chain, not a fabricated tree.
export default function SessionGraph({ session, turns, narrated, overview, recommendations, advice, vis, onOpen }) {
const maxCost = Math.max(1, ...turns.map((t) => t.tokens.cost ?? 0));
// Legend visibility filters which turns are shown (by severity).
const visible = turns.filter((t) => vis[turnSeverity(t)]);
const autonomousPct = Math.round(
(100 * session.indirect) / Math.max(1, session.direct + session.indirect)
);
const _cost = (tt) => tt.tokens.cost ?? 0;
const heaviest = session.heavyTurns && session.heavyTurns.length
? session.heavyTurns.reduce((a, b) => (_cost(turns[a]) >= _cost(turns[b]) ? a : b))
: turns.reduce((a, t) => (_cost(t) > _cost(turns[a]) ? t.i : a), 0);
const overBudget = (session.overBudgetTurns || []).length;
// The engine DETECTS the fixable signals (deterministic). The local model WRITES
// the advice, scoped to this session. Prefer the model-scoped list when present;
// fall back to the deterministic recommendations (and per-item, scoped || fix).
const detRecs = Array.isArray(recommendations) ? recommendations : [];
const advRecs = advice && Array.isArray(advice.recommendations) ? advice.recommendations : null;
const recs = advRecs && advRecs.length ? advRecs : detRecs;
const adviceModel = advice && advice.model ? String(advice.model).split("/").pop() : null;
const scopedReady = !!(advRecs && advRecs.some((r) => r && r.scoped));
return (
<div style={{ flex: 1, minWidth: 0, overflowY: "auto", padding: "22px 26px" }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<span style={{ fontFamily: FD, fontWeight: 700, fontSize: 20, letterSpacing: 0.3 }}>Session graph</span>
<span style={{ fontFamily: FM, fontSize: 10, color: C.cyan, border: `1px solid ${C.cyan}`, borderRadius: 5, padding: "2px 8px", display: "flex", alignItems: "center", gap: 4 }}>
<CheckCircle2 size={11} /> {session.systemTurns} SYSTEM · {session.humanTurns} HUMAN
</span>
</div>
{/* hero stat band — deterministic numbers only */}
<div style={{ display: "flex", gap: 12, marginTop: 16 }}>
<Hero v={session.turns} label="queries" />
<Hero v={session.tools} label="tool calls" />
<Hero v={fmt(session.cost ?? session.tokens?.cost ?? 0)} label="token cost · weighted" grad />
<Hero v={fmt(session.tokens.cacheRead)} label="cache re-reads · cumulative" />
{session.context && <Hero v={`${Math.round((session.context.peakPct || 0) * 100)}%`} label={`peak ctx · ${fmt(session.context.peak)}/${fmt(session.context.limit)}`} />}
<Hero v={`${autonomousPct}%`} label="agent-driven" />
</div>
{/* WHAT HAPPENED — the human, plain-English story of the session, FIRST.
Narrator prose (labelled generated); the numbers above are the engine. */}
{overview && overview.text && (
<>
<SecHead icon={ScrollText} c={C.cyan} text="WHAT HAPPENED" />
<div className="pop" style={{ marginTop: 8, fontSize: 14.5, color: C.text, lineHeight: 1.72, background: `linear-gradient(135deg,${C.card},${C.panel})`, border: `1px solid ${C.borderSoft}`, borderLeft: `3px solid ${C.cyan}`, borderRadius: 9, padding: "14px 16px" }}>
<div style={{ marginBottom: 8 }}>
<GeneratedTag cites={`turns 0–${turns.length - 1}`} />
</div>
{overview.text}
</div>
</>
)}
{/* the journey */}
<SecHead icon={Sparkles} c={C.orange} text="THE JOURNEY" />
<div style={{ fontSize: 11, color: C.muted, marginTop: 4, marginBottom: 6, fontFamily: FM }}>
node size = token cost (cost-weighted, not $) · orange = heaviest · amber ring = over budget · <Lightbulb size={10} color={C.amber} style={{ verticalAlign: "-1px" }} /> = has a tip · click to drill in
</div>
<div style={{ display: "flex", alignItems: "flex-start", overflowX: "auto", padding: "16px 4px 8px", gap: 0 }}>
{visible.map((x, idx) => {
const II = intentIcon(intentOf(x));
const heat = (x.tokens.cost ?? 0) / maxCost;
const size = 34 + Math.round(26 * heat);
const heavy = x.heavy;
const over = x.overBudget && !x.heavy; // over the absolute floor (amber ring)
const prev = visible[idx - 1];
// Tool-type legend filter: dim turns that use none of the visible tool
// types (e.g. leave only MCP on -> only MCP-using turns stay bright).
const dim = turnToolDimmed(vis, x);
return (
<React.Fragment key={x.i}>
{idx > 0 && (
<div
style={{
height: 2, minWidth: 18, flex: "0 0 22px",
marginTop: size >= 46 ? 28 : 24,
background: `linear-gradient(90deg,${prev.heavy ? C.orange : C.border},${heavy ? C.orange : C.border})`,
}}
/>
)}
<div
className="pop"
onClick={() => onOpen(x.i)}
title={`Query ${String(x.i).padStart(2, "0")} — cost ${fmt(x.tokens.cost ?? 0)} · ${fmt(x.tokens.cacheRead)} cache re-reads (cumulative) · ${fmt(x.tokens.out)} generated${x.ctxPeak ? ` · window peaked ${fmt(x.ctxPeak)}` : ""}${dim ? " · (no visible tool type)" : ""}`}
style={{ display: "flex", flexDirection: "column", alignItems: "center", cursor: "pointer", flexShrink: 0, width: Math.max(58, size + 18), animationDelay: `${idx * 45}ms`, opacity: dim ? 0.26 : 1, filter: dim ? "grayscale(0.6)" : "none", transition: "opacity .15s ease, filter .15s ease" }}
>
<div
className={heavy ? "glow" : ""}
style={{
width: size, height: size, borderRadius: "50%", display: "flex", alignItems: "center", justifyContent: "center", position: "relative",
background: heavy ? `linear-gradient(135deg,${C.orange},${C.orangeHi})` : C.card,
border: `2px solid ${x.guide ? C.amber : heavy ? C.orange : x.origin === "system" ? C.blue : C.border}`,
boxShadow: over && !x.guide ? `0 0 0 2px ${C.amber}55` : "none", // over-budget ring
}}
>
<II size={Math.round(size * 0.42)} color={heavy ? "#fff" : C.text2} />
{x.guide && (
<div style={{ position: "absolute", top: -4, right: -4, width: 15, height: 15, borderRadius: "50%", background: C.amber, display: "flex", alignItems: "center", justifyContent: "center", border: `1.5px solid ${C.bg}` }}>
<Lightbulb size={8} color="#1b1a19" />
</div>
)}
</div>
<span style={{ fontFamily: FM, fontSize: 9.5, color: C.muted, marginTop: 6 }}>{String(x.i).padStart(2, "0")}</span>
<span style={{ fontFamily: FM, fontSize: 9, color: heavy ? C.orange : over ? C.amber : C.muted }}>{fmt(x.tokens.cost ?? 0)}</span>
</div>
</React.Fragment>
);
})}
{visible.length === 0 && (
<div style={{ fontSize: 12, color: C.muted, fontFamily: FM, padding: "12px 4px" }}>
All turn types hidden — re-enable a type in the legend.
</div>
)}
</div>
{/* deterministic SHAPE note — the engine's read of cost/heat, below the
journey. Numbers + heaviest picks + causality split, never the model. */}
<div style={{ marginTop: 10, fontSize: 12.5, color: C.text2, lineHeight: 1.6, background: `linear-gradient(135deg,${C.card},${C.panel})`, border: `1px solid ${C.borderSoft}`, borderRadius: 8, padding: "11px 14px" }}>
<span style={{ fontFamily: FM, fontSize: 9.5, letterSpacing: 0.6, color: C.muted, border: `1px solid ${C.border}`, borderRadius: 4, padding: "1px 6px", marginRight: 8 }}>ENGINE</span>
Cost shape: <b style={{ color: C.orange }}>{session.turns}</b> turns ({session.humanTurns} human, {session.systemTurns} system) · <b style={{ color: C.orange }}>{fmt(session.cost ?? session.tokens?.cost ?? 0)}</b> cost (Anthropic token consumption). Heaviest turns{" "}
<b style={{ color: C.orange }}>{(session.heavyTurns || []).map((n) => String(n).padStart(2, "0")).join(", ")}</b>
{overBudget > 0 && <>; <b style={{ color: C.amber }}>{overBudget}</b> turn{overBudget === 1 ? "" : "s"} cleared the over-budget floor (≥{fmt(500000)} cost), not just the top 3</>}.
<span style={{ color: C.muted, fontStyle: "italic" }}> {" "}(Deterministic — cost-ranked heaviest picks &amp; causality split, not the model.)</span>
</div>
{/* heaviest-turn callout */}
<div className="lift" style={{ marginTop: 16, background: `linear-gradient(135deg,rgba(251,191,36,.1),transparent)`, border: `1px solid ${C.amber}`, borderRadius: 8, padding: 12 }}>
<div style={{ display: "flex", alignItems: "center", gap: 7, color: C.amber, fontSize: 12.5, fontWeight: 600 }}>
<AlertTriangle size={14} /> Heaviest turn
</div>
<div style={{ fontSize: 12, color: C.text2, marginTop: 6, lineHeight: 1.5 }}>
Query {String(heaviest).padStart(2, "0")} — <b style={{ color: C.amber }}>{fmt(turns[heaviest].tokens.cost ?? 0)}</b> cost ({fmt(turns[heaviest].tokens.cacheRead)} cache re-reads, cumulative) across {turns[heaviest].tools.length} tools.{" "}
<span onClick={() => onOpen(heaviest)} style={{ color: C.orange, cursor: "pointer", fontFamily: FM, display: "inline-flex", alignItems: "center", gap: 3 }}>
open <ArrowRight size={11} />
</span>
</div>
</div>
{/* WHAT COULD HAVE BEEN BETTER — session-level, abstracted from the engine's
FIXABLE signals (re-reads, retry loops, CLI flailing). Each item names the
turn(s) and the cited Anthropic best practice. Deterministic + cited, NOT
model prose. Empty => an honest "expensive but clean". Placed AFTER the
journey: read the story, see the cost shape, then the takeaways. */}
<SecHead icon={Lightbulb} c={C.amber} text="WHAT COULD HAVE BEEN BETTER" />
<div style={{ fontFamily: FM, fontSize: 10, color: C.muted, marginTop: 4, display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
<span style={{ color: C.text2, border: `1px solid ${C.border}`, borderRadius: 4, padding: "1px 6px" }}>ENGINE DETECTS</span>
{scopedReady ? (
<>
<span style={{ color: C.amber }}>·</span>
<span style={{ color: C.amber, border: `1px solid ${C.amber}`, borderRadius: 4, padding: "1px 6px" }}>{adviceModel || "narrator"} ADVISES</span>
scoped to this session · grounded in Anthropic's best practices · suggest-only
</>
) : (
<>the fixable signals; scoping advice to this session{advice === null ? "…" : " (model offline → showing the cited fix)"} · suggest-only</>
)}
</div>
{recs.length > 0 ? (
recs.map((r, i) => {
const adviceText = r.scoped || r.advice; // model prose, else the cited fix
const isGen = !!r.scoped;
return (
<div key={i} className="lift pop" style={{ background: C.card, border: `1px solid ${C.borderSoft}`, borderLeft: `3px solid ${C.amber}`, borderRadius: 9, padding: "12px 14px", marginTop: 9 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<div style={{ width: 24, height: 24, borderRadius: 7, flexShrink: 0, background: C.elevated, display: "flex", alignItems: "center", justifyContent: "center", border: `1px solid ${C.amber}` }}>
<Lightbulb size={12} color={C.amber} />
</div>
{(r.turns || []).map((tn) => (
<span key={tn} className="lift" onClick={() => onOpen(tn)} title={`open turn ${tn}`}
style={{ cursor: "pointer", fontFamily: FM, fontSize: 10, color: C.orange, border: `1px solid ${C.orangeBd}`, background: C.orangeMut, borderRadius: 5, padding: "2px 7px" }}>
turn {String(tn).padStart(2, "0")}
</span>
))}
<span style={{ fontSize: 13, fontWeight: 600, color: C.text }}>{r.headline}</span>
{isGen && (
<span title="written by the local model from the engine's signal" style={{ fontFamily: FM, fontSize: 8.5, color: C.amber, border: `1px solid ${C.amber}`, borderRadius: 4, padding: "1px 5px", display: "inline-flex", alignItems: "center", gap: 3 }}>
<Sparkles size={8} /> GENERATED
</span>
)}
</div>
<div style={{ fontSize: 12.5, color: C.text2, lineHeight: 1.6, marginTop: 8 }}>{adviceText}</div>
{r.practice && (
<div style={{ marginTop: 8, fontFamily: FM, fontSize: 10, color: C.muted, display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
{r.attribution === "Anthropic" ? (
// Cited Anthropic guidance — cyan badge + the best-practices link.
<>
<span style={{ color: C.cyan, border: `1px solid ${C.cyan}`, borderRadius: 4, padding: "1px 6px" }}>ANTHROPIC</span>
{r.practice}
{r.source && <> · <a href={r.source} target="_blank" rel="noreferrer" style={{ color: C.muted }}>best-practices ↗</a></>}
</>
) : (
// Custom, non-Anthropic craft — amber badge, NO Anthropic link
// (an optional public source link if the practice carries one).
<>
<span style={{ color: C.amber, border: `1px solid ${C.amber}`, borderRadius: 4, padding: "1px 6px" }}>GENERALLY RECOMMENDED</span>
{r.practice}
{r.source && <> · <a href={r.source} target="_blank" rel="noreferrer" style={{ color: C.muted }}>source ↗</a></>}
</>
)}
</div>
)}
</div>
);
})
) : (
<div className="pop" style={{ marginTop: 9, background: `linear-gradient(135deg,${C.card},transparent)`, border: `1px solid ${C.cyan}`, borderLeft: `3px solid ${C.cyan}`, borderRadius: 9, padding: "12px 14px", display: "flex", alignItems: "center", gap: 10, fontSize: 13, color: C.text2, lineHeight: 1.5 }}>
<CheckCircle2 size={16} color={C.cyan} style={{ flexShrink: 0 }} />
<span>Nothing stands out to change — this session was <b style={{ color: C.text }}>expensive but clean</b>. No retry loops, no avoidable re-reads, no CLI flailing without a skill.</span>
</div>
)}
<div style={{ fontSize: 11, color: C.muted, fontStyle: "italic", marginTop: 20, lineHeight: 1.5 }}>
The numbers, the heaviest-turn picks, "no retry loops", and which turns are flagged are the deterministic engine. "What happened" and the scoped "what could have been better" advice are the local model writing prose from those signals + Anthropic's best-practices doc — suggestions, not assertions. Tap any node to drill in.
</div>
</div>
);
}