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 <entry> - 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 <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-08-24 14:32:36 +02:00
parent 03995b7461
commit a4286e8420
8 changed files with 259 additions and 35 deletions
+29 -11
View File
@@ -74,6 +74,13 @@ export function App() {
const t = useUiMessages();
const [route, setRoute] = useState<RouteState>(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() {
</header>
{/* Content */}
<main className="flex-1 min-h-0">
<main className="flex-1 min-h-0 relative">
{graphVisited && (
<div
className={
activeTab === "graph"
? "h-full"
: "invisible pointer-events-none absolute inset-0"
}
>
<GraphTab
active={activeTab === "graph"}
project={selectedProject}
routeNode={route.node}
routeRegion={route.region}
onRouteChange={(node, region) =>
navigate({ tab: "graph", node, region })
}
/>
</div>
)}
{activeTab === "overview" && selectedProject ? (
<OverviewTab
project={selectedProject}
@@ -235,16 +261,8 @@ export function App() {
onOpenSymbol={openSymbol}
onOpenRegion={openRegion}
/>
) : activeTab === "graph" ? (
<GraphTab
project={selectedProject}
routeNode={route.node}
routeRegion={route.region}
onRouteChange={(node, region) =>
navigate({ tab: "graph", node, region })
}
/>
) : activeTab === "flows" && selectedProject ? (
) : activeTab === "graph" ? null : activeTab === "flows" &&
selectedProject ? (
<FlowsTab
project={selectedProject}
flowId={route.flow !== null ? Number(route.flow) : null}
+2 -2
View File
@@ -131,7 +131,7 @@ export function FilterPanel({
key={label}
onClick={() => 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"
}`}
>
<span className="w-[5px] h-[5px] rounded-full" style={{ backgroundColor: on ? c : "#444" }} />
@@ -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()}
+49 -21
View File
@@ -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<string | null>(null);
const [traceMode, setTraceMode] = useState<"calls" | "data">("calls");
const [trace, setTrace] = useState<TracePayload | null>(null);
const [expandedEntries, setExpandedEntries] = useState<Set<string>>(new Set());
const [tracing, setTracing] = useState(false);
const runTrace = async () => {
@@ -299,27 +301,53 @@ export function FlowsTab({ project, flowId, onOpenFlow, onOpenSymbol }: FlowsTab
<p className="px-4 pt-2 pb-1 text-[12px] uppercase tracking-widest text-foreground/40">
{groupKey === "cross" ? "across regions" : "within one region"}
</p>
{flows.map((flow) => (
<button
key={flow.id}
onClick={() => onOpenFlow(flow.id)}
className={`flex items-center gap-2 w-full text-left px-4 py-[6px] transition-colors ${
flowId === flow.id
? "bg-primary/10 text-primary"
: "text-foreground/60 hover:text-foreground/85 hover:bg-surface-3"
}`}
>
<span className="text-[13px] font-mono truncate flex-1">{flow.label}</span>
<span className="text-[12px] tabular-nums text-foreground/40 shrink-0">
{flow.steps}
</span>
{!flow.sink_terminated && (
<span className="text-[12px] text-foreground/35 shrink-0" title="walk stopped at the depth cap, not at a sink">
…
</span>
)}
</button>
))}
{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 (
<div key={groupKey2}>
{visible.map((flow) => (
<button
key={flow.id}
onClick={() => onOpenFlow(flow.id)}
className={`flex items-center gap-2 w-full text-left px-4 py-[6px] transition-colors ${
flowId === flow.id
? "bg-primary/10 text-primary"
: "text-foreground/60 hover:text-foreground/85 hover:bg-surface-3"
}`}
>
<span className="text-[13px] font-mono truncate flex-1">{flow.label}</span>
<span className="text-[12px] tabular-nums text-foreground/40 shrink-0">
{flow.steps}
</span>
{!flow.sink_terminated && (
<span className="text-[12px] text-foreground/35 shrink-0" title="walk stopped at the depth cap, not at a sink">
…
</span>
)}
</button>
))}
{group.flows.length > 1 && (
<button
onClick={() =>
setExpandedEntries((prev) => {
const next = new Set(prev);
if (next.has(groupKey2)) next.delete(groupKey2);
else next.add(groupKey2);
return next;
})
}
className="w-full text-left px-4 py-[3px] text-[12px] text-foreground/40 hover:text-primary transition-colors"
>
{expanded
? "− collapse"
: `× ${group.flows.length} journeys from ${group.entryName} — show all`}
</button>
)}
</div>
);
})}
</div>
);
})}
+4
View File
@@ -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 (
<Canvas
frameloop={active ? "always" : "never"}
camera={{ position: [0, 0, 800], fov: 50, near: 0.1, far: 100000 }}
style={{ background: "#0D0F12" }}
dpr={GRAPH_CANVAS_DPR}
+41
View File
@@ -35,6 +35,40 @@ import { ErrorBoundary } from "./ErrorBoundary";
import type { GraphNode, GraphData, Region, RegionsPayload, RepoInfo } from "../lib/types";
import { colorForStatus } from "../lib/colors";
/* One-time in-place orientation hint for the region scene. Dismissing
* persists; navigating away without dismissing does not (skip != dismiss). */
function GalaxyHint() {
const [dismissed, setDismissed] = useState(() => {
try {
return localStorage.getItem("cbm-hint-galaxy") === "1";
} catch {
return true;
}
});
if (dismissed) return null;
return (
<div className="absolute bottom-4 left-4 bg-card/90 backdrop-blur border border-border/50 rounded-md px-3 py-2.5 max-w-[300px] shadow-lg">
<p className="text-[13px] text-foreground/70 leading-relaxed">
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.
</p>
<button
onClick={() => {
try {
localStorage.setItem("cbm-hint-galaxy", "1");
} catch {
/* ignore */
}
setDismissed(true);
}}
className="mt-1.5 text-[12px] text-primary hover:text-primary/80 transition-colors"
>
Got it
</button>
</div>
);
}
/* 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=&region=) 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({
<div className="flex-1 relative overflow-hidden">
<ErrorBoundary>
<GraphScene
active={active}
data={regionGraph}
missed={null}
highlightedIds={
@@ -666,6 +704,8 @@ export function GraphTab({
</p>
</div>
<GalaxyHint />
<div className="absolute top-4 right-4 flex gap-2 items-center">
<Button variant="outline" size="sm" onClick={loadFullGalaxy}>
Load full galaxy
@@ -796,6 +836,7 @@ export function GraphTab({
<>
<ErrorBoundary>
<GraphScene
active={active}
data={filteredData}
missed={showMissedSkeleton ? missedSkeleton : null}
highlightedIds={highlightedIds}
+73 -1
View File
@@ -105,6 +105,22 @@ export function ModulesTab({
[project],
);
const [viewMode, setViewMode] = useState<"map" | "bars">(() => {
try {
return localStorage.getItem("cbm-modules-view") === "bars" ? "bars" : "map";
} catch {
return "map";
}
});
const switchView = (mode: "map" | "bars") => {
setViewMode(mode);
try {
localStorage.setItem("cbm-modules-view", mode);
} catch {
/* ignore */
}
};
const crumbs = useMemo(() => {
const parts = path ? path.split("/") : [];
return [{ label: "root", path: "" }].concat(
@@ -154,8 +170,23 @@ export function ModulesTab({
</button>
</span>
))}
<div className="ml-auto flex items-center gap-0.5 bg-popover rounded-md p-0.5 border border-border/40">
{(["map", "bars"] as const).map((mode) => (
<button
key={mode}
onClick={() => switchView(mode)}
className={`px-2 py-0.5 rounded text-[12px] transition-colors ${
viewMode === mode
? "bg-primary/20 text-primary"
: "text-foreground/45 hover:text-foreground/70"
}`}
>
{mode}
</button>
))}
</div>
{tree && (
<span className="ml-auto text-[13px] text-foreground/45 font-mono">
<span className="text-[13px] text-foreground/45 font-mono">
{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({
<div className="flex items-center justify-center h-full">
<p className="text-foreground/45 text-sm">Measuring the territory…</p>
</div>
) : viewMode === "bars" ? (
<div className="h-full overflow-y-auto pr-2">
{[...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 (
<button
key={child.path}
onClick={() =>
child.kind === "dir" ? onNavigatePath(child.path) : openFile(child)
}
className="flex items-center gap-2 w-full text-left px-2 py-[5px] rounded-md hover:bg-surface-3 transition-colors group"
>
<span
className="w-[7px] h-[7px] rounded-full shrink-0"
style={{ backgroundColor: meta?.color ?? NEUTRAL }}
/>
<span className="text-[13px] font-mono text-foreground/70 group-hover:text-foreground/90 truncate w-[220px] shrink-0 transition-colors">
{child.name}
{child.kind === "dir" ? "/" : ""}
</span>
<span className="flex-1 h-[10px] bg-surface-3/60 rounded-sm overflow-hidden">
<span
className="block h-full rounded-sm"
style={{
width: `${Math.max(1, (child.symbols / max) * 100)}%`,
backgroundColor: meta?.color ?? NEUTRAL,
opacity: 0.65,
}}
/>
</span>
<span className="text-[12px] tabular-nums text-foreground/40 shrink-0 w-[70px] text-right">
{child.symbols.toLocaleString("en-US")}
</span>
</button>
);
})}
</div>
) : (
<svg
width="100%"
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { groupFlowsByEntry } from "./flowgroup";
import type { FlowSummary } from "./atlas";
function flow(id: number, entry: string, steps: number): FlowSummary {
return {
id,
label: `${entry} → t${id}`,
entry: { id: id * 10, name: entry },
terminal: { id: id * 10 + 1, name: `t${id}` },
steps,
sink_terminated: true,
cross_region: false,
} as FlowSummary;
}
describe("groupFlowsByEntry", () => {
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([]);
});
});
+30
View File
@@ -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<string, FlowSummary[]>();
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),
};
});
}