From a4286e8420d83ae9eccced1a54ebec8fe45d5175 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 24 Aug 2026 14:32:36 +0200 Subject: [PATCH] feat(ui): flow merging, treemap bars, persistent galaxy (Field Guide D5) - The galaxy stays mounted once visited: hidden with invisible (never display:none, which zeroes a canvas), WebGL context and layout kept, frameloop paused via the scene's active prop - switching tabs no longer drops and refetches the whole 3D scene. - Flows list merges journeys by entry point: one row per entry (richest first) with an 'x N journeys from - show all' expander, so ten main() walks stop drowning the list. - Modules gains a map|bars toggle (persisted): the same children as a sorted horizontal-bar list - region-colored, symbol counts - for when proportion matters less than ranking. - Filter chips strike through when disabled (excluded, not missing). - One-time in-place hint on the region scene; dismissing persists, leaving without dismissing does not. Tests: groupFlowsByEntry ordering/merge (vitest); suite green with the keep-mounted change. Signed-off-by: Martin Vogel --- graph-ui/src/App.tsx | 40 +++++++++---- graph-ui/src/components/FilterPanel.tsx | 4 +- graph-ui/src/components/FlowsTab.tsx | 70 ++++++++++++++++------- graph-ui/src/components/GraphScene.tsx | 4 ++ graph-ui/src/components/GraphTab.tsx | 41 ++++++++++++++ graph-ui/src/components/ModulesTab.tsx | 74 ++++++++++++++++++++++++- graph-ui/src/lib/flowgroup.test.ts | 31 +++++++++++ graph-ui/src/lib/flowgroup.ts | 30 ++++++++++ 8 files changed, 259 insertions(+), 35 deletions(-) create mode 100644 graph-ui/src/lib/flowgroup.test.ts create mode 100644 graph-ui/src/lib/flowgroup.ts diff --git a/graph-ui/src/App.tsx b/graph-ui/src/App.tsx index 8420059d1..2742da707 100644 --- a/graph-ui/src/App.tsx +++ b/graph-ui/src/App.tsx @@ -74,6 +74,13 @@ export function App() { const t = useUiMessages(); const [route, setRoute] = useState(readRoute); const [commandOpen, setCommandOpen] = useState(false); + /* The galaxy keeps its WebGL context and layout across tab switches: + * mounted once visited, hidden with `invisible` (never display:none, which + * would zero the canvas), frameloop paused while hidden. */ + const [graphVisited, setGraphVisited] = useState(false); + useEffect(() => { + if (route.tab === "graph") setGraphVisited(true); + }, [route.tab]); const { tab: activeTab, project: selectedProject } = route; useEffect(() => { @@ -217,7 +224,26 @@ export function App() { {/* Content */} -
+
+ {graphVisited && ( +
+ + navigate({ tab: "graph", node, region }) + } + /> +
+ )} {activeTab === "overview" && selectedProject ? ( - ) : activeTab === "graph" ? ( - - navigate({ tab: "graph", node, region }) - } - /> - ) : activeTab === "flows" && selectedProject ? ( + ) : activeTab === "graph" ? null : activeTab === "flows" && + selectedProject ? ( onToggleLabel(label)} className={`inline-flex items-center gap-1 px-1.5 py-[3px] rounded-md text-[12px] font-medium transition-all border ${ - on ? "border-white/[0.08] bg-popover" : "border-transparent opacity-25" + on ? "border-white/[0.08] bg-popover" : "border-transparent opacity-25 line-through" }`} > @@ -156,7 +156,7 @@ export function FilterPanel({ key={type} onClick={() => onToggleEdgeType(type)} className={`inline-flex items-center gap-1 px-1.5 py-[3px] rounded-md text-[12px] font-medium transition-all border ${ - on ? "border-border bg-popover text-foreground/60" : "border-transparent opacity-20 text-foreground/45" + on ? "border-border bg-popover text-foreground/60" : "border-transparent opacity-20 text-foreground/45 line-through" }`} > {type.replace(/_/g, " ").toLowerCase()} diff --git a/graph-ui/src/components/FlowsTab.tsx b/graph-ui/src/components/FlowsTab.tsx index 3949e29c0..f3148b5b1 100644 --- a/graph-ui/src/components/FlowsTab.tsx +++ b/graph-ui/src/components/FlowsTab.tsx @@ -12,6 +12,7 @@ import { type FlowsPayload, type TracePayload, } from "../lib/atlas"; +import { groupFlowsByEntry } from "../lib/flowgroup"; import { AddToPromptButton } from "./PromptBasket"; interface FlowsTabProps { @@ -125,6 +126,7 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab const [traceTo, setTraceTo] = useState(null); const [traceMode, setTraceMode] = useState<"calls" | "data">("calls"); const [trace, setTrace] = useState(null); + const [expandedEntries, setExpandedEntries] = useState>(new Set()); const [tracing, setTracing] = useState(false); const runTrace = async () => { @@ -299,27 +301,53 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab

{groupKey === "cross" ? "across regions" : "within one region"}

- {flows.map((flow) => ( - - ))} + {groupFlowsByEntry(flows).map((group) => { + const groupKey2 = `${groupKey}:${group.entryName}`; + const expanded = expandedEntries.has(groupKey2); + const visible = expanded ? group.flows : group.flows.slice(0, 1); + return ( +
+ {visible.map((flow) => ( + + ))} + {group.flows.length > 1 && ( + + )} +
+ ); + })} ); })} diff --git a/graph-ui/src/components/GraphScene.tsx b/graph-ui/src/components/GraphScene.tsx index f6b8ec1e0..0785c394d 100644 --- a/graph-ui/src/components/GraphScene.tsx +++ b/graph-ui/src/components/GraphScene.tsx @@ -111,6 +111,8 @@ function IdleAutoRotate({ /* ── Main scene ─────────────────────────────────────────── */ interface GraphSceneProps { + /* False pauses the render loop (hidden-but-mounted tab). */ + active?: boolean; 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 @@ -131,6 +133,7 @@ interface GraphSceneProps { export type { CameraTarget }; export function GraphScene({ + active = true, data, missed = null, highlightedIds, @@ -155,6 +158,7 @@ export function GraphScene({ return ( { + try { + return localStorage.getItem("cbm-hint-galaxy") === "1"; + } catch { + return true; + } + }); + if (dismissed) return null; + return ( +
+

+ Each sphere is a region of related code. Double-click one to enter it, + type above to light up matches, and use ⌂ on the map to fit everything. +

+ +
+ ); +} + /* Persist panel widths */ function loadWidth(key: string, fallback: number): number { try { @@ -63,6 +97,8 @@ function saveNodeBudget(project: string, value: number) { } interface GraphTabProps { + /* False while the tab is hidden-but-mounted: the render loop pauses. */ + active?: boolean; project: string | null; /* Deep-link state from the URL (?node=®ion=) and the reporter back. */ routeNode?: string | null; @@ -84,6 +120,7 @@ export function formatGraphLimitNotice(data: GraphData | null): string | null { } export function GraphTab({ + active = true, project, routeNode = null, routeRegion = null, @@ -608,6 +645,7 @@ export function GraphTab({
+ +
))} +
+ {(["map", "bars"] as const).map((mode) => ( + + ))} +
{tree && ( - + {tree.files.toLocaleString("en-US")} files ·{" "} {tree.symbols.toLocaleString("en-US")} symbols {tree.children_dropped ? ` · ${tree.children_dropped} children not shown` : ""} @@ -170,6 +201,47 @@ export function ModulesTab({

Measuring the territory…

+ ) : viewMode === "bars" ? ( +
+ {[...tree.children] + .sort((a, b) => b.symbols - a.symbols) + .map((child) => { + const meta = + child.region !== undefined ? regionMeta.get(child.region) : undefined; + const max = Math.max(...tree.children.map((c) => c.symbols), 1); + return ( + + ); + })} +
) : ( { + it("merges flows sharing an entry, richest first, order preserved", () => { + const groups = groupFlowsByEntry([ + flow(1, "main", 5), + flow(2, "serve", 9), + flow(3, "main", 12), + ]); + expect(groups.map((g) => g.entryName)).toEqual(["main", "serve"]); + expect(groups[0].flows.map((f) => f.id)).toEqual([3, 1]); + expect(groups[1].flows).toHaveLength(1); + }); + it("handles empty input", () => { + expect(groupFlowsByEntry([])).toEqual([]); + }); +}); diff --git a/graph-ui/src/lib/flowgroup.ts b/graph-ui/src/lib/flowgroup.ts new file mode 100644 index 000000000..9b138c689 --- /dev/null +++ b/graph-ui/src/lib/flowgroup.ts @@ -0,0 +1,30 @@ +import type { FlowSummary } from "./atlas"; + +/* Merge flows that share an entry point: the list shows one row per entry, + * richest flow first, with the rest behind a x-N expander. */ +export interface FlowEntryGroup { + entryName: string; + flows: FlowSummary[]; +} + +export function groupFlowsByEntry(flows: FlowSummary[]): FlowEntryGroup[] { + const order: string[] = []; + const byEntry = new Map(); + for (const flow of flows) { + const key = flow.entry.name; + const bucket = byEntry.get(key); + if (bucket) { + bucket.push(flow); + } else { + byEntry.set(key, [flow]); + order.push(key); + } + } + return order.map((entryName) => { + const group = byEntry.get(entryName)!; + return { + entryName, + flows: [...group].sort((a, b) => b.steps - a.steps), + }; + }); +}