her / ui /src /components /TurnGraph.jsx
geekwrestler's picture
Squash history (purge pre-scrub demo session blobs)
5f43c7d
import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
import { MessageSquare, CornerDownRight, Lightbulb } from "lucide-react";
import { C, FD, FM, toolIcon, intentOf, intentIcon, toolTypeVisible } from "../theme.js";
import { EdgeLegend } from "./Primitives.jsx";
import { buildDataflow } from "../dataflow.js";
// MODE B centre: per-turn DATAFLOW graph. Root = the user query. Children = the
// tool calls along the spine. INDIRECT edges cross-link a tool_result to the
// later tool that consumed its value — the highlighted causal path.
//
// PROVEN (value-flow) edges = SOLID orange. HYPOTHESIS (proximity) = DOTTED muted.
// Always visually separated (NON-NEGOTIABLE #4).
const ROW_H = 40; // vertical spacing per tool row
const GUTTER = 64; // left gutter reserved for the edge SVG
const NODE_X = 22; // x of node centre inside the gutter
export default function TurnGraph({ turn, selected, onSelect, vis }) {
const { nodes, edges } = buildDataflow(turn);
// Tool-type legend filter: rows of a hidden tool type are dimmed and their
// value-flow arcs suppressed, so the eyes work here too.
const hidden = new Set();
(turn.tools || []).forEach((tl, idx) => {
if (vis && !toolTypeVisible(vis, tl.name)) hidden.add("t" + idx);
});
const containerRef = useRef(null);
const rowRefs = useRef({});
const [ys, setYs] = useState({});
// Measure each node's vertical centre so the SVG edges can connect to the
// real DOM rows (robust to wrapping / variable heights).
useLayoutEffect(() => {
const cont = containerRef.current;
if (!cont) return;
const base = cont.getBoundingClientRect().top;
const next = {};
nodes.forEach((n) => {
const el = rowRefs.current[n.key];
if (el) {
const r = el.getBoundingClientRect();
next[n.key] = r.top - base + r.height / 2;
}
});
setYs(next);
// re-run when the turn or selection changes layout
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [turn.i, selected, nodes.length]);
// When a tool is selected (e.g. opened from a chat citation), scroll it into
// view — a 71-tool turn can hide the cited tool far down the spine.
useEffect(() => {
if (selected == null) return;
const el = rowRefs.current["t" + selected];
if (el && el.scrollIntoView) el.scrollIntoView({ block: "center", behavior: "smooth" });
}, [selected, turn.i]);
const II = intentIcon(intentOf(turn));
// Focus-driven causal path: a turn with 49 value-flow links must NOT render as
// a permanent hairball. The clean grey spine is always on; an orange value-flow
// arc is drawn ONLY for the tool the user hovers or selects (UI-SPEC: "the
// highlighted causal path").
const [hover, setHover] = useState(null);
const activeIdx = hover != null ? hover : selected;
const activeKey = activeIdx != null ? "t" + activeIdx : null;
const provenCount = edges.filter((e) => e.type === "proven").length;
const tracedHere = edges.filter((e) => e.type === "proven" && e.from && e.to).length;
// Build SVG path data for each edge using measured y-centres.
function edgePath(e) {
const y1 = ys[e.from];
const y2 = ys[e.to];
if (y1 == null || y2 == null) return null;
if (e.type === "spine") {
// vertical backbone hugging the gutter
return `M ${NODE_X} ${y1} L ${NODE_X} ${y2}`;
}
// cross-link: ONE smooth cubic bow to the left — a clean causal jump.
const bow = Math.min(40, 16 + Math.abs(y2 - y1) * 0.12);
const cx = NODE_X - bow;
return `M ${NODE_X} ${y1} C ${cx} ${y1}, ${cx} ${y2}, ${NODE_X} ${y2}`;
}
return (
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
{/* query header */}
<div style={{ padding: "14px 22px 12px", borderBottom: `1px solid ${C.borderSoft}` }}>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<II size={15} color={turn.heavy ? C.orange : C.blue} />
<span style={{ fontFamily: FD, fontWeight: 600, fontSize: 13, letterSpacing: 0.3, color: C.muted }}>
QUERY {String(turn.i).padStart(2, "0")}
</span>
{turn.origin === "system" && (
<span style={{ fontFamily: FM, fontSize: 9, color: C.blue, border: `1px solid ${C.blue}`, borderRadius: 4, padding: "1px 6px" }}>SYSTEM</span>
)}
{turn.heavy && (
<span style={{ fontFamily: FM, fontSize: 9, color: C.orange, border: `1px solid ${C.orange}`, borderRadius: 4, padding: "1px 6px" }}>HEAVY</span>
)}
</div>
<div style={{ fontSize: 14, color: C.text, marginTop: 8, lineHeight: 1.5, whiteSpace: "pre-wrap", maxHeight: 80, overflow: "auto", fontFamily: FM, background: C.black, padding: "10px 12px", borderRadius: 7, border: `1px solid ${C.borderSoft}` }}>
{turn.prompt}
</div>
{turn.reply && (
<div style={{ display: "flex", gap: 7, marginTop: 10, fontSize: 12.5, color: C.text2, lineHeight: 1.5 }}>
<CornerDownRight size={14} color={C.cyan} style={{ flexShrink: 0, marginTop: 2 }} />
<span><b style={{ color: C.cyan, fontFamily: FM, fontSize: 10 }}>REPLY </b>{turn.reply.slice(0, 240)}…</span>
</div>
)}
</div>
<div style={{ overflowY: "auto", flex: 1, padding: "14px 22px" }}>
{/* guidance card — surfaces inline on flagged turns ONLY */}
{turn.guide && (
<div className="pop" style={{ display: "flex", gap: 12, background: `linear-gradient(135deg,${C.orangeMut},transparent)`, border: `1px solid ${C.amber}`, borderRadius: 8, padding: "13px 15px", marginBottom: 16 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, flexShrink: 0, background: C.amber, display: "flex", alignItems: "center", justifyContent: "center" }}>
<Lightbulb size={17} color="#1b1a19" />
</div>
<div>
<div style={{ fontSize: 13.5, fontWeight: 700, color: C.amber, fontFamily: FD, letterSpacing: 0.2 }}>NEXT TIME — {turn.guide.head}</div>
<div style={{ fontSize: 12.5, color: C.text2, lineHeight: 1.6, marginTop: 5 }}>{turn.guide.body}</div>
</div>
</div>
)}
{/* edge legend — proven vs hypothesis, always separated */}
<div style={{ display: "flex", gap: 16, marginBottom: 10, flexWrap: "wrap", alignItems: "center" }}>
<EdgeLegend c={C.border} text="sequence" />
<EdgeLegend c={C.orange} text="proven · value-flow" />
<EdgeLegend c={C.muted} text="hypothesis · proximity" dashed />
<div style={{ flex: 1 }} />
<span style={{ fontFamily: FM, fontSize: 11, color: C.muted }}>{turn.reqs} round-trips · {turn.tools.length} tools</span>
</div>
{/* causal-path hint — keeps the canvas clean; arcs are drawn on focus */}
{provenCount > 0 && (
<div style={{ marginBottom: 12, fontFamily: FM, fontSize: 10.5, color: C.muted }}>
<span style={{ color: C.orange }}></span> {provenCount} proven value-flow link{provenCount === 1 ? "" : "s"} on this turn
{tracedHere < provenCount && <span> · {tracedHere} traceable in-turn</span>} ·{" "}
<span style={{ color: C.text2 }}>hover or click a tool to trace its causal path</span>
</div>
)}
{/* the graph: SVG edge layer + DOM node rows */}
<div ref={containerRef} style={{ position: "relative" }}>
<svg style={{ position: "absolute", left: 0, top: 0, width: GUTTER, height: "100%", overflow: "visible", pointerEvents: "none" }}>
{edges.map((e, i) => {
// Spine = always-on clean grey backbone.
if (e.type === "spine") {
const d = edgePath(e);
if (!d) return null;
return <path key={i} d={d} fill="none" stroke={C.border} strokeWidth={1.5} opacity={0.5} />;
}
// Cross-links: focus-driven. Only the active node's incident,
// intra-turn links are drawn — no permanent hairball.
if (!e.from || !e.to) return null; // external (cross-turn source) — not drawable
if (hidden.has(e.from) || hidden.has(e.to)) return null; // tool type hidden
if (!activeKey || (e.from !== activeKey && e.to !== activeKey)) return null;
const d = edgePath(e);
if (!d) return null;
const proven = e.type === "proven";
return (
<path
key={i}
d={d}
fill="none"
stroke={proven ? C.orange : C.muted}
strokeWidth={proven ? 2.25 : 1.75}
strokeDasharray={proven ? "0" : "3 3"}
opacity={proven ? 0.98 : 0.8}
style={proven ? { filter: `drop-shadow(0 0 3px ${C.orange}66)` } : undefined}
/>
);
})}
</svg>
{/* root query node */}
<NodeRow
innerRef={(el) => (rowRefs.current.q = el)}
x={GUTTER}
icon={MessageSquare}
iconColor={C.blue}
title={nodes[0].label}
sub={nodes[0].sub}
tone="query"
/>
{/* tool nodes */}
{turn.tools.map((tl, idx) => {
const Icon = toolIcon(tl.name);
const ind = tl.provenance === "indirect";
const provenFlow = ind && tl.flowValue;
const sel = selected === idx;
return (
<NodeRow
key={idx}
innerRef={(el) => (rowRefs.current["t" + idx] = el)}
x={GUTTER}
icon={Icon}
iconColor={provenFlow ? C.orange : ind ? C.amber : C.text2}
title={tl.mcp ? `${tl.mcp.server}:${tl.mcp.tool}` : tl.name}
sub={tl.summary}
tone={provenFlow ? "proven" : ind ? "indirect" : "direct"}
errored={tl.errored}
selected={sel || hover === idx}
dimmed={hidden.has("t" + idx)}
onHover={(on) => setHover(on ? idx : null)}
tag={
ind
? provenFlow
? { color: C.orange, text: tl.sourceTool || "prior", solid: true }
: { color: C.muted, text: tl.sourceTool || "prior", solid: false }
: { color: C.muted, text: "direct", solid: false }
}
onClick={() => onSelect(sel ? null : idx)}
/>
);
})}
{/* reply node */}
<NodeRow
innerRef={(el) => (rowRefs.current.reply = el)}
x={GUTTER}
icon={CornerDownRight}
iconColor={C.cyan}
title="Reply to you"
sub={turn.reply ? turn.reply.slice(0, 90) + "…" : "(work-only turn)"}
tone="query"
noEdge
/>
</div>
</div>
</div>
);
}
function NodeRow({ innerRef, x, icon: Icon, iconColor, title, sub, tone, errored, selected, tag, onClick, onHover, dimmed }) {
const clickable = !!onClick;
const isTool = tone === "proven" || tone === "indirect" || tone === "direct";
const borderLeft = tone === "proven" ? C.orange : tone === "indirect" ? C.amber : C.border;
return (
<div
ref={innerRef}
className={clickable ? "row" : ""}
onClick={onClick}
onMouseEnter={onHover ? () => onHover(true) : undefined}
onMouseLeave={onHover ? () => onHover(false) : undefined}
style={{
marginLeft: x,
display: "flex",
alignItems: "center",
gap: 10,
minHeight: ROW_H,
padding: isTool ? "7px 10px" : "9px 11px",
cursor: clickable ? "pointer" : "default",
borderLeft: isTool ? `2px solid ${borderLeft}` : `1px solid transparent`,
background: selected ? C.card : isTool ? "transparent" : `linear-gradient(135deg,${C.card},${C.panel})`,
border: isTool ? undefined : `1px solid ${iconColor}`,
borderRadius: isTool ? "0 6px 6px 0" : 8,
marginBottom: isTool ? 0 : 6,
marginTop: isTool ? 0 : 0,
opacity: dimmed ? 0.32 : 1,
filter: dimmed ? "grayscale(0.5)" : "none",
transition: "opacity .15s ease",
}}
>
<div style={{ width: isTool ? 24 : 28, height: isTool ? 24 : 28, borderRadius: isTool ? 5 : 7, flexShrink: 0, background: C.elevated, display: "flex", alignItems: "center", justifyContent: "center", border: `1px solid ${iconColor}` }}>
<Icon size={isTool ? 13 : 15} color={iconColor} />
</div>
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ display: "flex", gap: 7, alignItems: "baseline" }}>
<span style={{ fontFamily: FM, fontSize: isTool ? 12 : 13, fontWeight: 600, color: iconColor }}>
{title}
{errored && <span style={{ marginLeft: 6, color: C.red, fontSize: 9 }}>● err</span>}
</span>
<span style={{ fontFamily: FM, fontSize: isTool ? 11 : 10.5, color: C.muted, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1 }}>{sub}</span>
</div>
</div>
{tag && (
<span style={{ fontFamily: FM, fontSize: 9, color: tag.color, display: "flex", alignItems: "center", gap: 3, flexShrink: 0 }}>
{tag.solid && <CornerDownRight size={10} />}
{tag.text}
</span>
)}
</div>
);
}