@principal-ai/subsystems-react 0.56.10 → 0.56.12

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 (34) hide show
  1. package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -1
  2. package/dist/subsystem/DiagnosticToggle.js +2 -1
  3. package/dist/subsystem/DiagnosticToggle.js.map +1 -1
  4. package/dist/subsystem/SubsystemComponentGraph.d.ts +2 -1
  5. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  6. package/dist/subsystem/SubsystemComponentGraph.js +64 -27
  7. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  8. package/dist/subsystem/SubsystemFileTree.d.ts.map +1 -1
  9. package/dist/subsystem/SubsystemFileTree.js +2 -1
  10. package/dist/subsystem/SubsystemFileTree.js.map +1 -1
  11. package/dist/subsystem/TrailStepsNav.d.ts +3 -1
  12. package/dist/subsystem/TrailStepsNav.d.ts.map +1 -1
  13. package/dist/subsystem/TrailStepsNav.js +13 -10
  14. package/dist/subsystem/TrailStepsNav.js.map +1 -1
  15. package/dist/subsystem/TrailsPanel.d.ts +5 -3
  16. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  17. package/dist/subsystem/TrailsPanel.js +44 -68
  18. package/dist/subsystem/TrailsPanel.js.map +1 -1
  19. package/dist/subsystem/trailSidebarScope.d.ts +11 -0
  20. package/dist/subsystem/trailSidebarScope.d.ts.map +1 -0
  21. package/dist/subsystem/trailSidebarScope.js +47 -0
  22. package/dist/subsystem/trailSidebarScope.js.map +1 -0
  23. package/package.json +1 -1
  24. package/src/stories/Canvases/Subsystem/TrailSidebarViews.stories.tsx +68 -0
  25. package/src/subsystem/DiagnosticToggle.tsx +2 -1
  26. package/src/subsystem/SubsystemComponentGraph.sidebar.test.tsx +106 -5
  27. package/src/subsystem/SubsystemComponentGraph.tsx +87 -41
  28. package/src/subsystem/SubsystemFileTree.tsx +2 -1
  29. package/src/subsystem/TrailStepsNav.test.tsx +60 -0
  30. package/src/subsystem/TrailStepsNav.tsx +16 -10
  31. package/src/subsystem/TrailsPanel.test.tsx +99 -35
  32. package/src/subsystem/TrailsPanel.tsx +43 -86
  33. package/src/subsystem/trailSidebarScope.test.ts +89 -0
  34. package/src/subsystem/trailSidebarScope.ts +62 -0
@@ -82,7 +82,7 @@ import {
82
82
  } from './IssueList';
83
83
  import { GraphLayoutCover } from './GraphLayoutCover';
84
84
  import { SubsystemFileTree } from './SubsystemFileTree';
85
- import { buildTreePathMapping } from './paths';
85
+ import { buildTrailSidebarScope, type TrailSidebarScope } from './trailSidebarScope';
86
86
  import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle, LAYOUT_FLIP_CLASS, LAYOUT_FLIP_EDGE_MS, LAYOUT_FLIP_NODE_MS } from './graphChrome';
87
87
  import { ComponentDeclaration } from './ComponentDeclaration';
88
88
  import type { ComponentVerificationState } from './ComponentDeclaration';
@@ -151,6 +151,7 @@ type DrawerTarget =
151
151
  interface PersistedViewState {
152
152
  expandedTrails?: string[];
153
153
  focusedTrailId?: string | null;
154
+ sidebarTrailId?: string | null;
154
155
  focusedStepIndex?: number | null;
155
156
  /** Sidebar width moved to the shared GraphWithSidebar chrome (persisted
156
157
  * under `principal.subsystems.sidebarWidth.<persistKey>`); reading the old
@@ -403,7 +404,8 @@ export interface SubsystemComponentGraphProps {
403
404
  /**
404
405
  * Verification issues for this graph (audit findings). When diagnostics are
405
406
  * active the sidebar's bottom panel becomes the issue list instead of the
406
- * file tree / trails.
407
+ * file tree / trails. Graph issue badges and issue-driven dimming are only
408
+ * shown in that mode.
407
409
  */
408
410
  issues?: SubsystemIssue[];
409
411
  /**
@@ -609,9 +611,11 @@ const TrailDrawerContent = memo(function TrailDrawerContent({
609
611
  interface InnerProps extends SubsystemComponentGraphProps {
610
612
  measured: { w: number; h: number } | null;
611
613
  filesActive: boolean;
614
+ sidebarScope: TrailSidebarScope;
615
+ onSidebarTrailChange: (trailId: string | null) => void;
612
616
  }
613
617
 
614
- function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirection: layoutDirectionProp, defaultLayoutDirection = 'horizontal', onLayoutDirectionChange, hideLayoutToggle = false, refocusOnLayoutFlip = true, initialTrailId, onTrailSelect, onTrailStepHover, onReorderTrails, stepEmphasis, onRemoveStepEmphasis, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, moduleNesting, edgeView, hideSidebar, trailStepMode = 'focus', autoPlayTrails = false, trailAutoPlayIntervalMs = TRAIL_PLAY_PAUSE_MS, zoomOnTrailFocus = true, trailFocusDurationMs = 300, graphTitle, showTrailTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, issues, showIssues, filesActive, focusIssueCategory, laneStatus, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileViewer, renderTrailViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, codeProcessKeys, hideDrawer = false, persistKey, liveEvents, agentsPanel }: InnerProps) {
618
+ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirection: layoutDirectionProp, defaultLayoutDirection = 'horizontal', onLayoutDirectionChange, hideLayoutToggle = false, refocusOnLayoutFlip = true, initialTrailId, onTrailSelect, onTrailStepHover, onReorderTrails, stepEmphasis, onRemoveStepEmphasis, onSelect, onEdgeSelect, measured: _measured, maxNodeWidth, showEdgeLabels, showSingletonFrames = true, moduleNesting, edgeView, hideSidebar, trailStepMode = 'focus', autoPlayTrails = false, trailAutoPlayIntervalMs = TRAIL_PLAY_PAUSE_MS, zoomOnTrailFocus = true, trailFocusDurationMs = 300, graphTitle, showTrailTitle = false, description, canvasOverlay, sidebarExtra, sidebarAfterDescription, issues, showIssues, filesActive, sidebarScope, onSidebarTrailChange, focusIssueCategory, laneStatus, onSelectIssue, onApplyIssueFix, onHoverIssue, renderFileViewer, renderTrailViewer, onFileSelect, componentVerification, onInspectSymbol, boundaryColors, codeProcessKeys, hideDrawer = false, persistKey, liveEvents, agentsPanel }: InnerProps) {
615
619
  const { theme } = useTheme();
616
620
  const { fitView, fitBounds } = useReactFlow();
617
621
  const viewport = useViewport();
@@ -1259,7 +1263,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1259
1263
  // node says "diagnostics here" for every finding the sidebar lists.
1260
1264
  const issueBadgeByAlias = useMemo(() => {
1261
1265
  const map = new Map<string, SubsystemNodeIssue>();
1262
- if (!issues?.length) return map;
1266
+ if (showIssues !== true || !issues?.length) return map;
1263
1267
  for (const issue of issues) {
1264
1268
  const rung = issueRung(issue.kind);
1265
1269
  if (!rung && !ISSUE_KIND_ICON[issue.kind]) continue;
@@ -1294,7 +1298,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1294
1298
  if (kinded) map.set(alias, { ...badge, kind: kinded.kind });
1295
1299
  }
1296
1300
  return map;
1297
- }, [issues, issueComponent]);
1301
+ }, [showIssues, issues, issueComponent]);
1298
1302
 
1299
1303
  // Per-frame diagnostics badge: the same fold, but for findings about a
1300
1304
  // BOUNDARY rather than a construct. Keyed by the region's React Flow node
@@ -1305,7 +1309,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1305
1309
  // borrowing its layer's icon would imply the frame is at fault for it.
1306
1310
  const regionIssueByNodeId = useMemo(() => {
1307
1311
  const map = new Map<string, SubsystemRegionIssue>();
1308
- if (!issues?.length) return map;
1312
+ if (showIssues !== true || !issues?.length) return map;
1309
1313
  for (const issue of issues) {
1310
1314
  const target = issue.target;
1311
1315
  if (!target || !ISSUE_KIND_ICON[issue.kind]) continue;
@@ -1326,7 +1330,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1326
1330
  });
1327
1331
  }
1328
1332
  return map;
1329
- }, [issues]);
1333
+ }, [showIssues, issues]);
1330
1334
 
1331
1335
  // Per-edge step verification badge: maps edge IDs to step verification status.
1332
1336
  // Keyed by the React Flow edge id (from → mechanism → to). An edge can have
@@ -1344,7 +1348,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1344
1348
  step_span_stale: 1,
1345
1349
  };
1346
1350
  const map = new Map<string, { kind: StepIssueKind; severity: 'info' | 'error'; count: number }>();
1347
- if (!issues?.length || !trails?.length) {
1351
+ if (showIssues !== true || !issues?.length || !trails?.length) {
1348
1352
  return map;
1349
1353
  }
1350
1354
  for (const issue of issues) {
@@ -1371,7 +1375,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1371
1375
  });
1372
1376
  }
1373
1377
  return map;
1374
- }, [issues, trails, issueStep]);
1378
+ }, [showIssues, issues, trails, issueStep]);
1375
1379
 
1376
1380
  // Expanded verification layers in the diagnostics list. When any layer with
1377
1381
  // findings is expanded, the canvas dims everything that layer does not
@@ -1420,7 +1424,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1420
1424
  // Returns null when nothing is expanded, no expanded layer has findings, or
1421
1425
  // the findings implicate nothing — so no dimming is applied.
1422
1426
  const issueFocus = useMemo(() => {
1423
- if (!issues?.length || expandedIssueCategories.length === 0) return null;
1427
+ if (showIssues !== true || !issues?.length || expandedIssueCategories.length === 0) return null;
1424
1428
  const active = new Set(expandedIssueCategories);
1425
1429
  const activeIssues = issues.filter((i) => active.has(issueCategory(i)));
1426
1430
  if (activeIssues.length === 0) return null;
@@ -1475,7 +1479,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1475
1479
  if (nodeIds.has(e.source) && nodeIds.has(e.target)) edgeIds.add(e.id);
1476
1480
  }
1477
1481
  return { nodeIds, edgeIds };
1478
- }, [issues, expandedIssueCategories, components, trails, resolveAlias]);
1482
+ }, [showIssues, issues, expandedIssueCategories, components, trails, resolveAlias]);
1479
1483
  const issueFocusNodeIds = issueFocus?.nodeIds ?? null;
1480
1484
  const issueFocusEdgeIds = issueFocus?.edgeIds ?? null;
1481
1485
 
@@ -1834,7 +1838,11 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1834
1838
  }, []);
1835
1839
 
1836
1840
  const hasTrails = useMemo(() => (trails?.length ?? 0) > 0, [trails]);
1837
- const treePaths = useMemo(() => buildTreePathMapping(components), [components]);
1841
+ const { treePaths, trail: sidebarTrail } = sidebarScope;
1842
+ const treeFilePaths = useMemo(() => treePaths.entries.map((entry) => entry.displayPath), [treePaths]);
1843
+ useEffect(() => {
1844
+ if (persistKey) writeViewState(persistKey, { sidebarTrailId: sidebarTrail?.id ?? null });
1845
+ }, [persistKey, sidebarTrail?.id]);
1838
1846
  // Alias → component, so the Trails panel can show human hop names instead
1839
1847
  // of model-local aliases (and the site/`via` distinction can read).
1840
1848
  const componentByAlias = useMemo(
@@ -2123,13 +2131,14 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2123
2131
  }
2124
2132
  setExpandedTrails(new Set([tl.id]));
2125
2133
  setLayoutTrailId(tl.id);
2134
+ onSidebarTrailChange(tl.id);
2126
2135
  setFocusedStepIndex(null);
2127
2136
  setFocusedTrailId(tl.id);
2128
2137
  onTrailSelect?.({ trailId: tl.id });
2129
2138
  fitFocusBounds(new Set(tl.steps.map((s) => trailStepGraphEdgeId(s))));
2130
2139
  setDrawerTarget((prev) => (prev?.kind === 'trail' ? null : prev));
2131
2140
  },
2132
- [fitFocusBounds, trailStepMode, onTrailSelect],
2141
+ [fitFocusBounds, trailStepMode, onTrailSelect, onSidebarTrailChange],
2133
2142
  );
2134
2143
 
2135
2144
  const clearTrailFocus = useCallback(() => {
@@ -2139,25 +2148,25 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2139
2148
  }
2140
2149
  setExpandedTrails(new Set());
2141
2150
  setLayoutTrailId(null);
2151
+ onSidebarTrailChange(null);
2142
2152
  setFocusedTrailId(null);
2143
2153
  setFocusedStepIndex(null);
2144
2154
  setHoveredTrailStep(null);
2145
2155
  setDrawerTarget((prev) => (prev?.kind === 'trail' ? null : prev));
2146
2156
  onTrailSelect?.(null);
2147
- }, [onTrailSelect]);
2157
+ }, [onTrailSelect, onSidebarTrailChange]);
2148
2158
 
2149
- // A hidden trail must not leave the file view filtered to its cast.
2159
+ // Switching panels keeps the selected trail, but drops transient hover previews.
2150
2160
  const filesWereActive = useRef(false);
2151
2161
  useEffect(() => {
2152
2162
  const enteringFiles = filesActive && !filesWereActive.current;
2153
2163
  filesWereActive.current = filesActive;
2154
2164
  if (!enteringFiles) return;
2155
- clearTrailFocus();
2156
- setExpandedTrails(new Set());
2165
+ if (!sidebarTrail) clearTrailFocus();
2157
2166
  setSelectedEdgeId(null);
2158
- setLayoutTrailId(null);
2167
+ setHoveredTrailStep(null);
2159
2168
  onTrailStepHover?.(null);
2160
- }, [filesActive, clearTrailFocus, onTrailStepHover]);
2169
+ }, [filesActive, clearTrailFocus, onTrailStepHover, sidebarTrail]);
2161
2170
 
2162
2171
  // Host deep-link: opening the graph from a trail row selects that
2163
2172
  // flow — expand its steps and focus its edges.
@@ -2195,6 +2204,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2195
2204
  return;
2196
2205
  }
2197
2206
  // Toggle: clicking the selected step unselects it (back to whole flow).
2207
+ onSidebarTrailChange(tl.id);
2198
2208
  if (focusedTrailId === tl.id && focusedStepIndex === stepIndex) {
2199
2209
  setFocusedStepIndex(null);
2200
2210
  // Pointer is still over the row — keep hover preview so dimming doesn't
@@ -2250,6 +2260,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2250
2260
  renderTrailViewer,
2251
2261
  trailStepMode,
2252
2262
  onTrailSelect,
2263
+ onSidebarTrailChange,
2253
2264
  ],
2254
2265
  );
2255
2266
 
@@ -2705,7 +2716,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2705
2716
  (description || filesActive || issuesActive || sidebarExtra || sidebarAfterDescription ||
2706
2717
  hasTrails);
2707
2718
  const sidebarBodyVisible = hasTrails || filesActive || issuesActive;
2708
- const sidebarTabs: GraphSidebarTab[] | undefined = issuesActive
2719
+ const sidebarTabs: GraphSidebarTab[] | undefined = issuesActive && !sidebarTrail
2709
2720
  ? agentsPanel
2710
2721
  ? [
2711
2722
  { id: 'issues', label: 'Issues' },
@@ -2736,14 +2747,14 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2736
2747
  </>
2737
2748
  );
2738
2749
  // Lower panel: diagnostics, the file tree, or the trail flow list.
2739
- const sidebarBody = issuesActive ? (
2740
- diagnosticsTab === 'agents' && agentsPanel ? (
2750
+ const sidebarPanel = issuesActive ? (
2751
+ diagnosticsTab === 'agents' && agentsPanel && !sidebarTrail ? (
2741
2752
  <SubsystemAgentsPanel {...agentsPanel} />
2742
2753
  ) : (
2743
2754
  <SubsystemIssueList
2744
- issues={issues ?? []}
2755
+ issues={sidebarScope.issues}
2745
2756
  focusCategory={focusIssueCategory}
2746
- laneStatus={laneStatus}
2757
+ laneStatus={sidebarTrail ? undefined : laneStatus}
2747
2758
  onSelectIssue={focusIssueTarget}
2748
2759
  onDeselectIssue={unfocusIssueTarget}
2749
2760
  onApplyFix={onApplyIssueFix}
@@ -2751,22 +2762,27 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2751
2762
  onExpandedCategoriesChange={handleExpandedCategoriesChange}
2752
2763
  />
2753
2764
  )
2754
- ) : filesActive ? (
2765
+ ) : filesActive ? treePaths.entries.length === 0 ? (
2766
+ <div style={{ padding: 16, color: theme.colors.textSecondary }}>No files for this trail.</div>
2767
+ ) : (
2755
2768
  <SubsystemFileTree
2756
- files={treePaths.entries.map((entry) => entry.displayPath)}
2769
+ headerless
2770
+ files={treeFilePaths}
2757
2771
  selectedFile={openFile ? treePaths.toDisplay.get(openFile) : null}
2758
2772
  onSelectFile={(path) => {
2759
2773
  const file = treePaths.toFile.get(path);
2760
2774
  if (file) onOpenDeclarationFile(file);
2761
2775
  }}
2762
2776
  />
2763
- ) : trails && trails.length > 0 ? (
2777
+ ) : null;
2778
+ const trailNavigation = trails && trails.length > 0 ? (
2764
2779
  <TrailsPanel
2765
2780
  drilldown={trailStepMode !== 'dim'}
2766
2781
  trails={trails}
2767
2782
  expandedTrails={expandedTrails}
2768
- focusedTrailId={focusedTrailId}
2769
- focusedStepIndex={focusedStepIndex}
2783
+ focusedTrailId={sidebarTrail?.id ?? focusedTrailId}
2784
+ focusedStepIndex={!sidebarTrail || focusedTrailId === sidebarTrail.id ? focusedStepIndex : null}
2785
+ detailContent={sidebarPanel}
2770
2786
  hoveredTrailStep={hoveredTrailStep}
2771
2787
  onToggleCollapsed={toggleTrailCollapsed}
2772
2788
  onFocusFlow={focusTrailEdges}
@@ -2790,6 +2806,9 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2790
2806
  onRemoveStepEmphasis={onRemoveStepEmphasis}
2791
2807
  />
2792
2808
  ) : null;
2809
+ const sidebarBody = sidebarTrail && trailStepMode !== 'dim'
2810
+ ? trailNavigation
2811
+ : sidebarPanel ?? trailNavigation;
2793
2812
  const fileOverlayView = fileOverlay && (
2794
2813
  <div
2795
2814
  style={{
@@ -3385,20 +3404,43 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
3385
3404
  const wrapRef = useRef<HTMLDivElement>(null);
3386
3405
  const [size, setSize] = useState<{ w: number; h: number } | null>(null);
3387
3406
  const [filesOpen, setFilesOpen] = useState(false);
3388
- const fileCount = useMemo(
3389
- () => buildTreePathMapping(props.components).entries.length,
3390
- [props.components],
3407
+ // Browsing a trail is independent of selecting one of its files or findings.
3408
+ const [sidebarTrailId, setSidebarTrailId] = useState<string | null>(() => {
3409
+ const saved = readViewState(props.persistKey);
3410
+ return saved.sidebarTrailId !== undefined ? saved.sidebarTrailId : saved.focusedTrailId ?? null;
3411
+ });
3412
+ const sidebarScope = useMemo(
3413
+ () => buildTrailSidebarScope(props.components, props.trails, props.issues, sidebarTrailId),
3414
+ [props.components, props.trails, props.issues, sidebarTrailId],
3391
3415
  );
3392
- const hasFiles = fileCount > 0;
3416
+ const fileCount = sidebarScope.treePaths.entries.length;
3417
+ const trailViewLabel = sidebarScope.trail ? 'Trail' : 'Trails';
3418
+ const trailCount = props.trails?.length ?? 0;
3419
+ const hasFiles = fileCount > 0 || sidebarScope.trail != null;
3420
+ const sidebarDiagnostic: SubsystemDiagnostic = sidebarScope.trail ? {
3421
+ ...props.diagnostic,
3422
+ status: props.issues == null ? props.diagnostic?.status ?? 'unknown'
3423
+ : sidebarScope.issues.length > 0 ? 'issues' : 'ok',
3424
+ issueCount: sidebarScope.issues.length,
3425
+ fixableCount: sidebarScope.issues.filter((issue) => issue.fix != null).length,
3426
+ title: undefined,
3427
+ } : props.diagnostic ?? { status: 'unknown', issueCount: props.issues?.length };
3428
+ const hasIssues = (props.issues != null
3429
+ ? sidebarScope.issues.length
3430
+ : sidebarDiagnostic.issueCount ?? 0) > 0;
3431
+ useEffect(() => {
3432
+ if (sidebarTrailId != null && !sidebarScope.trail) setSidebarTrailId(null);
3433
+ }, [sidebarTrailId, sidebarScope.trail]);
3393
3434
  const [diagnosticsOpen, setDiagnosticsOpen] = useState<boolean>(
3394
3435
  props.showIssues ?? (props.issues?.length ?? 0) > 0,
3395
3436
  );
3396
- const issuesActive = props.showIssues ?? diagnosticsOpen;
3437
+ const issuesRequested = props.showIssues ?? diagnosticsOpen;
3438
+ const issuesActive = hasIssues && issuesRequested;
3397
3439
  const filesActive = filesOpen && hasFiles && !issuesActive;
3398
3440
  const selectSidebarView = (view: 'trails' | 'files' | 'issues') => {
3399
3441
  const nextIssuesActive = view === 'issues';
3400
3442
  setFilesOpen(view === 'files');
3401
- if (nextIssuesActive !== issuesActive) {
3443
+ if (nextIssuesActive !== issuesRequested) {
3402
3444
  if (props.showIssues === undefined) setDiagnosticsOpen(nextIssuesActive);
3403
3445
  props.diagnostic?.onToggle?.();
3404
3446
  }
@@ -3409,7 +3451,7 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
3409
3451
  justifyContent: 'center',
3410
3452
  gap: 4,
3411
3453
  height: 28,
3412
- padding: '4px 7px',
3454
+ padding: '4px 16px',
3413
3455
  fontFamily: theme.fonts.monospace,
3414
3456
  fontSize: theme.fontSizes[0],
3415
3457
  border: 'none',
@@ -3500,13 +3542,14 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
3500
3542
  >
3501
3543
  <button
3502
3544
  type="button"
3503
- aria-label="Trails"
3545
+ aria-label={sidebarScope.trail ? 'Trail' : `Trails (${trailCount} ${trailCount === 1 ? 'trail' : 'trails'})`}
3504
3546
  aria-pressed={!filesActive && !issuesActive}
3505
3547
  onClick={() => selectSidebarView('trails')}
3506
3548
  style={viewButtonStyle(!filesActive && !issuesActive)}
3507
3549
  >
3508
3550
  <Route size={14} aria-hidden />
3509
- <span>Trails</span>
3551
+ <span>{trailViewLabel}</span>
3552
+ {!sidebarScope.trail && <span style={{ fontWeight: 600, marginLeft: 4 }}>{trailCount}</span>}
3510
3553
  </button>
3511
3554
  {hasFiles && (
3512
3555
  <button
@@ -3519,16 +3562,18 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
3519
3562
  >
3520
3563
  <Files size={14} aria-hidden />
3521
3564
  <span>Files</span>
3522
- <span style={{ fontWeight: 600 }}>{fileCount}</span>
3565
+ <span style={{ fontWeight: 600, marginLeft: 4 }}>{fileCount}</span>
3523
3566
  </button>
3524
3567
  )}
3568
+ {hasIssues && (
3525
3569
  <SubsystemDiagnosticToggle
3526
- {...(props.diagnostic ?? { status: 'unknown', issueCount: props.issues?.length })}
3570
+ {...sidebarDiagnostic}
3527
3571
  labelText="Issues"
3528
3572
  segmented
3529
3573
  active={issuesActive}
3530
3574
  onToggle={() => selectSidebarView('issues')}
3531
3575
  />
3576
+ )}
3532
3577
  </div>
3533
3578
  </header>
3534
3579
  )}
@@ -3552,7 +3597,8 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
3552
3597
  />
3553
3598
  ) : (
3554
3599
  <ReactFlowProvider>
3555
- <Inner {...props} showIssues={issuesActive} filesActive={filesActive} measured={size} />
3600
+ <Inner {...props} showIssues={issuesActive} filesActive={filesActive} measured={size}
3601
+ sidebarScope={sidebarScope} onSidebarTrailChange={setSidebarTrailId} />
3556
3602
  </ReactFlowProvider>
3557
3603
  )}
3558
3604
  </div>
@@ -143,6 +143,7 @@ export function SubsystemFileTree({ files, selectedFile, hoveredFile, hoveredFol
143
143
  ref={treeRef}
144
144
  onClickCapture={handleContainerClick}
145
145
  style={{
146
+ paddingTop: headerless ? 8 : undefined,
146
147
  flex: 1,
147
148
  minHeight: 0,
148
149
  display: 'flex',
@@ -187,7 +188,7 @@ export function SubsystemFileTree({ files, selectedFile, hoveredFile, hoveredFol
187
188
  '--trees-fg-muted-override': muted,
188
189
  '--trees-accent-override': theme.colors.primary,
189
190
  '--trees-font-family-override': theme.fonts.monospace,
190
- '--trees-font-size-override': `${theme.fontSizes[0]}px`,
191
+ '--trees-font-size-override': `${theme.fontSizes[1]}px`,
191
192
  '--trees-theme-list-hover-bg': theme.colors.border,
192
193
  '--trees-theme-list-active-selection-bg': theme.colors.border,
193
194
  } as CSSProperties
@@ -56,6 +56,61 @@ function renderNav(props: Record<string, unknown>) {
56
56
  }
57
57
 
58
58
  describe('TrailStepsNav', () => {
59
+ for (const title of ['Short flow', 'Generate the account report and deliver it to every subscribed recipient']) {
60
+ for (const selected of [false, true]) {
61
+ test(`keeps explicit title spacing in the ${selected ? 'selected header' : 'list'} for ${title}`, () => {
62
+ const { getByRole } = renderNav({
63
+ trails: [{ ...TRAILS[0], title }],
64
+ focusedTrailId: selected ? 't1' : null,
65
+ focusedStepIndex: null,
66
+ hoveredTrailStep: null,
67
+ onFocusFlow: () => {},
68
+ onClearFocus: () => {},
69
+ onFocusStep: () => {},
70
+ onHoverStep: () => {},
71
+ onHoverFlow: () => {},
72
+ onLeaveStep: () => {},
73
+ });
74
+ const button = getByRole('button', { name: selected ? title : new RegExp(title), exact: selected });
75
+ const row = selected ? button.parentElement! : button;
76
+ const label = selected ? button : button.children[1] as HTMLElement;
77
+ expect(row.style.paddingTop).toBe('14px');
78
+ expect(row.style.paddingBottom).toBe('14px');
79
+ expect(row.style.paddingLeft).toBe('12px');
80
+ expect(row.style.paddingRight).toBe(row.style.paddingLeft);
81
+ expect(row.style.alignItems).toBe('flex-start');
82
+ expect(label.style.lineHeight).toBe('20px');
83
+ expect((row.firstElementChild as SVGElement).style.marginTop).toBe('3px');
84
+ if (selected) {
85
+ expect(row.style.borderBottomStyle).toBe('');
86
+ expect(getByRole('button', { name: 'Back to trails' }).style.height).toBe('20px');
87
+ }
88
+ });
89
+ }
90
+ }
91
+
92
+ test('keeps the selected trail header when files or issues replace the step body', () => {
93
+ let cleared = false;
94
+ const { getByRole, getByText, queryByText } = renderNav({
95
+ trails: TRAILS,
96
+ focusedTrailId: 't1',
97
+ focusedStepIndex: null,
98
+ hoveredTrailStep: null,
99
+ onFocusFlow: () => {},
100
+ onClearFocus: () => { cleared = true; },
101
+ onFocusStep: () => {},
102
+ onHoverStep: () => {},
103
+ onHoverFlow: () => {},
104
+ onLeaveStep: () => {},
105
+ detailContent: createElement('div', null, 'Files for this trail'),
106
+ });
107
+ expect(getByRole('button', { name: 'First flow', exact: true })).toBeTruthy();
108
+ expect(getByText('Files for this trail')).toBeTruthy();
109
+ expect(queryByText('A.go')).toBeNull();
110
+ fireEvent.click(getByRole('button', { name: 'Back to trails' }));
111
+ expect(cleared).toBe(true);
112
+ });
113
+
59
114
  test('lists trails with step counts and focuses on click', () => {
60
115
  let focused: string | null = null;
61
116
  const { getByText, getByRole } = renderNav({
@@ -161,6 +216,11 @@ describe('TrailStepsNav', () => {
161
216
  expect(getByText('B.go').style.fontFamily).toBe(defaultEditorTheme.fonts.body);
162
217
  expect(getByRole('button', { name: /A\.go/ }).style.paddingLeft).toBe('12px');
163
218
  expect(getByRole('button', { name: /B\.go/ }).style.paddingLeft).toBe('22px');
219
+ for (const name of [/A\.go/, /B\.go/]) {
220
+ const row = getByRole('button', { name });
221
+ expect(row.style.paddingTop).toBe('14px');
222
+ expect(row.style.paddingBottom).toBe('14px');
223
+ }
164
224
  fireEvent.click(getByRole('button', { name: /B\.go/ }));
165
225
  expect(focusedStep).toBe(1);
166
226
  fireEvent.click(getByLabelText('Back to trails'));
@@ -54,6 +54,8 @@ export interface TrailStepsNavProps {
54
54
  listContent?: ReactNode;
55
55
  /** Optional step content, for hosts with copy and guidance controls. */
56
56
  renderSteps?: (trail: SubsystemTrail) => ReactNode;
57
+ /** Replace the step body without replacing the selected trail's header. */
58
+ detailContent?: ReactNode;
57
59
  }
58
60
 
59
61
  const PANE: CSSProperties = {
@@ -80,6 +82,7 @@ export function TrailStepsNav({
80
82
  transitionMs = 340,
81
83
  listContent,
82
84
  renderSteps,
85
+ detailContent,
83
86
  }: TrailStepsNavProps) {
84
87
  const { theme } = useTheme();
85
88
  const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
@@ -149,11 +152,11 @@ export function TrailStepsNav({
149
152
  onClick={() => onFocusFlow(trail)}
150
153
  style={{
151
154
  display: 'flex',
152
- alignItems: 'center',
155
+ alignItems: 'flex-start',
153
156
  gap: 8,
154
157
  width: '100%',
155
158
  minWidth: 0,
156
- padding: '10px 8px 10px 12px',
159
+ padding: '14px 12px',
157
160
  border: 'none',
158
161
  borderRadius: 0,
159
162
  background: focusedRow || hovered ? hoverBg : 'transparent',
@@ -168,6 +171,7 @@ export function TrailStepsNav({
168
171
  aria-hidden
169
172
  style={{
170
173
  flexShrink: 0,
174
+ marginTop: 3,
171
175
  color: focusedRow || hovered ? theme.colors.primary : muted,
172
176
  transition: 'color 120ms ease',
173
177
  }}
@@ -180,6 +184,7 @@ export function TrailStepsNav({
180
184
  fontSize: theme.fontSizes[1],
181
185
  fontFamily: theme.fonts.monospace,
182
186
  fontWeight: 600,
187
+ lineHeight: '20px',
183
188
  color: theme.colors.text,
184
189
  }}
185
190
  >
@@ -189,6 +194,7 @@ export function TrailStepsNav({
189
194
  style={{
190
195
  flexShrink: 0,
191
196
  fontSize: theme.fontSizes[0],
197
+ lineHeight: '20px',
192
198
  fontFamily: theme.fonts.monospace,
193
199
  color: muted,
194
200
  }}
@@ -217,12 +223,11 @@ export function TrailStepsNav({
217
223
  style={{
218
224
  flexShrink: 0,
219
225
  display: 'flex',
220
- alignItems: 'center',
226
+ alignItems: 'flex-start',
221
227
  gap: 8,
222
228
  // Left inset + leading cell + gap match the step rows below, so
223
229
  // the trail icon and the step numbers share one column.
224
- padding: '6px 6px 6px 12px',
225
- borderBottom: `1px solid ${theme.colors.border}`,
230
+ padding: '14px 12px',
226
231
  }}
227
232
  >
228
233
  <Route
@@ -231,7 +236,7 @@ export function TrailStepsNav({
231
236
  aria-hidden
232
237
  // The focused trail: the icon stays primary, matching the row's
233
238
  // hover colour in the list it came from.
234
- style={{ flexShrink: 0, color: theme.colors.primary }}
239
+ style={{ flexShrink: 0, marginTop: 3, color: theme.colors.primary }}
235
240
  />
236
241
  {/* The title is the back affordance too: clicking it goes back to
237
242
  the list, like the trailing close. */}
@@ -254,6 +259,7 @@ export function TrailStepsNav({
254
259
  fontSize: theme.fontSizes[1],
255
260
  fontFamily: theme.fonts.monospace,
256
261
  fontWeight: 600,
262
+ lineHeight: '20px',
257
263
  color: theme.colors.text,
258
264
  }}
259
265
  >
@@ -272,7 +278,7 @@ export function TrailStepsNav({
272
278
  justifyContent: 'center',
273
279
  flexShrink: 0,
274
280
  width: 24,
275
- height: 24,
281
+ height: 20,
276
282
  padding: 0,
277
283
  border: 'none',
278
284
  borderRadius: 4,
@@ -285,7 +291,7 @@ export function TrailStepsNav({
285
291
  <X size={16} strokeWidth={2} />
286
292
  </button>
287
293
  </div>
288
- <div
294
+ {detailContent ?? <div
289
295
  style={{ flex: 1, minHeight: 0, overflowY: 'auto', padding: '0 0 12px' }}
290
296
  onMouseLeave={onLeaveStep}
291
297
  >
@@ -308,7 +314,7 @@ export function TrailStepsNav({
308
314
  gap: 8,
309
315
  width: '100%',
310
316
  minWidth: 0,
311
- padding: `8px 16px 8px ${12 + Math.max(0, stackDepth - 1) * 10}px`,
317
+ padding: `14px 16px 14px ${12 + Math.max(0, stackDepth - 1) * 10}px`,
312
318
  border: 'none',
313
319
  borderRadius: 0,
314
320
  background: active || hovered ? hoverBg : 'transparent',
@@ -370,7 +376,7 @@ export function TrailStepsNav({
370
376
  </button>
371
377
  );
372
378
  })}
373
- </div>
379
+ </div>}
374
380
  </>
375
381
  )}
376
382
  </div>