@principal-ai/principal-view-react 0.16.57 → 0.16.58

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.
@@ -32,7 +32,7 @@ import {
32
32
  applyNodeChanges,
33
33
  } from '@xyflow/react';
34
34
  import { useTheme } from '@principal-ade/industry-theme';
35
- import { Map as MapIcon } from 'lucide-react';
35
+ import { Map as MapIcon, X } from 'lucide-react';
36
36
  import { IndustryMarkdownSlide } from 'themed-markdown';
37
37
  import {
38
38
  buildSubsystemGraph,
@@ -41,9 +41,10 @@ import {
41
41
  type SubsystemComponentEdge,
42
42
  type SubsystemComponent,
43
43
  type SubsystemEdgeMechanism,
44
+ type SubsystemThroughline,
44
45
  } from './model';
45
46
  import type { SubsystemOpenFileOptions } from './declarationRef';
46
- import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS } from './nodes';
47
+ import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
47
48
  import { SubsystemFileTree } from './SubsystemFileTree';
48
49
  import { GraphLayoutCover } from './GraphLayoutCover';
49
50
  import { ComponentDeclaration } from './ComponentDeclaration';
@@ -55,6 +56,15 @@ import { buildRepoGroups, repoAvatarUrl, type RepoGroup } from './paths';
55
56
  export interface SubsystemComponentGraphProps {
56
57
  components: SubsystemComponent[];
57
58
  edges: SubsystemComponentEdge[];
59
+ /**
60
+ * Ordered execution stories over the graph's edges — one throughline per
61
+ * flow. When present the sidebar's bottom half offers a Files/Flows toggle:
62
+ * the flows panel lists each throughline's steps (`symbol` or `file:line`);
63
+ * clicking a flow row toggles its steps; clicking a step focuses that
64
+ * step's edge. Opened flows stay on the canvas (unselected ones dimmed);
65
+ * everything else is hidden.
66
+ */
67
+ throughlines?: SubsystemThroughline[];
58
68
  onSelect?: (componentId: string) => void;
59
69
  /** Called when an edge is clicked (relationship / mechanism + refs seam). */
60
70
  onEdgeSelect?: (edge: SubsystemComponentEdge) => void;
@@ -126,7 +136,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
126
136
  measured: { w: number; h: number } | null;
127
137
  }
128
138
 
129
- function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
139
+ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }: InnerProps) {
130
140
  const { theme } = useTheme();
131
141
  const { fitView } = useReactFlow();
132
142
  const viewport = useViewport();
@@ -147,6 +157,18 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
147
157
  const [legendOpen, setLegendOpen] = useState(false);
148
158
  // Component the pointer is over (null on leave) → transient tree highlight.
149
159
  const [hoveredComponentId, setHoveredComponentId] = useState<string | null>(null);
160
+ // Throughline focus — selected flow (or step) is full strength; other
161
+ // opened-flow members stay visible but dimmed; everything else is hidden.
162
+ const [focusedThroughlineId, setFocusedThroughlineId] = useState<string | null>(null);
163
+ // `null` = whole flow focused; a number = that single step's edge focused.
164
+ const [focusedStepIndex, setFocusedStepIndex] = useState<number | null>(null);
165
+ // Sidebar bottom half: which panel is shown when throughlines exist.
166
+ const [sidebarView, setSidebarView] = useState<'files' | 'flows'>(() =>
167
+ throughlines?.length ? 'flows' : 'files',
168
+ );
169
+ // Throughline flows the user has expanded (via the title row). Closed by
170
+ // default so a graph with several flows doesn't dump every step list at once.
171
+ const [expandedThroughlines, setExpandedThroughlines] = useState<Set<string>>(new Set());
150
172
  // Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
151
173
  // closure over the effect deps) can toggle without a stale value.
152
174
  const selectedRef = useRef<SubsystemComponent | null>(null);
@@ -245,6 +267,9 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
245
267
  }
246
268
  const src = edgeById.get(edgeId);
247
269
  setSelectedEdgeId(edgeId);
270
+ // A direct edge selection on the canvas supersedes any throughline focus.
271
+ setFocusedThroughlineId(null);
272
+ setFocusedStepIndex(null);
248
273
  if (src) onEdgeSelect?.(src);
249
274
  },
250
275
  [edgeById, onEdgeSelect],
@@ -263,6 +288,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
263
288
  }
264
289
  setSelected(comp);
265
290
  setSelectedEdgeId(null);
291
+ setFocusedThroughlineId(null);
292
+ setFocusedStepIndex(null);
266
293
  onSelect?.(id);
267
294
  }
268
295
  };
@@ -281,26 +308,120 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
281
308
  const xyflowNodesBase = nodes as Node[];
282
309
  const baseEdges = convertedEdges as Edge[];
283
310
 
311
+ // When an edge is selected, dim every other edge + its label to focus it.
312
+ const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
313
+ // Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
314
+ // without a stale closure value.
315
+ const selectedEdgeIdRef = useRef<string | null>(null);
316
+ selectedEdgeIdRef.current = selectedEdgeId;
317
+ // Edge ids in throughline focus (an active flow's edge set, or a single
318
+ // step's edge). Used to frame the camera. `null` = no throughline focus.
319
+ const focusEdgeIds = useMemo(() => {
320
+ if (focusedThroughlineId == null || !throughlines) return null;
321
+ const tl = throughlines.find((t) => t.id === focusedThroughlineId);
322
+ if (!tl) return null;
323
+ if (focusedStepIndex != null) {
324
+ const step = tl.steps[focusedStepIndex];
325
+ return step ? new Set([step.edgeId]) : null;
326
+ }
327
+ return new Set(tl.steps.map((s) => s.edgeId));
328
+ }, [throughlines, focusedThroughlineId, focusedStepIndex]);
329
+
330
+ // 1-based step numbers per edge of the selected flow (an edge can appear
331
+ // in more than one step).
332
+ const selectedFlowStepNos = useMemo(() => {
333
+ if (focusedThroughlineId == null || !throughlines) return null;
334
+ const tl = throughlines.find((t) => t.id === focusedThroughlineId);
335
+ if (!tl) return null;
336
+ const map = new Map<string, number[]>();
337
+ tl.steps.forEach((s, i) => {
338
+ const list = map.get(s.edgeId) ?? [];
339
+ list.push(i + 1);
340
+ map.set(s.edgeId, list);
341
+ });
342
+ return map;
343
+ }, [throughlines, focusedThroughlineId]);
344
+
345
+ // Union of every expanded (opened) throughline's edges — the visible set.
346
+ const openedEdgeIds = useMemo(() => {
347
+ if (!throughlines || expandedThroughlines.size === 0) return null;
348
+ const ids = new Set<string>();
349
+ for (const tl of throughlines) {
350
+ if (!expandedThroughlines.has(tl.id)) continue;
351
+ for (const s of tl.steps) ids.add(s.edgeId);
352
+ }
353
+ return ids.size > 0 ? ids : null;
354
+ }, [throughlines, expandedThroughlines]);
355
+
356
+ const endpointsOf = (edgeIds: ReadonlySet<string> | null): Set<string> | null => {
357
+ if (!edgeIds) return null;
358
+ const ids = new Set<string>();
359
+ for (const e of baseEdges) {
360
+ if (!edgeIds.has(e.id)) continue;
361
+ ids.add(e.source);
362
+ ids.add(e.target);
363
+ }
364
+ return ids.size > 0 ? ids : null;
365
+ };
366
+ const openedNodeIds = useMemo(
367
+ () => endpointsOf(openedEdgeIds),
368
+ // eslint-disable-next-line react-hooks/exhaustive-deps
369
+ [baseEdges, openedEdgeIds],
370
+ );
371
+ // Endpoints of the bright set: the selected step's edge, or the whole flow
372
+ // when no step is focused.
373
+ const brightNodeIds = useMemo(
374
+ () => endpointsOf(focusEdgeIds),
375
+ // eslint-disable-next-line react-hooks/exhaustive-deps
376
+ [baseEdges, focusEdgeIds],
377
+ );
378
+
379
+ // Source + target of the focused step/flow (or a canvas-selected edge). If a
380
+ // file is open, those endpoints stay undimmed even when they don't live in
381
+ // that file.
382
+ const focusNodeIds = useMemo(() => {
383
+ if (brightNodeIds) return brightNodeIds;
384
+ if (selectedEdgeId) {
385
+ const e = baseEdges.find((x) => x.id === selectedEdgeId);
386
+ if (e) return new Set([e.source, e.target]);
387
+ }
388
+ return null;
389
+ }, [baseEdges, brightNodeIds, selectedEdgeId]);
390
+
284
391
  // While a file is open in the drawer, tag each node with whether its
285
392
  // component lives in that file — the node renderer spotlights matches and
286
393
  // dims non-matches (mirrors the edge-dimming behavior on selection).
394
+ // Opened-but-unselected members are dimmed; a selected step further dims
395
+ // the rest of its own flow. Nodes not on any opened flow are hidden.
287
396
  // `isSelected` rides in data because the node's stopPropagation() keeps
288
- // React Flow's own selection state from ever updating.
397
+ // React Flow's own selection state from updating.
289
398
  const dispNodes = useMemo(() => {
290
399
  return xyflowNodesBase.map((n) => {
291
400
  const comp = (n.data as { component?: SubsystemComponent } | undefined)?.component;
292
- const fileMatch = openFile ? comp?.file === openFile : undefined;
401
+ const fileMatch = fileMatchForNode(comp?.file, openFile, focusNodeIds?.has(n.id) === true);
293
402
  const isSelected = selected?.id !== undefined && comp?.id === selected.id;
294
- if (fileMatch === undefined && !isSelected) {
295
- const { fileMatch: _f, isSelected: _s, ...rest } = n.data as Record<string, unknown>;
296
- return { ...n, data: rest };
403
+ const vis = flowElementVisibility({
404
+ inOpened: openedNodeIds?.has(n.id) === true,
405
+ inSelected: brightNodeIds?.has(n.id) === true,
406
+ anyOpened: openedNodeIds != null,
407
+ anySelected: brightNodeIds != null,
408
+ });
409
+ if (fileMatch === undefined && !isSelected && !vis.dimmed) {
410
+ const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data as Record<string, unknown>;
411
+ return { ...n, hidden: vis.hidden, data: rest };
297
412
  }
298
413
  return {
299
414
  ...n,
300
- data: { ...(n.data as object), ...(fileMatch !== undefined && { fileMatch }), ...(isSelected && { isSelected }) },
415
+ hidden: vis.hidden,
416
+ data: {
417
+ ...(n.data as object),
418
+ ...(fileMatch !== undefined && { fileMatch }),
419
+ ...(isSelected && { isSelected }),
420
+ ...(vis.dimmed && { dimmed: true }),
421
+ },
301
422
  };
302
423
  });
303
- }, [xyflowNodesBase, openFile, selected]);
424
+ }, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds]);
304
425
 
305
426
  const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
306
427
  const baseEdgesKey = useMemo(
@@ -308,22 +429,33 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
308
429
  [convertedEdges],
309
430
  );
310
431
 
311
- // When an edge is selected, dim every other edge + its label to focus it.
312
- const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
313
- // Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
314
- // without a stale closure value.
315
- const selectedEdgeIdRef = useRef<string | null>(null);
316
- selectedEdgeIdRef.current = selectedEdgeId;
317
432
  const dispEdges = useMemo(() => {
433
+ const paint = (e: Edge, dimmed: boolean): Edge => {
434
+ const markerEnd = e.markerEnd;
435
+ const nextMarker =
436
+ dimmed && markerEnd && typeof markerEnd === 'object' && typeof markerEnd.color === 'string'
437
+ ? { ...markerEnd, color: hexWithAlpha(markerEnd.color, EDGE_DIM_ALPHA) }
438
+ : markerEnd;
439
+ return {
440
+ ...e,
441
+ data: { ...(e.data as object), dimmed },
442
+ markerEnd: nextMarker,
443
+ };
444
+ };
445
+ if (openedEdgeIds || focusEdgeIds) {
446
+ return baseEdges.map((e) => {
447
+ const vis = flowElementVisibility({
448
+ inOpened: openedEdgeIds?.has(e.id) === true,
449
+ inSelected: focusEdgeIds?.has(e.id) === true,
450
+ anyOpened: openedEdgeIds != null,
451
+ anySelected: focusEdgeIds != null,
452
+ });
453
+ return { ...paint(e, vis.dimmed), hidden: vis.hidden };
454
+ });
455
+ }
318
456
  if (!selectedEdgeId) return baseEdges;
319
- return baseEdges.map((e) => ({
320
- ...e,
321
- data: {
322
- ...(e.data as object),
323
- dimmed: e.id !== selectedEdgeId,
324
- },
325
- }));
326
- }, [baseEdges, selectedEdgeId]);
457
+ return baseEdges.map((e) => paint(e, e.id !== selectedEdgeId));
458
+ }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds]);
327
459
 
328
460
  const onNodesChange = useCallback(
329
461
  (changes: NodeChange[]) => {
@@ -363,6 +495,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
363
495
  (_e, node: Node) => {
364
496
  const comp = (node.data as { component?: SubsystemComponent } | undefined)?.component;
365
497
  setSelectedEdgeId(null);
498
+ setFocusedThroughlineId(null);
499
+ setFocusedStepIndex(null);
366
500
  if (node.type === 'subsystem-component' && comp) {
367
501
  // Clicking the already-selected node unselects it (toggle off).
368
502
  // Selection is independent of the file drawer — nodes never open it.
@@ -388,11 +522,14 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
388
522
  const onPaneClick = useCallback(() => {
389
523
  setSelected(null);
390
524
  setSelectedEdgeId(null);
525
+ setFocusedThroughlineId(null);
526
+ setFocusedStepIndex(null);
391
527
  }, []);
392
528
 
393
529
  // Sidebar file trees — one per repo on multi-repo graphs, each under its
394
530
  // own owner-avatar header. Clicking a header collapses that repo's tree.
395
531
  const repoGroups = useMemo(() => buildRepoGroups(components), [components]);
532
+ const hasThroughlines = useMemo(() => (throughlines?.length ?? 0) > 0, [throughlines]);
396
533
  const [collapsedRepos, setCollapsedRepos] = useState<Set<string>>(new Set());
397
534
  const toggleRepoCollapsed = useCallback((key: string) => {
398
535
  setCollapsedRepos((prev) => {
@@ -434,6 +571,71 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
434
571
  [onFileSelect],
435
572
  );
436
573
 
574
+ // Camera helper shared by the throughline interactions: frames the focused
575
+ // edges' endpoint nodes via `fitView({ nodes })`, which uses the store's
576
+ // live positions + measured dims — so the frame always includes BOTH
577
+ // components the edge attaches to (and therefore the edge line between them).
578
+ const fitFocusBounds = useCallback(
579
+ (ids: ReadonlySet<string>) => {
580
+ const nodeIds = new Set<string>();
581
+ for (const e of baseEdges) {
582
+ if (!ids.has(e.id)) continue;
583
+ nodeIds.add(e.source);
584
+ nodeIds.add(e.target);
585
+ }
586
+ if (nodeIds.size === 0) return;
587
+ fitView({
588
+ nodes: [...nodeIds].map((id) => ({ id })),
589
+ padding: 0.25,
590
+ duration: 300,
591
+ });
592
+ },
593
+ [baseEdges, fitView],
594
+ );
595
+
596
+ // Focus an entire flow: hide everything but the flow's nodes and edges, and
597
+ // frame the flow on the canvas. Selection state is cleared — the graph now
598
+ // reads as the narrative.
599
+ const focusThroughlineEdges = useCallback(
600
+ (tl: SubsystemThroughline) => {
601
+ setSelected(null);
602
+ setSelectedEdgeId(null);
603
+ setFocusedStepIndex(null);
604
+ setFocusedThroughlineId(tl.id);
605
+ fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
606
+ },
607
+ [fitFocusBounds],
608
+ );
609
+
610
+ const clearThroughlineFocus = useCallback(() => {
611
+ setFocusedThroughlineId(null);
612
+ setFocusedStepIndex(null);
613
+ }, []);
614
+
615
+ // Focus a single step's edge on the canvas. The step's file:line is listed
616
+ // in the row; we don't open the drawer from here.
617
+ const focusThroughlineStep = useCallback(
618
+ (tl: SubsystemThroughline, stepIndex: number) => {
619
+ const step = tl.steps[stepIndex];
620
+ if (!step) return;
621
+ setSelected(null);
622
+ setSelectedEdgeId(null);
623
+ setFocusedStepIndex(stepIndex);
624
+ setFocusedThroughlineId(tl.id);
625
+ fitFocusBounds(new Set([step.edgeId]));
626
+ },
627
+ [fitFocusBounds],
628
+ );
629
+
630
+ const toggleThroughlineCollapsed = useCallback((tlId: string) => {
631
+ setExpandedThroughlines((prev) => {
632
+ const next = new Set(prev);
633
+ if (next.has(tlId)) next.delete(tlId);
634
+ else next.add(tlId);
635
+ return next;
636
+ });
637
+ }, []);
638
+
437
639
  // Filename-badge clicks on nodes open the drawer through the same path as
438
640
  // the declaration panel's file link (toggle + tree sync, no start line).
439
641
  useEffect(() => {
@@ -461,6 +663,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
461
663
  if (!comp || comp.id === selectedRef.current?.id) return;
462
664
  setSelected(comp);
463
665
  setSelectedEdgeId(null);
666
+ setFocusedThroughlineId(null);
667
+ setFocusedStepIndex(null);
464
668
  onSelect?.(comp.id);
465
669
  },
466
670
  [components, onSelect],
@@ -470,17 +674,20 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
470
674
  // doesn't intercept pointer events). Uses ELK-computed label midpoints from
471
675
  // the actual edge path (not node-center approximations).
472
676
  const edgeLabels = useMemo(() => {
473
- return dispEdges.map((e) => {
474
- const d = e.data as { mechanism?: string; dimmed?: boolean; labelX?: number; labelY?: number } | undefined;
475
- return {
476
- id: e.id,
477
- mechanism: d?.mechanism ?? 'imports',
478
- dimmed: d?.dimmed === true,
479
- midX: d?.labelX ?? 0,
480
- midY: d?.labelY ?? 0,
481
- };
482
- });
483
- }, [dispEdges]);
677
+ return dispEdges
678
+ .filter((e) => !e.hidden)
679
+ .map((e) => {
680
+ const d = e.data as { mechanism?: string; dimmed?: boolean; labelX?: number; labelY?: number } | undefined;
681
+ return {
682
+ id: e.id,
683
+ mechanism: d?.mechanism ?? 'imports',
684
+ dimmed: d?.dimmed === true,
685
+ midX: d?.labelX ?? 0,
686
+ midY: d?.labelY ?? 0,
687
+ stepNos: selectedFlowStepNos?.get(e.id),
688
+ };
689
+ });
690
+ }, [dispEdges, selectedFlowStepNos]);
484
691
 
485
692
  const usedMechanisms = useMemo(() => {
486
693
  return new Set(edgeLabels.map((l) => l.mechanism));
@@ -521,8 +728,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
521
728
 
522
729
  return (
523
730
  <div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }}>
524
- {/* Sidebar: scrollable title/description on top, file tree pinned to the bottom half */}
525
- {(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0) && (
731
+ {/* Sidebar: scrollable title/description on top, files or flows pinned to the bottom half */}
732
+ {(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (
526
733
  <div
527
734
  style={{
528
735
  width: 340,
@@ -576,7 +783,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
576
783
  )}
577
784
  {sidebarAfterDescription}
578
785
  </div>
579
- {treeFilePaths.length > 0 && (
786
+ {(treeFilePaths.length > 0 || hasThroughlines) && (
580
787
  <div
581
788
  style={{
582
789
  height: '50%',
@@ -588,6 +795,72 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
588
795
  overflow: 'hidden',
589
796
  }}
590
797
  >
798
+ {hasThroughlines && (
799
+ <div
800
+ role="tablist"
801
+ aria-label="Sidebar view"
802
+ style={{
803
+ display: 'flex',
804
+ width: '100%',
805
+ flexShrink: 0,
806
+ borderBottom: `1px solid ${theme.colors.border}`,
807
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
808
+ }}
809
+ >
810
+ {(['files', 'flows'] as const).map((view) => (
811
+ <button
812
+ key={view}
813
+ type="button"
814
+ role="tab"
815
+ aria-selected={sidebarView === view}
816
+ onClick={() => setSidebarView(view)}
817
+ style={{
818
+ flex: 1,
819
+ minWidth: 0,
820
+ padding: '8px 8px',
821
+ border: 'none',
822
+ borderRadius: 0,
823
+ background: sidebarView === view ? theme.colors.background : 'transparent',
824
+ color:
825
+ sidebarView === view
826
+ ? theme.colors.text
827
+ : theme.colors.textSecondary,
828
+ fontSize: theme.fontSizes[1],
829
+ fontFamily: theme.fonts.monospace,
830
+ textTransform: 'capitalize',
831
+ cursor: 'pointer',
832
+ }}
833
+ >
834
+ {view}
835
+ </button>
836
+ ))}
837
+ </div>
838
+ )}
839
+ {sidebarView === 'flows' && throughlines && throughlines.length > 0 ? (
840
+ <div
841
+ style={{
842
+ flex: 1,
843
+ minHeight: 0,
844
+ overflowY: 'auto',
845
+ padding: '0 4px 12px',
846
+ }}
847
+ >
848
+ {throughlines.map((tl) => (
849
+ <ThroughlineFlow
850
+ key={tl.id}
851
+ throughline={tl}
852
+ edges={edges}
853
+ collapsed={!expandedThroughlines.has(tl.id)}
854
+ active={focusedThroughlineId === tl.id ? { stepIndex: focusedStepIndex } : null}
855
+ onToggleCollapsed={toggleThroughlineCollapsed}
856
+ onFocusFlow={focusThroughlineEdges}
857
+ onClearFocus={clearThroughlineFocus}
858
+ onFocusStep={focusThroughlineStep}
859
+ />
860
+ ))}
861
+ </div>
862
+ ) : treeFilePaths.length > 0 ? (
863
+ <>
591
864
  {repoGroups.groups.map((group, i) => {
592
865
  const groupKey = group.repoKey ?? '__no-repo__';
593
866
  const collapsed = collapsedRepos.has(groupKey);
@@ -621,6 +894,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
621
894
  </div>
622
895
  );
623
896
  })}
897
+ </>
898
+ ) : null}
624
899
  </div>
625
900
  )}
626
901
  </div>
@@ -661,6 +936,10 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
661
936
  position: 'absolute',
662
937
  left: screenX,
663
938
  top: screenY,
939
+ // Center on the flow-space midpoint. Labels live in screen
940
+ // space (fixed size when zoomed out), so top-left anchoring
941
+ // would drift them right/down of the edge as zoom drops.
942
+ transform: 'translate(-50%, -50%)',
664
943
  fontSize: 10,
665
944
  fontFamily: theme.fonts.monospace,
666
945
  fontWeight: 500,
@@ -675,7 +954,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
675
954
  whiteSpace: 'nowrap',
676
955
  }}
677
956
  >
678
- {lbl.mechanism}
957
+ {lbl.stepNos?.length ? `${lbl.stepNos.map((n) => `${n}:`).join(' ')} ${lbl.mechanism}` : lbl.mechanism}
679
958
  </div>
680
959
  );
681
960
  })}
@@ -872,6 +1151,221 @@ function RepoGroupHeader({
872
1151
  );
873
1152
  }
874
1153
 
1154
+ /** One collapsible throughline in the sidebar's flows panel. Clicking the
1155
+ * title: closed → open + select; open and unselected → select; open and
1156
+ * selected → close + clear focus. The right-aligned close button collapses
1157
+ * without selecting. A step row focuses that step's edge. */
1158
+ function ThroughlineFlow({
1159
+ throughline,
1160
+ edges,
1161
+ collapsed,
1162
+ active,
1163
+ onToggleCollapsed,
1164
+ onFocusFlow,
1165
+ onClearFocus,
1166
+ onFocusStep,
1167
+ }: {
1168
+ throughline: SubsystemThroughline;
1169
+ edges: SubsystemComponentEdge[];
1170
+ collapsed: boolean;
1171
+ /** `{ stepIndex: null }` = whole flow focused; `{ stepIndex }` = one step. */
1172
+ active: { stepIndex: number | null } | null;
1173
+ onToggleCollapsed: (tlId: string) => void;
1174
+ onFocusFlow: (tl: SubsystemThroughline) => void;
1175
+ onClearFocus: () => void;
1176
+ onFocusStep: (tl: SubsystemThroughline, stepIndex: number) => void;
1177
+ }) {
1178
+ const { theme } = useTheme();
1179
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
1180
+ const hoverBg = theme.colors.background;
1181
+ const edgeById = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
1182
+ const wholeFlowActive = active !== null && active.stepIndex === null;
1183
+ const [headerHover, setHeaderHover] = useState(false);
1184
+ const [closeHover, setCloseHover] = useState(false);
1185
+ const [hoveredStep, setHoveredStep] = useState<number | null>(null);
1186
+
1187
+ return (
1188
+ <div style={{ margin: '4px 0', borderRadius: 8 }}>
1189
+ <div
1190
+ onMouseEnter={() => setHeaderHover(true)}
1191
+ onMouseLeave={() => setHeaderHover(false)}
1192
+ style={{
1193
+ display: 'flex',
1194
+ alignItems: 'center',
1195
+ gap: 4,
1196
+ borderRadius: 6,
1197
+ background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
1198
+ transition: 'background 120ms ease',
1199
+ }}
1200
+ >
1201
+ <button
1202
+ type="button"
1203
+ onClick={() => {
1204
+ if (collapsed) {
1205
+ onToggleCollapsed(throughline.id);
1206
+ onFocusFlow(throughline);
1207
+ } else if (active === null) {
1208
+ onFocusFlow(throughline);
1209
+ } else {
1210
+ onToggleCollapsed(throughline.id);
1211
+ onClearFocus();
1212
+ }
1213
+ }}
1214
+ style={{
1215
+ flex: 1,
1216
+ display: 'flex',
1217
+ alignItems: 'center',
1218
+ minWidth: 0,
1219
+ padding: '6px 8px',
1220
+ borderRadius: 6,
1221
+ border: 'none',
1222
+ background: 'transparent',
1223
+ textAlign: 'left',
1224
+ cursor: 'pointer',
1225
+ }}
1226
+ >
1227
+ <span
1228
+ style={{
1229
+ overflow: 'hidden',
1230
+ textOverflow: 'ellipsis',
1231
+ whiteSpace: 'nowrap',
1232
+ fontSize: theme.fontSizes[1],
1233
+ fontFamily: theme.fonts.monospace,
1234
+ fontWeight: 600,
1235
+ color: theme.colors.text,
1236
+ }}
1237
+ >
1238
+ {throughline.title}
1239
+ </span>
1240
+ </button>
1241
+ {!collapsed && (
1242
+ <button
1243
+ type="button"
1244
+ aria-label={`Close ${throughline.title}`}
1245
+ onMouseEnter={() => setCloseHover(true)}
1246
+ onMouseLeave={() => setCloseHover(false)}
1247
+ onClick={(e) => {
1248
+ e.stopPropagation();
1249
+ onToggleCollapsed(throughline.id);
1250
+ if (active !== null) onClearFocus();
1251
+ }}
1252
+ style={{
1253
+ display: 'inline-flex',
1254
+ alignItems: 'center',
1255
+ justifyContent: 'center',
1256
+ flexShrink: 0,
1257
+ width: 22,
1258
+ height: 22,
1259
+ marginRight: 4,
1260
+ padding: 0,
1261
+ border: 'none',
1262
+ borderRadius: 4,
1263
+ background: closeHover ? theme.colors.border : 'transparent',
1264
+ color: closeHover ? theme.colors.text : muted,
1265
+ cursor: 'pointer',
1266
+ transition: 'background 120ms ease, color 120ms ease',
1267
+ }}
1268
+ >
1269
+ <X size={12} strokeWidth={2} />
1270
+ </button>
1271
+ )}
1272
+ </div>
1273
+ {!collapsed && (
1274
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
1275
+ {throughline.steps.map((step, i) => {
1276
+ const edge = edgeById.get(step.edgeId);
1277
+ const mech = edge?.mechanism;
1278
+ const color = mech ? MECHANISM_COLOR[mech] : muted;
1279
+ const stepActive = active !== null && active.stepIndex === i;
1280
+ return (
1281
+ <button
1282
+ key={`${step.edgeId}-${i}`}
1283
+ type="button"
1284
+ onMouseEnter={() => setHoveredStep(i)}
1285
+ onMouseLeave={() => setHoveredStep(null)}
1286
+ onClick={() => onFocusStep(throughline, i)}
1287
+ style={{
1288
+ display: 'flex',
1289
+ alignItems: 'center',
1290
+ gap: 8,
1291
+ minWidth: 0,
1292
+ padding: '4px 8px 4px 12px',
1293
+ textAlign: 'left',
1294
+ borderRadius: 6,
1295
+ border: 'none',
1296
+ background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
1297
+ cursor: 'pointer',
1298
+ transition: 'background 120ms ease',
1299
+ }}
1300
+ >
1301
+ <span
1302
+ style={{
1303
+ flexShrink: 0,
1304
+ width: 14,
1305
+ fontSize: theme.fontSizes[0] * 0.8,
1306
+ fontFamily: theme.fonts.monospace,
1307
+ color: stepActive ? theme.colors.text : muted,
1308
+ }}
1309
+ >
1310
+ {i + 1}
1311
+ </span>
1312
+ {step.symbol ? (
1313
+ <span
1314
+ style={{
1315
+ flex: 1,
1316
+ minWidth: 0,
1317
+ overflow: 'hidden',
1318
+ textOverflow: 'ellipsis',
1319
+ whiteSpace: 'nowrap',
1320
+ fontSize: theme.fontSizes[0],
1321
+ fontFamily: theme.fonts.monospace,
1322
+ color: stepActive ? theme.colors.text : muted,
1323
+ }}
1324
+ >
1325
+ {step.symbol}
1326
+ </span>
1327
+ ) : (
1328
+ <>
1329
+ <span
1330
+ style={{
1331
+ flexShrink: 0,
1332
+ width: 74,
1333
+ overflow: 'hidden',
1334
+ textOverflow: 'ellipsis',
1335
+ whiteSpace: 'nowrap',
1336
+ fontSize: theme.fontSizes[0],
1337
+ fontFamily: theme.fonts.monospace,
1338
+ color,
1339
+ }}
1340
+ >
1341
+ {mech ?? step.edgeId}
1342
+ </span>
1343
+ <span
1344
+ style={{
1345
+ flex: 1,
1346
+ minWidth: 0,
1347
+ overflow: 'hidden',
1348
+ textOverflow: 'ellipsis',
1349
+ whiteSpace: 'nowrap',
1350
+ fontSize: theme.fontSizes[0],
1351
+ fontFamily: theme.fonts.monospace,
1352
+ color: stepActive ? theme.colors.text : muted,
1353
+ }}
1354
+ >
1355
+ {step.file.split('/').pop()}
1356
+ <span style={{ opacity: 0.7 }}>:{step.line}</span>
1357
+ </span>
1358
+ </>
1359
+ )}
1360
+ </button>
1361
+ );
1362
+ })}
1363
+ </div>
1364
+ )}
1365
+ </div>
1366
+ );
1367
+ }
1368
+
875
1369
  export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
876
1370
  const wrapRef = useRef<HTMLDivElement>(null);
877
1371
  const [size, setSize] = useState<{ w: number; h: number } | null>(null);