@principal-ai/subsystems-react 0.42.0 → 0.43.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/stories/Subsystem/C4Graph/c4Fixture.js +1 -1
  6. package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +1 -1
  7. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts +6 -9
  8. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +1 -1
  9. package/dist/stories/Subsystem/ComponentGraph/fixtures.js +15 -50
  10. package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +1 -1
  11. package/dist/subsystem/C4Graph.d.ts +2 -3
  12. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  13. package/dist/subsystem/C4Graph.js +2 -3
  14. package/dist/subsystem/C4Graph.js.map +1 -1
  15. package/dist/subsystem/IssueList.d.ts +5 -5
  16. package/dist/subsystem/IssueList.d.ts.map +1 -1
  17. package/dist/subsystem/IssueList.js +9 -18
  18. package/dist/subsystem/IssueList.js.map +1 -1
  19. package/dist/subsystem/SubsystemComponentGraph.d.ts +16 -12
  20. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  21. package/dist/subsystem/SubsystemComponentGraph.js +99 -113
  22. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  23. package/dist/subsystem/SubsystemFileTree.d.ts +5 -5
  24. package/dist/subsystem/SubsystemFileTree.d.ts.map +1 -1
  25. package/dist/subsystem/SubsystemFileTree.js +13 -49
  26. package/dist/subsystem/SubsystemFileTree.js.map +1 -1
  27. package/dist/subsystem/model.d.ts +67 -53
  28. package/dist/subsystem/model.d.ts.map +1 -1
  29. package/dist/subsystem/model.js +305 -55
  30. package/dist/subsystem/model.js.map +1 -1
  31. package/dist/subsystem/nodes.d.ts.map +1 -1
  32. package/dist/subsystem/nodes.js +11 -4
  33. package/dist/subsystem/nodes.js.map +1 -1
  34. package/dist/subsystem/toC4.d.ts +4 -4
  35. package/dist/subsystem/toC4.d.ts.map +1 -1
  36. package/dist/subsystem/toC4.js +3 -7
  37. package/dist/subsystem/toC4.js.map +1 -1
  38. package/dist/utils/elkLayout.d.ts.map +1 -1
  39. package/dist/utils/elkLayout.js +7 -0
  40. package/dist/utils/elkLayout.js.map +1 -1
  41. package/package.json +3 -3
  42. package/src/index.ts +0 -4
  43. package/src/stories/Subsystem/AgentsPanel.stories.tsx +3 -5
  44. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +1 -4
  45. package/src/stories/Subsystem/C4Graph/c4Fixture.ts +1 -1
  46. package/src/stories/Subsystem/ComponentGraph/Appearance.stories.tsx +2 -4
  47. package/src/stories/Subsystem/ComponentGraph/Basics.stories.tsx +8 -8
  48. package/src/stories/Subsystem/ComponentGraph/Captures.stories.tsx +19 -19
  49. package/src/stories/Subsystem/ComponentGraph/Constructs.stories.tsx +0 -1
  50. package/src/stories/Subsystem/ComponentGraph/CustomEntities.stories.tsx +3 -3
  51. package/src/stories/Subsystem/ComponentGraph/DetailPanel.stories.tsx +0 -2
  52. package/src/stories/Subsystem/ComponentGraph/Diagnostics.stories.tsx +2 -6
  53. package/src/stories/Subsystem/ComponentGraph/EdgeViews.stories.tsx +21 -21
  54. package/src/stories/Subsystem/ComponentGraph/Flows.stories.tsx +0 -3
  55. package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +30 -32
  56. package/src/stories/Subsystem/ComponentGraph/GraphTitle.stories.tsx +4 -4
  57. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +1 -26
  58. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +3 -25
  59. package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +7 -8
  60. package/src/stories/Subsystem/ComponentGraph/ModulePathNesting.stories.tsx +116 -0
  61. package/src/stories/Subsystem/ComponentGraph/Modules.stories.tsx +9 -11
  62. package/src/stories/Subsystem/ComponentGraph/Packages.stories.tsx +3 -6
  63. package/src/stories/Subsystem/ComponentGraph/Processes.stories.tsx +3 -3
  64. package/src/stories/Subsystem/ComponentGraph/Proposed.stories.tsx +8 -6
  65. package/src/stories/Subsystem/ComponentGraph/ProposedWalkthroughs.stories.tsx +0 -1
  66. package/src/stories/Subsystem/ComponentGraph/Reorder.stories.tsx +0 -1
  67. package/src/stories/Subsystem/ComponentGraph/Scenarios.stories.tsx +26 -28
  68. package/src/stories/Subsystem/ComponentGraph/WalkthroughAutoplay.stories.tsx +0 -1
  69. package/src/stories/Subsystem/ComponentGraph/WorkspacePackageFrames.stories.tsx +2 -3
  70. package/src/stories/Subsystem/ComponentGraph/fixtures.ts +18 -58
  71. package/src/stories/Subsystem/EgoGraph/EgoGraph.stories.tsx +0 -2
  72. package/src/subsystem/C4Graph.tsx +2 -3
  73. package/src/subsystem/IssueList.tsx +12 -22
  74. package/src/subsystem/SubsystemComponentGraph.tsx +117 -135
  75. package/src/subsystem/SubsystemFileTree.tsx +17 -50
  76. package/src/subsystem/model.test.ts +155 -24
  77. package/src/subsystem/model.ts +352 -108
  78. package/src/subsystem/nodes.tsx +13 -3
  79. package/src/subsystem/toC4.test.ts +5 -12
  80. package/src/subsystem/toC4.ts +5 -9
  81. package/src/utils/elkLayout.ts +10 -0
@@ -39,7 +39,6 @@ import {
39
39
  deriveGraphEdges,
40
40
  isConstructsOnlyModel,
41
41
  moduleGroupNodeId,
42
- isRelationMechanism,
43
42
  isWalkthroughMechanism,
44
43
  edgeColor,
45
44
  MECHANISM_DESCRIPTIONS,
@@ -52,7 +51,6 @@ import {
52
51
  type SubsystemGraphifyRelation,
53
52
  type SubsystemNodeIssue,
54
53
  type SubsystemRegionIssue,
55
- type SubsystemRelation,
56
54
  type SubsystemWalkthrough,
57
55
  } from './model';
58
56
  import { ConstructsCatalog } from './ConstructsCatalog';
@@ -182,8 +180,6 @@ function writeViewState(
182
180
 
183
181
  export interface SubsystemComponentGraphProps {
184
182
  components: SubsystemComponent[];
185
- /** Topology relations (structural / module / type). Display edges are derived with walkthrough hops. */
186
- relations: SubsystemRelation[];
187
183
  /**
188
184
  * Ordered runtime walkthroughs — one walkthrough per flow. When present the
189
185
  * sidebar's bottom half offers a Files/Walkthroughs toggle:
@@ -198,7 +194,7 @@ export interface SubsystemComponentGraphProps {
198
194
  * graphify-native relations (raw static-graph edges: `imports`, `contains`,
199
195
  * `re_exports`, …). Display-only, drawn with the separate
200
196
  * `GRAPHIFY_RELATION_COLOR` palette so they read as derived facts, distinct
201
- * from authored subsystem mechanisms. Visible in the `relations` edge view.
197
+ * from authored subsystem mechanisms. Visible in the `graphify` edge view.
202
198
  */
203
199
  graphifyRelations?: readonly SubsystemGraphifyRelation[];
204
200
  /**
@@ -239,16 +235,22 @@ export interface SubsystemComponentGraphProps {
239
235
  */
240
236
  showSingletonFrames?: boolean;
241
237
  /**
242
- * Which edge vocabulary the canvas draws. The relation and walkthrough
243
- * vocabularies are disjoint, so a graph carrying both shows one or the
244
- * other — never both. Edges outside the view are hidden (labels go too).
245
- * - `relations`: topology relation edges (`extends`, `implements`, …)
238
+ * How module frames group.
239
+ * - `exact` (default): one frame per distinct `module` string.
240
+ * - `path`: derive directory frames from `module` path segments and nest
241
+ * module frames inside them (`src` → `src/session` → module).
242
+ */
243
+ moduleNesting?: 'exact' | 'path';
244
+ /**
245
+ * Which edge source the canvas draws. The graphify and walkthrough sources
246
+ * are disjoint, so a graph carrying both shows one or the other — never
247
+ * both. Edges outside the view are hidden (labels go too).
248
+ * - `graphify`: graphify-native static edges (`imports`, `contains`, …)
246
249
  * - `walkthroughs`: walkthrough hop edges (`calls`, `feeds`, …), including
247
250
  * step numbers when a flow is focused/hovered
248
251
  * Leave unset to let the sidebar's Files / Walkthroughs tab drive it: Files
249
- * draws topology relations, Walkthroughs draws runtime hops. Without visible
250
- * tabs, defaults to `walkthroughs` for a flow-only model (walkthroughs but
251
- * no relations), otherwise `relations`.
252
+ * draws graphify edges, Walkthroughs draws runtime hops. Without visible
253
+ * tabs, defaults to `walkthroughs`.
252
254
  */
253
255
  edgeView?: SubsystemEdgeView;
254
256
  /** Subsystem title displayed in the sidebar. */
@@ -348,7 +350,7 @@ export interface SubsystemComponentGraphProps {
348
350
  /**
349
351
  * Click an issue. The graph first focuses the target on the canvas itself —
350
352
  * a component target is selected and framed (any edge / walkthrough focus
351
- * that would hide it is cleared), a relation target frames its edge, and a
353
+ * that would hide it is cleared), and a
352
354
  * module target frames that boundary frame — then this fires so the host can
353
355
  * open its own detail. Collapsing the card again reverses that (deselect +
354
356
  * zoom out). Other target kinds (flow / repo) have no node to frame and just
@@ -522,16 +524,16 @@ interface InnerProps extends SubsystemComponentGraphProps {
522
524
  measured: { w: number; h: number } | null;
523
525
  }
524
526
 
525
- function Inner({ components, relations, walkthroughs, graphifyRelations, orderByLine, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, walkthroughFocusDurationMs = 300, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, hideDrawer = false, persistKey, liveEvents, agentsPanel }: InnerProps) {
527
+ function Inner({ components, walkthroughs, graphifyRelations, orderByLine, initialWalkthroughId, onReorderWalkthroughs, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, moduleNesting, edgeView, title, hideSidebar, walkthroughStepMode = 'focus', autoPlayWalkthroughs = false, walkthroughAutoPlayIntervalMs = WALKTHROUGH_PLAY_PAUSE_MS, zoomOnWalkthroughFocus = true, walkthroughFocusDurationMs = 300, graphTitle, showWalkthroughTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileView, renderFileViewer, renderWalkthroughViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, hideDrawer = false, persistKey, liveEvents, agentsPanel }: InnerProps) {
526
528
  const { theme } = useTheme();
527
- const { fitView, fitBounds } = useReactFlow();
529
+ const { fitView, fitBounds, screenToFlowPosition } = useReactFlow();
528
530
  const viewport = useViewport();
529
531
  // Restored once per mount from `localStorage` (see `readViewState`). Each
530
532
  // graph is its own tab/mount, so `persistKey` is stable for a mount.
531
533
  const persisted = useMemo(() => readViewState(persistKey), [persistKey]);
532
534
  const graphEdges = useMemo(
533
- () => deriveGraphEdges({ relations, walkthroughs }),
534
- [relations, walkthroughs],
535
+ () => deriveGraphEdges({ walkthroughs }),
536
+ [walkthroughs],
535
537
  );
536
538
  const [built, setBuilt] = useState<{
537
539
  nodes: Node[];
@@ -559,6 +561,73 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
559
561
  const pendingFocusFitRef = useRef<number | null>(null);
560
562
  // Component the pointer is over (null on leave) → transient tree highlight.
561
563
  const [hoveredComponentAlias, setHoveredComponentId] = useState<string | null>(null);
564
+ // Boundary frame hovered on the canvas → highlight the matching folder row in
565
+ // the sidebar file tree, so a frame's place in the repo tree is visible.
566
+ const [hoveredRegion, setHoveredRegion] = useState<{
567
+ kind: string;
568
+ key: string;
569
+ } | null>(null);
570
+ const hoveredBoundaryPath =
571
+ hoveredRegion && (hoveredRegion.kind === 'module' || hoveredRegion.kind === 'directory')
572
+ ? hoveredRegion.key
573
+ : null;
574
+ // Path frames (module / directory) as absolute flow-coord rects, hit-tested
575
+ // on pointer move. A pointer hit-test keeps the frames' interior hoverable
576
+ // WITHOUT an overlay that would block edges / child nodes.
577
+ const pathFrameRects = useMemo(() => {
578
+ const out: Array<{ kind: string; key: string; x: number; y: number; w: number; h: number }> = [];
579
+ for (const n of built.nodes) {
580
+ if (n.type !== 'subsystem-group') continue;
581
+ const region = (n.data as { region?: { kind?: string; key?: string } } | undefined)?.region;
582
+ if (!region?.key || (region.kind !== 'module' && region.kind !== 'directory')) continue;
583
+ const r = built.absoluteRects.get(n.id);
584
+ if (!r) continue;
585
+ out.push({ kind: region.kind, key: region.key, x: r.x, y: r.y, w: r.width, h: r.height });
586
+ }
587
+ return out;
588
+ }, [built.nodes, built.absoluteRects]);
589
+ useEffect(() => {
590
+ if (pathFrameRects.length === 0) return;
591
+ let raf = 0;
592
+ let last: { x: number; y: number } | null = null;
593
+ const overCanvas = (e: MouseEvent): boolean =>
594
+ e.composedPath().some(
595
+ (el) => el instanceof HTMLElement && el.classList.contains(GRAPH_CANVAS_CLASS),
596
+ );
597
+ const onMove = (e: MouseEvent) => {
598
+ if (!overCanvas(e)) {
599
+ last = null;
600
+ setHoveredRegion((prev) => (prev == null ? prev : null));
601
+ return;
602
+ }
603
+ last = { x: e.clientX, y: e.clientY };
604
+ if (raf) return;
605
+ raf = window.requestAnimationFrame(() => {
606
+ raf = 0;
607
+ if (!last) return;
608
+ const p = screenToFlowPosition(last);
609
+ let best: { kind: string; key: string } | null = null;
610
+ let bestArea = Number.POSITIVE_INFINITY;
611
+ for (const f of pathFrameRects) {
612
+ if (p.x >= f.x && p.x <= f.x + f.w && p.y >= f.y && p.y <= f.y + f.h) {
613
+ const area = f.w * f.h;
614
+ if (area < bestArea) {
615
+ bestArea = area;
616
+ best = { kind: f.kind, key: f.key };
617
+ }
618
+ }
619
+ }
620
+ setHoveredRegion((prev) =>
621
+ prev?.kind === best?.kind && prev?.key === best?.key ? prev : best,
622
+ );
623
+ });
624
+ };
625
+ window.addEventListener('mousemove', onMove);
626
+ return () => {
627
+ window.removeEventListener('mousemove', onMove);
628
+ if (raf) cancelAnimationFrame(raf);
629
+ };
630
+ }, [pathFrameRects, screenToFlowPosition]);
562
631
  // Walkthrough focus — selected flow (or step) is full strength; other
563
632
  // opened-flow members stay visible but dimmed; everything else is hidden.
564
633
  // Restored from the persisted view state when a `persistKey` is set.
@@ -586,27 +655,20 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
586
655
  const [diagnosticsTab, setDiagnosticsTab] = useState<'issues' | 'agents'>(
587
656
  () => persisted.diagnosticsTab ?? 'issues',
588
657
  );
589
- // Temporary edge-vocabulary override. Focusing a relation finding whose edge
590
- // belongs to the *other* vocabulary flips the canvas so the edge is actually
591
- // visible; it reverts when the focus is undone (see `unfocusIssueTarget`) or
592
- // when the diagnostics list closes. Null = use the host-pinned / tab view.
593
- const [edgeViewOverride, setEdgeViewOverride] = useState<SubsystemEdgeView | null>(
594
- null,
595
- );
596
- // One edge vocabulary at a time. When the caller doesn't pick, the sidebar's
597
- // Files / Walkthroughs tab picks: Files draws topology relation edges,
658
+ // One edge source at a time. When the caller doesn't pick, the sidebar's
659
+ // Files / Walkthroughs tab picks: Files draws graphify static edges,
598
660
  // Walkthroughs draws runtime hop edges. Without visible tabs (no walkthroughs,
599
- // or a sidebar-less embed) fall back to the model: a flow-only graph defaults
600
- // to walkthrough edges rather than empty, everything else to relations.
661
+ // or a sidebar-less embed) fall back to walkthrough edges.
601
662
  const sidebarTabsVisible = !hideSidebar && (walkthroughs?.length ?? 0) > 0;
602
663
  const resolvedEdgeView: SubsystemEdgeView =
603
- edgeViewOverride ??
604
664
  edgeView ??
605
665
  (sidebarTabsVisible
606
- ? (sidebarView === 'walkthroughs' ? 'walkthroughs' : 'relations')
607
- : relations.length === 0 && (walkthroughs?.length ?? 0) > 0
666
+ ? (sidebarView === 'walkthroughs' ? 'walkthroughs' : 'graphify')
667
+ : (walkthroughs?.length ?? 0) > 0
608
668
  ? 'walkthroughs'
609
- : 'relations');
669
+ : (graphifyRelations?.length ?? 0) > 0
670
+ ? 'graphify'
671
+ : 'walkthroughs');
610
672
  // Walkthrough flows the user has expanded (via the title row). Closed by
611
673
  // default so a graph with several flows doesn't dump every step list at once.
612
674
  // Restored from the persisted view state when a `persistKey` is set.
@@ -807,15 +869,13 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
807
869
  // The pane stays hidden until Pass 2 completes. Key off layout-affecting
808
870
  // fields only — declarationRef updates after verify must not re-run ELK.
809
871
  const layoutKey = useMemo(
810
- () => subsystemGraphLayoutKey({ components, relations, walkthroughs, graphifyRelations }),
811
- [components, relations, walkthroughs, graphifyRelations],
872
+ () => subsystemGraphLayoutKey({ components, walkthroughs, graphifyRelations }),
873
+ [components, walkthroughs, graphifyRelations],
812
874
  );
813
875
  const componentsRef = useRef(components);
814
- const relationsRef = useRef(relations);
815
876
  const walkthroughsRef = useRef(walkthroughs);
816
877
  const graphifyRelationsRef = useRef(graphifyRelations);
817
878
  componentsRef.current = components;
818
- relationsRef.current = relations;
819
879
  walkthroughsRef.current = walkthroughs;
820
880
  graphifyRelationsRef.current = graphifyRelations;
821
881
 
@@ -836,13 +896,13 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
836
896
  pass2GenRef.current += 1;
837
897
  const doc = {
838
898
  components: componentsRef.current,
839
- relations: relationsRef.current,
840
899
  walkthroughs: walkthroughsRef.current,
841
900
  };
842
901
  void buildSubsystemGraph(doc, {
843
902
  maxNodeWidth,
844
903
  showEdgeLabels,
845
904
  showSingletonFrames,
905
+ moduleNesting,
846
906
  graphifyRelations: graphifyRelationsRef.current,
847
907
  orderByLine,
848
908
  })
@@ -867,7 +927,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
867
927
  setLayoutReady(true);
868
928
  });
869
929
  return () => { alive = false; };
870
- }, [layoutKey, maxNodeWidth, showEdgeLabels]);
930
+ }, [layoutKey, maxNodeWidth, showEdgeLabels, moduleNesting]);
871
931
 
872
932
  // Pass 2: once every leaf node has a measured dimension, re-run ELK.
873
933
  // Group parents are sized by ELK, not measured — exclude them or pass 2
@@ -893,8 +953,8 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
893
953
  const measuredHeights = new Map(leafNodes.map((n) => [n.id, dims.get(n.id)!.height]));
894
954
  const gen = ++pass2GenRef.current;
895
955
  void buildSubsystemGraph(
896
- { components, relations, walkthroughs },
897
- { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights, showSingletonFrames, graphifyRelations, orderByLine },
956
+ { components, walkthroughs },
957
+ { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights, showSingletonFrames, moduleNesting, graphifyRelations, orderByLine },
898
958
  )
899
959
  .then(({ nodes, edges: e, absoluteRects }) => {
900
960
  if (gen !== pass2GenRef.current) return;
@@ -907,7 +967,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
907
967
  // Reveal Pass 1 layout rather than leaving the cover up forever.
908
968
  setLayoutReady(true);
909
969
  });
910
- }, [built.nodes, components, relations, walkthroughs, graphifyRelations, orderByLine, maxNodeWidth, showEdgeLabels]);
970
+ }, [built.nodes, components, walkthroughs, graphifyRelations, orderByLine, maxNodeWidth, showEdgeLabels, moduleNesting]);
911
971
 
912
972
  // After Pass 1 commits, try Pass 2 immediately with retained measurements.
913
973
  // Same-id live updates often get no new React Flow `dimensions` events, so
@@ -991,7 +1051,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
991
1051
 
992
1052
  // Resolve an issue's component target to the matching node. `id` is the
993
1053
  // stable alias; `label` may be an alias, name, or symbol. Non-component
994
- // targets (relation / module / flow / repo) have no single node → null.
1054
+ // targets (module / flow / repo) have no single node → null.
995
1055
  const issueComponent = useCallback(
996
1056
  (issue: SubsystemIssue): SubsystemComponent | null => {
997
1057
  const target = issue.target;
@@ -1009,33 +1069,6 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1009
1069
  [components],
1010
1070
  );
1011
1071
 
1012
- // Resolve a `relation` issue target to the display edge it flags. The target
1013
- // names the two endpoints (`from → to`); match an edge joining both, in
1014
- // either direction, preferring the target's `detail` mechanism when it names
1015
- // one. Null when no display edge matches.
1016
- const issueEdge = useCallback(
1017
- (issue: SubsystemIssue): Edge | null => {
1018
- const target = issue.target;
1019
- if (target?.kind !== 'relation') return null;
1020
- const parts = `${target.id ?? ''} → ${target.label}`
1021
- .split('→')
1022
- .map((p) => p.replace(/\(\)$/, '').trim())
1023
- .filter(Boolean);
1024
- const joins = (e: Edge): boolean =>
1025
- parts.includes(e.source) && parts.includes(e.target);
1026
- const matches = baseEdges.filter(joins);
1027
- const byMechanism = target.detail
1028
- ? matches.filter(
1029
- (e) =>
1030
- (e.data as { mechanism?: string } | undefined)?.mechanism ===
1031
- target.detail,
1032
- )
1033
- : matches;
1034
- return byMechanism[0] ?? matches[0] ?? null;
1035
- },
1036
- [baseEdges],
1037
- );
1038
-
1039
1072
  // A `module` issue target names a boundary frame, so focusing the card frames
1040
1073
  // that region on the canvas. Null when the target names no frame (a singleton
1041
1074
  // dropped from the layout, or a region key nothing declares).
@@ -1160,7 +1193,6 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1160
1193
  // the canvas keeps while everything else dims. Derived from each finding's
1161
1194
  // target:
1162
1195
  // component → that node
1163
- // relation → the edge it flags (+ its two endpoint nodes)
1164
1196
  // module → every component in that module
1165
1197
  // walkthrough → the flow's nodes + its hop edges
1166
1198
  // repo → every component of that repo
@@ -1180,20 +1212,6 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1180
1212
  if (t.kind === 'component') {
1181
1213
  const a = resolveAlias(t.id ?? t.label);
1182
1214
  if (a) nodeIds.add(a);
1183
- } else if (t.kind === 'relation') {
1184
- // The target names the two endpoints (`from → to`); light the edge and
1185
- // its endpoints. Falls back to endpoints alone if no edge matches.
1186
- const edge = issueEdge(issue);
1187
- if (edge) {
1188
- edgeIds.add(edge.id);
1189
- nodeIds.add(edge.source);
1190
- nodeIds.add(edge.target);
1191
- } else {
1192
- for (const part of `${t.id ?? ''} → ${t.label}`.split('→')) {
1193
- const a = resolveAlias(part);
1194
- if (a) nodeIds.add(a);
1195
- }
1196
- }
1197
1215
  } else if (t.kind === 'module') {
1198
1216
  const key = t.id ?? t.label;
1199
1217
  const prefix = `${key.replace(/\/$/, '')}/`;
@@ -1232,7 +1250,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1232
1250
  if (nodeIds.has(e.source) && nodeIds.has(e.target)) edgeIds.add(e.id);
1233
1251
  }
1234
1252
  return { nodeIds, edgeIds };
1235
- }, [issues, expandedIssueCategories, components, walkthroughs, baseEdges, resolveAlias, issueEdge]);
1253
+ }, [issues, expandedIssueCategories, components, walkthroughs, resolveAlias]);
1236
1254
  const issueFocusNodeIds = issueFocus?.nodeIds ?? null;
1237
1255
  const issueFocusEdgeIds = issueFocus?.edgeIds ?? null;
1238
1256
 
@@ -1479,15 +1497,15 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1479
1497
  };
1480
1498
  };
1481
1499
  // Edges outside the selected view are hidden entirely (labels included).
1482
- // graphify-native edges belong to the relations view (they are static
1500
+ // graphify-native edges belong to the graphify view (they are static
1483
1501
  // topology, not runtime hops) regardless of their verb.
1484
1502
  const edgeInView = (e: Edge): boolean => {
1485
1503
  const d = e.data as
1486
1504
  | { mechanism?: string; provenance?: SubsystemEdgeProvenance }
1487
1505
  | undefined;
1488
1506
  const mechanism = d?.mechanism ?? 'uses';
1489
- if (resolvedEdgeView === 'relations') {
1490
- return d?.provenance === 'graphify' || isRelationMechanism(mechanism);
1507
+ if (resolvedEdgeView === 'graphify') {
1508
+ return d?.provenance === 'graphify';
1491
1509
  }
1492
1510
  return isWalkthroughMechanism(mechanism);
1493
1511
  };
@@ -1586,7 +1604,6 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1586
1604
  setSelectedEdgeId(null);
1587
1605
  setFocusedWalkthroughId(null);
1588
1606
  setFocusedStepIndex(null);
1589
- setEdgeViewOverride(null);
1590
1607
  issueModuleFocusRef.current = null;
1591
1608
  }, []);
1592
1609
 
@@ -1612,7 +1629,6 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1612
1629
  useEffect(() => {
1613
1630
  if (!issuesActive) {
1614
1631
  setExpandedIssueCategories([]);
1615
- setEdgeViewOverride(null);
1616
1632
  }
1617
1633
  }, [issuesActive]);
1618
1634
  const toggleIssues = () => {
@@ -2081,11 +2097,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
2081
2097
  if (playable.length === 0) return;
2082
2098
  // Only run the cycles that can actually be shown in the current edge view.
2083
2099
  const inView = playable.filter((tl) =>
2084
- tl.steps.some((s) =>
2085
- resolvedEdgeView === 'relations'
2086
- ? isRelationMechanism(s.mechanism)
2087
- : isWalkthroughMechanism(s.mechanism),
2088
- ),
2100
+ tl.steps.some((s) => isWalkthroughMechanism(s.mechanism)),
2089
2101
  );
2090
2102
  if (inView.length === 0) return;
2091
2103
  const cyc = inView;
@@ -2254,16 +2266,14 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
2254
2266
  );
2255
2267
 
2256
2268
  // Issue click → select + frame the target on the canvas. Component targets
2257
- // frame their node; relation targets frame their edge (endpoints + the routed
2258
- // line); other kinds (module / flow / repo) have no single element and just
2259
- // forward. Selections / focus that would hide the target are cleared first,
2260
- // then the camera flies to it on the next frame. The host's `onSelectIssue`
2261
- // still fires afterwards for its own detail.
2269
+ // frame their node; other kinds (module / flow / repo) have no single element
2270
+ // and just forward. Selections / focus that would hide the target are cleared
2271
+ // first, then the camera flies to it on the next frame. The host's
2272
+ // `onSelectIssue` still fires afterwards for its own detail.
2262
2273
  const focusIssueTarget = useCallback(
2263
2274
  (issue: SubsystemIssue) => {
2264
2275
  const comp = issueComponent(issue);
2265
- const edge = comp ? null : issueEdge(issue);
2266
- const step = comp || edge ? null : issueStep(issue);
2276
+ const step = comp ? null : issueStep(issue);
2267
2277
  if (comp) {
2268
2278
  setSelected(comp);
2269
2279
  setSelectedEdgeId(null);
@@ -2281,27 +2291,6 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
2281
2291
  maxZoom: 1.5,
2282
2292
  });
2283
2293
  });
2284
- } else if (edge) {
2285
- setSelected(null);
2286
- setFocusedWalkthroughId(null);
2287
- setFocusedStepIndex(null);
2288
- setHoveredWalkthroughStep(null);
2289
- setSelectedEdgeId(edge.id);
2290
- issueModuleFocusRef.current = null;
2291
- // Flip to the vocabulary this edge belongs to, so a hop flagged in the
2292
- // relations view (or vice versa) is actually drawn while focused.
2293
- const d = edge.data as
2294
- | { mechanism?: string; provenance?: SubsystemEdgeProvenance }
2295
- | undefined;
2296
- const mechanism = d?.mechanism ?? 'uses';
2297
- setEdgeViewOverride(
2298
- d?.provenance === 'graphify' || isRelationMechanism(mechanism)
2299
- ? 'relations'
2300
- : 'walkthroughs',
2301
- );
2302
- requestAnimationFrame(() => {
2303
- fitFocusBounds(new Set([edge.id]));
2304
- });
2305
2294
  } else if (step) {
2306
2295
  // A step finding focuses the step itself, through the same entry point
2307
2296
  // the walkthrough panel uses — so the drawer, the step numbering, the
@@ -2329,14 +2318,12 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
2329
2318
  },
2330
2319
  [
2331
2320
  issueComponent,
2332
- issueEdge,
2333
2321
  issueStep,
2334
2322
  issueModuleNodeId,
2335
2323
  focusWalkthroughStep,
2336
2324
  onSelect,
2337
2325
  onSelectIssue,
2338
2326
  fitView,
2339
- fitFocusBounds,
2340
2327
  fitNodeRects,
2341
2328
  ],
2342
2329
  );
@@ -2355,14 +2342,9 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
2355
2342
  setSelected(null);
2356
2343
  setSelectedEdgeId(null);
2357
2344
  } else {
2358
- const edge = issueEdge(issue);
2359
- const step = edge ? null : issueStep(issue);
2360
- const moduleNodeId = edge || step ? null : issueModuleNodeId(issue);
2361
- if (edge) {
2362
- if (selectedEdgeIdRef.current !== edge.id) return;
2363
- setSelectedEdgeId(null);
2364
- setEdgeViewOverride(null);
2365
- } else if (step) {
2345
+ const step = issueStep(issue);
2346
+ const moduleNodeId = step ? null : issueModuleNodeId(issue);
2347
+ if (step) {
2366
2348
  // Only unwind a step focus this card still owns. A dim-mode graph
2367
2349
  // keeps its step focus in hover state instead (owned by the pointer,
2368
2350
  // not the card), and correctly leaves that alone here.
@@ -2388,7 +2370,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
2388
2370
  fitView({ padding: 0.1, duration: 300, minZoom: 0.05, maxZoom: 2 });
2389
2371
  });
2390
2372
  },
2391
- [issueComponent, issueEdge, issueStep, issueModuleNodeId, fitView],
2373
+ [issueComponent, issueStep, issueModuleNodeId, fitView],
2392
2374
  );
2393
2375
 
2394
2376
  // Construct tokens inside a walkthrough snippet. A step's line is an edge
@@ -2800,6 +2782,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
2800
2782
  selectedFile={selected?.file ?? openFile}
2801
2783
  hoveredFile={hoveredFile}
2802
2784
  onSelectFile={onTreeSelectFile}
2785
+ hoveredFolder={hoveredBoundaryPath}
2803
2786
  headerless={repoGroups.multiRepo}
2804
2787
  />
2805
2788
  )}
@@ -3362,7 +3345,6 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
3362
3345
 
3363
3346
  const constructsOnly = isConstructsOnlyModel({
3364
3347
  components: props.components,
3365
- relations: props.relations,
3366
3348
  walkthroughs: props.walkthroughs,
3367
3349
  graphifyRelations: props.graphifyRelations,
3368
3350
  });
@@ -18,19 +18,19 @@ export interface SubsystemFileTreeProps {
18
18
  /** File to transiently highlight while its node is hovered on the graph;
19
19
  * falls back to `selectedFile` when null. */
20
20
  hoveredFile?: string | null;
21
+ /** Boundary path (folder or file) to transiently highlight — e.g. hovering a
22
+ * module/directory frame on the canvas. Matched with or without a trailing
23
+ * slash. Takes a back seat to `hoveredFile`. */
24
+ hoveredFolder?: string | null;
21
25
  /** Called when a file row is clicked; upstream toggles the drawer. */
22
26
  onSelectFile?: (file: string) => void;
23
- /** Called on hover in/out of a file row — directories and non-file rows
24
- * emit null. Pierre exposes no hover API, so this rides a shadow-DOM
25
- * `mouseover` listener (see below). */
26
- onHoverFile?: (file: string | null) => void;
27
27
  /** Hide the built-in "Files N" header row — used when an upstream repo
28
28
  * header already labels the tree. */
29
29
  headerless?: boolean;
30
30
  }
31
31
 
32
32
  /** Fills its parent height by design — pin it with a sized flex container. */
33
- export function SubsystemFileTree({ files, selectedFile, hoveredFile, onSelectFile, onHoverFile, headerless }: SubsystemFileTreeProps) {
33
+ export function SubsystemFileTree({ files, selectedFile, hoveredFile, hoveredFolder, onSelectFile, headerless }: SubsystemFileTreeProps) {
34
34
  const { theme } = useTheme();
35
35
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
36
36
  const paths = useMemo(() => Array.from(new Set(files)).sort(), [files]);
@@ -42,8 +42,6 @@ export function SubsystemFileTree({ files, selectedFile, hoveredFile, onSelectFi
42
42
  pathSetRef.current = pathSet;
43
43
  const onSelectFileRef = useRef(onSelectFile);
44
44
  onSelectFileRef.current = onSelectFile;
45
- const onHoverFileRef = useRef(onHoverFile);
46
- onHoverFileRef.current = onHoverFile;
47
45
  const selectedFileRef = useRef<string | null>(selectedFile ?? null);
48
46
  selectedFileRef.current = selectedFile ?? null;
49
47
  // Suppresses onSelectFile while selection is driven programmatically from
@@ -96,59 +94,28 @@ export function SubsystemFileTree({ files, selectedFile, hoveredFile, onSelectFi
96
94
  useEffect(() => {
97
95
  hoveredRowRef.current?.style.removeProperty('background');
98
96
  hoveredRowRef.current = null;
99
- const target = hoveredFile ?? null;
100
- if (!target || !pathSetRef.current.has(target)) return;
97
+ const target = hoveredFile ?? hoveredFolder ?? null;
98
+ if (!target) return;
99
+ // Files are keyed exact; folder rows may carry a trailing slash. Try both.
100
+ const candidates = hoveredFile ? [target] : [target, `${target}/`];
101
101
 
102
102
  // Depth-first walk across shadow roots — handles nested custom elements.
103
103
  const roots: Array<Document | ShadowRoot> = [document];
104
104
  while (roots.length > 0) {
105
105
  const root = roots.shift()!;
106
- const row = root.querySelector(`[data-item-path="${CSS.escape(target)}"]`);
107
- if (row instanceof HTMLElement) {
108
- row.style.background = 'var(--trees-theme-list-hover-bg, rgba(128, 128, 128, 0.25))';
109
- hoveredRowRef.current = row;
110
- return;
106
+ for (const candidate of candidates) {
107
+ const row = root.querySelector(`[data-item-path="${CSS.escape(candidate)}"]`);
108
+ if (row instanceof HTMLElement) {
109
+ row.style.background = 'var(--trees-theme-list-hover-bg, rgba(128, 128, 128, 0.25))';
110
+ hoveredRowRef.current = row;
111
+ return;
112
+ }
111
113
  }
112
114
  for (const el of Array.from(root.querySelectorAll('*'))) {
113
115
  if (el.shadowRoot) roots.push(el.shadowRoot);
114
116
  }
115
117
  }
116
- }, [hoveredFile]);
117
-
118
- // Hover notifications. Pierre exposes no hover callback hook, but its rows
119
- // carry `data-item-path` / `data-item-type` inside an OPEN shadow root and
120
- // it binds no `mouseover`/`mouseleave` handlers, so native mouse events
121
- // bubble out to this wrapper. Resolve the row via `composedPath()` (which
122
- // crosses the shadow boundary even though `event.target` is retargeted to
123
- // the host), then emit the repo-root-relative path. Directories emit null.
124
- const lastHoveredRef = useRef<string | null>(null);
125
- useEffect(() => {
126
- const host = treeRef.current;
127
- if (!host) return;
128
- const resolveFile = (e: MouseEvent): string | null => {
129
- for (const el of e.composedPath()) {
130
- if (!(el instanceof HTMLElement) || el.dataset.type !== 'item') continue;
131
- if (el.dataset.itemType === 'folder') return null;
132
- const raw = el.dataset.itemPath ?? '';
133
- const path = raw.endsWith('/') ? raw.slice(0, -1) : raw;
134
- return pathSetRef.current.has(path) ? path : null;
135
- }
136
- return null;
137
- };
138
- const emit = (file: string | null) => {
139
- if (lastHoveredRef.current === file) return;
140
- lastHoveredRef.current = file;
141
- onHoverFileRef.current?.(file);
142
- };
143
- const handleOver = (e: MouseEvent) => emit(resolveFile(e));
144
- const handleLeave = () => emit(null);
145
- host.addEventListener('mouseover', handleOver);
146
- host.addEventListener('mouseleave', handleLeave);
147
- return () => {
148
- host.removeEventListener('mouseover', handleOver);
149
- host.removeEventListener('mouseleave', handleLeave);
150
- };
151
- }, []);
118
+ }, [hoveredFile, hoveredFolder]);
152
119
 
153
120
  // Re-scope the tree in place when the subsystem's file set changes.
154
121
  useEffect(() => {