/**
* 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 */}
{/* 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 && (
)}
{/* 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 && (
)}
{/* 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 (
);
}
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 ── */}
{/* ── 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