@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
@@ -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', () => {
@@ -11,9 +11,9 @@
11
11
  */
12
12
 
13
13
  import { useCallback, useEffect, useRef, useState } from 'react';
14
- import type { DragEvent as ReactDragEvent } from 'react';
14
+ import type { DragEvent as ReactDragEvent, ReactNode } from 'react';
15
15
  import { useTheme } from '@principal-ade/industry-theme';
16
- import { Check, ChevronDown, Copy, Focus, GripVertical, Minimize2, Pause, Play, Route, Star } from 'lucide-react';
16
+ import { Check, ChevronDown, Focus, GripVertical, Minimize2, Pause, Play, Route, Star } from 'lucide-react';
17
17
  import { IndustryMarkdownSlide } from 'themed-markdown';
18
18
  import {
19
19
  reorderTargetIndex,
@@ -45,10 +45,10 @@ const EMPHASIS_COLOR = '#f5a524';
45
45
  */
46
46
  export const TRAIL_HOVER_PREVIEW_MS = 150;
47
47
 
48
- /** How long a step's copy button flashes its "copied" checkmark (ms). */
48
+ /** How long a step's number shows its "copied" checkmark (ms). */
49
49
  export const STEP_COPY_FEEDBACK_MS = 2000;
50
50
 
51
- /** Square size (px) of a trail header's Play/Collapse control. */
51
+ /** Width (px) of a trail header's Play/Collapse control. */
52
52
  const TRAIL_CONTROL_SIZE = 34;
53
53
 
54
54
  /** Drag state handed to a row's grip while a reorder is in flight. */
@@ -166,11 +166,6 @@ function TrailFlow({
166
166
  // A pending preview must not fire after the row unmounts or collapses.
167
167
  useEffect(() => () => cancelHoverPreview(), [cancelHoverPreview]);
168
168
  const stepButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
169
- // Per-step copy affordance: revealed while a row is hovered or holds focus.
170
- // `focusedStep` tracks focus-within (button or its copy control) so the icon
171
- // stays put while the pointer or focus moves between them.
172
- const [focusedStep, setFocusedStep] = useState<number | null>(null);
173
- const [copyHoverStep, setCopyHoverStep] = useState<number | null>(null);
174
169
  const [copiedStep, setCopiedStep] = useState<number | null>(null);
175
170
  const copyTimerRef = useRef<number | null>(null);
176
171
  // Autoplay: stepping through the flow's steps with a pause between each.
@@ -291,10 +286,9 @@ function TrailFlow({
291
286
  }}
292
287
  style={{
293
288
  display: 'flex',
294
- alignItems: 'center',
289
+ alignItems: 'flex-start',
295
290
  gap: 4,
296
- minHeight: TRAIL_CONTROL_SIZE,
297
- padding: '0 0 0 16px',
291
+ padding: '14px 16px',
298
292
  // A collapsed row follows the pointer, not the delayed graph preview.
299
293
  background: headerHover || (!collapsed && wholeFlowActive) ? hoverBg : 'transparent',
300
294
  }}
@@ -318,16 +312,10 @@ function TrailFlow({
318
312
  style={{
319
313
  flex: 1,
320
314
  display: 'flex',
321
- alignItems: 'center',
315
+ alignItems: 'flex-start',
322
316
  minWidth: 0,
323
317
  gap: 8,
324
- // Vertical padding, not just the row's min-height: a title that
325
- // wraps to two+ lines must grow the header past `minHeight`, and
326
- // without this the text sits flush against the row's top and bottom
327
- // edges (the controls stretch to the text height). 6px keeps a
328
- // one-line title under `TRAIL_CONTROL_SIZE`, so single-line rows
329
- // stay 34px.
330
- padding: '6px 0',
318
+ padding: 0,
331
319
  border: 'none',
332
320
  background: 'transparent',
333
321
  textAlign: 'left',
@@ -340,6 +328,7 @@ function TrailFlow({
340
328
  aria-hidden
341
329
  style={{
342
330
  flexShrink: 0,
331
+ marginTop: 3,
343
332
  color: headerHover || (!collapsed && wholeFlowActive) ? theme.colors.primary : muted,
344
333
  }}
345
334
  />
@@ -350,6 +339,7 @@ function TrailFlow({
350
339
  fontSize: theme.fontSizes[1],
351
340
  fontFamily: theme.fonts.monospace,
352
341
  fontWeight: 600,
342
+ lineHeight: '20px',
353
343
  color: theme.colors.text,
354
344
  }}
355
345
  >
@@ -365,6 +355,7 @@ function TrailFlow({
365
355
  gap: 2,
366
356
  flexShrink: 0,
367
357
  marginRight: 4,
358
+ height: 20,
368
359
  fontSize: theme.fontSizes[0],
369
360
  fontFamily: theme.fonts.monospace,
370
361
  color: theme.colors.accent ?? theme.colors.primary ?? muted,
@@ -401,7 +392,7 @@ function TrailFlow({
401
392
  alignItems: 'center',
402
393
  justifyContent: 'center',
403
394
  flexShrink: 0,
404
- alignSelf: 'stretch',
395
+ height: 20,
405
396
  width: TRAIL_CONTROL_SIZE,
406
397
  padding: 0,
407
398
  border: 'none',
@@ -434,7 +425,7 @@ function TrailFlow({
434
425
  alignItems: 'center',
435
426
  justifyContent: 'center',
436
427
  flexShrink: 0,
437
- alignSelf: 'stretch',
428
+ height: 20,
438
429
  width: TRAIL_CONTROL_SIZE,
439
430
  padding: 0,
440
431
  border: 'none',
@@ -463,7 +454,7 @@ function TrailFlow({
463
454
  alignItems: 'center',
464
455
  justifyContent: 'center',
465
456
  flexShrink: 0,
466
- alignSelf: 'stretch',
457
+ height: 20,
467
458
  width: TRAIL_CONTROL_SIZE,
468
459
  padding: 0,
469
460
  border: 'none',
@@ -496,8 +487,8 @@ function TrailFlow({
496
487
  display: 'inline-flex',
497
488
  alignItems: 'center',
498
489
  flexShrink: 0,
499
- marginRight: 8,
500
- padding: '2px 0',
490
+ height: 20,
491
+ padding: 0,
501
492
  color: gripHover || reorder.isDragging ? theme.colors.text : muted,
502
493
  cursor: reorder.isDragging ? 'grabbing' : 'grab',
503
494
  transition: 'color 120ms ease',
@@ -518,10 +509,7 @@ function TrailFlow({
518
509
  >
519
510
  {trail.steps.map((step, i) => {
520
511
  const stepActive = active !== null && active.stepIndex === i;
521
- const revealed =
522
- stepActive || hoveredStep === i || focusedStep === i || copiedStep === i;
523
512
  const copied = copiedStep === i;
524
- const copyHovered = copyHoverStep === i;
525
513
  const record = stepEmphasis?.get(emphasisKey(trail.id, step));
526
514
  const display = trailStepDisplay(step, componentByAlias);
527
515
  // Calls nest; uses/data steps sit at their anchor frame's depth.
@@ -530,6 +518,7 @@ function TrailFlow({
530
518
  i,
531
519
  componentByAlias,
532
520
  ).frames.length;
521
+ const stepInset = (hideHeader ? 12 : 16) + Math.max(0, stackDepth - 1) * 10;
533
522
  // The guidance body follows step focus: clicking a step opens it,
534
523
  // clicking the focused step again returns to the flow. Hover is
535
524
  // deliberately not enough — the list would flutter on sweep.
@@ -538,14 +527,8 @@ function TrailFlow({
538
527
  <div
539
528
  key={`${trailStepGraphEdgeId(step)}-${i}`}
540
529
  style={{ position: 'relative' }}
541
- onFocus={() => setFocusedStep(i)}
542
- onBlur={(e) => {
543
- if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
544
- setFocusedStep((cur) => (cur === i ? null : cur));
545
- }
546
- }}
547
530
  >
548
- {/* The row's absolute affordances (star, copy) are scoped to
531
+ {/* The row's absolute star is scoped to
549
532
  this wrapper so the guidance body below cannot stretch
550
533
  them — `top:0;bottom:0` on the outer row would grow their
551
534
  hit area with the markdown block. */}
@@ -559,16 +542,23 @@ function TrailFlow({
559
542
  setHoveredStep(i);
560
543
  scheduleHoverPreview(() => onHoverStep(trail, i));
561
544
  }}
562
- onClick={() => onFocusStep(trail, i)}
545
+ onClick={(e) => {
546
+ if (e.metaKey) {
547
+ e.preventDefault();
548
+ e.stopPropagation();
549
+ cancelHoverPreview();
550
+ copyStep(i);
551
+ return;
552
+ }
553
+ onFocusStep(trail, i);
554
+ }}
563
555
  style={{
564
556
  display: 'flex',
565
557
  alignItems: 'flex-start',
566
558
  gap: 8,
567
559
  minWidth: 0,
568
560
  width: '100%',
569
- padding: `8px 70px 8px ${
570
- 17 + Math.max(0, stackDepth - 1) * 10
571
- }px`,
561
+ padding: `14px 36px 14px ${stepInset}px`,
572
562
  textAlign: 'left',
573
563
  borderRadius: 0,
574
564
  border: 'none',
@@ -579,21 +569,26 @@ function TrailFlow({
579
569
  }}
580
570
  >
581
571
  <span
572
+ role={copied ? 'status' : undefined}
573
+ aria-label={copied ? 'Copied' : undefined}
582
574
  style={{
583
575
  flexShrink: 0,
584
576
  width: 14,
577
+ textAlign: 'center',
585
578
  paddingTop: 1,
586
579
  fontSize: theme.fontSizes[0],
587
580
  fontFamily: theme.fonts.monospace,
588
581
  fontWeight: record ? 700 : undefined,
589
- color: record
582
+ color: copied
583
+ ? theme.colors.primary
584
+ : record
590
585
  ? EMPHASIS_COLOR
591
586
  : stepActive
592
587
  ? theme.colors.text
593
588
  : muted,
594
589
  }}
595
590
  >
596
- {i + 1}
591
+ {copied ? <Check size={12} strokeWidth={2} aria-hidden /> : i + 1}
597
592
  </span>
598
593
  <span
599
594
  style={{
@@ -639,7 +634,7 @@ function TrailFlow({
639
634
  title="Key step"
640
635
  style={{
641
636
  position: 'absolute',
642
- right: 34,
637
+ right: 12,
643
638
  top: 0,
644
639
  bottom: 0,
645
640
  display: 'inline-flex',
@@ -654,54 +649,12 @@ function TrailFlow({
654
649
  <Star size={12} strokeWidth={2} fill={EMPHASIS_COLOR} />
655
650
  </span>
656
651
  )}
657
- {revealed && (
658
- <button
659
- type="button"
660
- aria-label={`Copy step ${i + 1} of ${trail.title} for an agent`}
661
- title={copied ? 'Copied' : 'Copy this step for an agent'}
662
- onMouseDown={(e) => e.preventDefault()}
663
- onMouseEnter={() => setCopyHoverStep(i)}
664
- onMouseLeave={() =>
665
- setCopyHoverStep((cur) => (cur === i ? null : cur))
666
- }
667
- onClick={() => copyStep(i)}
668
- style={{
669
- position: 'absolute',
670
- right: 0,
671
- top: 0,
672
- bottom: 0,
673
- display: 'inline-flex',
674
- alignItems: 'center',
675
- justifyContent: 'center',
676
- width: 'auto',
677
- aspectRatio: '1 / 1',
678
- padding: 0,
679
- border: 'none',
680
- borderRadius: 0,
681
- background: copied
682
- ? 'rgba(16,185,129,0.12)'
683
- : copyHovered
684
- ? theme.colors.border
685
- : 'transparent',
686
- color: copied ? '#10b981' : copyHovered ? theme.colors.text : muted,
687
- cursor: 'pointer',
688
- transition: 'background 120ms ease, color 120ms ease',
689
- }}
690
- >
691
- {copied ? (
692
- <Check size={12} strokeWidth={2} />
693
- ) : (
694
- <Copy size={12} strokeWidth={2} />
695
- )}
696
- </button>
697
- )}
698
652
  </div>
699
653
  {guidanceVisible && record?.guidance && (
700
654
  <div
701
655
  style={{
702
- // Align with the step text: 17px row inset + 14px number
703
- // column + 8px gap.
704
- padding: '2px 12px 10px 39px',
656
+ // Follow the row inset, number column, and text gap.
657
+ padding: `2px 12px 10px ${stepInset + 14 + 8}px`,
705
658
  fontSize: theme.fontSizes[0],
706
659
  lineHeight: 1.5,
707
660
  }}
@@ -786,6 +739,8 @@ export interface TrailsPanelProps {
786
739
  componentByAlias?: TrailComponentLabels;
787
740
  /** Slide from the trail list into its steps, without playback controls. */
788
741
  drilldown?: boolean;
742
+ /** Files or issues for the selected trail, beneath its shared header. */
743
+ detailContent?: ReactNode;
789
744
  }
790
745
 
791
746
  /**
@@ -813,6 +768,7 @@ export function TrailsPanel({
813
768
  onRemoveStepEmphasis,
814
769
  componentByAlias,
815
770
  drilldown = false,
771
+ detailContent,
816
772
  }: TrailsPanelProps) {
817
773
  const { theme } = useTheme();
818
774
  // Reordering is offered only while every row is collapsed: the compact list
@@ -1023,6 +979,7 @@ export function TrailsPanel({
1023
979
  onLeaveStep={onLeaveStep}
1024
980
  componentByAlias={componentByAlias}
1025
981
  listContent={list}
982
+ detailContent={detailContent}
1026
983
  renderSteps={(trail) => (
1027
984
  <TrailFlow
1028
985
  key={trail.id}
@@ -0,0 +1,89 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import type { SubsystemComponent, SubsystemTrail } from './model';
3
+ import type { SubsystemIssue, SubsystemIssueTarget } from './IssueList';
4
+ import { buildTrailSidebarScope } from './trailSidebarScope';
5
+
6
+ const components: SubsystemComponent[] = [
7
+ { alias: 'entry', name: 'Entry', construct: 'function', file: 'src/entry.ts', purl: 'pkg:github/acme/app', module: 'src', process: 'web' },
8
+ { alias: 'store', name: 'Store', construct: 'function', file: 'src/store.ts', purl: 'pkg:github/acme/app', module: 'src', process: 'host' },
9
+ { alias: 'other', name: 'Other', construct: 'function', file: 'src/other.ts', purl: 'pkg:github/acme/other', module: 'other', process: 'worker' },
10
+ ];
11
+ const trail: SubsystemTrail = {
12
+ id: 'save', title: 'Save record',
13
+ steps: [{
14
+ from: 'entry', to: 'store', mechanism: 'calls', file: 'src/seam.ts', purl: 'pkg:github/acme/app',
15
+ target: { file: 'src/target.ts', purl: 'pkg:github/acme/shared#src/target.ts', symbol: 'persist' },
16
+ }],
17
+ };
18
+ const finding = (id: string, target?: SubsystemIssueTarget): SubsystemIssue => ({
19
+ id, kind: 'trail', severity: 'error', message: id, target,
20
+ });
21
+
22
+ describe('trail sidebar scope', () => {
23
+ test('without a selected trail, keeps the model-wide files and findings', () => {
24
+ const issues = [finding('global')];
25
+ const scope = buildTrailSidebarScope(components, [trail], issues, null);
26
+ expect(scope.trail).toBeNull();
27
+ expect(scope.treePaths.entries.map((entry) => entry.file)).toEqual(components.map((component) => component.file));
28
+ expect(scope.issues).toBe(issues);
29
+ });
30
+
31
+ test('includes endpoint declarations, seam sites, and target files, excluding unrelated files', () => {
32
+ const scope = buildTrailSidebarScope(components, [trail], [], trail.id);
33
+ expect(scope.trail).toBe(trail);
34
+ expect(scope.treePaths.entries.map((entry) => entry.file)).toEqual([
35
+ 'src/entry.ts', 'src/store.ts', 'src/seam.ts', 'src/target.ts',
36
+ ]);
37
+ expect(scope.treePaths.multiRepo).toBe(true);
38
+ });
39
+
40
+ test('includes only findings for this trail and its participating constructs, boundaries, and repos', () => {
41
+ const relevant = [
42
+ finding('component', { kind: 'component', id: 'entry', label: 'Entry' }),
43
+ finding('legacy-label', { kind: 'component', label: 'Store' }),
44
+ finding('trail', { kind: 'trail', id: 'save', label: 'Save record' }),
45
+ finding('step', { kind: 'step', id: 'save', stepIndex: 0, label: 'Entry → Store' }),
46
+ finding('module', { kind: 'module', id: 'src', label: 'src' }),
47
+ finding('process', { kind: 'process', id: 'host', label: 'Host' }),
48
+ finding('repo', { kind: 'repo', id: 'pkg:github/acme/app#src/entry.ts', label: 'app' }),
49
+ finding('target-repo', { kind: 'repo', id: 'pkg:github/acme/shared', label: 'shared' }),
50
+ ];
51
+ const unrelated = [
52
+ finding('other-component', { kind: 'component', id: 'other', label: 'Other' }),
53
+ finding('other-trail', { kind: 'trail', id: 'other', label: 'Other' }),
54
+ finding('other-step', { kind: 'step', id: 'other', stepIndex: 0, label: 'Other step' }),
55
+ finding('invalid-step', { kind: 'step', id: 'save', stepIndex: 10, label: 'Old step' }),
56
+ finding('other-module', { kind: 'module', id: 'other', label: 'other' }),
57
+ finding('other-process', { kind: 'process', id: 'worker', label: 'Worker' }),
58
+ finding('other-repo', { kind: 'repo', id: 'pkg:github/acme/other', label: 'other' }),
59
+ finding('graph', { kind: 'graph', label: 'Whole graph' }),
60
+ finding('untargeted'),
61
+ ];
62
+ expect(buildTrailSidebarScope(components, [trail], [...relevant, ...unrelated], trail.id).issues).toEqual(relevant);
63
+ });
64
+
65
+ test('deduplicates repeated sites without merging identical paths from different repos', () => {
66
+ const repeated: SubsystemTrail = {
67
+ ...trail, steps: [trail.steps[0]!, trail.steps[0]!, {
68
+ ...trail.steps[0]!, purl: 'pkg:github/acme/shared',
69
+ }],
70
+ };
71
+ const paths = buildTrailSidebarScope(components, [repeated], [], trail.id).treePaths;
72
+ expect(paths.entries.filter((entry) => entry.file === 'src/seam.ts')).toHaveLength(2);
73
+ expect(paths.toFile.get('acme/shared/src/seam.ts')).toBe('src/seam.ts');
74
+ });
75
+
76
+ test('a removed trail restores model-wide content', () => {
77
+ const scope = buildTrailSidebarScope(components, [], [], trail.id);
78
+ expect(scope.trail).toBeNull();
79
+ expect(scope.treePaths.entries).toHaveLength(3);
80
+ });
81
+
82
+ test('an empty trail has empty files and issues rather than falling back to the full model', () => {
83
+ const empty: SubsystemTrail = { id: 'empty', title: 'Empty', steps: [] };
84
+ const scope = buildTrailSidebarScope(components, [empty], [finding('global')], empty.id);
85
+ expect(scope.trail).toBe(empty);
86
+ expect(scope.treePaths.entries).toHaveLength(0);
87
+ expect(scope.issues).toHaveLength(0);
88
+ });
89
+ });
@@ -0,0 +1,62 @@
1
+ import type { SubsystemComponent, SubsystemTrail } from './model';
2
+ import type { SubsystemIssue } from './IssueList';
3
+ import { buildTreePathMapping, purlOwnerName, purlRepoKey, type TreePathMapping } from './paths';
4
+
5
+ export interface TrailSidebarScope {
6
+ trail: SubsystemTrail | null;
7
+ treePaths: TreePathMapping;
8
+ issues: SubsystemIssue[];
9
+ }
10
+
11
+ /** Scope sidebar content to a trail's endpoints and authored source sites. */
12
+ export function buildTrailSidebarScope(
13
+ components: SubsystemComponent[],
14
+ trails: SubsystemTrail[] | undefined,
15
+ issues: SubsystemIssue[] | undefined,
16
+ trailId: string | null,
17
+ ): TrailSidebarScope {
18
+ const trail = trails?.find((item) => item.id === trailId) ?? null;
19
+ if (!trail) {
20
+ return { trail: null, treePaths: buildTreePathMapping(components), issues: issues ?? [] };
21
+ }
22
+ const aliases = new Set(trail.steps.flatMap((step) => [step.from, step.to]));
23
+ const participants = components.filter((component) => aliases.has(component.alias));
24
+ const files: Array<{ file?: string; purl?: string }> = [...participants];
25
+ for (const step of trail.steps) {
26
+ files.push(step);
27
+ if (step.target) files.push(step.target);
28
+ }
29
+ const modules = new Set(participants.map((component) => component.module?.trim()).filter(Boolean));
30
+ const processes = new Set(participants.map((component) => component.process).filter(Boolean));
31
+ const repos = new Set(files.flatMap((file) =>
32
+ [purlRepoKey(file.purl), purlOwnerName(file.purl)].filter((key): key is string => !!key),
33
+ ));
34
+ const scopedIssues = (issues ?? []).filter((issue) => {
35
+ const target = issue.target;
36
+ if (!target) return false;
37
+ const key = target.id ?? target.label;
38
+ switch (target.kind) {
39
+ case 'component': {
40
+ const component = components.find((candidate) =>
41
+ candidate.alias === target.id || candidate.alias === target.label ||
42
+ candidate.name === target.label || candidate.symbol === target.label,
43
+ );
44
+ return component != null && aliases.has(component.alias);
45
+ }
46
+ case 'trail':
47
+ return target.id != null ? target.id === trail.id : target.label === trail.title;
48
+ case 'step':
49
+ return target.id === trail.id && target.stepIndex != null &&
50
+ Number.isInteger(target.stepIndex) && trail.steps[target.stepIndex] != null;
51
+ case 'module':
52
+ return modules.has(key);
53
+ case 'process':
54
+ return processes.has(key);
55
+ case 'repo':
56
+ return repos.has(purlRepoKey(key) ?? key);
57
+ default:
58
+ return false;
59
+ }
60
+ });
61
+ return { trail, treePaths: buildTreePathMapping(files), issues: scopedIssues };
62
+ }