Skip to content

Commit fb969b3

Browse files
authored
fix(graph): execution-flow picker on the module overview; hierarchical layout guardrails (#805)
* fix(graph): fetch execution flows on the module overview so the picker works there The shell already supports picking a flow from the module overview — enterFullViewFromModule switches to the file-level graph and highlights the trace. But the wrapper only fetched flows for file-level scopes, so on the module overview the panel had no data and the toolbar button silently did nothing. Widen the fetch gate to include the module view; add a shell test covering the picker-then-jump path. * fix(graph): refuse hierarchical layout above the ELK cap at the click Past 500 nodes ELK never runs, but the toggle still activated: the force layout stopped and the button sat active doing nothing. Guard the mode switch itself — keep force running, surface the existing skip notice synchronously — instead of relying on the canvas hook to fire it after the mode has already changed. * fix(graph): keep hierarchical layers readable when most modules are isolated Modules with no dependency edges each became their own ELK component, packed side by side into one huge band that drowned out the layering. Lay out only the connected subgraph and shelve isolated modules in a grid below it. * fix(graph): drop leftover shell prop; focus flow trace once the full graph lands; quiet sigma in jsdom
1 parent 23f0e66 commit fb969b3

5 files changed

Lines changed: 247 additions & 14 deletions

File tree

packages/ui/__tests__/graph/graph-flow.test.tsx

Lines changed: 141 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,29 @@
1-
import { describe, it, expect, vi } from "vitest";
2-
import { render, screen, fireEvent } from "@testing-library/react";
1+
import { describe, it, expect, vi, afterEach } from "vitest";
2+
import { render, screen, fireEvent, act } from "@testing-library/react";
3+
import { forwardRef, useImperativeHandle } from "react";
34
import { GraphFlow } from "../../src/graph/graph-flow.js";
45

6+
// Stub the Sigma canvas: it dynamically imports "sigma", which needs WebGL2
7+
// and rejects under jsdom. These tests assert toolbar / notice / picker
8+
// behavior, none of which lives inside the canvas.
9+
const { focusNodeSpy } = vi.hoisted(() => ({ focusNodeSpy: vi.fn() }));
10+
vi.mock("../../src/graph/sigma/sigma-canvas.js", () => ({
11+
SigmaCanvas: forwardRef(function MockSigmaCanvas(_props, ref) {
12+
useImperativeHandle(ref, () => ({
13+
focusNode: focusNodeSpy,
14+
fitView: () => {},
15+
zoomIn: () => {},
16+
zoomOut: () => {},
17+
}));
18+
return <div data-testid="sigma-canvas" />;
19+
}),
20+
}));
21+
22+
afterEach(() => {
23+
vi.useRealTimers();
24+
focusNodeSpy.mockClear();
25+
});
26+
527
// Minimal prop set — no graphs supplied, so the canvas renders its empty state
628
// while the toolbar (and its color-mode control) still mounts.
729
const baseProps = {
@@ -102,4 +124,121 @@ describe("GraphFlow shell", () => {
102124
fireEvent.click(screen.getByRole("radio", { name: "Dead" }));
103125
expect(screen.getByText("No dead files in this repo")).toBeTruthy();
104126
});
127+
128+
it("opens the flow picker on the module overview and jumps to the full graph on selection", () => {
129+
const onViewModeChange = vi.fn();
130+
render(
131+
<GraphFlow
132+
{...baseProps}
133+
initialViewMode="module"
134+
onViewModeChange={onViewModeChange}
135+
executionFlows={{
136+
total_entry_points: 1,
137+
flows: [
138+
{
139+
entry_point: "app.py::main",
140+
entry_point_name: "main",
141+
entry_point_score: 1,
142+
trace: ["app.py::main", "core.py::run"],
143+
depth: 1,
144+
crosses_community: false,
145+
communities_visited: [0],
146+
},
147+
],
148+
}}
149+
/>,
150+
);
151+
152+
fireEvent.click(screen.getByRole("button", { name: "Execution flows" }));
153+
expect(screen.getByText("Execution Flows")).toBeTruthy();
154+
155+
// Selecting a flow from the module overview switches to the file-level
156+
// graph so the trace can actually be highlighted.
157+
fireEvent.click(screen.getByText("main"));
158+
expect(onViewModeChange).toHaveBeenCalledWith("full");
159+
});
160+
161+
it("focuses the flow trace head once the graph gains it, exactly once", () => {
162+
vi.useFakeTimers();
163+
const flows = {
164+
total_entry_points: 1,
165+
flows: [
166+
{
167+
entry_point: "app.py::main",
168+
entry_point_name: "main",
169+
entry_point_score: 1,
170+
trace: ["app.py::main", "core.py::run"],
171+
depth: 1,
172+
crosses_community: false,
173+
communities_visited: [0],
174+
},
175+
],
176+
};
177+
const { rerender } = render(
178+
<GraphFlow {...baseProps} initialViewMode="full" executionFlows={flows} />,
179+
);
180+
181+
fireEvent.click(screen.getByRole("button", { name: "Execution flows" }));
182+
fireEvent.click(screen.getByText("main"));
183+
184+
// The focus timer fires while the full graph is still loading — the
185+
// trace head isn't in the (empty) graph yet, so nothing is focused.
186+
act(() => {
187+
vi.advanceTimersByTime(800);
188+
});
189+
expect(focusNodeSpy).not.toHaveBeenCalled();
190+
191+
// The full graph lands: the deferred focus fires once for the trace head.
192+
const nodes = flows.flows[0]!.trace.map((id) => ({
193+
node_id: id,
194+
label: id,
195+
language: "python",
196+
}));
197+
rerender(
198+
<GraphFlow
199+
{...baseProps}
200+
initialViewMode="full"
201+
executionFlows={flows}
202+
fullGraph={{ nodes, links: [] }}
203+
/>,
204+
);
205+
expect(focusNodeSpy).toHaveBeenCalledWith("app.py::main");
206+
expect(focusNodeSpy).toHaveBeenCalledTimes(1);
207+
208+
// Later graph changes must not re-steer the camera for the same flow.
209+
rerender(
210+
<GraphFlow
211+
{...baseProps}
212+
initialViewMode="full"
213+
executionFlows={flows}
214+
fullGraph={{ nodes: [...nodes], links: [] }}
215+
/>,
216+
);
217+
expect(focusNodeSpy).toHaveBeenCalledTimes(1);
218+
});
219+
220+
it("refuses hierarchical layout above the ELK cap and explains why", () => {
221+
const nodes = Array.from({ length: 501 }, (_, i) => ({
222+
node_id: `f${i}.ts`,
223+
label: `f${i}.ts`,
224+
language: "typescript",
225+
}));
226+
render(
227+
<GraphFlow
228+
{...baseProps}
229+
initialViewMode="full"
230+
fullGraph={{ nodes, links: [] }}
231+
/>,
232+
);
233+
234+
fireEvent.click(screen.getByRole("button", { name: "Hierarchical" }));
235+
236+
// The mode must not activate — force stays on — and the notice says why.
237+
expect(
238+
screen.getByRole("button", { name: "Hierarchical" }).getAttribute("aria-pressed"),
239+
).toBe("false");
240+
expect(screen.getByRole("status").textContent).toContain(
241+
"Hierarchical layout is limited to 500 nodes",
242+
);
243+
});
105244
});

packages/ui/src/graph/elk-layout.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -374,11 +374,26 @@ export async function computeElkModulePositions(
374374
): Promise<Map<string, { x: number; y: number }>> {
375375
if (moduleNodes.length === 0) return new Map();
376376

377-
const moduleIds = new Set(moduleNodes.map((m) => m.module_id));
377+
// Modules without any dependency edge would each become their own ELK
378+
// component, packed side by side into one huge band that drowns out the
379+
// actual layering. Run ELK on the connected subgraph only; the isolated
380+
// modules get a compact grid below it (see after extractPositions).
381+
const connectedIds = new Set<string>();
382+
for (const e of moduleEdges) {
383+
connectedIds.add(e.source);
384+
connectedIds.add(e.target);
385+
}
386+
const isolated = moduleNodes.filter((m) => !connectedIds.has(m.module_id));
387+
const layoutNodes =
388+
isolated.length === moduleNodes.length
389+
? moduleNodes
390+
: moduleNodes.filter((m) => connectedIds.has(m.module_id));
391+
392+
const moduleIds = new Set(layoutNodes.map((m) => m.module_id));
378393

379394
// Build all ancestor directories for compound node nesting
380395
const dirSet = new Set<string>();
381-
for (const m of moduleNodes) {
396+
for (const m of layoutNodes) {
382397
const ancestors = ancestorDirs(dirOf(m.module_id));
383398
for (const a of ancestors) {
384399
if (!moduleIds.has(a)) dirSet.add(a);
@@ -413,7 +428,7 @@ export async function computeElkModulePositions(
413428
}
414429

415430
// Place module nodes into their parent directory (or root)
416-
for (const m of moduleNodes) {
431+
for (const m of layoutNodes) {
417432
const elkNode: ElkNode = {
418433
id: m.module_id,
419434
width: MODULE_NODE_WIDTH,
@@ -474,6 +489,39 @@ export async function computeElkModulePositions(
474489
};
475490
extractPositions(layout);
476491

492+
// Shelve isolated modules in a grid below the layered area so they stay
493+
// visible without stretching the layers into a single band.
494+
if (isolated.length > 0 && isolated.length < moduleNodes.length) {
495+
let minX = Infinity;
496+
let maxX = -Infinity;
497+
let maxY = -Infinity;
498+
for (const p of positions.values()) {
499+
minX = Math.min(minX, p.x);
500+
maxX = Math.max(maxX, p.x);
501+
maxY = Math.max(maxY, p.y);
502+
}
503+
if (!Number.isFinite(minX)) {
504+
minX = 0;
505+
maxX = 0;
506+
maxY = 0;
507+
}
508+
// Wider than the layered spacing: grid rows have no edges to separate
509+
// nodes visually, so labels need the room instead.
510+
const cellW = MODULE_NODE_WIDTH + 90;
511+
const cellH = MODULE_NODE_HEIGHT + 45;
512+
const cols = Math.max(
513+
Math.ceil(Math.sqrt(isolated.length)),
514+
Math.min(isolated.length, Math.floor((maxX - minX) / cellW) || 1),
515+
);
516+
const startY = maxY + MODULE_NODE_HEIGHT + 90;
517+
isolated.forEach((m, i) => {
518+
positions.set(m.module_id, {
519+
x: minX + (i % cols) * cellW,
520+
y: startY + Math.floor(i / cols) * cellH,
521+
});
522+
});
523+
}
524+
477525
return positions;
478526
}
479527

packages/ui/src/graph/graph-flow.tsx

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import {
7070
mergeCommunitySlice,
7171
} from "./sigma/constellation-adapter";
7272
import { computeRadialLayout } from "./sigma/radial-layout";
73+
import { ELK_MAX_NODES, elkSkipReason } from "./sigma/use-elk-sigma-layout";
7374
import type { SigmaNodeAttributes, SigmaEdgeAttributes } from "./sigma/types";
7475
import type GraphologyGraph from "graphology";
7576
import { useEgoFilter } from "./sigma/use-ego-filter";
@@ -842,6 +843,15 @@ export function GraphFlow(props: GraphFlowProps) {
842843
}
843844
}, [viewMode, onViewModeChange]);
844845

846+
// Flow index whose trace head has already been focused, so the deferred
847+
// re-focus below fires at most once per selection and never re-steers the
848+
// camera on later graph changes while the same flow stays active.
849+
const flowFocusedRef = useRef<number | null>(null);
850+
// Live graph handle for the focus timer (the effect below deliberately
851+
// keeps sigmaGraph out of its deps).
852+
const sigmaGraphRef = useRef(sigmaGraph);
853+
sigmaGraphRef.current = sigmaGraph;
854+
845855
// Execution flow highlighting
846856
useEffect(() => {
847857
if (activeFlowIdx === null || !executionFlows) {
@@ -860,13 +870,37 @@ export function GraphFlow(props: GraphFlowProps) {
860870

861871
clearTimeout(focusTimerRef.current);
862872
focusTimerRef.current = setTimeout(() => {
873+
focusTimerRef.current = undefined;
863874
const firstNode = flow.trace[0];
864-
if (firstNode) sigmaRef.current?.focusNode(firstNode);
875+
if (!firstNode) return;
876+
if (sigmaGraphRef.current?.hasNode(firstNode)) {
877+
flowFocusedRef.current = activeFlowIdx;
878+
sigmaRef.current?.focusNode(firstNode);
879+
}
880+
// Node not loaded yet (module → full jump still fetching): the
881+
// deferred-focus effect below picks it up once the graph gains it.
865882
}, 800);
866883
return () => clearTimeout(focusTimerRef.current);
867884
// eslint-disable-next-line react-hooks/exhaustive-deps
868885
}, [activeFlowIdx, executionFlows]);
869886

887+
// Deferred flow focus: selecting a flow from the module overview kicks off
888+
// the full-graph fetch, which can land after the 800ms timer above already
889+
// fired against a graph without the trace head. Focus once when the graph
890+
// gains the node; while the timer is still pending it stays the fast path.
891+
useEffect(() => {
892+
if (activeFlowIdx === null) {
893+
flowFocusedRef.current = null;
894+
return;
895+
}
896+
if (flowFocusedRef.current === activeFlowIdx) return;
897+
if (focusTimerRef.current !== undefined) return;
898+
const firstNode = executionFlows?.flows[activeFlowIdx]?.trace[0];
899+
if (!firstNode || !sigmaGraph?.hasNode(firstNode)) return;
900+
flowFocusedRef.current = activeFlowIdx;
901+
sigmaRef.current?.focusNode(firstNode);
902+
}, [activeFlowIdx, executionFlows, sigmaGraph]);
903+
870904
// Context value (hover fields use static empty sets — highlighting handled by Sigma reducers)
871905
const ctxValue = useMemo<GraphContextValue>(
872906
() => ({
@@ -1045,9 +1079,17 @@ export function GraphFlow(props: GraphFlowProps) {
10451079
}, [onViewModeChange, collapseAllHubs]);
10461080

10471081
const handleLayoutModeChange = useCallback((mode: LayoutMode) => {
1082+
// Refuse right at the click when ELK can't run: switching the mode anyway
1083+
// would stop the force layout and leave an active-looking toggle doing
1084+
// nothing (the canvas-side notice covers graphs that grow past the cap
1085+
// after the mode is already active).
1086+
if (mode === "hierarchical" && sigmaGraph && sigmaGraph.order > ELK_MAX_NODES) {
1087+
setLayoutNotice(elkSkipReason(sigmaGraph.order));
1088+
return;
1089+
}
10481090
setLayoutMode(mode);
10491091
setLayoutNotice(null);
1050-
}, []);
1092+
}, [sigmaGraph]);
10511093

10521094
const handleGraphThemeChange = useCallback(
10531095
(theme: GraphTheme) => {

packages/ui/src/graph/sigma/use-elk-sigma-layout.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ import {
2121
// interactive; raising this ceiling means moving ELK into a web worker first.
2222
export const ELK_MAX_NODES = 500;
2323

24+
export function elkSkipReason(order: number): string {
25+
return `Hierarchical layout is limited to ${ELK_MAX_NODES} nodes — this view has ${order.toLocaleString()}. Switch to the Modules scope or narrow the view to use it.`;
26+
}
27+
2428
export interface UseElkSigmaLayoutOptions {
2529
graph: Graph<SigmaNodeAttributes, SigmaEdgeAttributes> | null;
2630
sigma: Sigma | null;
@@ -102,9 +106,7 @@ export function useElkSigmaLayout(
102106
useEffect(() => {
103107
if (enabled && graph && graph.order > 0) {
104108
if (graph.order > ELK_MAX_NODES) {
105-
options.onSkipped?.(
106-
`Hierarchical layout is limited to ${ELK_MAX_NODES} nodes — this view has ${graph.order.toLocaleString()}. Switch to the Modules scope or narrow the view to use it.`,
107-
);
109+
options.onSkipped?.(elkSkipReason(graph.order));
108110
return;
109111
}
110112
compute();

packages/web/src/components/graph/graph-flow.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,11 +107,13 @@ export function GraphFlow({
107107
const { repo } = useRepo(repoId);
108108
const resolvedRepoName = repoName ?? repo?.name;
109109
const { communities } = useCommunities(repoId);
110-
// Execution flows only highlight a file-level trace, so defer the fetch until
111-
// a full/file graph is actually needed (keeps the modules mount to
112-
// /communities + /modules only).
110+
// Flow traces highlight file-level nodes, but the picker must also work from
111+
// the module overview: selecting a flow there makes the shell jump to the
112+
// full graph (enterFullViewFromModule) and highlight the trace. Dead/hot
113+
// scopes render file-level graphs too, so flows work there as well — only
114+
// the constellation skips the fetch.
113115
const { flows: executionFlowsData } = useExecutionFlows(
114-
needsFullGraph ? repoId : null,
116+
viewMode !== "architecture" || needsFullGraph ? repoId : null,
115117
{
116118
top_n: 10,
117119
max_depth: 6,

0 commit comments

Comments
 (0)