@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.
@@ -12,12 +12,21 @@
12
12
  * injection, onNodeClick) adapted to the subsystem model — read-only.
13
13
  */
14
14
  import type { ReactNode } from 'react';
15
- import { type SubsystemComponentEdge, type SubsystemComponent } from './model';
15
+ import { type SubsystemComponentEdge, type SubsystemComponent, type SubsystemThroughline } from './model';
16
16
  import type { SubsystemOpenFileOptions } from './declarationRef';
17
17
  import type { ComponentVerificationState } from './ComponentDeclaration';
18
18
  export interface SubsystemComponentGraphProps {
19
19
  components: SubsystemComponent[];
20
20
  edges: SubsystemComponentEdge[];
21
+ /**
22
+ * Ordered execution stories over the graph's edges — one throughline per
23
+ * flow. When present the sidebar's bottom half offers a Files/Flows toggle:
24
+ * the flows panel lists each throughline's steps (`symbol` or `file:line`);
25
+ * clicking a flow row toggles its steps; clicking a step focuses that
26
+ * step's edge. Opened flows stay on the canvas (unselected ones dimmed);
27
+ * everything else is hidden.
28
+ */
29
+ throughlines?: SubsystemThroughline[];
21
30
  onSelect?: (componentId: string) => void;
22
31
  /** Called when an edge is clicked (relationship / mechanism + refs seam). */
23
32
  onEdgeSelect?: (edge: SubsystemComponentEdge) => void;
@@ -1 +1 @@
1
- {"version":3,"file":"SubsystemComponentGraph.d.ts","sourceRoot":"","sources":["../../src/subsystem/SubsystemComponentGraph.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,KAAK,EAAiC,SAAS,EAAE,MAAM,OAAO,CAAC;AAqBtE,OAAO,EAIL,KAAK,sBAAsB,EAC3B,KAAK,kBAAkB,EAExB,MAAM,SAAS,CAAC;AACjB,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,kBAAkB,CAAC;AAKjE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,wBAAwB,CAAC;AAKzE,MAAM,WAAW,4BAA4B;IAC3C,UAAU,EAAE,kBAAkB,EAAE,CAAC;IACjC,KAAK,EAAE,sBAAsB,EAAE,CAAC;IAChC,QAAQ,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,6EAA6E;IAC7E,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,sBAAsB,KAAK,IAAI,CAAC;IACtD;kFAC8E;IAC9E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,qEAAqE;IACrE,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0EAA0E;IAC1E,aAAa,CAAC,EAAE,SAAS,CAAC;IAC1B,6DAA6D;IAC7D,YAAY,CAAC,EAAE,SAAS,CAAC;IACzB,gFAAgF;IAChF,uBAAuB,CAAC,EAAE,SAAS,CAAC;IACpC;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,wBAAwB,KAAK,SAAS,CAAC;IAChF;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,SAAS,EAAE,kBAAkB,KAAK,SAAS,CAAC;IAC9D;;;OAGG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,0EAA0E;IAC1E,iBAAiB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;IAClD,2DAA2D;IAC3D,qBAAqB,CAAC,EAAE,0BAA0B,GAAG,IAAI,CAAC;CAC3D;AAywBD,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,4BAA4B,2CAsC1E"}
1
+ {"version":3,"file":"SubsystemComponentGraph.d.ts","sourceRoot":"","sources":["../../src/subsystem/SubsystemComponentGraph.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,KAAK,EAAiC,SAAS,EAAE,MAAM,OAAO,CAAC;AAqBtE,OAAO,EAIL,KAAK,sBAAsB,EAC3B,KAAK,kBAAkB,EAEvB,KAAK,oBAAoB,EAC1B,MAAM,SAAS,CAAC;AACjB,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,kBAAkB,CAAC;AAKjE,OAAO,KAAK,EAAE,0BAA0B,EAAE,MAAM,wBAAwB,CAAC;AAKzE,MAAM,WAAW,4BAA4B;IAC3C,UAAU,EAAE,kBAAkB,EAAE,CAAC;IACjC,KAAK,EAAE,sBAAsB,EAAE,CAAC;IAChC;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,oBAAoB,EAAE,CAAC;IACtC,QAAQ,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;IACzC,6EAA6E;IAC7E,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,sBAAsB,KAAK,IAAI,CAAC;IACtD;kFAC8E;IAC9E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,qEAAqE;IACrE,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,oDAAoD;IACpD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0EAA0E;IAC1E,aAAa,CAAC,EAAE,SAAS,CAAC;IAC1B,6DAA6D;IAC7D,YAAY,CAAC,EAAE,SAAS,CAAC;IACzB,gFAAgF;IAChF,uBAAuB,CAAC,EAAE,SAAS,CAAC;IACpC;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,wBAAwB,KAAK,SAAS,CAAC;IAChF;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,SAAS,EAAE,kBAAkB,KAAK,SAAS,CAAC;IAC9D;;;OAGG;IACH,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,0EAA0E;IAC1E,iBAAiB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,KAAK,IAAI,CAAC;IAClD,2DAA2D;IAC3D,qBAAqB,CAAC,EAAE,0BAA0B,GAAG,IAAI,CAAC;CAC3D;AA6uCD,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,4BAA4B,2CAsC1E"}
@@ -15,10 +15,10 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
15
15
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
16
16
  import { ReactFlow, ReactFlowProvider, Background, BackgroundVariant, Controls, useReactFlow, useViewport, applyNodeChanges, } from '@xyflow/react';
17
17
  import { useTheme } from '@principal-ade/industry-theme';
18
- import { Map as MapIcon } from 'lucide-react';
18
+ import { Map as MapIcon, X } from 'lucide-react';
19
19
  import { IndustryMarkdownSlide } from 'themed-markdown';
20
20
  import { buildSubsystemGraph, MECHANISM_COLOR, subsystemGraphLayoutKey, } from './model';
21
- import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS } from './nodes';
21
+ import { SubsystemComponentNode, SubsystemEdge, SUBSYSTEM_CALLBACKS, hexWithAlpha, EDGE_DIM_ALPHA, fileMatchForNode, flowElementVisibility } from './nodes';
22
22
  import { SubsystemFileTree } from './SubsystemFileTree';
23
23
  import { GraphLayoutCover } from './GraphLayoutCover';
24
24
  import { ComponentDeclaration } from './ComponentDeclaration';
@@ -40,7 +40,7 @@ const DrawerContent = memo(function DrawerContent({ render, file, startLine, })
40
40
  return null;
41
41
  return _jsx(_Fragment, { children: render(file, startLine != null ? { startLine } : undefined) });
42
42
  });
43
- function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }) {
43
+ function Inner({ components, edges, throughlines, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, title, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, renderFileView, renderFileViewer, onFileSelect, onVerifyComponent, componentVerification }) {
44
44
  const { theme } = useTheme();
45
45
  const { fitView } = useReactFlow();
46
46
  const viewport = useViewport();
@@ -58,6 +58,16 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
58
58
  const [legendOpen, setLegendOpen] = useState(false);
59
59
  // Component the pointer is over (null on leave) → transient tree highlight.
60
60
  const [hoveredComponentId, setHoveredComponentId] = useState(null);
61
+ // Throughline focus — selected flow (or step) is full strength; other
62
+ // opened-flow members stay visible but dimmed; everything else is hidden.
63
+ const [focusedThroughlineId, setFocusedThroughlineId] = useState(null);
64
+ // `null` = whole flow focused; a number = that single step's edge focused.
65
+ const [focusedStepIndex, setFocusedStepIndex] = useState(null);
66
+ // Sidebar bottom half: which panel is shown when throughlines exist.
67
+ const [sidebarView, setSidebarView] = useState(() => throughlines?.length ? 'flows' : 'files');
68
+ // Throughline flows the user has expanded (via the title row). Closed by
69
+ // default so a graph with several flows doesn't dump every step list at once.
70
+ const [expandedThroughlines, setExpandedThroughlines] = useState(new Set());
61
71
  // Ref mirror of `selected` so the SUBSYSTEM_CALLBACKS click handler (a
62
72
  // closure over the effect deps) can toggle without a stale value.
63
73
  const selectedRef = useRef(null);
@@ -150,6 +160,9 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
150
160
  }
151
161
  const src = edgeById.get(edgeId);
152
162
  setSelectedEdgeId(edgeId);
163
+ // A direct edge selection on the canvas supersedes any throughline focus.
164
+ setFocusedThroughlineId(null);
165
+ setFocusedStepIndex(null);
153
166
  if (src)
154
167
  onEdgeSelect?.(src);
155
168
  }, [edgeById, onEdgeSelect]);
@@ -166,6 +179,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
166
179
  }
167
180
  setSelected(comp);
168
181
  setSelectedEdgeId(null);
182
+ setFocusedThroughlineId(null);
183
+ setFocusedStepIndex(null);
169
184
  onSelect?.(id);
170
185
  }
171
186
  };
@@ -182,45 +197,151 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
182
197
  const { nodes, edges: convertedEdges } = built;
183
198
  const xyflowNodesBase = nodes;
184
199
  const baseEdges = convertedEdges;
200
+ // When an edge is selected, dim every other edge + its label to focus it.
201
+ const [selectedEdgeId, setSelectedEdgeId] = useState(null);
202
+ // Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
203
+ // without a stale closure value.
204
+ const selectedEdgeIdRef = useRef(null);
205
+ selectedEdgeIdRef.current = selectedEdgeId;
206
+ // Edge ids in throughline focus (an active flow's edge set, or a single
207
+ // step's edge). Used to frame the camera. `null` = no throughline focus.
208
+ const focusEdgeIds = useMemo(() => {
209
+ if (focusedThroughlineId == null || !throughlines)
210
+ return null;
211
+ const tl = throughlines.find((t) => t.id === focusedThroughlineId);
212
+ if (!tl)
213
+ return null;
214
+ if (focusedStepIndex != null) {
215
+ const step = tl.steps[focusedStepIndex];
216
+ return step ? new Set([step.edgeId]) : null;
217
+ }
218
+ return new Set(tl.steps.map((s) => s.edgeId));
219
+ }, [throughlines, focusedThroughlineId, focusedStepIndex]);
220
+ // 1-based step numbers per edge of the selected flow (an edge can appear
221
+ // in more than one step).
222
+ const selectedFlowStepNos = useMemo(() => {
223
+ if (focusedThroughlineId == null || !throughlines)
224
+ return null;
225
+ const tl = throughlines.find((t) => t.id === focusedThroughlineId);
226
+ if (!tl)
227
+ return null;
228
+ const map = new Map();
229
+ tl.steps.forEach((s, i) => {
230
+ const list = map.get(s.edgeId) ?? [];
231
+ list.push(i + 1);
232
+ map.set(s.edgeId, list);
233
+ });
234
+ return map;
235
+ }, [throughlines, focusedThroughlineId]);
236
+ // Union of every expanded (opened) throughline's edges — the visible set.
237
+ const openedEdgeIds = useMemo(() => {
238
+ if (!throughlines || expandedThroughlines.size === 0)
239
+ return null;
240
+ const ids = new Set();
241
+ for (const tl of throughlines) {
242
+ if (!expandedThroughlines.has(tl.id))
243
+ continue;
244
+ for (const s of tl.steps)
245
+ ids.add(s.edgeId);
246
+ }
247
+ return ids.size > 0 ? ids : null;
248
+ }, [throughlines, expandedThroughlines]);
249
+ const endpointsOf = (edgeIds) => {
250
+ if (!edgeIds)
251
+ return null;
252
+ const ids = new Set();
253
+ for (const e of baseEdges) {
254
+ if (!edgeIds.has(e.id))
255
+ continue;
256
+ ids.add(e.source);
257
+ ids.add(e.target);
258
+ }
259
+ return ids.size > 0 ? ids : null;
260
+ };
261
+ const openedNodeIds = useMemo(() => endpointsOf(openedEdgeIds),
262
+ // eslint-disable-next-line react-hooks/exhaustive-deps
263
+ [baseEdges, openedEdgeIds]);
264
+ // Endpoints of the bright set: the selected step's edge, or the whole flow
265
+ // when no step is focused.
266
+ const brightNodeIds = useMemo(() => endpointsOf(focusEdgeIds),
267
+ // eslint-disable-next-line react-hooks/exhaustive-deps
268
+ [baseEdges, focusEdgeIds]);
269
+ // Source + target of the focused step/flow (or a canvas-selected edge). If a
270
+ // file is open, those endpoints stay undimmed even when they don't live in
271
+ // that file.
272
+ const focusNodeIds = useMemo(() => {
273
+ if (brightNodeIds)
274
+ return brightNodeIds;
275
+ if (selectedEdgeId) {
276
+ const e = baseEdges.find((x) => x.id === selectedEdgeId);
277
+ if (e)
278
+ return new Set([e.source, e.target]);
279
+ }
280
+ return null;
281
+ }, [baseEdges, brightNodeIds, selectedEdgeId]);
185
282
  // While a file is open in the drawer, tag each node with whether its
186
283
  // component lives in that file — the node renderer spotlights matches and
187
284
  // dims non-matches (mirrors the edge-dimming behavior on selection).
285
+ // Opened-but-unselected members are dimmed; a selected step further dims
286
+ // the rest of its own flow. Nodes not on any opened flow are hidden.
188
287
  // `isSelected` rides in data because the node's stopPropagation() keeps
189
- // React Flow's own selection state from ever updating.
288
+ // React Flow's own selection state from updating.
190
289
  const dispNodes = useMemo(() => {
191
290
  return xyflowNodesBase.map((n) => {
192
291
  const comp = n.data?.component;
193
- const fileMatch = openFile ? comp?.file === openFile : undefined;
292
+ const fileMatch = fileMatchForNode(comp?.file, openFile, focusNodeIds?.has(n.id) === true);
194
293
  const isSelected = selected?.id !== undefined && comp?.id === selected.id;
195
- if (fileMatch === undefined && !isSelected) {
196
- const { fileMatch: _f, isSelected: _s, ...rest } = n.data;
197
- return { ...n, data: rest };
294
+ const vis = flowElementVisibility({
295
+ inOpened: openedNodeIds?.has(n.id) === true,
296
+ inSelected: brightNodeIds?.has(n.id) === true,
297
+ anyOpened: openedNodeIds != null,
298
+ anySelected: brightNodeIds != null,
299
+ });
300
+ if (fileMatch === undefined && !isSelected && !vis.dimmed) {
301
+ const { fileMatch: _f, isSelected: _s, dimmed: _d, ...rest } = n.data;
302
+ return { ...n, hidden: vis.hidden, data: rest };
198
303
  }
199
304
  return {
200
305
  ...n,
201
- data: { ...n.data, ...(fileMatch !== undefined && { fileMatch }), ...(isSelected && { isSelected }) },
306
+ hidden: vis.hidden,
307
+ data: {
308
+ ...n.data,
309
+ ...(fileMatch !== undefined && { fileMatch }),
310
+ ...(isSelected && { isSelected }),
311
+ ...(vis.dimmed && { dimmed: true }),
312
+ },
202
313
  };
203
314
  });
204
- }, [xyflowNodesBase, openFile, selected]);
315
+ }, [xyflowNodesBase, openFile, selected, focusNodeIds, openedNodeIds, brightNodeIds]);
205
316
  const baseNodesKey = useMemo(() => nodes.map((n) => n.id).sort().join(','), [nodes]);
206
317
  const baseEdgesKey = useMemo(() => convertedEdges.map((e) => e.id).sort().join(','), [convertedEdges]);
207
- // When an edge is selected, dim every other edge + its label to focus it.
208
- const [selectedEdgeId, setSelectedEdgeId] = useState(null);
209
- // Ref mirror so `selectEdge` (a useCallback over early deps) can toggle
210
- // without a stale closure value.
211
- const selectedEdgeIdRef = useRef(null);
212
- selectedEdgeIdRef.current = selectedEdgeId;
213
318
  const dispEdges = useMemo(() => {
319
+ const paint = (e, dimmed) => {
320
+ const markerEnd = e.markerEnd;
321
+ const nextMarker = dimmed && markerEnd && typeof markerEnd === 'object' && typeof markerEnd.color === 'string'
322
+ ? { ...markerEnd, color: hexWithAlpha(markerEnd.color, EDGE_DIM_ALPHA) }
323
+ : markerEnd;
324
+ return {
325
+ ...e,
326
+ data: { ...e.data, dimmed },
327
+ markerEnd: nextMarker,
328
+ };
329
+ };
330
+ if (openedEdgeIds || focusEdgeIds) {
331
+ return baseEdges.map((e) => {
332
+ const vis = flowElementVisibility({
333
+ inOpened: openedEdgeIds?.has(e.id) === true,
334
+ inSelected: focusEdgeIds?.has(e.id) === true,
335
+ anyOpened: openedEdgeIds != null,
336
+ anySelected: focusEdgeIds != null,
337
+ });
338
+ return { ...paint(e, vis.dimmed), hidden: vis.hidden };
339
+ });
340
+ }
214
341
  if (!selectedEdgeId)
215
342
  return baseEdges;
216
- return baseEdges.map((e) => ({
217
- ...e,
218
- data: {
219
- ...e.data,
220
- dimmed: e.id !== selectedEdgeId,
221
- },
222
- }));
223
- }, [baseEdges, selectedEdgeId]);
343
+ return baseEdges.map((e) => paint(e, e.id !== selectedEdgeId));
344
+ }, [baseEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds]);
224
345
  const onNodesChange = useCallback((changes) => {
225
346
  // Capture dimension changes (React Flow's measurement callback).
226
347
  for (const ch of changes) {
@@ -250,6 +371,8 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
250
371
  const onNodeClick = useCallback((_e, node) => {
251
372
  const comp = node.data?.component;
252
373
  setSelectedEdgeId(null);
374
+ setFocusedThroughlineId(null);
375
+ setFocusedStepIndex(null);
253
376
  if (node.type === 'subsystem-component' && comp) {
254
377
  // Clicking the already-selected node unselects it (toggle off).
255
378
  // Selection is independent of the file drawer — nodes never open it.
@@ -269,10 +392,13 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
269
392
  const onPaneClick = useCallback(() => {
270
393
  setSelected(null);
271
394
  setSelectedEdgeId(null);
395
+ setFocusedThroughlineId(null);
396
+ setFocusedStepIndex(null);
272
397
  }, []);
273
398
  // Sidebar file trees — one per repo on multi-repo graphs, each under its
274
399
  // own owner-avatar header. Clicking a header collapses that repo's tree.
275
400
  const repoGroups = useMemo(() => buildRepoGroups(components), [components]);
401
+ const hasThroughlines = useMemo(() => (throughlines?.length ?? 0) > 0, [throughlines]);
276
402
  const [collapsedRepos, setCollapsedRepos] = useState(new Set());
277
403
  const toggleRepoCollapsed = useCallback((key) => {
278
404
  setCollapsedRepos((prev) => {
@@ -303,6 +429,62 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
303
429
  setOpenFileTarget({ file, startLine });
304
430
  onFileSelect?.(file);
305
431
  }, [onFileSelect]);
432
+ // Camera helper shared by the throughline interactions: frames the focused
433
+ // edges' endpoint nodes via `fitView({ nodes })`, which uses the store's
434
+ // live positions + measured dims — so the frame always includes BOTH
435
+ // components the edge attaches to (and therefore the edge line between them).
436
+ const fitFocusBounds = useCallback((ids) => {
437
+ const nodeIds = new Set();
438
+ for (const e of baseEdges) {
439
+ if (!ids.has(e.id))
440
+ continue;
441
+ nodeIds.add(e.source);
442
+ nodeIds.add(e.target);
443
+ }
444
+ if (nodeIds.size === 0)
445
+ return;
446
+ fitView({
447
+ nodes: [...nodeIds].map((id) => ({ id })),
448
+ padding: 0.25,
449
+ duration: 300,
450
+ });
451
+ }, [baseEdges, fitView]);
452
+ // Focus an entire flow: hide everything but the flow's nodes and edges, and
453
+ // frame the flow on the canvas. Selection state is cleared — the graph now
454
+ // reads as the narrative.
455
+ const focusThroughlineEdges = useCallback((tl) => {
456
+ setSelected(null);
457
+ setSelectedEdgeId(null);
458
+ setFocusedStepIndex(null);
459
+ setFocusedThroughlineId(tl.id);
460
+ fitFocusBounds(new Set(tl.steps.map((s) => s.edgeId)));
461
+ }, [fitFocusBounds]);
462
+ const clearThroughlineFocus = useCallback(() => {
463
+ setFocusedThroughlineId(null);
464
+ setFocusedStepIndex(null);
465
+ }, []);
466
+ // Focus a single step's edge on the canvas. The step's file:line is listed
467
+ // in the row; we don't open the drawer from here.
468
+ const focusThroughlineStep = useCallback((tl, stepIndex) => {
469
+ const step = tl.steps[stepIndex];
470
+ if (!step)
471
+ return;
472
+ setSelected(null);
473
+ setSelectedEdgeId(null);
474
+ setFocusedStepIndex(stepIndex);
475
+ setFocusedThroughlineId(tl.id);
476
+ fitFocusBounds(new Set([step.edgeId]));
477
+ }, [fitFocusBounds]);
478
+ const toggleThroughlineCollapsed = useCallback((tlId) => {
479
+ setExpandedThroughlines((prev) => {
480
+ const next = new Set(prev);
481
+ if (next.has(tlId))
482
+ next.delete(tlId);
483
+ else
484
+ next.add(tlId);
485
+ return next;
486
+ });
487
+ }, []);
306
488
  // Filename-badge clicks on nodes open the drawer through the same path as
307
489
  // the declaration panel's file link (toggle + tree sync, no start line).
308
490
  useEffect(() => {
@@ -327,13 +509,17 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
327
509
  return;
328
510
  setSelected(comp);
329
511
  setSelectedEdgeId(null);
512
+ setFocusedThroughlineId(null);
513
+ setFocusedStepIndex(null);
330
514
  onSelect?.(comp.id);
331
515
  }, [components, onSelect]);
332
516
  // Edge label data for the overlay (rendered OUTSIDE ReactFlow so the pane
333
517
  // doesn't intercept pointer events). Uses ELK-computed label midpoints from
334
518
  // the actual edge path (not node-center approximations).
335
519
  const edgeLabels = useMemo(() => {
336
- return dispEdges.map((e) => {
520
+ return dispEdges
521
+ .filter((e) => !e.hidden)
522
+ .map((e) => {
337
523
  const d = e.data;
338
524
  return {
339
525
  id: e.id,
@@ -341,9 +527,10 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
341
527
  dimmed: d?.dimmed === true,
342
528
  midX: d?.labelX ?? 0,
343
529
  midY: d?.labelY ?? 0,
530
+ stepNos: selectedFlowStepNos?.get(e.id),
344
531
  };
345
532
  });
346
- }, [dispEdges]);
533
+ }, [dispEdges, selectedFlowStepNos]);
347
534
  const usedMechanisms = useMemo(() => {
348
535
  return new Set(edgeLabels.map((l) => l.mechanism));
349
536
  }, [edgeLabels]);
@@ -372,7 +559,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
372
559
  const fileViewerRef = useRef(fileViewer);
373
560
  fileViewerRef.current = fileViewer;
374
561
  const renderDrawerContent = useCallback((file, opts) => fileViewerRef.current?.(file, opts) ?? null, []);
375
- return (_jsxs("div", { style: { width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }, children: [(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0) && (_jsxs("div", { style: {
562
+ return (_jsxs("div", { style: { width: '100%', height: '100%', display: 'flex', flexDirection: 'row' }, children: [(title || description || sidebarExtra || sidebarAfterDescription || treeFilePaths.length > 0 || hasThroughlines) && (_jsxs("div", { style: {
376
563
  width: 340,
377
564
  minWidth: 340,
378
565
  borderRight: `1px solid ${theme.colors.border}`,
@@ -394,7 +581,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
394
581
  fontWeight: 600,
395
582
  color: theme.colors.text,
396
583
  fontFamily: theme.fonts.heading,
397
- }, children: title })), description && (_jsx("div", { style: { fontSize: theme.fontSizes[0], lineHeight: 1.5 }, children: _jsx(IndustryMarkdownSlide, { content: description, slideIdPrefix: "subsystem-desc", slideIndex: 0, isVisible: true, theme: theme, disableScroll: true, fontSizeScale: 0.9, enableKeyboardScrolling: false, autoFocusOnVisible: false }) })), sidebarAfterDescription] }), treeFilePaths.length > 0 && (_jsx("div", { style: {
584
+ }, children: title })), description && (_jsx("div", { style: { fontSize: theme.fontSizes[0], lineHeight: 1.5 }, children: _jsx(IndustryMarkdownSlide, { content: description, slideIdPrefix: "subsystem-desc", slideIndex: 0, isVisible: true, theme: theme, disableScroll: true, fontSizeScale: 0.9, enableKeyboardScrolling: false, autoFocusOnVisible: false }) })), sidebarAfterDescription] }), (treeFilePaths.length > 0 || hasThroughlines) && (_jsxs("div", { style: {
398
585
  height: '50%',
399
586
  minHeight: 160,
400
587
  flexShrink: 0,
@@ -402,17 +589,42 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
402
589
  display: 'flex',
403
590
  flexDirection: 'column',
404
591
  overflow: 'hidden',
405
- }, children: repoGroups.groups.map((group, i) => {
406
- const groupKey = group.repoKey ?? '__no-repo__';
407
- const collapsed = collapsedRepos.has(groupKey);
408
- return (_jsxs("div", { style: {
409
- flex: collapsed ? '0 0 auto' : 1,
410
- minHeight: collapsed ? 0 : undefined,
592
+ }, children: [hasThroughlines && (_jsx("div", { role: "tablist", "aria-label": "Sidebar view", style: {
411
593
  display: 'flex',
412
- flexDirection: 'column',
413
- borderTop: i > 0 ? `1px solid ${theme.colors.border}` : undefined,
414
- }, children: [repoGroups.multiRepo && (_jsx(RepoGroupHeader, { group: group, collapsed: collapsed, onToggle: () => toggleRepoCollapsed(groupKey) })), !collapsed && (_jsx(SubsystemFileTree, { files: group.entries.map((e) => e.displayPath), selectedFile: selected?.file ?? openFile, hoveredFile: hoveredFile, onSelectFile: onTreeSelectFile, headerless: repoGroups.multiRepo }))] }, groupKey));
415
- }) }))] })), _jsxs("div", { style: { flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0 }, children: [_jsxs("div", { style: { position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }, children: [showEdgeLabels !== false && (_jsx("div", { style: {
594
+ width: '100%',
595
+ flexShrink: 0,
596
+ borderBottom: `1px solid ${theme.colors.border}`,
597
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
598
+ }, children: ['files', 'flows'].map((view) => (_jsx("button", { type: "button", role: "tab", "aria-selected": sidebarView === view, onClick: () => setSidebarView(view), style: {
599
+ flex: 1,
600
+ minWidth: 0,
601
+ padding: '8px 8px',
602
+ border: 'none',
603
+ borderRadius: 0,
604
+ background: sidebarView === view ? theme.colors.background : 'transparent',
605
+ color: sidebarView === view
606
+ ? theme.colors.text
607
+ : theme.colors.textSecondary,
608
+ fontSize: theme.fontSizes[1],
609
+ fontFamily: theme.fonts.monospace,
610
+ textTransform: 'capitalize',
611
+ cursor: 'pointer',
612
+ }, children: view }, view))) })), sidebarView === 'flows' && throughlines && throughlines.length > 0 ? (_jsx("div", { style: {
613
+ flex: 1,
614
+ minHeight: 0,
615
+ overflowY: 'auto',
616
+ padding: '0 4px 12px',
617
+ }, children: throughlines.map((tl) => (_jsx(ThroughlineFlow, { throughline: tl, edges: edges, collapsed: !expandedThroughlines.has(tl.id), active: focusedThroughlineId === tl.id ? { stepIndex: focusedStepIndex } : null, onToggleCollapsed: toggleThroughlineCollapsed, onFocusFlow: focusThroughlineEdges, onClearFocus: clearThroughlineFocus, onFocusStep: focusThroughlineStep }, tl.id))) })) : treeFilePaths.length > 0 ? (_jsx(_Fragment, { children: repoGroups.groups.map((group, i) => {
618
+ const groupKey = group.repoKey ?? '__no-repo__';
619
+ const collapsed = collapsedRepos.has(groupKey);
620
+ return (_jsxs("div", { style: {
621
+ flex: collapsed ? '0 0 auto' : 1,
622
+ minHeight: collapsed ? 0 : undefined,
623
+ display: 'flex',
624
+ flexDirection: 'column',
625
+ borderTop: i > 0 ? `1px solid ${theme.colors.border}` : undefined,
626
+ }, children: [repoGroups.multiRepo && (_jsx(RepoGroupHeader, { group: group, collapsed: collapsed, onToggle: () => toggleRepoCollapsed(groupKey) })), !collapsed && (_jsx(SubsystemFileTree, { files: group.entries.map((e) => e.displayPath), selectedFile: selected?.file ?? openFile, hoveredFile: hoveredFile, onSelectFile: onTreeSelectFile, headerless: repoGroups.multiRepo }))] }, groupKey));
627
+ }) })) : null] }))] })), _jsxs("div", { style: { flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0 }, children: [_jsxs("div", { style: { position: 'relative', flex: 1, minHeight: 0, display: 'flex', flexDirection: 'column' }, children: [showEdgeLabels !== false && (_jsx("div", { style: {
416
628
  position: 'absolute',
417
629
  inset: 0,
418
630
  overflow: 'hidden',
@@ -431,6 +643,10 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
431
643
  position: 'absolute',
432
644
  left: screenX,
433
645
  top: screenY,
646
+ // Center on the flow-space midpoint. Labels live in screen
647
+ // space (fixed size when zoomed out), so top-left anchoring
648
+ // would drift them right/down of the edge as zoom drops.
649
+ transform: 'translate(-50%, -50%)',
434
650
  fontSize: 10,
435
651
  fontFamily: theme.fonts.monospace,
436
652
  fontWeight: 500,
@@ -443,7 +659,7 @@ function Inner({ components, edges, onSelect, onEdgeSelect, measured: _measured,
443
659
  pointerEvents: 'auto',
444
660
  opacity: lbl.dimmed ? 0.15 : 1,
445
661
  whiteSpace: 'nowrap',
446
- }, children: lbl.mechanism }, lbl.id));
662
+ }, children: lbl.stepNos?.length ? `${lbl.stepNos.map((n) => `${n}:`).join(' ')} ${lbl.mechanism}` : lbl.mechanism }, lbl.id));
447
663
  }) })), _jsxs(ReactFlow, { nodes: dispNodes, edges: dispEdges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, minZoom: 0.05, maxZoom: 4, onNodeClick: onNodeClick, onEdgeClick: onEdgeClick, onNodesChange: onNodesChange, onEdgesChange: onEdgesChange, proOptions: { hideAttribution: true }, nodesDraggable: false, elementsSelectable: true, selectNodesOnDrag: false, nodesConnectable: false, edgesReconnectable: false, onPaneClick: onPaneClick, panOnDrag: true, panOnScroll: true, zoomOnScroll: false, zoomOnPinch: true, zoomOnDoubleClick: false, style: {
448
664
  width: '100%',
449
665
  height: '100%',
@@ -521,6 +737,130 @@ function RepoGroupHeader({ group, collapsed, onToggle, }) {
521
737
  whiteSpace: 'nowrap',
522
738
  }, children: label })] }));
523
739
  }
740
+ /** One collapsible throughline in the sidebar's flows panel. Clicking the
741
+ * title: closed → open + select; open and unselected → select; open and
742
+ * selected → close + clear focus. The right-aligned close button collapses
743
+ * without selecting. A step row focuses that step's edge. */
744
+ function ThroughlineFlow({ throughline, edges, collapsed, active, onToggleCollapsed, onFocusFlow, onClearFocus, onFocusStep, }) {
745
+ const { theme } = useTheme();
746
+ const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
747
+ const hoverBg = theme.colors.background;
748
+ const edgeById = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
749
+ const wholeFlowActive = active !== null && active.stepIndex === null;
750
+ const [headerHover, setHeaderHover] = useState(false);
751
+ const [closeHover, setCloseHover] = useState(false);
752
+ const [hoveredStep, setHoveredStep] = useState(null);
753
+ return (_jsxs("div", { style: { margin: '4px 0', borderRadius: 8 }, children: [_jsxs("div", { onMouseEnter: () => setHeaderHover(true), onMouseLeave: () => setHeaderHover(false), style: {
754
+ display: 'flex',
755
+ alignItems: 'center',
756
+ gap: 4,
757
+ borderRadius: 6,
758
+ background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
759
+ transition: 'background 120ms ease',
760
+ }, children: [_jsx("button", { type: "button", onClick: () => {
761
+ if (collapsed) {
762
+ onToggleCollapsed(throughline.id);
763
+ onFocusFlow(throughline);
764
+ }
765
+ else if (active === null) {
766
+ onFocusFlow(throughline);
767
+ }
768
+ else {
769
+ onToggleCollapsed(throughline.id);
770
+ onClearFocus();
771
+ }
772
+ }, style: {
773
+ flex: 1,
774
+ display: 'flex',
775
+ alignItems: 'center',
776
+ minWidth: 0,
777
+ padding: '6px 8px',
778
+ borderRadius: 6,
779
+ border: 'none',
780
+ background: 'transparent',
781
+ textAlign: 'left',
782
+ cursor: 'pointer',
783
+ }, children: _jsx("span", { style: {
784
+ overflow: 'hidden',
785
+ textOverflow: 'ellipsis',
786
+ whiteSpace: 'nowrap',
787
+ fontSize: theme.fontSizes[1],
788
+ fontFamily: theme.fonts.monospace,
789
+ fontWeight: 600,
790
+ color: theme.colors.text,
791
+ }, children: throughline.title }) }), !collapsed && (_jsx("button", { type: "button", "aria-label": `Close ${throughline.title}`, onMouseEnter: () => setCloseHover(true), onMouseLeave: () => setCloseHover(false), onClick: (e) => {
792
+ e.stopPropagation();
793
+ onToggleCollapsed(throughline.id);
794
+ if (active !== null)
795
+ onClearFocus();
796
+ }, style: {
797
+ display: 'inline-flex',
798
+ alignItems: 'center',
799
+ justifyContent: 'center',
800
+ flexShrink: 0,
801
+ width: 22,
802
+ height: 22,
803
+ marginRight: 4,
804
+ padding: 0,
805
+ border: 'none',
806
+ borderRadius: 4,
807
+ background: closeHover ? theme.colors.border : 'transparent',
808
+ color: closeHover ? theme.colors.text : muted,
809
+ cursor: 'pointer',
810
+ transition: 'background 120ms ease, color 120ms ease',
811
+ }, children: _jsx(X, { size: 12, strokeWidth: 2 }) }))] }), !collapsed && (_jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: 1 }, children: throughline.steps.map((step, i) => {
812
+ const edge = edgeById.get(step.edgeId);
813
+ const mech = edge?.mechanism;
814
+ const color = mech ? MECHANISM_COLOR[mech] : muted;
815
+ const stepActive = active !== null && active.stepIndex === i;
816
+ return (_jsxs("button", { type: "button", onMouseEnter: () => setHoveredStep(i), onMouseLeave: () => setHoveredStep(null), onClick: () => onFocusStep(throughline, i), style: {
817
+ display: 'flex',
818
+ alignItems: 'center',
819
+ gap: 8,
820
+ minWidth: 0,
821
+ padding: '4px 8px 4px 12px',
822
+ textAlign: 'left',
823
+ borderRadius: 6,
824
+ border: 'none',
825
+ background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
826
+ cursor: 'pointer',
827
+ transition: 'background 120ms ease',
828
+ }, children: [_jsx("span", { style: {
829
+ flexShrink: 0,
830
+ width: 14,
831
+ fontSize: theme.fontSizes[0] * 0.8,
832
+ fontFamily: theme.fonts.monospace,
833
+ color: stepActive ? theme.colors.text : muted,
834
+ }, children: i + 1 }), step.symbol ? (_jsx("span", { style: {
835
+ flex: 1,
836
+ minWidth: 0,
837
+ overflow: 'hidden',
838
+ textOverflow: 'ellipsis',
839
+ whiteSpace: 'nowrap',
840
+ fontSize: theme.fontSizes[0],
841
+ fontFamily: theme.fonts.monospace,
842
+ color: stepActive ? theme.colors.text : muted,
843
+ }, children: step.symbol })) : (_jsxs(_Fragment, { children: [_jsx("span", { style: {
844
+ flexShrink: 0,
845
+ width: 74,
846
+ overflow: 'hidden',
847
+ textOverflow: 'ellipsis',
848
+ whiteSpace: 'nowrap',
849
+ fontSize: theme.fontSizes[0],
850
+ fontFamily: theme.fonts.monospace,
851
+ color,
852
+ }, children: mech ?? step.edgeId }), _jsxs("span", { style: {
853
+ flex: 1,
854
+ minWidth: 0,
855
+ overflow: 'hidden',
856
+ textOverflow: 'ellipsis',
857
+ whiteSpace: 'nowrap',
858
+ fontSize: theme.fontSizes[0],
859
+ fontFamily: theme.fonts.monospace,
860
+ color: stepActive ? theme.colors.text : muted,
861
+ }, children: [step.file.split('/').pop(), _jsxs("span", { style: { opacity: 0.7 }, children: [":", step.line] })] })] }))] }, `${step.edgeId}-${i}`));
862
+ }) }))] }));
863
+ }
524
864
  export function SubsystemComponentGraph(props) {
525
865
  const wrapRef = useRef(null);
526
866
  const [size, setSize] = useState(null);