diff --git a/packages/ui/__tests__/graph/graph-flow.test.tsx b/packages/ui/__tests__/graph/graph-flow.test.tsx
index 0f7f5c7d3..e0c521ff4 100644
--- a/packages/ui/__tests__/graph/graph-flow.test.tsx
+++ b/packages/ui/__tests__/graph/graph-flow.test.tsx
@@ -1,7 +1,29 @@
-import { describe, it, expect, vi } from "vitest";
-import { render, screen, fireEvent } from "@testing-library/react";
+import { describe, it, expect, vi, afterEach } from "vitest";
+import { render, screen, fireEvent, act } from "@testing-library/react";
+import { forwardRef, useImperativeHandle } from "react";
import { GraphFlow } from "../../src/graph/graph-flow.js";
+// Stub the Sigma canvas: it dynamically imports "sigma", which needs WebGL2
+// and rejects under jsdom. These tests assert toolbar / notice / picker
+// behavior, none of which lives inside the canvas.
+const { focusNodeSpy } = vi.hoisted(() => ({ focusNodeSpy: vi.fn() }));
+vi.mock("../../src/graph/sigma/sigma-canvas.js", () => ({
+ SigmaCanvas: forwardRef(function MockSigmaCanvas(_props, ref) {
+ useImperativeHandle(ref, () => ({
+ focusNode: focusNodeSpy,
+ fitView: () => {},
+ zoomIn: () => {},
+ zoomOut: () => {},
+ }));
+ return
;
+ }),
+}));
+
+afterEach(() => {
+ vi.useRealTimers();
+ focusNodeSpy.mockClear();
+});
+
// Minimal prop set — no graphs supplied, so the canvas renders its empty state
// while the toolbar (and its color-mode control) still mounts.
const baseProps = {
@@ -102,4 +124,121 @@ describe("GraphFlow shell", () => {
fireEvent.click(screen.getByRole("radio", { name: "Dead" }));
expect(screen.getByText("No dead files in this repo")).toBeTruthy();
});
+
+ it("opens the flow picker on the module overview and jumps to the full graph on selection", () => {
+ const onViewModeChange = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Execution flows" }));
+ expect(screen.getByText("Execution Flows")).toBeTruthy();
+
+ // Selecting a flow from the module overview switches to the file-level
+ // graph so the trace can actually be highlighted.
+ fireEvent.click(screen.getByText("main"));
+ expect(onViewModeChange).toHaveBeenCalledWith("full");
+ });
+
+ it("focuses the flow trace head once the graph gains it, exactly once", () => {
+ vi.useFakeTimers();
+ const flows = {
+ total_entry_points: 1,
+ flows: [
+ {
+ entry_point: "app.py::main",
+ entry_point_name: "main",
+ entry_point_score: 1,
+ trace: ["app.py::main", "core.py::run"],
+ depth: 1,
+ crosses_community: false,
+ communities_visited: [0],
+ },
+ ],
+ };
+ const { rerender } = render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Execution flows" }));
+ fireEvent.click(screen.getByText("main"));
+
+ // The focus timer fires while the full graph is still loading — the
+ // trace head isn't in the (empty) graph yet, so nothing is focused.
+ act(() => {
+ vi.advanceTimersByTime(800);
+ });
+ expect(focusNodeSpy).not.toHaveBeenCalled();
+
+ // The full graph lands: the deferred focus fires once for the trace head.
+ const nodes = flows.flows[0]!.trace.map((id) => ({
+ node_id: id,
+ label: id,
+ language: "python",
+ }));
+ rerender(
+ ,
+ );
+ expect(focusNodeSpy).toHaveBeenCalledWith("app.py::main");
+ expect(focusNodeSpy).toHaveBeenCalledTimes(1);
+
+ // Later graph changes must not re-steer the camera for the same flow.
+ rerender(
+ ,
+ );
+ expect(focusNodeSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("refuses hierarchical layout above the ELK cap and explains why", () => {
+ const nodes = Array.from({ length: 501 }, (_, i) => ({
+ node_id: `f${i}.ts`,
+ label: `f${i}.ts`,
+ language: "typescript",
+ }));
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Hierarchical" }));
+
+ // The mode must not activate — force stays on — and the notice says why.
+ expect(
+ screen.getByRole("button", { name: "Hierarchical" }).getAttribute("aria-pressed"),
+ ).toBe("false");
+ expect(screen.getByRole("status").textContent).toContain(
+ "Hierarchical layout is limited to 500 nodes",
+ );
+ });
});
diff --git a/packages/ui/src/graph/elk-layout.ts b/packages/ui/src/graph/elk-layout.ts
index ed8d6c019..e47a95c9d 100644
--- a/packages/ui/src/graph/elk-layout.ts
+++ b/packages/ui/src/graph/elk-layout.ts
@@ -374,11 +374,26 @@ export async function computeElkModulePositions(
): Promise