Spaces:
Running
Running
| /** | |
| * ExploreView.jsx β Interactive Codebase Tour. | |
| * | |
| * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * WHAT THIS SHOWS | |
| * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * | |
| * Instead of a raw scatter plot of files, this view teaches a student | |
| * HOW to approach a new codebase. The LLM generates 6-8 key concepts β | |
| * the ideas a student must understand β and their dependencies, then | |
| * renders them as an interactive node diagram: | |
| * | |
| * [Value class] β [Forward Pass] β [Backward Pass] β [Loss + SGD] | |
| * β [MLP layer] β | |
| * | |
| * Each node = a card you can click to expand. Arrows mean "you need | |
| * to understand X before Y". Reading order is encoded as numbered badges. | |
| * | |
| * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * LAYOUT ALGORITHM | |
| * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * | |
| * 1. Topological sort: assign each concept a column depth | |
| * (longest dependency chain from a root node). | |
| * 2. Within each column, sort by reading_order. | |
| * 3. Center columns vertically relative to the tallest column. | |
| * 4. Draw bezier arrows between connected cards. | |
| * | |
| * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * INTERACTIONS | |
| * βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| * | |
| * Click card β expand description + key methods | |
| * Hover card β highlight its edges + connected nodes, dim others | |
| * Ask button β pre-fills chat with a targeted question | |
| * Scroll β zoom (non-passive wheel so preventDefault works) | |
| * Drag β pan the canvas | |
| */ | |
| import { useEffect, useRef, useState, useCallback } from "react"; | |
| import { streamTour } from "../api"; | |
| import TourStory from "./TourStory"; | |
| // Module-level cache β survives tab switches because ExploreView is unmounted | |
| // when the user navigates to Architecture/Class tabs and remounted on return. | |
| // Without this, switching to Explore re-fetches (and re-generates) every time. | |
| // Key: repo slug β tour data object | |
| const tourCache = {}; | |
| // localStorage key for a given repo's tour. | |
| // We persist tour data across page refreshes so the backend (and LLM quota) | |
| // is only hit once per repo, not on every refresh. | |
| function tourLsKey(repo) { return `ghrc_tour_${repo.replace(/\//g, "_")}`; } | |
| // ββ Type β visual token βββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Each concept type maps to a hue from a different part of the spectrum so | |
| // they're immediately legible even at small sizes. Four clearly-distinct hues: | |
| // blue (class), amber (function), violet (module), emerald (algorithm). | |
| const TYPE_STYLE = { | |
| class: { border: "#5B8FF9", glow: "rgba(91,143,249,0.38)", dot: "#7DABFF", tag: "class" }, // blue 240Β° | |
| function: { border: "#FBBF24", glow: "rgba(251,191,36,0.32)", dot: "#FCD34D", tag: "fn" }, // amber 45Β° | |
| module: { border: "#A78BFA", glow: "rgba(167,139,250,0.32)", dot: "#C4B5FD", tag: "module" }, // violet 270Β° | |
| algorithm: { border: "#34D399", glow: "rgba(52,211,153,0.32)", dot: "#6EE7B7", tag: "algo" }, // emerald 160Β° | |
| }; | |
| const FALLBACK_STYLE = { border: "#4E5E80", glow: "rgba(78,94,128,0.30)", dot: "#8896B8", tag: "?" }; | |
| function styleFor(type) { | |
| return TYPE_STYLE[type] || FALLBACK_STYLE; | |
| } | |
| // ββ Card geometry βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // Cards no longer grow on click (description moved to Story mode). The | |
| // at-rest size covers name + subtitle + file + ask button only β key | |
| // items and the "Builds on" row are revealed on hover via CSS max-height | |
| // transitions. Hovering a card expands it AND its dependency neighbours | |
| // (so context shows up alongside the focal concept). The lower rows are | |
| // pushed down by EXPANSION_H below to keep the expansion from clipping | |
| // into them. | |
| const CARD_W = 220; // card width in canvas px | |
| const CARD_H = 142; // at-rest card height | |
| const COL_GAP = 100; // horizontal gap between cards in the same row | |
| const ROW_GAP = 72; // vertical gap between rows | |
| // Approximate height the card grows when hovered (key items row + builds-on | |
| // row + paddings). Used to offset rows below a hovered card so the expansion | |
| // has room to land. Slightly conservative β under-shooting causes overlap, | |
| // over-shooting wastes a bit of vertical canvas during the hover. | |
| const EXPANSION_H = 180; | |
| // How many concepts appear in each horizontal row. | |
| // With 12 concepts and PER_ROW=4: 3 rows of 4, reads like a book. | |
| const PER_ROW = 4; | |
| // ββ Layout: row-major reading order βββββββββββββββββββββββββββββββββββββββββββ | |
| // Concepts are placed left-to-right by reading_order, wrapping to the next row | |
| // after PER_ROW concepts β exactly like reading text. | |
| // | |
| // 1 β 2 β 3 β 4 | |
| // β | |
| // 5 β 6 β 7 β 8 | |
| // β | |
| // 9 β 10 β 11 β 12 | |
| // | |
| // This avoids the "spreadsheet" feel of column-major layouts where the eye | |
| // must scan down a column then jump back to the top of the next column. | |
| function computeLayout(concepts) { | |
| if (!concepts.length) return {}; | |
| const sorted = [...concepts].sort((a, b) => | |
| (a.reading_order ?? 999) - (b.reading_order ?? 999) | |
| ); | |
| const positions = {}; | |
| sorted.forEach((c, i) => { | |
| const row = Math.floor(i / PER_ROW); | |
| const col = i % PER_ROW; | |
| positions[c.id] = { | |
| x: col * (CARD_W + COL_GAP) + 48, | |
| y: row * (CARD_H + ROW_GAP) + 48, | |
| }; | |
| }); | |
| return positions; | |
| } | |
| // ββ Arrow: cubic bezier between source and target βββββββββββββββββββββββββββββ | |
| // Normally left-to-right (right edge β left edge). If the dependency arrow | |
| // goes backwards (prerequisite placed to the right due to reading_order layout), | |
| // flip to exit from the left edge and enter the right edge instead. | |
| function bezierPath(fromPos, toPos) { | |
| const fromCenterX = fromPos.x + CARD_W / 2; | |
| const toCenterX = toPos.x + CARD_W / 2; | |
| const leftToRight = toCenterX >= fromCenterX; | |
| const x1 = leftToRight ? fromPos.x + CARD_W : fromPos.x; | |
| const y1 = fromPos.y + CARD_H / 2; | |
| const x2 = leftToRight ? toPos.x : toPos.x + CARD_W; | |
| const y2 = toPos.y + CARD_H / 2; | |
| const tension = Math.max(Math.abs(x2 - x1) * 0.55, 60); | |
| const dir = leftToRight ? 1 : -1; | |
| return `M ${x1} ${y1} C ${x1 + dir * tension} ${y1}, ${x2 - dir * tension} ${y2}, ${x2} ${y2}`; | |
| } | |
| // ββ ConceptCard ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // | |
| // Canvas cards used to embed the full description here when selected, which | |
| // duplicated content with Story mode. The split now is: | |
| // β’ Canvas cards = relational view: name, file, key items, what this | |
| // depends on. A clicked card jumps the viewer to Story mode for the | |
| // deeper read. | |
| // β’ Story mode = the reader: long-form description, code link, depends-on | |
| // pills with cross-references. | |
| // Each view does one job well; we no longer maintain the same prose in two | |
| // places. | |
| function ConceptCard({ | |
| concept, visualNum, isEntry, isHovered, isDimmed, pos, | |
| onOpenStory, onHover, onAsk, onDragStart, wasDragged, | |
| dependsOnNames, // [{id, name}] β resolved neighbours for the "Connects to" row | |
| }) { | |
| const s = styleFor(concept.type); | |
| return ( | |
| <div | |
| className={`ec-card${isHovered ? " ec-hover" : ""}${isDimmed ? " ec-dimmed" : ""}`} | |
| style={{ | |
| position: "absolute", | |
| zIndex: isHovered ? 10 : 1, | |
| left: pos.x, | |
| top: pos.y, | |
| width: CARD_W, | |
| cursor: "grab", | |
| borderColor: isHovered ? s.border : undefined, | |
| boxShadow: isHovered | |
| ? `0 0 0 2px ${s.border}, 0 0 20px ${s.glow.replace(/[\d.]+\)$/, '0.60)')}, 0 20px 60px ${s.glow.replace(/[\d.]+\)$/, '0.45)')}` | |
| : undefined, | |
| }} | |
| onMouseDown={(e) => onDragStart?.(e, concept, pos)} | |
| onClick={() => { if (!wasDragged?.current) onOpenStory(concept.id); }} | |
| onMouseEnter={() => onHover(concept.id)} | |
| onMouseLeave={() => onHover(null)} | |
| > | |
| {/* Top row: reading order badge + type tag */} | |
| <div className="ec-card-top"> | |
| <div style={{ display: "flex", alignItems: "center", gap: 6 }}> | |
| <span className="ec-order">{visualNum ?? concept.reading_order}</span> | |
| {isEntry && <span className="ec-entry-tag">Start here</span>} | |
| </div> | |
| <span className="ec-type-tag" style={{ color: s.dot, borderColor: `${s.dot}44` }}> | |
| {s.tag} | |
| </span> | |
| </div> | |
| {/* Name + subtitle */} | |
| <div className="ec-name">{concept.name}</div> | |
| <div className="ec-subtitle">{concept.subtitle}</div> | |
| {/* File pill */} | |
| <div className="ec-file"> | |
| <svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style={{ opacity: 0.5, flexShrink: 0 }}> | |
| <path d="M2 1.75C2 .784 2.784 0 3.75 0h6.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v9.586A1.75 1.75 0 0 1 13.25 16h-9.5A1.75 1.75 0 0 1 2 14.25Zm1.75-.25a.25.25 0 0 0-.25.25v12.5c0 .138.112.25.25.25h9.5a.25.25 0 0 0 .25-.25V6h-2.75A1.75 1.75 0 0 1 8.75 4.25V1.5Zm6.75.56v2.19c0 .138.112.25.25.25h2.19Z"/> | |
| </svg> | |
| {concept.file} | |
| </div> | |
| {/* Key items β always visible. These are the named methods/functions | |
| inside the concept, the relational hook readers care about most | |
| when scanning the canvas ("what's IN this thing?"). */} | |
| {concept.key_items?.length > 0 && ( | |
| <div className="ec-items"> | |
| {concept.key_items.slice(0, 4).map(item => ( | |
| <code key={item} className="ec-item">{item}</code> | |
| ))} | |
| {concept.key_items.length > 4 && ( | |
| <span className="ec-item-more">+{concept.key_items.length - 4}</span> | |
| )} | |
| </div> | |
| )} | |
| {/* Connects to β surfaces the dependency edges as readable text so | |
| users can scan a card and see where exploration leads next without | |
| tracing arrows visually. Mirrors the depends_on data already used | |
| to draw the blue connection arrows on the canvas. */} | |
| {dependsOnNames?.length > 0 && ( | |
| <div className="ec-connects"> | |
| <span className="ec-connects-label">Builds on</span> | |
| <div className="ec-connects-list"> | |
| {dependsOnNames.slice(0, 3).map(d => ( | |
| <span key={d.id} className="ec-connects-item">{d.name}</span> | |
| ))} | |
| {dependsOnNames.length > 3 && ( | |
| <span className="ec-connects-more">+{dependsOnNames.length - 3}</span> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {/* Ask button β separate path from "Read in Story" (card body click). | |
| Story = read; Ask = converse. Two intents, two affordances. */} | |
| <button | |
| className="ec-ask" | |
| onClick={e => { e.stopPropagation(); onAsk(concept); }} | |
| > | |
| Ask about this β | |
| </button> | |
| </div> | |
| ); | |
| } | |
| // ββ TracePanel β live log of agent investigation steps βββββββββββββββββββββββββ | |
| // Each entry in `log` is the "trace" payload from a TourAgent SSE event: | |
| // { type: "info"|"thinking"|"found"|"file"|"finding"|"react", text, name?, stages? } | |
| // | |
| // "react" entries come from the agentic Phase 1 ReAct loop β they show the | |
| // THINK β TOOL β RESULT cycle that the agent uses to explore the codebase. | |
| // Showing this live demonstrates how agentic AI works: the model reasons about | |
| // what to read next, calls a tool, reads the result, and decides where to go. | |
| // | |
| // WHY SHOW THIS: transparency builds trust. When users see "Investigating: | |
| // retrieval/hybrid_search.py" they understand WHY that concept appears in | |
| // the tour β it was specifically investigated, not guessed from a keyword scan. | |
| function TracePanel({ log, open, onToggle }) { | |
| const bodyRef = useRef(null); | |
| // Auto-scroll to bottom as new lines arrive | |
| useEffect(() => { | |
| if (open && bodyRef.current) { | |
| bodyRef.current.scrollTop = bodyRef.current.scrollHeight; | |
| } | |
| }, [log, open]); | |
| const ICONS = { | |
| // ReAct loop step β tool icon (wrench) to distinguish from investigation steps | |
| react: ( | |
| <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"> | |
| <path d="M13.371 2.629a3.5 3.5 0 0 0-4.849 4.274L2.78 12.745a1.5 1.5 0 1 0 2.121 2.121l5.842-5.742a3.5 3.5 0 0 0 2.628-6.495zm-1.414 3.536a1.5 1.5 0 1 1-2.121-2.122 1.5 1.5 0 0 1 2.121 2.122z"/> | |
| </svg> | |
| ), | |
| thinking: ( | |
| <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"> | |
| <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0zm.93 6.588-2.29.287-.082.38.45.083c.294.07.352.176.288.469l-.738 3.468c-.194.897.105 1.319.808 1.319.545 0 1.178-.252 1.465-.598l.088-.416c-.2.176-.492.246-.686.246-.275 0-.375-.193-.304-.533zM8 5.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/> | |
| </svg> | |
| ), | |
| found: ( | |
| <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"> | |
| <path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/> | |
| </svg> | |
| ), | |
| file: ( | |
| <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"> | |
| <path d="M4 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.707A1 1 0 0 0 13.707 4L10 .293A1 1 0 0 0 9.293 0H4zm5.5 1.5v2a1 1 0 0 0 1 1h2l-3-3z"/> | |
| </svg> | |
| ), | |
| finding: ( | |
| <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"> | |
| <path d="M11.742 10.344a6.5 6.5 0 1 0-1.397 1.398h-.001c.03.04.062.078.098.115l3.85 3.85a1 1 0 0 0 1.415-1.414l-3.85-3.85a1.007 1.007 0 0 0-.115-.099zm-5.242 1.656a5.5 5.5 0 1 1 0-11 5.5 5.5 0 0 1 0 11z"/> | |
| </svg> | |
| ), | |
| info: ( | |
| <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"> | |
| <path d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16zm.93-9.412-1 4.705c-.07.34.029.533.304.533.194 0 .487-.07.686-.246l-.088.416c-.287.346-.92.598-1.465.598-.703 0-1.002-.422-.808-1.319l.738-3.468c.064-.293.006-.399-.287-.47l-.451-.081.082-.381 2.29-.287zM8 5.5a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"/> | |
| </svg> | |
| ), | |
| }; | |
| return ( | |
| <div className="ec-trace-panel"> | |
| <div className="ec-trace-header" onClick={onToggle}> | |
| <svg viewBox="0 0 16 16" fill="currentColor" width="12" height="12"> | |
| <path d="M5 3.5h6A1.5 1.5 0 0 1 12.5 5v5.034a.5.5 0 0 1-.276.447l-1.5.75-.448-.894.776-.388V5a.5.5 0 0 0-.5-.5H5a.5.5 0 0 0-.5.5v7a.5.5 0 0 0 .5.5h3.5v1H5A1.5 1.5 0 0 1 3.5 12V5A1.5 1.5 0 0 1 5 3.5z"/> | |
| <path d="M11.854 11.146a.5.5 0 0 0-.707.708L12.293 13H9.5a.5.5 0 0 0 0 1h2.793l-1.147 1.146a.5.5 0 0 0 .708.708l2-2a.5.5 0 0 0 0-.708l-2-2z"/> | |
| </svg> | |
| Agent trace β {log.length} steps | |
| <svg viewBox="0 0 16 16" fill="currentColor" width="10" height="10" | |
| style={{ marginLeft: "auto", transform: open ? "rotate(180deg)" : undefined, transition: "transform 0.2s" }}> | |
| <path d="M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z"/> | |
| </svg> | |
| </div> | |
| {open && ( | |
| <div className="ec-trace-body" ref={bodyRef}> | |
| {log.map((entry, i) => ( | |
| <div key={i} className="ec-trace-line"> | |
| <span className={`ec-trace-icon ${entry.type}`}> | |
| {ICONS[entry.type] || ICONS.info} | |
| </span> | |
| <div className="ec-trace-text"> | |
| {entry.type === "react" ? ( | |
| // ReAct entries: show tool call prominently, THINK text faint + truncated. | |
| // entry.tool = "read_file("backend/services/agent.py")" | |
| // entry.think = full reasoning sentence (can be 200+ chars) | |
| <> | |
| {entry.tool && <span className="ec-trace-react-tool">{entry.tool}</span>} | |
| {entry.think && ( | |
| <span className="ec-trace-react-think"> | |
| {entry.think.length > 90 ? entry.think.slice(0, 90) + "β¦" : entry.think} | |
| </span> | |
| )} | |
| </> | |
| ) : ( | |
| <> | |
| {entry.name && <span className="ec-trace-name">{entry.name} </span>} | |
| {entry.text && <span className="ec-trace-sub">{entry.text}</span>} | |
| {entry.stages && ( | |
| <div className="ec-trace-stages"> | |
| {entry.stages.map((s, j) => ( | |
| <span key={j} className="ec-trace-stage-pill">{s}</span> | |
| ))} | |
| </div> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // ββ ExploreView ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| export default function ExploreView({ repo, onAskAbout, onRegenerateRef }) { | |
| const [data, setData] = useState(null); | |
| const [loading, setLoading] = useState(false); | |
| const [loadStage, setStage] = useState(null); // { stage, progress, message } | |
| const [traceLog, setTrace] = useState([]); // agent investigation steps | |
| const [traceOpen, setTrOpen] = useState(true); // trace panel expanded? | |
| const [error, setError] = useState(null); | |
| const [hoveredId, setHovered] = useState(null); | |
| // Tracks which concept Story mode should jump to when launched from a | |
| // Canvas card click. Bumped each click so TourStory remounts to that | |
| // concept; null means "open Story at the start" (e.g. via the mode tab). | |
| const [storyInitialId, setStoryInitialId] = useState(null); | |
| // "canvas" = scatter of cards + arrows; "story" = focused one-at-a-time reading. | |
| // Persist so the user's chosen mode survives page reloads. | |
| const [mode, setMode] = useState( | |
| () => localStorage.getItem("ghrc_tourMode") === "story" ? "story" : "canvas" | |
| ); | |
| useEffect(() => { localStorage.setItem("ghrc_tourMode", mode); }, [mode]); | |
| // Open Story mode focused on a specific concept. Used by Canvas card | |
| // clicks: the card is the entry point, Story is the reader. Resetting | |
| // storyInitialId before bumping ensures the same-concept second click | |
| // still triggers a remount via the React key on TourStory below. | |
| function openStoryFor(conceptId) { | |
| setStoryInitialId(conceptId); | |
| setMode("story"); | |
| } | |
| const [xform, setXform] = useState({ x: 0, y: 0, scale: window.innerWidth < 768 ? 0.5 : 0.85 }); | |
| const dragging = useRef(false); | |
| const drag0 = useRef({}); | |
| const wrapRef = useRef(null); | |
| // Per-node drag β same pattern as GraphDiagram | |
| const [nodePos, setNodePos] = useState({}); // id β {x,y} overrides | |
| const dragNode = useRef(null); // active node drag state | |
| const wasDragged = useRef(false); // suppress click-after-drag | |
| const scaleRef = useRef(xform.scale); // current scale for doc-level handlers | |
| useEffect(() => { scaleRef.current = xform.scale; }, [xform.scale]); | |
| // ββ Fetch βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| const load = useCallback((force = false) => { | |
| if (!repo) return; | |
| // 1. In-memory cache: survives tab switches within the same page session. | |
| if (!force && tourCache[repo]) { | |
| setData(tourCache[repo]); | |
| setLoading(false); | |
| setError(null); | |
| return; | |
| } | |
| // 2. localStorage cache: survives page refreshes. Avoids re-generating | |
| // expensive LLM calls just because the user hit F5. | |
| if (!force) { | |
| try { | |
| const stored = localStorage.getItem(tourLsKey(repo)); | |
| if (stored) { | |
| const parsed = JSON.parse(stored); | |
| tourCache[repo] = parsed; | |
| setData(parsed); | |
| setLoading(false); | |
| setError(null); | |
| return; | |
| } | |
| } catch { /* corrupt entry β fall through to fetch */ } | |
| } | |
| setLoading(true); | |
| setStage(null); | |
| setTrace([]); | |
| setTrOpen(true); | |
| setError(null); | |
| setData(null); | |
| setXform({ x: 0, y: 0, scale: window.innerWidth < 768 ? 0.5 : 0.85 }); | |
| const cancel = streamTour(repo, { | |
| force, | |
| onProgress: (ev) => { | |
| setStage(ev); | |
| // Accumulate trace events for the live-log panel | |
| if (ev.trace) setTrace(prev => [...prev, ev.trace]); | |
| }, | |
| onDone: (d) => { | |
| tourCache[repo] = d; | |
| try { localStorage.setItem(tourLsKey(repo), JSON.stringify(d)); } catch { /* quota full */ } | |
| setLoading(false); | |
| setStage(null); | |
| setData(d); | |
| }, | |
| onError: (e) => { setLoading(false); setStage(null); setError(e); }, | |
| }); | |
| return cancel; | |
| }, [repo]); | |
| useEffect(() => { load(); }, [load]); | |
| // Reset dragged positions whenever a new tour loads | |
| useEffect(() => { setNodePos({}); }, [data]); | |
| // Expose a force-reload function to DiagramView via a ref so the header | |
| // "Regenerate" button can bust the cache without prop-drilling a callback. | |
| useEffect(() => { | |
| if (onRegenerateRef) { | |
| onRegenerateRef.current = () => { | |
| delete tourCache[repo]; | |
| try { localStorage.removeItem(tourLsKey(repo)); } catch {} | |
| load(true); // force=true β api passes ?force=true β backend busts disk cache | |
| }; | |
| } | |
| }, [onRegenerateRef, repo, load]); | |
| // ββ Non-passive wheel zoom βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| useEffect(() => { | |
| const el = wrapRef.current; | |
| if (!el) return; | |
| function onWheel(e) { | |
| e.preventDefault(); | |
| const f = Math.exp(-e.deltaY * 0.001); | |
| const rect = el.getBoundingClientRect(); | |
| const mx = e.clientX - rect.left; | |
| const my = e.clientY - rect.top; | |
| setXform(t => { | |
| const newScale = Math.min(Math.max(t.scale * f, 0.3), 3); | |
| const ratio = newScale / t.scale; | |
| return { x: mx - (mx - t.x) * ratio, y: my - (my - t.y) * ratio, scale: newScale }; | |
| }); | |
| } | |
| el.addEventListener("wheel", onWheel, { passive: false }); | |
| return () => el.removeEventListener("wheel", onWheel); | |
| // !!data as dep: ExploreView has early returns for loading/error/null states, | |
| // so wrapRef.current is null on first mount. Re-run once data arrives and the | |
| // canvas wrapper is actually in the DOM. Cleanup removes the old listener | |
| // before reattaching, so there's no double-registration risk. | |
| }, [!!data]); | |
| // Touch pan (1 finger) + pinch-to-zoom (2 fingers) β same logic as GraphDiagram | |
| useEffect(() => { | |
| const el = wrapRef.current; | |
| if (!el) return; | |
| let lastTouch = null; | |
| let lastPinch = null; | |
| function pinchDist(t) { return Math.hypot(t[1].clientX - t[0].clientX, t[1].clientY - t[0].clientY); } | |
| function pinchMid(t, rect) { | |
| return { x: (t[0].clientX + t[1].clientX) / 2 - rect.left, | |
| y: (t[0].clientY + t[1].clientY) / 2 - rect.top }; | |
| } | |
| function onTouchStart(e) { | |
| if (e.touches.length === 1) { | |
| lastTouch = { x: e.touches[0].clientX, y: e.touches[0].clientY }; | |
| lastPinch = null; | |
| } else if (e.touches.length === 2) { | |
| const t = [...e.touches]; | |
| lastPinch = { dist: pinchDist(t), mid: pinchMid(t, el.getBoundingClientRect()) }; | |
| lastTouch = null; | |
| } | |
| } | |
| function onTouchMove(e) { | |
| e.preventDefault(); | |
| if (e.touches.length === 1 && lastTouch) { | |
| const dx = e.touches[0].clientX - lastTouch.x; | |
| const dy = e.touches[0].clientY - lastTouch.y; | |
| lastTouch = { x: e.touches[0].clientX, y: e.touches[0].clientY }; | |
| setXform(t => ({ ...t, x: t.x + dx, y: t.y + dy })); | |
| } else if (e.touches.length === 2 && lastPinch) { | |
| const t = [...e.touches]; | |
| const rect = el.getBoundingClientRect(); | |
| const d = pinchDist(t); | |
| const mid = pinchMid(t, rect); | |
| const f = d / lastPinch.dist; | |
| lastPinch = { dist: d, mid }; | |
| setXform(s => { | |
| const newScale = Math.min(Math.max(s.scale * f, 0.3), 3); | |
| const ratio = newScale / s.scale; | |
| return { x: mid.x - (mid.x - s.x) * ratio, y: mid.y - (mid.y - s.y) * ratio, scale: newScale }; | |
| }); | |
| } | |
| } | |
| function onTouchEnd() { lastTouch = null; lastPinch = null; } | |
| el.addEventListener("touchstart", onTouchStart, { passive: true }); | |
| el.addEventListener("touchmove", onTouchMove, { passive: false }); | |
| el.addEventListener("touchend", onTouchEnd); | |
| return () => { | |
| el.removeEventListener("touchstart", onTouchStart); | |
| el.removeEventListener("touchmove", onTouchMove); | |
| el.removeEventListener("touchend", onTouchEnd); | |
| }; | |
| }, [!!data]); | |
| // ββ Pan handlers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| // We attach mousemove/mouseup to the DOCUMENT rather than the wrapper div. | |
| // | |
| // Why: React synthetic events on the wrapper only fire when the pointer is | |
| // directly over the wrapper element. The moment it moves over a child card | |
| // the wrapper's onMouseMove stops firing, breaking the drag mid-gesture. | |
| // | |
| // Document-level listeners receive every mouse event regardless of which | |
| // element the cursor is currently over β the standard pattern for drag. | |
| useEffect(() => { | |
| function onDocMove(e) { | |
| // Node drag takes priority over canvas pan | |
| if (dragNode.current) { | |
| const dx = (e.clientX - dragNode.current.startMouse.x) / scaleRef.current; | |
| const dy = (e.clientY - dragNode.current.startMouse.y) / scaleRef.current; | |
| if (Math.abs(dx) > 4 || Math.abs(dy) > 4) wasDragged.current = true; | |
| const id = dragNode.current.id; | |
| const newX = dragNode.current.startPos.x + dx; | |
| const newY = dragNode.current.startPos.y + dy; | |
| setNodePos(prev => ({ ...prev, [id]: { x: newX, y: newY } })); | |
| return; | |
| } | |
| if (!dragging.current) return; | |
| setXform(t => ({ | |
| ...t, | |
| x: drag0.current.tx + (e.clientX - drag0.current.mx), | |
| y: drag0.current.ty + (e.clientY - drag0.current.my), | |
| })); | |
| } | |
| function onDocUp() { | |
| dragNode.current = null; | |
| dragging.current = false; | |
| if (wrapRef.current) wrapRef.current.style.cursor = "grab"; | |
| setTimeout(() => { wasDragged.current = false; }, 0); | |
| } | |
| document.addEventListener("mousemove", onDocMove); | |
| document.addEventListener("mouseup", onDocUp); | |
| return () => { | |
| document.removeEventListener("mousemove", onDocMove); | |
| document.removeEventListener("mouseup", onDocUp); | |
| }; | |
| }, []); // empty deps β only refs + stable setters used inside | |
| function onMouseDown(e) { | |
| if (e.button !== 0) return; | |
| dragging.current = true; | |
| drag0.current = { mx: e.clientX, my: e.clientY, tx: xform.x, ty: xform.y }; | |
| if (wrapRef.current) wrapRef.current.style.cursor = "grabbing"; | |
| } | |
| function onNodeDragStart(e, concept, currentPos) { | |
| if (e.button !== 0) return; | |
| e.stopPropagation(); // prevent canvas pan from activating | |
| dragNode.current = { | |
| id: concept.id, | |
| startPos: currentPos, | |
| startMouse: { x: e.clientX, y: e.clientY }, | |
| }; | |
| } | |
| function handleAsk(concept) { | |
| onAskAbout?.( | |
| concept.ask || | |
| `Explain "${concept.name}" in ${repo} in detail β what does it do, how does it work, and what are the key methods or functions involved?` | |
| ); | |
| } | |
| // ββ Loading / error states βββββββββββββββββββββββββββββββββββββββββββββββββ | |
| if (loading) { | |
| const pct = loadStage ? Math.round(loadStage.progress * 100) : 0; | |
| const rawLabel = loadStage?.message || "Building your guided tourβ¦"; | |
| // Cap the progress label β long THINK strings must not overflow this area | |
| const label = rawLabel.length > 72 ? rawLabel.slice(0, 72) + "β¦" : rawLabel; | |
| return ( | |
| <div className="ec-loading" style={{ flexDirection: "column", alignItems: "stretch", gap: 16, maxWidth: 480, margin: "auto" }}> | |
| {/* Progress row */} | |
| <div style={{ display: "flex", alignItems: "center", gap: 12 }}> | |
| <span className="spinner" /> | |
| <div style={{ flex: 1 }}> | |
| <div style={{ fontWeight: 600, marginBottom: 6, fontSize: 13 }}>{label}</div> | |
| <div style={{ height: 3, background: "var(--border)", borderRadius: 2, overflow: "hidden" }}> | |
| <div style={{ | |
| height: "100%", width: `${pct}%`, | |
| background: "var(--accent)", borderRadius: 2, transition: "width 0.5s ease", | |
| }} /> | |
| </div> | |
| {pct > 0 && ( | |
| <div style={{ fontSize: 11, color: "var(--muted)", marginTop: 4 }}>{pct}%</div> | |
| )} | |
| </div> | |
| </div> | |
| {/* Live agent trace log */} | |
| {traceLog.length > 0 && ( | |
| <TracePanel log={traceLog} open={traceOpen} onToggle={() => setTrOpen(v => !v)} /> | |
| )} | |
| </div> | |
| ); | |
| } | |
| if (error) { | |
| return ( | |
| <div className="ec-error"> | |
| <div style={{ fontSize: 13, color: "var(--red)" }}>{error}</div> | |
| <button className="diagram-retry-btn" onClick={() => load(true)}>Retry</button> | |
| </div> | |
| ); | |
| } | |
| if (!data) return null; | |
| const concepts = data.concepts || []; | |
| const basePositions = computeLayout(concepts); | |
| // Visual sequence numbers: row-first then column β matches the left-to-right, | |
| // top-to-bottom reading order of the row-major layout. | |
| const visualNumber = {}; | |
| Object.entries(basePositions) | |
| .sort(([, a], [, b]) => a.y !== b.y ? a.y - b.y : a.x - b.x) | |
| .forEach(([id], i) => { visualNumber[Number(id)] = i + 1; }); | |
| // When a card is hovered, push every card in a row strictly BELOW the | |
| // hovered card's row down by EXPANSION_H so the expansion has room. The | |
| // lower rows reflow smoothly because positions feed into a CSS transform | |
| // with a transition on the `top` property β visually the lower rows | |
| // glide down rather than jumping. | |
| // | |
| // We use the HOVERED row, not the connected-set row: the connected | |
| // neighbours are usually in the same row as the hovered card (left/right | |
| // of it), so pushing only the hovered row's lower neighbours is correct. | |
| // If a connected neighbour is in a different row, the offset still | |
| // applies via the hovered card's row check below. | |
| const yOffsets = (() => { | |
| if (hoveredId === null) return {}; | |
| const hoverPos = basePositions[hoveredId]; | |
| if (!hoverPos) return {}; | |
| const offsets = {}; | |
| concepts.forEach(c => { | |
| const p = basePositions[c.id]; | |
| if (p && p.y > hoverPos.y) offsets[c.id] = EXPANSION_H; | |
| }); | |
| return offsets; | |
| })(); | |
| const positions = Object.fromEntries( | |
| Object.entries(basePositions).map(([id, pos]) => [ | |
| id, | |
| { x: pos.x, y: pos.y + (yOffsets[id] ?? 0) }, | |
| ]) | |
| ); | |
| // Dragged position overrides static layout β falls back to positions[id] | |
| const getPosFor = (id) => nodePos[id] ?? positions[id]; | |
| // Canvas bounding box β accounts for the maximum offset that any card | |
| // could pick up if a top-row card is hovered (push amount = EXPANSION_H). | |
| const allX = Object.values(positions).map(p => p.x + CARD_W + 80); | |
| const allY = Object.values(positions).map(p => p.y + CARD_H + EXPANSION_H + 80); | |
| const canvasW = Math.max(...allX, 700); | |
| const canvasH = Math.max(...allY, 500); | |
| // Connected set for hover dimming: hovered node + its direct neighbors | |
| // (concepts that depend on it AND concepts it depends on). Other cards | |
| // get the .ec-dimmed class so the relational structure pops on hover. | |
| const connectedIds = hoveredId !== null | |
| ? new Set([ | |
| hoveredId, | |
| ...concepts.filter(c => c.depends_on?.includes(hoveredId)).map(c => c.id), | |
| ...(concepts.find(c => c.id === hoveredId)?.depends_on ?? []), | |
| ]) | |
| : null; | |
| // Resolve depends_on ids β concept names for the "Builds on" row on each | |
| // card. Done once per render so cards don't each re-walk the concepts | |
| // array. Missing ids are filtered out (the LLM occasionally references | |
| // concepts that didn't make the final cut). | |
| const conceptById = Object.fromEntries(concepts.map(c => [c.id, c])); | |
| const dependsOnByCard = Object.fromEntries( | |
| concepts.map(c => [ | |
| c.id, | |
| (c.depends_on ?? []) | |
| .map(depId => conceptById[depId]) | |
| .filter(Boolean) | |
| .map(dep => ({ id: dep.id, name: dep.name })), | |
| ]) | |
| ); | |
| return ( | |
| <div className="ec-container"> | |
| {/* ββ Summary header ββ */} | |
| <div className="ec-header"> | |
| <div className="ec-summary">{data.summary}</div> | |
| <div className="ec-controls"> | |
| <div className="ec-mode-toggle" role="tablist" aria-label="Tour view mode"> | |
| <button | |
| role="tab" | |
| aria-selected={mode === "canvas"} | |
| className={`ec-mode-btn${mode === "canvas" ? " is-active" : ""}`} | |
| onClick={() => setMode("canvas")} | |
| title="See all concepts at once" | |
| > | |
| <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> | |
| <rect x="1.5" y="1.5" width="5.5" height="5.5" rx="1"/> | |
| <rect x="9" y="1.5" width="5.5" height="5.5" rx="1"/> | |
| <rect x="1.5" y="9" width="5.5" height="5.5" rx="1"/> | |
| <rect x="9" y="9" width="5.5" height="5.5" rx="1"/> | |
| </svg> | |
| Canvas | |
| </button> | |
| <button | |
| role="tab" | |
| aria-selected={mode === "story"} | |
| className={`ec-mode-btn${mode === "story" ? " is-active" : ""}`} | |
| onClick={() => setMode("story")} | |
| title="Read one concept at a time" | |
| > | |
| <svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"> | |
| <path d="M2 3.25C2 2.56 2.56 2 3.25 2h9.5c.69 0 1.25.56 1.25 1.25v9.5c0 .69-.56 1.25-1.25 1.25h-9.5C2.56 14 2 13.44 2 12.75v-9.5ZM3.5 3.5v9h9v-9h-9Z"/> | |
| <path d="M5 6h6v1.2H5V6Zm0 2.4h6v1.2H5V8.4Z"/> | |
| </svg> | |
| Story | |
| </button> | |
| </div> | |
| {data.entry_point && ( | |
| <div className="ec-entry-hint"> | |
| <svg width="11" height="11" viewBox="0 0 16 16" fill="currentColor" style={{ opacity: 0.6 }}> | |
| <path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm4.879-2.773 4.264 2.559a.25.25 0 0 1 0 .428l-4.264 2.559A.25.25 0 0 1 6 10.559V5.442a.25.25 0 0 1 .379-.215Z"/> | |
| </svg> | |
| Start reading: <code>{data.entry_point}</code> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* Keyed flex wrapper β remounts on mode change so .view-switch-in replays. | |
| display:flex + flex:1 so Canvas/Story still fill the container. */} | |
| <div | |
| key={mode} | |
| className="view-switch-in" | |
| style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }} | |
| > | |
| {mode === "story" ? ( | |
| // Key on storyInitialId so opening Story for a new concept remounts | |
| // and re-runs the initializer that picks the starting index. Without | |
| // the key, a second card click while already in Story mode wouldn't | |
| // jump to the new concept (useState initializer fires once). | |
| <TourStory | |
| key={`story-${storyInitialId ?? "start"}`} | |
| data={data} | |
| repo={repo} | |
| onAskAbout={onAskAbout} | |
| initialConceptId={storyInitialId} | |
| /> | |
| ) : ( | |
| <> | |
| {/* ββ Canvas ββ */} | |
| <div | |
| ref={wrapRef} | |
| className="ec-canvas-wrapper has-cursor-glow" | |
| onMouseDown={onMouseDown} | |
| // Feed --mx / --my for the cursor-glow primitive. | |
| // Pan drag uses onMouseDown + document mousemove, so this local | |
| // handler doesn't interfere with drag gesture state. | |
| onMouseMove={(e) => { | |
| const r = e.currentTarget.getBoundingClientRect(); | |
| e.currentTarget.style.setProperty("--mx", `${e.clientX - r.left}px`); | |
| e.currentTarget.style.setProperty("--my", `${e.clientY - r.top}px`); | |
| }} | |
| style={{ "--glow-size": "520px", "--glow-intensity": "8%" }} | |
| > | |
| <div | |
| className="ec-canvas" | |
| style={{ | |
| width: canvasW, | |
| height: canvasH, | |
| transform: `translate(${xform.x}px, ${xform.y}px) scale(${xform.scale})`, | |
| transformOrigin: "0 0", | |
| }} | |
| > | |
| {/* ββ SVG arrow layer ββ */} | |
| {/* Each arrow has two layers: | |
| 1. Base path β solid thin line + arrowhead (always visible) | |
| 2. Traveling dot β small glowing circle that animates from source to target | |
| using <animateMotion> + <mpath>. This communicates DIRECTION: you can | |
| instantly see which way concepts depend on each other. The dot fades in | |
| after 10% of the journey and fades out before 90% so it never looks | |
| abrupt at the endpoints. Highlighted arrows skip the dot β the glow | |
| filter communicates selection state instead. */} | |
| <svg | |
| width={canvasW} | |
| height={canvasH} | |
| style={{ position: "absolute", inset: 0, pointerEvents: "none", overflow: "visible" }} | |
| aria-hidden="true" | |
| > | |
| <defs> | |
| <marker id="ec-arrow" markerWidth="7" markerHeight="5" refX="7" refY="2.5" orient="auto"> | |
| <polygon points="0 0, 7 2.5, 0 5" fill="rgba(91,143,249,0.4)" /> | |
| </marker> | |
| <marker id="ec-arrow-hi" markerWidth="7" markerHeight="5" refX="7" refY="2.5" orient="auto"> | |
| <polygon points="0 0, 7 2.5, 0 5" fill="#7DABFF" /> | |
| </marker> | |
| {/* Amber arrowhead for sequential reading-path arrows */} | |
| <marker id="ec-arrow-seq" markerWidth="7" markerHeight="5" refX="7" refY="2.5" orient="auto"> | |
| <polygon points="0 0, 7 2.5, 0 5" fill="rgba(245,158,11,0.75)" /> | |
| </marker> | |
| </defs> | |
| {/* ββ Sequential reading-path arrows (amber) ββββββββββββββββββ | |
| Connect concept N β N+1 in reading order so the learning | |
| path is visually explicit. These are the primary navigation | |
| guide; dependency arrows (blue) are supporting context. */} | |
| {(() => { | |
| const seq = [...concepts].sort((a, b) => | |
| (a.reading_order ?? 999) - (b.reading_order ?? 999) | |
| ); | |
| return seq.slice(0, -1).map((c, i) => { | |
| const next = seq[i + 1]; | |
| const from = getPosFor(c.id); | |
| const to = getPosFor(next.id); | |
| if (!from || !to) return null; | |
| const d = bezierPath(from, to); | |
| const isDim = connectedIds && !connectedIds.has(c.id) && !connectedIds.has(next.id); | |
| return ( | |
| <path | |
| key={`seq-${c.id}β${next.id}`} | |
| d={d} | |
| stroke="rgba(245,158,11,0.50)" | |
| strokeWidth="1.5" | |
| fill="none" | |
| markerEnd="url(#ec-arrow-seq)" | |
| strokeDasharray="5 3" | |
| style={{ opacity: isDim ? 0.06 : 1, transition: "opacity 0.15s" }} | |
| /> | |
| ); | |
| }); | |
| })()} | |
| {/* ββ Dependency arrows (blue) β prerequisite relationships ββ */} | |
| {concepts.map(c => | |
| (c.depends_on ?? []).map(depId => { | |
| const from = getPosFor(depId); | |
| const to = getPosFor(c.id); | |
| if (!from || !to) return null; | |
| const isHi = connectedIds?.has(c.id) && connectedIds?.has(depId); | |
| const isDim = connectedIds && !isHi; | |
| const pathId = `ec-path-${depId}-${c.id}`; | |
| // Stagger dot travel per connection so all dots don't move in sync | |
| const stagger = `${((depId * 3 + c.id * 7) % 40) / 10}s`; | |
| const d = bezierPath(from, to); | |
| return ( | |
| <g key={`${depId}β${c.id}`} style={{ opacity: isDim ? 0.08 : 1, transition: "opacity 0.15s" }}> | |
| {/* Base arrow path */} | |
| <path | |
| id={pathId} | |
| d={d} | |
| stroke={isHi ? "#7DABFF" : "rgba(91,143,249,0.35)"} | |
| strokeWidth={isHi ? 2 : 1.2} | |
| fill="none" | |
| markerEnd={isHi ? "url(#ec-arrow-hi)" : "url(#ec-arrow)"} | |
| style={{ | |
| filter: isHi ? "drop-shadow(0 0 3px rgba(125,171,255,0.6))" : undefined, | |
| transition: "stroke 0.15s, stroke-width 0.15s, filter 0.15s", | |
| }} | |
| /> | |
| {/* Traveling dot β communicates flow direction. | |
| Hidden on highlighted arrows (glow covers it) and dimmed arrows. */} | |
| {!isHi && !isDim && ( | |
| <circle r="2.5" fill="#7DABFF"> | |
| {/* Fade in at 10%, full at 20%, full at 80%, fade out at 90% */} | |
| <animate attributeName="opacity" | |
| values="0;0;1;1;0;0" keyTimes="0;0.1;0.2;0.8;0.9;1" | |
| dur="4s" begin={stagger} repeatCount="indefinite" /> | |
| <animateMotion dur="4s" begin={stagger} repeatCount="indefinite" rotate="auto"> | |
| <mpath href={`#${pathId}`} /> | |
| </animateMotion> | |
| </circle> | |
| )} | |
| </g> | |
| ); | |
| }) | |
| )} | |
| </svg> | |
| {/* ββ Concept cards ββ */} | |
| {concepts.map(c => { | |
| const pos = getPosFor(c.id); | |
| if (!pos) return null; | |
| // isEntry = the leftmost card (visual number 1) β always the pipeline overview | |
| const isEntry = visualNumber[c.id] === 1; | |
| return ( | |
| <ConceptCard | |
| key={c.id} | |
| concept={c} | |
| visualNum={visualNumber[c.id]} | |
| isEntry={isEntry} | |
| isHovered={hoveredId === c.id || (!!connectedIds && connectedIds.has(c.id))} | |
| isDimmed={!!connectedIds && !connectedIds.has(c.id)} | |
| pos={pos} | |
| dependsOnNames={dependsOnByCard[c.id]} | |
| onOpenStory={openStoryFor} | |
| onHover={setHovered} | |
| onAsk={handleAsk} | |
| onDragStart={onNodeDragStart} | |
| wasDragged={wasDragged} | |
| /> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| {/* ββ Legend + hint ββ */} | |
| <div className="ec-legend"> | |
| {Object.entries(TYPE_STYLE).map(([type, s]) => ( | |
| <span key={type} className="ec-legend-item"> | |
| <span className="ec-legend-dot" style={{ background: s.dot }} /> | |
| {type} | |
| </span> | |
| ))} | |
| <span className="ec-legend-hint"> | |
| {concepts.length} concepts Β· scroll to zoom Β· drag to pan Β· hover for detail Β· click to read | |
| </span> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |