mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-30 07:23:47 +08:00
feat(ui): missed skeleton beside the code galaxy with click-to-focus
Render the miss graph side by side with the code graph instead of
swapping layouts:
- /api/layout (code graph) now also attaches "missed_graph": {nodes,
edges, offset} — the shadow-project layout placed below the primary
cluster (same satellite pattern as linked_projects; -Y slot so
cross-repo satellites collide last). graph=missed stays the isolated
view for API users
- the UI paints the skeleton white and ghostly beside the galaxy,
auto-frames BOTH clusters on load, and navigates naturally: clicking
the skeleton flies the camera into it (file labels + detail panel),
clicking a code node flies back to the code side, and clicking empty
space while the skeleton has focus returns to the overview (the
galaxy can be entirely off-screen at that point)
- the sidebar toggle now shows/hides the skeleton (with a missed-file
count) rather than swapping the layout
Verified in the browser: overview composition, skeleton focus with
split.c labeled, and the empty-space return flight; ui/httpd suites and
frontend build + tests green.
Refs #963
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
@@ -22,8 +22,9 @@ interface FilterPanelProps {
|
||||
onToggleShowOnlyDead: () => void;
|
||||
onToggleHideEntryPoints: () => void;
|
||||
onToggleHideTests: () => void;
|
||||
/* Missed graph (#963): file structure of not-fully-indexed files */
|
||||
/* Missed skeleton (#963): white satellite of not-fully-indexed files */
|
||||
missedView: boolean;
|
||||
missedCount: number;
|
||||
onToggleMissedView: () => void;
|
||||
}
|
||||
|
||||
@@ -80,6 +81,7 @@ export function FilterPanel({
|
||||
onToggleHideEntryPoints,
|
||||
onToggleHideTests,
|
||||
missedView,
|
||||
missedCount,
|
||||
onToggleMissedView,
|
||||
}: FilterPanelProps) {
|
||||
const { labelCounts, edgeTypeCounts, statusCounts } = useMemo(() => {
|
||||
@@ -168,25 +170,30 @@ export function FilterPanel({
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Missed graph (#963): swaps the layout to the file structure of files
|
||||
the indexer could not fully cover (best-effort signal). */}
|
||||
{/* Missed skeleton (#963): white satellite cluster of files the indexer
|
||||
could not fully cover, shown beside the code galaxy. Click it to
|
||||
focus; click the code galaxy to come back. */}
|
||||
<div className="px-4 pt-2 border-t border-border/30 space-y-2 shrink-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] text-foreground/30 uppercase tracking-widest">
|
||||
Graph
|
||||
Missed files
|
||||
</span>
|
||||
{missedCount > 0 && (
|
||||
<span className="text-[10px] text-foreground/50 tabular-nums">
|
||||
{missedCount.toLocaleString()} files
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<CheckRow
|
||||
checked={missedView}
|
||||
onToggle={onToggleMissedView}
|
||||
label="Missed files (not fully indexed)"
|
||||
label="Show missed skeleton"
|
||||
/>
|
||||
{missedView && (
|
||||
<p className="text-[9px] leading-snug text-foreground/30">
|
||||
Best-effort: files with unparseable regions or skips. Empty = no
|
||||
known misses (not a completeness guarantee).
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[9px] leading-snug text-foreground/30">
|
||||
{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)."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Dead-code view */}
|
||||
|
||||
@@ -112,22 +112,31 @@ function IdleAutoRotate({
|
||||
|
||||
interface GraphSceneProps {
|
||||
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
|
||||
* galaxy. null hides it. */
|
||||
missed?: { nodes: GraphNode[]; edges: GraphData["edges"] } | null;
|
||||
highlightedIds: Set<number> | null;
|
||||
cameraTarget: CameraTarget | null;
|
||||
showLabels: boolean;
|
||||
display?: DisplaySettings;
|
||||
onNodeClick: (node: GraphNode) => void;
|
||||
/* Fired when a click hits empty space (no node). Used to fly back to the
|
||||
* overview after focusing the missed skeleton. */
|
||||
onBackgroundClick?: () => void;
|
||||
}
|
||||
|
||||
export type { CameraTarget };
|
||||
|
||||
export function GraphScene({
|
||||
data,
|
||||
missed = null,
|
||||
highlightedIds,
|
||||
cameraTarget,
|
||||
showLabels,
|
||||
display = DEFAULT_DISPLAY_SETTINGS,
|
||||
onNodeClick,
|
||||
onBackgroundClick,
|
||||
}: GraphSceneProps) {
|
||||
const [hovered, setHovered] = useState<GraphNode | null>(null);
|
||||
const controlsRef = useRef<OrbitControlsImpl | null>(null);
|
||||
@@ -151,6 +160,7 @@ export function GraphScene({
|
||||
alpha: false,
|
||||
powerPreference: "high-performance",
|
||||
}}
|
||||
onPointerMissed={onBackgroundClick}
|
||||
>
|
||||
<color attach="background" args={["#06090f"]} />
|
||||
<ambientLight intensity={0.5} />
|
||||
@@ -176,6 +186,30 @@ export function GraphScene({
|
||||
/>
|
||||
{showLabels && <NodeLabels nodes={data.nodes} highlightedIds={highlightedIds} />}
|
||||
|
||||
{/* Missed skeleton (#963): white ghost of the not-fully-indexed files.
|
||||
* Clicks route through the same handler — GraphTab re-centers the
|
||||
* camera on the whole skeleton cluster. */}
|
||||
{missed && missed.nodes.length > 0 && (
|
||||
<group>
|
||||
<EdgeLines
|
||||
nodes={missed.nodes}
|
||||
edges={missed.edges}
|
||||
highlightedIds={null}
|
||||
opacity={0.28}
|
||||
brightness={display.edgeBrightness}
|
||||
/>
|
||||
<NodeCloud
|
||||
nodes={missed.nodes}
|
||||
highlightedIds={null}
|
||||
onHover={setHovered}
|
||||
onClick={onNodeClick}
|
||||
opacity={0.6}
|
||||
boost={nodeBoost * 0.75}
|
||||
/>
|
||||
{showLabels && <NodeLabels nodes={missed.nodes} highlightedIds={null} />}
|
||||
</group>
|
||||
)}
|
||||
|
||||
{/* Satellite galaxies for cross-repo linked projects */}
|
||||
{data.linked_projects?.map((lp: LinkedProject) => {
|
||||
const offsetNodes = lp.nodes.map((n) => ({
|
||||
|
||||
@@ -102,10 +102,11 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
const [enabledLabels, setEnabledLabels] = useState<Set<string>>(new Set());
|
||||
const [enabledEdgeTypes, setEnabledEdgeTypes] = useState<Set<string>>(new Set());
|
||||
|
||||
/* Missed graph (#963): render the file structure of files the indexer
|
||||
* could not fully cover instead of the code graph. Server-side option
|
||||
* (`graph=missed` on /api/layout) — toggling refetches. */
|
||||
const [missedView, setMissedView] = useState(false);
|
||||
/* 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);
|
||||
@@ -189,11 +190,53 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
/* …and fetch only once budget and project agree (one fetch per change). */
|
||||
useEffect(() => {
|
||||
if (project && budget.project === project) {
|
||||
fetchOverview(project, budget.value, missedView ? "missed" : "code");
|
||||
fetchOverview(project, budget.value);
|
||||
setHighlightedIds(null);
|
||||
setSelectedPath(null);
|
||||
}
|
||||
}, [project, budget, missedView, fetchOverview]);
|
||||
}, [project, budget, fetchOverview]);
|
||||
|
||||
/* 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(() => {
|
||||
@@ -231,6 +274,19 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
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 */
|
||||
@@ -243,7 +299,7 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
setSelectedPath(node.file_path ?? null);
|
||||
setCameraTarget(computeCameraTarget(filteredData.nodes, connectedIds));
|
||||
},
|
||||
[filteredData],
|
||||
[filteredData, missedSkeleton],
|
||||
);
|
||||
|
||||
const handleNavigateToNode = useCallback(
|
||||
@@ -356,8 +412,9 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
onToggleShowOnlyDead={() => setShowOnlyDead((v) => !v)}
|
||||
onToggleHideEntryPoints={() => setHideEntryPoints((v) => !v)}
|
||||
onToggleHideTests={() => setHideTests((v) => !v)}
|
||||
missedView={missedView}
|
||||
onToggleMissedView={() => setMissedView((v) => !v)}
|
||||
missedView={showMissedSkeleton}
|
||||
missedCount={data?.missed_graph?.nodes.filter((n) => n.label === "File").length ?? 0}
|
||||
onToggleMissedView={() => setShowMissedSkeleton((v) => !v)}
|
||||
/>
|
||||
<Sidebar
|
||||
nodes={filteredData.nodes}
|
||||
@@ -392,11 +449,13 @@ export function GraphTab({ project }: GraphTabProps) {
|
||||
<ErrorBoundary>
|
||||
<GraphScene
|
||||
data={filteredData}
|
||||
missed={showMissedSkeleton ? missedSkeleton : null}
|
||||
highlightedIds={highlightedIds}
|
||||
cameraTarget={cameraTarget}
|
||||
showLabels={showLabels}
|
||||
display={display}
|
||||
onNodeClick={handleNodeClick}
|
||||
onBackgroundClick={handleBackgroundClick}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
|
||||
@@ -50,11 +50,21 @@ export interface LinkedProject {
|
||||
cross_edges: GraphEdge[];
|
||||
}
|
||||
|
||||
/* Missed-graph skeleton (#963): the file structure of files the indexer
|
||||
* could not fully cover, laid out as a satellite cluster beside the code
|
||||
* galaxy (server-computed offset, same shape as LinkedProject's). */
|
||||
export interface MissedGraph {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
offset: { x: number; y: number; z: number };
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
total_nodes: number;
|
||||
linked_projects?: LinkedProject[];
|
||||
missed_graph?: MissedGraph;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
|
||||
+65
-2
@@ -1366,6 +1366,63 @@ static double layout_radius(const cbm_layout_result_t *r) {
|
||||
return sqrt(max_r2);
|
||||
}
|
||||
|
||||
/* Attach the missed-graph skeleton (#963) to the primary layout doc as
|
||||
* "missed_graph": {"nodes":[...], "edges":[...], "offset":{x,y,z}}
|
||||
* — the file structure of files the indexer could not fully cover, laid out
|
||||
* as a satellite cluster beside the code galaxy (the UI renders it as a white
|
||||
* skeleton; clicking it re-centers the camera there). The offset sits on the
|
||||
* -Y side: linked-project satellites spread counter-clockwise from +X, so
|
||||
* this slot collides last. Returns true when a non-empty skeleton was
|
||||
* attached; no-op when the project has no missed files. */
|
||||
static bool attach_missed_graph(yyjson_mut_doc *mdoc, yyjson_mut_val *mroot, cbm_store_t *store,
|
||||
const char *project, double primary_radius) {
|
||||
char covproj[512];
|
||||
cbm_store_coverage_shadow_project(covproj, sizeof(covproj), project);
|
||||
cbm_layout_result_t *ml = cbm_layout_compute(store, covproj, CBM_LAYOUT_OVERVIEW, NULL, 0, 0);
|
||||
if (!ml) {
|
||||
return false;
|
||||
}
|
||||
if (ml->node_count == 0) {
|
||||
cbm_layout_free(ml);
|
||||
return false;
|
||||
}
|
||||
double miss_radius = layout_radius(ml);
|
||||
char *mjson = cbm_layout_to_json(ml);
|
||||
cbm_layout_free(ml);
|
||||
if (!mjson) {
|
||||
return false;
|
||||
}
|
||||
yyjson_doc *mldoc = yyjson_read(mjson, strlen(mjson), 0);
|
||||
free(mjson);
|
||||
if (!mldoc) {
|
||||
return false;
|
||||
}
|
||||
yyjson_mut_val *entry = yyjson_mut_obj(mdoc);
|
||||
yyjson_val *mlroot = yyjson_doc_get_root(mldoc);
|
||||
yyjson_val *mn = yyjson_obj_get(mlroot, "nodes");
|
||||
yyjson_val *me = yyjson_obj_get(mlroot, "edges");
|
||||
if (mn) {
|
||||
yyjson_mut_obj_add_val(mdoc, entry, "nodes", yyjson_val_mut_copy(mdoc, mn));
|
||||
}
|
||||
if (me) {
|
||||
yyjson_mut_obj_add_val(mdoc, entry, "edges", yyjson_val_mut_copy(mdoc, me));
|
||||
}
|
||||
yyjson_doc_free(mldoc);
|
||||
|
||||
double dist = primary_radius + miss_radius + LAYOUT_GALAXY_PAD;
|
||||
if (dist < LAYOUT_GALAXY_SPACING) {
|
||||
dist = LAYOUT_GALAXY_SPACING;
|
||||
}
|
||||
yyjson_mut_val *offset = yyjson_mut_obj(mdoc);
|
||||
yyjson_mut_obj_add_real(mdoc, offset, "x", 0.0);
|
||||
yyjson_mut_obj_add_real(mdoc, offset, "y", -dist);
|
||||
yyjson_mut_obj_add_real(mdoc, offset, "z", 0.0);
|
||||
yyjson_mut_obj_add_val(mdoc, entry, "offset", offset);
|
||||
|
||||
yyjson_mut_obj_add_val(mdoc, mroot, "missed_graph", entry);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
char project[256] = {0};
|
||||
char max_str[32] = {0};
|
||||
@@ -1440,14 +1497,16 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (linked_count == 0) {
|
||||
/* Fast path: no satellites to attach. The missed skeleton only decorates
|
||||
* the CODE graph — a graph=missed request already IS the miss graph. */
|
||||
if (linked_count == 0 && missed_graph) {
|
||||
cbm_store_close(store);
|
||||
cbm_http_replyf(c, 200, g_cors_json, "%s", primary_json);
|
||||
free(primary_json);
|
||||
return;
|
||||
}
|
||||
|
||||
/* Parse primary JSON and append linked_projects array */
|
||||
/* Parse primary JSON and append missed_graph + linked_projects */
|
||||
yyjson_doc *pdoc = yyjson_read(primary_json, strlen(primary_json), 0);
|
||||
free(primary_json);
|
||||
if (!pdoc) {
|
||||
@@ -1460,6 +1519,10 @@ static void handle_layout(cbm_http_conn_t *c, const cbm_http_req_t *req) {
|
||||
yyjson_doc_free(pdoc);
|
||||
yyjson_mut_val *mroot = yyjson_mut_doc_get_root(mdoc);
|
||||
|
||||
if (!missed_graph) {
|
||||
(void)attach_missed_graph(mdoc, mroot, store, project, primary_radius);
|
||||
}
|
||||
|
||||
yyjson_mut_val *lp_arr = yyjson_mut_arr(mdoc);
|
||||
|
||||
for (int li = 0; li < linked_count; li++) {
|
||||
|
||||
Reference in New Issue
Block a user