From e53561a23a3b76e8896592fe1934692bd24c54fc Mon Sep 17 00:00:00 2001 From: Maksim Beliakov Date: Sun, 7 Jun 2026 16:47:30 +0000 Subject: [PATCH 1/8] fix(dashboard): render edges attached to containers ContainerNode had no React Flow Handle components, so every edge whose endpoint was a container atom (container->portal dashed edges and aggregated container->container edges) was silently skipped by React Flow. Add invisible target/top + source/bottom handles, and call updateNodeInternals when the container resizes on expand/collapse so edge anchors follow the new bounds instead of pointing at the stale pre-expansion geometry. --- .../dashboard/src/components/ContainerNode.tsx | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/understand-anything-plugin/packages/dashboard/src/components/ContainerNode.tsx b/understand-anything-plugin/packages/dashboard/src/components/ContainerNode.tsx index e0d4f1ca..19ae6463 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/ContainerNode.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/ContainerNode.tsx @@ -1,4 +1,5 @@ -import { memo } from "react"; +import { memo, useEffect } from "react"; +import { Handle, Position, useUpdateNodeInternals } from "@xyflow/react"; import type { NodeProps, Node } from "@xyflow/react"; import { getLayerColor } from "./LayerLegend"; @@ -18,8 +19,14 @@ export interface ContainerNodeData extends Record { export type ContainerFlowNode = Node; -function ContainerNodeComponent({ data, width, height }: NodeProps) { +function ContainerNodeComponent({ id, data, width, height }: NodeProps) { const color = getLayerColor(data.colorIndex); + // Re-measure handles when the container resizes on expand/collapse — + // otherwise edges keep pointing at the stale (pre-expansion) bounds. + const updateNodeInternals = useUpdateNodeInternals(); + useEffect(() => { + updateNodeInternals(id); + }, [id, width, height, data.isExpanded, updateNodeInternals]); const borderColor = data.isDiffAffected ? "var(--color-diff-changed)" @@ -58,6 +65,11 @@ function ContainerNodeComponent({ data, width, height }: NodeProps + {/* Invisible handles — without them React Flow cannot anchor + container→portal / container→container aggregated edges and + silently skips rendering them. */} + +
Date: Sun, 7 Jun 2026 16:47:30 +0000 Subject: [PATCH 2/8] feat(dashboard): re-route portal edges to files on expand + show neighbor names on portal cards 1. Portal edges were sourced off container atoms with re-routing on expand explicitly deferred (TODO in Stage 1). Implement it: when a container is expanded, its container->portal edge is replaced with per-file edges from the actual cross-layer files inside it (portalCrossFiles map computed in Stage 1). 2. Portal cards now list the names of the external files behind the aggregated connection count (up to 6, then +N more), via a new findExternalNeighborFiles() helper - the user can see WHICH files in the neighboring layer this layer talks to without leaving the view. --- .../dashboard/src/components/GraphView.tsx | 56 ++++++++++++++++-- .../dashboard/src/components/PortalNode.tsx | 19 ++++++ .../dashboard/src/utils/edgeAggregation.ts | Bin 5839 -> 6721 bytes 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx index 2a5037a1..b3b6c1eb 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx @@ -48,6 +48,7 @@ import { aggregateContainerEdges, aggregateLayerEdges, computePortals, + findExternalNeighborFiles, findCrossLayerFileNodes, } from "../utils/edgeAggregation"; import { deriveContainers } from "../utils/containers"; @@ -340,6 +341,7 @@ interface LayerDetailTopology { containers: DerivedContainer[]; nodeToContainer: Map; intraContainer: GraphEdge[]; + portalCrossFiles: Map; } const EMPTY_TOPOLOGY: LayerDetailTopology = { @@ -352,6 +354,7 @@ const EMPTY_TOPOLOGY: LayerDetailTopology = { containers: [], nodeToContainer: new Map(), intraContainer: [], + portalCrossFiles: new Map(), }; /** @@ -588,6 +591,7 @@ function useLayerDetailTopology(): LayerDetailTopology & { const portals = computePortals(graph, activeLayerId); const layerIndexMap = new Map(graph.layers.map((l, i) => [l.id, i])); + const nodeNameById = new Map(graph.nodes.map((n) => [n.id, n.name ?? n.id])); const portalNodes: PortalFlowNode[] = portals.map((portal) => ({ id: `portal:${portal.layerId}`, type: "portal" as const, @@ -597,14 +601,24 @@ function useLayerDetailTopology(): LayerDetailTopology & { targetLayerName: portal.layerName, connectionCount: portal.connectionCount, layerColorIndex: layerIndexMap.get(portal.layerId) ?? 0, + externalFileNames: [ + ...findExternalNeighborFiles(graph, activeLayerId, portal.layerId), + ] + .map((id) => nodeNameById.get(id) ?? id) + .sort(), onNavigate: drillIntoLayer, }, })); const portalEdges: Edge[] = []; + const portalCrossFiles = new Map(); let portalEdgeIdx = aggEdges.length; for (const portal of portals) { const crossFiles = findCrossLayerFileNodes(graph, activeLayerId, portal.layerId); + portalCrossFiles.set( + `portal:${portal.layerId}`, + [...crossFiles].filter((id) => filteredNodeIds.has(id)), + ); // Dedupe by atom — multiple files in the same container hitting the // same portal collapse to one Stage 1 edge. Task 12 will re-route to // the actual file ids when the source container expands. @@ -618,7 +632,7 @@ function useLayerDetailTopology(): LayerDetailTopology & { id: `e-${portalEdgeIdx++}`, source: atomId, target: `portal:${portal.layerId}`, - style: { stroke: "rgba(212,165,116,0.2)", strokeWidth: 1, strokeDasharray: "4 4" }, + style: { stroke: "rgba(212,165,116,0.55)", strokeWidth: 1.6, strokeDasharray: "5 4" }, animated: false, }); } @@ -636,6 +650,7 @@ function useLayerDetailTopology(): LayerDetailTopology & { aggEdges, portalNodes, portalEdges, + portalCrossFiles, }; }, [ graph, @@ -681,6 +696,7 @@ function useLayerDetailTopology(): LayerDetailTopology & { aggEdges, portalNodes, portalEdges, + portalCrossFiles, } = built; // Build Stage 1 ELK input: containers as opaque atoms + ungrouped files @@ -758,6 +774,7 @@ function useLayerDetailTopology(): LayerDetailTopology & { containers, nodeToContainer, intraContainer, + portalCrossFiles, }); setLayoutStatus("ready"); }) @@ -1258,10 +1275,39 @@ function useLayerDetailGraph() { expandedContainers, ]); + // Re-route portal edges: when a container is expanded, replace its single + // container→portal edge with per-file edges from the actual cross-layer + // files inside it (e.g. service.ts → "REST API" portal). + const portalEdgesFinal = useMemo(() => { + if (expandedContainers.size === 0) return topo.portalEdges; + const out: Edge[] = []; + const seen = new Set(); + for (const pe of topo.portalEdges) { + const srcAtom = String(pe.source); + if (!expandedContainers.has(srcAtom)) { + out.push(pe); + continue; + } + const portalId = String(pe.target); + const files = topo.portalCrossFiles.get(portalId) ?? []; + let emitted = false; + for (const f of files) { + if (topo.nodeToContainer.get(f) !== srcAtom) continue; + const key = `${f}|${portalId}`; + if (seen.has(key)) continue; + seen.add(key); + out.push({ ...pe, id: `portal-file-${key}`, source: f }); + emitted = true; + } + if (!emitted) out.push(pe); + } + return out; + }, [topo.portalEdges, topo.portalCrossFiles, topo.nodeToContainer, expandedContainers]); + const edges = useMemo(() => { - // Compose: Stage 1 / inflated edges, plus portal edges (Stage 1 sources - // them off container atoms — re-sourcing on expand is deferred). - const base = [...expandedEdges, ...topo.portalEdges]; + // Compose: Stage 1 / inflated edges, plus portal edges (re-routed to + // actual files for expanded containers). + const base = [...expandedEdges, ...portalEdgesFinal]; if (!selectedNodeId) return base; // Apply selection-based edge styling on top of topology edges @@ -1276,7 +1322,7 @@ function useLayerDetailGraph() { // Fade unrelated edges return { ...edge, animated: false, style: { stroke: "rgba(212,165,116,0.08)", strokeWidth: 1 }, labelStyle: { fill: "rgba(163,151,135,0.2)", fontSize: 10 } }; }); - }, [expandedEdges, topo.portalEdges, selectedNodeId]); + }, [expandedEdges, portalEdgesFinal, selectedNodeId]); // Expose container topology so the parent component can wire auto-expand // triggers (focus, tour, zoom) without having to re-derive containers. diff --git a/understand-anything-plugin/packages/dashboard/src/components/PortalNode.tsx b/understand-anything-plugin/packages/dashboard/src/components/PortalNode.tsx index 04f94212..05911fbf 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/PortalNode.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/PortalNode.tsx @@ -8,6 +8,7 @@ export interface PortalNodeData extends Record { targetLayerName: string; connectionCount: number; layerColorIndex: number; + externalFileNames?: string[]; onNavigate: (layerId: string) => void; } @@ -50,6 +51,24 @@ function PortalNode({
{data.connectionCount} connection{data.connectionCount !== 1 ? "s" : ""}
+ {data.externalFileNames && data.externalFileNames.length > 0 && ( +
+ {data.externalFileNames.slice(0, 6).map((n) => ( +
+ · {n} +
+ ))} + {data.externalFileNames.length > 6 && ( +
+ +{data.externalFileNames.length - 6} more +
+ )} +
+ )}
Y5Jf9l!a)dHD8keSn#M{ik!T|)NCd&gH9MDE*zAOvJwJ-zkJw*fC;AKQ zh5RMwOj6D8-kUe?EsuUqK9_s%W>}U6N*KK+maw3;lUpEgg#t9Q}P)QKX@iBp%`vXgw%+aGMIRBOG4&c*g&zTY|c e{_1^f<=>*8L-Cj&ivI4xz`BYKX+t_Q3-bplO=BMb delta 7 OcmX?Ta$a}Cc`*PF2?K`! From 5799e7749f451e4038ddef02bb123142c9e0d96c Mon Sep 17 00:00:00 2001 From: Maksim Beliakov Date: Sun, 7 Jun 2026 16:47:30 +0000 Subject: [PATCH 3/8] feat(dashboard): meaningful container names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Community clusters are now labeled by their member files ("sagas · service · actions +2") instead of "Cluster A/B/C". 2. A single folder covering the whole filtered set (e.g. a Redux slice folder like src/store/meetingTypes) is kept as ONE container named after the folder instead of being split into anonymous Louvain communities. Tests updated + new coverage for both behaviors. --- .../src/utils/__tests__/containers.test.ts | 28 +++++++++++++++- .../dashboard/src/utils/containers.ts | 32 +++++++++++++++++-- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts b/understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts index b5d6d407..92d9b061 100644 --- a/understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts +++ b/understand-anything-plugin/packages/dashboard/src/utils/__tests__/containers.test.ts @@ -107,10 +107,36 @@ describe("deriveContainers — community fallback", () => { } } const { containers } = deriveContainers(nodes, edges); + // A single folder covering the whole set is now kept as ONE folder + // container (named after the folder) instead of being split into + // anonymous Louvain communities. + expect(containers.length).toBe(1); + expect(containers[0].strategy).toBe("folder"); + expect(containers[0].name).toBe("services"); + }); + + it("names community clusters by member files when no folder signal exists", () => { + // Flat paths (no directories) force the community fallback. + const nodes = Array.from({ length: 10 }, (_, i) => + node(`n${i}`, `n${i}.go`), + ); + const edges: GraphEdge[] = []; + for (const i of [0, 1, 2, 3, 4]) { + for (const j of [0, 1, 2, 3, 4]) { + if (i !== j) edges.push({ source: `n${i}`, target: `n${j}`, type: "calls" } as GraphEdge); + } + } + for (const i of [5, 6, 7, 8, 9]) { + for (const j of [5, 6, 7, 8, 9]) { + if (i !== j) edges.push({ source: `n${i}`, target: `n${j}`, type: "calls" } as GraphEdge); + } + } + const { containers } = deriveContainers(nodes, edges); expect(containers.length).toBeGreaterThanOrEqual(2); for (const c of containers) { expect(c.strategy).toBe("community"); - expect(c.name).toMatch(/^Cluster [A-Z]$/); + // Member-derived label, e.g. "n0 · n1 · n2 +2" — not "Cluster A". + expect(c.name).toMatch(/·|\+/); } }); diff --git a/understand-anything-plugin/packages/dashboard/src/utils/containers.ts b/understand-anything-plugin/packages/dashboard/src/utils/containers.ts index dd885219..2afe8af5 100644 --- a/understand-anything-plugin/packages/dashboard/src/utils/containers.ts +++ b/understand-anything-plugin/packages/dashboard/src/utils/containers.ts @@ -79,6 +79,10 @@ function shouldFallbackToCommunity( rooted: string[], totalNodes: number, ): boolean { + // A single folder covering the whole set is a meaningful unit (e.g. a + // Redux slice folder like src/store/meetingTypes) — keep it as ONE named + // container instead of splitting into anonymous Louvain communities. + if (groups.size === 1 && rooted.length === 0) return false; const bucketCount = groups.size + (rooted.length > 0 ? 1 : 0); if (bucketCount < MIN_BUCKET_COUNT) return true; for (const ids of groups.values()) { @@ -113,11 +117,33 @@ export function deriveContainers( byCommunity.set(cid, arr); } const sorted = [...byCommunity.entries()].sort((a, b) => a[0] - b[0]); + // Name community clusters by their member files instead of "Cluster A/B/C". + const nodeById = new Map(nodes.map((n) => [n.id, n])); + const FILE_LEVEL = new Set([ + "file", "config", "document", "service", "pipeline", + "table", "schema", "resource", "endpoint", + ]); + const labelFor = (ids: string[]): string => { + let members = ids + .map((id) => nodeById.get(id)) + .filter((n): n is GraphNode => Boolean(n)); + const fileMembers = members.filter((m) => FILE_LEVEL.has(m.type)); + if (fileMembers.length > 0) members = fileMembers; + const bases = [ + ...new Set( + members.map((m) => + (m.name ?? m.id).replace(/\.(tsx?|jsx?|java|py|go|rb|cs|kt)$/i, ""), + ), + ), + ]; + const head = bases.slice(0, 3).join(" · "); + return bases.length > 3 ? `${head} +${bases.length - 3}` : head; + }; containers = sorted.map(([cid, ids], i) => ({ id: `container:cluster-${cid}`, - // A-Z for the first 26, then numeric. Avoids `String.fromCharCode(65+i)` - // wrapping into `[`, `\`, `]` ... once the cluster count exceeds 26. - name: i < 26 ? `Cluster ${String.fromCharCode(65 + i)}` : `Cluster ${i + 1}`, + name: + labelFor(ids) || + (i < 26 ? `Cluster ${String.fromCharCode(65 + i)}` : `Cluster ${i + 1}`), nodeIds: ids, strategy: "community" as const, })); From 2e97477588af69d53c10df59fa33a6db4ebf0488 Mon Sep 17 00:00:00 2001 From: Maksim Beliakov Date: Sun, 7 Jun 2026 16:47:30 +0000 Subject: [PATCH 4/8] style(dashboard): readable node cards and visible portal edges - Node cards: 310px max width (was 220), titles wrap to 2 lines and truncate at 60 chars (was 24), summaries clamp at 3 lines (was 2). NODE_WIDTH/HEIGHT layout constants bumped to match. - Portal edges: stroke opacity 0.2 -> 0.55, width 1.6 - they were nearly invisible on the dark background, making layers look disconnected even when edges existed. --- .../packages/dashboard/src/components/CustomNode.tsx | 8 ++++---- .../packages/dashboard/src/utils/layout.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx b/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx index 5dffd2df..e40243eb 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx @@ -129,11 +129,11 @@ function CustomNodeComponent({ const name = data.label ?? "unnamed"; const truncatedName = - name.length > 24 ? name.slice(0, 22) + "..." : name; + name.length > 60 ? name.slice(0, 58) + "..." : name; return (
data.onNodeClick?.(id)} > {/* Left color bar */} @@ -168,11 +168,11 @@ function CustomNodeComponent({
-
+
{truncatedName}
-
+
{data.summary}
diff --git a/understand-anything-plugin/packages/dashboard/src/utils/layout.ts b/understand-anything-plugin/packages/dashboard/src/utils/layout.ts index b35328c5..4d2c89e8 100644 --- a/understand-anything-plugin/packages/dashboard/src/utils/layout.ts +++ b/understand-anything-plugin/packages/dashboard/src/utils/layout.ts @@ -12,8 +12,8 @@ import type { SimulationNodeDatum, SimulationLinkDatum } from "d3-force"; import type { Node, Edge } from "@xyflow/react"; import type { ElkInput } from "./elk-layout"; -export const NODE_WIDTH = 280; -export const NODE_HEIGHT = 120; +export const NODE_WIDTH = 330; +export const NODE_HEIGHT = 150; export const LAYER_CLUSTER_WIDTH = 320; export const LAYER_CLUSTER_HEIGHT = 180; export const PORTAL_NODE_WIDTH = 240; From 470b78090dacb278d4436d0409ea1d4c845c2030 Mon Sep 17 00:00:00 2001 From: Maksim Beliakov Date: Sun, 7 Jun 2026 21:40:48 +0000 Subject: [PATCH 5/8] =?UTF-8?q?feat(dashboard):=20navigation=20redesign=20?= =?UTF-8?q?=E2=80=94=20feature=20containers,=20drill-in,=20layer=20index?= =?UTF-8?q?=20view,=20tier=20pinning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - layer.containers (core schema): manual feature grouping with optional per-container 'group' for index-view sections; singleton feature containers allowed; single-folder layers keep ONE named container; small layers (<=10 files) skip auto-grouping entirely - containers are navigation chips: click drills into a feature mini-canvas (project -> layer -> feature), Esc walks back; breadcrumb shows the trail; zoom auto-expand removed - LayerIndexView: layers with many data-defined containers render as a scrollable sectioned feature index instead of a canvas - dense grid fallback for >12-children containers expanded via focus/tour - aggregateLayerEdges: net-direction merge of antiparallel pairs (alphabetical canonicalization destroyed dependency direction) - layer.tier + ELK partitioning pins storage layers to the bottom row - portal ELK size accounts for the member-name list (overlap fix) --- .../packages/core/src/schema.ts | 11 ++ .../packages/core/src/types.ts | 4 + .../packages/dashboard/src/App.tsx | 2 + .../dashboard/src/components/Breadcrumb.tsx | 22 ++- .../src/components/ContainerNode.tsx | 4 +- .../dashboard/src/components/CustomNode.tsx | 28 +++ .../dashboard/src/components/GraphView.tsx | 169 +++++++++++++++--- .../src/components/LayerIndexView.tsx | 154 ++++++++++++++++ .../packages/dashboard/src/store.ts | 29 +++ .../dashboard/src/utils/containers.ts | 38 ++++ .../dashboard/src/utils/edgeAggregation.ts | Bin 6721 -> 7769 bytes .../dashboard/src/utils/elk-layout.ts | 1 + .../packages/dashboard/src/utils/layout.ts | 6 + understand-anything-plugin/pnpm-lock.yaml | 127 +++++++++++-- 14 files changed, 555 insertions(+), 40 deletions(-) create mode 100644 understand-anything-plugin/packages/dashboard/src/components/LayerIndexView.tsx diff --git a/understand-anything-plugin/packages/core/src/schema.ts b/understand-anything-plugin/packages/core/src/schema.ts index 71b9cd8a..3ba8b7c9 100644 --- a/understand-anything-plugin/packages/core/src/schema.ts +++ b/understand-anything-plugin/packages/core/src/schema.ts @@ -399,6 +399,17 @@ export const LayerSchema = z.object({ name: z.string(), description: z.string(), nodeIds: z.array(z.string()), + containers: z + .array( + z.object({ + id: z.string().optional(), + name: z.string(), + nodeIds: z.array(z.string()), + group: z.string().optional(), + }), + ) + .optional(), + tier: z.number().optional(), }); export const TourStepSchema = z.object({ diff --git a/understand-anything-plugin/packages/core/src/types.ts b/understand-anything-plugin/packages/core/src/types.ts index b7a0fa6e..cbc47520 100644 --- a/understand-anything-plugin/packages/core/src/types.ts +++ b/understand-anything-plugin/packages/core/src/types.ts @@ -66,6 +66,10 @@ export interface Layer { name: string; description: string; nodeIds: string[]; + /** Optional manual grouping of this layer's nodes into named feature containers. */ + containers?: { id?: string; name: string; nodeIds: string[]; group?: string }[]; + /** Optional vertical tier for the project overview (0 = top). */ + tier?: number; } // TourStep (for learn mode) diff --git a/understand-anything-plugin/packages/dashboard/src/App.tsx b/understand-anything-plugin/packages/dashboard/src/App.tsx index 4d0b3969..e01469fd 100644 --- a/understand-anything-plugin/packages/dashboard/src/App.tsx +++ b/understand-anything-plugin/packages/dashboard/src/App.tsx @@ -297,6 +297,8 @@ function DashboardContent({ state.closeCodeViewer(); } else if (state.selectedNodeId) { state.selectNode(null); + } else if (state.activeContainerId) { + state.exitContainer(); } else if (state.navigationLevel === "layer-detail") { state.navigateToOverview(); } else if (state.tourActive) { diff --git a/understand-anything-plugin/packages/dashboard/src/components/Breadcrumb.tsx b/understand-anything-plugin/packages/dashboard/src/components/Breadcrumb.tsx index bb009497..ef31fa34 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/Breadcrumb.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/Breadcrumb.tsx @@ -4,6 +4,9 @@ import { useI18n } from "../contexts/I18nContext"; export default function Breadcrumb() { const navigationLevel = useDashboardStore((s) => s.navigationLevel); const activeLayerId = useDashboardStore((s) => s.activeLayerId); + const activeContainerId = useDashboardStore((s) => s.activeContainerId); + const activeContainerName = useDashboardStore((s) => s.activeContainerName); + const exitContainer = useDashboardStore((s) => s.exitContainer); const graph = useDashboardStore((s) => s.graph); const navigateToOverview = useDashboardStore((s) => s.navigateToOverview); const { t } = useI18n(); @@ -27,9 +30,22 @@ export default function Breadcrumb() { {t.breadcrumb.project} - - {activeLayer?.name ?? t.layer.defaultName} - + {activeContainerId ? ( + <> + + + {activeContainerName} + + ) : ( + + {activeLayer?.name ?? t.layer.defaultName} + + )} ({t.breadcrumb.escBack}) diff --git a/understand-anything-plugin/packages/dashboard/src/components/ContainerNode.tsx b/understand-anything-plugin/packages/dashboard/src/components/ContainerNode.tsx index 19ae6463..3fd4cb0f 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/ContainerNode.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/ContainerNode.tsx @@ -14,7 +14,7 @@ export interface ContainerNodeData extends Record { searchHitCount?: number; isDiffAffected: boolean; isFocusedViaChild: boolean; - onToggle: (containerId: string) => void; + onToggle: (containerId: string, name?: string) => void; } export type ContainerFlowNode = Node; @@ -40,7 +40,7 @@ function ContainerNodeComponent({ id, data, width, height }: NodeProps { e.stopPropagation(); - data.onToggle(data.containerId); + data.onToggle(data.containerId, data.name); }; return ( diff --git a/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx b/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx index e40243eb..d0d3f7e3 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/CustomNode.tsx @@ -77,6 +77,7 @@ export interface CustomNodeData extends Record { incomingCount?: number; outgoingCount?: number; tags?: string[]; + dense?: boolean; } export type CustomFlowNode = Node; @@ -131,6 +132,33 @@ function CustomNodeComponent({ const truncatedName = name.length > 60 ? name.slice(0, 58) + "..." : name; + // Dense row variant for children of large expanded containers. + if (data.dense) { + return ( +
data.onNodeClick?.(id)} + title={data.summary} + > +
+ +
+
+ {name} +
+
+ {data.nodeType} +
+
+ +
+ ); + } + return (
s.activeContainerId); + const { fitView } = useReactFlow(); + const prevRef = useRef(null); + + useEffect(() => { + if (activeContainerId !== prevRef.current) { + prevRef.current = activeContainerId; + const timer = setTimeout(() => { + fitView({ duration: 400, padding: 0.2 }); + }, 350); + return () => clearTimeout(timer); + } + }, [activeContainerId, fitView]); + + return null; +} + function SelectedNodeFitView() { const selectedNodeId = useDashboardStore((s) => s.selectedNodeId); const { fitView } = useReactFlow(); @@ -302,7 +324,25 @@ function useOverviewGraph() { let cancelled = false; const { clusterNodes, flowEdges, dims } = built; const baseNodes = clusterNodes as unknown as Node[]; - const elkInput = nodesToElkInput(baseNodes, flowEdges, dims); + // Tier pinning: when layers carry a tier, use ELK partitioning so the + // vertical order follows the declared architecture. + const tiers = new Map(); + for (const layer of graph?.layers ?? []) { + if (typeof layer.tier === "number") tiers.set(layer.id, layer.tier); + } + let layoutOverride: Record | undefined; + let childOptions: Map> | undefined; + if (tiers.size > 0) { + const maxTier = Math.max(...Array.from(tiers.values())); + layoutOverride = { "elk.partitioning.activate": "true" }; + childOptions = new Map( + baseNodes.map((n) => [ + n.id, + { "elk.partitioning.partition": String(tiers.get(n.id) ?? maxTier + 1) }, + ]), + ); + } + const elkInput = nodesToElkInput(baseNodes, flowEdges, dims, layoutOverride, childOptions); setLayoutStatus("computing"); applyElkLayout(elkInput, { strict: import.meta.env.DEV }) .then(({ positioned, issues }) => { @@ -369,6 +409,7 @@ function useLayerDetailTopology(): LayerDetailTopology & { layoutStatus: "computing" | "ready"; } { const graph = useDashboardStore((s) => s.graph); + const activeContainerId = useDashboardStore((s) => s.activeContainerId); const nodesById = useDashboardStore((s) => s.nodesById); const activeLayerId = useDashboardStore((s) => s.activeLayerId); const selectNode = useDashboardStore((s) => s.selectNode); @@ -392,10 +433,11 @@ function useLayerDetailTopology(): LayerDetailTopology & { // Stable across renders so ContainerNode's memo() actually short-circuits. // Reading toggleContainer via getState() avoids subscribing this hook to // expandedContainers — Stage 1 must not relayout on expand. - const handleContainerToggle = useCallback( - (id: string) => useDashboardStore.getState().toggleContainer(id), - [], - ); + const handleContainerToggle = useCallback((id: string, name?: string) => { + // Containers are a navigation level (project → layer → feature): + // clicking drills in instead of expanding dozens of cards in place. + useDashboardStore.getState().drillIntoContainer(id, name ?? id.replace(/^container:/, "")); + }, []); // ── Structural build (synchronous): filtering + containers + nodes/edges // pre-layout. Re-runs whenever the inputs that drive container derivation @@ -477,10 +519,33 @@ function useLayerDetailTopology(): LayerDetailTopology & { } // Derive containers + bucket edges - const { containers, ungrouped } = deriveContainers( - filteredGraphNodes, - filteredGraphEdges, - ); + // Small layers read better as plain full cards with real edges than as + // a couple of half-empty folder chips — skip auto-grouping (data-defined + // layer.containers still win). + const skipAutoGrouping = + !activeLayer.containers?.length && filteredGraphNodes.length <= 10; + let { containers, ungrouped } = skipAutoGrouping + ? { containers: [], ungrouped: filteredGraphNodes.map((n) => n.id) } + : deriveContainers( + filteredGraphNodes, + filteredGraphEdges, + activeLayer.containers, + ); + // Container drill-in view: only the active container's children, all + // ungrouped (full cards), no sibling containers on the canvas. + if (activeContainerId) { + const active = containers.find((c) => c.id === activeContainerId); + if (active) { + const childSet = new Set(active.nodeIds); + filteredGraphNodes = filteredGraphNodes.filter((n) => childSet.has(n.id)); + filteredNodeIds = new Set(filteredGraphNodes.map((n) => n.id)); + filteredGraphEdges = filteredGraphEdges.filter( + (e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target), + ); + containers = []; + ungrouped = [...childSet]; + } + } const ungroupedSet = new Set(ungrouped); const nodeToContainer = new Map(); for (const c of containers) { @@ -500,20 +565,17 @@ function useLayerDetailTopology(): LayerDetailTopology & { // Caps prevent first-paint sprawl: at 100 children sqrt() yields // ~3360px which renders as a huge empty box pre-expansion. Stage 2 // sets the actual size once it's measured, and Task 15 re-flows. - const STAGE1_MAX_CONTAINER_WIDTH = 800; - const STAGE1_MAX_CONTAINER_HEIGHT = 600; const sizeMemory = useDashboardStore.getState().containerSizeMemory; + // Containers render as compact navigation chips (click = drill in). const containerWidth = (c: DerivedContainer) => { const memo = sizeMemory.get(c.id)?.width; if (memo) return memo; - const estimate = Math.sqrt(c.nodeIds.length) * NODE_WIDTH * 1.2; - return Math.min(STAGE1_MAX_CONTAINER_WIDTH, Math.max(NODE_WIDTH, estimate)); + return 230; }; const containerHeight = (c: DerivedContainer) => { const memo = sizeMemory.get(c.id)?.height; if (memo) return memo; - const estimate = Math.sqrt(c.nodeIds.length) * NODE_HEIGHT * 1.2; - return Math.min(STAGE1_MAX_CONTAINER_HEIGHT, Math.max(NODE_HEIGHT, estimate)); + return 64; }; // Build container flow nodes (children NOT rendered yet — Task 12) @@ -578,7 +640,10 @@ function useLayerDetailTopology(): LayerDetailTopology & { id: `agg-${i}`, source: agg.sourceContainerId, target: agg.targetContainerId, - label: String(agg.count), + label: + agg.count === 1 && agg.edgeTypes.length === 1 + ? agg.edgeTypes[0] + : String(agg.count), style: baseStyle, labelStyle: { fill: diffMode ? "rgba(163,151,135,0.3)" : "#a39787", @@ -723,11 +788,17 @@ function useLayerDetailTopology(): LayerDetailTopology & { width: NODE_WIDTH, height: NODE_HEIGHT, })), - ...portalNodes.map((pn) => ({ - id: pn.id, - width: PORTAL_NODE_WIDTH, - height: PORTAL_NODE_HEIGHT, - })), + ...portalNodes.map((pn) => { + // Portal cards grew a member-name list — account for it in the + // layout estimate or ELK packs them at 80px and cards overlap. + const names = (pn.data.externalFileNames ?? []).length; + const listLines = Math.min(names, 6) + (names > 6 ? 1 : 0); + return { + id: pn.id, + width: PORTAL_NODE_WIDTH, + height: PORTAL_NODE_HEIGHT + (listLines > 0 ? 6 + listLines * 15 : 0), + }; + }), ]; const stage1Edges: ElkEdge[] = [ @@ -823,6 +894,36 @@ function useLayerDetailTopology(): LayerDetailTopology & { const childEdges = stage2Intra.filter( (e) => childIds.has(e.source) && childIds.has(e.target), ); + + // Dense path: large containers skip ELK and pack compact rows into + // a name-sorted grid — dozens of full cards produce an unusable + // multi-thousand-pixel canvas. + if (c.nodeIds.length > DENSE_THRESHOLD) { + const GAP_X = 14; + const GAP_Y = 10; + const sorted = [...c.nodeIds].sort((a, b) => { + const an = a.split("/").pop() ?? a; + const bn = b.split("/").pop() ?? b; + return an.localeCompare(bn); + }); + const cols = Math.max(2, Math.round(0.62 * Math.sqrt(sorted.length))); + const childPositions = new Map(); + sorted.forEach((id, i) => { + const col = i % cols; + const row = Math.floor(i / cols); + childPositions.set(id, { + x: 20 + col * (DENSE_NODE_WIDTH + GAP_X), + y: 56 + row * (DENSE_NODE_HEIGHT + GAP_Y), + }); + }); + const rows = Math.ceil(sorted.length / cols); + const actualSize = { + width: 40 + cols * (DENSE_NODE_WIDTH + GAP_X) - GAP_X, + height: 76 + rows * (DENSE_NODE_HEIGHT + GAP_Y) - GAP_Y, + }; + return { containerId, childPositions, actualSize, deviated: true }; + } + const stage2Children: ElkChild[] = c.nodeIds.map((id) => ({ id, width: NODE_WIDTH, @@ -1014,6 +1115,9 @@ function useLayerDetailGraph() { affectedNodeIds, onNodeClick: handleNodeSelect, }); + if (container.nodeIds.length > DENSE_THRESHOLD) { + (base.data as Record).dense = true; + } out.push({ ...base, parentId: containerId, @@ -1346,6 +1450,7 @@ function GraphViewInner() { const graph = useDashboardStore((s) => s.graph); const navigationLevel = useDashboardStore((s) => s.navigationLevel); const activeLayerId = useDashboardStore((s) => s.activeLayerId); + const activeContainerIdNav = useDashboardStore((s) => s.activeContainerId); const selectNode = useDashboardStore((s) => s.selectNode); const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer); const focusNodeId = useDashboardStore((s) => s.focusNodeId); @@ -1506,10 +1611,8 @@ function GraphViewInner() { if (vp.zoom <= 1.0) return; // Only fire when zoom actually increased — pan and zoom-out are no-ops. if (prev !== null && vp.zoom <= prev) return; - const expanded = useDashboardStore.getState().expandedContainers; - for (const cid of containerIds) { - if (!expanded.has(cid)) expandContainer(cid); - } + // Zoom-driven auto-expand disabled: containers open only by + // explicit click (drill-in), focus or tour navigation. }, 200); }, [containerIds, getViewport, expandContainer], @@ -1551,6 +1654,21 @@ function GraphViewInner() { ); } + const indexLayer = + navigationLevel === "layer-detail" && !activeContainerIdNav + ? graph.layers.find((l) => l.id === activeLayerId) + : undefined; + const indexMode = (indexLayer?.containers?.length ?? 0) > 8; + + if (indexMode) { + return ( +
+ + +
+ ); + } + return (
@@ -1595,6 +1713,7 @@ function GraphViewInner() { /> + {(layoutStatus === "computing" || tourFitPending) && (
s.graph); + const activeLayerId = useDashboardStore((s) => s.activeLayerId); + const nodesById = useDashboardStore((s) => s.nodesById); + const drillIntoContainer = useDashboardStore((s) => s.drillIntoContainer); + const drillIntoLayer = useDashboardStore((s) => s.drillIntoLayer); + const selectNode = useDashboardStore((s) => s.selectNode); + const selectedNodeId = useDashboardStore((s) => s.selectedNodeId); + + if (!graph || !activeLayerId) return null; + const layer = graph.layers.find((l) => l.id === activeLayerId); + if (!layer?.containers?.length) return null; + + const claimed = new Set(layer.containers.flatMap((c) => c.nodeIds)); + const ungrouped = layer.nodeIds.filter((id) => !claimed.has(id)); + const portals = computePortals(graph, activeLayerId); + const layerIndexMap = new Map(graph.layers.map((l, i) => [l.id, i])); + // Sections: containers may declare a `group` (e.g. "services", "models"). + // Section order = first appearance in data; ungrouped go to "features". + const sections: { title: string; items: typeof layer.containers }[] = []; + const sectionIdx = new Map(); + for (const c of layer.containers) { + const title = c.group ?? "features"; + if (!sectionIdx.has(title)) { + sectionIdx.set(title, sections.length); + sections.push({ title, items: [] }); + } + sections[sectionIdx.get(title)!].items.push(c); + } + for (const sec of sections) { + sec.items = [...sec.items].sort((a, b) => a.name.localeCompare(b.name)); + } + const showSectionTitles = sections.length > 1; + + const baseName = (id: string) => + nodesById.get(id)?.name ?? id.split("/").pop() ?? id; + + return ( +
+ {ungrouped.length > 0 && ( + <> +
+ Layer level +
+
+ {ungrouped.map((id) => { + const n = nodesById.get(id); + if (!n) return null; + const sel = selectedNodeId === id; + return ( +
selectNode(id)} + className={`cursor-pointer rounded-lg bg-elevated border px-4 py-3 max-w-[440px] transition-colors ${ + sel ? "border-gold" : "border-gold/40 hover:border-gold" + }`} + > +
{n.name}
+
+ {n.summary} +
+
+ ); + })} +
+ + )} + + {sections.map((sec) => ( +
+
+ {showSectionTitles ? sec.title : "Features"} · {sec.items.length} +
+
+ {sec.items.map((c) => ( +
drillIntoContainer(c.id ?? `container:${slug(c.name)}`, c.name)} + className="cursor-pointer rounded-lg bg-elevated border border-border-subtle hover:border-gold/60 transition-colors px-4 py-3" + > +
+ + {c.name} + + + {c.nodeIds.length} + +
+
+ {c.nodeIds.slice(0, 3).map((id) => ( +
+ · {baseName(id)} +
+ ))} + {c.nodeIds.length > 3 && ( +
+ +{c.nodeIds.length - 3} more +
+ )} +
+
+ ))} +
+
+ ))} + + {portals.length > 0 && ( + <> +
+ Connected layers +
+
+ {portals.map((p) => { + const color = getLayerColor(layerIndexMap.get(p.layerId) ?? 0); + return ( + + ); + })} +
+ + )} +
+ ); +} diff --git a/understand-anything-plugin/packages/dashboard/src/store.ts b/understand-anything-plugin/packages/dashboard/src/store.ts index b3b2a96c..fcc87af9 100644 --- a/understand-anything-plugin/packages/dashboard/src/store.ts +++ b/understand-anything-plugin/packages/dashboard/src/store.ts @@ -115,6 +115,9 @@ interface DashboardStore { // Lens navigation navigationLevel: NavigationLevel; activeLayerId: string | null; + /** Drilled-in container (third navigation level: project → layer → feature). */ + activeContainerId: string | null; + activeContainerName: string | null; codeViewerOpen: boolean; codeViewerNodeId: string | null; @@ -161,6 +164,8 @@ interface DashboardStore { navigateToHistoryIndex: (index: number) => void; goBackNode: () => void; drillIntoLayer: (layerId: string) => void; + drillIntoContainer: (containerId: string, name: string) => void; + exitContainer: () => void; navigateToOverview: () => void; setFocusNode: (nodeId: string | null) => void; setSearchQuery: (query: string) => void; @@ -299,6 +304,8 @@ export const useDashboardStore = create()((set, get) => ({ navigationLevel: "overview", activeLayerId: null, + activeContainerId: null, + activeContainerName: null, codeViewerOpen: false, codeViewerNodeId: null, codeViewerExpanded: false, @@ -471,10 +478,30 @@ export const useDashboardStore = create()((set, get) => ({ } }, + drillIntoContainer: (containerId, name) => + set({ + activeContainerId: containerId, + activeContainerName: name, + selectedNodeId: null, + focusNodeId: null, + expandedContainers: new Set(), + pendingFocusContainer: null, + }), + + exitContainer: () => + set({ + activeContainerId: null, + activeContainerName: null, + selectedNodeId: null, + focusNodeId: null, + }), + drillIntoLayer: (layerId) => set({ navigationLevel: "layer-detail", activeLayerId: layerId, + activeContainerId: null, + activeContainerName: null, selectedNodeId: null, focusNodeId: null, codeViewerOpen: false, @@ -493,6 +520,8 @@ export const useDashboardStore = create()((set, get) => ({ set({ navigationLevel: "overview", activeLayerId: null, + activeContainerId: null, + activeContainerName: null, selectedNodeId: null, focusNodeId: null, codeViewerOpen: false, diff --git a/understand-anything-plugin/packages/dashboard/src/utils/containers.ts b/understand-anything-plugin/packages/dashboard/src/utils/containers.ts index 2afe8af5..4ebdb752 100644 --- a/understand-anything-plugin/packages/dashboard/src/utils/containers.ts +++ b/understand-anything-plugin/packages/dashboard/src/utils/containers.ts @@ -83,6 +83,9 @@ function shouldFallbackToCommunity( // Redux slice folder like src/store/meetingTypes) — keep it as ONE named // container instead of splitting into anonymous Louvain communities. if (groups.size === 1 && rooted.length === 0) return false; + // A single folder covering the whole set is a meaningful unit — keep it + // as ONE named container instead of splitting into Louvain communities. + if (groups.size === 1 && rooted.length === 0) return false; const bucketCount = groups.size + (rooted.length > 0 ? 1 : 0); if (bucketCount < MIN_BUCKET_COUNT) return true; for (const ids of groups.values()) { @@ -92,14 +95,49 @@ function shouldFallbackToCommunity( return false; } +export interface PredefinedGroup { + id?: string; + name: string; + nodeIds: string[]; +} + +export function slug(name: string): string { + return name.toLowerCase().replace(/[^a-z0-9\u0400-\u04ff]+/gi, "-").replace(/^-+|-+$/g, ""); +} + export function deriveContainers( nodes: GraphNode[], edges: GraphEdge[], + predefined?: PredefinedGroup[], ): DeriveResult { if (nodes.length === 0) { return { containers: [], ungrouped: [] }; } + // Manual grouping from the graph data (layer.containers) wins over + // folder/community derivation. Nodes not claimed by any group stay + // ungrouped — they render as standalone cards one level above. + if (predefined && predefined.length > 0) { + const present = new Set(nodes.map((n) => n.id)); + const claimed = new Set(); + const containers: DerivedContainer[] = []; + for (const g of predefined) { + const ids = g.nodeIds.filter((id) => present.has(id) && !claimed.has(id)); + if (ids.length === 0) continue; // singleton FEATURE containers are allowed + for (const id of ids) claimed.add(id); + containers.push({ + id: g.id ?? `container:${slug(g.name)}`, + name: g.name, + nodeIds: ids, + strategy: "folder", + }); + } + if (containers.length > 0) { + const ungrouped = nodes.map((n) => n.id).filter((id) => !claimed.has(id)); + return { containers, ungrouped }; + } + } + const { groups, rooted } = groupByFolder(nodes); const useCommunity = shouldFallbackToCommunity(groups, rooted, nodes.length); diff --git a/understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts b/understand-anything-plugin/packages/dashboard/src/utils/edgeAggregation.ts index 5c39c1bcebc8f0ee5e323e3b713842d3408b2155..480762361b077e69ca70afe68d3f0710499e59db 100644 GIT binary patch delta 1083 zcmZuv!EO^V5LJniLpe4=^guP0ij);mA_NBr4XA}ktOVtf3sjNntS5;@VlQ4Nu!Im! zh!Y2v4*}&1_yW#cD{wFeH*F9LIG~BjhkaIYdAbj36XT0^>@+vy%=W9pW}Xvk9TlLB@yr&tTxB(*?>T z28sy|=O4ZT8HXd%N5uj%hJbLv*-&5ETaug)xay zA_Xx+Ib--5grq2e$Uak&$he`20>L8%bal5h-FlQgUSbSwI6Ig}Vk!fCVptaM4(5vJ z9#!@{yQi@hjc-_#cGlKPl2_H|$?x*sC4K!<-K(bgK5c=+mNY86mmRv~aiyEwEPYx6 z41|PQrUSW>F^GX_?a0C&eV(5u-PoAq%vYJ&UP!8Bnv(C;t4rlsQZ|6vKK)OV-2pSgOt+eZdTZAj3KOto fj>f{j3p9@2$=BSc8;}LdHBuM0S&P-@%4+8yIk|6E delta 273 zcmcaVb1)KN%^Rj>sMDFAhp7A2?pfGk$9Q7B0SDlP%@pz;a|_6pHRigb|Fs1?P+ zRaq%SBWcz_Hg~c-hr;B139-o`98!}PNH|UY$tpLwkwc3yaq>P6Nyenf`D`JR|MCfM JKF&2s7yz!4Ttxr? diff --git a/understand-anything-plugin/packages/dashboard/src/utils/elk-layout.ts b/understand-anything-plugin/packages/dashboard/src/utils/elk-layout.ts index 8bc9f862..a4668c26 100644 --- a/understand-anything-plugin/packages/dashboard/src/utils/elk-layout.ts +++ b/understand-anything-plugin/packages/dashboard/src/utils/elk-layout.ts @@ -6,6 +6,7 @@ export interface ElkChild { id: string; width?: number; height?: number; + layoutOptions?: Record; /** Set by ELK after layout; absent on input. Downstream consumers must default. */ x?: number; y?: number; diff --git a/understand-anything-plugin/packages/dashboard/src/utils/layout.ts b/understand-anything-plugin/packages/dashboard/src/utils/layout.ts index 4d2c89e8..5ba347a9 100644 --- a/understand-anything-plugin/packages/dashboard/src/utils/layout.ts +++ b/understand-anything-plugin/packages/dashboard/src/utils/layout.ts @@ -13,6 +13,9 @@ import type { Node, Edge } from "@xyflow/react"; import type { ElkInput } from "./elk-layout"; export const NODE_WIDTH = 330; +export const DENSE_THRESHOLD = 12; +export const DENSE_NODE_WIDTH = 250; +export const DENSE_NODE_HEIGHT = 44; export const NODE_HEIGHT = 150; export const LAYER_CLUSTER_WIDTH = 320; export const LAYER_CLUSTER_HEIGHT = 180; @@ -208,16 +211,19 @@ export function nodesToElkInput( edges: Edge[], dims: Map, layoutOptionsOverride?: Record, + childOptions?: Map>, ): ElkInput { return { id: "root", layoutOptions: { ...ELK_DEFAULT_LAYOUT_OPTIONS, ...layoutOptionsOverride }, children: nodes.map((n) => { const d = dims.get(n.id); + const opts = childOptions?.get(n.id); return { id: n.id, width: d?.width ?? NODE_WIDTH, height: d?.height ?? NODE_HEIGHT, + ...(opts ? { layoutOptions: opts } : {}), }; }), edges: edges.map((e, i) => ({ diff --git a/understand-anything-plugin/pnpm-lock.yaml b/understand-anything-plugin/pnpm-lock.yaml index 16b6eed8..ea7bd259 100644 --- a/understand-anything-plugin/pnpm-lock.yaml +++ b/understand-anything-plugin/pnpm-lock.yaml @@ -11,6 +11,12 @@ importers: '@understand-anything/core': specifier: workspace:* version: link:packages/core + graphology: + specifier: ~0.26.0 + version: 0.26.0(graphology-types@0.24.8) + graphology-communities-louvain: + specifier: ^2.0.2 + version: 2.0.2(graphology-types@0.24.8) devDependencies: '@types/node': specifier: ^22.0.0 @@ -133,7 +139,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.0.0 - version: 4.2.2(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + version: 4.2.2(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) '@types/d3-force': specifier: ^3.0.10 version: 3.0.10 @@ -145,7 +151,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react': specifier: ^4.3.0 - version: 4.7.0(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) + version: 4.7.0(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) '@vitest/coverage-v8': specifier: ^3.2.4 version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)) @@ -156,8 +162,8 @@ importers: specifier: ^5.7.0 version: 5.9.3 vite: - specifier: ^6.0.0 - version: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + specifier: ^6.4.2 + version: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) vitest: specifier: ^3.1.0 version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) @@ -482,66 +488,79 @@ packages: resolution: {integrity: sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.60.0': resolution: {integrity: sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.60.0': resolution: {integrity: sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.60.0': resolution: {integrity: sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.60.0': resolution: {integrity: sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.60.0': resolution: {integrity: sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.60.0': resolution: {integrity: sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.60.0': resolution: {integrity: sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.60.0': resolution: {integrity: sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.60.0': resolution: {integrity: sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.60.0': resolution: {integrity: sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.0': resolution: {integrity: sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.60.0': resolution: {integrity: sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.60.0': resolution: {integrity: sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==} @@ -611,24 +630,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.2': resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} @@ -1084,6 +1107,11 @@ packages: peerDependencies: graphology-types: '>=0.24.0' + graphology@0.26.0: + resolution: {integrity: sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==} + peerDependencies: + graphology-types: '>=0.24.0' + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1207,24 +1235,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1759,6 +1791,46 @@ packages: yaml: optional: true + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitest@3.2.4: resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -2231,12 +2303,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - '@tailwindcss/vite@4.2.2(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': + '@tailwindcss/vite@4.2.2(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': dependencies: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 tailwindcss: 4.2.2 - vite: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + vite: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) '@types/babel__core@7.20.5': dependencies: @@ -2333,7 +2405,7 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react@4.7.0(vite@6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -2341,7 +2413,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.27 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + vite: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -2692,6 +2764,11 @@ snapshots: graphology-types: 0.24.8 obliterator: 2.0.5 + graphology@0.26.0(graphology-types@0.24.8): + dependencies: + events: 3.3.0 + graphology-types: 0.24.8 + has-flag@4.0.0: {} hast-util-to-jsx-runtime@2.3.6: @@ -3444,7 +3521,7 @@ snapshots: debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.1(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + vite: 6.4.3(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -3465,7 +3542,7 @@ snapshots: debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) + vite: 6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -3510,6 +3587,36 @@ snapshots: lightningcss: 1.32.0 yaml: 2.8.3 + vite@6.4.3(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.15 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + yaml: 2.8.3 + + vite@6.4.3(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.5.0 + fsevents: 2.3.3 + jiti: 2.6.1 + lightningcss: 1.32.0 + yaml: 2.8.3 + vitest@3.2.4(@types/debug@4.1.13)(@types/node@22.19.15)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3): dependencies: '@types/chai': 5.2.3 From 583542fc463a7a1bbc1b3d507257d62f76a630f0 Mon Sep 17 00:00:00 2001 From: Maksim Beliakov Date: Sun, 7 Jun 2026 22:01:22 +0000 Subject: [PATCH 6/8] fix(dashboard): post-compaction silently violates ELK partitions elk.layered.compaction.postCompaction.strategy=LEFT moves nodes across layer boundaries after layering, so a partition-2 layer (Copilot) ended up rendered in the top row next to partition-0. Disable post-compaction whenever partitioning is active. Reproduced with elkjs in isolation: minimal options place the node correctly; adding the compaction option breaks it. --- .../packages/dashboard/src/components/GraphView.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx index cd28737c..dc1b1ecc 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx @@ -334,7 +334,13 @@ function useOverviewGraph() { let childOptions: Map> | undefined; if (tiers.size > 0) { const maxTier = Math.max(...Array.from(tiers.values())); - layoutOverride = { "elk.partitioning.activate": "true" }; + layoutOverride = { + "elk.partitioning.activate": "true", + // Post-compaction moves nodes across layer boundaries and silently + // violates partitions (a tier-2 node ends up in the top row) — + // disable it whenever partitioning is active. + "elk.layered.compaction.postCompaction.strategy": "NONE", + }; childOptions = new Map( baseNodes.map((n) => [ n.id, From f6224482ee9695c56a63407ae4dbd789b996b107 Mon Sep 17 00:00:00 2001 From: Maksim Beliakov Date: Sun, 7 Jun 2026 22:05:12 +0000 Subject: [PATCH 7/8] fix(dashboard): flip against-tier overview edges for clean routing With pinned tiers, an aggregated edge whose net direction points from a lower tier to a higher one (e.g. services -> copilot) left the lower card's bottom handle, looped around the canvas and entered the upper card's top handle. The overview draws no arrowheads, so flip such edges visually: exit the upper card's bottom, enter the lower card's top. --- .../dashboard/src/components/GraphView.tsx | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx index dc1b1ecc..02b92ac7 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx @@ -289,17 +289,35 @@ function useOverviewGraph() { // Aggregate edges between layers const aggregated = aggregateLayerEdges(graph); - const flowEdges: Edge[] = aggregated.map((agg, i) => ({ - id: `le-${i}`, - source: agg.sourceLayerId, - target: agg.targetLayerId, - label: `${agg.count}`, + // With pinned tiers, edges flowing AGAINST the vertical order (lower + // tier → higher tier) would leave the lower card's bottom handle, loop + // around and enter the upper card's top handle. The overview shows no + // arrowheads, so flip such edges visually: exit the upper card's + // bottom, enter the lower card's top. + const tierOf = new Map(); + for (const layer of graph.layers) { + if (typeof layer.tier === "number") tierOf.set(layer.id, layer.tier); + } + const flowEdges: Edge[] = aggregated.map((agg, i) => { + let src = agg.sourceLayerId; + let tgt = agg.targetLayerId; + const ts = tierOf.get(src); + const tt = tierOf.get(tgt); + if (ts !== undefined && tt !== undefined && ts > tt) { + [src, tgt] = [tgt, src]; + } + return { + id: `le-${i}`, + source: src, + target: tgt, + label: `${agg.count}`, style: { stroke: "rgba(212,165,116,0.4)", strokeWidth: Math.min(1 + Math.log2(agg.count + 1), 5), }, labelStyle: { fill: "#a39787", fontSize: 11, fontWeight: 600 }, - })); + }; + }); const dims = new Map(); for (const n of clusterNodes) { From b44ce395e5fb546588cac50deb28e62f763dacd3 Mon Sep 17 00:00:00 2001 From: Maksim Beliakov Date: Sun, 7 Jun 2026 22:12:52 +0000 Subject: [PATCH 8/8] fix(dashboard): drill-in showed stale full-layer topology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit activeContainerId was read inside the layer-detail structural memo but missing from its dependency array, so clicking a feature tile switched the view to the canvas while the topology stayed the full-layer chip maze — the drilled feature's children never rendered. --- .../packages/dashboard/src/components/GraphView.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx index 02b92ac7..fcad43eb 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx @@ -745,6 +745,7 @@ function useLayerDetailTopology(): LayerDetailTopology & { graph, nodesById, activeLayerId, + activeContainerId, persona, diffMode, changedNodeIds,