diff --git a/graph-ui/src/components/FilterPanel.tsx b/graph-ui/src/components/FilterPanel.tsx
index db2d0a513..b2e221531 100644
--- a/graph-ui/src/components/FilterPanel.tsx
+++ b/graph-ui/src/components/FilterPanel.tsx
@@ -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({
- {/* 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. */}
- Graph
+ Missed files
+ {missedCount > 0 && (
+
+ {missedCount.toLocaleString()} files
+
+ )}
- {missedView && (
-
- Best-effort: files with unparseable regions or skips. Empty = no
- known misses (not a completeness guarantee).
-
- )}
+
+ {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)."}
+
{/* Dead-code view */}
diff --git a/graph-ui/src/components/GraphScene.tsx b/graph-ui/src/components/GraphScene.tsx
index 663c2ad32..cad93d926 100644
--- a/graph-ui/src/components/GraphScene.tsx
+++ b/graph-ui/src/components/GraphScene.tsx
@@ -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 | 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(null);
const controlsRef = useRef(null);
@@ -151,6 +160,7 @@ export function GraphScene({
alpha: false,
powerPreference: "high-performance",
}}
+ onPointerMissed={onBackgroundClick}
>
@@ -176,6 +186,30 @@ export function GraphScene({
/>
{showLabels && }
+ {/* 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 && (
+
+
+
+ {showLabels && }
+
+ )}
+
{/* Satellite galaxies for cross-repo linked projects */}
{data.linked_projects?.map((lp: LinkedProject) => {
const offsetNodes = lp.nodes.map((n) => ({
diff --git a/graph-ui/src/components/GraphTab.tsx b/graph-ui/src/components/GraphTab.tsx
index 56f549cd2..1144b9f64 100644
--- a/graph-ui/src/components/GraphTab.tsx
+++ b/graph-ui/src/components/GraphTab.tsx
@@ -102,10 +102,11 @@ export function GraphTab({ project }: GraphTabProps) {
const [enabledLabels, setEnabledLabels] = useState>(new Set());
const [enabledEdgeTypes, setEnabledEdgeTypes] = useState>(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)}
/>
diff --git a/graph-ui/src/lib/types.ts b/graph-ui/src/lib/types.ts
index a10b53d95..4d8ffa638 100644
--- a/graph-ui/src/lib/types.ts
+++ b/graph-ui/src/lib/types.ts
@@ -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 {
diff --git a/src/ui/http_server.c b/src/ui/http_server.c
index 939c1d76e..4d26f0561 100644
--- a/src/ui/http_server.c
+++ b/src/ui/http_server.c
@@ -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++) {