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 (
{/* query header */}
QUERY {String(turn.i).padStart(2, "0")} {turn.origin === "system" && ( SYSTEM )} {turn.heavy && ( HEAVY )}
{turn.prompt}
{turn.reply && (
REPLY {turn.reply.slice(0, 240)}…
)}
{/* guidance card — surfaces inline on flagged turns ONLY */} {turn.guide && (
NEXT TIME — {turn.guide.head}
{turn.guide.body}
)} {/* edge legend — proven vs hypothesis, always separated */}
{turn.reqs} round-trips · {turn.tools.length} tools
{/* causal-path hint — keeps the canvas clean; arcs are drawn on focus */} {provenCount > 0 && (
{provenCount} proven value-flow link{provenCount === 1 ? "" : "s"} on this turn {tracedHere < provenCount && · {tracedHere} traceable in-turn} ·{" "} hover or click a tool to trace its causal path
)} {/* the graph: SVG edge layer + DOM node rows */}
{edges.map((e, i) => { // Spine = always-on clean grey backbone. if (e.type === "spine") { const d = edgePath(e); if (!d) return null; return ; } // 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 ( ); })} {/* root query node */} (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 ( (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 */} (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 />
); } 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 (
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", }} >
{title} {errored && ● err} {sub}
{tag && ( {tag.solid && } {tag.text} )}
); }