@principal-ai/subsystems-react 0.56.11 → 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.
@@ -404,7 +404,8 @@ export interface SubsystemComponentGraphProps {
404
404
  /**
405
405
  * Verification issues for this graph (audit findings). When diagnostics are
406
406
  * active the sidebar's bottom panel becomes the issue list instead of the
407
- * file tree / trails.
407
+ * file tree / trails. Graph issue badges and issue-driven dimming are only
408
+ * shown in that mode.
408
409
  */
409
410
  issues?: SubsystemIssue[];
410
411
  /**
@@ -1262,7 +1263,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1262
1263
  // node says "diagnostics here" for every finding the sidebar lists.
1263
1264
  const issueBadgeByAlias = useMemo(() => {
1264
1265
  const map = new Map<string, SubsystemNodeIssue>();
1265
- if (!issues?.length) return map;
1266
+ if (showIssues !== true || !issues?.length) return map;
1266
1267
  for (const issue of issues) {
1267
1268
  const rung = issueRung(issue.kind);
1268
1269
  if (!rung && !ISSUE_KIND_ICON[issue.kind]) continue;
@@ -1297,7 +1298,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1297
1298
  if (kinded) map.set(alias, { ...badge, kind: kinded.kind });
1298
1299
  }
1299
1300
  return map;
1300
- }, [issues, issueComponent]);
1301
+ }, [showIssues, issues, issueComponent]);
1301
1302
 
1302
1303
  // Per-frame diagnostics badge: the same fold, but for findings about a
1303
1304
  // BOUNDARY rather than a construct. Keyed by the region's React Flow node
@@ -1308,7 +1309,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1308
1309
  // borrowing its layer's icon would imply the frame is at fault for it.
1309
1310
  const regionIssueByNodeId = useMemo(() => {
1310
1311
  const map = new Map<string, SubsystemRegionIssue>();
1311
- if (!issues?.length) return map;
1312
+ if (showIssues !== true || !issues?.length) return map;
1312
1313
  for (const issue of issues) {
1313
1314
  const target = issue.target;
1314
1315
  if (!target || !ISSUE_KIND_ICON[issue.kind]) continue;
@@ -1329,7 +1330,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1329
1330
  });
1330
1331
  }
1331
1332
  return map;
1332
- }, [issues]);
1333
+ }, [showIssues, issues]);
1333
1334
 
1334
1335
  // Per-edge step verification badge: maps edge IDs to step verification status.
1335
1336
  // Keyed by the React Flow edge id (from → mechanism → to). An edge can have
@@ -1347,7 +1348,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1347
1348
  step_span_stale: 1,
1348
1349
  };
1349
1350
  const map = new Map<string, { kind: StepIssueKind; severity: 'info' | 'error'; count: number }>();
1350
- if (!issues?.length || !trails?.length) {
1351
+ if (showIssues !== true || !issues?.length || !trails?.length) {
1351
1352
  return map;
1352
1353
  }
1353
1354
  for (const issue of issues) {
@@ -1374,7 +1375,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1374
1375
  });
1375
1376
  }
1376
1377
  return map;
1377
- }, [issues, trails, issueStep]);
1378
+ }, [showIssues, issues, trails, issueStep]);
1378
1379
 
1379
1380
  // Expanded verification layers in the diagnostics list. When any layer with
1380
1381
  // findings is expanded, the canvas dims everything that layer does not
@@ -1423,7 +1424,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1423
1424
  // Returns null when nothing is expanded, no expanded layer has findings, or
1424
1425
  // the findings implicate nothing — so no dimming is applied.
1425
1426
  const issueFocus = useMemo(() => {
1426
- if (!issues?.length || expandedIssueCategories.length === 0) return null;
1427
+ if (showIssues !== true || !issues?.length || expandedIssueCategories.length === 0) return null;
1427
1428
  const active = new Set(expandedIssueCategories);
1428
1429
  const activeIssues = issues.filter((i) => active.has(issueCategory(i)));
1429
1430
  if (activeIssues.length === 0) return null;
@@ -1478,7 +1479,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
1478
1479
  if (nodeIds.has(e.source) && nodeIds.has(e.target)) edgeIds.add(e.id);
1479
1480
  }
1480
1481
  return { nodeIds, edgeIds };
1481
- }, [issues, expandedIssueCategories, components, trails, resolveAlias]);
1482
+ }, [showIssues, issues, expandedIssueCategories, components, trails, resolveAlias]);
1482
1483
  const issueFocusNodeIds = issueFocus?.nodeIds ?? null;
1483
1484
  const issueFocusEdgeIds = issueFocus?.edgeIds ?? null;
1484
1485
 
@@ -2765,6 +2766,7 @@ function Inner({ components, trails, graphifyRelations, orderByLine, layoutDirec
2765
2766
  <div style={{ padding: 16, color: theme.colors.textSecondary }}>No files for this trail.</div>
2766
2767
  ) : (
2767
2768
  <SubsystemFileTree
2769
+ headerless
2768
2770
  files={treeFilePaths}
2769
2771
  selectedFile={openFile ? treePaths.toDisplay.get(openFile) : null}
2770
2772
  onSelectFile={(path) => {
@@ -3413,6 +3415,7 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
3413
3415
  );
3414
3416
  const fileCount = sidebarScope.treePaths.entries.length;
3415
3417
  const trailViewLabel = sidebarScope.trail ? 'Trail' : 'Trails';
3418
+ const trailCount = props.trails?.length ?? 0;
3416
3419
  const hasFiles = fileCount > 0 || sidebarScope.trail != null;
3417
3420
  const sidebarDiagnostic: SubsystemDiagnostic = sidebarScope.trail ? {
3418
3421
  ...props.diagnostic,
@@ -3422,18 +3425,22 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
3422
3425
  fixableCount: sidebarScope.issues.filter((issue) => issue.fix != null).length,
3423
3426
  title: undefined,
3424
3427
  } : props.diagnostic ?? { status: 'unknown', issueCount: props.issues?.length };
3428
+ const hasIssues = (props.issues != null
3429
+ ? sidebarScope.issues.length
3430
+ : sidebarDiagnostic.issueCount ?? 0) > 0;
3425
3431
  useEffect(() => {
3426
3432
  if (sidebarTrailId != null && !sidebarScope.trail) setSidebarTrailId(null);
3427
3433
  }, [sidebarTrailId, sidebarScope.trail]);
3428
3434
  const [diagnosticsOpen, setDiagnosticsOpen] = useState<boolean>(
3429
3435
  props.showIssues ?? (props.issues?.length ?? 0) > 0,
3430
3436
  );
3431
- const issuesActive = props.showIssues ?? diagnosticsOpen;
3437
+ const issuesRequested = props.showIssues ?? diagnosticsOpen;
3438
+ const issuesActive = hasIssues && issuesRequested;
3432
3439
  const filesActive = filesOpen && hasFiles && !issuesActive;
3433
3440
  const selectSidebarView = (view: 'trails' | 'files' | 'issues') => {
3434
3441
  const nextIssuesActive = view === 'issues';
3435
3442
  setFilesOpen(view === 'files');
3436
- if (nextIssuesActive !== issuesActive) {
3443
+ if (nextIssuesActive !== issuesRequested) {
3437
3444
  if (props.showIssues === undefined) setDiagnosticsOpen(nextIssuesActive);
3438
3445
  props.diagnostic?.onToggle?.();
3439
3446
  }
@@ -3444,7 +3451,7 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
3444
3451
  justifyContent: 'center',
3445
3452
  gap: 4,
3446
3453
  height: 28,
3447
- padding: '4px 7px',
3454
+ padding: '4px 16px',
3448
3455
  fontFamily: theme.fonts.monospace,
3449
3456
  fontSize: theme.fontSizes[0],
3450
3457
  border: 'none',
@@ -3535,13 +3542,14 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
3535
3542
  >
3536
3543
  <button
3537
3544
  type="button"
3538
- aria-label={trailViewLabel}
3545
+ aria-label={sidebarScope.trail ? 'Trail' : `Trails (${trailCount} ${trailCount === 1 ? 'trail' : 'trails'})`}
3539
3546
  aria-pressed={!filesActive && !issuesActive}
3540
3547
  onClick={() => selectSidebarView('trails')}
3541
3548
  style={viewButtonStyle(!filesActive && !issuesActive)}
3542
3549
  >
3543
3550
  <Route size={14} aria-hidden />
3544
3551
  <span>{trailViewLabel}</span>
3552
+ {!sidebarScope.trail && <span style={{ fontWeight: 600, marginLeft: 4 }}>{trailCount}</span>}
3545
3553
  </button>
3546
3554
  {hasFiles && (
3547
3555
  <button
@@ -3554,9 +3562,10 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
3554
3562
  >
3555
3563
  <Files size={14} aria-hidden />
3556
3564
  <span>Files</span>
3557
- <span style={{ fontWeight: 600 }}>{fileCount}</span>
3565
+ <span style={{ fontWeight: 600, marginLeft: 4 }}>{fileCount}</span>
3558
3566
  </button>
3559
3567
  )}
3568
+ {hasIssues && (
3560
3569
  <SubsystemDiagnosticToggle
3561
3570
  {...sidebarDiagnostic}
3562
3571
  labelText="Issues"
@@ -3564,6 +3573,7 @@ export function SubsystemComponentGraph(props: SubsystemComponentGraphProps) {
3564
3573
  active={issuesActive}
3565
3574
  onToggle={() => selectSidebarView('issues')}
3566
3575
  />
3576
+ )}
3567
3577
  </div>
3568
3578
  </header>
3569
3579
  )}
@@ -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', () => {