Nodes
@@ -538,7 +963,12 @@ export function GraphTab({ project }: GraphTabProps) {
setSelectedPath(null);
setSelectedNode(null);
setCameraTarget(null);
- fetchOverview(project, budget.value);
+ fetchOverview(
+ project,
+ budget.value,
+ "code",
+ view.kind === "region" ? `region:${view.region.id}` : undefined,
+ );
}}
>
Refresh
@@ -585,10 +1015,25 @@ export function GraphTab({ project }: GraphTabProps) {
allEdges={filteredData.edges}
project={project}
repoInfo={repoInfo}
+ scopeNote={(() => {
+ if (view.kind !== "region" || selectedNode.in_calls === undefined)
+ return null;
+ const visible = filteredData.edges.filter(
+ (e) => e.target === selectedNode.id && e.type === "CALLS",
+ ).length;
+ const outside = selectedNode.in_calls - visible;
+ return outside > 0
+ ? `+${outside.toLocaleString("en-US")} caller${outside === 1 ? "" : "s"} outside this region`
+ : null;
+ })()}
onClose={() => {
setSelectedNode(null);
setHighlightedIds(null);
setSelectedPath(null);
+ onRouteChange?.(
+ null,
+ view.kind === "region" ? String(view.region.id) : null,
+ );
}}
onNavigate={handleNavigateToNode}
/>
diff --git a/graph-ui/src/components/HaloLayer.tsx b/graph-ui/src/components/HaloLayer.tsx
new file mode 100644
index 000000000..c5adc513b
--- /dev/null
+++ b/graph-ui/src/components/HaloLayer.tsx
@@ -0,0 +1,73 @@
+import { useMemo } from "react";
+import * as THREE from "three";
+import type { GraphNode } from "../lib/types";
+
+/* Landmark halos: the top hubs get a soft additive corona so the scene has
+ * stable visual anchors while panning — the CodeCity landmark idea. Kept to
+ * a handful so the halo stays a landmark, not decoration. */
+
+const HALO_COUNT = 12;
+
+let haloSprite: THREE.CanvasTexture | null = null;
+function getHaloSprite(): THREE.CanvasTexture {
+ if (haloSprite) return haloSprite;
+ const size = 128;
+ const canvas = document.createElement("canvas");
+ canvas.width = size;
+ canvas.height = size;
+ const ctx = canvas.getContext("2d")!;
+ const gradient = ctx.createRadialGradient(
+ size / 2,
+ size / 2,
+ 0,
+ size / 2,
+ size / 2,
+ size / 2,
+ );
+ gradient.addColorStop(0, "rgba(255,255,255,0.9)");
+ gradient.addColorStop(0.35, "rgba(255,255,255,0.25)");
+ gradient.addColorStop(1, "rgba(255,255,255,0)");
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, size, size);
+ haloSprite = new THREE.CanvasTexture(canvas);
+ return haloSprite;
+}
+
+export function HaloLayer({ nodes }: { nodes: GraphNode[] }) {
+ const { geometry, material } = useMemo(() => {
+ const landmarks = [...nodes]
+ .sort((a, b) => b.size - a.size)
+ .slice(0, Math.min(HALO_COUNT, nodes.length));
+ const positions = new Float32Array(landmarks.length * 3);
+ const colors = new Float32Array(landmarks.length * 3);
+ const temp = new THREE.Color();
+ landmarks.forEach((node, index) => {
+ positions[index * 3] = node.x;
+ positions[index * 3 + 1] = node.y;
+ positions[index * 3 + 2] = node.z;
+ temp.set(node.color);
+ colors[index * 3] = temp.r;
+ colors[index * 3 + 1] = temp.g;
+ colors[index * 3 + 2] = temp.b;
+ });
+ const geometry = new THREE.BufferGeometry();
+ geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
+ geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
+ const sizes = landmarks.map((node) => node.size);
+ const haloSize = Math.max(...sizes, 1) * 6;
+ const material = new THREE.PointsMaterial({
+ size: haloSize,
+ map: getHaloSprite(),
+ vertexColors: true,
+ transparent: true,
+ opacity: 0.28,
+ depthWrite: false,
+ blending: THREE.AdditiveBlending,
+ sizeAttenuation: true,
+ });
+ return { geometry, material };
+ }, [nodes]);
+
+ if (nodes.length === 0) return null;
+ return
;
+}
diff --git a/graph-ui/src/components/MetricChip.tsx b/graph-ui/src/components/MetricChip.tsx
new file mode 100644
index 000000000..0a9c984b6
--- /dev/null
+++ b/graph-ui/src/components/MetricChip.tsx
@@ -0,0 +1,34 @@
+/* The wiki affordance: any metric name in the UI becomes a dotted-underline
+ * chip that opens the metric wiki panel — the label itself is the door to
+ * the definition. Inherits the surrounding typography so wrapping an
+ * existing label changes nothing but the underline. Dwelling (or focusing)
+ * shows a WikiHoverCard preview of the entry, which replaces the old native
+ * title tooltip — two tooltips on one chip would fight. */
+import { useRef, type ReactNode } from "react";
+import { wikiEntry } from "../wiki/entries";
+import { WikiHoverCard } from "./WikiHoverCard";
+
+export function MetricChip({
+ slug,
+ onOpen,
+ children,
+}: {
+ slug: string;
+ onOpen: (slug: string) => void;
+ children?: ReactNode;
+}) {
+ const anchorRef = useRef
(null);
+ return (
+ <>
+ onOpen(slug)}
+ className="[font:inherit] [letter-spacing:inherit] text-inherit align-baseline text-left border-b border-dotted border-foreground/40 hover:border-primary hover:text-primary transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary rounded-none"
+ >
+ {children ?? wikiEntry(slug)?.term ?? slug}
+
+
+ >
+ );
+}
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 ? "▴" : "▾"}
+
+
+ {!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}
{copied ? "✓ Copied — paste it to your agent" : "Copy agent prompt"}
@@ -137,14 +137,14 @@ export function MissedCallout({ node, project, onClose }: MissedCalloutProps) {
href={buildIssueUrl(issuesUrl, path, project)}
target="_blank"
rel="noreferrer"
- className="text-[12px] font-medium rounded-md border border-white/10 bg-white/[0.03] text-foreground/80 hover:bg-white/[0.07] px-3 py-1.5 transition-all"
+ className="text-[12px] font-medium rounded-md border border-white/10 bg-popover text-foreground/80 hover:bg-white/[0.07] px-3 py-1.5 transition-all"
>
File a GitHub issue (prefilled) ↗
)}
-
+
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 (
+
+ );
+ }
+
+ return (
+
+ {/* Breadcrumbs */}
+
+ {crumbs.map((crumb, index) => (
+
+ {index > 0 && / }
+ onNavigatePath(crumb.path)}
+ className={`text-[12px] transition-colors ${
+ index === crumbs.length - 1
+ ? "text-foreground/80 font-medium"
+ : "text-foreground/40 hover:text-primary"
+ }`}
+ >
+ {crumb.label}
+
+
+ ))}
+
+ {(["map", "bars"] as const).map((mode) => (
+ switchView(mode)}
+ className={`px-2 py-0.5 rounded text-[12px] transition-colors ${
+ viewMode === mode
+ ? "bg-primary/20 text-primary"
+ : "text-foreground/45 hover:text-foreground/70"
+ }`}
+ >
+ {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 (
+
+ child.kind === "dir" ? onNavigatePath(child.path) : openFile(child)
+ }
+ className="flex items-center gap-2 w-full text-left px-2 py-[5px] rounded-md hover:bg-surface-3 transition-colors group"
+ >
+
+
+ {child.name}
+ {child.kind === "dir" ? "/" : ""}
+
+
+
+
+
+ {child.symbols.toLocaleString("en-US")}
+
+
+ );
+ })}
+
+ ) : (
+
+
+
+
+
+
+
+ {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}
+
+
setSelectedFile(null)}
+ className="text-foreground/40 hover:text-foreground/60 text-[15px] leading-none"
+ >
+ ×
+
+
+
+ {selectedFile.symbols.toLocaleString("en-US")} symbols
+ {selectedFile.missed ? " · not fully indexed" : ""}
+
+
+
+ {fileSymbols === null ? (
+
Loading…
+ ) : fileSymbols.length === 0 ? (
+
No symbols found.
+ ) : (
+ fileSymbols.map((symbol) => (
+
onOpenSymbol({ qn: symbol.qualified_name })}
+ className="flex items-center gap-2 w-full text-left px-4 py-[4px] text-[13px] hover:bg-surface-3 transition-colors group"
+ >
+
+ {symbol.name}
+
+
+ {symbol.label}
+
+
+ ))
+ )}
+
+ {selectedFile.region !== undefined && regionMeta.get(selectedFile.region) && (
+
onOpenRegion(selectedFile.region!)}
+ className="px-4 py-2.5 border-t border-border/30 text-left text-[13px] text-primary/70 hover:text-primary transition-colors"
+ >
+ region: {regionMeta.get(selectedFile.region)!.name} — view in galaxy →
+
+ )}
+
+ )}
+
+
+ );
+}
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({
setCode(null) : loadCode}
disabled={codeLoading}
- className="px-2.5 py-1 rounded-md bg-primary/15 text-primary text-[11px] font-medium hover:bg-primary/25 transition-colors disabled:opacity-50"
+ className="px-2.5 py-1 rounded-md bg-primary/15 text-primary text-[13px] font-medium hover:bg-primary/25 transition-colors disabled:opacity-50"
>
{codeLoading ? "Loading…" : code ? "Hide code" : "Show code"}
@@ -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) => (
+ onNavigate(c.node)}
+ className="flex items-center gap-1.5 w-full text-left px-2 py-[4px] rounded-md hover:bg-surface-3 text-[13px] transition-colors group"
+ >
+ {icon}
+
+ {c.node.name}
+ {c.node.label}
+
+ ))}
+ {remaining > 0 && (
+ setVisible((v) => v + 200)}
+ className="w-full text-left px-2 py-1 text-[12px] text-primary/70 hover:text-primary transition-colors"
+ >
+ Show {Math.min(remaining, 200)} more ({remaining} hidden)
+
+ )}
+
+
+ );
+}
+
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) => (
-
onNavigate(c.node)}
- className="flex items-center gap-1.5 w-full text-left px-2 py-[4px] rounded-md hover:bg-white/[0.04] text-[11px] transition-colors group"
- >
- {icon}
-
- {c.node.name}
- {c.node.label}
-
- ))}
- {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}
+
+ ) : (
+
{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 (
+
+ {status === "answers" ? (
+
+ ) : status === "partial" ? (
+ <>
+
+
+ >
+ ) : (
+
+ )}
+
+ );
+}
+
+/* "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 (
+
+
+
+ {t.questions.title}
+
+
+ {t.questions.summary(counts.answers, counts.partial, counts.lacks)}
+
+
+ {open ? "▾" : "▸"}
+
+
+ {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 ? (
+
onOpenTab(tab)}
+ className="flex items-start gap-3 w-full text-left px-4 py-2 hover:bg-surface-3 transition-colors group/question"
+ >
+ {inner(true)}
+
+ ) : (
+
+ {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 (
+
+ );
+ }
+ if (!arch && !regions) {
+ return (
+
+ );
+ }
+
+ 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(" · ")}
+
+
+
onOpenRegion(coupling.source.id)}
+ className="text-[12px] text-primary/80 hover:text-primary shrink-0 transition-colors"
+ >
+ {t.overview.inspect}
+
+
{
+ dismiss(key);
+ setDismissedTick((tick) => tick + 1);
+ }}
+ className="text-[12px] text-foreground/30 hover:text-foreground/60 shrink-0 transition-colors"
+ title={t.overview.intendedTitle}
+ >
+ {t.overview.intended}
+
+
+ ))}
+
+
+ )}
+
+ {/* 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 && (
+
setIncludeTests((value) => !value)}
+ className="text-[12px] text-foreground/40 hover:text-foreground/70 transition-colors"
+ >
+ {includeTests
+ ? t.overview.hideTestCode
+ : t.overview.testRegionsHidden(hiddenTestRegions)}
+
+ )}
+
+
+ {displayRegions.slice(0, 12).map((region) => (
+
onOpenRegion(region.id)}
+ className="text-left rounded-md border border-border/40 bg-card hover:bg-surface-3 hover:border-primary/30 p-3 transition-all group"
+ >
+
+
+
+ {region.name}
+
+
+
+
+ {t.overview.regionMeta(region.members, region.cohesion)}
+
+
r.cohesion)}
+ own={region.cohesion}
+ />
+
+ {region.why && (
+
+ {localizeRegionWhy(region.why, t.regions)}
+
+ )}
+
+ ))}
+
+ {displayRegions.length > 12 && (
+
+ {t.overview.allRegions(displayRegions.length)}
+
+ )}
+
+
+ {/* Boundary spanners — the seam-stitching metric, distinct from
+ * degree hubs (which find utilities). */}
+ {bridges.length > 0 && (
+
+
+ {t.overview.bridgesHeading}
+
+ {bridges.map((bridge) => (
+
+ bridge.qualified_name && onOpenSymbol(bridge.qualified_name)}
+ className="text-[13px] font-mono text-foreground/65 hover:text-primary truncate text-left transition-colors"
+ title={bridge.file_path}
+ >
+ {bridge.name}
+
+
+ {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) => (
+
+ onOpenSymbol(hotspot.qn)}
+ className="text-[13px] font-mono text-foreground/65 hover:text-primary truncate flex-1 text-left transition-colors"
+ title={hotspot.qn}
+ >
+ {shortQn(hotspot.qn)}
+
+
+ {Number(hotspot.fan_in).toLocaleString("en-US")}
+
+
+ ))}
+ {hotspots.length === 0 && (
+
{t.overview.noHotspots}
+ )}
+
+
+
+ {t.overview.entryPoints}
+
+ {entries.slice(0, 8).map((entry) => (
+
onOpenSymbol(entry.qn)}
+ className="flex items-center gap-2 w-full text-left py-[3px] group/entry"
+ title={entry.qn}
+ >
+
+ {shortQn(entry.qn)}
+
+
+ {entry.file}
+
+
+ ))}
+
+ {t.overview.followFlows}
+
+
+
+
+ {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 (
+
+
{
+ setOpen((v) => !v);
+ load();
+ }}
+ className={`flex items-center gap-1.5 px-2.5 py-1 rounded-md border text-[13px] transition-all max-w-[280px] ${
+ selected
+ ? "bg-popover border-border/40 text-foreground/85 hover:border-border"
+ : "bg-primary/15 border-primary/40 text-primary hover:bg-primary/25"
+ }`}
+ title={selected ?? "Choose which indexed project to explore"}
+ >
+ {selected ?? labels.select}
+ ▾
+
+ {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) => (
+
{
+ setOpen(false);
+ onSelect(project.name);
+ }}
+ className={`flex items-center gap-2 w-full text-left px-3 py-1.5 transition-colors hover:bg-surface-4 ${
+ project.name === selected ? "text-primary" : "text-foreground/75"
+ }`}
+ >
+ {project.name}
+ {project.name === selected && (
+ ●
+ )}
+
+ ))}
+
+
+ {
+ setOpen(false);
+ onAllProjects();
+ }}
+ className="w-full text-left px-3 py-1.5 text-[13px] text-foreground/55 hover:text-foreground/85 hover:bg-surface-4 transition-colors"
+ >
+ {allProjectsLabel} — {labels.manage} →
+
+
+
+ >,
+ 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 (
+ basket.add(item)}
+ disabled={inBasket}
+ className={`${small ? "px-1.5 py-0.5 text-[12px]" : "px-2.5 py-1 text-[13px]"} rounded-md font-medium transition-colors ${
+ inBasket
+ ? "bg-popover text-foreground/40 cursor-default"
+ : "bg-primary/15 text-primary hover:bg-primary/25"
+ }`}
+ title={inBasket ? "Already in the prompt" : "Cite this in the prompt composer"}
+ >
+ {inBasket ? "✓ cited" : "+ prompt"}
+
+ );
+}
+
+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 (
+ basket.setOpen(true)}
+ className="fixed bottom-4 right-4 z-40 px-3 py-2 rounded-full bg-primary/20 text-primary text-[12px] font-medium border border-primary/30 backdrop-blur-md hover:bg-primary/30 transition-colors shadow-lg"
+ >
+ Prompt {basket.items.length > 0 ? `(${basket.items.length})` : ""}
+
+ );
+ }
+
+ return (
+
+
+
+ Prompt composer ({basket.items.length} cited)
+
+
+ {basket.items.length > 0 && (
+
+ clear
+
+ )}
+ basket.setOpen(false)}
+ className="text-foreground/40 hover:text-foreground/60 text-[15px] leading-none transition-colors"
+ >
+ ×
+
+
+
+
+
+ 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)}
+
+ basket.remove(basketKey(item))}
+ className="text-foreground/35 hover:text-foreground/50 text-[12px] transition-colors"
+ >
+ ×
+
+
+ ))
+ )}
+
+
+
+ {prompt}
+
+
+
+ {
+ navigator.clipboard?.writeText(prompt).then(() => setCopied(true));
+ }}
+ className="px-3 py-1.5 rounded-md bg-primary/20 text-primary text-[12px] font-medium hover:bg-primary/30 transition-colors"
+ >
+ {copied ? "Copied ✓" : "Copy 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)}
+
+ )}
+
+
onOpen(region)}
+ className="mt-3 px-2.5 py-1 rounded-md bg-primary/15 text-primary text-[13px] font-medium hover:bg-primary/25 transition-colors"
+ >
+ Open region →
+
+
+
+ {[
+ { 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 (
{ setExpanded(!expanded); onSelect(dir.fullPath, dir.nodeIds, undefined); }}
+ onClick={() => { setExpanded(!expanded); onSelect(dir.fullPath, dir.nodeIds); }}
className={`flex items-center gap-1.5 w-full text-left px-3 py-[5px] text-[12px] transition-colors ${
- isSelected ? "bg-primary/10 text-primary" : "text-foreground/60 hover:text-foreground/80 hover:bg-white/[0.03]"
+ isSelected ? "bg-primary/10 text-primary" : "text-foreground/60 hover:text-foreground/80 hover:bg-surface-3"
}`}
style={{ paddingLeft: `${depth * 16 + 12}px` }}
>
-
+
{(dir.children.size > 0 || dir.directNodes.length > 0) ? (expanded ? "▾" : "▸") : ""}
{dir.name}
- {dir.nodeIds.size}
+ {dir.nodeIds.size}
{expanded && (
<>
- {sorted.map((c) =>
)}
+ {sorted.map((c) =>
)}
{sortedNodes.map((gn) => (
onSelect(dir.fullPath + "/" + gn.name, new Set([gn.id]), gn)}
- className="flex items-center gap-1.5 w-full text-left px-3 py-[3px] text-[11px] text-foreground/40 hover:text-foreground/60 hover:bg-white/[0.02] transition-colors"
+ onClick={() => onSelectNode(gn)}
+ className="flex items-center gap-1.5 w-full text-left px-3 py-[3px] text-[13px] text-foreground/40 hover:text-foreground/60 hover:bg-surface-3 transition-colors"
style={{ paddingLeft: `${(depth+1) * 16 + 12}px` }}
>
{gn.name}
- {gn.label}
+ {gn.label}
))}
>
@@ -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) => (
onSelectPath(n.file_path ?? "", new Set([n.id]), n)}
- className="flex items-center gap-2 w-full text-left px-4 py-1.5 text-[11px] hover:bg-white/[0.03] transition-colors"
+ onClick={() => onSelectNode(n)}
+ className="flex items-center gap-2 w-full text-left px-4 py-1.5 text-[13px] hover:bg-surface-3 transition-colors"
>
{n.name}
- {n.file_path}
+ {n.file_path}
))
)
) : (
- topLevel.map((c) =>
)
+ topLevel.map((c) =>
)
)}
@@ -167,7 +171,7 @@ export function Sidebar({ nodes, onSelectPath, selectedPath }: SidebarProps) {
onSelectPath("", new Set())}
- className="w-full px-3 py-1.5 rounded-lg bg-white/[0.04] hover:bg-white/[0.07] text-[11px] text-foreground/40 font-medium transition-all"
+ className="w-full px-3 py-1.5 rounded-md bg-popover hover:bg-white/[0.07] text-[13px] text-foreground/40 font-medium transition-all"
>
{t.graph.clearSelection}
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 }) {
<>
{ setOpen(true); fetchAdr(); }}
- className={`px-2.5 py-1 rounded-lg text-[10px] font-medium transition-all ${
+ className={`px-2.5 py-1 rounded-md text-[12px] font-medium transition-all ${
hasAdr
? "bg-accent/15 text-accent hover:bg-accent/25"
- : "bg-white/[0.03] text-foreground/25 hover:text-foreground/40 hover:bg-white/[0.06]"
+ : "bg-popover text-foreground/40 hover:text-foreground/40 hover:bg-surface-3"
}`}
>
{hasAdr ? "ADR" : "+ ADR"}
@@ -121,18 +121,18 @@ function AdrButton({ project }: { project: string }) {
{t.adr.title}
-
{project}
+
{project}
-
setOpen(false)} className="text-foreground/20 hover:text-foreground/50 text-[16px] p-1">×
+
setOpen(false)} className="text-foreground/35 hover:text-foreground/50 text-[16px] p-1">×
{updatedAt && (
- {t.adr.lastUpdated}: {updatedAt}
+ {t.adr.lastUpdated}: {updatedAt}
)}