diff --git a/Makefile.cbm b/Makefile.cbm index de57fe785..180086760 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -414,6 +414,12 @@ UI_SRCS = \ src/ui/config.c \ src/ui/http_server.c \ src/ui/layout3d.c \ + src/ui/layout_regions.c \ + src/ui/atlas_api.c \ + src/ui/atlas_flows.c \ + src/ui/atlas_handout.c \ + src/ui/atlas_why.c \ + src/ui/atlas_metrics.c \ src/ui/httpd.c \ src/ui/embedded_stub.c diff --git a/README.md b/README.md index b41939a8f..7d5ffb6cf 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-si - **162 languages** — vendored tree-sitter grammars compiled into the binary. Nothing to install, nothing that breaks. - **120x fewer tokens** — 5 structural queries: ~3,400 tokens vs ~412,000 via file-by-file search. One graph query replaces dozens of grep/read cycles. - **45 supported automatic/conditional client surfaces** — `install` configures detected clients and safely activates conditional clients only when their documented platform, marker, or explicit existing config path is present. See [Multi-Agent Support](#multi-agent-support) for the complete matrix and manual/UI-only boundaries. -- **Built-in graph visualization** — 3D interactive UI at `localhost:9749`, served from the binary itself. +- **CBM Atlas** — the built-in human interface at `localhost:9749`: overview, modules treemap, region-level 3D galaxy, call flows, change impact, symbol pages and a prompt composer, served from the binary itself. - **Infrastructure-as-code indexing** — Dockerfiles, Kubernetes manifests, and Kustomize overlays indexed as graph nodes with cross-references. `Resource` nodes for K8s kinds, `Module` nodes for Kustomize overlays with `IMPORTS` edges to referenced resources. - **15 MCP tools** — search, trace, architecture, impact analysis, targeted index-coverage checks, Cypher queries, dead code detection, cross-service HTTP linking, ADR management, and more. @@ -131,9 +131,9 @@ Package-manager setup (npm, PyPI, or Go) verifies and publishes a coherent priva The ordinary `cli` mode is intentionally separate: it runs one command locally and never starts or connects to the coordination daemon, registers a daemon session, or starts watchers/UI. Its only shared state is the OS admission barrier plus per-project locks for graph mutations. While the command is running, a temporary monitor lets activation cancel that operation and its supervised worker safely; the monitor exits with the command and never becomes a standing daemon. See [CLI Mode](#cli-mode) for details. -### Graph Visualization UI +### CBM Atlas — the human interface -The graph UI is built into the binary — every install on every channel has it. Then run it: +The MCP tools are the agent's interface to the code graph; **CBM Atlas** is the human's. It is built into the binary — every install on every channel has it: ```bash codebase-memory-mcp --ui=true --port=9749 @@ -141,6 +141,18 @@ codebase-memory-mcp --ui=true --port=9749 Open `http://localhost:9749` in your browser. The UI is owned by the shared coordination daemon, so concurrent agent sessions do not start duplicate HTTP servers. +Atlas is a code-discovery instrument, not a chart: every insight is deterministic, carries the reason it is shown, and links onward. + +- **Overview** — the first read of a project: regions (Leiden call communities voted per file, named with provenance and cohesion), hubs by fan-in, entry points, surprising cross-region couplings with reasons, suggested questions, cross-package boundaries. +- **Modules** — a treemap of any folder, sized by symbols, colored by region, hatched where indexing was incomplete; drill to files and symbols. +- **Galaxy** — the 3D view, opening at region level of detail (one body per region, weighted arcs) so any project size renders; open a region for full detail, or load the whole galaxy explicitly. +- **Flows** — named entry→terminal call journeys as clickable call trees, with truncation stated, exportable as mermaid. +- **Changes** — `detect_changes` on the working tree: affected symbols grouped by risk before you ask an agent to finish the job. +- **Symbol pages** — docstring, region, callers/callees with true totals and per-edge confidence (resolved / reference / unproven), tests, co-change partners, near-clones, editor and GitHub links. +- **⌘K** search across symbol names and code text; a **prompt composer** turns anything you cite into a precise, identifier-exact prompt for your coding agent. + +Every screen deep-links (`?tab=…&project=…&sym=…®ion=…&flow=…`), so a place in the codebase is a URL you can share. + ### Auto-Index Enable automatic indexing on MCP session start: diff --git a/graph-ui/index.html b/graph-ui/index.html index e20a6503b..8ce5f20a9 100644 --- a/graph-ui/index.html +++ b/graph-ui/index.html @@ -3,9 +3,9 @@ - Codebase Memory — Graph + CBM Atlas - +
diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index a0d84b0b6..617ea49dd 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -2,40 +2,93 @@ import { useCallback, useEffect, useState } from "react"; import { GraphTab } from "./components/GraphTab"; import { StatsTab } from "./components/StatsTab"; import { ControlTab } from "./components/ControlTab"; +import { OverviewTab } from "./components/OverviewTab"; +import { ModulesTab } from "./components/ModulesTab"; +import { SymbolTab } from "./components/SymbolTab"; +import { FlowsTab } from "./components/FlowsTab"; +import { ChangesTab } from "./components/ChangesTab"; +import { DashboardTab } from "./components/DashboardTab"; +import { CommandK } from "./components/CommandK"; +import { ProjectSwitcher } from "./components/ProjectSwitcher"; +import { WikiPanel } from "./components/WikiPanel"; +import { wikiEntry } from "./wiki/entries"; +import { + PromptBasketProvider, + PromptBasketDrawer, +} from "./components/PromptBasket"; import type { TabId } from "./lib/types"; import { useUiMessages } from "./lib/i18n"; -const TAB_IDS: TabId[] = ["graph", "stats", "control"]; +const TAB_IDS: TabId[] = [ + "overview", + "modules", + "graph", + "flows", + "changes", + "dashboard", + "symbol", + "stats", + "control", +]; + +/* Tabs that need a selected project. "symbol" is reachable only via links. */ interface RouteState { tab: TabId; project: string | null; + /* Deep links (#564): node/region on the galaxy, sym on the symbol page, + * path in Modules, flow in Flows — all survive reloads and sharing. */ + node: string | null; + region: string | null; + sym: string | null; + path: string; + flow: string | null; } -/* Read the active tab + selected project from the URL query string so the - * current view survives refreshes and can be bookmarked or shared. */ function readRoute(): RouteState { const params = new URLSearchParams(window.location.search); const rawTab = params.get("tab"); const tab = TAB_IDS.includes(rawTab as TabId) ? (rawTab as TabId) : "stats"; - const project = params.get("project"); - return { tab, project: project ? project : null }; + return { + tab, + project: params.get("project") || null, + node: params.get("node") || null, + region: params.get("region") || null, + sym: params.get("sym") || null, + path: params.get("path") || "", + flow: params.get("flow") || null, + }; } -/* Build the canonical URL for a route, preserving the path and hash. */ -function routeUrl(tab: TabId, project: string | null): string { +function routeUrl(route: RouteState): string { const params = new URLSearchParams(); - params.set("tab", tab); - if (project) params.set("project", project); + params.set("tab", route.tab); + if (route.project) params.set("project", route.project); + if (route.region) params.set("region", route.region); + if (route.node) params.set("node", route.node); + if (route.sym) params.set("sym", route.sym); + if (route.path) params.set("path", route.path); + if (route.flow) params.set("flow", route.flow); return `${window.location.pathname}?${params.toString()}${window.location.hash}`; } export function App() { const t = useUiMessages(); const [route, setRoute] = useState(readRoute); - const [version, setVersion] = useState(null); + const [commandOpen, setCommandOpen] = useState(false); + /* The metric wiki: any dotted-underline term opens its entry here. */ + const [wikiSlug, setWikiSlug] = useState(null); + /* The galaxy keeps its WebGL context and layout across tab switches: + * mounted once visited, hidden with `invisible` (never display:none, which + * would zero the canvas), frameloop paused while hidden. */ + const [graphVisited, setGraphVisited] = useState(false); + useEffect(() => { + if (route.tab === "graph") setGraphVisited(true); + }, [route.tab]); const { tab: activeTab, project: selectedProject } = route; + const [version, setVersion] = useState(null); + useEffect(() => { let cancelled = false; void fetch("/api/ui-config") @@ -54,106 +107,257 @@ export function App() { /* Normalize the URL on first load so it always carries the current route. */ useEffect(() => { const initial = readRoute(); - window.history.replaceState(null, "", routeUrl(initial.tab, initial.project)); + window.history.replaceState(null, "", routeUrl(initial)); }, []); - /* Sync state when the user navigates with the browser back/forward buttons. */ useEffect(() => { const onPopState = () => setRoute(readRoute()); window.addEventListener("popstate", onPopState); return () => window.removeEventListener("popstate", onPopState); }, []); - /* Change the route and push a history entry (skips no-op navigations). */ - const navigate = useCallback((tab: TabId, project: string | null) => { - const url = routeUrl(tab, project); - const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; - if (url === current) return; - window.history.pushState(null, "", url); - setRoute({ tab, project }); + /* #wiki= deep link: wiki entries are addressable by URL (the doc_url + * target), opening the panel over whatever view the rest of the URL names. */ + useEffect(() => { + const openFromHash = () => { + const match = window.location.hash.match(/wiki=([a-z][a-z-]*)/); + if (match && wikiEntry(match[1])) setWikiSlug(match[1]); + }; + openFromHash(); + window.addEventListener("hashchange", openFromHash); + return () => window.removeEventListener("hashchange", openFromHash); + }, []); + + /* ⌘K / ctrl-K anywhere. */ + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { + event.preventDefault(); + setCommandOpen((value) => !value); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); }, []); - const tabs: { id: TabId; label: string }[] = [ + const navigate = useCallback((next: Partial & { tab: TabId }) => { + setRoute((previous) => { + const merged: RouteState = { + project: previous.project, + node: null, + region: null, + sym: null, + path: "", + flow: null, + ...next, + } as RouteState; + const url = routeUrl(merged); + const current = `${window.location.pathname}${window.location.search}${window.location.hash}`; + if (url !== current) window.history.pushState(null, "", url); + return merged; + }); + }, []); + + const openSymbol = useCallback( + (ref: { id?: number; qn?: string }) => { + navigate({ + tab: "symbol", + sym: ref.qn ?? (ref.id !== undefined ? `#${ref.id}` : null), + }); + }, + [navigate], + ); + const openRegion = useCallback( + (regionId: number) => { + navigate({ tab: "graph", region: String(regionId) }); + }, + [navigate], + ); + + /* Project-scoped views only — they render once a project is chosen. + * App-level destinations (Projects, Control) live on the right instead of + * being mixed into this row as dead buttons. */ + const projectTabs: { id: TabId; label: string }[] = [ + { id: "overview", label: t.tabs.overview }, + { id: "modules", label: t.tabs.modules }, { id: "graph", label: t.tabs.graph }, - { id: "stats", label: t.tabs.projects }, - { id: "control", label: t.tabs.control }, + { id: "flows", label: t.tabs.flows }, + { id: "changes", label: t.tabs.changes }, + { id: "dashboard", label: t.tabs.dashboard }, ]; + const symbolRef = route.sym + ? route.sym.startsWith("#") + ? { id: Number(route.sym.slice(1)) } + : { qn: route.sym } + : null; + return ( -
- {/* Header */} -
-
-
-
- - Codebase Memory - - {version && ( - - {version.startsWith("v") ? version : `v${version}`} + +
+ {/* Header */} +
+
+
+
+ + CBM Atlas + {version && ( + + {version.startsWith("v") ? version : `v${version}`} + + )} +
+ + {/* Context first: which project is everything below about? */} + navigate({ tab: "overview", project })} + onAllProjects={() => navigate({ tab: "stats", project: null })} + /> + + {selectedProject && ( + <> +
+ + )}
- {/* Tabs inline in header */} - -
- - {selectedProject && ( -
- - {t.graph.selectedLabel} - - - {selectedProject} - +
+ {selectedProject && ( + + )}
- )} -
- - {/* Content */} -
- {activeTab === "graph" ? ( - - ) : activeTab === "control" ? ( - - ) : ( - navigate("graph", p)} +
+ + {/* Content */} +
+ {graphVisited && ( +
+ + navigate({ tab: "graph", node, region }) + } + /> +
+ )} + {activeTab === "overview" && selectedProject ? ( + openSymbol({ qn })} + onOpenModules={() => navigate({ tab: "modules" })} + onOpenFlows={() => navigate({ tab: "flows" })} + onOpenDashboard={() => navigate({ tab: "dashboard" })} + onOpenTab={(tab) => navigate({ tab })} + /> + ) : activeTab === "modules" && selectedProject ? ( + navigate({ tab: "modules", path })} + onOpenSymbol={openSymbol} + onOpenRegion={openRegion} + /> + ) : activeTab === "graph" ? null : activeTab === "flows" && + selectedProject ? ( + + navigate({ tab: "flows", flow: id !== null ? String(id) : null }) + } + onOpenSymbol={openSymbol} + onOpenWiki={setWikiSlug} + /> + ) : activeTab === "changes" && selectedProject ? ( + + ) : activeTab === "dashboard" && selectedProject ? ( + openSymbol(ref)} + onOpenModulesPath={(path) => navigate({ tab: "modules", path })} + onOpenWiki={setWikiSlug} + /> + ) : activeTab === "symbol" && selectedProject && symbolRef ? ( + + ) : activeTab === "control" ? ( + + ) : ( + navigate({ tab: "overview", project })} + /> + )} +
+ + setCommandOpen(false)} + onOpenSymbol={openSymbol} + /> + {wikiSlug && ( + setWikiSlug(null)} + onNavigate={setWikiSlug} /> )} - -
+ + + ); } diff --git a/graph-ui/src/components/ChangesTab.tsx b/graph-ui/src/components/ChangesTab.tsx new file mode 100644 index 000000000..6fb354720 --- /dev/null +++ b/graph-ui/src/components/ChangesTab.tsx @@ -0,0 +1,271 @@ +/* Changes — what am I about to break: detect_changes on the working tree, + * grouped by risk, every affected symbol one click from its page and the + * whole blast radius one click from the prompt composer. */ +import { useEffect, useMemo, useState } from "react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + fetchChanges, + fetchBlast, + archRows, + type ArchitectureJson, + type BlastPayload, +} from "../lib/atlas"; +import { usePromptBasket } from "./PromptBasket"; +import { fetchRegions } from "../hooks/useGraphData"; +import { disambiguateRegionNames } from "../lib/regions"; + +interface ChangesTabProps { + project: string; + onOpenSymbol: (ref: { id?: number; qn?: string }) => void; +} + +interface AffectedRow { + qn?: string; + symbol?: string; + file?: string; + risk?: string; + hops?: number; + [key: string]: string | number | null | undefined; +} + +const RISK_ORDER = ["CRITICAL", "HIGH", "MEDIUM", "LOW"]; +const RISK_TONE: Record = { + CRITICAL: "text-red-300/90 border-red-300/40 bg-red-400/10", + HIGH: "text-amber-300/90 border-amber-300/40 bg-amber-400/10", + MEDIUM: "text-sky-300/80 border-sky-300/30 bg-sky-400/5", + LOW: "text-foreground/50 border-border/40 bg-card", +}; + +export function ChangesTab({ project, onOpenSymbol }: ChangesTabProps) { + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [blast, setBlast] = useState(null); + const [regionNames, setRegionNames] = useState>(new Map()); + const basket = usePromptBasket(); + + const run = () => { + setLoading(true); + setError(null); + fetchChanges(project) + .then(setResult) + .catch((err) => setError(err instanceof Error ? err.message : "failed")) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + setResult(null); + setError(null); + setBlast(null); + /* Region display names, disambiguated the same way the galaxy does + * (duplicate folder names get their hub appended). */ + let cancelled = false; + fetchRegions(project) + .then((payload) => { + if (cancelled) return; + setRegionNames( + new Map(disambiguateRegionNames(payload.regions).map((r) => [r.id, r.name])), + ); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [project]); + + const sections = useMemo(() => { + if (!result) return []; + /* detect_changes(format:"json") emits {cols, rows} sections; collect + * whichever carry symbol-shaped rows, keep scalars as banner text. */ + return Object.entries(result) + .filter(([, value]) => typeof value === "object" && value !== null && "cols" in value) + .map(([name]) => ({ name, rows: archRows(result, name) })) + .filter((section) => section.rows.length > 0); + }, [result]); + + const scalars = useMemo(() => { + if (!result) return []; + return Object.entries(result).filter( + ([, value]) => typeof value === "string" || typeof value === "number", + ) as [string, string | number][]; + }, [result]); + + const riskOf = (row: AffectedRow): string => String(row.risk ?? "LOW").toUpperCase(); + const qnOf = (row: AffectedRow): string | undefined => + (row.qn as string) ?? (row.symbol as string) ?? undefined; + + const affected = useMemo(() => { + const rows = sections.flatMap((section) => section.rows).filter((row) => qnOf(row)); + return [...rows].sort( + (a, b) => RISK_ORDER.indexOf(riskOf(a)) - RISK_ORDER.indexOf(riskOf(b)), + ); + }, [sections]); + + /* Blast radius: the impacted symbols' files (falling back to the raw + * changed files) bucketed by region server-side — "this change touches + * N regions" is the review signal git cannot compute. */ + useEffect(() => { + if (!result) { + setBlast(null); + return; + } + const files = new Set(); + for (const row of affected) { + if (typeof row.file === "string" && row.file) files.add(row.file); + } + const changedFiles = (result as { changed_files?: unknown }).changed_files; + if (files.size === 0 && Array.isArray(changedFiles)) { + for (const file of changedFiles) { + if (typeof file === "string" && !file.endsWith("/")) files.add(file); + } + } + if (files.size === 0) { + setBlast(null); + return; + } + let cancelled = false; + fetchBlast(project, [...files].slice(0, 200)) + .then((payload) => { + if (!cancelled) setBlast(payload); + }) + .catch(() => { + if (!cancelled) setBlast(null); + }); + return () => { + cancelled = true; + }; + }, [project, result, affected]); + + return ( + +
+
+

+ Working-tree impact +

+ + {affected.length > 0 && ( + + )} +
+ + {blast && blast.regions.length > 0 && ( +
+

+ Your change reaches{" "} + {blast.files} file + {blast.files === 1 ? "" : "s"} across{" "} + = 3 ? "text-warn" : ""}`} + > + {blast.regions.length} + {" "} + region{blast.regions.length === 1 ? "" : "s"} + {blast.unmapped > 0 && ` (+${blast.unmapped} outside regions)`} + {blast.truncated && " — file list truncated at 200"} +

+
+ {[...blast.regions] + .sort((a, b) => b.count - a.count) + .map((region) => ( + + {regionNames.get(region.region) ?? region.name ?? `region ${region.region}`} + × {region.count} + + ))} +
+ {blast.regions.length >= 3 && ( +

+ Spanning {blast.regions.length} regions in one change is where regressions + hide — consider splitting, or cite the region names in your prompt so the + agent checks each seam. +

+ )} +
+ )} + + {error &&

{error}

} + {!result && !loading && !error && ( +

+ Runs detect_changes against the + project's git working tree: changed symbols, their transitive callers with risk + classification, and the tests that cover them — the map to read before asking an + agent to finish a refactor. +

+ )} + + {scalars.length > 0 && ( +
+ {scalars.map(([key, value]) => ( +
+

+ {key.replace(/_/g, " ")} +

+

{String(value)}

+
+ ))} +
+ )} + + {result && affected.length === 0 && ( +

+ No affected symbols — the working tree is clean or the changes touch nothing indexed. +

+ )} + + {affected.length > 0 && ( +
+ {affected.map((row, index) => { + const risk = riskOf(row); + const qn = qnOf(row)!; + return ( + + ); + })} +
+ )} +
+
+ ); +} diff --git a/graph-ui/src/components/CommandK.tsx b/graph-ui/src/components/CommandK.tsx new file mode 100644 index 000000000..6ffa9606f --- /dev/null +++ b/graph-ui/src/components/CommandK.tsx @@ -0,0 +1,197 @@ +/* ⌘K — one box, two engines: symbol names (search_graph) and code text + * (search_code), grouped, keyboard-first. Enter opens, Esc closes. */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { callTool } from "../api/rpc"; +import { searchGraph, type SearchRow } from "../lib/atlas"; + +interface CommandKProps { + project: string | null; + open: boolean; + onClose: () => void; + onOpenSymbol: (ref: { qn: string }) => void; +} + +interface TextHit { + file: string; + line: number; + text: string; +} + +/* search_code(format:"json") shape is tool-versioned; decode defensively. */ +function decodeTextHits(payload: unknown): TextHit[] { + const out: TextHit[] = []; + const root = payload as { + matches?: { file?: string; line?: number; text?: string }[]; + groups?: { file?: string; rows?: (string | number | null)[][] }[]; + }; + if (Array.isArray(root.matches)) { + for (const match of root.matches) + if (match.file) + out.push({ file: match.file, line: match.line ?? 0, text: match.text ?? "" }); + } else if (Array.isArray(root.groups)) { + for (const group of root.groups) + for (const row of group.rows ?? []) + out.push({ + file: group.file ?? "?", + line: Number(row[0] ?? 0), + text: String(row[1] ?? ""), + }); + } + return out.slice(0, 12); +} + +export function CommandK({ project, open, onClose, onOpenSymbol }: CommandKProps) { + const [query, setQuery] = useState(""); + const [symbols, setSymbols] = useState([]); + const [textHits, setTextHits] = useState([]); + const [active, setActive] = useState(0); + const [busy, setBusy] = useState(false); + const inputRef = useRef(null); + const seqRef = useRef(0); + + useEffect(() => { + if (open) { + setQuery(""); + setSymbols([]); + setTextHits([]); + setActive(0); + setTimeout(() => inputRef.current?.focus(), 30); + } + }, [open]); + + useEffect(() => { + if (!open || !project || query.trim().length < 2) { + setSymbols([]); + setTextHits([]); + return; + } + const seq = ++seqRef.current; + const timer = setTimeout(async () => { + setBusy(true); + const escaped = query.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const [nameResult, textResult] = await Promise.allSettled([ + searchGraph(project, { name_pattern: escaped, limit: 12 }), + callTool("search_code", { project, query: query.trim(), limit: 12, format: "json" }), + ]); + if (seq !== seqRef.current) return; + setSymbols(nameResult.status === "fulfilled" ? nameResult.value.rows : []); + setTextHits(textResult.status === "fulfilled" ? decodeTextHits(textResult.value) : []); + setActive(0); + setBusy(false); + }, 220); + return () => clearTimeout(timer); + }, [open, project, query]); + + const entries = useMemo( + () => + [ + ...symbols.map((row) => ({ kind: "symbol" as const, row })), + ...textHits.map((hit) => ({ kind: "text" as const, hit })), + ], + [symbols, textHits], + ); + + const activate = useCallback( + (index: number) => { + const entry = entries[index]; + if (!entry) return; + if (entry.kind === "symbol") { + onOpenSymbol({ qn: entry.row.qualified_name }); + onClose(); + } + /* Text hits have no symbol identity; they stay informational. */ + }, + [entries, onOpenSymbol, onClose], + ); + + useEffect(() => { + if (!open) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + else if (event.key === "ArrowDown") { + event.preventDefault(); + setActive((value) => Math.min(value + 1, entries.length - 1)); + } else if (event.key === "ArrowUp") { + event.preventDefault(); + setActive((value) => Math.max(value - 1, 0)); + } else if (event.key === "Enter") { + event.preventDefault(); + activate(active); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, entries.length, active, activate, onClose]); + + if (!open) return null; + + return ( +
+
event.stopPropagation()} + > + setQuery(event.target.value)} + className="w-full bg-transparent px-4 py-3 text-[14px] text-foreground placeholder-foreground/25 outline-none border-b border-border/40" + /> +
+ {busy && entries.length === 0 && ( +

Searching…

+ )} + {!busy && query.trim().length >= 2 && entries.length === 0 && ( +

No matches.

+ )} + {symbols.length > 0 && ( +

+ Symbols +

+ )} + {symbols.map((row, index) => ( + + ))} + {textHits.length > 0 && ( +

+ In code +

+ )} + {textHits.map((hit, index) => ( +
+

+ {hit.file}:{hit.line} +

+

{hit.text}

+
+ ))} +
+
+ ↑↓ navigate · Enter opens the symbol · Esc closes +
+
+
+ ); +} diff --git a/graph-ui/src/components/ControlTab.tsx b/graph-ui/src/components/ControlTab.tsx index 6ea05bc29..f0523b21b 100644 --- a/graph-ui/src/components/ControlTab.tsx +++ b/graph-ui/src/components/ControlTab.tsx @@ -9,18 +9,34 @@ function Gauge({ label, value, max, unit, color }: { label: string; value: number; max: number; unit: string; color: string; }) { const pct = Math.min(100, (value / max) * 100); + /* The bands are stated in the legend and glyphed beside the value, so the + * bar's color is redundant, not the only carrier (WCAG 1.4.1). */ + const band = pct > 80 ? "crit" : pct > 50 ? "warn" : null; + const bandColor = band === "crit" ? "#e05252" : "#eab308"; return ( -
-

{label}

+
+

{label}

- {value.toFixed(1)}{unit} + {value.toFixed(1)}{unit} + {band && ( + + {band === "crit" ? "▲▲" : "▲"} + + )}

-
+
80 ? "#e05252" : pct > 50 ? "#eab308" : "#1DA27E" }} + style={{ width: `${pct}%`, backgroundColor: band === "crit" ? "#e05252" : band === "warn" ? "#eab308" : "#1DA27E" }} />
+

+ ▲ >{(max * 0.5).toFixed(0)}{unit} · ▲▲ >{(max * 0.8).toFixed(0)}{unit} +

); } @@ -35,10 +51,10 @@ function ProcessCard({ proc, selected, onSelect }: { return (
-

CPU

+

CPU

{proc.cpu.toFixed(1)}%

-

RAM

+

RAM

{proc.rss_mb.toFixed(0)} MB

-

{t.control.uptime}

+

{t.control.uptime}

{proc.elapsed}

-

{proc.command}

+

{proc.command}

); } @@ -93,15 +109,15 @@ function LogViewer() { }, []); return ( -
+
- {t.control.processLogs} - {lines.length} lines + {t.control.processLogs} + {lines.length} lines
-
+
{lines.length === 0 ? ( -

{t.control.noLogs}

+

{t.control.noLogs}

) : ( lines.map((line, i) => { const isErr = line.includes("level=error"); @@ -110,7 +126,7 @@ function LogViewer() {
{line} @@ -176,14 +192,14 @@ export function ControlTab() {
{processes.length === 0 ? ( -

{t.control.noProcesses}

+

{t.control.noProcesses}

) : (
{processes.map((p) => ( diff --git a/graph-ui/src/components/DashboardTab.tsx b/graph-ui/src/components/DashboardTab.tsx new file mode 100644 index 000000000..457b01a61 --- /dev/null +++ b/graph-ui/src/components/DashboardTab.tsx @@ -0,0 +1,558 @@ +/* Dashboard — one hero and its context. The hero is the churn × complexity + * list (the evidence-backed "where the bugs live") with a knee-point head, + * a cost sentence, and mute-as-intended dispositions. Quality signals get + * tones and trends; inventory facts live in the footer; histograms exist + * only with a computed takeaway. No composite score — the cards are the + * score. Everything derives from the graph and one bounded git-log pass. */ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { kneeCount, costSentence, findingKey, isDismissed, dismiss } from "../lib/findings"; +import { useUiMessages } from "../lib/i18n"; +import { + CPLX_BINS, + CPLX_TAIL_MIN, + CPLX_TAIL_START, + complexityTakeaway, +} from "../lib/complexity"; +import { MetricChip } from "./MetricChip"; + +interface MetricEntry { + qn?: string; + name?: string; + file?: string; + value: number; + commits?: number; +} + +interface MetricsPayload { + generated_from: string; + totals: { + callables: number; + dead: number; + tested_symbols: number; + similar_edges: number; + missed_files: number; + exported: number; + documented_exported: number; + avg_complexity: number; + files: number; + }; + certainty: { calls: number; call_reference: number; usage: number }; + complexity_hist: number[]; + lines_hist: number[]; + top_complex: MetricEntry[]; + top_cognitive: MetricEntry[]; + top_long: MetricEntry[]; + top_churn: MetricEntry[]; + top_churn_complex: MetricEntry[]; + churn_available: boolean; + churn_total_commits: number; + churn_total_files: number; + history: { + indexed_at: string; + callables: number; + dead: number; + avg_complexity: number; + tested: number; + usage_share: number; + }[]; +} + +interface DashboardTabProps { + project: string; + onOpenSymbol: (ref: { qn: string }) => void; + onOpenModulesPath: (path: string) => void; + onOpenWiki: (slug: string) => void; +} + +interface TrendPoint { + /* Epoch ms of the snapshot; NaN when the timestamp failed to parse. */ + t: number; + v: number; +} + +/* Shape-only sparkline on a real time axis — reindex snapshots are + * irregular, so equal spacing would lie about the pace of change. Each + * snapshot is a dot; lines only join runs of ≥4 snapshots, and never span + * a gap wider than 3× the median gap (a segment across a dark period + * invents data). The printed delta beside it carries the magnitude. */ +function Sparkline({ points }: { points: TrendPoint[] }) { + if (points.length < 2) return null; + const values = points.map((point) => point.v); + const max = Math.max(...values); + const min = Math.min(...values); + const span = max - min || 1; + const t0 = points[0].t; + const tN = points[points.length - 1].t; + const timed = points.every((point) => Number.isFinite(point.t)) && tN > t0; + const xs = points.map((point, index) => + timed ? ((point.t - t0) / (tN - t0)) * 100 : (index / (points.length - 1)) * 100, + ); + const ys = values.map((value) => 28 - ((value - min) / span) * 24); + const gaps = xs.slice(1).map((x, index) => x - xs[index]); + const medianGap = [...gaps].sort((a, b) => a - b)[Math.floor(gaps.length / 2)] || 1; + const runs: string[][] = [[`${xs[0]},${ys[0]}`]]; + for (let i = 1; i < xs.length; i++) { + if (gaps[i - 1] > 3 * medianGap) runs.push([]); + runs[runs.length - 1].push(`${xs[i]},${ys[i]}`); + } + return ( + + {points.length >= 4 && + runs + .filter((run) => run.length > 1) + .map((run, index) => ( + + ))} + {/* Dots as zero-length round-capped strokes — with preserveAspectRatio + * "none", circles would stretch into ellipses; non-scaling strokes + * don't. */} + `M${x},${ys[index]}h0.01`).join("")} + fill="none" + stroke="currentColor" + strokeWidth="3" + strokeLinecap="round" + className="text-primary/60" + vectorEffect="non-scaling-stroke" + /> + + ); +} + +function QualityCard({ + label, + value, + sub, + tone, + trend, + trendFormat, +}: { + label: ReactNode; + value: string; + sub?: string; + tone?: "warn" | "crit"; + trend?: TrendPoint[]; + trendFormat?: (v: number) => string; +}) { + const fmt = trendFormat ?? ((v: number) => v.toLocaleString("en-US")); + const first = trend?.[0]?.v; + const last = trend?.[trend.length - 1]?.v; + const delta = first !== undefined && last !== undefined ? last - first : 0; + return ( +
+

{label}

+

+ {value} +

+ {sub &&

{sub}

} + {trend && trend.length > 1 && ( + <> + + {/* The min–max band makes any wobble fill it — this pair is the + * honest magnitude. */} +

+ {fmt(first!)} → {fmt(last!)} ({delta > 0 ? "+" : ""} + {fmt(delta)}) +

+ + )} +
+ ); +} + +function TopList({ + title, + entries, + unit, + total, + onOpenSymbol, +}: { + title: ReactNode; + entries: MetricEntry[]; + unit: string; + /* Size of the population the list was ranked over — only when the payload + * carries a real total; never estimated. Absent → the cap reads "top N". */ + total?: number; + onOpenSymbol?: (ref: { qn: string }) => void; +}) { + return ( +
+

+ {title} + {entries.length > 0 && ( + + top {entries.length.toLocaleString("en-US")} + {total !== undefined && ` of ${total.toLocaleString("en-US")}`} + + )} +

+
+ {entries.map((entry, index) => { + const label = entry.name ?? entry.file ?? "?"; + const clickable = entry.qn && onOpenSymbol; + const Row = clickable ? "button" : "div"; + return ( + onOpenSymbol!({ qn: entry.qn! }) : undefined} + className={`flex items-center gap-2 w-full text-left py-[3px] ${ + clickable ? "group cursor-pointer" : "" + }`} + > + + {label} + + + {entry.value.toLocaleString("en-US")} {unit} + + + ); + })} + {entries.length === 0 &&

None.

} +
+
+ ); +} + +export function DashboardTab({ + project, + onOpenSymbol, + onOpenModulesPath, + onOpenWiki, +}: DashboardTabProps) { + const t = useUiMessages(); + const [metrics, setMetrics] = useState(null); + const [error, setError] = useState(null); + const [showMuted, setShowMuted] = useState(false); + const [mutedTick, setMutedTick] = useState(0); + + useEffect(() => { + let cancelled = false; + setMetrics(null); + setError(null); + fetch(`/api/metrics?${new URLSearchParams({ project })}`) + .then(async (res) => { + if (!res.ok) + throw new Error((await res.json().catch(() => null))?.error ?? `HTTP ${res.status}`); + return res.json(); + }) + .then((payload) => { + if (!cancelled) setMetrics(payload); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : "failed"); + }); + return () => { + cancelled = true; + }; + }, [project]); + + /* Hero rows: knee-point head + dispositions. */ + const hero = useMemo(() => { + void mutedTick; + const entries = metrics?.top_churn_complex ?? []; + const head = kneeCount(entries.map((entry) => entry.value)); + const rows = entries.map((entry, index) => ({ + entry, + inHead: index < head, + key: findingKey("hotspot", entry.file ?? String(index), entry.value), + })); + return { + visible: rows.filter((row) => showMuted || !isDismissed(row.key)), + mutedCount: rows.filter((row) => isDismissed(row.key)).length, + head, + headCommits: entries + .slice(0, head) + .reduce((sum, entry) => sum + (entry.commits ?? 0), 0), + }; + }, [metrics, showMuted, mutedTick]); + + const takeaway = useMemo(() => { + if (!metrics) return null; + return complexityTakeaway( + metrics.complexity_hist, + metrics.top_complex.slice(0, 3).map((entry) => entry.name ?? ""), + t.dashboard, + ); + }, [metrics, t]); + + if (error) { + return ( +
+

{error}

+
+ ); + } + if (!metrics) { + return ( +
+

Measuring…

+
+ ); + } + + const { totals, certainty } = metrics; + const callish = certainty.calls + certainty.call_reference + certainty.usage; + const resolvedShare = callish > 0 ? (certainty.calls + certainty.call_reference) / callish : 0; + const docShare = totals.exported > 0 ? totals.documented_exported / totals.exported : 0; + const testedShare = totals.callables > 0 ? totals.tested_symbols / totals.callables : 0; + const history = metrics.history ?? []; + const cost = costSentence( + hero.head, + hero.headCommits, + totals.files, + metrics.churn_total_commits, + t.dashboard, + ); + + return ( + +
+ {/* ── Hero: where the bugs live ─────────────────────────── */} +
+
+

+ + Where the bugs live — churn × complexity + +

+ {metrics.top_churn_complex.length > 0 && ( + + top {metrics.top_churn_complex.length.toLocaleString("en-US")} + {metrics.churn_total_files > 0 && + ` of ${metrics.churn_total_files.toLocaleString("en-US")}`} + + )} + {hero.mutedCount > 0 && ( + + )} +
+ {cost ? ( +

+ {cost} + {t.dashboard.costConcentrate} +

+ ) : ( +

+ {metrics.churn_available + ? "Not enough churn data yet." + : "Churn unavailable — the project root has no readable git history."} +

+ )} +
+ {hero.visible.map(({ entry, inHead, key }) => ( +
+ + + {entry.commits?.toLocaleString("en-US") ?? "?"} commits + + {/* The score is commits × max complexity — dividing the + * factor back out shows both axes, not just the ranking. */} + + cplx {entry.commits ? Math.round(entry.value / entry.commits).toLocaleString("en-US") : "?"} + + + {entry.value.toLocaleString("en-US")} + + +
+ ))} + {hero.visible.length === 0 && ( +

+ Nothing here — either healthy, or everything is muted. +

+ )} +
+
+ + {/* ── Quality signals: toned, trended ───────────────────── */} +
+ ({ + t: Date.parse(entry.indexed_at), + v: 1 - entry.usage_share, + }))} + trendFormat={(v) => `${(v * 100).toFixed(0)}%`} + /> + + Dead candidates + + } + value={totals.dead.toLocaleString("en-US")} + sub="no callers; entry/test/exported excluded" + tone={totals.dead > 0 ? "warn" : undefined} + trend={history.map((entry) => ({ + t: Date.parse(entry.indexed_at), + v: entry.dead, + }))} + /> + + Tested symbols + + } + value={`${(testedShare * 100).toFixed(0)}%`} + sub={`${totals.tested_symbols.toLocaleString("en-US")} with TESTS edges`} + trend={history.map((entry) => ({ + t: Date.parse(entry.indexed_at), + v: entry.tested, + }))} + /> + + Documented exports + + } + value={`${(docShare * 100).toFixed(0)}%`} + sub={`${totals.documented_exported.toLocaleString("en-US")} of ${totals.exported.toLocaleString("en-US")} exported`} + tone={docShare < 0.3 ? "warn" : undefined} + /> +
+ + {/* ── Complexity, with its takeaway ─────────────────────── */} +
+
+

+ Complexity mix + + {totals.callables.toLocaleString("en-US")} callables + +

+ {takeaway &&

{takeaway}

} + {/* TODO(W2b): real log-binned histogram behind a disclosure — needs per-bin API data */} +
+ {metrics.complexity_hist.map((value, index) => { + const max = Math.max(1, ...metrics.complexity_hist); + return ( +
+ + {value.toLocaleString("en-US")} + +
= CPLX_TAIL_START ? "bg-warn/60" : "bg-primary/35"}`} + style={{ height: `${Math.max(2, (value / max) * 40)}px` }} + /> + + {CPLX_BINS[index]?.label} + +
+ ); + })} +
+

+ + >{CPLX_TAIL_MIN} flagged +

+
+ +
+ + {/* ── Drill-down lists ──────────────────────────────────── */} +
+ + Most complex + + } + entries={metrics.top_complex} + unit="cyclo" + total={totals.callables} + onOpenSymbol={onOpenSymbol} + /> + + +
+ + {/* ── Inventory footer — facts, untoned ─────────────────── */} +

+ {totals.callables.toLocaleString("en-US")} callables ·{" "} + {totals.files.toLocaleString("en-US")} files ·{" "} + {totals.similar_edges.toLocaleString("en-US")} near-clone edges ·{" "} + {totals.missed_files.toLocaleString("en-US")} files not fully indexed · computed from + index {metrics.generated_from} + {history.length > 1 && ` · trends across ${history.length} index runs`} +

+
+ + ); +} diff --git a/graph-ui/src/components/DisplaySettingsMenu.tsx b/graph-ui/src/components/DisplaySettingsMenu.tsx index 3b78f814c..fe66cfea8 100644 --- a/graph-ui/src/components/DisplaySettingsMenu.tsx +++ b/graph-ui/src/components/DisplaySettingsMenu.tsx @@ -17,29 +17,41 @@ interface SliderRowProps { value: number; min: number; max: number; + /* Multiplier rows use the defaults; the ms row overrides both. */ + step?: number; + format?: (value: number) => string; onChange: (value: number) => void; } -function SliderRow({ label, hint, value, min, max, onChange }: SliderRowProps) { +function SliderRow({ + label, + hint, + value, + min, + max, + step = 0.05, + format = (v) => `${v.toFixed(2)}×`, + onChange, +}: SliderRowProps) { return ( ); } @@ -79,7 +91,8 @@ export function DisplaySettingsMenu({ const isDefault = settings.edgeBrightness === DEFAULT_DISPLAY_SETTINGS.edgeBrightness && settings.nodeGlow === DEFAULT_DISPLAY_SETTINGS.nodeGlow && - settings.bloom === DEFAULT_DISPLAY_SETTINGS.bloom; + settings.bloom === DEFAULT_DISPLAY_SETTINGS.bloom && + settings.tooltipDelayMs === DEFAULT_DISPLAY_SETTINGS.tooltipDelayMs; return (
@@ -98,15 +111,15 @@ export function DisplaySettingsMenu({
- + Contrast ); @@ -101,18 +101,45 @@ export function FilterPanel({ const deadCount = statusCounts.get("dead") ?? 0; + /* Tri-state master over everything the panel lists (node types + edge + * types — the same global scope enableAll/disableAll always had). */ + const filterCount = labelCounts.length + edgeTypeCounts.length; + let enabledCount = 0; + for (const [label] of labelCounts) if (enabledLabels.has(label)) enabledCount++; + for (const [type] of edgeTypeCounts) if (enabledEdgeTypes.has(type)) enabledCount++; + const master: "all" | "none" | "some" = + filterCount > 0 && enabledCount === filterCount + ? "all" + : enabledCount === 0 + ? "none" + : "some"; + return (
{/* Header row — always visible */}
- + Filters -
- - | - -
+
{/* Scrollable filter groups */} @@ -121,7 +148,7 @@ export function FilterPanel({ {/* Node types */} {labelCounts.length > 0 && (
-

Node types

+

Node types

{labelCounts.map(([label, count]) => { const on = enabledLabels.has(label); @@ -130,13 +157,13 @@ export function FilterPanel({ ); })} @@ -147,7 +174,7 @@ export function FilterPanel({ {/* Relationships */} {edgeTypeCounts.length > 0 && (
-

Relationships

+

Relationships

{edgeTypeCounts.map(([type, count]) => { const on = enabledEdgeTypes.has(type); @@ -155,12 +182,12 @@ export function FilterPanel({ ); })} @@ -175,12 +202,12 @@ export function FilterPanel({ focus; click the code galaxy to come back. */}
- + Missed files {missedCount > 0 && ( - - {missedCount.toLocaleString()} files + + {missedCount.toLocaleString("en-US")} files )}
@@ -189,7 +216,7 @@ export function FilterPanel({ onToggle={onToggleMissedView} label="Show missed skeleton" /> -

+

{missedCount > 0 ? "White satellite = files not fully indexed (best-effort). Click it to focus, click the galaxy to return." : "No known misses (best-effort — not a completeness guarantee)."} @@ -199,11 +226,11 @@ export function FilterPanel({ {/* Dead-code view */}

- + Dead code - - {deadCount.toLocaleString()} dead + + {deadCount.toLocaleString("en-US")} dead
@@ -230,7 +257,7 @@ export function FilterPanel({ {STATUS_LEGEND.map((s) => ( diff --git a/graph-ui/src/components/FlowsTab.observed.test.tsx b/graph-ui/src/components/FlowsTab.observed.test.tsx new file mode 100644 index 000000000..3b9041fad --- /dev/null +++ b/graph-ui/src/components/FlowsTab.observed.test.tsx @@ -0,0 +1,30 @@ +/* @vitest-environment jsdom */ +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { ObservedChip } from "./FlowsTab"; + +/* The chip is exercised in isolation — FlowsTab itself fetches on mount. + * The all-observed line logic lives in lib/observed.ts and is tested there. */ +describe("ObservedChip", () => { + const observed = { + count: 3, + label: "pytest 2026-08-27", + last_seen: "2026-08-27T14:12:00Z", + }; + + it("renders glyph, word and count, with the run in the title", () => { + render(); + const chip = screen.getByTitle("ran in pytest 2026-08-27 · last 2026-08-27"); + expect(chip).toHaveTextContent("▶ observed ×3"); + /* No onOpenWiki → plain text, no nested button (flow-step rows are buttons). */ + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("opens the wiki entry from the marker word when onOpenWiki is given", () => { + const onOpenWiki = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "observed" })); + expect(onOpenWiki).toHaveBeenCalledWith("observed"); + }); +}); diff --git a/graph-ui/src/components/FlowsTab.tsx b/graph-ui/src/components/FlowsTab.tsx new file mode 100644 index 000000000..74abb2352 --- /dev/null +++ b/graph-ui/src/components/FlowsTab.tsx @@ -0,0 +1,535 @@ +/* Flows — how the codebase runs: ranked entry→terminal call journeys, each + * opening as an indented call tree with every step one click from its + * symbol page. Truncation is stated, never silent. */ +import { useEffect, useMemo, useState } from "react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + fetchFlow, + fetchFlows, + fetchTrace, + searchGraph, + type FlowDetail, + type FlowsPayload, + type ObservedCall, + type TracePayload, +} from "../lib/atlas"; +import { groupFlowsByEntry } from "../lib/flowgroup"; +import { useUiMessages } from "../lib/i18n"; +import { allHopsObserved, newestObserved, observedTitle } from "../lib/observed"; +import { formatGuard } from "../lib/why"; +import { MetricChip } from "./MetricChip"; +import { AddToPromptButton } from "./PromptBasket"; + +interface FlowsTabProps { + project: string; + flowId: number | null; + onOpenFlow: (id: number | null) => void; + onOpenSymbol: (ref: { id?: number; qn?: string }) => void; + onOpenWiki: (slug: string) => void; +} + +function flowToMermaid(detail: FlowDetail): string { + const lines = ["graph TD"]; + for (let index = 1; index < detail.steps.length; index++) { + const step = detail.steps[index]; + const parent = detail.steps[step.parent]; + if (!parent) continue; + const safe = (name: string) => name.replace(/[^A-Za-z0-9_]/g, "_"); + lines.push(` ${safe(parent.name)}${step.parent} --> ${safe(step.name)}${index}`); + } + return lines.join("\n"); +} + +/* "▶ observed ×N" — runtime-evidence marker beside the guard chips: this + * exact hop fired in a recorded run. Glyph + word, never color-only. With + * onOpenWiki the word opens the wiki entry; omit it where the chip sits + * inside a row button (flow steps) — nested buttons are invalid HTML. */ +export function ObservedChip({ + observed, + onOpenWiki, +}: { + observed: ObservedCall; + onOpenWiki?: (slug: string) => void; +}) { + const t = useUiMessages(); + return ( + + ▶{" "} + {onOpenWiki ? ( + + {t.observed.word} + + ) : ( + t.observed.word + )}{" "} + ×{observed.count} + + ); +} + +/* One endpoint picker: type ≥2 chars, pick from symbol matches. */ +function EndpointPicker({ + project, + placeholder, + value, + onPick, +}: { + project: string; + placeholder: string; + value: string | null; + onPick: (qn: string | null) => void; +}) { + const [query, setQuery] = useState(""); + const [options, setOptions] = useState<{ qn: string; name: string; file?: string }[]>([]); + useEffect(() => { + if (query.trim().length < 2) { + setOptions([]); + return; + } + const timer = setTimeout(async () => { + try { + const escaped = query.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const { rows } = await searchGraph(project, { name_pattern: escaped, limit: 6 }); + setOptions( + rows.map((row) => ({ qn: row.qualified_name, name: row.name, file: row.file })), + ); + } catch { + setOptions([]); + } + }, 250); + return () => clearTimeout(timer); + }, [project, query]); + + if (value) { + return ( + + + {value.split(".").slice(-1)[0]} + + + + ); + } + return ( + + setQuery(event.target.value)} + className="bg-popover border border-border/50 rounded-md px-2 py-1 text-[13px] text-foreground placeholder-foreground/30 outline-none focus:border-primary/50 w-[180px] transition-all" + /> + {options.length > 0 && ( +
+ {options.map((option) => ( + + ))} +
+ )} +
+ ); +} + +export function FlowsTab({ + project, + flowId, + onOpenFlow, + onOpenSymbol, + onOpenWiki, +}: FlowsTabProps) { + const t = useUiMessages(); + const [payload, setPayload] = useState(null); + const [detail, setDetail] = useState(null); + const [error, setError] = useState(null); + const [copied, setCopied] = useState(false); + const [traceFrom, setTraceFrom] = useState(null); + const [traceTo, setTraceTo] = useState(null); + const [traceMode, setTraceMode] = useState<"calls" | "data">("calls"); + const [trace, setTrace] = useState(null); + const [expandedEntries, setExpandedEntries] = useState>(new Set()); + const [tracing, setTracing] = useState(false); + + const runTrace = async () => { + if (!traceFrom || !traceTo) return; + setTracing(true); + setTrace(null); + try { + setTrace(await fetchTrace(project, traceFrom, traceTo, traceMode)); + } catch (err) { + setTrace({ + mode: traceMode, + max_depth: 0, + reachable: false, + error: err instanceof Error ? err.message : t.flows.traceFailed, + }); + } finally { + setTracing(false); + } + }; + + useEffect(() => { + let cancelled = false; + setPayload(null); + setError(null); + fetchFlows(project) + .then((flows) => { + if (!cancelled) setPayload(flows); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : "failed"); + }); + return () => { + cancelled = true; + }; + }, [project]); + + useEffect(() => { + let cancelled = false; + setDetail(null); + if (flowId === null) return; + fetchFlow(project, flowId) + .then((flow) => { + if (!cancelled) setDetail(flow); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [project, flowId]); + + const grouped = useMemo(() => { + const flows = payload?.flows ?? []; + return { + cross: flows.filter((flow) => flow.cross_region), + intra: flows.filter((flow) => !flow.cross_region), + }; + }, [payload]); + + /* Newest observation each — names the runtime-freshness footers. */ + const traceNewest = useMemo(() => newestObserved(trace?.path ?? []), [trace]); + const detailNewest = useMemo( + () => (detail ? newestObserved(detail.steps) : null), + [detail], + ); + + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 1500); + return () => clearTimeout(timer); + }, [copied]); + + if (error) { + return ( +
+

{error}

+
+ ); + } + if (!payload) { + return ( +
+

{t.flows.loading}

+
+ ); + } + + return ( +
+ {/* Flow list */} +
+
+

+ {t.flows.traceHeading} +

+
+ + → + + + +
+ {trace && ( +
+ {trace.error ? ( +

{trace.error}

+ ) : trace.reachable ? ( +
+

+ {t.flows.reachableIn(trace.hops ?? 0, trace.mode === "data")} +

+ {allHopsObserved(trace.path ?? []) && ( +

+ {t.observed.allObserved} +

+ )} +
+ {(trace.path ?? []).map((step, index) => ( + + {index > 0 && ( + → + )} + + {(step.guards ?? []).map((guard, gi) => ( + + {formatGuard(guard, t.why)} + + ))} + {step.observed && ( + + )} + + ))} +
+ {traceNewest && ( +

+ {t.observed.freshness(traceNewest.label)} +

+ )} +
+ ) : ( +

+ {t.flows.notReachable(trace.max_depth, trace.mode === "data", trace.explored)} +

+ )} +
+ )} +
+
+

+ {t.flows.listHeading} +

+

+ {t.flows.summary( + payload.flows.length, + payload.callable_total, + payload.candidates_dropped, + )} +

+
+ +
+ {(["cross", "intra"] as const).map((groupKey) => { + const flows = grouped[groupKey]; + if (flows.length === 0) return null; + return ( +
+

+ {groupKey === "cross" ? t.flows.acrossRegions : t.flows.withinOneRegion} +

+ {groupFlowsByEntry(flows).map((group) => { + const groupKey2 = `${groupKey}:${group.entryName}`; + const expanded = expandedEntries.has(groupKey2); + const visible = expanded ? group.flows : group.flows.slice(0, 1); + return ( +
+ {visible.map((flow) => ( + + ))} + {group.flows.length > 1 && ( + + )} +
+ ); + })} +
+ ); + })} + {payload.flows.length === 0 && ( +

+ {t.flows.noFlows} +

+ )} +
+
+
+ + {/* Flow detail */} +
+ {flowId === null || !detail ? ( +
+

+ {flowId === null ? t.flows.pickJourney : t.common.loading} +

+
+ ) : ( + +
+
+

+ {detail.entry.name} + → + {detail.terminal.name} +

+ + +
+

+ {[ + t.flows.stepsCount(detail.steps.length), + detail.sink_terminated ? t.flows.endsAtSink : t.flows.stoppedAtCap, + ...(detail.cross_region ? [t.flows.crossesRegions] : []), + ...(detail.steps_capped + ? [t.flows.branchesBeyondCap(detail.steps_capped)] + : []), + ].join(" · ")} +

+ +
+ {detail.steps.map((step, index) => ( + + ))} +
+ {detailNewest && ( +

+ {t.observed.freshness(detailNewest.label)} +

+ )} +
+
+ )} +
+
+ ); +} diff --git a/graph-ui/src/components/FlowsTab.zh.test.tsx b/graph-ui/src/components/FlowsTab.zh.test.tsx new file mode 100644 index 000000000..1d5d32692 --- /dev/null +++ b/graph-ui/src/components/FlowsTab.zh.test.tsx @@ -0,0 +1,56 @@ +/* @vitest-environment jsdom */ +/* The zh locale reaches the tab exactly as it does in production: the + * /api/ui-config override that detectLanguage honours (see i18n.test.ts). + * The i18n module caches the resolved language per module registry, so the + * zh render lives in its own file — FlowsTab.observed.test.tsx stays English. */ +import "@testing-library/jest-dom/vitest"; +import { render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FlowsTab } from "./FlowsTab"; + +describe("FlowsTab (zh locale)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("renders the chrome — headings, pickers, counts, empty states — in Chinese", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockImplementation((url: string) => { + if (String(url).includes("/api/ui-config")) { + return Promise.resolve({ json: () => Promise.resolve({ lang: "zh" }) }); + } + /* /api/flows — no journeys, so the empty states render too. */ + return Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ flows: [], callable_total: 1234, candidates_dropped: 5 }), + }); + }), + ); + render( + {}} + onOpenSymbol={() => {}} + onOpenWiki={() => {}} + />, + ); + + expect(await screen.findByText("追踪 A → B")).toBeInTheDocument(); + expect(screen.getByText("流程——入口 → 终点")).toBeInTheDocument(); + /* The counts line keeps en-US number formatting; the dropped tail rides along. */ + expect( + screen.getByText("1,234 个可调用符号中走出 0 条调用链路 · 5 个候选未遍历"), + ).toBeInTheDocument(); + expect( + screen.getByText("未检测到流程——项目可能没有明确的入口点。"), + ).toBeInTheDocument(); + expect(screen.getByText("在左侧选择一条调用链路")).toBeInTheDocument(); + /* Placeholders and the mode toggle come from the zh catalog too. */ + expect(screen.getByPlaceholderText("起点符号…")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("终点符号…")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "控制流" })).toBeInTheDocument(); + }); +}); diff --git a/graph-ui/src/components/GraphLoader.tsx b/graph-ui/src/components/GraphLoader.tsx index 0d9154b17..46e6854ce 100644 --- a/graph-ui/src/components/GraphLoader.tsx +++ b/graph-ui/src/components/GraphLoader.tsx @@ -68,7 +68,7 @@ export function GraphLoader({ nodeBudget, progress }: GraphLoaderProps) { /> ))} -

+

{receiving ? "Receiving graph" : "Computing layout"} — up to{" "} {nodeBudget.toLocaleString("en-US")} nodes

diff --git a/graph-ui/src/components/GraphScene.tsx b/graph-ui/src/components/GraphScene.tsx index cad93d926..46eee8005 100644 --- a/graph-ui/src/components/GraphScene.tsx +++ b/graph-ui/src/components/GraphScene.tsx @@ -5,6 +5,7 @@ import type { OrbitControls as OrbitControlsImpl } from "three-stdlib"; import { EffectComposer, Bloom } from "@react-three/postprocessing"; import * as THREE from "three"; import { NodeCloud } from "./NodeCloud"; +import { HaloLayer } from "./HaloLayer"; import { EdgeLines } from "./EdgeLines"; import { NodeLabels } from "./NodeLabels"; import { NodeTooltip } from "./NodeTooltip"; @@ -108,9 +109,98 @@ function IdleAutoRotate({ return null; } +/* ── Semantic-zoom approach watcher ────────────────────────── + * Auto-open fires only when ALL guards pass (the anti-annoyance set from + * the Understand-Anything study): (1) the movement is user-driven — a + * wheel/pinch happened within the last 600 ms, so programmatic fly-tos + * never trigger it; (2) the camera is APPROACHING (distance decreasing); + * (3) debounced — the same node fires once, with a 1.5 s global grace + * period so backing out doesn't bounce straight back in. */ + +function ApproachWatcher({ + nodes, + onApproachNode, +}: { + nodes: GraphNode[]; + onApproachNode: (node: GraphNode) => void; +}) { + const { camera, gl } = useThree(); + const lastWheel = useRef(0); + const lastDist = useRef(Infinity); + const lastFiredAt = useRef(0); + const lastCheck = useRef(0); + + useEffect(() => { + const canvas = gl.domElement; + const onWheel = () => { + lastWheel.current = performance.now(); + }; + canvas.addEventListener("wheel", onWheel, { passive: true }); + return () => canvas.removeEventListener("wheel", onWheel); + }, [gl]); + + useFrame(() => { + const now = performance.now(); + if (now - lastCheck.current < 200) return; + lastCheck.current = now; + if (now - lastWheel.current > 600) return; /* guard 1: user-driven */ + if (now - lastFiredAt.current < 1500) return; /* guard 3: grace */ + + let nearest: GraphNode | null = null; + let nearestDist = Infinity; + for (const node of nodes) { + const dx = camera.position.x - node.x; + const dy = camera.position.y - node.y; + const dz = camera.position.z - node.z; + const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); + if (dist < nearestDist) { + nearestDist = dist; + nearest = node; + } + } + const approaching = nearestDist < lastDist.current - 0.5; /* guard 2 */ + lastDist.current = nearestDist; + if (!nearest || !approaching) return; + if (nearestDist > Math.max(nearest.size * 2.2, 30)) return; + lastFiredAt.current = now; + onApproachNode(nearest); + }); + return null; +} + +/* ── Orbit-target reporter (throttled) ───────────────────── */ + +function ViewTargetReporter({ + controlsRef, + onViewTarget, +}: { + controlsRef: React.RefObject; + onViewTarget: (x: number, y: number) => void; +}) { + const last = useRef({ x: NaN, y: NaN, at: 0 }); + useFrame(() => { + const controls = controlsRef.current; + if (!controls) return; + const now = performance.now(); + if (now - last.current.at < 200) return; + const { x, y } = controls.target; + if ( + Math.abs(x - last.current.x) < 1 && + Math.abs(y - last.current.y) < 1 && + !Number.isNaN(last.current.x) + ) + return; + last.current = { x, y, at: now }; + onViewTarget(x, y); + }); + return null; +} + /* ── Main scene ─────────────────────────────────────────── */ interface GraphSceneProps { + /* False pauses the render loop (hidden-but-mounted tab). */ + active?: boolean; data: GraphData; /* Missed skeleton (#963): pre-offset, pre-painted white nodes + edges of * the not-fully-indexed files, rendered as a ghost cluster beside the @@ -124,11 +214,23 @@ interface GraphSceneProps { /* Fired when a click hits empty space (no node). Used to fly back to the * overview after focusing the missed skeleton. */ onBackgroundClick?: () => void; + /* Custom hover card (the region scene shows region facts, not node ones). */ + renderTooltip?: (node: GraphNode) => React.ReactNode; + /* Landmark halos on the top hubs (off in the region scene, where every + * body is already a landmark). */ + landmarks?: boolean; + /* Reports the orbit target (throttled) so the 2D minimap can show where + * the camera is looking. */ + onViewTarget?: (x: number, y: number) => void; + /* Semantic zoom: fired once when the USER zooms close onto a node (the + * region scene opens that region). Guards live in ApproachWatcher. */ + onApproachNode?: (node: GraphNode) => void; } export type { CameraTarget }; export function GraphScene({ + active = true, data, missed = null, highlightedIds, @@ -137,6 +239,10 @@ export function GraphScene({ display = DEFAULT_DISPLAY_SETTINGS, onNodeClick, onBackgroundClick, + renderTooltip, + landmarks = false, + onViewTarget, + onApproachNode, }: GraphSceneProps) { const [hovered, setHovered] = useState(null); const controlsRef = useRef(null); @@ -146,14 +252,26 @@ export function GraphScene({ * NodeCloud applies `nodeBoost` directly (no internal density scaling), * whereas EdgeLines scales by edge density itself — so it receives only the * user edge-brightness multiplier to avoid double-applying. */ + /* Constrained camera: orbit bounds follow the scene's actual extent so + * you can neither clip through the cloud nor zoom out into the void. */ + const sceneRadius = (() => { + let max = 100; + for (const node of data.nodes) { + const r = Math.sqrt(node.x * node.x + node.y * node.y + node.z * node.z); + if (r > max) max = r; + } + return max; + })(); + const nodeBoost = nodeBoostScale(data.nodes.length) * display.nodeGlow; const bloomIntensity = BASE_BLOOM_INTENSITY * bloomIntensityScale(data.nodes.length) * display.bloom; return ( - + {showLabels && } + {landmarks && } {/* Missed skeleton (#963): white ghost of the not-fully-indexed files. * Clicks route through the same handler — GraphTab re-centers the @@ -251,9 +370,16 @@ export function GraphScene({ ); })} - {hovered && } + {hovered && + (renderTooltip ? renderTooltip(hovered) : )} + {onViewTarget && ( + + )} + {onApproachNode && ( + + )} @@ -272,8 +398,8 @@ export function GraphScene({ dampingFactor={0.08} rotateSpeed={0.5} zoomSpeed={1.5} - minDistance={10} - maxDistance={50000} + minDistance={Math.max(5, sceneRadius * 0.02)} + maxDistance={sceneRadius * 4} autoRotateSpeed={0.4} /> diff --git a/graph-ui/src/components/GraphTab.deadcode.test.tsx b/graph-ui/src/components/GraphTab.deadcode.test.tsx index 43f880f30..285600188 100644 --- a/graph-ui/src/components/GraphTab.deadcode.test.tsx +++ b/graph-ui/src/components/GraphTab.deadcode.test.tsx @@ -30,6 +30,20 @@ function mockLayoutFetch(data: GraphData) { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.startsWith("/api/layout")) { + /* The region probe answers first: a small project — no region scene. */ + if (url.includes("level=regions")) { + return new Response( + JSON.stringify({ + level: "regions", + method: "folders", + total_nodes: data.total_nodes, + unmapped_nodes: 0, + regions: [], + edges: [], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } return new Response(JSON.stringify(data), { status: 200, headers: { "Content-Type": "application/json" }, diff --git a/graph-ui/src/components/GraphTab.filters.test.tsx b/graph-ui/src/components/GraphTab.filters.test.tsx index 9ee22f210..c1ab5a167 100644 --- a/graph-ui/src/components/GraphTab.filters.test.tsx +++ b/graph-ui/src/components/GraphTab.filters.test.tsx @@ -24,6 +24,20 @@ function mockLayoutFetch(data: GraphData) { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url.startsWith("/api/layout")) { + /* The region probe answers first: a small project — no region scene. */ + if (url.includes("level=regions")) { + return new Response( + JSON.stringify({ + level: "regions", + method: "folders", + total_nodes: data.total_nodes, + unmapped_nodes: 0, + regions: [], + edges: [], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } return new Response(JSON.stringify(data), { status: 200, headers: { "Content-Type": "application/json" }, @@ -48,8 +62,11 @@ describe("GraphTab filters", () => { /* Wait for the layout to load — the filter panel header appears. */ expect(await screen.findByText("Filters")).toBeInTheDocument(); - /* Disable every filter via the "None" shortcut. */ - fireEvent.click(screen.getByRole("button", { name: "None" })); + /* Everything starts enabled — the tri-state master reads checked. + One click cycles all-on → all-off. */ + const master = screen.getByRole("checkbox", { name: "All filters" }); + expect(master).toHaveAttribute("aria-checked", "true"); + fireEvent.click(master); /* The graph area reports that everything is filtered out… */ expect(screen.getByText("All nodes filtered out")).toBeInTheDocument(); @@ -57,7 +74,9 @@ describe("GraphTab filters", () => { /* …but the filter sidebar must stay so the user can re-enable filters instead of being forced to reset everything. */ expect(screen.getByText("Filters")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "All" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "None" })).toBeInTheDocument(); + expect(screen.getByRole("checkbox", { name: "All filters" })).toHaveAttribute( + "aria-checked", + "false", + ); }); }); diff --git a/graph-ui/src/components/GraphTab.regions.test.tsx b/graph-ui/src/components/GraphTab.regions.test.tsx new file mode 100644 index 000000000..6a77b8186 --- /dev/null +++ b/graph-ui/src/components/GraphTab.regions.test.tsx @@ -0,0 +1,156 @@ +/* @vitest-environment jsdom */ +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { GraphTab } from "./GraphTab"; +import type { GraphData, RegionsPayload } from "../lib/types"; + +vi.mock("./GraphScene", () => ({ + GraphScene: () => null, + computeCameraTarget: () => null, +})); + +const REGIONS: RegionsPayload = { + level: "regions", + method: "leiden+folders", + total_nodes: 48000, + unmapped_nodes: 12, + regions: [ + { + id: 0, + name: "src/alpha", + hub: "alpha_one", + why: "call community: 2 files, 100% under src/alpha", + files: 2, + members: 24000, + cohesion: 0.8, + top_nodes: ["alpha_one", "alpha_two"], + x: 0, + y: 0, + z: 0, + size: 60, + color: "#22aa88", + }, + { + id: 1, + name: "src/beta", + hub: "beta_one", + why: "call community: 2 files, 100% under src/beta", + files: 2, + members: 24000, + cohesion: 0.7, + top_nodes: ["beta_one"], + x: 100, + y: 0, + z: 0, + size: 60, + color: "#8822aa", + }, + ], + edges: [{ source: 0, target: 1, weight: 4 }], +}; + +const SCOPED: GraphData = { + nodes: [ + { + id: 7, x: 0, y: 0, z: 0, label: "Function", name: "alpha_one", + file_path: "src/alpha/a1.c", size: 1, color: "#fff", + }, + ], + edges: [], + total_nodes: 1, +}; + +function mockAtlasFetch() { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.startsWith("/api/layout")) { + if (url.includes("level=regions")) { + return new Response(JSON.stringify(REGIONS), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify(SCOPED), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response("{}", { status: 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +describe("GraphTab region level", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("opens large projects on the region scene, not the full galaxy", async () => { + const fetchMock = mockAtlasFetch(); + render(); + + /* The region list is the left panel; both regions are listed. */ + expect( + (await screen.findAllByText("src/alpha")).some((el) => el.tagName === "SPAN"), + ).toBe(true); + expect( + screen.getAllByText("src/beta").some((el) => el.tagName === "SPAN"), + ).toBe(true); + expect(screen.getByRole("button", { name: "Load full galaxy" })).toBeInTheDocument(); + + /* No full layout was fetched — only the region probe. */ + const urls = fetchMock.mock.calls.map((call) => String(call[0])); + expect(urls.some((u) => u.includes("level=regions"))).toBe(true); + expect(urls.some((u) => u.includes("max_nodes"))).toBe(false); + }); + + it("selects a region into its panel and opens it as a scoped layout", async () => { + const fetchMock = mockAtlasFetch(); + render(); + + fireEvent.click( + (await screen.findAllByText("src/alpha")).find((el) => el.tagName === "SPAN")!, + ); + /* The region panel shows provenance and the door in. */ + expect( + screen.getByText("call community: 2 files, 100% under src/alpha"), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Open region →" })); + + /* The scoped node arrives and the breadcrumb back to regions exists. */ + expect(await screen.findByText("region: src/alpha")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "‹ Regions" })).toBeInTheDocument(); + const urls = fetchMock.mock.calls.map((call) => String(call[0])); + expect(urls.some((u) => u.includes("scope=region%3A0"))).toBe(true); + }); + + it("restores an opened region from the URL (?region=)", async () => { + const fetchMock = mockAtlasFetch(); + render(); + + expect(await screen.findByText("region: src/beta")).toBeInTheDocument(); + const urls = fetchMock.mock.calls.map((call) => String(call[0])); + expect(urls.some((u) => u.includes("scope=region%3A1"))).toBe(true); + }); + + it("reports selected nodes to the route for deep links", async () => { + mockAtlasFetch(); + const onRouteChange = vi.fn(); + render( + , + ); + expect(await screen.findByText("region: src/alpha")).toBeInTheDocument(); + + /* Select the only node via the sidebar symbol row (#1197). */ + fireEvent.click( + screen.getAllByText("src/alpha").find((el) => el.tagName === "SPAN")!, + ); /* expand the tree */ + fireEvent.click(await screen.findByText("alpha_one")); + expect(onRouteChange).toHaveBeenCalledWith("7", "0"); + /* The detail panel now shows the node, not a stale or empty panel. */ + expect(screen.getByRole("heading", { name: "alpha_one" })).toBeInTheDocument(); + }); +}); diff --git a/graph-ui/src/components/GraphTab.selection.test.tsx b/graph-ui/src/components/GraphTab.selection.test.tsx deleted file mode 100644 index 2e1444311..000000000 --- a/graph-ui/src/components/GraphTab.selection.test.tsx +++ /dev/null @@ -1,168 +0,0 @@ -/* @vitest-environment jsdom */ -import "@testing-library/jest-dom/vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { GraphTab } from "./GraphTab"; -import type { GraphData } from "../lib/types"; - -/* GraphScene renders a WebGL which jsdom can't run — stub it out. */ -vi.mock("./GraphScene", () => ({ - GraphScene: () => null, - computeCameraTarget: () => null, -})); - -const SELECTION_DATA: GraphData = { - nodes: [ - /* Multi-node directory: src/ has foo.ts + bar.ts */ - { - id: 1, - x: 0, y: 0, z: 0, - label: "Function", - name: "foo", - file_path: "src/foo.ts", - size: 1, - color: "#ff0000", - }, - { - id: 2, - x: 1, y: 0, z: 0, - label: "Class", - name: "bar", - file_path: "src/bar.ts", - size: 1, - color: "#00ff00", - }, - /* One-node directory: utils/ has only helper.ts */ - { - id: 3, - x: 2, y: 0, z: 0, - label: "Function", - name: "helper", - file_path: "utils/helper.ts", - size: 1, - color: "#0000ff", - }, - /* Node without file_path — reachable only via search */ - { - id: 4, - x: 3, y: 0, z: 0, - label: "Class", - name: "orphanFn", - size: 1, - color: "#ffff00", - }, - ], - edges: [ - { source: 1, target: 2, type: "CALLS" }, - { source: 1, target: 3, type: "CALLS" }, - ], - total_nodes: 4, -}; - -function mockFetch(data: GraphData) { - const fetchMock = vi.fn(async (input: RequestInfo | URL) => { - const url = String(input); - if (url.startsWith("/api/layout")) { - return new Response(JSON.stringify(data), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - /* /api/ui-config, /api/repo-info → default empty */ - return new Response("{}", { status: 200 }); - }); - vi.stubGlobal("fetch", fetchMock); - return fetchMock; -} - -describe("GraphTab selection", () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("opens the detail panel when a leaf node is clicked in the file tree", async () => { - mockFetch(SELECTION_DATA); - render(); - - /* Wait for the layout to load. */ - expect(await screen.findByText("Filters")).toBeInTheDocument(); - - /* Expand the "src" directory. */ - fireEvent.click(screen.getByRole("button", { name: /src/ })); - - /* Click the "foo" file node inside. */ - fireEvent.click(screen.getByRole("button", { name: /^foo/ })); - - /* The detail panel must render the node name as a heading. */ - expect(screen.getByRole("heading", { name: "foo" })).toBeInTheDocument(); - }); - - it("does NOT open the detail panel for a directory containing exactly one node", async () => { - mockFetch(SELECTION_DATA); - render(); - - expect(await screen.findByText("Filters")).toBeInTheDocument(); - - /* Click the "utils" directory — it contains exactly 1 node (helper), - * but Sidebar passes no GraphNode arg for directories. */ - fireEvent.click(screen.getByRole("button", { name: /utils/ })); - - /* The detail panel must NOT open just because nodeIds.size === 1. */ - expect(screen.queryByRole("heading", { name: "helper" })).not.toBeInTheDocument(); - - /* The directory IS selected though — HUD shows count. */ - expect(screen.getByText("1 selected")).toBeInTheDocument(); - }); - - it("shows the detail panel for a search result that has no file_path", async () => { - mockFetch(SELECTION_DATA); - render(); - - expect(await screen.findByText("Filters")).toBeInTheDocument(); - - /* Search for the orphan node. */ - const searchInput = screen.getByPlaceholderText("Search..."); - fireEvent.change(searchInput, { target: { value: "orphan" } }); - - /* Click the search result — Sidebar passes "" for path (no file_path) - * but still passes the GraphNode as the third argument. */ - fireEvent.click(screen.getByRole("button", { name: /orphanFn/ })); - - /* The detail panel must still appear. */ - expect(screen.getByRole("heading", { name: "orphanFn" })).toBeInTheDocument(); - }); - - it("clears selection via the Sidebar 'Clear selection' button", async () => { - mockFetch(SELECTION_DATA); - render(); - - expect(await screen.findByText("Filters")).toBeInTheDocument(); - - /* Select a leaf node first. */ - fireEvent.click(screen.getByRole("button", { name: /src/ })); - fireEvent.click(screen.getByRole("button", { name: /^foo/ })); - expect(screen.getByRole("heading", { name: "foo" })).toBeInTheDocument(); - - /* Click "Clear selection" — there are two buttons with this label - * (Sidebar footer + HUD). Both clear, so clicking either is fine. */ - const clearButtons = screen.getAllByRole("button", { name: "Clear selection" }); - fireEvent.click(clearButtons[0]); - - /* Detail panel must disappear. */ - expect(screen.queryByRole("heading", { name: "foo" })).not.toBeInTheDocument(); - }); - - it("highlights the selected node and its direct neighbors", async () => { - mockFetch(SELECTION_DATA); - render(); - - expect(await screen.findByText("Filters")).toBeInTheDocument(); - - /* Select foo (id:1), which has edges to bar (id:2) and helper (id:3). */ - fireEvent.click(screen.getByRole("button", { name: /src/ })); - fireEvent.click(screen.getByRole("button", { name: /^foo/ })); - - /* foo + bar + helper = 3 nodes highlighted. */ - expect(screen.getByText("3 selected")).toBeInTheDocument(); - }); -}); diff --git a/graph-ui/src/components/GraphTab.tsx b/graph-ui/src/components/GraphTab.tsx index 2f3a17e14..7f430386f 100644 --- a/graph-ui/src/components/GraphTab.tsx +++ b/graph-ui/src/components/GraphTab.tsx @@ -1,12 +1,19 @@ -import { useEffect, useState, useCallback, useMemo } from "react"; +import { useEffect, useState, useCallback, useMemo, useRef } from "react"; import { Button } from "@/components/ui/button"; import { useGraphData, + fetchRegions, clampNodeBudget, GRAPH_RENDER_NODE_LIMIT, GRAPH_NODE_BUDGET_STEP, GRAPH_NODE_BUDGET_MAX, } from "../hooks/useGraphData"; +import { regionsToGraphData, regionsViewWorthwhile, disambiguateRegionNames, isTestRegion } from "../lib/regions"; +import { fetchScent } from "../lib/atlas"; +import { RegionTooltip } from "./RegionTooltip"; +import { NodeTooltip } from "./NodeTooltip"; +import { Minimap } from "./Minimap"; +import { RegionPanel } from "./RegionPanel"; import { GraphLoader } from "./GraphLoader"; import { DisplaySettingsMenu } from "./DisplaySettingsMenu"; import { @@ -25,9 +32,43 @@ import { NodeDetailPanel } from "./NodeDetailPanel"; import { MissedCallout } from "./MissedCallout"; import { ResizeHandle } from "./ResizeHandle"; import { ErrorBoundary } from "./ErrorBoundary"; -import type { GraphNode, GraphData, RepoInfo } from "../lib/types"; +import type { GraphNode, GraphData, Region, RegionsPayload, RepoInfo } from "../lib/types"; import { colorForStatus } from "../lib/colors"; +/* One-time in-place orientation hint for the region scene. Dismissing + * persists; navigating away without dismissing does not (skip != dismiss). */ +function GalaxyHint() { + const [dismissed, setDismissed] = useState(() => { + try { + return localStorage.getItem("cbm-hint-galaxy") === "1"; + } catch { + return true; + } + }); + if (dismissed) return null; + return ( +
+

+ Each sphere is a region of related code. Double-click one to enter it, + type above to light up matches, and use ⌂ on the map to fit everything. +

+ +
+ ); +} + /* Persist panel widths */ function loadWidth(key: string, fallback: number): number { try { @@ -56,20 +97,47 @@ function saveNodeBudget(project: string, value: number) { } interface GraphTabProps { + /* False while the tab is hidden-but-mounted: the render loop pauses. */ + active?: boolean; project: string | null; + /* Deep-link state from the URL (?node=®ion=) and the reporter back. */ + routeNode?: string | null; + routeRegion?: string | null; + onRouteChange?: (node: string | null, region: string | null) => void; } +/* The galaxy's level-of-detail state: the region scene (coarsest), one + * opened region (full detail, scoped), or the classic full galaxy. */ +type AtlasView = + | { kind: "deciding" } + | { kind: "regions" } + | { kind: "region"; region: Region } + | { kind: "full" }; + export function formatGraphLimitNotice(data: GraphData | null): string | null { if (!data || data.total_nodes <= data.nodes.length) return null; return `Showing ${data.nodes.length.toLocaleString("en-US")} of ${data.total_nodes.toLocaleString("en-US")} nodes (${data.edges.length.toLocaleString("en-US")} edges). Raise the node budget or use filters.`; } -export function GraphTab({ project }: GraphTabProps) { +export function GraphTab({ + active = true, + project, + routeNode = null, + routeRegion = null, + onRouteChange, +}: GraphTabProps) { const { data, loading, error, progress, fetchOverview } = useGraphData(); + const [view, setView] = useState({ kind: "deciding" }); + const viewRef = useRef(view); + viewRef.current = view; + const [regionsPayload, setRegionsPayload] = useState(null); + const [regionsError, setRegionsError] = useState(null); + const [selectedRegion, setSelectedRegion] = useState(null); const [highlightedIds, setHighlightedIds] = useState | null>(null); const [selectedPath, setSelectedPath] = useState(null); const [selectedNode, setSelectedNode] = useState(null); const [cameraTarget, setCameraTarget] = useState(null); + const [viewTarget, setViewTarget] = useState<{ x: number; y: number } | null>(null); const [repoInfo, setRepoInfo] = useState(null); const [showLabels, setShowLabels] = useState(true); const [display, setDisplay] = useState(() => @@ -188,15 +256,66 @@ export function GraphTab({ project }: GraphTabProps) { } }, [project]); - /* …and fetch only once budget and project agree (one fetch per change). */ + /* …and decide the level of detail once budget and project agree. The + * region scene is the default above REGIONS_MIN_TOTAL_NODES; small + * projects load the full galaxy directly. An open region survives budget + * changes (the ref carries the current view across this effect). */ useEffect(() => { - if (project && budget.project === project) { + if (!project || budget.project !== project) return; + let cancelled = false; + setHighlightedIds(null); + setSelectedPath(null); + const current = viewRef.current; + if (current.kind === "region") { + fetchOverview(project, budget.value, "code", `region:${current.region.id}`); + return; + } + if (current.kind === "full") { fetchOverview(project, budget.value); - setHighlightedIds(null); - setSelectedPath(null); + return; } + setView({ kind: "deciding" }); + setRegionsError(null); + (async () => { + try { + const payload = await fetchRegions(project); + if (cancelled) return; + setRegionsPayload(payload); + const restored = routeRegion + ? payload.regions.find((r) => String(r.id) === routeRegion) + : null; + if (restored) { + setView({ kind: "region", region: restored }); + fetchOverview(project, budget.value, "code", `region:${restored.id}`); + } else if (regionsViewWorthwhile(payload)) { + setView({ kind: "regions" }); + } else { + setView({ kind: "full" }); + fetchOverview(project, budget.value); + } + } catch (e) { + if (cancelled) return; + /* Older servers have no region level — fall back to the galaxy. */ + setRegionsError(e instanceof Error ? e.message : "regions unavailable"); + setView({ kind: "full" }); + fetchOverview(project, budget.value); + } + })(); + return () => { + cancelled = true; + }; + /* routeRegion is a first-load restore input, not a live dependency. */ + // eslint-disable-next-line react-hooks/exhaustive-deps }, [project, budget, fetchOverview]); + /* Deep link: once data is loaded, restore the node the URL names. */ + useEffect(() => { + if (!routeNode || !data || selectedNode) return; + const target = data.nodes.find((n) => String(n.id) === routeNode); + if (target) handleNodeClickRef.current?.(target); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [routeNode, data]); + /* Missed skeleton: offset into place and paint white — a ghost of the * files the graph could not fully cover, sitting beside the galaxy. */ const missedSkeleton = useMemo(() => { @@ -219,13 +338,15 @@ export function GraphTab({ project }: GraphTabProps) { return computeCameraTarget(all, new Set(all.map((n) => n.id))); }, [data, missedSkeleton]); - /* With a skeleton beside the galaxy, auto-frame BOTH clusters on load so - * the side-by-side composition is visible without manual zooming. */ + /* Auto-frame the scene on load (skeleton or not): the fixed default + * camera lands inside larger clouds. Deep-linked nodes keep their own + * focus fly-to instead. */ useEffect(() => { - if (missedSkeleton && overviewTarget) { + if (overviewTarget && !routeNode) { setCameraTarget(overviewTarget); } - }, [missedSkeleton, overviewTarget]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [overviewTarget]); /* Clicking empty space while the skeleton has focus flies back to the * overview (the galaxy may be entirely off-screen at that point, so there @@ -257,6 +378,44 @@ export function GraphTab({ project }: GraphTabProps) { }; }, [project]); + /* Search scent: a query is answerable from the coarsest view — matched + * regions brighten and carry hit counts before any drill. */ + const [scentQuery, setScentQuery] = useState(""); + const [scent, setScent] = useState<{ + total: number; + unmapped: number; + counts: Map; + } | null>(null); + useEffect(() => { + setScentQuery(""); + setScent(null); + }, [project]); + useEffect(() => { + const q = scentQuery.trim(); + if (!project || q.length < 2) { + setScent(null); + return; + } + let cancelled = false; + const timer = setTimeout(async () => { + try { + const payload = await fetchScent(project, q); + if (cancelled) return; + setScent({ + total: payload.total, + unmapped: payload.unmapped, + counts: new Map(payload.regions.map((row) => [row.region, row.count])), + }); + } catch { + if (!cancelled) setScent(null); + } + }, 300); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [project, scentQuery]); + const handleNodeClick = useCallback( (node: GraphNode) => { if (!filteredData) return; @@ -284,9 +443,55 @@ export function GraphTab({ project }: GraphTabProps) { setHighlightedIds(connectedIds); setSelectedPath(node.file_path ?? null); setCameraTarget(computeCameraTarget(filteredData.nodes, connectedIds)); + onRouteChange?.( + String(node.id), + viewRef.current.kind === "region" ? String(viewRef.current.region.id) : null, + ); }, - [filteredData, missedSkeleton], + [filteredData, missedSkeleton, onRouteChange], ); + const handleNodeClickRef = useRef(null); + handleNodeClickRef.current = handleNodeClick; + + /* Region scene interactions. */ + const regionGraph = useMemo( + () => (regionsPayload ? regionsToGraphData(regionsPayload) : null), + [regionsPayload], + ); + const displayRegions = useMemo( + () => (regionsPayload ? disambiguateRegionNames(regionsPayload.regions) : []), + [regionsPayload], + ); + const openRegion = useCallback( + (region: Region) => { + if (!project) return; + setSelectedRegion(null); + setSelectedNode(null); + setHighlightedIds(null); + setSelectedPath(null); + setCameraTarget(null); + setView({ kind: "region", region }); + fetchOverview(project, budget.value, "code", `region:${region.id}`); + onRouteChange?.(null, String(region.id)); + }, + [project, budget.value, fetchOverview, onRouteChange], + ); + const backToRegions = useCallback(() => { + setSelectedRegion(null); + setSelectedNode(null); + setHighlightedIds(null); + setSelectedPath(null); + setCameraTarget(null); + setView({ kind: "regions" }); + onRouteChange?.(null, null); + }, [onRouteChange]); + const loadFullGalaxy = useCallback(() => { + if (!project) return; + setSelectedRegion(null); + setView({ kind: "full" }); + fetchOverview(project, budget.value); + onRouteChange?.(null, null); + }, [project, budget.value, fetchOverview, onRouteChange]); const handleNavigateToNode = useCallback( (node: GraphNode) => { @@ -357,13 +562,202 @@ export function GraphTab({ project }: GraphTabProps) { if (!project) { return (
-

+

Select a project from the Projects tab

); } + if (view.kind === "deciding") { + return ( +
+

Mapping regions…

+
+ ); + } + + /* ── Region scene: the coarsest level of detail ──────────────── */ + if (view.kind === "regions" && regionGraph) { + const selected = selectedRegion; + return ( +
+
+
+ + Regions + +

+ {regionsPayload?.method === "leiden+folders" + ? "call communities + folder groups" + : "folder groups"} +

+ setScentQuery(e.target.value)} + placeholder="find a symbol…" + className="mt-2 w-full bg-popover border border-border/50 rounded-md px-2 py-1 text-[13px] text-foreground placeholder-foreground/30 outline-none focus:border-primary/50 transition-all" + /> + {scent && ( +

+ {scent.total.toLocaleString("en-US")} match + {scent.total === 1 ? "" : "es"} + {scent.unmapped > 0 && ` · ${scent.unmapped} outside regions`} +

+ )} +
+
+ {[...displayRegions] + .sort((a, b) => Number(isTestRegion(a.name)) - Number(isTestRegion(b.name))) + .map((region) => ( + + ))} +
+
+ { + setLeftWidth((w) => { + const nw = Math.max(150, Math.min(500, w + d)); + saveWidth("cbm-left-w", nw); + return nw; + }); + }} + /> + +
+ + 0 + ? new Set(scent.counts.keys()) + : selected + ? new Set([selected.id]) + : null + } + cameraTarget={cameraTarget} + showLabels={true} + display={display} + onNodeClick={(node) => { + const region = displayRegions.find((r) => r.id === node.id); + if (region) { + setSelectedRegion(region); + setCameraTarget( + computeCameraTarget(regionGraph.nodes, new Set([node.id])), + ); + } + }} + onBackgroundClick={() => setSelectedRegion(null)} + renderTooltip={(node) => { + const region = displayRegions.find((r) => r.id === node.id); + return region ? ( + + ) : ( + + ); + }} + onViewTarget={(x, y) => setViewTarget({ x, y })} + onApproachNode={(node) => { + const region = displayRegions.find((r) => r.id === node.id); + if (region) openRegion(region); + }} + /> + + + + setCameraTarget( + computeCameraTarget( + regionGraph.nodes, + new Set(regionGraph.nodes.map((n) => n.id)), + ), + ) + } + /> + +
+

+ {displayRegions.length} regions /{" "} + {regionsPayload?.total_nodes.toLocaleString("en-US")} nodes +

+

+ double-click a region — or just zoom into it — to open it +

+
+ + + +
+ + +
+
+ + {selected && ( + <> + { + setRightWidth((w) => { + const nw = Math.max(200, Math.min(500, w + d)); + saveWidth("cbm-right-w", nw); + return nw; + }); + }} + /> +
+ setSelectedRegion(null)} + /> +
+ + )} +
+ ); + } + if (loading) { return (
@@ -391,7 +785,7 @@ export function GraphTab({ project }: GraphTabProps) { if (!data || !filteredData || data.nodes.length === 0) { return (
-

No nodes in this project

+

No nodes in this project

); } @@ -400,7 +794,7 @@ export function GraphTab({ project }: GraphTabProps) {
{/* Left sidebar — resizable */}
@@ -447,7 +842,7 @@ export function GraphTab({ project }: GraphTabProps) { {filteredData.nodes.length === 0 ? (
-

All nodes filtered out

+

All nodes filtered out

@@ -457,6 +852,7 @@ export function GraphTab({ project }: GraphTabProps) { <> setViewTarget({ x, y })} /> + + setCameraTarget( + computeCameraTarget( + filteredData.nodes, + new Set(filteredData.nodes.map((n) => n.id)), + ), + ) + } + /> + {/* HUD */} -
+
+ {regionsError && ( +

regions unavailable: {regionsError}

+ )} + {view.kind === "region" && ( +

region: {view.region.name}

+ )}

- {filteredData.nodes.length.toLocaleString()} nodes /{" "} - {filteredData.edges.length.toLocaleString()} edges + {filteredData.nodes.length.toLocaleString("en-US")} nodes /{" "} + {filteredData.edges.length.toLocaleString("en-US")} edges

{data.nodes.length > filteredData.nodes.length && ( -

- filtered from {data.nodes.length.toLocaleString()} +

+ filtered from {data.nodes.length.toLocaleString("en-US")}

)} {limitNotice && ( @@ -490,6 +910,11 @@ export function GraphTab({ project }: GraphTabProps) {
+ {view.kind === "region" && ( + + )} {highlightedIds && ( + + + ); +} diff --git a/graph-ui/src/components/Minimap.tsx b/graph-ui/src/components/Minimap.tsx new file mode 100644 index 000000000..94f4e6807 --- /dev/null +++ b/graph-ui/src/components/Minimap.tsx @@ -0,0 +1,194 @@ +import { useMemo, useState } from "react"; +import type { Region } from "../lib/types"; + +/* The 2D synced panel of the orientation triad: every region as a colored + * dot on a fixed top-down projection, so the 3D view always has a stable + * "you are here" companion. Click a dot to open that region. */ + +export interface MinimapDot { + id: number; + cx: number; + cy: number; + r: number; + color: string; + name: string; +} + +/* Project one world-space point with the same bounds the dots use. */ +export function minimapProject( + regions: Region[], + width: number, + height: number, + point: { x: number; y: number }, + pad = 12, +): { cx: number; cy: number } | null { + if (regions.length === 0) return null; + let minX = Infinity, + maxX = -Infinity, + minY = Infinity, + maxY = -Infinity; + for (const region of regions) { + minX = Math.min(minX, region.x); + maxX = Math.max(maxX, region.x); + minY = Math.min(minY, region.y); + maxY = Math.max(maxY, region.y); + } + const spanX = maxX - minX || 1; + const spanY = maxY - minY || 1; + const cx = pad + ((point.x - minX) / spanX) * (width - pad * 2); + const cy = pad + ((point.y - minY) / spanY) * (height - pad * 2); + if (cx < -pad || cx > width + pad || cy < -pad || cy > height + pad) return null; + return { cx, cy }; +} + +export function minimapLayout( + regions: Region[], + width: number, + height: number, + pad = 12, +): MinimapDot[] { + if (regions.length === 0) return []; + let minX = Infinity, + maxX = -Infinity, + minY = Infinity, + maxY = -Infinity; + for (const region of regions) { + minX = Math.min(minX, region.x); + maxX = Math.max(maxX, region.x); + minY = Math.min(minY, region.y); + maxY = Math.max(maxY, region.y); + } + const spanX = maxX - minX || 1; + const spanY = maxY - minY || 1; + const maxMembers = Math.max(...regions.map((region) => region.members), 1); + return regions.map((region) => ({ + id: region.id, + cx: pad + ((region.x - minX) / spanX) * (width - pad * 2), + cy: pad + ((region.y - minY) / spanY) * (height - pad * 2), + r: 3 + 7 * Math.sqrt(region.members / maxMembers), + color: region.color, + name: region.name, + })); +} + +const W = 190; +const H = 150; + +export function Minimap({ + regions, + openRegionId, + scentCounts, + viewTarget, + onOpen, + onHome, +}: { + regions: Region[]; + openRegionId: number | null; + scentCounts: Map | null; + /* Where the camera is looking (world x/y) — drawn as a ring. */ + viewTarget?: { x: number; y: number } | null; + onOpen: (region: Region) => void; + onHome: () => void; +}) { + const [collapsed, setCollapsed] = useState(() => { + try { + return localStorage.getItem("cbm-minimap-collapsed") === "1"; + } catch { + return false; + } + }); + const dots = useMemo(() => minimapLayout(regions, W, H), [regions]); + const marker = useMemo( + () => (viewTarget ? minimapProject(regions, W, H, viewTarget) : null), + [regions, viewTarget], + ); + const byId = useMemo(() => new Map(regions.map((r) => [r.id, r])), [regions]); + + const toggle = () => { + setCollapsed((v) => { + try { + localStorage.setItem("cbm-minimap-collapsed", v ? "0" : "1"); + } catch { + /* ignore */ + } + return !v; + }); + }; + + if (regions.length === 0) return null; + return ( +
+
+ + map + + + +
+ {!collapsed && ( + + {marker && ( + + )} + {dots.map((dot) => { + const isOpen = dot.id === openRegionId; + const hits = scentCounts?.get(dot.id) ?? 0; + return ( + { + const region = byId.get(dot.id); + if (region) onOpen(region); + }} + className="cursor-pointer" + > + {dot.name} + + {hits > 0 && ( + + {hits} + + )} + + ); + })} + + )} +
+ ); +} diff --git a/graph-ui/src/components/MissedCallout.tsx b/graph-ui/src/components/MissedCallout.tsx index 1156d19b9..ec15600f5 100644 --- a/graph-ui/src/components/MissedCallout.tsx +++ b/graph-ui/src/components/MissedCallout.tsx @@ -96,11 +96,11 @@ export function MissedCallout({ node, project, onClose }: MissedCalloutProps) {
-

+

Not fully indexed

{path}

-

{node.label}

+

{node.label}

-

+

The prefilled issue contains only the file path and project name — add code snippets only if they are shareable.

diff --git a/graph-ui/src/components/ModulesTab.tsx b/graph-ui/src/components/ModulesTab.tsx new file mode 100644 index 000000000..c75d48931 --- /dev/null +++ b/graph-ui/src/components/ModulesTab.tsx @@ -0,0 +1,396 @@ +/* Modules — the territory: a squarified treemap of one folder's children, + * sized by symbols, colored by dominant region, hatched when the indexer + * missed files inside. Click a folder to drill, a file to list and open its + * symbols. */ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { fetchTree, type TreeChild, type TreePayload } from "../lib/atlas"; +import { fetchRegions } from "../hooks/useGraphData"; +import { squarify } from "../lib/treemap"; +import { disambiguateRegionNames } from "../lib/regions"; +import { searchGraph, type SearchRow } from "../lib/atlas"; +import type { RegionsPayload } from "../lib/types"; + +interface ModulesTabProps { + project: string; + path: string; + onNavigatePath: (path: string) => void; + onOpenSymbol: (ref: { id?: number; qn?: string }) => void; + onOpenRegion: (regionId: number) => void; +} + +const NEUTRAL = "#39505f"; + +/* search_graph rows for one file, via the read-only /rpc surface. */ +async function fetchFileSymbols(project: string, file: string): Promise { + const { rows } = await searchGraph(project, { file_pattern: file, limit: 200 }); + return rows; +} + +export function ModulesTab({ + project, + path, + onNavigatePath, + onOpenSymbol, + onOpenRegion, +}: ModulesTabProps) { + const [tree, setTree] = useState(null); + const [regions, setRegions] = useState(null); + const [error, setError] = useState(null); + const [selectedFile, setSelectedFile] = useState(null); + const [fileSymbols, setFileSymbols] = useState(null); + const [size, setSize] = useState({ w: 900, h: 560 }); + + useEffect(() => { + let cancelled = false; + setTree(null); + setError(null); + setSelectedFile(null); + setFileSymbols(null); + fetchTree(project, path) + .then((payload) => { + if (!cancelled) setTree(payload); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof Error ? err.message : "failed"); + }); + return () => { + cancelled = true; + }; + }, [project, path]); + + useEffect(() => { + let cancelled = false; + fetchRegions(project) + .then((payload) => { + if (!cancelled) setRegions(payload); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [project]); + + const regionMeta = useMemo(() => { + const map = new Map(); + if (regions) + for (const region of disambiguateRegionNames(regions.regions)) + map.set(region.id, { name: region.name, color: region.color }); + return map; + }, [regions]); + + const rects = useMemo(() => { + if (!tree) return []; + const items = tree.children.map((child) => ({ + id: `${child.kind}:${child.path}`, + value: Math.max(1, child.symbols), + })); + return squarify(items, 0, 0, size.w, size.h); + }, [tree, size]); + + const childByKey = useMemo(() => { + const map = new Map(); + if (tree) + for (const child of tree.children) map.set(`${child.kind}:${child.path}`, child); + return map; + }, [tree]); + + const openFile = useCallback( + (child: TreeChild) => { + setSelectedFile(child); + setFileSymbols(null); + fetchFileSymbols(project, child.path) + .then(setFileSymbols) + .catch(() => setFileSymbols([])); + }, + [project], + ); + + const [viewMode, setViewMode] = useState<"map" | "bars">(() => { + try { + return localStorage.getItem("cbm-modules-view") === "bars" ? "bars" : "map"; + } catch { + return "map"; + } + }); + const switchView = (mode: "map" | "bars") => { + setViewMode(mode); + try { + localStorage.setItem("cbm-modules-view", mode); + } catch { + /* ignore */ + } + }; + + const crumbs = useMemo(() => { + const parts = path ? path.split("/") : []; + return [{ label: "root", path: "" }].concat( + parts.map((part, index) => ({ + label: part, + path: parts.slice(0, index + 1).join("/"), + })), + ); + }, [path]); + + const measureRef = useCallback((element: HTMLDivElement | null) => { + if (!element) return; + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const rect = entry.contentRect; + if (rect.width > 100 && rect.height > 100) + setSize({ w: rect.width, h: rect.height }); + } + }); + observer.observe(element); + }, []); + + if (error) { + return ( +
+

{error}

+
+ ); + } + + return ( +
+ {/* Breadcrumbs */} +
+ {crumbs.map((crumb, index) => ( + + {index > 0 && /} + + + ))} +
+ {(["map", "bars"] as const).map((mode) => ( + + ))} +
+ {tree && ( + + {tree.files.toLocaleString("en-US")} files ·{" "} + {tree.symbols.toLocaleString("en-US")} symbols + {tree.children_dropped ? ` · ${tree.children_dropped} children not shown` : ""} + + )} +
+ +
+ {/* Treemap */} +
+ {!tree ? ( +
+

Measuring the territory…

+
+ ) : viewMode === "bars" ? ( +
+ {/* Bars are scaled to the largest sibling, not the whole — + * without saying so, they read as share-of-total. */} +

+ bar length: relative to largest module +

+ {[...tree.children] + .sort((a, b) => b.symbols - a.symbols) + .map((child) => { + const meta = + child.region !== undefined ? regionMeta.get(child.region) : undefined; + const max = Math.max(...tree.children.map((c) => c.symbols), 1); + return ( + + ); + })} +
+ ) : ( + + + + + + + + {rects.map((rect) => { + const child = childByKey.get(rect.id); + if (!child) return null; + const meta = child.region !== undefined ? regionMeta.get(child.region) : undefined; + const fill = meta?.color ?? NEUTRAL; + const showLabel = rect.w > 60 && rect.h > 24; + return ( + + child.kind === "dir" ? onNavigatePath(child.path) : openFile(child) + } + className="cursor-pointer" + > + + + {`${child.path} — ${child.symbols.toLocaleString("en-US")} symbols` + + (child.kind === "dir" ? `, ${child.files} files` : "") + + (meta ? `\nregion: ${meta.name}` : "") + + (child.missed ? `\n${child.missed} file(s) not fully indexed` : "")} + + + {child.missed ? ( + + ) : null} + {showLabel && ( + + {child.kind === "dir" ? `${child.name}/` : child.name} + + )} + {showLabel && rect.h > 40 && ( + + {child.symbols.toLocaleString("en-US")} + + )} + + ); + })} + + )} +
+ + {/* File panel */} + {selectedFile && ( +
+
+
+

+ {selectedFile.path} +

+ +
+

+ {selectedFile.symbols.toLocaleString("en-US")} symbols + {selectedFile.missed ? " · not fully indexed" : ""} +

+
+
+ {fileSymbols === null ? ( +

Loading…

+ ) : fileSymbols.length === 0 ? ( +

No symbols found.

+ ) : ( + fileSymbols.map((symbol) => ( + + )) + )} +
+ {selectedFile.region !== undefined && regionMeta.get(selectedFile.region) && ( + + )} +
+ )} +
+
+ ); +} diff --git a/graph-ui/src/components/NodeDetailPanel.showmore.test.tsx b/graph-ui/src/components/NodeDetailPanel.showmore.test.tsx new file mode 100644 index 000000000..ed30602f3 --- /dev/null +++ b/graph-ui/src/components/NodeDetailPanel.showmore.test.tsx @@ -0,0 +1,45 @@ +/* @vitest-environment jsdom */ +import "@testing-library/jest-dom/vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { NodeDetailPanel, CONNECTIONS_PAGE_SIZE } from "./NodeDetailPanel"; +import type { GraphEdge, GraphNode } from "../lib/types"; + +/* #1198: a node with more connections than one page must offer them all — + * paged behind "Show more", never silently cut at 25. */ +describe("NodeDetailPanel connections paging", () => { + it("pages beyond the first slice instead of capping at 25", () => { + const total = CONNECTIONS_PAGE_SIZE + 5; + const center: GraphNode = { + id: 0, x: 0, y: 0, z: 0, label: "Function", name: "hub", size: 1, color: "#fff", + }; + const nodes: GraphNode[] = [center]; + const edges: GraphEdge[] = []; + for (let i = 1; i <= total; i++) { + nodes.push({ + id: i, x: 0, y: 0, z: 0, label: "Function", name: `callee_${i}`, size: 1, color: "#fff", + }); + edges.push({ source: 0, target: i, type: "CALLS" }); + } + + render( + , + ); + + /* First page rendered, the rest offered. */ + expect(screen.getByText(`callee_${CONNECTIONS_PAGE_SIZE}`)).toBeInTheDocument(); + expect(screen.queryByText(`callee_${total}`)).not.toBeInTheDocument(); + const more = screen.getByRole("button", { name: /Show 5 more/ }); + fireEvent.click(more); + expect(screen.getByText(`callee_${total}`)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /Show .* more/ })).not.toBeInTheDocument(); + }); +}); diff --git a/graph-ui/src/components/NodeDetailPanel.tsx b/graph-ui/src/components/NodeDetailPanel.tsx index b75d9083d..2a43ffb6b 100644 --- a/graph-ui/src/components/NodeDetailPanel.tsx +++ b/graph-ui/src/components/NodeDetailPanel.tsx @@ -1,4 +1,7 @@ import { useMemo, useState, useEffect } from "react"; + +/* #1198: connections render in slices the user grows — never a silent cap. */ +export const CONNECTIONS_PAGE_SIZE = 25; import { ScrollArea } from "@/components/ui/scroll-area"; import { colorForLabel } from "../lib/colors"; import { callTool } from "../api/rpc"; @@ -18,6 +21,9 @@ interface NodeDetailPanelProps { repoInfo: RepoInfo | null; onClose: () => void; onNavigate: (node: GraphNode) => void; + /* Honest-scope note, e.g. "+12 callers outside this region" — the loaded + * edges are a window, never the whole truth. */ + scopeNote?: string | null; } interface SnippetResult { @@ -53,6 +59,7 @@ export function NodeDetailPanel({ repoInfo, onClose, onNavigate, + scopeNote = null, }: NodeDetailPanelProps) { const [code, setCode] = useState(null); const [codeLoading, setCodeLoading] = useState(false); @@ -114,7 +121,7 @@ export function NodeDetailPanel({ }; return ( -
+
{/* Header */}
@@ -124,17 +131,17 @@ export function NodeDetailPanel({

{node.name}

{node.label}
- +
{node.file_path && ( -

+

{node.file_path} {node.start_line ? ( @@ -151,7 +158,7 @@ export function NodeDetailPanel({ @@ -161,16 +168,16 @@ export function NodeDetailPanel({ href={ghUrl} target="_blank" rel="noopener noreferrer" - className="px-2.5 py-1 rounded-md bg-white/[0.05] text-foreground/60 text-[11px] font-medium hover:bg-white/[0.09] hover:text-foreground/90 transition-colors" + className="px-2.5 py-1 rounded-md bg-surface-3 text-foreground/60 text-[13px] font-medium hover:bg-surface-4 hover:text-foreground/90 transition-colors" > Open on GitHub ↗ )}

- {codeError &&

{codeError}

} + {codeError &&

{codeError}

} {code && ( -
+          
             {code}
           
)} @@ -183,7 +190,7 @@ export function NodeDetailPanel({ { label: "Total", value: connections.length, color: "text-foreground" }, ].map((s) => (
-

{s.label}

+

{s.label}

{s.value}

))} @@ -196,11 +203,14 @@ export function NodeDetailPanel({ {outbound.length > 0 && ( )} + {scopeNote && ( +

{scopeNote}

+ )} {inbound.length > 0 && ( )} {connections.length === 0 && ( -

No connections

+

No connections

)}
@@ -208,6 +218,44 @@ export function NodeDetailPanel({ ); } +function ConnectionGroup({ type, conns, icon, onNavigate }: { + type: string; conns: Connection[]; icon: string; + onNavigate: (n: GraphNode) => void; +}) { + const [visible, setVisible] = useState(CONNECTIONS_PAGE_SIZE); + useEffect(() => setVisible(CONNECTIONS_PAGE_SIZE), [type, conns.length]); + const remaining = conns.length - visible; + return ( +
+

+ {type.replace(/_/g, " ").toLowerCase()} +

+
+ {conns.slice(0, visible).map((c, i) => ( + + ))} + {remaining > 0 && ( + + )} +
+
+ ); +} + function ConnectionSection({ title, count, icon, groups, onNavigate }: { title: string; count: number; icon: string; groups: [string, Connection[]][]; @@ -215,32 +263,11 @@ function ConnectionSection({ title, count, icon, groups, onNavigate }: { }) { return (
-

- {title} ({count}) +

+ {title} ({count})

{groups.map(([type, conns]) => ( -
-

- {type.replace(/_/g, " ").toLowerCase()} -

-
- {conns.slice(0, 25).map((c, i) => ( - - ))} - {conns.length > 25 && ( -

+{conns.length - 25} more

- )} -
-
+ ))}
); diff --git a/graph-ui/src/components/NodeTooltip.tsx b/graph-ui/src/components/NodeTooltip.tsx index ea7c16bce..f84116367 100644 --- a/graph-ui/src/components/NodeTooltip.tsx +++ b/graph-ui/src/components/NodeTooltip.tsx @@ -13,43 +13,72 @@ function lineRange(node: GraphNode): string | null { return `L${node.start_line}`; } +/* The arithmetic behind the node, from the facts the scene payload already + * carries — no fetch on hover, ever (jitter). "fan" is a wiki term, but this + * tooltip follows the cursor with pointer-events off, so a MetricChip here + * would be an unclickable target — labels stay plain text; the wiki remains + * reachable from the panels. */ +function breakdownRows(node: GraphNode): [string, string][] { + const rows: [string, string][] = []; + if (node.in_calls !== undefined) + rows.push(["fan-in", node.in_calls.toLocaleString("en-US")]); + if (node.out_calls !== undefined) + rows.push(["fan-out", node.out_calls.toLocaleString("en-US")]); + if (node.start_line && node.end_line && node.end_line >= node.start_line) + rows.push([ + "lines", + (node.end_line - node.start_line + 1).toLocaleString("en-US"), + ]); + return rows; +} + export function NodeTooltip({ node }: NodeTooltipProps) { + const rows = breakdownRows(node); return ( -
+
{node.name} - {node.label} + {node.label}
{node.file_path && ( -

+

{node.file_path} - {lineRange(node) && · {lineRange(node)}} + {lineRange(node) && · {lineRange(node)}}

)} + {rows.length > 0 && ( + + + {rows.map(([label, value]) => ( + + + + + ))} + +
{label} + {value} +
+ )} {node.status && node.status !== "structural" && (
- {node.status} - {node.in_calls !== undefined && ( - - · {node.in_calls} caller{node.in_calls === 1 ? "" : "s"} - - )} + {node.status}
)} -

click for code →

+

click for code →

); diff --git a/graph-ui/src/components/OverviewTab.tsx b/graph-ui/src/components/OverviewTab.tsx new file mode 100644 index 000000000..73c613032 --- /dev/null +++ b/graph-ui/src/components/OverviewTab.tsx @@ -0,0 +1,783 @@ +/* Overview — a findings feed on a landmark map. Four opinionated slots + * answer "is anything wrong?"; findings are sentences with evidence, a next + * click and a dismissal; the region map gives newcomers named landmarks; + * reference material (hubs, entry points, boundaries) is one disclosure + * away instead of shouting; inventory lives in the footer. */ +import { useEffect, useMemo, useState } from "react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + fetchArchitecture, + fetchBridges, + archRows, + type ArchitectureJson, + type BridgeRow, +} from "../lib/atlas"; +import { fetchRegions } from "../hooks/useGraphData"; +import { + disambiguateRegionNames, + lensRegionsPayload, + isBuiltinQn, + localizeRegionWhy, +} from "../lib/regions"; +import { surprisingCouplings, suggestedQuestions } from "../lib/firstread"; +import { kneeCount, findingKey, isDismissed, dismiss } from "../lib/findings"; +import { + QUESTION_FAMILIES, + localizeQuestion, + questionStatusCounts, + type QuestionStatus, + type QuestionTab, +} from "../lib/questions"; +import { messages, useUiLanguage, useUiMessages } from "../lib/i18n"; +import { AddToPromptButton } from "./PromptBasket"; +import type { RegionsPayload } from "../lib/types"; + +interface OverviewTabProps { + project: string; + onOpenRegion: (regionId: number) => void; + onOpenSymbol: (qn: string) => void; + onOpenModules: () => void; + onOpenFlows: () => void; + onOpenDashboard: () => void; + onOpenTab: (tab: QuestionTab) => void; +} + +interface HotspotRow { + qn: string; + fan_in: number; +} +interface EntryRow { + qn: string; + file: string; +} +interface BoundaryRow { + from: string; + to: string; + calls: number; +} +interface LanguageRow { + language: string; + files: number; +} + +interface MetricsLite { + generated_from: string; + totals: { dead: number; missed_files: number; callables: number }; + top_churn_complex: { file?: string; value: number; commits?: number }[]; + churn_available: boolean; + history: { indexed_at: string; dead: number; avg_complexity: number }[]; +} + +function shortQn(qn: string): string { + return qn.split(".").slice(-2).join("."); +} + +function Slot({ + label, + value, + sub, + tone, + onClick, +}: { + label: string; + value: string; + sub?: string; + tone?: "warn" | "crit" | "quiet"; + onClick?: () => void; +}) { + const inner = ( + <> +

{label}

+

+ {value} +

+ {sub &&

{sub}

} + + ); + return onClick ? ( + + ) : ( +
{inner}
+ ); +} + +/* Status glyph for the question index — the shape carries the state + * (filled / half / hollow), the color is redundant with the word chip, + * never color-only. */ +function StatusGlyph({ status }: { status: QuestionStatus }) { + const tone = + status === "answers" + ? "text-good" + : status === "partial" + ? "text-warn" + : "text-foreground/40"; + return ( + + ); +} + +/* "What can Atlas answer?" — the 18 developer-question families as an + * honest scorecard: answered, partially answered, or not answered yet. + * Collapsed by default; the header summary is derived from the data. */ +function QuestionIndex({ onOpenTab }: { onOpenTab: (tab: QuestionTab) => void }) { + const lang = useUiLanguage(); + const t = messages[lang]; + const [open, setOpen] = useState(() => { + try { + return localStorage.getItem("cbm-questions-open") === "1"; + } catch { + return false; + } + }); + const toggle = () => { + setOpen((value) => { + try { + localStorage.setItem("cbm-questions-open", value ? "0" : "1"); + } catch { + /* ignore */ + } + return !value; + }); + }; + const counts = questionStatusCounts(QUESTION_FAMILIES); + const statusWord: Record = { + answers: t.questions.answers, + partial: t.questions.partial, + lacks: t.questions.lacks, + }; + const statusTone: Record = { + answers: "text-good/90", + partial: "text-warn/90", + lacks: "text-foreground/40", + }; + + return ( +
+ + {open && ( +
+ {QUESTION_FAMILIES.map((family) => { + const tab = family.tab; + const loc = localizeQuestion(family, lang); + const inner = (clickable: boolean) => ( + <> + + + + {statusWord[family.status]} + + + + + {loc.question} + + {(loc.hint || loc.gap) && ( + + {loc.hint} + {loc.hint && loc.gap && " · "} + {loc.gap && `${t.questions.missingPrefix} ${loc.gap}`} + + )} + + + ); + /* Only rows with a destination are buttons — no fake affordance; + * for the rest the hint carries the "how". */ + return tab ? ( + + ) : ( +
+ {inner(false)} +
+ ); + })} +
+ )} +
+ ); +} + +/* All displayed regions' cohesion values as an inline dot strip — a bare + * value invites misreading (0.4 can be the tightest region on the map or + * the loosest). This region's dot is emphasized; the tick is the median. */ +function CohesionStrip({ values, own }: { values: number[]; own: number }) { + const t = useUiMessages(); + if (values.length < 2) return null; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + const median = + sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; + const x = (value: number) => 4 + Math.max(0, Math.min(1, value)) * 112; + return ( + + {`p50 = ${median.toFixed(2)}`} + + {values.map((value, index) => ( + + ))} + + + ); +} + +export function OverviewTab({ + project, + onOpenRegion, + onOpenSymbol, + onOpenModules, + onOpenFlows, + onOpenDashboard, + onOpenTab, +}: OverviewTabProps) { + const t = useUiMessages(); + const [arch, setArch] = useState(null); + const [regions, setRegions] = useState(null); + const [metrics, setMetrics] = useState(null); + const [error, setError] = useState(null); + const [includeTests, setIncludeTests] = useState(false); + const [dismissedTick, setDismissedTick] = useState(0); + const [bridges, setBridges] = useState([]); + + /* Boundary spanners ride alongside the main load; best-effort, silent on + * failure. (A true staleness banner needs a files-changed-since-indexing + * signal the server does not expose yet — recorded as a follow-up rather + * than shipped with wrong semantics.) */ + useEffect(() => { + let cancelled = false; + setBridges([]); + fetchBridges(project) + .then((payload) => { + if (!cancelled) setBridges(payload.bridges ?? []); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, [project]); + + useEffect(() => { + let cancelled = false; + setArch(null); + setRegions(null); + setMetrics(null); + setError(null); + Promise.allSettled([ + fetchArchitecture(project), + fetchRegions(project), + fetch(`/api/metrics?${new URLSearchParams({ project })}`).then((res) => + res.ok ? res.json() : Promise.reject(new Error(String(res.status))), + ), + ]).then(([archResult, regionsResult, metricsResult]) => { + if (cancelled) return; + if (archResult.status === "fulfilled") setArch(archResult.value); + if (regionsResult.status === "fulfilled") setRegions(regionsResult.value); + if (metricsResult.status === "fulfilled") + setMetrics(metricsResult.value as MetricsLite); + if (archResult.status === "rejected" && regionsResult.status === "rejected") + setError(String(archResult.reason?.message ?? "failed to load")); + }); + return () => { + cancelled = true; + }; + }, [project]); + + const lensed = useMemo( + () => (regions ? lensRegionsPayload(regions, includeTests) : null), + [regions, includeTests], + ); + const displayRegions = useMemo( + () => (lensed ? disambiguateRegionNames(lensed.regions) : []), + [lensed], + ); + const hiddenTestRegions = + regions && lensed ? regions.regions.length - lensed.regions.length : 0; + + const couplings = useMemo( + () => (lensed ? surprisingCouplings(lensed, 5, t.firstread) : []), + [lensed, t], + ); + const questions = useMemo( + () => + lensed + ? suggestedQuestions(lensed, { deadCount: metrics?.totals.dead }, 4, t.firstread) + : [], + [lensed, metrics, t], + ); + + /* Findings with dismissal (magnitude-bucketed keys re-alert on jumps). */ + const findings = useMemo(() => { + void dismissedTick; + return couplings + .map((coupling) => ({ + coupling, + key: findingKey( + "coupling", + `${coupling.source.name}⇄${coupling.target.name}`, + coupling.weight, + ), + })) + .filter((finding) => !isDismissed(finding.key)); + }, [couplings, dismissedTick]); + const dismissedCount = couplings.length - findings.length; + + const hotspots = useMemo( + () => archRows(arch, "hotspots").filter((row) => !isBuiltinQn(row.qn)), + [arch], + ); + const entries = useMemo(() => archRows(arch, "entry_points"), [arch]); + const boundaries = useMemo(() => archRows(arch, "boundaries"), [arch]); + const languages = useMemo(() => archRows(arch, "languages"), [arch]); + const totalNodes = + typeof arch?.total_nodes === "number" ? arch.total_nodes : regions?.total_nodes; + const totalEdges = typeof arch?.total_edges === "number" ? arch.total_edges : undefined; + + /* The four slots. */ + const attention = useMemo(() => { + const scores = (metrics?.top_churn_complex ?? []).map((entry) => entry.value); + if (scores.length === 0) return null; + return kneeCount(scores); + }, [metrics]); + const riskiest = metrics?.top_churn_complex?.[0] ?? null; + const direction = useMemo(() => { + const history = metrics?.history ?? []; + if (history.length < 2) return null; + const previous = history[history.length - 2]; + const current = history[history.length - 1]; + const deadDelta = current.dead - previous.dead; + const cplxDelta = current.avg_complexity - previous.avg_complexity; + const deadPart = + deadDelta === 0 ? "dead →" : `dead ${deadDelta > 0 ? "+" : ""}${deadDelta}`; + const cplxPart = + Math.abs(cplxDelta) < 0.05 ? "complexity →" : `complexity ${cplxDelta > 0 ? "↑" : "↓"}`; + return { + text: `${deadPart} · ${cplxPart}`, + worsening: deadDelta > 0 || cplxDelta > 0.05, + }; + }, [metrics]); + + if (error) { + return ( +
+

{error}

+
+ ); + } + if (!arch && !regions) { + return ( +
+

{t.overview.loading}

+
+ ); + } + + return ( + +
+ + + {/* The scorecard: what can Atlas answer? */} + + + {/* The four slots: is anything wrong? */} +
+ 0 ? "warn" : undefined} + onClick={onOpenDashboard} + /> + + + +
+ + {/* Findings — sentences with evidence, a next click, a dismissal. */} + {(findings.length > 0 || dismissedCount > 0) && ( +
+
+

+ {t.overview.findings} +

+ {dismissedCount > 0 && ( + + {t.overview.dismissed(dismissedCount)} + + )} +
+
+ {findings.map(({ coupling, key }) => ( +
+
+

+ {coupling.source.name} + {t.overview.couplingAnd} + {coupling.target.name} + {t.overview.couplingSuffix} +

+

+ {coupling.reasons.join(" · ")} +

+
+ + +
+ ))} +
+
+ )} + + {/* Questions to hand your agent. */} + {questions.length > 0 && ( +
+

+ {t.overview.questionsHeading} +

+
+ {questions.map((question) => ( +
+
+

{question.question}

+

{question.why}

+
+ +
+ ))} +
+
+ )} + + {/* The landmark map. */} +
+
+

+ {t.overview.regionsHeading(regions?.method ?? "…")} +

+ {hiddenTestRegions > 0 && ( + + )} +
+
+ {displayRegions.slice(0, 12).map((region) => ( + + ))} +
+ {displayRegions.length > 12 && ( + + )} +
+ + {/* Boundary spanners — the seam-stitching metric, distinct from + * degree hubs (which find utilities). */} + {bridges.length > 0 && ( +
+

+ {t.overview.bridgesHeading} +

+ {bridges.map((bridge) => ( +
+ + + {t.overview.bridgeMeta(bridge.regions, bridge.cross_calls)} + +
+ ))} +

+ {t.overview.bridgesFootnote} +

+
+ )} + + {/* Reference — one disclosure away, never shouting. */} +
+ + {t.overview.referenceSummary} + +
+
+

+ {t.overview.hubsHeading} +

+ {hotspots.slice(0, 8).map((hotspot) => ( +
+ + + {Number(hotspot.fan_in).toLocaleString("en-US")} + +
+ ))} + {hotspots.length === 0 && ( +

{t.overview.noHotspots}

+ )} +
+
+

+ {t.overview.entryPoints} +

+ {entries.slice(0, 8).map((entry) => ( + + ))} + +
+
+

+ {t.overview.boundariesHeading} +

+ + + {boundaries.slice(0, 8).map((boundary, index) => ( + + + + + + ))} + +
+ {boundary.from} + {boundary.to} + {Number(boundary.calls).toLocaleString("en-US")} +
+ {boundaries.length === 0 && ( +

{t.overview.noBoundaries}

+ )} +
+
+
+ + {/* Inventory footer — facts, untoned. */} +

+ {t.overview.inventory( + totalNodes?.toLocaleString("en-US") ?? "…", + totalEdges?.toLocaleString("en-US") ?? "…", + `${regions?.regions.length ?? "…"}`, + )}{" "} + ·{" "} + {languages + .slice(0, 4) + .map((lang) => lang.language) + .join(" · ") || "…"} +

+
+
+ ); +} diff --git a/graph-ui/src/components/ProjectCard.tsx b/graph-ui/src/components/ProjectCard.tsx index 930e9e850..824516597 100644 --- a/graph-ui/src/components/ProjectCard.tsx +++ b/graph-ui/src/components/ProjectCard.tsx @@ -8,7 +8,7 @@ interface ProjectCardProps { } function formatNumber(n: number): string { - return n.toLocaleString(); + return n.toLocaleString("en-US"); } export function ProjectCard({ project, schema, onSelect }: ProjectCardProps) { @@ -16,11 +16,11 @@ export function ProjectCard({ project, schema, onSelect }: ProjectCardProps) { const totalEdges = schema?.edge_types?.reduce((s, t) => s + t.count, 0) ?? 0; return ( -
+

{project.name}

-

+

{project.root_path}

@@ -34,7 +34,7 @@ export function ProjectCard({ project, schema, onSelect }: ProjectCardProps) { {schema && ( <> -
+
{formatNumber(totalNodes)} nodes {formatNumber(totalEdges)} edges
@@ -54,7 +54,7 @@ export function ProjectCard({ project, schema, onSelect }: ProjectCardProps) { {l.label} - {formatNumber(l.count)} + {formatNumber(l.count)} ))}
@@ -63,7 +63,7 @@ export function ProjectCard({ project, schema, onSelect }: ProjectCardProps) { )} {!schema && ( -

Loading schema...

+

Loading schema...

)}
); diff --git a/graph-ui/src/components/ProjectSwitcher.tsx b/graph-ui/src/components/ProjectSwitcher.tsx new file mode 100644 index 000000000..e673e381e --- /dev/null +++ b/graph-ui/src/components/ProjectSwitcher.tsx @@ -0,0 +1,158 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { callTool } from "../api/rpc"; +import type { Project } from "../lib/types"; + +/* The project is the context every other view depends on, so it lives + * first in the header as a switcher — not as a trailing chip gating a row + * of dead tabs. */ +export function ProjectSwitcher({ + selected, + labels, + allProjectsLabel, + onSelect, + onAllProjects, +}: { + selected: string | null; + labels: { select: string; search: string; manage: string }; + allProjectsLabel: string; + onSelect: (project: string) => void; + onAllProjects: () => void; +}) { + const [open, setOpen] = useState(false); + const [projects, setProjects] = useState(null); + const [query, setQuery] = useState(""); + const [anchor, setAnchor] = useState<{ top: number; left: number } | null>(null); + const rootRef = useRef(null); + const inputRef = useRef(null); + + const load = useCallback(() => { + if (projects !== null) return; + callTool<{ projects: Project[] }>("list_projects", {}) + .then((result) => setProjects(result.projects ?? [])) + .catch(() => setProjects([])); + }, [projects]); + + useEffect(() => { + if (!open) return; + setQuery(""); + /* The panel is portaled to , so anchor it to the button. */ + const rect = rootRef.current?.getBoundingClientRect(); + if (rect) setAnchor({ top: rect.bottom + 6, left: rect.left }); + /* Focus lands in the filter so typing narrows immediately. */ + const focusTimer = setTimeout(() => inputRef.current?.focus(), 0); + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape") setOpen(false); + }; + const onResize = () => setOpen(false); + document.addEventListener("keydown", onKey); + window.addEventListener("resize", onResize); + return () => { + clearTimeout(focusTimer); + document.removeEventListener("keydown", onKey); + window.removeEventListener("resize", onResize); + }; + }, [open]); + + const filtered = useMemo(() => { + if (projects === null) return null; + const needle = query.trim().toLowerCase(); + if (!needle) return projects; + return projects.filter((project) => project.name.toLowerCase().includes(needle)); + }, [projects, query]); + + return ( +
+ + {open && + anchor && + /* BOTH the backdrop and the panel are portaled to . The + * header's backdrop-filter makes it a stacking context, so anything + * left inside paints as one atomic layer BELOW a body-level + * overlay — the first version dimmed its own panel. Out here the + * order is explicit: backdrop z-20, panel z-30. */ + createPortal( + <> +
setOpen(false)} + /> +
+
+ setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && filtered && filtered.length > 0) { + setOpen(false); + onSelect(filtered[0].name); + } + }} + placeholder={labels.search} + className="w-full bg-background border border-border/60 rounded-md px-2.5 py-1.5 text-[13px] text-foreground placeholder-foreground/35 outline-none focus:border-primary/60 transition-all" + /> +
+
+ {filtered === null && ( +

Loading…

+ )} + {filtered !== null && filtered.length === 0 && ( +

+ {query ? `Nothing matches "${query}".` : "No indexed projects yet."} +

+ )} + {(filtered ?? []).map((project) => ( + + ))} +
+
+ +
+
+ , + document.body, + )} +
+ ); +} diff --git a/graph-ui/src/components/PromptBasket.tsx b/graph-ui/src/components/PromptBasket.tsx new file mode 100644 index 000000000..3dfeb39b9 --- /dev/null +++ b/graph-ui/src/components/PromptBasket.tsx @@ -0,0 +1,208 @@ +/* The prompt composer: a basket of cited entities (symbols, regions, flows, + * questions) that becomes a precise prompt for the user's coding agent — + * the bridge from what the human learned to what the agent is told. */ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react"; +import { composePrompt, basketKey, type BasketItem } from "../lib/composer"; + +interface BasketState { + items: BasketItem[]; + add: (item: BasketItem) => void; + remove: (key: string) => void; + clear: () => void; + open: boolean; + setOpen: (open: boolean) => void; +} + +const BasketContext = createContext(null); + +const STORAGE_KEY = "cbm-atlas-basket"; + +export function PromptBasketProvider({ children }: { children: ReactNode }) { + const [items, setItems] = useState(() => { + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + return raw ? (JSON.parse(raw) as BasketItem[]) : []; + } catch { + return []; + } + }); + const [open, setOpen] = useState(false); + + useEffect(() => { + try { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(items)); + } catch { + /* session storage unavailable — the basket still works in memory */ + } + }, [items]); + + const add = useCallback((item: BasketItem) => { + setItems((prev) => + prev.some((existing) => basketKey(existing) === basketKey(item)) + ? prev + : [...prev, item], + ); + setOpen(true); + }, []); + const remove = useCallback((key: string) => { + setItems((prev) => prev.filter((item) => basketKey(item) !== key)); + }, []); + const clear = useCallback(() => setItems([]), []); + + const value = useMemo( + () => ({ items, add, remove, clear, open, setOpen }), + [items, add, remove, clear, open], + ); + return {children}; +} + +export function usePromptBasket(): BasketState { + const ctx = useContext(BasketContext); + if (!ctx) throw new Error("usePromptBasket outside PromptBasketProvider"); + return ctx; +} + +/* The standard "cite this" affordance. */ +export function AddToPromptButton({ item, small }: { item: BasketItem; small?: boolean }) { + const basket = usePromptBasket(); + const inBasket = basket.items.some((existing) => basketKey(existing) === basketKey(item)); + return ( + + ); +} + +function itemLabel(item: BasketItem): string { + switch (item.kind) { + case "symbol": + return item.qualified_name ?? item.name; + case "region": + return `region ${item.name}`; + case "flow": + return `flow ${item.label}`; + case "question": + return item.question; + } +} + +/* The drawer: goal text + cited items + the composed prompt with copy. */ +export function PromptBasketDrawer({ project }: { project: string | null }) { + const basket = usePromptBasket(); + const [goal, setGoal] = useState(""); + const [copied, setCopied] = useState(false); + const prompt = useMemo( + () => composePrompt(project ?? "?", goal, basket.items), + [project, goal, basket.items], + ); + + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 1500); + return () => clearTimeout(timer); + }, [copied]); + + if (!basket.open) { + return ( + + ); + } + + return ( +
+
+ + Prompt composer ({basket.items.length} cited) + +
+ {basket.items.length > 0 && ( + + )} + +
+
+ +
+ setGoal(event.target.value)} + className="w-full bg-popover border border-border rounded-md px-3 py-1.5 text-[12px] text-foreground placeholder-foreground/25 outline-none focus:border-primary/40 transition-all" + /> +
+ +
+ {basket.items.length === 0 ? ( +

+ Cite symbols, regions, flows or questions with “+ prompt” anywhere in Atlas. +

+ ) : ( + basket.items.map((item) => ( +
+ + {item.kind} + + + {itemLabel(item)} + + +
+ )) + )} +
+ +
+        {prompt}
+      
+ +
+ +
+
+ ); +} diff --git a/graph-ui/src/components/RegionPanel.tsx b/graph-ui/src/components/RegionPanel.tsx new file mode 100644 index 000000000..885202f5b --- /dev/null +++ b/graph-ui/src/components/RegionPanel.tsx @@ -0,0 +1,97 @@ +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useUiMessages } from "../lib/i18n"; +import { localizeRegionWhy } from "../lib/regions"; +import type { Region } from "../lib/types"; + +interface RegionPanelProps { + region: Region; + onOpen: (region: Region) => void; + onClose: () => void; +} + +/* Detail panel for a selected region body: what it is, why it got its name, + * its representative symbols, and the door into its full-detail layout. */ +export function RegionPanel({ region, onOpen, onClose }: RegionPanelProps) { + const t = useUiMessages(); + return ( +
+
+
+
+
+ +

+ {region.name} +

+
+ + Region + +
+ +
+ + {region.why && ( +

+ {localizeRegionWhy(region.why, t.regions)} +

+ )} + + + +
+ {[ + { label: "Symbols", value: region.members.toLocaleString("en-US") }, + { label: "Files", value: region.files.toLocaleString("en-US") }, + { label: "Cohesion", value: region.cohesion.toFixed(2) }, + ].map((stat) => ( +
+

+ {stat.label} +

+

+ {stat.value} +

+
+ ))} +
+
+ + +
+

+ Most connected +

+
+ {region.top_nodes.map((name) => ( +

+ {name} +

+ ))} + {region.top_nodes.length === 0 && ( +

+ No callable members +

+ )} +
+
+
+
+ ); +} diff --git a/graph-ui/src/components/RegionTooltip.tsx b/graph-ui/src/components/RegionTooltip.tsx new file mode 100644 index 000000000..e8a4048f3 --- /dev/null +++ b/graph-ui/src/components/RegionTooltip.tsx @@ -0,0 +1,54 @@ +import { Html } from "@react-three/drei"; +import type { GraphNode, Region } from "../lib/types"; +import { cohesionWord } from "../lib/regions"; + +/* Hover card for a region blob in the coarsest galaxy view: what a human + * needs before deciding to drill — the region's arithmetic as scannable + * rows, cohesion in words, the hub. Only facts the regions payload already + * carries are shown — no fetch on hover, ever (jitter); internal/external + * share and the top external tie are not in the payload today and stay in + * the RegionPanel. "cohesion" is a wiki term, but this tooltip follows the + * cursor with pointer-events off, so a MetricChip here would be an + * unclickable target — labels stay plain text; the wiki remains reachable + * from the panels. */ +export function RegionTooltip({ region, node }: { region: Region; node: GraphNode }) { + const rows: [string, string][] = [ + ["symbols", region.members.toLocaleString("en-US")], + ["files", region.files.toLocaleString("en-US")], + ["cohesion", region.cohesion.toFixed(2)], + ]; + return ( + +
+
+ + {region.name} +
+ + + {rows.map(([label, value]) => ( + + + + + ))} + +
{label} + {value} +
+

{cohesionWord(region.cohesion)}

+ {region.hub && ( +

hub: {region.hub}

+ )} +

double-click to open →

+
+ + ); +} diff --git a/graph-ui/src/components/Sidebar.tsx b/graph-ui/src/components/Sidebar.tsx index 4dab26467..52a0b1f1d 100644 --- a/graph-ui/src/components/Sidebar.tsx +++ b/graph-ui/src/components/Sidebar.tsx @@ -5,7 +5,10 @@ import { useUiMessages } from "../lib/i18n"; interface SidebarProps { nodes: GraphNode[]; - onSelectPath: (path: string, nodeIds: Set, node?: GraphNode) => void; + onSelectPath: (path: string, nodeIds: Set) => void; + /* #1197: clicking a symbol row selects the node itself — highlight in the + * scene AND the detail panel, exactly like clicking its 3D point. */ + onSelectNode: (node: GraphNode) => void; selectedPath: string | null; } @@ -59,9 +62,10 @@ function flattenSingleChild(dir: DirNode): DirNode { return { ...dir, children }; } -function TreeItem({ dir, depth, onSelect, selectedPath }: { +function TreeItem({ dir, depth, onSelect, onSelectNode, selectedPath }: { dir: DirNode; depth: number; - onSelect: (path: string, ids: Set, node?: GraphNode) => void; + onSelect: (path: string, ids: Set) => void; + onSelectNode: (node: GraphNode) => void; selectedPath: string | null; }) { const [expanded, setExpanded] = useState(false); @@ -72,31 +76,31 @@ function TreeItem({ dir, depth, onSelect, selectedPath }: { return (
{expanded && ( <> - {sorted.map((c) => )} + {sorted.map((c) => )} {sortedNodes.map((gn) => ( ))} @@ -105,7 +109,7 @@ function TreeItem({ dir, depth, onSelect, selectedPath }: { ); } -export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) { +export function Sidebar({ nodes, onSelectPath, onSelectNode, selectedPath }: SidebarProps) { const t = useUiMessages(); const [search, setSearch] = useState(""); const tree = useMemo(() => flattenSingleChild(buildFileTree(nodes)), [nodes]); @@ -121,7 +125,7 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) { return (
- + {t.graph.folders}
@@ -132,7 +136,7 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) { placeholder={t.graph.search} value={search} onChange={(e) => setSearch(e.target.value)} - className="w-full bg-white/[0.04] border border-white/[0.06] rounded-lg px-3 py-1.5 text-[12px] text-foreground placeholder-foreground/25 outline-none focus:border-primary/40 focus:bg-white/[0.06] transition-all" + className="w-full bg-popover border border-border rounded-md px-3 py-1.5 text-[12px] text-foreground placeholder-foreground/25 outline-none focus:border-primary/40 focus:bg-surface-3 transition-all" />
@@ -141,24 +145,24 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
{filtered ? ( filtered.length === 0 ? ( -

+

{t.common.noMatches}

) : ( filtered.map((n) => ( )) ) ) : ( - topLevel.map((c) => ) + topLevel.map((c) => ) )}
@@ -167,7 +171,7 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
diff --git a/graph-ui/src/components/StatsTab.tsx b/graph-ui/src/components/StatsTab.tsx index 644805812..1371b7d5a 100644 --- a/graph-ui/src/components/StatsTab.tsx +++ b/graph-ui/src/components/StatsTab.tsx @@ -22,7 +22,7 @@ function HealthDot({ name }: { name: string }) { setStatus(d.status ?? "corrupt"); if (d.nodes !== undefined) { const sizeMB = ((d.size_bytes ?? 0) / 1024 / 1024).toFixed(1); - setInfo(`${d.nodes.toLocaleString()} nodes, ${d.edges.toLocaleString()} edges, ${sizeMB} MB`); + setInfo(`${d.nodes.toLocaleString("en-US")} nodes, ${d.edges.toLocaleString("en-US")} edges, ${sizeMB} MB`); } else if (d.reason) { setInfo(d.reason); } @@ -54,9 +54,9 @@ function HealthDot({ name }: { name: string }) { /> {/* Tooltip */}
-
+

{label}

- {info &&

{info}

} + {info &&

{info}

}
@@ -105,10 +105,10 @@ function AdrButton({ project }: { project: string }) { <> +
{updatedAt && ( -

{t.adr.lastUpdated}: {updatedAt}

+

{t.adr.lastUpdated}: {updatedAt}

)}