@principal-ai/subsystems-react 0.38.0 → 0.40.0

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 (72) hide show
  1. package/dist/components/maintain-events/MaintainEventLog.d.ts +35 -0
  2. package/dist/components/maintain-events/MaintainEventLog.d.ts.map +1 -0
  3. package/dist/components/maintain-events/MaintainEventLog.js +66 -0
  4. package/dist/components/maintain-events/MaintainEventLog.js.map +1 -0
  5. package/dist/components/maintain-events/MaintainLivePanel.d.ts +15 -0
  6. package/dist/components/maintain-events/MaintainLivePanel.d.ts.map +1 -0
  7. package/dist/components/maintain-events/MaintainLivePanel.js +49 -0
  8. package/dist/components/maintain-events/MaintainLivePanel.js.map +1 -0
  9. package/dist/components/maintain-events/index.d.ts +8 -0
  10. package/dist/components/maintain-events/index.d.ts.map +1 -0
  11. package/dist/components/maintain-events/index.js +6 -0
  12. package/dist/components/maintain-events/index.js.map +1 -0
  13. package/dist/graphify/consolidated.d.ts +8 -0
  14. package/dist/graphify/consolidated.d.ts.map +1 -1
  15. package/dist/graphify/construct.d.ts +18 -0
  16. package/dist/graphify/construct.d.ts.map +1 -1
  17. package/dist/graphify/construct.js +23 -0
  18. package/dist/graphify/construct.js.map +1 -1
  19. package/dist/graphify/index.d.ts +2 -2
  20. package/dist/graphify/index.d.ts.map +1 -1
  21. package/dist/graphify/index.js +1 -1
  22. package/dist/graphify/index.js.map +1 -1
  23. package/dist/index.d.ts +3 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +3 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/subsystem/IssueList.d.ts +49 -3
  28. package/dist/subsystem/IssueList.d.ts.map +1 -1
  29. package/dist/subsystem/IssueList.js +180 -79
  30. package/dist/subsystem/IssueList.js.map +1 -1
  31. package/dist/subsystem/SubsystemComponentGraph.d.ts +15 -1
  32. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  33. package/dist/subsystem/SubsystemComponentGraph.js +464 -14
  34. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  35. package/dist/subsystem/formatDeclaration.js +19 -5
  36. package/dist/subsystem/formatDeclaration.js.map +1 -1
  37. package/dist/subsystem/model.d.ts +38 -0
  38. package/dist/subsystem/model.d.ts.map +1 -1
  39. package/dist/subsystem/model.js.map +1 -1
  40. package/dist/subsystem/nodes.d.ts.map +1 -1
  41. package/dist/subsystem/nodes.js +124 -67
  42. package/dist/subsystem/nodes.js.map +1 -1
  43. package/dist/subsystem/symbolRefs.d.ts.map +1 -1
  44. package/dist/subsystem/symbolRefs.js +2 -0
  45. package/dist/subsystem/symbolRefs.js.map +1 -1
  46. package/package.json +3 -3
  47. package/src/components/maintain-events/MaintainEventLog.tsx +152 -0
  48. package/src/components/maintain-events/MaintainLivePanel.tsx +99 -0
  49. package/src/components/maintain-events/index.ts +10 -0
  50. package/src/graphify/consolidated.ts +8 -0
  51. package/src/graphify/construct.test.ts +61 -1
  52. package/src/graphify/construct.ts +33 -0
  53. package/src/graphify/index.ts +6 -1
  54. package/src/index.ts +9 -0
  55. package/src/stories/MaintainEvents/MaintainEventLog.stories.tsx +66 -0
  56. package/src/stories/MaintainEvents/MaintainLivePanel.stories.tsx +77 -0
  57. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +625 -0
  58. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +7 -1
  59. package/src/stories/Subsystem/ComponentGraph/NodeAnatomy.stories.tsx +40 -73
  60. package/src/stories/Subsystem/ComponentGraph/StoreValueType.stories.tsx +261 -0
  61. package/src/stories/Subsystem/StoreValueTypeDeclaration.stories.tsx +182 -0
  62. package/src/stories/data/maintain-live-events.json +554 -0
  63. package/src/subsystem/IssueList.focus.test.tsx +113 -0
  64. package/src/subsystem/IssueList.icon.test.tsx +95 -0
  65. package/src/subsystem/IssueList.tsx +267 -111
  66. package/src/subsystem/SubsystemComponentGraph.tsx +541 -13
  67. package/src/subsystem/formatDeclaration.test.ts +64 -0
  68. package/src/subsystem/formatDeclaration.ts +20 -5
  69. package/src/subsystem/model.ts +46 -0
  70. package/src/subsystem/nodes.group.test.tsx +52 -0
  71. package/src/subsystem/nodes.tsx +102 -2
  72. package/src/subsystem/symbolRefs.ts +2 -0
@@ -38,6 +38,7 @@ import {
38
38
  buildSubsystemGraph,
39
39
  deriveGraphEdges,
40
40
  isConstructsOnlyModel,
41
+ moduleGroupNodeId,
41
42
  isRelationMechanism,
42
43
  isWalkthroughMechanism,
43
44
  edgeColor,
@@ -49,16 +50,27 @@ import {
49
50
  type SubsystemEdgeProvenance,
50
51
  type SubsystemEdgeView,
51
52
  type SubsystemGraphifyRelation,
53
+ type SubsystemNodeIssue,
54
+ type SubsystemRegionIssue,
52
55
  type SubsystemRelation,
53
56
  type SubsystemWalkthrough,
54
57
  } from './model';
55
58
  import { ConstructsCatalog } from './ConstructsCatalog';
56
59
  import type { SubsystemOpenFileOptions } from './declarationRef';
60
+ import {
61
+ MaintainLivePanel,
62
+ type MaintainLivePanelProps,
63
+ } from '../components/maintain-events/MaintainLivePanel';
57
64
  import type { WalkthroughSymbolQuery } from '../pierre/PierreWalkthroughCodeView';
58
65
  import { SubsystemComponentNode, SubsystemGroupNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
59
66
  import { SubsystemDiagnosticToggle, type SubsystemDiagnostic } from './DiagnosticToggle';
60
67
  import {
61
68
  SubsystemIssueList,
69
+ issueCategory,
70
+ issueKindOrder,
71
+ issueRung,
72
+ ISSUE_KIND_ICON,
73
+ ISSUE_RUNG_ORDER,
62
74
  type SubsystemIssue,
63
75
  type SubsystemIssueCategory,
64
76
  } from './IssueList';
@@ -287,6 +299,11 @@ export interface SubsystemComponentGraphProps {
287
299
  onDescriptionOpenChange?: (open: boolean) => void;
288
300
  /** Rendered over the graph canvas only (not the title/legend sidebar). */
289
301
  canvasOverlay?: ReactNode;
302
+ /**
303
+ * Live agent-run events to show in a collapsible half-height panel over the
304
+ * graph canvas. Omit (or pass null) to hide it.
305
+ */
306
+ liveEvents?: MaintainLivePanelProps | null;
290
307
  /** Extra controls at the top of the title/legend sidebar. */
291
308
  sidebarExtra?: ReactNode;
292
309
  /** Rendered in the sidebar under the description (e.g. selection inspector). */
@@ -315,7 +332,15 @@ export interface SubsystemComponentGraphProps {
315
332
  * plain issues view expands everything.
316
333
  */
317
334
  focusIssueCategory?: SubsystemIssueCategory;
318
- /** Click an issue — focus its target on the graph / open detail. */
335
+ /**
336
+ * Click an issue. The graph first focuses the target on the canvas itself —
337
+ * a component target is selected and framed (any edge / walkthrough focus
338
+ * that would hide it is cleared), a relation target frames its edge, and a
339
+ * module target frames that boundary frame — then this fires so the host can
340
+ * open its own detail. Collapsing the card again reverses that (deselect +
341
+ * zoom out). Other target kinds (flow / repo) have no node to frame and just
342
+ * forward here.
343
+ */
319
344
  onSelectIssue?: (issue: SubsystemIssue) => void;
320
345
  /** Apply an issue's deterministic fix. */
321
346
  onApplyIssueFix?: (issue: SubsystemIssue) => void;
@@ -484,7 +509,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
484
509
  measured: { w: number; h: number } | null;
485
510
  }
486
511
 
487
- 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 }: InnerProps) {
512
+ 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 }: InnerProps) {
488
513
  const { theme } = useTheme();
489
514
  const { fitView, fitBounds } = useReactFlow();
490
515
  const viewport = useViewport();
@@ -544,6 +569,13 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
544
569
  const [sidebarView, setSidebarView] = useState<'files' | 'walkthroughs'>(() =>
545
570
  walkthroughs?.length ? 'walkthroughs' : 'files',
546
571
  );
572
+ // Temporary edge-vocabulary override. Focusing a relation finding whose edge
573
+ // belongs to the *other* vocabulary flips the canvas so the edge is actually
574
+ // visible; it reverts when the focus is undone (see `unfocusIssueTarget`) or
575
+ // when the diagnostics list closes. Null = use the host-pinned / tab view.
576
+ const [edgeViewOverride, setEdgeViewOverride] = useState<SubsystemEdgeView | null>(
577
+ null,
578
+ );
547
579
  // One edge vocabulary at a time. When the caller doesn't pick, the sidebar's
548
580
  // Files / Walkthroughs tab picks: Files draws topology relation edges,
549
581
  // Walkthroughs draws runtime hop edges. Without visible tabs (no walkthroughs,
@@ -551,6 +583,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
551
583
  // to walkthrough edges rather than empty, everything else to relations.
552
584
  const sidebarTabsVisible = !hideSidebar && (walkthroughs?.length ?? 0) > 0;
553
585
  const resolvedEdgeView: SubsystemEdgeView =
586
+ edgeViewOverride ??
554
587
  edgeView ??
555
588
  (sidebarTabsVisible
556
589
  ? (sidebarView === 'walkthroughs' ? 'walkthroughs' : 'relations')
@@ -640,6 +673,11 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
640
673
  // closure over the effect deps) can toggle without a stale value.
641
674
  const selectedRef = useRef<SubsystemComponent | null>(null);
642
675
  selectedRef.current = selected;
676
+ // The module frame an issue card framed. A module target selects no node and
677
+ // no edge, so nothing else records that focus — without this the canvas has
678
+ // no way to tell a module focus it still owns from one a later click took
679
+ // over, and the camera would stay parked on the region forever.
680
+ const issueModuleFocusRef = useRef<string | null>(null);
643
681
  // Ref mirror of the open file drawer target for tree-click toggle.
644
682
  const openFileRef = useRef<{ file: string; startLine?: number } | null>(null);
645
683
  const openFile =
@@ -926,16 +964,273 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
926
964
  const xyflowNodesBase = nodes as Node[];
927
965
  const baseEdges = convertedEdges as Edge[];
928
966
 
967
+ // Resolve an issue's component target to the matching node. `id` is the
968
+ // stable alias; `label` may be an alias, name, or symbol. Non-component
969
+ // targets (relation / module / flow / repo) have no single node → null.
970
+ const issueComponent = useCallback(
971
+ (issue: SubsystemIssue): SubsystemComponent | null => {
972
+ const target = issue.target;
973
+ if (target?.kind !== 'component') return null;
974
+ return (
975
+ components.find(
976
+ (c) =>
977
+ (target.id != null && c.alias === target.id) ||
978
+ c.alias === target.label ||
979
+ c.name === target.label ||
980
+ c.symbol === target.label,
981
+ ) ?? null
982
+ );
983
+ },
984
+ [components],
985
+ );
986
+
987
+ // Resolve a `relation` issue target to the display edge it flags. The target
988
+ // names the two endpoints (`from → to`); match an edge joining both, in
989
+ // either direction, preferring the target's `detail` mechanism when it names
990
+ // one. Null when no display edge matches.
991
+ const issueEdge = useCallback(
992
+ (issue: SubsystemIssue): Edge | null => {
993
+ const target = issue.target;
994
+ if (target?.kind !== 'relation') return null;
995
+ const parts = `${target.id ?? ''} → ${target.label}`
996
+ .split('→')
997
+ .map((p) => p.replace(/\(\)$/, '').trim())
998
+ .filter(Boolean);
999
+ const joins = (e: Edge): boolean =>
1000
+ parts.includes(e.source) && parts.includes(e.target);
1001
+ const matches = baseEdges.filter(joins);
1002
+ const byMechanism = target.detail
1003
+ ? matches.filter(
1004
+ (e) =>
1005
+ (e.data as { mechanism?: string } | undefined)?.mechanism ===
1006
+ target.detail,
1007
+ )
1008
+ : matches;
1009
+ return byMechanism[0] ?? matches[0] ?? null;
1010
+ },
1011
+ [baseEdges],
1012
+ );
1013
+
1014
+ // A `module` issue target names a boundary frame, so focusing the card frames
1015
+ // that region on the canvas. Null when the target names no frame (a singleton
1016
+ // dropped from the layout, or a region key nothing declares).
1017
+ const issueModuleNodeId = useCallback(
1018
+ (issue: SubsystemIssue): string | null => {
1019
+ const target = issue.target;
1020
+ if (target?.kind !== 'module') return null;
1021
+ const key = target.id ?? target.label;
1022
+ if (!components.some((c) => c.module?.trim() === key)) return null;
1023
+ return moduleGroupNodeId(key);
1024
+ },
1025
+ [components],
1026
+ );
1027
+
1028
+ // A `step` issue target names ONE step of a walkthrough — the walkthrough id in
1029
+ // `id`, the 0-based step position in `stepIndex`. Validated against the loaded
1030
+ // walkthroughs so a finding left over from an edited flow resolves to null
1031
+ // rather than framing whatever now happens to sit at that index.
1032
+ const issueStep = useCallback(
1033
+ (
1034
+ issue: SubsystemIssue,
1035
+ ): { walkthrough: SubsystemWalkthrough; stepIndex: number } | null => {
1036
+ const target = issue.target;
1037
+ if (target?.kind !== 'step') return null;
1038
+ if (target.id == null || target.stepIndex == null) return null;
1039
+ const walkthrough = walkthroughs?.find((t) => t.id === target.id);
1040
+ if (!walkthrough?.steps[target.stepIndex]) return null;
1041
+ return { walkthrough, stepIndex: target.stepIndex };
1042
+ },
1043
+ [walkthroughs],
1044
+ );
1045
+
1046
+ // Per-node diagnostics badge: fold each component-targeted finding that maps
1047
+ // to a verification rung into one badge per node — worst severity wins, the
1048
+ // chip shows the earliest failing rung, and the count tallies the findings.
1049
+ const issueBadgeByAlias = useMemo(() => {
1050
+ const map = new Map<string, SubsystemNodeIssue>();
1051
+ if (!issues?.length) return map;
1052
+ for (const issue of issues) {
1053
+ const rung = issueRung(issue.kind);
1054
+ if (!rung) continue;
1055
+ const comp = issueComponent(issue);
1056
+ if (!comp) continue;
1057
+ const prev = map.get(comp.alias);
1058
+ map.set(comp.alias, {
1059
+ severity:
1060
+ prev?.severity === 'error' || issue.severity === 'error'
1061
+ ? 'error'
1062
+ : 'info',
1063
+ rung:
1064
+ prev != null && ISSUE_RUNG_ORDER[prev.rung] <= ISSUE_RUNG_ORDER[rung]
1065
+ ? prev.rung
1066
+ : rung,
1067
+ count: (prev?.count ?? 0) + 1,
1068
+ });
1069
+ }
1070
+ return map;
1071
+ }, [issues, issueComponent]);
1072
+
1073
+ // Per-frame diagnostics badge: the same fold, but for findings about a
1074
+ // BOUNDARY rather than a construct. Keyed by the region's React Flow node id.
1075
+ // Only kinds with a dedicated frame icon qualify (`ISSUE_KIND_ICON`) — a
1076
+ // finding with no icon of its own has nothing to badge the frame with, and
1077
+ // borrowing its layer's icon would imply the frame is at fault for it.
1078
+ const regionIssueByNodeId = useMemo(() => {
1079
+ const map = new Map<string, SubsystemRegionIssue>();
1080
+ if (!issues?.length) return map;
1081
+ for (const issue of issues) {
1082
+ const target = issue.target;
1083
+ if (target?.kind !== 'module' || !ISSUE_KIND_ICON[issue.kind]) continue;
1084
+ const id = moduleGroupNodeId(target.id ?? target.label);
1085
+ const prev = map.get(id);
1086
+ map.set(id, {
1087
+ severity:
1088
+ prev?.severity === 'error' || issue.severity === 'error' ? 'error' : 'info',
1089
+ kind:
1090
+ prev != null && issueKindOrder(prev.kind) <= issueKindOrder(issue.kind)
1091
+ ? prev.kind
1092
+ : issue.kind,
1093
+ count: (prev?.count ?? 0) + 1,
1094
+ });
1095
+ }
1096
+ return map;
1097
+ }, [issues]);
1098
+
1099
+ // Expanded verification layers in the diagnostics list. When any layer with
1100
+ // findings is expanded, the canvas dims everything that layer does not
1101
+ // implicate (see `issueFocus`). The list publishes this through
1102
+ // `onExpandedCategoriesChange`; empty while every layer is collapsed.
1103
+ const [expandedIssueCategories, setExpandedIssueCategories] = useState<
1104
+ SubsystemIssueCategory[]
1105
+ >([]);
1106
+ // Stable setter — the list re-emits on mount and on every toggle, so compare
1107
+ // before storing to avoid churn (and to keep the list effect's dep stable).
1108
+ const handleExpandedCategoriesChange = useCallback(
1109
+ (cats: SubsystemIssueCategory[]) => {
1110
+ setExpandedIssueCategories((prev) =>
1111
+ prev.length === cats.length && prev.every((c, i) => c === cats[i])
1112
+ ? prev
1113
+ : cats,
1114
+ );
1115
+ },
1116
+ [],
1117
+ );
1118
+
1119
+ // Resolve a component name / symbol / alias reference to a component alias.
1120
+ const resolveAlias = useCallback(
1121
+ (ref: string): string | null => {
1122
+ const clean = ref.replace(/\(\)$/, '').trim();
1123
+ const c = components.find(
1124
+ (x) =>
1125
+ x.alias === clean ||
1126
+ x.name === clean ||
1127
+ (x.symbol != null && x.symbol.replace(/\(\)$/, '') === clean),
1128
+ );
1129
+ return c?.alias ?? null;
1130
+ },
1131
+ [components],
1132
+ );
1133
+
1134
+ // Graph elements implicated by the expanded layers' findings — the bright set
1135
+ // the canvas keeps while everything else dims. Derived from each finding's
1136
+ // target:
1137
+ // component → that node
1138
+ // relation → the edge it flags (+ its two endpoint nodes)
1139
+ // module → every component in that module
1140
+ // walkthrough → the flow's nodes + its hop edges
1141
+ // repo → every component of that repo
1142
+ // graph → whole-graph finding; implicates nothing specific
1143
+ // Returns null when nothing is expanded, no expanded layer has findings, or
1144
+ // the findings implicate nothing — so no dimming is applied.
1145
+ const issueFocus = useMemo(() => {
1146
+ if (!issues?.length || expandedIssueCategories.length === 0) return null;
1147
+ const active = new Set(expandedIssueCategories);
1148
+ const activeIssues = issues.filter((i) => active.has(issueCategory(i)));
1149
+ if (activeIssues.length === 0) return null;
1150
+ const nodeIds = new Set<string>();
1151
+ const edgeIds = new Set<string>();
1152
+ for (const issue of activeIssues) {
1153
+ const t = issue.target;
1154
+ if (!t) continue; // whole-graph finding — no specific element
1155
+ if (t.kind === 'component') {
1156
+ const a = resolveAlias(t.id ?? t.label);
1157
+ if (a) nodeIds.add(a);
1158
+ } else if (t.kind === 'relation') {
1159
+ // The target names the two endpoints (`from → to`); light the edge and
1160
+ // its endpoints. Falls back to endpoints alone if no edge matches.
1161
+ const edge = issueEdge(issue);
1162
+ if (edge) {
1163
+ edgeIds.add(edge.id);
1164
+ nodeIds.add(edge.source);
1165
+ nodeIds.add(edge.target);
1166
+ } else {
1167
+ for (const part of `${t.id ?? ''} → ${t.label}`.split('→')) {
1168
+ const a = resolveAlias(part);
1169
+ if (a) nodeIds.add(a);
1170
+ }
1171
+ }
1172
+ } else if (t.kind === 'module') {
1173
+ const key = t.id ?? t.label;
1174
+ const prefix = `${key.replace(/\/$/, '')}/`;
1175
+ for (const c of components) {
1176
+ if (
1177
+ c.module === key ||
1178
+ c.file === key ||
1179
+ (c.file !== '' && c.file.endsWith(key)) ||
1180
+ c.file.startsWith(prefix)
1181
+ ) {
1182
+ nodeIds.add(c.alias);
1183
+ }
1184
+ }
1185
+ } else if (t.kind === 'walkthrough') {
1186
+ const wt = (walkthroughs ?? []).find(
1187
+ (w) => w.id === t.id || w.title === t.label,
1188
+ );
1189
+ if (wt) {
1190
+ for (const s of wt.steps) {
1191
+ nodeIds.add(s.from);
1192
+ nodeIds.add(s.to);
1193
+ edgeIds.add(walkthroughStepGraphEdgeId(s));
1194
+ }
1195
+ }
1196
+ } else if (t.kind === 'repo') {
1197
+ const key = t.id ?? t.label;
1198
+ for (const c of components) {
1199
+ if (c.purl === key) nodeIds.add(c.alias);
1200
+ }
1201
+ }
1202
+ }
1203
+ if (nodeIds.size === 0 && edgeIds.size === 0) return null;
1204
+ // An edge whose endpoints both sit in the bright set stays lit too, so a
1205
+ // focused cluster doesn't read as isolated nodes.
1206
+ for (const e of baseEdges) {
1207
+ if (nodeIds.has(e.source) && nodeIds.has(e.target)) edgeIds.add(e.id);
1208
+ }
1209
+ return { nodeIds, edgeIds };
1210
+ }, [issues, expandedIssueCategories, components, walkthroughs, baseEdges, resolveAlias, issueEdge]);
1211
+ const issueFocusNodeIds = issueFocus?.nodeIds ?? null;
1212
+ const issueFocusEdgeIds = issueFocus?.edgeIds ?? null;
1213
+
929
1214
  // When an edge is selected, dim every other edge + its label to focus it.
930
1215
  const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
931
1216
  // Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
932
1217
  // without a stale closure value.
933
1218
  const selectedEdgeIdRef = useRef<string | null>(null);
934
1219
  selectedEdgeIdRef.current = selectedEdgeId;
1220
+ // Ref mirrors of the focused step, so `unfocusIssueTarget` (a useCallback
1221
+ // over early deps) can tell a step focus it still owns from one the user has
1222
+ // since moved elsewhere in the walkthrough panel.
1223
+ const focusedStepRef = useRef<{ walkthroughId: string; stepIndex: number } | null>(
1224
+ null,
1225
+ );
1226
+ focusedStepRef.current =
1227
+ focusedWalkthroughId != null && focusedStepIndex != null
1228
+ ? { walkthroughId: focusedWalkthroughId, stepIndex: focusedStepIndex }
1229
+ : null;
1230
+
935
1231
  // Edge ids in walkthrough focus (an active flow's edge set, or a single
936
1232
  // step's edge). Used to frame the camera. `null` = no walkthrough focus.
937
- const focusEdgeIds = useMemo(() => {
938
- if (autoPlayFocus) {
1233
+ const focusEdgeIds = useMemo(() => { if (autoPlayFocus) {
939
1234
  const tl = walkthroughs?.find((t) => t.id === autoPlayFocus.walkthroughId);
940
1235
  const step = tl?.steps[autoPlayFocus.stepIndex];
941
1236
  return step ? new Set([walkthroughStepGraphEdgeId(step)]) : null;
@@ -1070,12 +1365,22 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1070
1365
  anyOpened: openedNodeIds != null,
1071
1366
  anySelected: brightNodeIds != null || previewNodeIds != null,
1072
1367
  });
1073
- const dimmed = previewNodeIds
1074
- ? vis.hidden || !memberAliases.some((alias) => previewNodeIds.has(alias))
1368
+ // `brightNodeIds` is the focused step's endpoints. It has to be a dim
1369
+ // source, not just a bright set: without an expanded flow,
1370
+ // `flowElementVisibility` reports non-participants as *hidden*, and the
1371
+ // noOpenedFlow escape below discards that — leaving them neither hidden
1372
+ // nor dimmed. Naming the participants as the dim source dims everything
1373
+ // outside the focused step however the flow got focused.
1374
+ const dimSource = previewNodeIds ?? brightNodeIds ?? issueFocusNodeIds;
1375
+ const dimmed = dimSource
1376
+ ? vis.hidden || !memberAliases.some((alias) => dimSource.has(alias))
1075
1377
  : vis.dimmed;
1076
1378
  const hidden = noOpenedFlow ? false : vis.hidden;
1077
1379
  // Host override wins over the library's derived frame color.
1078
1380
  const color = region?.key != null ? boundaryColors?.[region.key] : undefined;
1381
+ // Boundary findings badge the FRAME, not a member — a region's shape is
1382
+ // the fault, so no leaf construct carries it.
1383
+ const regionIssue = regionIssueByNodeId.get(n.id);
1079
1384
  return {
1080
1385
  ...n,
1081
1386
  hidden,
@@ -1084,6 +1389,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1084
1389
  ...(n.data as object),
1085
1390
  ...(color != null && { color }),
1086
1391
  ...(dimmed && { dimmed: true }),
1392
+ ...(regionIssue && { issue: regionIssue }),
1087
1393
  },
1088
1394
  };
1089
1395
  }
@@ -1100,9 +1406,17 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1100
1406
  anyOpened: openedNodeIds != null,
1101
1407
  anySelected: brightNodeIds != null || previewNodeIds != null,
1102
1408
  });
1103
- const dimmed = previewNodeIds ? (vis.hidden || !previewNodeIds.has(n.id)) : vis.dimmed;
1409
+ // `brightNodeIds` (the focused step's endpoints) is a dim source as well
1410
+ // as a bright set — see the group branch above.
1411
+ const dimSource = previewNodeIds ?? brightNodeIds ?? issueFocusNodeIds;
1412
+ const dimmed = dimSource ? (vis.hidden || !dimSource.has(n.id)) : vis.dimmed;
1104
1413
  const hidden = noOpenedFlow ? false : vis.hidden;
1105
- if (fileMatch === undefined && !isSelected && !dimmed) {
1414
+ // Component findings key by alias, boundary findings by region node id.
1415
+ // The two id spaces are disjoint (`module:` / `process:` are prefixed), so
1416
+ // one lookup covers both node kinds.
1417
+ const issueBadge =
1418
+ issueBadgeByAlias.get(n.id) ?? regionIssueByNodeId.get(n.id);
1419
+ if (fileMatch === undefined && !isSelected && !dimmed && !issueBadge) {
1106
1420
  const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data as Record<string, unknown>;
1107
1421
  return { ...n, hidden, data: rest };
1108
1422
  }
@@ -1114,10 +1428,11 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1114
1428
  ...(fileMatch !== undefined && { fileMatch }),
1115
1429
  ...(isSelected && { isSelected }),
1116
1430
  ...(dimmed && { dimmed: true }),
1431
+ ...(issueBadge && { issue: issueBadge }),
1117
1432
  },
1118
1433
  };
1119
1434
  });
1120
- }, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, previewNodeIds, boundaryColors]);
1435
+ }, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds, previewNodeIds, issueFocusNodeIds, issueBadgeByAlias, regionIssueByNodeId, boundaryColors]);
1121
1436
 
1122
1437
  const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
1123
1438
  const baseEdgesKey = useMemo(
@@ -1151,7 +1466,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1151
1466
  }
1152
1467
  return isWalkthroughMechanism(mechanism);
1153
1468
  };
1154
- if (openedEdgeIds || focusEdgeIds || previewEdgeIds) {
1469
+ if (openedEdgeIds || focusEdgeIds || previewEdgeIds || issueFocusEdgeIds) {
1155
1470
  return baseEdges.map((e) => {
1156
1471
  const vis = flowElementVisibility({
1157
1472
  inOpened: openedEdgeIds?.has(e.id) === true,
@@ -1159,7 +1474,8 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1159
1474
  anyOpened: openedEdgeIds != null,
1160
1475
  anySelected: focusEdgeIds != null,
1161
1476
  });
1162
- const dimmed = previewEdgeIds ? vis.hidden || !previewEdgeIds.has(e.id) : vis.dimmed;
1477
+ const dimSource = previewEdgeIds ?? issueFocusEdgeIds;
1478
+ const dimmed = dimSource ? vis.hidden || !dimSource.has(e.id) : vis.dimmed;
1163
1479
  const hidden = vis.hidden || !edgeInView(e);
1164
1480
  return { ...paint(e, dimmed), hidden };
1165
1481
  });
@@ -1171,7 +1487,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1171
1487
  }));
1172
1488
  }
1173
1489
  return baseEdges.map((e) => ({ ...e, hidden: !edgeInView(e) }));
1174
- }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, resolvedEdgeView]);
1490
+ }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, issueFocusEdgeIds, resolvedEdgeView]);
1175
1491
 
1176
1492
  const onNodesChange = useCallback(
1177
1493
  (changes: NodeChange[]) => {
@@ -1225,6 +1541,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1225
1541
  return;
1226
1542
  }
1227
1543
  setSelected(comp);
1544
+ issueModuleFocusRef.current = null;
1228
1545
  if (comp.alias) onSelect?.(comp.alias);
1229
1546
  }
1230
1547
  },
@@ -1244,6 +1561,8 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1244
1561
  setSelectedEdgeId(null);
1245
1562
  setFocusedWalkthroughId(null);
1246
1563
  setFocusedStepIndex(null);
1564
+ setEdgeViewOverride(null);
1565
+ issueModuleFocusRef.current = null;
1247
1566
  }, []);
1248
1567
 
1249
1568
  // Sidebar file trees — one per repo on multi-repo graphs, each under its
@@ -1263,6 +1582,14 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1263
1582
  );
1264
1583
  const controlledIssues = showIssues !== undefined;
1265
1584
  const issuesActive = controlledIssues ? showIssues : diagnosticsOpen;
1585
+ // Layer focus only applies while the diagnostics list is on screen — closing
1586
+ // it clears any dimming the expanded layers were driving.
1587
+ useEffect(() => {
1588
+ if (!issuesActive) {
1589
+ setExpandedIssueCategories([]);
1590
+ setEdgeViewOverride(null);
1591
+ }
1592
+ }, [issuesActive]);
1266
1593
  const toggleIssues = () => {
1267
1594
  if (!controlledIssues) setDiagnosticsOpen((v) => !v);
1268
1595
  diagnostic?.onToggle?.();
@@ -1463,6 +1790,66 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1463
1790
  ],
1464
1791
  );
1465
1792
 
1793
+ // A node's laid-out rect in absolute flow coords. `absoluteRects` is the
1794
+ // authority; the fallback covers an unparented group node, whose `position` is
1795
+ // already absolute. Nested groups are parent-relative, so they are skipped
1796
+ // rather than mis-boxed.
1797
+ const rectForNode = useCallback(
1798
+ (id: string): { x: number; y: number; width: number; height: number } | undefined => {
1799
+ const r = built.absoluteRects.get(id);
1800
+ if (r) return r;
1801
+ const n = built.nodes.find((x) => x.id === id) as
1802
+ | {
1803
+ position?: { x: number; y: number };
1804
+ width?: number;
1805
+ height?: number;
1806
+ parentId?: string;
1807
+ }
1808
+ | undefined;
1809
+ if (!n?.position || n.parentId) return undefined;
1810
+ return {
1811
+ x: n.position.x,
1812
+ y: n.position.y,
1813
+ width: n.width ?? 0,
1814
+ height: n.height ?? 0,
1815
+ };
1816
+ },
1817
+ [built.absoluteRects, built.nodes],
1818
+ );
1819
+
1820
+ // Frame a set of nodes by their laid-out rects. Unlike `fitFocusBounds` this
1821
+ // takes node ids, so it works for boundary frames too — React Flow's own
1822
+ // `fitView({ nodes })` can't be relied on for group nodes here, and the rects
1823
+ // are already in absolute flow coords (grouped child `position`s are
1824
+ // parent-relative and would skew the box).
1825
+ const fitNodeRects = useCallback(
1826
+ (ids: ReadonlySet<string>, padding: number) => {
1827
+ let minX = Infinity;
1828
+ let minY = Infinity;
1829
+ let maxX = -Infinity;
1830
+ let maxY = -Infinity;
1831
+ for (const id of ids) {
1832
+ const r = rectForNode(id);
1833
+ if (!r) continue;
1834
+ minX = Math.min(minX, r.x);
1835
+ minY = Math.min(minY, r.y);
1836
+ maxX = Math.max(maxX, r.x + r.width);
1837
+ maxY = Math.max(maxY, r.y + r.height);
1838
+ }
1839
+ if (!Number.isFinite(minX) || !Number.isFinite(minY)) return;
1840
+ fitBounds(
1841
+ {
1842
+ x: minX,
1843
+ y: minY,
1844
+ width: Math.max(1, maxX - minX),
1845
+ height: Math.max(1, maxY - minY),
1846
+ },
1847
+ { padding, duration: walkthroughFocusDurationMs },
1848
+ );
1849
+ },
1850
+ [rectForNode, fitBounds, walkthroughFocusDurationMs],
1851
+ );
1852
+
1466
1853
  // Zoom back out to the full diagram after the last expanded walkthrough
1467
1854
  // closes (visibility restores non-flow nodes that were hidden).
1468
1855
  const fitOverview = useCallback(() => {
@@ -1841,6 +2228,144 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
1841
2228
  [components, onSelect],
1842
2229
  );
1843
2230
 
2231
+ // Issue click → select + frame the target on the canvas. Component targets
2232
+ // frame their node; relation targets frame their edge (endpoints + the routed
2233
+ // line); other kinds (module / flow / repo) have no single element and just
2234
+ // forward. Selections / focus that would hide the target are cleared first,
2235
+ // then the camera flies to it on the next frame. The host's `onSelectIssue`
2236
+ // still fires afterwards for its own detail.
2237
+ const focusIssueTarget = useCallback(
2238
+ (issue: SubsystemIssue) => {
2239
+ const comp = issueComponent(issue);
2240
+ const edge = comp ? null : issueEdge(issue);
2241
+ const step = comp || edge ? null : issueStep(issue);
2242
+ if (comp) {
2243
+ setSelected(comp);
2244
+ setSelectedEdgeId(null);
2245
+ setFocusedWalkthroughId(null);
2246
+ setFocusedStepIndex(null);
2247
+ setHoveredWalkthroughStep(null);
2248
+ issueModuleFocusRef.current = null;
2249
+ onSelect?.(comp.alias);
2250
+ requestAnimationFrame(() => {
2251
+ fitView({
2252
+ nodes: [{ id: comp.alias }],
2253
+ padding: 0.4,
2254
+ duration: 300,
2255
+ minZoom: 0.05,
2256
+ maxZoom: 1.5,
2257
+ });
2258
+ });
2259
+ } else if (edge) {
2260
+ setSelected(null);
2261
+ setFocusedWalkthroughId(null);
2262
+ setFocusedStepIndex(null);
2263
+ setHoveredWalkthroughStep(null);
2264
+ setSelectedEdgeId(edge.id);
2265
+ issueModuleFocusRef.current = null;
2266
+ // Flip to the vocabulary this edge belongs to, so a hop flagged in the
2267
+ // relations view (or vice versa) is actually drawn while focused.
2268
+ const d = edge.data as
2269
+ | { mechanism?: string; provenance?: SubsystemEdgeProvenance }
2270
+ | undefined;
2271
+ const mechanism = d?.mechanism ?? 'uses';
2272
+ setEdgeViewOverride(
2273
+ d?.provenance === 'graphify' || isRelationMechanism(mechanism)
2274
+ ? 'relations'
2275
+ : 'walkthroughs',
2276
+ );
2277
+ requestAnimationFrame(() => {
2278
+ fitFocusBounds(new Set([edge.id]));
2279
+ });
2280
+ } else if (step) {
2281
+ // A step finding focuses the step itself, through the same entry point
2282
+ // the walkthrough panel uses — so the drawer, the step numbering, the
2283
+ // dim-mode hover preview, and the camera all behave identically whether
2284
+ // the step was reached from the panel or from a diagnostics card.
2285
+ issueModuleFocusRef.current = null;
2286
+ focusWalkthroughStep(step.walkthrough, step.stepIndex);
2287
+ } else {
2288
+ // Nothing selectable — but a module target names a boundary frame, so
2289
+ // frame that region.
2290
+ const moduleNodeId = issueModuleNodeId(issue);
2291
+ if (moduleNodeId) {
2292
+ setSelected(null);
2293
+ setSelectedEdgeId(null);
2294
+ setFocusedWalkthroughId(null);
2295
+ setFocusedStepIndex(null);
2296
+ setHoveredWalkthroughStep(null);
2297
+ issueModuleFocusRef.current = moduleNodeId;
2298
+ requestAnimationFrame(() => {
2299
+ fitNodeRects(new Set([moduleNodeId]), 0.4);
2300
+ });
2301
+ }
2302
+ }
2303
+ onSelectIssue?.(issue);
2304
+ },
2305
+ [
2306
+ issueComponent,
2307
+ issueEdge,
2308
+ issueStep,
2309
+ issueModuleNodeId,
2310
+ focusWalkthroughStep,
2311
+ onSelect,
2312
+ onSelectIssue,
2313
+ fitView,
2314
+ fitFocusBounds,
2315
+ fitNodeRects,
2316
+ ],
2317
+ );
2318
+
2319
+ // Collapsing the issue card again undoes the focus: drop the node / edge
2320
+ // selection (only when it is still this issue's target — another card may
2321
+ // have taken it over) and zoom back out to the whole graph. A module target
2322
+ // frames a region rather than selecting anything, so its ownership lives in
2323
+ // `issueModuleFocusRef`; without this branch a module focus never unwinds and
2324
+ // the camera never zooms back out.
2325
+ const unfocusIssueTarget = useCallback(
2326
+ (issue: SubsystemIssue) => {
2327
+ const comp = issueComponent(issue);
2328
+ if (comp) {
2329
+ if (selectedRef.current?.alias !== comp.alias) return;
2330
+ setSelected(null);
2331
+ setSelectedEdgeId(null);
2332
+ } else {
2333
+ const edge = issueEdge(issue);
2334
+ const step = edge ? null : issueStep(issue);
2335
+ const moduleNodeId = edge || step ? null : issueModuleNodeId(issue);
2336
+ if (edge) {
2337
+ if (selectedEdgeIdRef.current !== edge.id) return;
2338
+ setSelectedEdgeId(null);
2339
+ setEdgeViewOverride(null);
2340
+ } else if (step) {
2341
+ // Only unwind a step focus this card still owns. A dim-mode graph
2342
+ // keeps its step focus in hover state instead (owned by the pointer,
2343
+ // not the card), and correctly leaves that alone here.
2344
+ const owned = focusedStepRef.current;
2345
+ if (
2346
+ owned?.walkthroughId !== step.walkthrough.id ||
2347
+ owned?.stepIndex !== step.stepIndex
2348
+ ) {
2349
+ return;
2350
+ }
2351
+ setFocusedWalkthroughId(null);
2352
+ setFocusedStepIndex(null);
2353
+ setDrawerTarget((prev) => (prev?.kind === 'walkthrough' ? null : prev));
2354
+ } else if (moduleNodeId) {
2355
+ // Only unwind framing this card still owns.
2356
+ if (issueModuleFocusRef.current !== moduleNodeId) return;
2357
+ issueModuleFocusRef.current = null;
2358
+ } else {
2359
+ return;
2360
+ }
2361
+ }
2362
+ requestAnimationFrame(() => {
2363
+ fitView({ padding: 0.1, duration: 300, minZoom: 0.05, maxZoom: 2 });
2364
+ });
2365
+ },
2366
+ [issueComponent, issueEdge, issueStep, issueModuleNodeId, fitView],
2367
+ );
2368
+
1844
2369
  // Construct tokens inside a walkthrough snippet. A step's line is an edge
1845
2370
  // between its `from`/`to` components, so a token naming either of those
1846
2371
  // constructs should navigate to that construct's declaration. Index the
@@ -2096,9 +2621,11 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
2096
2621
  <SubsystemIssueList
2097
2622
  issues={issues ?? []}
2098
2623
  focusCategory={focusIssueCategory}
2099
- onSelectIssue={onSelectIssue}
2624
+ onSelectIssue={focusIssueTarget}
2625
+ onDeselectIssue={unfocusIssueTarget}
2100
2626
  onApplyFix={onApplyIssueFix}
2101
2627
  onHoverIssue={onHoverIssue}
2628
+ onExpandedCategoriesChange={handleExpandedCategoriesChange}
2102
2629
  />
2103
2630
  ) : (
2104
2631
  <>
@@ -2649,6 +3176,7 @@ function Inner({ components, relations, walkthroughs, graphifyRelations, orderBy
2649
3176
  {/* Startup cover — hides measurement, layout swap, and camera settle. */}
2650
3177
  <GraphLayoutCover revealed={layoutReady} />
2651
3178
  {canvasOverlay}
3179
+ {liveEvents ? <MaintainLivePanel {...liveEvents} /> : null}
2652
3180
  </div>
2653
3181
  </div>
2654
3182
  );