@principal-ai/subsystems-react 0.55.1 → 0.55.2

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 (105) hide show
  1. package/README.md +2 -1
  2. package/dist/graphify/index.d.ts +2 -0
  3. package/dist/graphify/index.d.ts.map +1 -1
  4. package/dist/graphify/index.js +1 -0
  5. package/dist/graphify/index.js.map +1 -1
  6. package/dist/graphify/symbolFocus.d.ts +49 -0
  7. package/dist/graphify/symbolFocus.d.ts.map +1 -0
  8. package/dist/graphify/symbolFocus.js +139 -0
  9. package/dist/graphify/symbolFocus.js.map +1 -0
  10. package/dist/index.d.ts +5 -3
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +2 -1
  13. package/dist/index.js.map +1 -1
  14. package/dist/stories/Canvases/Aggregate/aggregateViewFixture.d.ts.map +1 -0
  15. package/dist/stories/Canvases/Aggregate/aggregateViewFixture.js.map +1 -0
  16. package/dist/stories/Canvases/Subsystem/fixtures.d.ts.map +1 -0
  17. package/dist/stories/Canvases/Subsystem/fixtures.js.map +1 -0
  18. package/dist/subsystem/SubsystemComponentGraph.d.ts +35 -1
  19. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  20. package/dist/subsystem/SubsystemComponentGraph.js +248 -33
  21. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  22. package/dist/subsystem/SymbolFocusedView.d.ts +30 -0
  23. package/dist/subsystem/SymbolFocusedView.d.ts.map +1 -0
  24. package/dist/subsystem/SymbolFocusedView.js +24 -0
  25. package/dist/subsystem/SymbolFocusedView.js.map +1 -0
  26. package/dist/subsystem/TrailsPanel.d.ts +16 -1
  27. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  28. package/dist/subsystem/TrailsPanel.js +64 -8
  29. package/dist/subsystem/TrailsPanel.js.map +1 -1
  30. package/dist/subsystem/graphChrome.d.ts +13 -0
  31. package/dist/subsystem/graphChrome.d.ts.map +1 -1
  32. package/dist/subsystem/graphChrome.js +19 -1
  33. package/dist/subsystem/graphChrome.js.map +1 -1
  34. package/dist/subsystem/model.d.ts +40 -4
  35. package/dist/subsystem/model.d.ts.map +1 -1
  36. package/dist/subsystem/model.js +38 -7
  37. package/dist/subsystem/model.js.map +1 -1
  38. package/package.json +2 -2
  39. package/src/graphify/index.ts +6 -0
  40. package/src/graphify/symbolFocus.test.ts +163 -0
  41. package/src/graphify/symbolFocus.ts +190 -0
  42. package/src/index.ts +7 -0
  43. package/src/stories/{Subsystem/AggregateGraph → Canvases/Aggregate}/AggregateFrameGraph.stories.tsx +1 -1
  44. package/src/stories/{Subsystem/C4Graph → Canvases/C4}/C4CardGrow.stories.tsx +1 -1
  45. package/src/stories/{Subsystem/C4Graph → Canvases/C4}/C4Component.stories.tsx +1 -1
  46. package/src/stories/{Subsystem/C4Graph → Canvases/C4}/C4Container.stories.tsx +1 -1
  47. package/src/stories/{Subsystem/C4Graph → Canvases/C4}/C4NodeGallery.stories.tsx +1 -1
  48. package/src/stories/{Subsystem/C4Graph → Canvases/C4}/C4ToComponentDrill.stories.tsx +1 -1
  49. package/src/stories/{Subsystem/C4Graph → Canvases/C4}/C4Transition.stories.tsx +1 -1
  50. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/AnimatedProgression.stories.tsx +1 -1
  51. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Appearance.stories.tsx +1 -1
  52. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Basics.stories.tsx +1 -1
  53. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Captures.stories.tsx +1 -1
  54. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Constructs.stories.tsx +1 -1
  55. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/CustomEntities.stories.tsx +1 -1
  56. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Deprecated.stories.tsx +1 -1
  57. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/DetailPanel.stories.tsx +1 -1
  58. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Diagnostics.stories.tsx +1 -1
  59. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/EdgeViews.stories.tsx +1 -1
  60. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/FrameworkStereotype.stories.tsx +1 -1
  61. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/GraphTitle.stories.tsx +1 -1
  62. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Issues.stories.tsx +1 -1
  63. package/src/stories/Canvases/Subsystem/LayoutDirection.stories.tsx +122 -0
  64. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/ModuleBadges.stories.tsx +1 -1
  65. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/ModulePathNesting.stories.tsx +1 -1
  66. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Modules.stories.tsx +1 -1
  67. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/NodeAnatomy.stories.tsx +1 -1
  68. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Packages.stories.tsx +1 -1
  69. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Processes.stories.tsx +1 -1
  70. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Proposed.stories.tsx +1 -1
  71. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/ProposedTrails.stories.tsx +1 -1
  72. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Reorder.stories.tsx +1 -1
  73. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Scenarios.stories.tsx +1 -1
  74. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Spotlights.stories.tsx +1 -1
  75. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/StoreValueType.stories.tsx +1 -1
  76. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/TrailAutoplay.stories.tsx +1 -1
  77. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/Trails.stories.tsx +1 -1
  78. package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/WorkspacePackageFrames.stories.tsx +1 -1
  79. package/src/stories/Canvases/SymbolFocusedView/SymbolFocusedView.stories.tsx +163 -0
  80. package/src/stories/{Subsystem → Components}/AgentsPanel.stories.tsx +1 -1
  81. package/src/stories/{Subsystem/ComponentDeclarationAudit.stories.tsx → Components/ComponentDeclaration/Audit.stories.tsx} +4 -4
  82. package/src/stories/{Subsystem/ComponentDeclarationSymbols.stories.tsx → Components/ComponentDeclaration/Symbols.stories.tsx} +5 -5
  83. package/src/stories/{Subsystem → Components}/ConstructsCatalog.stories.tsx +1 -1
  84. package/src/stories/{Subsystem/Sidebar → Components}/GraphSidebar.stories.tsx +4 -4
  85. package/src/stories/{Subsystem/IssueIcons.stories.tsx → Components/IssueList.stories.tsx} +1 -1
  86. package/src/stories/{Subsystem/StoreValueTypeDeclaration.stories.tsx → Components/StoreValueType.stories.tsx} +1 -1
  87. package/src/subsystem/SubsystemComponentGraph.tsx +331 -25
  88. package/src/subsystem/SymbolFocusedView.tsx +80 -0
  89. package/src/subsystem/TrailsPanel.tsx +107 -4
  90. package/src/subsystem/graphChrome.tsx +22 -1
  91. package/src/subsystem/layoutDirection.test.ts +26 -0
  92. package/src/subsystem/model.test.ts +17 -0
  93. package/src/subsystem/model.ts +70 -10
  94. package/dist/stories/Subsystem/AggregateGraph/aggregateViewFixture.d.ts.map +0 -1
  95. package/dist/stories/Subsystem/AggregateGraph/aggregateViewFixture.js.map +0 -1
  96. package/dist/stories/Subsystem/ComponentGraph/fixtures.d.ts.map +0 -1
  97. package/dist/stories/Subsystem/ComponentGraph/fixtures.js.map +0 -1
  98. package/src/stories/Subsystem/EgoGraph/EgoGraph.stories.tsx +0 -351
  99. /package/dist/stories/{Subsystem/AggregateGraph → Canvases/Aggregate}/aggregateViewFixture.d.ts +0 -0
  100. /package/dist/stories/{Subsystem/AggregateGraph → Canvases/Aggregate}/aggregateViewFixture.js +0 -0
  101. /package/dist/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/fixtures.d.ts +0 -0
  102. /package/dist/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/fixtures.js +0 -0
  103. /package/src/graphify/__fixtures__/{ego-samples.json → symbol-focused-samples.json} +0 -0
  104. /package/src/stories/{Subsystem/AggregateGraph → Canvases/Aggregate}/aggregateViewFixture.ts +0 -0
  105. /package/src/stories/{Subsystem/ComponentGraph → Canvases/Subsystem}/fixtures.ts +0 -0
@@ -18,9 +18,9 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
18
18
  import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
19
19
  import { ReactFlow, ReactFlowProvider, useReactFlow, useViewport, applyNodeChanges, } from '@xyflow/react';
20
20
  import { useTheme } from '@principal-ade/industry-theme';
21
- import { FileText, X } from 'lucide-react';
21
+ import { FileText, ArrowDown, ArrowRight, Unlink, X } from 'lucide-react';
22
22
  import { IndustryMarkdownSlide } from 'themed-markdown';
23
- import { buildSubsystemGraph, deriveGraphEdges, isConstructsOnlyModel, moduleGroupNodeId, processGroupNodeId, isTrailMechanism, edgeColor, MECHANISM_DESCRIPTIONS, subsystemGraphLayoutKey, trailStepGraphEdgeId, } from './model';
23
+ import { buildSubsystemGraph, deriveGraphEdges, isConstructsOnlyModel, moduleGroupNodeId, processGroupNodeId, isTrailMechanism, edgeColor, MECHANISM_DESCRIPTIONS, subsystemGraphLayoutKey, trailStepGraphEdgeId, unroutedEdgeIds, } from './model';
24
24
  import { ConstructsCatalog } from './ConstructsCatalog';
25
25
  import { SubsystemAgentsPanel, } from './AgentsPanel';
26
26
  import { MaintainLivePanel, } from '../components/maintain-events/MaintainLivePanel';
@@ -29,7 +29,7 @@ import { SubsystemDiagnosticToggle } from './DiagnosticToggle';
29
29
  import { SubsystemIssueList, issueCategory, issueKindOrder, issueRung, ISSUE_KIND_ICON, ISSUE_RUNG_ORDER, } from './IssueList';
30
30
  import { SubsystemFileTree } from './SubsystemFileTree';
31
31
  import { GraphLayoutCover } from './GraphLayoutCover';
32
- import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
32
+ import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle, LAYOUT_FLIP_CLASS, LAYOUT_FLIP_EDGE_MS, LAYOUT_FLIP_NODE_MS } from './graphChrome';
33
33
  import { ComponentDeclaration } from './ComponentDeclaration';
34
34
  import { FileDrawer, FILE_DRAWER_HEIGHT_MS } from './FileDrawer';
35
35
  import { GraphWithSidebar } from './GraphWithSidebar';
@@ -128,7 +128,7 @@ const TrailDrawerContent = memo(function TrailDrawerContent({ render, trail, ste
128
128
  onVisibleStepChange,
129
129
  }) }));
130
130
  });
131
- function Inner({ components, trails, graphifyRelations, orderByLine, initialTrailId, onTrailSelect, onTrailStepHover, onReorderTrails, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, moduleNesting, edgeView, title, hideSidebar, trailStepMode = 'focus', autoPlayTrails = false, trailAutoPlayIntervalMs = TRAIL_PLAY_PAUSE_MS, zoomOnTrailFocus = true, trailFocusDurationMs = 300, graphTitle, showTrailTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, laneStatus, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileViewer, renderTrailViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, codeProcessKeys, hideDrawer = false, persistKey, liveEvents, agentsPanel }) {
131
+ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirection: layoutDirectionProp, defaultLayoutDirection = 'horizontal', onLayoutDirectionChange, hideLayoutToggle = false, refocusOnLayoutFlip = true, initialTrailId, onTrailSelect, onTrailStepHover, onReorderTrails, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, moduleNesting, edgeView, title, hideSidebar, trailStepMode = 'focus', autoPlayTrails = false, trailAutoPlayIntervalMs = TRAIL_PLAY_PAUSE_MS, zoomOnTrailFocus = true, trailFocusDurationMs = 300, graphTitle, showTrailTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, diagnostic, issues, showIssues, focusIssueCategory, laneStatus, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileViewer, renderTrailViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, codeProcessKeys, hideDrawer = false, persistKey, liveEvents, agentsPanel }) {
132
132
  const { theme } = useTheme();
133
133
  const { fitView, fitBounds, screenToFlowPosition } = useReactFlow();
134
134
  const viewport = useViewport();
@@ -142,6 +142,139 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
142
142
  absoluteRects: new Map(),
143
143
  });
144
144
  const [layoutReady, setLayoutReady] = useState(false);
145
+ // --- Layout direction -----------------------------------------------------
146
+ // The graph owns direction unless the host passes `layoutDirection`. A change
147
+ // is sequenced so old edge routes never cut across the new layout: edges fade
148
+ // out, ELK re-lays out while the nodes slide to their new slots, then the
149
+ // edges fade back in. Two values keep that honest: `requestedDirection` is
150
+ // what the toggle shows, `committedDirection` is what ELK last ran with.
151
+ const [ownDirection, setOwnDirection] = useState(() => persisted.layoutDirection ?? defaultLayoutDirection);
152
+ const requestedDirection = layoutDirectionProp ?? ownDirection;
153
+ const [committedDirection, setCommittedDirection] = useState(requestedDirection);
154
+ const [layoutFlipping, setLayoutFlipping] = useState(false);
155
+ const flipSawPendingLayoutRef = useRef(false);
156
+ /** Pending fade-out timer; non-null only between request and commit. */
157
+ const flipFadeTimerRef = useRef(null);
158
+ /** Set when a layout flip starts; the camera refits once layout lands. */
159
+ const refitAfterLayoutRef = useRef(false);
160
+ /** Solo layout: only this trail's components and edges are laid out. */
161
+ const [layoutTrailId, setLayoutTrailId] = useState(null);
162
+ const [committedTrailId, setCommittedTrailId] = useState(null);
163
+ const changeLayoutDirection = useCallback((next) => {
164
+ if (next === requestedDirection)
165
+ return;
166
+ if (layoutDirectionProp == null)
167
+ setOwnDirection(next);
168
+ onLayoutDirectionChange?.(next);
169
+ }, [layoutDirectionProp, onLayoutDirectionChange, requestedDirection]);
170
+ const toggleLayoutFocus = useCallback((trailId) => {
171
+ setLayoutTrailId((cur) => (cur === trailId ? null : trailId));
172
+ }, []);
173
+ // A focused trail that disappears (model reload) releases the solo layout
174
+ // instead of pinning the canvas to a slice that no longer exists.
175
+ useEffect(() => {
176
+ if (layoutTrailId && !(trails ?? []).some((t) => t.id === layoutTrailId)) {
177
+ setLayoutTrailId(null);
178
+ }
179
+ }, [trails, layoutTrailId]);
180
+ const requestedLayoutKey = `${requestedDirection}|${layoutTrailId ?? ''}`;
181
+ const committedLayoutKey = `${committedDirection}|${committedTrailId ?? ''}`;
182
+ // Beat 1: request → fade the edges out, then commit the direction / focus
183
+ // (which re-runs ELK). Reduced-motion users get the new layout immediately.
184
+ useEffect(() => {
185
+ if (requestedLayoutKey === committedLayoutKey) {
186
+ // A host can revert a request before the fade-out commits. Drop the
187
+ // pending flip instead of leaving the canvas edge-less until the safety
188
+ // timeout. A normal commit also lands here (request now equals
189
+ // committed), but by then the timer ref is already null — and the
190
+ // pending refit must survive that pass.
191
+ if (flipFadeTimerRef.current != null) {
192
+ clearTimeout(flipFadeTimerRef.current);
193
+ flipFadeTimerRef.current = null;
194
+ setLayoutFlipping(false);
195
+ refitAfterLayoutRef.current = false;
196
+ }
197
+ return;
198
+ }
199
+ refitAfterLayoutRef.current = refocusOnLayoutFlip;
200
+ const reduceMotion = typeof window !== 'undefined' &&
201
+ typeof window.matchMedia === 'function' &&
202
+ window.matchMedia('(prefers-reduced-motion: reduce)').matches;
203
+ if (reduceMotion) {
204
+ setCommittedDirection(requestedDirection);
205
+ setCommittedTrailId(layoutTrailId);
206
+ return;
207
+ }
208
+ flipSawPendingLayoutRef.current = false;
209
+ setLayoutFlipping(true);
210
+ flipFadeTimerRef.current = window.setTimeout(() => {
211
+ flipFadeTimerRef.current = null;
212
+ setCommittedDirection(requestedDirection);
213
+ setCommittedTrailId(layoutTrailId);
214
+ }, LAYOUT_FLIP_EDGE_MS);
215
+ return () => {
216
+ if (flipFadeTimerRef.current != null) {
217
+ clearTimeout(flipFadeTimerRef.current);
218
+ flipFadeTimerRef.current = null;
219
+ }
220
+ };
221
+ }, [
222
+ requestedLayoutKey,
223
+ committedLayoutKey,
224
+ refocusOnLayoutFlip,
225
+ requestedDirection,
226
+ layoutTrailId,
227
+ ]);
228
+ // Beat 3: the committed layout has settled → the node slide is over, fade
229
+ // the edges back in. The ref keeps the OLD `layoutReady` (still true until
230
+ // the new pass 1 commits) from ending the flip before the layout swaps.
231
+ useEffect(() => {
232
+ if (!layoutFlipping)
233
+ return;
234
+ if (!layoutReady) {
235
+ flipSawPendingLayoutRef.current = true;
236
+ return;
237
+ }
238
+ if (!flipSawPendingLayoutRef.current)
239
+ return;
240
+ const t = setTimeout(() => setLayoutFlipping(false), LAYOUT_FLIP_NODE_MS);
241
+ return () => clearTimeout(t);
242
+ }, [layoutFlipping, layoutReady]);
243
+ // Camera: a direction flip moves the whole graph, and a vertical layout grows
244
+ // downward, so keeping the old viewport would leave most of it off-screen.
245
+ // Refit once the new layout is final — for both the animated flip and the
246
+ // reduced-motion instant swap. The edge reveal is separate (beat 3 above).
247
+ // Keyed on `built` as well as `layoutReady`: whichever of the two passes
248
+ // lands last is the one that has the final positions.
249
+ useEffect(() => {
250
+ if (!layoutReady || !refitAfterLayoutRef.current)
251
+ return;
252
+ refitAfterLayoutRef.current = false;
253
+ requestAnimationFrame(() => {
254
+ fitView({
255
+ padding: 0.1,
256
+ includeHiddenNodes: false,
257
+ minZoom: 0.05,
258
+ maxZoom: 2,
259
+ duration: LAYOUT_FLIP_NODE_MS,
260
+ });
261
+ });
262
+ }, [built, layoutReady, fitView]);
263
+ // Persist the choice so a model reopens the way it was left.
264
+ useEffect(() => {
265
+ if (!persistKey)
266
+ return;
267
+ writeViewState(persistKey, { layoutDirection: requestedDirection });
268
+ }, [persistKey, requestedDirection]);
269
+ // Safety net: never leave the canvas edge-less if a layout pass stalls
270
+ // (measurement never lands, ELK hangs). The edges come back; the layout
271
+ // stays wherever it got to.
272
+ useEffect(() => {
273
+ if (!layoutFlipping)
274
+ return;
275
+ const t = setTimeout(() => setLayoutFlipping(false), 5000);
276
+ return () => clearTimeout(t);
277
+ }, [layoutFlipping]);
145
278
  const [selected, setSelected] = useState(null);
146
279
  /** Bottom drawer: single file or trail multi-snippet mode. */
147
280
  const [drawerTarget, setDrawerTarget] = useState(null);
@@ -248,14 +381,18 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
248
381
  // Trails draws runtime step edges. Without visible tabs (no trails,
249
382
  // or a sidebar-less embed) fall back to trail edges.
250
383
  const sidebarTabsVisible = !hideSidebar && (trails?.length ?? 0) > 0;
251
- const resolvedEdgeView = edgeView ??
252
- (sidebarTabsVisible
253
- ? (sidebarView === 'trails' ? 'trails' : 'graphify')
254
- : (trails?.length ?? 0) > 0
255
- ? 'trails'
256
- : (graphifyRelations?.length ?? 0) > 0
257
- ? 'graphify'
258
- : 'trails');
384
+ // A solo trail layout only draws that flow's steps, so the trail edge source
385
+ // is the only one that can render anything.
386
+ const resolvedEdgeView = committedTrailId
387
+ ? 'trails'
388
+ : edgeView ??
389
+ (sidebarTabsVisible
390
+ ? (sidebarView === 'trails' ? 'trails' : 'graphify')
391
+ : (trails?.length ?? 0) > 0
392
+ ? 'trails'
393
+ : (graphifyRelations?.length ?? 0) > 0
394
+ ? 'graphify'
395
+ : 'trails');
259
396
  // Trail flows the user has expanded (via the title row). Closed by
260
397
  // default so a graph with several flows doesn't dump every step list at once.
261
398
  // Restored from the persisted view state when a `persistKey` is set.
@@ -435,13 +572,36 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
435
572
  // Pass 1: build with estimated widths so React Flow can measure the DOM.
436
573
  // The pane stays hidden until Pass 2 completes. Key off layout-affecting
437
574
  // fields only — declarationSpan updates after verify must not re-run ELK.
438
- const layoutKey = useMemo(() => subsystemGraphLayoutKey({ components, trails, graphifyRelations }), [components, trails, graphifyRelations]);
439
- const componentsRef = useRef(components);
440
- const trailsRef = useRef(trails);
441
- const graphifyRelationsRef = useRef(graphifyRelations);
442
- componentsRef.current = components;
443
- trailsRef.current = trails;
444
- graphifyRelationsRef.current = graphifyRelations;
575
+ // Solo layout: a trail focus filters the model down to that flow's endpoints
576
+ // (and drops graphify relations) before it reaches ELK. Built from the
577
+ // COMMITTED trail, not the requested one, so the swap happens behind the
578
+ // edge fade like every other layout flip.
579
+ const layoutDoc = useMemo(() => {
580
+ if (!committedTrailId)
581
+ return { components, trails };
582
+ const trail = (trails ?? []).find((t) => t.id === committedTrailId);
583
+ if (!trail)
584
+ return { components, trails };
585
+ const aliases = new Set();
586
+ for (const step of trail.steps) {
587
+ aliases.add(step.from);
588
+ aliases.add(step.to);
589
+ }
590
+ return {
591
+ components: components.filter((c) => aliases.has(c.alias)),
592
+ trails: [trail],
593
+ };
594
+ }, [components, trails, committedTrailId]);
595
+ const layoutRelations = committedTrailId ? undefined : graphifyRelations;
596
+ const layoutKey = useMemo(() => subsystemGraphLayoutKey({
597
+ components: layoutDoc.components,
598
+ trails: layoutDoc.trails,
599
+ graphifyRelations: layoutRelations,
600
+ }), [layoutDoc, layoutRelations]);
601
+ const layoutDocRef = useRef(layoutDoc);
602
+ const layoutRelationsRef = useRef(layoutRelations);
603
+ layoutDocRef.current = layoutDoc;
604
+ layoutRelationsRef.current = layoutRelations;
445
605
  // Track measured dimensions from React Flow's dimension changes.
446
606
  // These arrive as { type: 'dimensions', id, dimensions } in onNodesChange.
447
607
  // Retained across same-id layoutKey updates so Pass 2 can run without a remount.
@@ -456,17 +616,15 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
456
616
  prevMeasuredSigRef.current = '';
457
617
  // Bump generation so any in-flight Pass 2 from the prior layoutKey is ignored.
458
618
  pass2GenRef.current += 1;
459
- const doc = {
460
- components: componentsRef.current,
461
- trails: trailsRef.current,
462
- };
619
+ const doc = layoutDocRef.current;
463
620
  void buildSubsystemGraph(doc, {
464
621
  maxNodeWidth,
465
622
  showEdgeLabels,
466
623
  showSingletonFrames,
467
624
  moduleNesting,
468
- graphifyRelations: graphifyRelationsRef.current,
625
+ graphifyRelations: layoutRelationsRef.current,
469
626
  orderByLine,
627
+ layoutDirection: committedDirection,
470
628
  codeProcessKeys,
471
629
  })
472
630
  .then(({ nodes, edges: e, absoluteRects }) => {
@@ -491,7 +649,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
491
649
  setLayoutReady(true);
492
650
  });
493
651
  return () => { alive = false; };
494
- }, [layoutKey, maxNodeWidth, showEdgeLabels, moduleNesting]);
652
+ }, [layoutKey, maxNodeWidth, showEdgeLabels, moduleNesting, committedDirection]);
495
653
  // Pass 2: once every leaf node has a measured dimension, re-run ELK.
496
654
  // Group parents are sized by ELK, not measured — exclude them or pass 2
497
655
  // would wait forever for dimensions that never arrive.
@@ -518,7 +676,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
518
676
  const measuredWidths = new Map(leafNodes.map((n) => [n.id, dims.get(n.id).width]));
519
677
  const measuredHeights = new Map(leafNodes.map((n) => [n.id, dims.get(n.id).height]));
520
678
  const gen = ++pass2GenRef.current;
521
- void buildSubsystemGraph({ components, trails }, { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights, showSingletonFrames, moduleNesting, graphifyRelations, orderByLine, codeProcessKeys })
679
+ void buildSubsystemGraph(layoutDoc, { maxNodeWidth, showEdgeLabels, measuredWidths, measuredHeights, showSingletonFrames, moduleNesting, graphifyRelations: layoutRelations, orderByLine, layoutDirection: committedDirection, codeProcessKeys })
522
680
  .then(({ nodes, edges: e, absoluteRects }) => {
523
681
  if (gen !== pass2GenRef.current)
524
682
  return;
@@ -532,7 +690,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
532
690
  // Reveal Pass 1 layout rather than leaving the cover up forever.
533
691
  setLayoutReady(true);
534
692
  });
535
- }, [built.nodes, components, trails, graphifyRelations, orderByLine, maxNodeWidth, showEdgeLabels, moduleNesting]);
693
+ }, [built.nodes, layoutDoc, layoutRelations, orderByLine, maxNodeWidth, showEdgeLabels, moduleNesting, committedDirection]);
536
694
  // After Pass 1 commits, try Pass 2 immediately with retained measurements.
537
695
  // Same-id live updates often get no new React Flow `dimensions` events, so
538
696
  // waiting only on onNodesChange leaves the cover stuck.
@@ -1144,6 +1302,8 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1144
1302
  }
1145
1303
  return proposedStampedEdges.map((e) => ({ ...e, hidden: !edgeInView(e) }));
1146
1304
  }, [proposedStampedEdges, selectedEdgeId, openedEdgeIds, focusEdgeIds, previewEdgeIds, issueFocusEdgeIds, resolvedEdgeView]);
1305
+ // Gated on layoutReady so the pre-reveal Pass 1 swap never flashes a count.
1306
+ const unroutedIds = useMemo(() => (layoutReady ? unroutedEdgeIds(dispEdges) : []), [dispEdges, layoutReady]);
1147
1307
  const onNodesChange = useCallback((changes) => {
1148
1308
  // Capture dimension changes (React Flow's measurement callback).
1149
1309
  // Group parents are ELK-sized — ignore their measurements.
@@ -1164,6 +1324,10 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
1164
1324
  }, [dispNodes, triggerPass2]);
1165
1325
  const onEdgesChange = useCallback((_changes) => dispEdges, [dispEdges]);
1166
1326
  useEffect(() => {
1327
+ // A layout flip runs its own camera refit when the new layout lands. This
1328
+ // mount/edit fit would otherwise fire mid-flip and fight it.
1329
+ if (layoutFlipping)
1330
+ return;
1167
1331
  const t = setTimeout(() => {
1168
1332
  fitView({ padding: 0.1, includeHiddenNodes: false, minZoom: 0.05, maxZoom: 2, duration: 200 });
1169
1333
  }, 200);
@@ -2065,7 +2229,14 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2065
2229
  }, children: _jsx(FileText, { size: 14 }) }))] })] })), description && descriptionVisible && (_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, disableBasePadding: true, enableKeyboardScrolling: false, autoFocusOnVisible: false }) })), sidebarAfterDescription] }));
2066
2230
  // Lower panel: the Issues/Agents diagnostics stack, or the Files/Trails
2067
2231
  // stack with the trail flow list / repo file tree.
2068
- const sidebarBody = issuesActive ? (diagnosticsTab === 'agents' && agentsPanel ? (_jsx(SubsystemAgentsPanel, { ...agentsPanel })) : (_jsx(SubsystemIssueList, { issues: issues ?? [], focusCategory: focusIssueCategory, laneStatus: laneStatus, onSelectIssue: focusIssueTarget, onDeselectIssue: unfocusIssueTarget, onApplyFix: onApplyIssueFix, onHoverIssue: onHoverIssue, onExpandedCategoriesChange: handleExpandedCategoriesChange }))) : sidebarView === 'trails' && trails && trails.length > 0 ? (_jsx(TrailsPanel, { trails: trails, expandedTrails: expandedTrails, focusedTrailId: focusedTrailId, focusedStepIndex: focusedStepIndex, hoveredTrailStep: hoveredTrailStep, onToggleCollapsed: toggleTrailCollapsed, onFocusFlow: focusTrailEdges, onClearFocus: clearTrailFocus, onFocusStep: focusTrailStep, onHoverStep: (tl, i) => {
2232
+ const sidebarBody = issuesActive ? (diagnosticsTab === 'agents' && agentsPanel ? (_jsx(SubsystemAgentsPanel, { ...agentsPanel })) : (_jsx(SubsystemIssueList, { issues: issues ?? [], focusCategory: focusIssueCategory, laneStatus: laneStatus, onSelectIssue: focusIssueTarget, onDeselectIssue: unfocusIssueTarget, onApplyFix: onApplyIssueFix, onHoverIssue: onHoverIssue, onExpandedCategoriesChange: handleExpandedCategoriesChange }))) : sidebarView === 'trails' && trails && trails.length > 0 ? (_jsx(TrailsPanel, { trails: trails, expandedTrails: expandedTrails, focusedTrailId: focusedTrailId, focusedStepIndex: focusedStepIndex, hoveredTrailStep: hoveredTrailStep, onToggleCollapsed: toggleTrailCollapsed, onFocusFlow: (tl) => {
2233
+ // While a solo layout is on, following the selection keeps the canvas
2234
+ // and the highlighted flow in agreement instead of leaving the graph
2235
+ // pinned to a flow the user just navigated away from.
2236
+ if (layoutTrailId != null)
2237
+ setLayoutTrailId(tl.id);
2238
+ focusTrailEdges(tl);
2239
+ }, onClearFocus: clearTrailFocus, onFocusStep: focusTrailStep, onHoverStep: (tl, i) => {
2069
2240
  setHoveredTrailStep({ trailId: tl.id, stepIndex: i });
2070
2241
  onTrailStepHover?.({ trailId: tl.id, stepIndex: i });
2071
2242
  }, onHoverFlow: (tl) => {
@@ -2074,7 +2245,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2074
2245
  }, onLeaveStep: () => {
2075
2246
  setHoveredTrailStep(null);
2076
2247
  onTrailStepHover?.(null);
2077
- }, onReorder: onReorderTrails, proposedAliases: proposedAliases })) : treeFilePaths.length > 0 ? (_jsx(_Fragment, { children: repoGroups.groups.map((group, i) => {
2248
+ }, onReorder: onReorderTrails, proposedAliases: proposedAliases, layoutFocusedTrailId: layoutTrailId, onFocusLayout: toggleLayoutFocus })) : treeFilePaths.length > 0 ? (_jsx(_Fragment, { children: repoGroups.groups.map((group, i) => {
2078
2249
  const groupKey = group.repoKey ?? '__no-repo__';
2079
2250
  const collapsed = collapsedRepos.has(groupKey);
2080
2251
  return (_jsxs("div", { style: {
@@ -2087,7 +2258,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2087
2258
  }) })) : null;
2088
2259
  return (_jsx(GraphWithSidebar, { hidden: !sidebarVisible, bodyHidden: !sidebarBodyVisible, stretchHeader: showDesc, header: sidebarHeader, tabs: sidebarTabs, activeTab: activeSidebarTab, onTabChange: (id) => issuesActive
2089
2260
  ? setDiagnosticsTab(id)
2090
- : changeSidebarView(id), sidebarBody: sidebarBody, tabListLabel: issuesActive ? 'Diagnostics view' : 'Sidebar view', defaultWidth: persisted.sidebarWidth ?? 450, minWidth: 240, maxWidth: sidebarMaxWidth, persistKey: persistKey, onResizeEnd: () => requestAnimationFrame(() => fitView()), children: _jsxs("div", { style: { flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0 }, children: [_jsxs("div", { style: {
2261
+ : changeSidebarView(id), sidebarBody: sidebarBody, tabListLabel: issuesActive ? 'Diagnostics view' : 'Sidebar view', defaultWidth: persisted.sidebarWidth ?? 450, minWidth: 240, maxWidth: sidebarMaxWidth, persistKey: persistKey, onResizeEnd: () => requestAnimationFrame(() => fitView()), children: _jsxs("div", { style: { flex: 1, position: 'relative', display: 'flex', flexDirection: 'column', minHeight: 0 }, children: [_jsxs("div", { className: layoutFlipping ? LAYOUT_FLIP_CLASS : undefined, style: {
2091
2262
  position: 'relative',
2092
2263
  flex: collapseCanvas ? 0 : 1,
2093
2264
  minHeight: 0,
@@ -2101,6 +2272,8 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2101
2272
  overflow: 'hidden',
2102
2273
  pointerEvents: 'none',
2103
2274
  zIndex: 5,
2275
+ opacity: layoutFlipping ? 0 : 1,
2276
+ transition: `opacity ${LAYOUT_FLIP_EDGE_MS}ms ease`,
2104
2277
  }, children: edgeLabels.map((lbl) => {
2105
2278
  const mechanism = lbl.mechanism;
2106
2279
  const isGraphify = lbl.provenance === 'graphify';
@@ -2200,13 +2373,55 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2200
2373
  lineHeight: 1,
2201
2374
  }, children: lbl.stepIssue.count }))] }));
2202
2375
  })()] }, lbl.id));
2203
- }) })), _jsx(GraphLayerStyle, {}), _jsx(ReactFlow, { nodes: dispNodes, edges: dispEdges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, className: GRAPH_CANVAS_CLASS, ...GRAPH_NAV_PROPS, onNodeClick: onNodeClick, onNodeDoubleClick: onNodeDoubleClick, onEdgeClick: onEdgeClick, onNodesChange: onNodesChange, onEdgesChange: onEdgesChange, proOptions: { hideAttribution: true }, onPaneClick: onPaneClick, style: {
2376
+ }) })), _jsx(GraphLayerStyle, {}), _jsx(ReactFlow, { nodes: dispNodes, edges: dispEdges, nodeTypes: nodeTypes, edgeTypes: edgeTypes, className: GRAPH_CANVAS_CLASS,
2377
+ // The key above remounts ReactFlow when the node/edge SET changes (a
2378
+ // trail-focus layout, a filtered model). A remount otherwise resets the
2379
+ // viewport to identity — a visible jump to scale 1 before the post-flip
2380
+ // refit. Seed the new instance with the viewport the old one had.
2381
+ defaultViewport: viewport, ...GRAPH_NAV_PROPS, onNodeClick: onNodeClick, onNodeDoubleClick: onNodeDoubleClick, onEdgeClick: onEdgeClick, onNodesChange: onNodesChange, onEdgesChange: onEdgesChange, proOptions: { hideAttribution: true }, onPaneClick: onPaneClick, style: {
2204
2382
  width: '100%',
2205
2383
  height: '100%',
2206
2384
  flex: 1,
2207
2385
  minHeight: 0,
2208
2386
  background: theme.colors.background,
2209
- }, children: _jsx(GraphChrome, {}) }, `${baseNodesKey}-${baseEdgesKey}`), (graphTitle || overlayTrailTitle || overlayTrailStep) && (_jsxs("div", { style: {
2387
+ }, children: _jsx(GraphChrome, {}) }, `${baseNodesKey}-${baseEdgesKey}`), !hideLayoutToggle && (_jsxs("button", { type: "button", onClick: () => changeLayoutDirection(requestedDirection === 'horizontal' ? 'vertical' : 'horizontal'), disabled: layoutFlipping, title: requestedDirection === 'horizontal'
2388
+ ? 'Switch to a vertical layout (top-to-bottom)'
2389
+ : 'Switch to a horizontal layout (left-to-right)', "aria-label": requestedDirection === 'horizontal'
2390
+ ? 'Switch to vertical layout'
2391
+ : 'Switch to horizontal layout', style: {
2392
+ position: 'absolute',
2393
+ top: 10,
2394
+ right: 10,
2395
+ zIndex: 6,
2396
+ display: 'inline-flex',
2397
+ alignItems: 'center',
2398
+ gap: 5,
2399
+ padding: '4px 9px',
2400
+ border: `1px solid ${theme.colors.border}`,
2401
+ borderRadius: 6,
2402
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
2403
+ color: theme.colors.textSecondary,
2404
+ fontFamily: theme.fonts.monospace,
2405
+ fontSize: theme.fontSizes[0],
2406
+ cursor: layoutFlipping ? 'default' : 'pointer',
2407
+ opacity: layoutFlipping ? 0.55 : 1,
2408
+ }, children: [requestedDirection === 'horizontal' ? (_jsx(ArrowDown, { size: 13, "aria-hidden": "true" })) : (_jsx(ArrowRight, { size: 13, "aria-hidden": "true" })), requestedDirection === 'horizontal' ? 'Vertical' : 'Horizontal'] })), unroutedIds.length > 0 && (_jsxs("div", { role: "status", title: `Edges with no routed path (not drawn):\n${unroutedIds.join('\n')}`, style: {
2409
+ position: 'absolute',
2410
+ top: 10,
2411
+ left: 10,
2412
+ zIndex: 6,
2413
+ display: 'inline-flex',
2414
+ alignItems: 'center',
2415
+ gap: 6,
2416
+ padding: '4px 10px',
2417
+ fontSize: theme.fontSizes[0],
2418
+ fontFamily: theme.fonts.body,
2419
+ color: theme.colors.text,
2420
+ background: theme.colors.backgroundSecondary ?? theme.colors.background,
2421
+ border: `1px solid ${theme.colors.warning ?? '#d4a017'}`,
2422
+ borderRadius: 6,
2423
+ boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
2424
+ }, children: [_jsx(Unlink, { size: 13, color: theme.colors.warning ?? '#d4a017', "aria-hidden": true }), unroutedIds.length, " ", unroutedIds.length === 1 ? 'edge' : 'edges', " not drawn"] })), (graphTitle || overlayTrailTitle || overlayTrailStep) && (_jsxs("div", { style: {
2210
2425
  position: 'absolute',
2211
2426
  top: 10,
2212
2427
  left: '50%',
@@ -2338,7 +2553,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, initialTrai
2338
2553
  fontFamily: theme.fonts.monospace,
2339
2554
  fontSize: theme.fontSizes[0],
2340
2555
  color: theme.colors.textMuted ?? theme.colors.textSecondary,
2341
- }, children: fileOverlay.file }), _jsx(FileOverlayCloseButton, { onClose: closeFileOverlay })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflow: 'auto' }, children: _jsx(FileDrawerContent, { render: renderDrawerContent, file: fileOverlay.file, startLine: fileOverlay.startLine, endLine: fileOverlay.endLine, fullFile: true }) })] })), _jsx(GraphLayoutCover, { revealed: layoutReady }), canvasOverlay, liveEvents ? _jsx(MaintainLivePanel, { ...liveEvents }) : null] }) }));
2556
+ }, children: fileOverlay.file }), _jsx(FileOverlayCloseButton, { onClose: closeFileOverlay })] }), _jsx("div", { style: { flex: 1, minHeight: 0, overflow: 'auto' }, children: _jsx(FileDrawerContent, { render: renderDrawerContent, file: fileOverlay.file, startLine: fileOverlay.startLine, endLine: fileOverlay.endLine, fullFile: true }) })] })), _jsx(GraphLayoutCover, { revealed: layoutReady || layoutFlipping }), canvasOverlay, liveEvents ? _jsx(MaintainLivePanel, { ...liveEvents }) : null] }) }));
2342
2557
  }
2343
2558
  /** Sidebar header for one repo's tree: owner avatar + repo name + file count.
2344
2559
  * Clicking toggles the tree's visibility. */