/** * 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 (
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 */}
{visualNum ?? concept.reading_order} {isEntry && Start here}
{s.tag}
{/* Name + subtitle */}
{concept.name}
{concept.subtitle}
{/* File pill */}
{concept.file}
{/* 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 && (
{concept.key_items.slice(0, 4).map(item => ( {item} ))} {concept.key_items.length > 4 && ( +{concept.key_items.length - 4} )}
)} {/* 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 && (
Builds on
{dependsOnNames.slice(0, 3).map(d => ( {d.name} ))} {dependsOnNames.length > 3 && ( +{dependsOnNames.length - 3} )}
)} {/* Ask button — separate path from "Read in Story" (card body click). Story = read; Ask = converse. Two intents, two affordances. */}
); } // ── 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: ( ), thinking: ( ), found: ( ), file: ( ), finding: ( ), info: ( ), }; return (
Agent trace — {log.length} steps
{open && (
{log.map((entry, i) => (
{ICONS[entry.type] || ICONS.info}
{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 && {entry.tool}} {entry.think && ( {entry.think.length > 90 ? entry.think.slice(0, 90) + "…" : entry.think} )} ) : ( <> {entry.name && {entry.name} } {entry.text && {entry.text}} {entry.stages && (
{entry.stages.map((s, j) => ( {s} ))}
)} )}
))}
)}
); } // ── 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 (
{/* Progress row */}
{label}
{pct > 0 && (
{pct}%
)}
{/* Live agent trace log */} {traceLog.length > 0 && ( setTrOpen(v => !v)} /> )}
); } if (error) { return (
{error}
); } 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 (
{/* ── Summary header ── */}
{data.summary}
{data.entry_point && (
Start reading: {data.entry_point}
)}
{/* Keyed flex wrapper — remounts on mode change so .view-switch-in replays. display:flex + flex:1 so Canvas/Story still fill the container. */}
{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). ) : ( <> {/* ── Canvas ── */}
{ 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%" }} >
{/* ── 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 + . 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. */} {/* ── 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 ( ); })}
{/* ── Legend + hint ── */}
{Object.entries(TYPE_STYLE).map(([type, s]) => ( {type} ))} {concepts.length} concepts · scroll to zoom · drag to pan · hover for detail · click to read
)}
); }