Spaces:
Running
Running
| /** | |
| * DiagramView.jsx — System diagrams + interactive codebase explorer. | |
| * | |
| * Tabs: | |
| * Explore — Interactive concept map (AI-generated, clearly labelled) | |
| * Architecture — Real import edges from AST + AI descriptions [verified] | |
| * Class Hierarchy— Real inheritance from AST + AI descriptions [verified] | |
| * Sequence — AI-generated call flow [speculative] | |
| * Data Flow — AI-generated data pipeline [speculative] | |
| * | |
| * Interactions: | |
| * - Click a diagram node → inline NodeDetailPanel slides in (no view switch) | |
| * - Click a diagram edge → NodeDetailPanel explains the relationship | |
| * - Hover a node → dims unrelated nodes, highlights connections | |
| */ | |
| import { useEffect, useRef, useState } from "react"; | |
| import { streamDiagram } from "../api"; | |
| import ExploreView from "./ExploreView"; | |
| import GraphDiagram from "./GraphDiagram"; | |
| import NodeDetailPanel from "./NodeDetailPanel"; | |
| // ── Diagram tab definitions ─────────────────────────────────────────────────── | |
| const EXPLORE_TAB = { | |
| id: "explore", label: "Explore", desc: "Guided concept tour", | |
| }; | |
| // Only AST-verified diagrams — Sequence and Data Flow were removed because | |
| // they were fully LLM-generated with no static analysis backing, making them | |
| // unreliable for a learning tool where accuracy matters. | |
| const DIAGRAM_TABS = [ | |
| { | |
| id: "architecture", | |
| label: "Architecture", | |
| desc: "Components & connections", | |
| }, | |
| { | |
| id: "class", | |
| label: "Class Hierarchy", | |
| desc: "Classes & relationships", | |
| }, | |
| ]; | |
| // ── Tab icons (SVG) ─────────────────────────────────────────────────────────── | |
| // Inline SVGs render crisp at every DPI — unicode glyphs (◈ ⬡ ◫) are | |
| // rasterised at screen resolution and look blurry on high-DPI displays. | |
| function TabIcon({ id }) { | |
| if (id === "explore") return ( | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true"> | |
| {/* Compass rose — N point pulses in the landing animation */} | |
| <path d="M12 2L14.5 7.5L12 12L9.5 7.5Z" fill="currentColor"/> | |
| <path d="M12 22L13.5 16.5L12 12L10.5 16.5Z" fill="currentColor" opacity="0.45"/> | |
| <path d="M22 12L16.5 10.5L12 12L16.5 13.5Z" fill="currentColor" opacity="0.45"/> | |
| <path d="M2 12L7.5 10.5L12 12L7.5 13.5Z" fill="currentColor" opacity="0.45"/> | |
| <circle cx="12" cy="12" r="1.5" fill="currentColor"/> | |
| </svg> | |
| ); | |
| if (id === "architecture") return ( | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" | |
| stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" aria-hidden="true"> | |
| {/* Three nodes connected by edges — represents a dependency/import graph */} | |
| <circle cx="5" cy="12" r="2.5"/> | |
| <circle cx="19" cy="6.5" r="2.5"/> | |
| <circle cx="19" cy="17.5" r="2.5"/> | |
| <line x1="7.4" y1="11" x2="16.6" y2="7.5"/> | |
| <line x1="7.4" y1="13" x2="16.6" y2="16.5"/> | |
| </svg> | |
| ); | |
| if (id === "class") return ( | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" | |
| stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"> | |
| {/* Parent node + two children — represents inheritance / class tree */} | |
| <rect x="8.5" y="2" width="7" height="5" rx="1.5"/> | |
| <rect x="1" y="17" width="7" height="5" rx="1.5"/> | |
| <rect x="16" y="17" width="7" height="5" rx="1.5"/> | |
| <line x1="12" y1="7" x2="12" y2="12"/> | |
| <line x1="12" y1="12" x2="4.5" y2="17"/> | |
| <line x1="12" y1="12" x2="19.5" y2="17"/> | |
| </svg> | |
| ); | |
| return null; | |
| } | |
| const ALL_TABS = [EXPLORE_TAB, ...DIAGRAM_TABS]; | |
| export default function DiagramView({ repo, onAskAbout, focusFiles, onFullscreenChange }) { | |
| const [diagramType, setDiagramType] = useState( | |
| () => localStorage.getItem("ghrc_diagramType") || "explore" | |
| ); | |
| function setType(t) { | |
| setDiagramType(t); | |
| localStorage.setItem("ghrc_diagramType", t); | |
| // Clear any open detail panel when switching tabs | |
| setSelected(null); | |
| } | |
| const [loading, setLoading] = useState(false); | |
| const [loadStage, setLoadStage] = useState(null); // { stage, progress, message } | |
| const [error, setError] = useState(null); | |
| const [diagramData, setDiagramData] = useState(null); | |
| const [cache, setCache] = useState({}); | |
| // Per-type retry counter — using a single shared counter caused switching tabs | |
| // while retryKey > 0 to bypass the cache for the OTHER diagram type too. | |
| const [retryKeys, setRetryKeys] = useState({}); // { "architecture": 1, "class": 0, ... } | |
| const [fullscreen, setFullscreen] = useState(false); | |
| // ── Inline detail panel state ───────────────────────────────────────────── | |
| // subject = { kind, label, type, file, description, items, autoQuestion? } | |
| const [selected, setSelected] = useState(null); | |
| // Ref passed into ExploreView so we can call its force-reload from our header | |
| const exploreRegenRef = useRef(null); | |
| const isExplore = diagramType === "explore"; | |
| const selectedTypeDef = ALL_TABS.find(t => t.id === diagramType); | |
| // localStorage key for a diagram — used to persist across page refreshes. | |
| function diagLsKey(r, type) { return `ghrc_diagram_${r.replace(/\//g, "_")}_${type}`; } | |
| // ── Fetch diagram ───────────────────────────────────────────────────────── | |
| useEffect(() => { | |
| if (!repo || isExplore) return; | |
| const isForced = !!retryKeys[diagramType]; | |
| // 1. In-memory cache: survives tab switches. | |
| if (!isForced && cache[diagramType]) { | |
| setDiagramData(cache[diagramType]); | |
| setError(null); | |
| return; | |
| } | |
| // 2. localStorage cache: survives page refreshes. | |
| if (!isForced) { | |
| try { | |
| const stored = localStorage.getItem(diagLsKey(repo, diagramType)); | |
| if (stored) { | |
| const parsed = JSON.parse(stored); | |
| setCache(prev => ({ ...prev, [diagramType]: parsed })); | |
| setDiagramData(parsed); | |
| setError(null); | |
| return; | |
| } | |
| } catch { /* corrupt — fall through to fetch */ } | |
| } | |
| setLoading(true); | |
| setLoadStage(null); | |
| setError(null); | |
| setDiagramData(null); | |
| // force=true when retryKeys[diagramType] > 0 (user hit Regenerate) so the | |
| // backend bypasses its disk cache and actually produces a fresh diagram. | |
| const cancel = streamDiagram(repo, diagramType, { | |
| force: isForced, | |
| onProgress: (ev) => setLoadStage(ev), | |
| onDone: ({ diagram, type }) => { | |
| setLoading(false); | |
| setLoadStage(null); | |
| setCache(prev => ({ ...prev, [diagramType]: diagram })); | |
| setDiagramData(diagram); | |
| setRetryKeys(prev => ({ ...prev, [diagramType]: 0 })); | |
| try { localStorage.setItem(diagLsKey(repo, diagramType), JSON.stringify(diagram)); } catch {} | |
| }, | |
| onError: (msg) => { | |
| setLoading(false); | |
| setLoadStage(null); | |
| setError(msg); | |
| }, | |
| }); | |
| return cancel; | |
| }, [repo, diagramType, retryKeys[diagramType], isExplore]); | |
| // Reset on repo change — always land on Explore so the concept map is the | |
| // first thing seen when switching repos. | |
| useEffect(() => { | |
| setCache({}); | |
| setDiagramData(null); | |
| setError(null); | |
| setRetryKeys({}); | |
| setSelected(null); | |
| setDiagramType("explore"); | |
| localStorage.setItem("ghrc_diagramType", "explore"); | |
| }, [repo]); | |
| // Escape exits fullscreen | |
| useEffect(() => { | |
| if (!fullscreen) return; | |
| function onKey(e) { if (e.key === "Escape") setFullscreen(false); } | |
| window.addEventListener("keydown", onKey); | |
| return () => window.removeEventListener("keydown", onKey); | |
| }, [fullscreen]); | |
| // Notify parent so it can collapse the sidebar while fullscreen is on. | |
| // The sidebar sits under the z:300 overlay anyway, but collapsing it also | |
| // reclaims the grid column behind the overlay — less reflow on exit. | |
| useEffect(() => { | |
| onFullscreenChange?.(fullscreen); | |
| }, [fullscreen, onFullscreenChange]); | |
| function handleRegenerate() { | |
| try { localStorage.removeItem(diagLsKey(repo, diagramType)); } catch {} | |
| setCache(prev => { const n = {...prev}; delete n[diagramType]; return n; }); | |
| setDiagramData(null); | |
| setError(null); | |
| setSelected(null); | |
| setRetryKeys(prev => ({ ...prev, [diagramType]: (prev[diagramType] || 0) + 1 })); | |
| } | |
| // When user clicks "Open in full chat →" from the detail panel | |
| function handleOpenInChat(question) { | |
| setSelected(null); | |
| onAskAbout?.(question); | |
| } | |
| return ( | |
| <div className={`diagram-container${fullscreen ? " diagram-fullscreen" : ""}`}> | |
| {/* ── Header ── */} | |
| <div className="diagram-header"> | |
| {!fullscreen && ( | |
| <span className="diagram-title"> | |
| {isExplore ? `Explore — ${repo}` : `System Diagram — ${repo}`} | |
| </span> | |
| )} | |
| {/* Fullscreen breadcrumb — the chat-header (which normally carries the | |
| repo context) is hidden in fullscreen, so we surface the same | |
| signal inline: a backend dot + owner/repo slug + current view. */} | |
| {fullscreen && ( | |
| <div className="diagram-fs-crumbs" aria-label="Current view"> | |
| <span className="diagram-fs-slug"> | |
| {(() => { | |
| const [owner, name] = repo.split("/"); | |
| return (<> | |
| <span className="diagram-fs-owner">{owner}/</span> | |
| <span className="diagram-fs-name">{name}</span> | |
| </>); | |
| })()} | |
| </span> | |
| <span className="diagram-fs-sep" aria-hidden="true">›</span> | |
| <span className="diagram-fs-view">{selectedTypeDef?.label || "Explore"}</span> | |
| </div> | |
| )} | |
| {/* Right-side controls — action buttons then fullscreen on the far right */} | |
| <div style={{ display: "flex", gap: 8, alignItems: "center", marginLeft: "auto" }}> | |
| {isExplore ? ( | |
| <button className="diagram-retry-btn" onClick={() => exploreRegenRef.current?.()} title="Generate a fresh tour"> | |
| ↺ Regenerate | |
| </button> | |
| ) : diagramData && ( | |
| <> | |
| <button className="diagram-retry-btn" onClick={handleRegenerate} title="Generate a fresh diagram"> | |
| ↺ Regenerate | |
| </button> | |
| <button | |
| className="diagram-ask-btn" | |
| onClick={() => onAskAbout?.( | |
| `Explain the ${selectedTypeDef?.label.toLowerCase()} of ${repo} — walk me through each component and how they connect` | |
| )} | |
| > | |
| Ask about this → | |
| </button> | |
| </> | |
| )} | |
| <button | |
| className="diagram-fullscreen-btn" | |
| onClick={() => setFullscreen(f => !f)} | |
| title={fullscreen ? "Exit fullscreen" : "Fullscreen"} | |
| aria-label={fullscreen ? "Exit fullscreen" : "Fullscreen"} | |
| > | |
| {fullscreen ? ( | |
| /* Compress — 4 inward corners */ | |
| <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ display: "block" }}> | |
| <path d="M6 1v5H1"/><path d="M10 1v5h5"/> | |
| <path d="M6 15v-5H1"/><path d="M10 15v-5h5"/> | |
| </svg> | |
| ) : ( | |
| /* Expand — 4 outward corners */ | |
| <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ display: "block" }}> | |
| <path d="M1 6V1h5"/><path d="M15 6V1h-5"/> | |
| <path d="M1 10v5h5"/><path d="M15 10v5h-5"/> | |
| </svg> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| {/* ── Focus Files banner ── */} | |
| {!fullscreen && focusFiles?.length > 0 && ( | |
| <div className="diagram-focus-banner"> | |
| <span className="diagram-focus-icon">◎</span> | |
| <span>Focused on {focusFiles.length} file{focusFiles.length !== 1 ? "s" : ""} from your last answer:</span> | |
| <span className="diagram-focus-files"> | |
| {focusFiles.slice(0, 3).join(", ")} | |
| {focusFiles.length > 3 ? ` +${focusFiles.length - 3} more` : ""} | |
| </span> | |
| </div> | |
| )} | |
| {/* ── Tab selector ── */} | |
| {!fullscreen && ( | |
| <div className="diagram-type-bar"> | |
| {/* ── Explore — hero tab, visually distinct from technical diagrams ── */} | |
| <button | |
| key="explore" | |
| className={`diagram-type-btn${diagramType === "explore" ? " active" : ""}`} | |
| onClick={() => setType("explore")} | |
| style={{ | |
| background: diagramType === "explore" | |
| ? "rgba(91,143,249,0.14)" | |
| : "rgba(91,143,249,0.04)", | |
| borderColor: diagramType === "explore" | |
| ? "rgba(91,143,249,0.55)" | |
| : "rgba(91,143,249,0.20)", | |
| }} | |
| > | |
| <span className="diagram-type-icon" style={{ color: "var(--accent-soft)" }}><TabIcon id="explore" /></span> | |
| <span className="diagram-type-label" style={{ color: diagramType === "explore" ? "var(--accent)" : "var(--accent-soft)" }}>Explore</span> | |
| <span className="diagram-type-desc">Guided concept tour</span> | |
| </button> | |
| {/* Vertical divider between Explore and technical tabs */} | |
| <div style={{ | |
| width: 1, alignSelf: "stretch", | |
| background: "var(--border)", | |
| margin: "0 4px", flexShrink: 0, | |
| }} /> | |
| {/* ── Technical diagram tabs ── */} | |
| {DIAGRAM_TABS.map(t => ( | |
| <button | |
| key={t.id} | |
| className={`diagram-type-btn${diagramType === t.id ? " active" : ""}`} | |
| onClick={() => setType(t.id)} | |
| > | |
| <span className="diagram-type-icon"><TabIcon id={t.id} /></span> | |
| <span className="diagram-type-label">{t.label}</span> | |
| <span className="diagram-type-desc">{t.desc}</span> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| {/* ── Canvas area (diagram on top, detail tray below) ── */} | |
| <div | |
| className="diagram-canvas has-cursor-glow" | |
| style={{ display: "flex", flexDirection: "column", overflow: "hidden", alignItems: "stretch", minHeight: 0, "--glow-size": "480px", "--glow-intensity": "7%" }} | |
| onMouseMove={(e) => { | |
| // Cursor-glow primitive — fed via CSS vars, painted on the compositor. | |
| 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`); | |
| }} | |
| > | |
| {/* Keyed wrapper — replays .view-switch-in when the diagram type changes, | |
| so Explore/Architecture/Class tab switches share the app's motion language. */} | |
| <div | |
| key={diagramType} | |
| className="view-switch-in" | |
| style={{ flex: 1, position: "relative", overflow: "hidden", minHeight: 0 }} | |
| > | |
| {isExplore ? ( | |
| <ExploreView repo={repo} onAskAbout={onAskAbout} onRegenerateRef={exploreRegenRef} /> | |
| ) : ( | |
| <> | |
| {loading && ( | |
| <div className="diagram-loading"> | |
| <span className="spinner" /> | |
| <div style={{ flex: 1, maxWidth: 300 }}> | |
| <div>{loadStage?.message || `Generating ${selectedTypeDef?.label.toLowerCase()} diagram…`}</div> | |
| {loadStage && ( | |
| <div style={{ marginTop: 8 }}> | |
| <div style={{ | |
| height: 3, background: "var(--border)", borderRadius: 2, overflow: "hidden", | |
| }}> | |
| <div style={{ | |
| height: "100%", | |
| width: `${Math.round((loadStage.progress || 0) * 100)}%`, | |
| background: "var(--accent)", | |
| borderRadius: 2, | |
| transition: "width 0.4s ease", | |
| }} /> | |
| </div> | |
| <div style={{ fontSize: 11, color: "var(--muted)", marginTop: 3 }}> | |
| {Math.round((loadStage.progress || 0) * 100)}% | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| {error && ( | |
| <div className="diagram-error"> | |
| {error} | |
| <button className="diagram-retry-btn" onClick={handleRegenerate}>Retry</button> | |
| </div> | |
| )} | |
| {diagramData && ( | |
| <GraphDiagram | |
| data={diagramData} | |
| repo={repo} | |
| diagramType={diagramType} | |
| onNodeSelect={setSelected} | |
| onEdgeSelect={setSelected} | |
| onAskAbout={onAskAbout} | |
| panelOpen={!!selected} | |
| /> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| {/* Bottom tray detail panel */} | |
| {selected && ( | |
| <NodeDetailPanel | |
| subject={selected} | |
| repo={repo} | |
| onClose={() => setSelected(null)} | |
| onOpenInChat={handleOpenInChat} | |
| /> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |