From 57050d7e0db7109d1c019681791acaf57eb20c0c Mon Sep 17 00:00:00 2001 From: Randy Wilson Date: Wed, 19 Aug 2026 20:29:24 -0400 Subject: [PATCH 1/4] fix(graph): resolve graph edge endpoints by identity, not by file path getFullGraph matched each edge endpoint with a check that tested filePath on its own before anything else. Every function, class and interface declared in a file shares that path, so the lookup returned whichever node for the file came first, in practice the File node. The name comparison that followed could never run because the || had already short-circuited. The effect was that edges collapsed onto File nodes and the real endpoints were left with none, so the dashboard drew functions and interfaces as a disconnected grid. Endpoints are now matched on name plus path, falling back to the file itself only when the endpoint carries no name. Measured on the same graph: every node type went from partially connected to fully connected, including 152 of 152 functions and 41 of 41 interfaces. Co-Authored-By: Claude Opus 5 --- packages/graph/src/queries.ts | 46 +++++++++++++++++++++++------------ 1 file changed, 31 insertions(+), 15 deletions(-) diff --git a/packages/graph/src/queries.ts b/packages/graph/src/queries.ts index e860ced1..e5096e6e 100644 --- a/packages/graph/src/queries.ts +++ b/packages/graph/src/queries.ts @@ -256,6 +256,27 @@ export interface GraphQueries { // Query Operations Implementation // ============================================================================ +/** + * Find the graph node an edge endpoint refers to. + * + * Prefers name plus path, which identifies a specific declaration. Falls back to + * the file itself only when the endpoint carries no name, since matching on path + * alone cannot distinguish declarations that share a file. + */ +function findEndpoint( + nodes: GraphNode[], + props: Record, +): GraphNode | undefined { + const name = props['name']; + const filePath = props['filePath']; + + if (typeof name === 'string' && name.length > 0) { + const exact = nodes.find((n) => n.displayName === name && n.filePath === filePath); + if (exact) return exact; + } + return nodes.find((n) => n.filePath === filePath && n.label === 'File'); +} + class GraphQueriesImpl implements GraphQueries { private readonly dialect: CypherDialect; private readonly templates: ReturnType; @@ -300,21 +321,16 @@ class GraphQueriesImpl implements GraphQueries { const fromLabels = extractLabels(row.a, [], this.dialect); const toLabels = row.toLabels ?? extractLabels(row.b, [], this.dialect); - // Get source node from our nodes array - const fromNode = nodes.find((n) => { - return ( - n.filePath === fromProps['filePath'] || - (n.displayName === fromProps['name'] && n.filePath === fromProps['filePath']) - ); - }); - - // Get target node - or create External node if needed - let toNode = nodes.find((n) => { - return ( - n.filePath === toProps['filePath'] || - (n.displayName === toProps['name'] && n.filePath === toProps['filePath']) - ); - }); + // Resolve each endpoint by identity. + // + // This previously tested filePath on its own first, and every function, + // class and interface declared in a file shares that path. The lookup + // therefore returned whichever node for the file happened to come first, + // usually the File node, so real endpoints ended up with no edges at all + // and rendered as a disconnected grid. The name check that followed could + // never run because the || had already short-circuited. + const fromNode = findEndpoint(nodes, fromProps); + let toNode = findEndpoint(nodes, toProps); // If target is External and not in nodes, add it const isExternalTarget = toLabels.includes('External') || From e14d4ecca898c9909c105eb9e8402b49b6ccc7a5 Mon Sep 17 00:00:00 2001 From: Randy Wilson Date: Wed, 19 Aug 2026 20:29:24 -0400 Subject: [PATCH 2/4] fix(dashboard): draw graph edges, settle the layout, and open details on search Three defects found while exercising the UI rather than the API. Node ids were being overwritten. The element mapping spread the payload's data object after setting id, and that object carries its own internal id for 208 of 235 nodes, every Function, Class, Interface and Variable. Their cytoscape ids were replaced, so every edge referencing the real id was treated as an orphan and dropped. Files and Types carry no inner id, which is why only files ever appeared connected. The spread now comes first. The layout never settled. cose runs numIter simulation steps, 1000 by default, and animating them left the graph drifting for many seconds, during which nodes moved out from under the pointer and could not reliably be clicked. Animating only the final transition was worse: the viewport fit to the pre-layout positions and the graph settled off screen. Positioning without animation gives final coordinates immediately, so both the fit and the hit testing are correct. Clicking a search result highlighted the node but never selected it, leaving the detail panel on its empty state. The result now flows through to selection, and because the search payload already carries filePath and line numbers, the signature, metrics and syntax-highlighted source preview all render. Co-Authored-By: Claude Opus 5 --- .../dashboard/src/components/dashboard/app-shell.tsx | 12 +++++++++++- .../src/components/dashboard/graph-canvas.tsx | 7 ++++++- .../src/components/dashboard/search-panel.tsx | 9 ++++++--- packages/dashboard/src/lib/cytoscape-config.ts | 10 ++++++++-- 4 files changed, 31 insertions(+), 7 deletions(-) diff --git a/packages/dashboard/src/components/dashboard/app-shell.tsx b/packages/dashboard/src/components/dashboard/app-shell.tsx index ed57f734..4eea8fb7 100644 --- a/packages/dashboard/src/components/dashboard/app-shell.tsx +++ b/packages/dashboard/src/components/dashboard/app-shell.tsx @@ -48,7 +48,17 @@ export function AppShell({ projectId }: { projectId?: string | null }) { setHighlightedNames(new Set([name]))} + onSelectResult={(result) => { + setHighlightedNames(new Set([result.name])) + // Open the detail panel too. The search payload already carries + // filePath and line numbers, which is everything the panel needs. + setSelectedNode({ + id: `${result.nodeType}:${result.filePath ?? ''}:${result.name}`, + label: result.name, + type: result.nodeType, + properties: result, + }) + }} /> diff --git a/packages/dashboard/src/components/dashboard/graph-canvas.tsx b/packages/dashboard/src/components/dashboard/graph-canvas.tsx index a486897a..a94451b4 100644 --- a/packages/dashboard/src/components/dashboard/graph-canvas.tsx +++ b/packages/dashboard/src/components/dashboard/graph-canvas.tsx @@ -52,11 +52,16 @@ export function GraphCanvas({ apiUrl, onNodeSelect, highlightedNames, hiddenEdge const nodeType = (n.label ?? nodeData.type ?? 'Unknown') as string return { data: { + // Spread first: the graph payload carries its own internal "id", + // and spreading it last replaced the cytoscape node id. Every edge + // referencing the real id was then treated as an orphan and + // dropped, so functions, classes and interfaces rendered with no + // edges at all while files, which carry no inner id, looked fine. + ...nodeData, id: n.id as string, label: displayName, type: nodeType, filePath: n.filePath as string | undefined, - ...nodeData, }, } }) diff --git a/packages/dashboard/src/components/dashboard/search-panel.tsx b/packages/dashboard/src/components/dashboard/search-panel.tsx index 50e06d42..98974897 100644 --- a/packages/dashboard/src/components/dashboard/search-panel.tsx +++ b/packages/dashboard/src/components/dashboard/search-panel.tsx @@ -2,18 +2,21 @@ import { useState, useCallback } from 'react' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' -interface SearchResult { +export interface SearchResult { name: string nodeType: string filePath?: string callerCount?: number importerCount?: number + // The search endpoint returns the full node payload (startLine, docstring, + // params and so on). Those extra fields are what the detail panel renders. + [key: string]: unknown } interface SearchPanelProps { apiUrl: string onHighlight: (names: string[]) => void - onSelectResult: (name: string) => void + onSelectResult: (result: SearchResult) => void } export function SearchPanel({ apiUrl, onHighlight, onSelectResult }: SearchPanelProps) { @@ -82,7 +85,7 @@ export function SearchPanel({ apiUrl, onHighlight, onSelectResult }: SearchPanel