Spaces:
Running on Zero
Running on Zero
| 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> | |
| ); | |
| } | |