@principal-ai/subsystems-react 0.56.11 → 0.56.13

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 (38) hide show
  1. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  2. package/dist/subsystem/C4Graph.js +10 -4
  3. package/dist/subsystem/C4Graph.js.map +1 -1
  4. package/dist/subsystem/DescriptionOverlay.d.ts +6 -0
  5. package/dist/subsystem/DescriptionOverlay.d.ts.map +1 -0
  6. package/dist/subsystem/DescriptionOverlay.js +58 -0
  7. package/dist/subsystem/DescriptionOverlay.js.map +1 -0
  8. package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -1
  9. package/dist/subsystem/DiagnosticToggle.js +2 -1
  10. package/dist/subsystem/DiagnosticToggle.js.map +1 -1
  11. package/dist/subsystem/SubsystemComponentGraph.d.ts +2 -1
  12. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  13. package/dist/subsystem/SubsystemComponentGraph.js +307 -284
  14. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  15. package/dist/subsystem/SubsystemFileTree.d.ts.map +1 -1
  16. package/dist/subsystem/SubsystemFileTree.js +2 -1
  17. package/dist/subsystem/SubsystemFileTree.js.map +1 -1
  18. package/dist/subsystem/TrailStepsNav.d.ts.map +1 -1
  19. package/dist/subsystem/TrailStepsNav.js +11 -8
  20. package/dist/subsystem/TrailStepsNav.js.map +1 -1
  21. package/dist/subsystem/TrailsPanel.d.ts +1 -1
  22. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  23. package/dist/subsystem/TrailsPanel.js +42 -66
  24. package/dist/subsystem/TrailsPanel.js.map +1 -1
  25. package/package.json +1 -1
  26. package/src/stories/Canvases/C4/C4Draft.stories.tsx +238 -0
  27. package/src/stories/Canvases/Subsystem/TrailSidebarViews.stories.tsx +2 -2
  28. package/src/subsystem/C4Graph.tsx +10 -4
  29. package/src/subsystem/DescriptionOverlay.test.tsx +76 -0
  30. package/src/subsystem/DescriptionOverlay.tsx +101 -0
  31. package/src/subsystem/DiagnosticToggle.tsx +2 -1
  32. package/src/subsystem/SubsystemComponentGraph.sidebar.test.tsx +81 -10
  33. package/src/subsystem/SubsystemComponentGraph.tsx +68 -26
  34. package/src/subsystem/SubsystemFileTree.tsx +2 -1
  35. package/src/subsystem/TrailStepsNav.test.tsx +38 -0
  36. package/src/subsystem/TrailStepsNav.tsx +11 -8
  37. package/src/subsystem/TrailsPanel.test.tsx +99 -35
  38. package/src/subsystem/TrailsPanel.tsx +38 -85
@@ -89,6 +89,7 @@ import type { ComponentVerificationState } from './ComponentDeclaration';
89
89
  import type { DeclarationSymbolRef, SymbolInspection } from './symbolRefs';
90
90
  import { FileDrawer, FILE_DRAWER_HEIGHT_MS } from './FileDrawer';
91
91
  import { GraphWithSidebar } from './GraphWithSidebar';
92
+ import { DescriptionOverlay } from './DescriptionOverlay';
92
93
  import type { GraphSidebarTab } from './GraphSidebar';
93
94
  import { TrailsPanel, TRAIL_PLAY_PAUSE_MS } from './TrailsPanel';
94
95
  import type { StepEmphasisMap } from './stepEmphasis';
@@ -404,7 +405,8 @@ export interface SubsystemComponentGraphProps {
404
405
  /**
405
406
  * Verification issues for this graph (audit findings). When diagnostics are
406
407
  * active the sidebar's bottom panel becomes the issue list instead of the
407
- * file tree / trails.
408
+ * file tree / trails. Graph issue badges and issue-driven dimming are only
409
+ * shown in that mode.
408
410
  */
409
411
  issues?: SubsystemIssue[];
410
412
  /**
@@ -617,6 +619,7 @@ interface InnerProps extends SubsystemComponentGraphProps {
617
619
  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) {
618
620
  const { theme } = useTheme();
619
621
  const { fitView, fitBounds } = useReactFlow();
622
+ const [descriptionOpen, setDescriptionOpen] = useState(false);
620
623
  const viewport = useViewport();
621
624
  // Restored once per mount from `localStorage` (see `readViewState`). Each
622
625
  // graph is its own tab/mount, so `persistKey` is stable for a mount.
@@ -1262,7 +1265,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1262
1265
  // node says "diagnostics here" for every finding the sidebar lists.
1263
1266
  const issueBadgeByAlias = useMemo(() => {
1264
1267
  const map = new Map<string, SubsystemNodeIssue>();
1265
- if (!issues?.length) return map;
1268
+ if (showIssues !== true || !issues?.length) return map;
1266
1269
  for (const issue of issues) {
1267
1270
  const rung = issueRung(issue.kind);
1268
1271
  if (!rung && !ISSUE_KIND_ICON[issue.kind]) continue;
@@ -1297,7 +1300,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1297
1300
  if (kinded) map.set(alias, { ...badge, kind: kinded.kind });
1298
1301
  }
1299
1302
  return map;
1300
- }, [issues, issueComponent]);
1303
+ }, [showIssues, issues, issueComponent]);
1301
1304
 
1302
1305
  // Per-frame diagnostics badge: the same fold, but for findings about a
1303
1306
  // BOUNDARY rather than a construct. Keyed by the region's React Flow node
@@ -1308,7 +1311,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1308
1311
  // borrowing its layer's icon would imply the frame is at fault for it.
1309
1312
  const regionIssueByNodeId = useMemo(() => {
1310
1313
  const map = new Map<string, SubsystemRegionIssue>();
1311
- if (!issues?.length) return map;
1314
+ if (showIssues !== true || !issues?.length) return map;
1312
1315
  for (const issue of issues) {
1313
1316
  const target = issue.target;
1314
1317
  if (!target || !ISSUE_KIND_ICON[issue.kind]) continue;
@@ -1329,7 +1332,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1329
1332
  });
1330
1333
  }
1331
1334
  return map;
1332
- }, [issues]);
1335
+ }, [showIssues, issues]);
1333
1336
 
1334
1337
  // Per-edge step verification badge: maps edge IDs to step verification status.
1335
1338
  // Keyed by the React Flow edge id (from → mechanism → to). An edge can have
@@ -1347,7 +1350,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1347
1350
  step_span_stale: 1,
1348
1351
  };
1349
1352
  const map = new Map<string, { kind: StepIssueKind; severity: 'info' | 'error'; count: number }>();
1350
- if (!issues?.length || !trails?.length) {
1353
+ if (showIssues !== true || !issues?.length || !trails?.length) {
1351
1354
  return map;
1352
1355
  }
1353
1356
  for (const issue of issues) {
@@ -1374,7 +1377,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1374
1377
  });
1375
1378
  }
1376
1379
  return map;
1377
- }, [issues, trails, issueStep]);
1380
+ }, [showIssues, issues, trails, issueStep]);
1378
1381
 
1379
1382
  // Expanded verification layers in the diagnostics list. When any layer with
1380
1383
  // findings is expanded, the canvas dims everything that layer does not
@@ -1423,7 +1426,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1423
1426
  // Returns null when nothing is expanded, no expanded layer has findings, or
1424
1427
  // the findings implicate nothing — so no dimming is applied.
1425
1428
  const issueFocus = useMemo(() => {
1426
- if (!issues?.length || expandedIssueCategories.length === 0) return null;
1429
+ if (showIssues !== true || !issues?.length || expandedIssueCategories.length === 0) return null;
1427
1430
  const active = new Set(expandedIssueCategories);
1428
1431
  const activeIssues = issues.filter((i) => active.has(issueCategory(i)));
1429
1432
  if (activeIssues.length === 0) return null;
@@ -1478,7 +1481,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1478
1481
  if (nodeIds.has(e.source) && nodeIds.has(e.target)) edgeIds.add(e.id);
1479
1482
  }
1480
1483
  return { nodeIds, edgeIds };
1481
- }, [issues, expandedIssueCategories, components, trails, resolveAlias]);
1484
+ }, [showIssues, issues, expandedIssueCategories, components, trails, resolveAlias]);
1482
1485
  const issueFocusNodeIds = issueFocus?.nodeIds ?? null;
1483
1486
  const issueFocusEdgeIds = issueFocus?.edgeIds ?? null;
1484
1487
 
@@ -2729,17 +2732,45 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2729
2732
  {sidebarExtra}
2730
2733
  {description && (
2731
2734
  <div style={{ fontSize: theme.fontSizes[0], lineHeight: 1.5 }}>
2732
- <IndustryMarkdownSlide
2733
- content={description}
2734
- slideIdPrefix="subsystem-desc"
2735
- slideIndex={0}
2736
- isVisible={true}
2737
- theme={theme}
2738
- disableScroll={true}
2739
- disableBasePadding
2740
- enableKeyboardScrolling={false}
2741
- autoFocusOnVisible={false}
2742
- />
2735
+ <div style={{
2736
+ display: '-webkit-box',
2737
+ WebkitBoxOrient: 'vertical',
2738
+ WebkitLineClamp: 6,
2739
+ maxHeight: '9em',
2740
+ overflow: 'hidden',
2741
+ }}>
2742
+ <IndustryMarkdownSlide
2743
+ content={description}
2744
+ slideIdPrefix="subsystem-desc"
2745
+ slideIndex={0}
2746
+ isVisible={true}
2747
+ theme={theme}
2748
+ disableScroll={true}
2749
+ disableBasePadding
2750
+ enableKeyboardScrolling={false}
2751
+ autoFocusOnVisible={false}
2752
+ />
2753
+ </div>
2754
+ <button
2755
+ type="button"
2756
+ aria-label="Read full description"
2757
+ aria-haspopup="dialog"
2758
+ aria-expanded={descriptionOpen}
2759
+ onClick={() => setDescriptionOpen(true)}
2760
+ style={{
2761
+ marginTop: 6,
2762
+ padding: 0,
2763
+ border: 'none',
2764
+ background: 'transparent',
2765
+ color: theme.colors.textSecondary,
2766
+ fontFamily: theme.fonts.body,
2767
+ fontSize: theme.fontSizes[0],
2768
+ textDecoration: 'underline',
2769
+ cursor: 'pointer',
2770
+ }}
2771
+ >
2772
+ More
2773
+ </button>
2743
2774
  </div>
2744
2775
  )}
2745
2776
  {sidebarAfterDescription}
@@ -2765,6 +2796,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2765
2796
  <div style={{ padding: 16, color: theme.colors.textSecondary }}>No files for this trail.</div>
2766
2797
  ) : (
2767
2798
  <SubsystemFileTree
2799
+ headerless
2768
2800
  files={treeFilePaths}
2769
2801
  selectedFile={openFile ? treePaths.toDisplay.get(openFile) : null}
2770
2802
  onSelectFile={(path) => {
@@ -2862,7 +2894,6 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2862
2894
  <GraphWithSidebar
2863
2895
  hidden={!sidebarVisible}
2864
2896
  bodyHidden={!sidebarBodyVisible}
2865
- headerMaxHeight={sidebarBodyVisible ? '30%' : '100%'}
2866
2897
  header={sidebarHeader}
2867
2898
  tabs={sidebarTabs}
2868
2899
  activeTab={diagnosticsTab}
@@ -2875,6 +2906,9 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2875
2906
  persistKey={persistKey}
2876
2907
  onResizeEnd={() => requestAnimationFrame(() => fitView())}
2877
2908
  >
2909
+ {!hideSidebar && description && descriptionOpen && (
2910
+ <DescriptionOverlay description={description} onClose={() => setDescriptionOpen(false)} />
2911
+ )}
2878
2912
  {/* Graph area */}
2879
2913
  <div style={{ flex: 1, position: 'relative', display: 'flex', flexDirection: committedDirection === 'vertical' ? 'row' : 'column', minHeight: 0, minWidth: 0 }}>
2880
2914
  {/* Canvas box — the edge-label overlay and legend anchor here, so
@@ -3413,6 +3447,7 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
3413
3447
  );
3414
3448
  const fileCount = sidebarScope.treePaths.entries.length;
3415
3449
  const trailViewLabel = sidebarScope.trail ? 'Trail' : 'Trails';
3450
+ const trailCount = props.trails?.length ?? 0;
3416
3451
  const hasFiles = fileCount > 0 || sidebarScope.trail != null;
3417
3452
  const sidebarDiagnostic: SubsystemDiagnostic = sidebarScope.trail ? {
3418
3453
  ...props.diagnostic,
@@ -3422,18 +3457,22 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
3422
3457
  fixableCount: sidebarScope.issues.filter((issue) => issue.fix != null).length,
3423
3458
  title: undefined,
3424
3459
  } : props.diagnostic ?? { status: 'unknown', issueCount: props.issues?.length };
3460
+ const hasIssues = (props.issues != null
3461
+ ? sidebarScope.issues.length
3462
+ : sidebarDiagnostic.issueCount ?? 0) > 0;
3425
3463
  useEffect(() => {
3426
3464
  if (sidebarTrailId != null && !sidebarScope.trail) setSidebarTrailId(null);
3427
3465
  }, [sidebarTrailId, sidebarScope.trail]);
3428
3466
  const [diagnosticsOpen, setDiagnosticsOpen] = useState<boolean>(
3429
3467
  props.showIssues ?? (props.issues?.length ?? 0) > 0,
3430
3468
  );
3431
- const issuesActive = props.showIssues ?? diagnosticsOpen;
3469
+ const issuesRequested = props.showIssues ?? diagnosticsOpen;
3470
+ const issuesActive = hasIssues && issuesRequested;
3432
3471
  const filesActive = filesOpen && hasFiles && !issuesActive;
3433
3472
  const selectSidebarView = (view: 'trails' | 'files' | 'issues') => {
3434
3473
  const nextIssuesActive = view === 'issues';
3435
3474
  setFilesOpen(view === 'files');
3436
- if (nextIssuesActive !== issuesActive) {
3475
+ if (nextIssuesActive !== issuesRequested) {
3437
3476
  if (props.showIssues === undefined) setDiagnosticsOpen(nextIssuesActive);
3438
3477
  props.diagnostic?.onToggle?.();
3439
3478
  }
@@ -3444,7 +3483,7 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
3444
3483
  justifyContent: 'center',
3445
3484
  gap: 4,
3446
3485
  height: 28,
3447
- padding: '4px 7px',
3486
+ padding: '4px 16px',
3448
3487
  fontFamily: theme.fonts.monospace,
3449
3488
  fontSize: theme.fontSizes[0],
3450
3489
  border: 'none',
@@ -3535,13 +3574,14 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
3535
3574
  >
3536
3575
  <button
3537
3576
  type="button"
3538
- aria-label={trailViewLabel}
3577
+ aria-label={sidebarScope.trail ? 'Trail' : `Trails (${trailCount} ${trailCount === 1 ? 'trail' : 'trails'})`}
3539
3578
  aria-pressed={!filesActive && !issuesActive}
3540
3579
  onClick={() => selectSidebarView('trails')}
3541
3580
  style={viewButtonStyle(!filesActive && !issuesActive)}
3542
3581
  >
3543
3582
  <Route size={14} aria-hidden />
3544
3583
  <span>{trailViewLabel}</span>
3584
+ {!sidebarScope.trail && <span style={{ fontWeight: 600, marginLeft: 4 }}>{trailCount}</span>}
3545
3585
  </button>
3546
3586
  {hasFiles && (
3547
3587
  <button
@@ -3554,9 +3594,10 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
3554
3594
  >
3555
3595
  <Files size={14} aria-hidden />
3556
3596
  <span>Files</span>
3557
- <span style={{ fontWeight: 600 }}>{fileCount}</span>
3597
+ <span style={{ fontWeight: 600, marginLeft: 4 }}>{fileCount}</span>
3558
3598
  </button>
3559
3599
  )}
3600
+ {hasIssues && (
3560
3601
  <SubsystemDiagnosticToggle
3561
3602
  {...sidebarDiagnostic}
3562
3603
  labelText="Issues"
@@ -3564,6 +3605,7 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
3564
3605
  active={issuesActive}
3565
3606
  onToggle={() => selectSidebarView('issues')}
3566
3607
  />
3608
+ )}
3567
3609
  </div>
3568
3610
  </header>
3569
3611
  )}
@@ -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,39 @@ 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
+
59
92
  test('keeps the selected trail header when files or issues replace the step body', () => {
60
93
  let cleared = false;
61
94
  const { getByRole, getByText, queryByText } = renderNav({
@@ -183,6 +216,11 @@ describe('TrailStepsNav', () => {
183
216
  expect(getByText('B.go').style.fontFamily).toBe(defaultEditorTheme.fonts.body);
184
217
  expect(getByRole('button', { name: /A\.go/ }).style.paddingLeft).toBe('12px');
185
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
+ }
186
224
  fireEvent.click(getByRole('button', { name: /B\.go/ }));
187
225
  expect(focusedStep).toBe(1);
188
226
  fireEvent.click(getByLabelText('Back to trails'));
@@ -152,11 +152,11 @@ export function TrailStepsNav({
152
152
  onClick={() => onFocusFlow(trail)}
153
153
  style={{
154
154
  display: 'flex',
155
- alignItems: 'center',
155
+ alignItems: 'flex-start',
156
156
  gap: 8,
157
157
  width: '100%',
158
158
  minWidth: 0,
159
- padding: '10px 8px 10px 12px',
159
+ padding: '14px 12px',
160
160
  border: 'none',
161
161
  borderRadius: 0,
162
162
  background: focusedRow || hovered ? hoverBg : 'transparent',
@@ -171,6 +171,7 @@ export function TrailStepsNav({
171
171
  aria-hidden
172
172
  style={{
173
173
  flexShrink: 0,
174
+ marginTop: 3,
174
175
  color: focusedRow || hovered ? theme.colors.primary : muted,
175
176
  transition: 'color 120ms ease',
176
177
  }}
@@ -183,6 +184,7 @@ export function TrailStepsNav({
183
184
  fontSize: theme.fontSizes[1],
184
185
  fontFamily: theme.fonts.monospace,
185
186
  fontWeight: 600,
187
+ lineHeight: '20px',
186
188
  color: theme.colors.text,
187
189
  }}
188
190
  >
@@ -192,6 +194,7 @@ export function TrailStepsNav({
192
194
  style={{
193
195
  flexShrink: 0,
194
196
  fontSize: theme.fontSizes[0],
197
+ lineHeight: '20px',
195
198
  fontFamily: theme.fonts.monospace,
196
199
  color: muted,
197
200
  }}
@@ -220,12 +223,11 @@ export function TrailStepsNav({
220
223
  style={{
221
224
  flexShrink: 0,
222
225
  display: 'flex',
223
- alignItems: 'center',
226
+ alignItems: 'flex-start',
224
227
  gap: 8,
225
228
  // Left inset + leading cell + gap match the step rows below, so
226
229
  // the trail icon and the step numbers share one column.
227
- padding: '6px 6px 6px 12px',
228
- borderBottom: `1px solid ${theme.colors.border}`,
230
+ padding: '14px 12px',
229
231
  }}
230
232
  >
231
233
  <Route
@@ -234,7 +236,7 @@ export function TrailStepsNav({
234
236
  aria-hidden
235
237
  // The focused trail: the icon stays primary, matching the row's
236
238
  // hover colour in the list it came from.
237
- style={{ flexShrink: 0, color: theme.colors.primary }}
239
+ style={{ flexShrink: 0, marginTop: 3, color: theme.colors.primary }}
238
240
  />
239
241
  {/* The title is the back affordance too: clicking it goes back to
240
242
  the list, like the trailing close. */}
@@ -257,6 +259,7 @@ export function TrailStepsNav({
257
259
  fontSize: theme.fontSizes[1],
258
260
  fontFamily: theme.fonts.monospace,
259
261
  fontWeight: 600,
262
+ lineHeight: '20px',
260
263
  color: theme.colors.text,
261
264
  }}
262
265
  >
@@ -275,7 +278,7 @@ export function TrailStepsNav({
275
278
  justifyContent: 'center',
276
279
  flexShrink: 0,
277
280
  width: 24,
278
- height: 24,
281
+ height: 20,
279
282
  padding: 0,
280
283
  border: 'none',
281
284
  borderRadius: 4,
@@ -311,7 +314,7 @@ export function TrailStepsNav({
311
314
  gap: 8,
312
315
  width: '100%',
313
316
  minWidth: 0,
314
- 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`,
315
318
  border: 'none',
316
319
  borderRadius: 0,
317
320
  background: active || hovered ? hoverBg : 'transparent',
@@ -115,8 +115,6 @@ function renderPanel(overrides: Partial<TrailsPanelProps> = {}) {
115
115
  );
116
116
  }
117
117
 
118
- const COPY_LABEL = 'Copy step 1 of Auth flow for an agent';
119
-
120
118
  describe('TrailsPanel drilldown navigation', () => {
121
119
  test('clicking a trail opens its steps, and Back restores the list without playback', () => {
122
120
  const onFocusFlow = mock(() => {});
@@ -165,14 +163,17 @@ describe('TrailsPanel drilldown navigation', () => {
165
163
  expect(queryByRole('button', { name: /Login.submit/ })).toBeNull();
166
164
  });
167
165
 
168
- test('keeps step-copy controls in the focused pane without playback controls', async () => {
169
- const { getByText, getByLabelText, queryByLabelText } = renderPanel({
166
+ test('supports Command-click copy in the focused pane without extra controls', async () => {
167
+ const onFocusStep = mock(() => {});
168
+ const { getByText, getByLabelText, queryByLabelText, queryByRole } = renderPanel({
170
169
  drilldown: true,
171
170
  focusedTrailId: 'auth-flow',
171
+ onFocusStep,
172
172
  });
173
- fireEvent.mouseEnter(getByText('Login.submit').closest('button')!);
174
- fireEvent.click(getByLabelText(COPY_LABEL));
173
+ fireEvent.click(getByText('Login.submit').closest('button')!, { metaKey: true });
175
174
  await waitFor(() => expect(writeText).toHaveBeenCalledWith(buildStepBrief(trails[0]!, 0)));
175
+ expect(onFocusStep).not.toHaveBeenCalled();
176
+ expect(queryByRole('button', { name: /Copy step/ })).toBeNull();
176
177
  expect(queryByLabelText('Play Auth flow')).toBeNull();
177
178
  expect(queryByLabelText('Collapse Auth flow')).toBeNull();
178
179
  expect(getByLabelText('Back to trails')).toBeTruthy();
@@ -188,6 +189,35 @@ describe('TrailsPanel drilldown navigation', () => {
188
189
  });
189
190
 
190
191
  describe('TrailsPanel trail titles', () => {
192
+ for (const title of ['Short flow', 'Validate every selected checkout item and persist the complete customer order']) {
193
+ for (const collapsed of [true, false]) {
194
+ test(`keeps explicit title spacing for ${collapsed ? 'collapsed' : 'expanded'} ${title}`, () => {
195
+ const { getByRole } = renderPanel({
196
+ trails: [{ ...trails[0]!, title }],
197
+ expandedTrails: collapsed ? new Set() : new Set(['auth-flow']),
198
+ onReorder: () => {},
199
+ });
200
+ const button = getByRole('button', { name: title, exact: true });
201
+ const row = button.parentElement!;
202
+ expect(row.style.paddingTop).toBe('14px');
203
+ expect(row.style.paddingBottom).toBe('14px');
204
+ expect(row.style.paddingLeft).toBe('16px');
205
+ expect(row.style.paddingRight).toBe(row.style.paddingLeft);
206
+ expect(row.style.minHeight).toBe('');
207
+ expect(row.style.alignItems).toBe('flex-start');
208
+ expect(button.style.padding).toBe('0px');
209
+ expect((button.lastElementChild as HTMLElement).style.lineHeight).toBe('20px');
210
+ expect((button.firstElementChild as SVGElement).style.marginTop).toBe('3px');
211
+ if (collapsed) {
212
+ expect(getByRole('button', { name: `Reorder ${title}` }).style.height).toBe('20px');
213
+ expect(getByRole('button', { name: `Reorder ${title}` }).style.marginRight).toBe('');
214
+ } else {
215
+ expect(getByRole('button', { name: `Play ${title}` }).style.height).toBe('20px');
216
+ }
217
+ });
218
+ }
219
+ }
220
+
191
221
  test('highlights immediately without a CSS fade while graph preview waits', async () => {
192
222
  const onHoverFlow = mock(() => {});
193
223
  const onLeaveStep = mock(() => {});
@@ -253,30 +283,28 @@ describe('TrailsPanel trail titles', () => {
253
283
  });
254
284
 
255
285
  describe('TrailsPanel step copy', () => {
256
- test('copy icon is hidden until the step row is hovered', () => {
257
- const { getByText, queryByLabelText } = renderPanel();
258
- expect(queryByLabelText(COPY_LABEL)).toBeNull();
286
+ test('does not show a copy button on hover or keyboard focus', () => {
287
+ const { getByText, queryByRole, container } = renderPanel();
259
288
  const row = getByText('Login.submit').closest('button')!;
260
289
  fireEvent.mouseEnter(row);
261
- expect(queryByLabelText(COPY_LABEL)).not.toBeNull();
262
- });
263
-
264
- test('copy icon is revealed on keyboard focus', () => {
265
- const { getByText, queryByLabelText } = renderPanel();
266
- const row = getByText('Login.submit').closest('button')!;
267
290
  fireEvent.focus(row);
268
- expect(queryByLabelText(COPY_LABEL)).not.toBeNull();
291
+ expect(queryByRole('button', { name: /Copy step/ })).toBeNull();
292
+ expect(container.querySelector('.lucide-copy')).toBeNull();
293
+ expect(row.hasAttribute('title')).toBe(false);
294
+ expect(row.firstElementChild?.textContent).toBe('1');
269
295
  });
270
296
 
271
- test('clicking copies the step brief and flashes a copied state', async () => {
272
- const { getByText, getByLabelText } = renderPanel();
273
- fireEvent.mouseEnter(getByText('Login.submit').closest('button')!);
274
- const copy = getByLabelText(COPY_LABEL);
275
- fireEvent.mouseDown(copy);
276
- fireEvent.click(copy);
277
- await waitFor(() => expect(writeText).toHaveBeenCalledTimes(1));
297
+ test('Command-click copies the brief without focusing and replaces the number with a checkmark', async () => {
298
+ const onFocusStep = mock(() => {});
299
+ const { getByText, getByRole } = renderPanel({ onFocusStep });
300
+ const row = getByText('Login.submit').closest('button')!;
301
+ fireEvent.click(row, { metaKey: true });
302
+ await waitFor(() => expect(getByRole('status', { name: 'Copied' })).toBeTruthy());
303
+ expect(writeText).toHaveBeenCalledTimes(1);
278
304
  expect(writeText).toHaveBeenCalledWith(buildStepBrief(trails[0]!, 0));
279
- expect(copy.getAttribute('title')).toBe('Copied');
305
+ expect(onFocusStep).not.toHaveBeenCalled();
306
+ expect(row.hasAttribute('title')).toBe(false);
307
+ expect(row.firstElementChild?.querySelector('.lucide-check')).not.toBeNull();
280
308
  });
281
309
 
282
310
  test('hovering the row still focuses the step on click', () => {
@@ -286,26 +314,40 @@ describe('TrailsPanel step copy', () => {
286
314
  fireEvent.mouseEnter(row);
287
315
  fireEvent.click(row);
288
316
  expect(onFocusStep).toHaveBeenCalledWith(trails[0]!, 0);
317
+ expect(writeText).not.toHaveBeenCalled();
289
318
  });
290
319
 
291
320
  test('the copied checkmark clears after the feedback window', async () => {
292
- const { getByText, getByLabelText } = renderPanel();
293
- fireEvent.mouseEnter(getByText('Login.submit').closest('button')!);
294
- const copy = getByLabelText(COPY_LABEL);
295
- fireEvent.click(copy);
296
- await waitFor(() => expect(copy.getAttribute('title')).toBe('Copied'));
321
+ const { getByText, getByRole, queryByRole } = renderPanel();
322
+ const row = getByText('Login.submit').closest('button')!;
323
+ fireEvent.click(row, { metaKey: true });
324
+ await waitFor(() => expect(getByRole('status', { name: 'Copied' })).toBeTruthy());
297
325
  await new Promise((r) => setTimeout(r, STEP_COPY_FEEDBACK_MS + 20));
298
- await waitFor(() => expect(copy.getAttribute('title')).not.toBe('Copied'));
326
+ await waitFor(() => expect(queryByRole('status', { name: 'Copied' })).toBeNull());
327
+ expect(row.firstElementChild?.textContent).toBe('1');
299
328
  });
300
329
 
301
330
  test('a failed copy shows no copied state', async () => {
302
331
  writeText.mockImplementationOnce(() => Promise.reject(new Error('denied')));
303
- const { getByText, getByLabelText } = renderPanel();
304
- fireEvent.mouseEnter(getByText('Login.submit').closest('button')!);
305
- const copy = getByLabelText(COPY_LABEL);
306
- fireEvent.click(copy);
332
+ const { getByText, queryByRole } = renderPanel();
333
+ const row = getByText('Login.submit').closest('button')!;
334
+ fireEvent.click(row, { metaKey: true });
307
335
  await new Promise((r) => setTimeout(r, 10));
308
- expect(copy.getAttribute('title')).toBe('Copy this step for an agent');
336
+ expect(writeText).toHaveBeenCalledTimes(1);
337
+ expect(queryByRole('status', { name: 'Copied' })).toBeNull();
338
+ expect(row.firstElementChild?.textContent).toBe('1');
339
+ });
340
+
341
+ test('an unavailable clipboard leaves the number and does not focus the step', () => {
342
+ Object.defineProperty(navigator, 'clipboard', { value: undefined, configurable: true });
343
+ const onFocusStep = mock(() => {});
344
+ const { getByText, queryByRole } = renderPanel({ onFocusStep });
345
+ const row = getByText('Login.submit').closest('button')!;
346
+ fireEvent.click(row, { metaKey: true });
347
+ expect(writeText).not.toHaveBeenCalled();
348
+ expect(onFocusStep).not.toHaveBeenCalled();
349
+ expect(queryByRole('status', { name: 'Copied' })).toBeNull();
350
+ expect(row.firstElementChild?.textContent).toBe('1');
309
351
  });
310
352
  });
311
353
 
@@ -334,9 +376,31 @@ describe('TrailsPanel step semantics', () => {
334
376
  expect(symbol.style.color).toBe(normal.style.color);
335
377
  expect(symbol.style.fontFamily).toBe(defaultEditorTheme.fonts.body);
336
378
  expect(symbol.previousElementSibling).toBe(annotation);
379
+ const row = symbol.closest('button')!;
380
+ expect(row.style.paddingTop).toBe('14px');
381
+ expect(row.style.paddingBottom).toBe('14px');
382
+ expect(row.style.paddingLeft).toBe(drilldown ? '12px' : '16px');
383
+ expect((row.firstElementChild as HTMLElement).style.width).toBe('14px');
384
+ expect((row.firstElementChild as HTMLElement).style.textAlign).toBe('center');
337
385
  fireEvent.click(getByRole('button', { name: /Submit the login request.*Login.submit/ }));
338
386
  expect(onFocusStep).toHaveBeenCalledWith(trail, 0);
339
387
  });
388
+
389
+ test(`keeps nested steps offset from the ${drilldown ? 'selected' : 'inline'} header column`, () => {
390
+ const trail: SubsystemTrail = {
391
+ ...trails[0]!,
392
+ steps: [
393
+ { ...trails[0]!.steps[0]!, from: 'a', to: 'b', symbol: 'A.go' },
394
+ { ...trails[0]!.steps[0]!, from: 'b', to: 'c', symbol: 'B.go' },
395
+ { ...trails[0]!.steps[0]!, from: 'a', to: 'd', symbol: 'A.next' },
396
+ ],
397
+ };
398
+ const { getByText } = renderPanel({ trails: [trail], drilldown, focusedTrailId: trail.id });
399
+ const inset = drilldown ? 12 : 16;
400
+ expect(getByText('A.go').closest('button')!.style.paddingLeft).toBe(`${inset}px`);
401
+ expect(getByText('B.go').closest('button')!.style.paddingLeft).toBe(`${inset + 10}px`);
402
+ expect(getByText('A.next').closest('button')!.style.paddingLeft).toBe(`${inset}px`);
403
+ });
340
404
  }
341
405
 
342
406
  test('calls leads with the caller frame', () => {