Files
codebase-memory-mcp/graph-ui/src/components/GraphTab.tsx
T
Martin Vogel bbbc015304 feat(ui): CBM Atlas region scene, deep links, sidebar selection, uncapped connections
The graph tab becomes CBM Atlas and gains the region level as its default
for large projects:

- Projects above 5,000 nodes open on the REGION SCENE: one body per Leiden
  call community (folder groups as fallback), weighted arcs between them,
  a region list on the left and a region panel (provenance, cohesion, top
  callables) on the right. Double-click or 'Open region' loads that
  region's full-detail layout (scope=region:N) with a '< Regions'
  breadcrumb; 'Load full galaxy' keeps the classic whole-graph view one
  click away. Small projects skip the region hop entirely.
- Deep links (#564): ?node= and ?region= join ?tab=&project= — a selected
  node or opened region survives reload and back/forward, and can be
  shared.
- #1197: clicking a symbol row in the sidebar (tree or search) selects the
  node itself — scene highlight AND detail panel; selecting a directory
  clears a stale panel instead of leaving the previous node in it.
- #1198: connection lists page with 'Show N more' instead of a silent cap
  of 25.
- Rename: the UI announces itself as CBM Atlas (header + title).

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
2026-08-23 22:22:56 +02:00

869 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 } from "../lib/regions";
import { RegionPanel } from "./RegionPanel";
import { GraphLoader } from "./GraphLoader";
import { DisplaySettingsMenu } from "./DisplaySettingsMenu";
import {
loadDisplaySettings,
saveDisplaySettings,
type DisplaySettings,
} from "../lib/density";
import {
GraphScene,
computeCameraTarget,
type CameraTarget,
} from "./GraphScene";
import { Sidebar } from "./Sidebar";
import { FilterPanel } from "./FilterPanel";
import { NodeDetailPanel } from "./NodeDetailPanel";
import { MissedCallout } from "./MissedCallout";
import { ResizeHandle } from "./ResizeHandle";
import { ErrorBoundary } from "./ErrorBoundary";
import type { GraphNode, GraphData, Region, RegionsPayload, RepoInfo } from "../lib/types";
import { colorForStatus } from "../lib/colors";
/* Persist panel widths */
function loadWidth(key: string, fallback: number): number {
try {
const v = localStorage.getItem(key);
if (v) return Math.max(150, Math.min(600, parseInt(v, 10)));
} catch { /* ignore */ }
return fallback;
}
function saveWidth(key: string, value: number) {
try { localStorage.setItem(key, String(Math.round(value))); } catch { /* ignore */ }
}
/* Persist the node budget per project */
function budgetKey(project: string): string {
return `cbm-node-budget:${project}`;
}
function loadNodeBudget(project: string): number {
try {
const v = localStorage.getItem(budgetKey(project));
if (v) return clampNodeBudget(parseInt(v, 10));
} catch { /* ignore */ }
return GRAPH_RENDER_NODE_LIMIT;
}
function saveNodeBudget(project: string, value: number) {
try { localStorage.setItem(budgetKey(project), String(value)); } catch { /* ignore */ }
}
interface GraphTabProps {
project: string | null;
/* Deep-link state from the URL (?node=&region=) 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,
routeNode = null,
routeRegion = null,
onRouteChange,
}: GraphTabProps) {
const { data, loading, error, progress, fetchOverview } = useGraphData();
const [view, setView] = useState<AtlasView>({ kind: "deciding" });
const viewRef = useRef<AtlasView>(view);
viewRef.current = view;
const [regionsPayload, setRegionsPayload] = useState<RegionsPayload | null>(null);
const [regionsError, setRegionsError] = useState<string | null>(null);
const [selectedRegion, setSelectedRegion] = useState<Region | null>(null);
const [highlightedIds, setHighlightedIds] = useState<Set<number> | null>(null);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
const [cameraTarget, setCameraTarget] = useState<CameraTarget | null>(null);
const [repoInfo, setRepoInfo] = useState<RepoInfo | null>(null);
const [showLabels, setShowLabels] = useState(true);
const [display, setDisplay] = useState<DisplaySettings>(() =>
loadDisplaySettings(),
);
const updateDisplay = useCallback((next: DisplaySettings) => {
setDisplay(next);
saveDisplaySettings(next);
}, []);
const [leftWidth, setLeftWidth] = useState(() => loadWidth("cbm-left-w", 260));
const [rightWidth, setRightWidth] = useState(() => loadWidth("cbm-right-w", 280));
const limitNotice = formatGraphLimitNotice(data);
/* Node budget — keyed to its project so switching projects re-reads the
* persisted value and triggers exactly one fetch. */
const [budget, setBudget] = useState<{ project: string | null; value: number }>(
{ project: null, value: GRAPH_RENDER_NODE_LIMIT },
);
const [budgetDraft, setBudgetDraft] = useState(String(GRAPH_RENDER_NODE_LIMIT));
const commitBudget = useCallback(() => {
const parsed = clampNodeBudget(parseInt(budgetDraft, 10));
setBudgetDraft(String(parsed));
if (project && parsed !== budget.value) {
saveNodeBudget(project, parsed);
setBudget({ project, value: parsed });
}
}, [budgetDraft, project, budget.value]);
/* Filter state — all enabled by default */
const [enabledLabels, setEnabledLabels] = useState<Set<string>>(new Set());
const [enabledEdgeTypes, setEnabledEdgeTypes] = useState<Set<string>>(new Set());
/* Missed skeleton (#963): the file structure of files the indexer could
* not fully cover, shown as a white satellite cluster beside the code
* galaxy. Toggle only hides/shows it — the data rides along with every
* code-graph layout. */
const [showMissedSkeleton, setShowMissedSkeleton] = useState(true);
/* Dead-code view: recolor by status + status-based filters */
const [deadCodeView, setDeadCodeView] = useState(false);
const [showOnlyDead, setShowOnlyDead] = useState(false);
const [hideEntryPoints, setHideEntryPoints] = useState(false);
const [hideTests, setHideTests] = useState(false);
/* Initialize filters when data loads */
useEffect(() => {
if (!data) return;
const labels = new Set(data.nodes.map((n) => n.label));
const types = new Set(data.edges.map((e) => e.type));
for (const lp of data.linked_projects ?? []) {
for (const n of lp.nodes) labels.add(n.label);
for (const e of lp.edges) types.add(e.type);
for (const e of lp.cross_edges) types.add(e.type);
}
setEnabledLabels(labels);
setEnabledEdgeTypes(types);
}, [data]);
/* Compute filtered data */
const filteredData: GraphData | null = useMemo(() => {
if (!data) return null;
/* Status-based filters (dead-code view) */
const statusOk = (n: GraphNode) => {
if (showOnlyDead && n.status !== "dead") return false;
if (hideEntryPoints && n.status === "entry") return false;
if (hideTests && n.status === "test") return false;
return true;
};
/* Recolor by status when the dead-code view is on */
const paint = (n: GraphNode): GraphNode =>
deadCodeView ? { ...n, color: colorForStatus(n.status) } : n;
const keep = (n: GraphNode) => enabledLabels.has(n.label) && statusOk(n);
const nodes = data.nodes.filter(keep).map(paint);
const nodeIds = new Set(nodes.map((n) => n.id));
const edges = data.edges.filter(
(e) =>
enabledEdgeTypes.has(e.type) &&
nodeIds.has(e.source) &&
nodeIds.has(e.target),
);
const linked_projects = data.linked_projects?.map((lp) => {
const lpNodes = lp.nodes.filter(keep).map(paint);
const lpIds = new Set(lpNodes.map((n) => n.id));
const lpEdges = lp.edges.filter(
(e) =>
enabledEdgeTypes.has(e.type) && lpIds.has(e.source) && lpIds.has(e.target),
);
const crossEdges = lp.cross_edges.filter(
(e) =>
enabledEdgeTypes.has(e.type) && nodeIds.has(e.source) && lpIds.has(e.target),
);
return { ...lp, nodes: lpNodes, edges: lpEdges, cross_edges: crossEdges };
});
return { nodes, edges, total_nodes: data.total_nodes, linked_projects };
}, [
data,
enabledLabels,
enabledEdgeTypes,
deadCodeView,
showOnlyDead,
hideEntryPoints,
hideTests,
]);
/* Re-read the persisted budget when the project changes… */
useEffect(() => {
if (project) {
const value = loadNodeBudget(project);
setBudget({ project, value });
setBudgetDraft(String(value));
}
}, [project]);
/* …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) 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);
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(() => {
const mg = data?.missed_graph;
if (!mg || mg.nodes.length === 0) return null;
const nodes = mg.nodes.map((n) => ({
...n,
x: n.x + mg.offset.x,
y: n.y + mg.offset.y,
z: n.z + mg.offset.z,
color: "#e9eef5",
}));
return { nodes, edges: mg.edges, ids: new Set(nodes.map((n) => n.id)) };
}, [data]);
/* Overview framing: both clusters (galaxy + skeleton) in one shot. */
const overviewTarget = useMemo(() => {
if (!data) return null;
const all = missedSkeleton ? [...data.nodes, ...missedSkeleton.nodes] : data.nodes;
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. */
useEffect(() => {
if (missedSkeleton && overviewTarget) {
setCameraTarget(overviewTarget);
}
}, [missedSkeleton, 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
* is no code node to click). No-op during normal galaxy exploration. */
const handleBackgroundClick = useCallback(() => {
if (selectedNode && missedSkeleton?.ids.has(selectedNode.id) && overviewTarget) {
setSelectedNode(null);
setHighlightedIds(null);
setSelectedPath(null);
setCameraTarget(overviewTarget);
}
}, [selectedNode, missedSkeleton, overviewTarget]);
/* Fetch git remote metadata for GitHub deep-links */
useEffect(() => {
if (!project) {
setRepoInfo(null);
return;
}
let cancelled = false;
fetch(`/api/repo-info?project=${encodeURIComponent(project)}`)
.then((r) => (r.ok ? r.json() : null))
.then((d) => {
if (!cancelled && d && !d.error) setRepoInfo(d as RepoInfo);
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [project]);
const handleSelectPath = useCallback(
(path: string, nodeIds: Set<number>) => {
if (!filteredData || !path || nodeIds.size === 0) {
setHighlightedIds(null);
setSelectedPath(null);
setCameraTarget(null);
return;
}
setSelectedPath(path);
setHighlightedIds(nodeIds);
setSelectedNode(null); /* #1197: never leave a stale node in the panel */
setCameraTarget(computeCameraTarget(filteredData.nodes, nodeIds));
},
[filteredData],
);
const handleNodeClick = useCallback(
(node: GraphNode) => {
if (!filteredData) return;
/* Clicking the missed skeleton re-centers the camera on that whole
* cluster (it's small — the natural focus unit is the skeleton, not a
* single node); clicking any code node flies back to the code galaxy
* via the normal per-node focus below. */
if (missedSkeleton?.ids.has(node.id)) {
setSelectedNode(node);
setHighlightedIds(null);
setSelectedPath(node.file_path ?? null);
setCameraTarget(computeCameraTarget(missedSkeleton.nodes, missedSkeleton.ids));
return;
}
setSelectedNode(node);
/* Highlight the node and its direct connections */
const connectedIds = new Set([node.id]);
for (const edge of filteredData.edges) {
if (edge.source === node.id) connectedIds.add(edge.target);
if (edge.target === node.id) connectedIds.add(edge.source);
}
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, onRouteChange],
);
const handleNodeClickRef = useRef<typeof handleNodeClick | null>(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) => {
handleNodeClick(node);
},
[handleNodeClick],
);
const toggleLabel = useCallback((label: string) => {
setEnabledLabels((prev) => {
const next = new Set(prev);
if (next.has(label)) next.delete(label);
else next.add(label);
return next;
});
}, []);
const toggleEdgeType = useCallback((type: string) => {
setEnabledEdgeTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) next.delete(type);
else next.add(type);
return next;
});
}, []);
const enableAll = useCallback(() => {
if (!data) return;
const labels = new Set(data.nodes.map((n) => n.label));
const types = new Set(data.edges.map((e) => e.type));
for (const lp of data.linked_projects ?? []) {
for (const n of lp.nodes) labels.add(n.label);
for (const e of lp.edges) types.add(e.type);
for (const e of lp.cross_edges) types.add(e.type);
}
setEnabledLabels(labels);
setEnabledEdgeTypes(types);
}, [data]);
const disableAll = useCallback(() => {
setEnabledLabels(new Set());
setEnabledEdgeTypes(new Set());
}, []);
if (!project) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-white/30 text-sm">
Select a project from the Projects tab
</p>
</div>
);
}
if (view.kind === "deciding") {
return (
<div className="flex items-center justify-center h-full">
<p className="text-white/30 text-sm">Mapping regions…</p>
</div>
);
}
/* ── Region scene: the coarsest level of detail ──────────────── */
if (view.kind === "regions" && regionGraph) {
const selected = selectedRegion;
return (
<div className="h-full flex">
<div
className="border-r border-border/30 flex flex-col h-full bg-[#0b1920]/90 backdrop-blur-md shrink-0"
style={{ width: leftWidth }}
>
<div className="px-4 pt-3 pb-2 shrink-0">
<span className="text-[11px] font-medium text-foreground/50 uppercase tracking-widest">
Regions
</span>
<p className="text-[10px] text-foreground/25 mt-1">
{regionsPayload?.method === "leiden+folders"
? "call communities + folder groups"
: "folder groups"}
</p>
</div>
<div className="flex-1 min-h-0 overflow-y-auto py-1">
{displayRegions.map((region) => (
<button
key={region.id}
onClick={() => setSelectedRegion(region)}
onDoubleClick={() => openRegion(region)}
className={`flex items-center gap-2 w-full text-left px-4 py-[5px] text-[12px] transition-colors ${
selected?.id === region.id
? "bg-primary/10 text-primary"
: "text-foreground/60 hover:text-foreground/80 hover:bg-white/[0.03]"
}`}
>
<span
className="w-[7px] h-[7px] rounded-full shrink-0"
style={{ backgroundColor: region.color }}
/>
<span className="truncate">{region.name}</span>
<span className="text-foreground/15 ml-auto text-[10px] tabular-nums shrink-0">
{region.members.toLocaleString("en-US")}
</span>
</button>
))}
</div>
</div>
<ResizeHandle
side="left"
onResize={(d) => {
setLeftWidth((w) => {
const nw = Math.max(150, Math.min(500, w + d));
saveWidth("cbm-left-w", nw);
return nw;
});
}}
/>
<div className="flex-1 relative overflow-hidden">
<ErrorBoundary>
<GraphScene
data={regionGraph}
missed={null}
highlightedIds={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)}
/>
</ErrorBoundary>
<div className="absolute top-4 left-4 text-[11px] text-white/30 pointer-events-none font-mono">
<p>
{displayRegions.length} regions /{" "}
{regionsPayload?.total_nodes.toLocaleString("en-US")} nodes
</p>
<p className="text-white/20 mt-0.5">
double-click a region (or use its panel) to open it
</p>
</div>
<div className="absolute top-4 right-4 flex gap-2 items-center">
<Button variant="outline" size="sm" onClick={loadFullGalaxy}>
Load full galaxy
</Button>
<DisplaySettingsMenu settings={display} onChange={updateDisplay} />
</div>
</div>
{selected && (
<>
<ResizeHandle
side="right"
onResize={(d) => {
setRightWidth((w) => {
const nw = Math.max(200, Math.min(500, w + d));
saveWidth("cbm-right-w", nw);
return nw;
});
}}
/>
<div
className="border-l border-border shrink-0 h-full overflow-hidden"
style={{ width: rightWidth, maxHeight: "100%" }}
>
<RegionPanel
region={selected}
onOpen={openRegion}
onClose={() => setSelectedRegion(null)}
/>
</div>
</>
)}
</div>
);
}
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<GraphLoader nodeBudget={budget.value} progress={progress} />
</div>
);
}
if (error) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center p-8">
<p className="text-red-400 text-sm mb-2">{error}</p>
<Button variant="outline" size="sm" onClick={() => fetchOverview(project)}>
Retry
</Button>
</div>
</div>
);
}
/* No data, or the project genuinely has no nodes — there are no filters to
interact with, so show a plain full-screen message. The "all filtered out"
case is handled inside the layout below so the filter sidebar stays put. */
if (!data || !filteredData || data.nodes.length === 0) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-white/30 text-sm">No nodes in this project</p>
</div>
);
}
return (
<div className="h-full flex">
{/* Left sidebar — resizable */}
<div
className="border-r border-border/30 flex flex-col h-full bg-[#0b1920]/90 backdrop-blur-md shrink-0"
style={{ width: leftWidth }}
>
<FilterPanel
data={data}
enabledLabels={enabledLabels}
enabledEdgeTypes={enabledEdgeTypes}
showLabels={showLabels}
onToggleLabel={toggleLabel}
onToggleEdgeType={toggleEdgeType}
onToggleShowLabels={() => setShowLabels((v) => !v)}
onEnableAll={enableAll}
onDisableAll={disableAll}
deadCodeView={deadCodeView}
showOnlyDead={showOnlyDead}
hideEntryPoints={hideEntryPoints}
hideTests={hideTests}
onToggleDeadCodeView={() => setDeadCodeView((v) => !v)}
onToggleShowOnlyDead={() => setShowOnlyDead((v) => !v)}
onToggleHideEntryPoints={() => setHideEntryPoints((v) => !v)}
onToggleHideTests={() => setHideTests((v) => !v)}
missedView={showMissedSkeleton}
missedCount={data?.missed_graph?.nodes.filter((n) => n.label === "File").length ?? 0}
onToggleMissedView={() => setShowMissedSkeleton((v) => !v)}
/>
<Sidebar
nodes={filteredData.nodes}
onSelectPath={handleSelectPath}
onSelectNode={handleNodeClick}
selectedPath={selectedPath}
/>
</div>
<ResizeHandle
side="left"
onResize={(d) => {
setLeftWidth((w) => {
const nw = Math.max(150, Math.min(500, w + d));
saveWidth("cbm-left-w", nw);
return nw;
});
}}
/>
{/* Graph area */}
<div className="flex-1 relative overflow-hidden">
{filteredData.nodes.length === 0 ? (
<div className="flex items-center justify-center h-full">
<div className="text-center">
<p className="text-white/30 text-sm mb-3">All nodes filtered out</p>
<Button size="sm" onClick={enableAll}>
Reset Filters
</Button>
</div>
</div>
) : (
<>
<ErrorBoundary>
<GraphScene
data={filteredData}
missed={showMissedSkeleton ? missedSkeleton : null}
highlightedIds={highlightedIds}
cameraTarget={cameraTarget}
showLabels={showLabels}
display={display}
onNodeClick={handleNodeClick}
onBackgroundClick={handleBackgroundClick}
/>
</ErrorBoundary>
{/* HUD */}
<div className="absolute top-4 left-4 text-[11px] text-white/30 pointer-events-none font-mono">
{regionsError && (
<p className="text-amber-300/60">regions unavailable: {regionsError}</p>
)}
{view.kind === "region" && (
<p className="text-primary/60">region: {view.region.name}</p>
)}
<p>
{filteredData.nodes.length.toLocaleString()} nodes /{" "}
{filteredData.edges.length.toLocaleString()} edges
</p>
{data.nodes.length > filteredData.nodes.length && (
<p className="text-white/25 mt-0.5">
filtered from {data.nodes.length.toLocaleString()}
</p>
)}
{limitNotice && (
<p className="text-amber-300/80 mt-0.5">{limitNotice}</p>
)}
{highlightedIds && highlightedIds.size > 0 && (
<p className="text-cyan-400/50 mt-0.5">
{highlightedIds.size} selected
</p>
)}
</div>
<div className="absolute top-4 right-4 flex gap-2 items-center">
{view.kind === "region" && (
<Button variant="outline" size="sm" onClick={backToRegions}>
‹ Regions
</Button>
)}
{highlightedIds && (
<Button
size="sm"
onClick={() => {
setHighlightedIds(null);
setSelectedPath(null);
setSelectedNode(null);
setCameraTarget(null);
}}
>
Clear selection
</Button>
)}
<div className="flex items-center gap-1.5 h-8 px-2 rounded-md border border-border/50 bg-[#0b1920]/80 backdrop-blur-sm">
<label
htmlFor="node-budget"
className="text-[10px] uppercase tracking-wider text-white/40"
>
Nodes
</label>
<input
id="node-budget"
type="number"
min={GRAPH_NODE_BUDGET_STEP}
max={GRAPH_NODE_BUDGET_MAX}
step={GRAPH_NODE_BUDGET_STEP}
value={budgetDraft}
onChange={(e) => setBudgetDraft(e.target.value)}
onBlur={commitBudget}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
}
}}
className="w-24 bg-transparent text-right text-xs font-mono text-cyan-200/90 outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
aria-label="Node budget: how many nodes to load"
title="How many nodes to load (5,000 steps, edges between loaded nodes follow automatically)"
/>
</div>
<DisplaySettingsMenu settings={display} onChange={updateDisplay} />
<Button
variant="outline"
size="sm"
onClick={() => {
setHighlightedIds(null);
setSelectedPath(null);
setSelectedNode(null);
setCameraTarget(null);
fetchOverview(
project,
budget.value,
"code",
view.kind === "region" ? `region:${view.region.id}` : undefined,
);
}}
>
Refresh
</Button>
</div>
</>
)}
</div>
{/* Right detail panel — resizable */}
{selectedNode && filteredData && (
<>
<ResizeHandle
side="right"
onResize={(d) => {
setRightWidth((w) => {
const nw = Math.max(200, Math.min(500, w + d));
saveWidth("cbm-right-w", nw);
return nw;
});
}}
/>
<div
className="border-l border-border shrink-0 h-full overflow-hidden"
style={{ width: rightWidth, maxHeight: "100%" }}
>
{missedSkeleton?.ids.has(selectedNode.id) ? (
/* Skeleton node: the standard panel (code snippet, callers) is
* meaningless for a not-fully-indexed file — show the coverage
* callout with its report-the-edge-case actions instead. */
<MissedCallout
node={selectedNode}
project={project}
onClose={() => {
setSelectedNode(null);
setHighlightedIds(null);
setSelectedPath(null);
}}
/>
) : (
<NodeDetailPanel
node={selectedNode}
allNodes={filteredData.nodes}
allEdges={filteredData.edges}
project={project}
repoInfo={repoInfo}
onClose={() => {
setSelectedNode(null);
setHighlightedIds(null);
setSelectedPath(null);
onRouteChange?.(
null,
view.kind === "region" ? String(view.region.id) : null,
);
}}
onNavigate={handleNavigateToNode}
/>
)}
</div>
</>
)}
</div>
);
}