@shipfox/client-workflows 20.0.0 → 22.0.0

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 (118) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +51 -0
  3. package/dist/components/job-detail/job-detail-view.d.ts.map +1 -1
  4. package/dist/components/job-detail/job-detail-view.js +232 -44
  5. package/dist/components/job-detail/job-detail-view.js.map +1 -1
  6. package/dist/components/job-detail/job-empty-states.js +2 -2
  7. package/dist/components/job-detail/job-empty-states.js.map +1 -1
  8. package/dist/components/job-detail/step-attempt-log-panel.d.ts +7 -1
  9. package/dist/components/job-detail/step-attempt-log-panel.d.ts.map +1 -1
  10. package/dist/components/job-detail/step-attempt-log-panel.js +37 -10
  11. package/dist/components/job-detail/step-attempt-log-panel.js.map +1 -1
  12. package/dist/components/job-graph/job-graph-content.js +2 -2
  13. package/dist/components/job-graph/job-graph-content.js.map +1 -1
  14. package/dist/components/step-list/step-list-model.d.ts +1 -0
  15. package/dist/components/step-list/step-list-model.d.ts.map +1 -1
  16. package/dist/components/step-list/step-list-model.js +1 -1
  17. package/dist/components/step-list/step-list-model.js.map +1 -1
  18. package/dist/components/step-list/step-list.d.ts +2 -1
  19. package/dist/components/step-list/step-list.d.ts.map +1 -1
  20. package/dist/components/step-list/step-list.js +25 -9
  21. package/dist/components/step-list/step-list.js.map +1 -1
  22. package/dist/components/workflow-run-list/job-status-strip.d.ts +9 -1
  23. package/dist/components/workflow-run-list/job-status-strip.d.ts.map +1 -1
  24. package/dist/components/workflow-run-list/job-status-strip.js +22 -11
  25. package/dist/components/workflow-run-list/job-status-strip.js.map +1 -1
  26. package/dist/components/workflow-run-list/workflow-run-filters.js +1 -1
  27. package/dist/components/workflow-run-list/workflow-run-filters.js.map +1 -1
  28. package/dist/components/workflow-run-list/workflow-run-list-content.d.ts.map +1 -1
  29. package/dist/components/workflow-run-list/workflow-run-list-content.js +13 -7
  30. package/dist/components/workflow-run-list/workflow-run-list-content.js.map +1 -1
  31. package/dist/components/workflow-run-list/workflow-run-list-states.d.ts.map +1 -1
  32. package/dist/components/workflow-run-list/workflow-run-list-states.js +43 -38
  33. package/dist/components/workflow-run-list/workflow-run-list-states.js.map +1 -1
  34. package/dist/components/workflow-run-list/workflow-run-list-view.d.ts.map +1 -1
  35. package/dist/components/workflow-run-list/workflow-run-list-view.js +34 -22
  36. package/dist/components/workflow-run-list/workflow-run-list-view.js.map +1 -1
  37. package/dist/components/workflow-run-list/workflow-run-row.d.ts +3 -3
  38. package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
  39. package/dist/components/workflow-run-list/workflow-run-row.js +25 -18
  40. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  41. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  42. package/dist/components/workflow-run-summary/workflow-run-summary.js +4 -4
  43. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  44. package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -1
  45. package/dist/components/workflow-run-view/run-workspace-nav.js +20 -7
  46. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -1
  47. package/dist/components/workflow-run-view/workflow-run-states.d.ts.map +1 -1
  48. package/dist/components/workflow-run-view/workflow-run-states.js +1 -1
  49. package/dist/components/workflow-run-view/workflow-run-states.js.map +1 -1
  50. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  51. package/dist/components/workflow-run-view/workflow-run-view.js +103 -78
  52. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  53. package/dist/components/workflow-source-panel/workflow-source-content.js +1 -1
  54. package/dist/components/workflow-source-panel/workflow-source-content.js.map +1 -1
  55. package/dist/pages/project-workflows-page.d.ts.map +1 -1
  56. package/dist/pages/project-workflows-page.js +171 -158
  57. package/dist/pages/project-workflows-page.js.map +1 -1
  58. package/dist/pages/workflow-job-detail-page.d.ts.map +1 -1
  59. package/dist/pages/workflow-job-detail-page.js +3 -2
  60. package/dist/pages/workflow-job-detail-page.js.map +1 -1
  61. package/dist/pages/workflow-run-detail-page.js +2 -1
  62. package/dist/pages/workflow-run-detail-page.js.map +1 -1
  63. package/dist/pages/workflow-run-list-page.d.ts.map +1 -1
  64. package/dist/pages/workflow-run-list-page.js +9 -11
  65. package/dist/pages/workflow-run-list-page.js.map +1 -1
  66. package/dist/routes/job-detail.d.ts +1 -1
  67. package/dist/routes/job-detail.js +1 -1
  68. package/dist/routes/job-detail.js.map +1 -1
  69. package/dist/routes/run-detail.d.ts +1 -1
  70. package/dist/routes/run-detail.js +1 -1
  71. package/dist/routes/run-detail.js.map +1 -1
  72. package/dist/routes/runs.d.ts +1 -1
  73. package/dist/routes/runs.js +1 -1
  74. package/dist/routes/runs.js.map +1 -1
  75. package/dist/routes/workflows.d.ts +3 -0
  76. package/dist/routes/workflows.js +3 -0
  77. package/dist/routes/workflows.js.map +1 -1
  78. package/dist/tsconfig.test.tsbuildinfo +1 -1
  79. package/package.json +8 -8
  80. package/src/components/job-detail/job-detail-view.tsx +264 -49
  81. package/src/components/job-detail/job-empty-states.tsx +2 -2
  82. package/src/components/job-detail/step-attempt-log-panel.test.tsx +30 -12
  83. package/src/components/job-detail/step-attempt-log-panel.tsx +68 -10
  84. package/src/components/job-graph/job-graph-content.tsx +2 -2
  85. package/src/components/job-graph/job-graph.stories.tsx +1 -1
  86. package/src/components/job-graph/job-node.stories.tsx +1 -1
  87. package/src/components/step-list/step-list-model.ts +1 -1
  88. package/src/components/step-list/step-list.test.tsx +29 -1
  89. package/src/components/step-list/step-list.tsx +29 -11
  90. package/src/components/workflow-run-list/job-status-strip.tsx +25 -12
  91. package/src/components/workflow-run-list/workflow-run-filters.tsx +1 -1
  92. package/src/components/workflow-run-list/workflow-run-list-content.tsx +7 -2
  93. package/src/components/workflow-run-list/workflow-run-list-states.tsx +42 -41
  94. package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +53 -4
  95. package/src/components/workflow-run-list/workflow-run-list-view.tsx +28 -22
  96. package/src/components/workflow-run-list/workflow-run-list.stories.tsx +9 -20
  97. package/src/components/workflow-run-list/workflow-run-row.tsx +26 -19
  98. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +3 -3
  99. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +11 -2
  100. package/src/components/workflow-run-summary/workflow-run-summary.tsx +4 -7
  101. package/src/components/workflow-run-view/run-workspace-nav.test.tsx +40 -2
  102. package/src/components/workflow-run-view/run-workspace-nav.tsx +28 -9
  103. package/src/components/workflow-run-view/workflow-run-states.tsx +1 -4
  104. package/src/components/workflow-run-view/workflow-run-view.stories.tsx +142 -0
  105. package/src/components/workflow-run-view/workflow-run-view.test.tsx +42 -8
  106. package/src/components/workflow-run-view/workflow-run-view.tsx +112 -96
  107. package/src/components/workflow-source-panel/workflow-source-content.tsx +2 -2
  108. package/src/pages/project-workflows-page.tsx +135 -125
  109. package/src/pages/workflow-job-detail-page.test.tsx +32 -0
  110. package/src/pages/workflow-job-detail-page.tsx +5 -1
  111. package/src/pages/workflow-run-detail-page.tsx +1 -1
  112. package/src/pages/workflow-run-list-page.tsx +9 -11
  113. package/src/pages/workflow-run-pages.test.tsx +26 -0
  114. package/src/routes/job-detail.tsx +1 -1
  115. package/src/routes/run-detail.tsx +1 -1
  116. package/src/routes/runs.tsx +1 -1
  117. package/src/routes/workflows.tsx +1 -0
  118. package/tsconfig.build.tsbuildinfo +1 -1
@@ -1,4 +1,5 @@
1
1
  import type {JobStatusDto} from '@shipfox/api-workflows-dto';
2
+ import {WORKFLOW_RUN_JOB_PREVIEW_LIMIT} from '@shipfox/api-workflows-dto';
2
3
  import {screen, within} from '@testing-library/react';
3
4
  import userEvent from '@testing-library/user-event';
4
5
  import type {WorkflowRunListItem, WorkflowRunStatus} from '#core/workflow-run.js';
@@ -8,12 +9,14 @@ import {
8
9
  workflowRunListItem,
9
10
  } from '#test/fixtures/workflow-run.js';
10
11
  import {renderWithRouter} from '#test/render.js';
12
+ import {JOB_STATUS_STRIP_WIDTH, jobStatusStripWidthForPreview} from './job-status-strip.js';
11
13
  import type {WorkflowRunListQuery, WorkflowRunListViewProps} from './types.js';
12
14
  import {WorkflowRunListView} from './workflow-run-list-view.js';
13
15
 
14
16
  const PROJECT_ID = '44444444-4444-4444-8444-444444444444';
15
17
  const JOB_STRIP_LABEL_RE = /jobs:/u;
16
18
  const JOB_SUMMARY_NAME_RE = /queued-build/u;
19
+ const OVERFLOW_COUNT_RE = /^\+/u;
17
20
  const WORKSPACE_SLUG = 'acme';
18
21
  const PROJECT_SLUG = 'checkout-api';
19
22
 
@@ -35,6 +38,38 @@ describe('WorkflowRunListView', () => {
35
38
  vi.restoreAllMocks();
36
39
  });
37
40
 
41
+ describe('panel composition', () => {
42
+ test('keeps the controls in the panel header and states in its body', async () => {
43
+ renderListView([run('succeeded', 'build-image')]);
44
+ await screen.findByText('build-image');
45
+
46
+ const panel = document.querySelector<HTMLElement>('[data-slot="panel"]');
47
+ const header = panel?.querySelector<HTMLElement>('[data-slot="panel-header"]');
48
+ const body = panel?.querySelector<HTMLElement>('[data-slot="panel-body"]');
49
+
50
+ expect(panel).not.toBeNull();
51
+ expect(header).not.toBeNull();
52
+ expect(body).not.toBeNull();
53
+ expect(within(header as HTMLElement).getByLabelText('Search runs')).toBeInTheDocument();
54
+ expect(
55
+ within(header as HTMLElement).getByRole('button', {name: filterTrigger('Status')}),
56
+ ).toBeInTheDocument();
57
+ expect(
58
+ within(header as HTMLElement).getByRole('button', {name: filterTrigger('Branch')}),
59
+ ).toBeInTheDocument();
60
+ expect(
61
+ within(header as HTMLElement).getByRole('button', {name: filterTrigger('Actor')}),
62
+ ).toBeInTheDocument();
63
+ expect(
64
+ within(header as HTMLElement).getByRole('button', {name: filterTrigger('Event')}),
65
+ ).toBeInTheDocument();
66
+ expect(
67
+ within(header as HTMLElement).getByLabelText('Filter runs by creation date'),
68
+ ).toBeInTheDocument();
69
+ expect(within(body as HTMLElement).getByText('build-image')).toBeInTheDocument();
70
+ });
71
+ });
72
+
38
73
  describe('filtering', () => {
39
74
  test('narrows the list to the selected status', async () => {
40
75
  const user = userEvent.setup();
@@ -380,10 +415,24 @@ describe('WorkflowRunListView', () => {
380
415
  test('counts the jobs it had to hide beyond the strip threshold', async () => {
381
416
  renderListView([run('running', 'deploy-web', 'run-1', {...workflowRunJobsOfStatus(40)})]);
382
417
 
383
- expect(await screen.findByText('+33')).toBeInTheDocument();
418
+ expect(await screen.findByText('+24')).toBeInTheDocument();
384
419
  expect(screen.getByRole('img', {name: '40 jobs: 40 succeeded'})).toBeInTheDocument();
385
420
  });
386
421
 
422
+ test('renders every job in the API preview without a client-side cap', async () => {
423
+ renderListView([run('running', 'deploy-web', 'run-1', {...workflowRunJobsOfStatus(16)})]);
424
+
425
+ const strip = await screen.findByRole('img', {name: '16 jobs: 16 succeeded'});
426
+
427
+ expect(within(strip).getAllByLabelText('Succeeded')).toHaveLength(16);
428
+ expect(within(strip).queryByText(OVERFLOW_COUNT_RE)).not.toBeInTheDocument();
429
+ });
430
+
431
+ test('reserves the full API preview and overflow marker width', () => {
432
+ expect(jobStatusStripWidthForPreview(WORKFLOW_RUN_JOB_PREVIEW_LIMIT)).toBe(294);
433
+ expect(JOB_STATUS_STRIP_WIDTH).toBe(294);
434
+ });
435
+
387
436
  // The API sends a bounded preview, so a run whose only failure sits past it has no failed
388
437
  // job in the payload at all. The counts still carry it, and the overflow glyph has to say
389
438
  // so rather than showing an unbroken row of green.
@@ -398,7 +447,7 @@ describe('WorkflowRunListView', () => {
398
447
  name: '21 jobs: 1 failed, 20 succeeded',
399
448
  });
400
449
  expect(strip).toBeInTheDocument();
401
- expect(screen.getByText('+14')).toBeInTheDocument();
450
+ expect(screen.getByText('+5')).toBeInTheDocument();
402
451
  expect(within(strip).getByLabelText('Failed')).toBeInTheDocument();
403
452
  });
404
453
 
@@ -420,8 +469,8 @@ describe('WorkflowRunListView', () => {
420
469
  ]);
421
470
 
422
471
  const strip = await screen.findByRole('img', {name: '20 jobs: 20 listening'});
423
- expect(screen.getByText('+13')).toBeInTheDocument();
424
- expect(within(strip).getAllByLabelText('Listening')).toHaveLength(8);
472
+ expect(screen.getByText('+4')).toBeInTheDocument();
473
+ expect(within(strip).getAllByLabelText('Listening')).toHaveLength(17);
425
474
 
426
475
  const user = userEvent.setup();
427
476
  await user.hover(strip);
@@ -1,3 +1,4 @@
1
+ import {Panel, PanelBody, PanelHeader} from '@shipfox/react-ui/panel';
1
2
  import {TimeTickerProvider} from '@shipfox/react-ui/time-ticker';
2
3
  import {Header} from '@shipfox/react-ui/typography';
3
4
  import {cn} from '@shipfox/react-ui/utils';
@@ -58,7 +59,7 @@ export function WorkflowRunListView({
58
59
  <TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
59
60
  <section
60
61
  aria-labelledby={headingId}
61
- className={cn('flex min-h-0 min-w-0 flex-1 flex-col gap-cluster', className)}
62
+ className={cn('flex min-h-0 min-w-0 flex-1 flex-col', className)}
62
63
  >
63
64
  {/* The tab strip above already reads "Runs", so the page heading is for structure and
64
65
  assistive tech rather than a second visible title competing with it. */}
@@ -66,27 +67,32 @@ export function WorkflowRunListView({
66
67
  Workflow runs
67
68
  </Header>
68
69
 
69
- <WorkflowRunFilters
70
- search={currentSearch}
71
- facets={facets}
72
- onChange={handleFiltersChange}
73
- onClear={handleClearFilters}
74
- hasActiveFilters={hasActiveFilters}
75
- />
76
-
77
- <WorkflowRunListContent
78
- query={query}
79
- totalRuns={runs.length}
80
- runs={filteredRuns}
81
- workspaceSlug={workspaceSlug}
82
- projectSlug={projectSlug}
83
- hasActiveFilters={hasActiveFilters}
84
- onClearFilters={handleClearFilters}
85
- hasNextPage={hasNextPage}
86
- isFetchingNextPage={isFetchingNextPage}
87
- isFetchNextPageError={isFetchNextPageError}
88
- {...(onLoadMore ? {onLoadMore} : {})}
89
- />
70
+ <Panel className="min-h-0 flex-1">
71
+ <PanelHeader className="flex-wrap">
72
+ <WorkflowRunFilters
73
+ search={currentSearch}
74
+ facets={facets}
75
+ onChange={handleFiltersChange}
76
+ onClear={handleClearFilters}
77
+ hasActiveFilters={hasActiveFilters}
78
+ />
79
+ </PanelHeader>
80
+ <PanelBody className="min-h-0 flex-1">
81
+ <WorkflowRunListContent
82
+ query={query}
83
+ totalRuns={runs.length}
84
+ runs={filteredRuns}
85
+ workspaceSlug={workspaceSlug}
86
+ projectSlug={projectSlug}
87
+ hasActiveFilters={hasActiveFilters}
88
+ onClearFilters={handleClearFilters}
89
+ hasNextPage={hasNextPage}
90
+ isFetchingNextPage={isFetchingNextPage}
91
+ isFetchNextPageError={isFetchNextPageError}
92
+ {...(onLoadMore ? {onLoadMore} : {})}
93
+ />
94
+ </PanelBody>
95
+ </Panel>
90
96
  </section>
91
97
  </TimeTickerProvider>
92
98
  );
@@ -75,15 +75,15 @@ const SAMPLE_RUNS: WorkflowRunListItem[] = [
75
75
 
76
76
  // One story on the full-width page. The data states (loading / empty / errors / runs) are
77
77
  // driven by args; search, the filter menus, "clear filters" and "no matches" are live in the
78
- // rendered toolbar since the view owns that state. The decorator gives the list the 1120px
79
- // content cap and a real height, so `flex-1` scrolls the way it does in the app shell.
78
+ // rendered toolbar since the view owns that state. The decorator gives the list a full-width
79
+ // subtle canvas and a real height, so `flex-1` scrolls the way it does in the app shell.
80
80
  const meta = {
81
81
  title: 'Workflows/WorkflowRunList',
82
82
  component: WorkflowRunListView,
83
83
  parameters: {layout: 'centered'},
84
84
  decorators: [
85
85
  (Story) => (
86
- <div className="flex h-600 w-[1120px] max-w-full bg-background-neutral-base p-24">
86
+ <div className="flex h-600 w-full bg-background-subtle-base p-24">
87
87
  <Story />
88
88
  </div>
89
89
  ),
@@ -137,27 +137,18 @@ export const NarrowLayout: Story = {
137
137
  };
138
138
 
139
139
  /**
140
- * Row density against the three strip sizes the threshold has to survive: comfortably under
141
- * it, comfortably over it, and far enough over that the overflow count carries the failure.
140
+ * Row density against the three strip sizes the preview bound has to survive: comfortably under
141
+ * it, just over it, and far enough over that the overflow count carries the failure.
142
142
  */
143
143
  export const JobStripDensity: Story = {
144
144
  args: {
145
145
  runs: [
146
146
  makeRun('succeeded', 'two-jobs', 2, {jobs: ['succeeded', 'succeeded']}),
147
- makeRun('failed', 'twelve-jobs', 6, {
147
+ makeRun('failed', 'seventeen-jobs', 6, {
148
148
  jobs: [
149
- 'succeeded',
150
- 'succeeded',
151
- 'succeeded',
149
+ ...(Array.from({length: 12}, () => 'succeeded') as JobStatusDto[]),
152
150
  'failed',
153
- 'succeeded',
154
- 'succeeded',
155
- 'skipped',
156
- 'skipped',
157
- 'succeeded',
158
- 'succeeded',
159
- 'succeeded',
160
- 'succeeded',
151
+ ...(Array.from({length: 4}, () => 'skipped') as JobStatusDto[]),
161
152
  ],
162
153
  }),
163
154
  makeRun('running', 'forty-jobs', 9, {
@@ -229,9 +220,7 @@ function StateExample({label, children}: {label: string; children: ReactNode}) {
229
220
  <Code variant="label" className="text-foreground-neutral-subtle">
230
221
  {label}
231
222
  </Code>
232
- <div className="flex h-560 rounded-8 border border-border-neutral-base bg-background-neutral-base p-16">
233
- {children}
234
- </div>
223
+ <div className="flex h-560 min-w-0 bg-background-subtle-base p-16">{children}</div>
235
224
  </div>
236
225
  );
237
226
  }
@@ -1,5 +1,6 @@
1
1
  import {TriggerSourceIcon} from '@shipfox/client-triggers';
2
2
  import {Icon, type IconName} from '@shipfox/react-ui/icon';
3
+ import {Panel} from '@shipfox/react-ui/panel';
3
4
  import {RelativeTime} from '@shipfox/react-ui/relative-time';
4
5
  import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
5
6
  import {Code, Text} from '@shipfox/react-ui/typography';
@@ -22,7 +23,7 @@ import {
22
23
  workflowRunCommitLabel,
23
24
  } from '#core/workflow-run.js';
24
25
  import {withoutWorkflowRunSelectionSearch} from '#core/workflow-run-url-state.js';
25
- import {JobStatusStrip, jobStatusSummary} from './job-status-strip.js';
26
+ import {JOB_STATUS_STRIP_WIDTH, JobStatusStrip, jobStatusSummary} from './job-status-strip.js';
26
27
 
27
28
  export function WorkflowRunRowList({
28
29
  runs,
@@ -34,16 +35,18 @@ export function WorkflowRunRowList({
34
35
  projectSlug?: string | undefined;
35
36
  }) {
36
37
  return (
37
- // A container, not the viewport, drives the row's breakpoints. The list is capped at
38
- // 1120px inside a wider page, so what a row can afford is its own width; keying off the
39
- // viewport would keep the job strip hidden on a wide screen or crush it on a narrow one.
40
- <ul className="@container divide-y divide-border-neutral-base border-t border-border-neutral-base">
41
- {runs.map((run) => (
42
- <li key={run.id}>
43
- <WorkflowRunRow run={run} workspaceSlug={workspaceSlug} projectSlug={projectSlug} />
44
- </li>
45
- ))}
46
- </ul>
38
+ // A container, not the viewport, drives the row's breakpoints. What a row can afford is its
39
+ // own width; keying off the viewport would keep the job strip hidden on a wide screen or
40
+ // crush it on a narrow one.
41
+ <Panel className="@container">
42
+ <ul className="divide-y divide-border-neutral-base">
43
+ {runs.map((run) => (
44
+ <li key={run.id}>
45
+ <WorkflowRunRow run={run} workspaceSlug={workspaceSlug} projectSlug={projectSlug} />
46
+ </li>
47
+ ))}
48
+ </ul>
49
+ </Panel>
47
50
  );
48
51
  }
49
52
 
@@ -55,9 +58,9 @@ export function WorkflowRunRowList({
55
58
  * drops beneath. The numeric columns are fixed-width so duration and time form real columns
56
59
  * down the list rather than tracking each row's name length.
57
60
  *
58
- * The thresholds are the container widths the spec's viewport breakpoints produce, given a
59
- * column capped at 1120px with 24px of page padding on each side: a 1280px viewport leaves
60
- * the row 1072px, and a 1024px viewport leaves it 976px.
61
+ * The 976px threshold keeps identity and provenance on one line; below it, provenance drops
62
+ * beneath the identity. The 1200px threshold keeps the full job preview and numeric columns
63
+ * readable together; below it, the strip is hidden so the numeric columns remain aligned.
61
64
  */
62
65
  export function WorkflowRunRow({
63
66
  run,
@@ -99,10 +102,14 @@ export function WorkflowRunRow({
99
102
  </div>
100
103
 
101
104
  <span className="flex shrink-0 items-center gap-cluster">
102
- {/* Fixed at the strip's worst case (see MAX_VISIBLE_JOBS) so the overflow count cannot
103
- paint over the duration, and kept even when a run has no jobs planned yet, so the
104
- numerics stay in line down the list instead of stepping in and out. */}
105
- <span className="hidden w-160 @min-[1040px]:flex">
105
+ {/* Reserve the API preview's full width so the overflow count cannot paint over the
106
+ duration, and keep it even when a run has no jobs planned yet so the numerics stay in
107
+ line down the list instead of stepping in and out. The max-content width expands if
108
+ the payload ever exceeds the current API preview bound. */}
109
+ <span
110
+ className="hidden w-max shrink-0 @min-[1200px]:flex"
111
+ style={{minWidth: JOB_STATUS_STRIP_WIDTH}}
112
+ >
106
113
  <JobStatusStrip jobs={run.jobs} />
107
114
  </span>
108
115
  <span className="flex w-64 justify-end">
@@ -121,7 +128,7 @@ export function WorkflowRunRow({
121
128
  // Rows run edge to edge inside the list's scroll container, which would clip the standard
122
129
  // outset focus ring, so this one is inset per the design system's focus-ring rule.
123
130
  const rowClassName =
124
- 'flex w-full min-w-0 items-center gap-inline px-row py-row text-left transition-colors hover:bg-background-components-hover focus-visible:shadow-[inset_0_0_0_2px_var(--color-primary-500)] focus-visible:outline-none @min-[976px]:h-44 @min-[976px]:py-0';
131
+ 'flex w-full min-w-0 items-center gap-inline px-row py-row text-left transition-colors hover:bg-background-neutral-hover focus-visible:shadow-focus-inset focus-visible:outline-none @min-[976px]:h-44 @min-[976px]:py-0';
125
132
 
126
133
  // Optimistic manual runs (temp-<uuid>) have no detail page until the canonical row
127
134
  // replaces them on the next poll, so they render non-interactively instead of as a link
@@ -56,10 +56,10 @@ const RUN_ATTEMPTS_RESPONSE = runAttemptsResponseDto({
56
56
  });
57
57
 
58
58
  const withFrame: Decorator = (Story) => (
59
- <div className="min-h-screen bg-background-neutral-background">
60
- <div className="mx-auto flex min-h-screen w-full max-w-[1120px] flex-col overflow-hidden border-x border-border-neutral-base bg-background-subtle-base">
59
+ <div className="min-h-screen bg-background-subtle-base">
60
+ <div className="flex min-h-screen w-full flex-col px-frame py-frame">
61
61
  <Story />
62
- <div className="min-h-0 flex-1 bg-background-neutral-background p-16" />
62
+ <div className="min-h-0 flex-1 bg-background-subtle-base p-16" />
63
63
  </div>
64
64
  </div>
65
65
  );
@@ -38,7 +38,8 @@ describe('WorkflowRunSummary', () => {
38
38
 
39
39
  const summary = await screen.findByRole('region', {name: 'deploy-web'});
40
40
 
41
- expect(summary).toHaveClass('bg-background-neutral-background', 'px-row', 'py-row');
41
+ expect(summary).toHaveClass('px-row', 'py-row');
42
+ expect(summary).not.toHaveClass('bg-background-subtle-base');
42
43
  expect(summary.firstElementChild).not.toHaveClass('max-w-[1120px]');
43
44
  expect(within(summary).getByRole('heading', {name: 'deploy-web'})).toBeInTheDocument();
44
45
  expect(within(summary).getAllByText('Running')).not.toHaveLength(0);
@@ -298,7 +299,15 @@ describe('WorkflowRunSummary', () => {
298
299
  const onCancel = vi.fn();
299
300
  renderSummary({}, {onCancel});
300
301
 
301
- await user.click(await screen.findByRole('button', {name: 'Cancel workflow'}));
302
+ const button = await screen.findByRole('button', {name: 'Cancel workflow'});
303
+ expect(button).toHaveClass(
304
+ 'bg-background-button-danger-default',
305
+ 'text-foreground-neutral-on-color',
306
+ 'shadow-button-danger',
307
+ );
308
+ expect(button).not.toHaveClass('bg-background-neutral-base');
309
+
310
+ await user.click(button);
302
311
 
303
312
  expect(onCancel).toHaveBeenCalledTimes(1);
304
313
  });
@@ -29,7 +29,7 @@ import {WorkflowRunAttemptSwitcher} from './workflow-run-attempt-switcher.js';
29
29
  const STATUS_BADGE_LABEL_WIDTH_CH = Math.max(
30
30
  ...WORKFLOW_RUN_STATUSES.map((status) => getWorkflowStatusVisual(status).label.length),
31
31
  );
32
- const RERUN_BUTTON_SURFACE_CLASS_NAME =
32
+ const NEUTRAL_ACTION_SURFACE_CLASS_NAME =
33
33
  'bg-background-neutral-base hover:bg-background-neutral-hover active:bg-background-neutral-pressed';
34
34
 
35
35
  type WorkflowRunAction = 'cancel' | 'rerun-all' | 'rerun-menu' | 'none';
@@ -70,10 +70,7 @@ export function WorkflowRunSummary({
70
70
 
71
71
  return (
72
72
  <TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
73
- <section
74
- aria-labelledby={headingId}
75
- className="bg-background-neutral-background px-row py-row"
76
- >
73
+ <section aria-labelledby={headingId} className="px-row py-row">
77
74
  <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-cluster gap-y-inline overflow-hidden max-[480px]:grid-cols-1">
78
75
  <div className="col-start-1 row-start-1 min-w-0 max-[480px]:col-start-auto max-[480px]:row-start-auto">
79
76
  <div className="flex min-w-0 items-center gap-inline">
@@ -228,7 +225,7 @@ function WorkflowRunActionSlot({
228
225
  type="button"
229
226
  variant="secondary"
230
227
  size="xs"
231
- className={RERUN_BUTTON_SURFACE_CLASS_NAME}
228
+ className={NEUTRAL_ACTION_SURFACE_CLASS_NAME}
232
229
  isLoading={rerunPending}
233
230
  disabled={rerunPending}
234
231
  onClick={() => onRerun('all')}
@@ -247,7 +244,7 @@ function WorkflowRunActionSlot({
247
244
  type="button"
248
245
  variant="secondary"
249
246
  size="xs"
250
- className={RERUN_BUTTON_SURFACE_CLASS_NAME}
247
+ className={NEUTRAL_ACTION_SURFACE_CLASS_NAME}
251
248
  iconRight="arrowDownSLine"
252
249
  isLoading={rerunPending}
253
250
  disabled={rerunPending}
@@ -13,6 +13,7 @@ const RUN_ID = '66666666-6666-4666-8666-666666666666';
13
13
  const CURRENT_JOB_ID = '88888888-8888-4888-8888-888888888888';
14
14
  const BUILD_LINK_PATTERN = /build/;
15
15
  const DEPLOY_LINK_PATTERN = /deploy/;
16
+ const SETUP_LINK_PATTERN = /setup/;
16
17
  const NOW = Date.parse('2026-06-26T12:00:00.000Z');
17
18
 
18
19
  describe('RunWorkspaceNav', () => {
@@ -92,7 +93,8 @@ describe('RunWorkspaceNav', () => {
92
93
  />,
93
94
  );
94
95
 
95
- expect(await screen.findByRole('link', {name: 'Summary'})).toBeInTheDocument();
96
+ const summary = await screen.findByRole('link', {name: 'Summary'});
97
+ expect(summary).toBeInTheDocument();
96
98
  expect(screen.getByRole('heading', {name: 'Jobs'})).toBeInTheDocument();
97
99
  expect(screen.getByRole('heading', {name: 'Run details'})).toBeInTheDocument();
98
100
  expect(screen.getByRole('link', {name: 'Annotations'})).toBeInTheDocument();
@@ -112,6 +114,13 @@ describe('RunWorkspaceNav', () => {
112
114
 
113
115
  const current = within(jobs).getByRole('link', {name: BUILD_LINK_PATTERN});
114
116
  expect(current).toHaveAttribute('aria-current', 'page');
117
+ expect(current.querySelector('[data-run-workspace-active-bar]')).not.toBeNull();
118
+ expect(
119
+ within(jobs)
120
+ .getByRole('link', {name: SETUP_LINK_PATTERN})
121
+ .querySelector('[data-run-workspace-active-bar]'),
122
+ ).toBeNull();
123
+ expect(jobs.querySelectorAll('[data-run-workspace-active-bar]')).toHaveLength(1);
115
124
  expect(current).toHaveAttribute(
116
125
  'href',
117
126
  expect.stringContaining(`/runs/${RUN_ID}/jobs/${CURRENT_JOB_ID}`),
@@ -137,7 +146,36 @@ describe('RunWorkspaceNav', () => {
137
146
 
138
147
  expect(await screen.findByRole('heading', {name: 'Jobs'})).toBeInTheDocument();
139
148
  expect(screen.getByRole('link', {name: BUILD_LINK_PATTERN})).toBeInTheDocument();
140
- expect(screen.getByRole('link', {name: 'Summary'})).toHaveAttribute('aria-current', 'page');
149
+ const summary = screen.getByRole('link', {name: 'Summary'});
150
+ expect(summary).toHaveAttribute('aria-current', 'page');
151
+ expect(summary.querySelector('[data-run-workspace-active-bar]')).not.toBeNull();
152
+ });
153
+
154
+ test('falls back to the first job when the current job id is stale', async () => {
155
+ const run = workflowRunDetail({
156
+ id: RUN_ID,
157
+ jobs: [
158
+ workflowJobDto({id: 'job-deploy', name: 'deploy', position: 1}),
159
+ workflowJobDto({id: 'job-setup', name: 'setup', position: 0}),
160
+ ],
161
+ });
162
+
163
+ renderWithRouter(
164
+ <RunWorkspaceNav
165
+ workspaceSlug="acme"
166
+ projectSlug="project"
167
+ run={run}
168
+ activeSection="summary"
169
+ currentJobId="stale-job-id"
170
+ />,
171
+ );
172
+
173
+ const jobs = (await screen.findByRole('heading', {name: 'Jobs'})).closest('section');
174
+ if (!jobs) throw new Error('Missing jobs section');
175
+
176
+ const fallback = within(jobs).getByRole('link', {name: SETUP_LINK_PATTERN});
177
+ expect(fallback).toHaveAttribute('aria-current', 'page');
178
+ expect(jobs.querySelectorAll('[data-run-workspace-active-bar]')).toHaveLength(1);
141
179
  });
142
180
 
143
181
  test('scrolls the current job into view when the mobile rail opens', async () => {
@@ -23,6 +23,9 @@ import {JobExecutionTimeText} from '../job-detail/job-execution-time-text.js';
23
23
 
24
24
  type RunWorkspaceSection = Exclude<WorkflowRunTab, 'jobs'>;
25
25
 
26
+ const RUN_WORKSPACE_LINK_CLASS_NAME =
27
+ 'relative flex min-h-32 items-center gap-inline rounded-4 px-tight outline-none transition-colors hover:bg-background-neutral-hover focus-visible:shadow-border-interactive-with-active @max-[767px]:min-h-44 [@media(pointer:coarse)]:min-h-44';
28
+
26
29
  export interface RunWorkspaceNavProps {
27
30
  workspaceSlug: string;
28
31
  projectSlug: string;
@@ -48,12 +51,14 @@ export function RunWorkspaceNav({
48
51
  }: RunWorkspaceNavProps) {
49
52
  const jobs = useMemo(() => [...run.jobs].sort(compareJobs), [run.jobs]);
50
53
  const [mobileOpen, setMobileOpen] = useState(false);
51
- const currentJob = jobs.find((job) => job.id === currentJobId);
54
+ const currentJob =
55
+ jobs.find((job) => job.id === currentJobId) ?? (currentJobId ? jobs[0] : undefined);
56
+ const resolvedCurrentJobId = currentJob?.id;
52
57
  const currentLabel = currentJob?.displayName ?? sectionLabel(activeSection);
53
58
 
54
59
  return (
55
60
  <TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
56
- <aside className="flex min-h-0 w-full shrink-0 flex-col border-b border-border-neutral-base bg-background-neutral-background min-[768px]:w-240 min-[768px]:border-b-0 min-[768px]:border-r">
61
+ <aside className="flex min-h-0 w-full shrink-0 flex-col border-b border-border-neutral-base min-[768px]:w-240 min-[768px]:border-b-0">
57
62
  <Collapsible
58
63
  open={mobileOpen}
59
64
  onOpenChange={setMobileOpen}
@@ -96,7 +101,7 @@ export function RunWorkspaceNav({
96
101
  run={run}
97
102
  jobs={jobs}
98
103
  activeSection={activeSection}
99
- currentJobId={currentJobId}
104
+ currentJobId={resolvedCurrentJobId}
100
105
  jobSearch={jobSearch}
101
106
  annotationSummary={annotationSummary}
102
107
  mobileOpen={mobileOpen}
@@ -140,8 +145,8 @@ function RunWorkspaceNavContent({
140
145
  }, [currentJobId, mobileOpen]);
141
146
 
142
147
  return (
143
- <nav aria-label="Run workspace" className="flex min-h-0 min-w-0 flex-1 flex-col gap-group">
144
- <ul>
148
+ <nav aria-label="Run workspace" className="flex min-h-0 min-w-0 flex-1 flex-col">
149
+ <ul className="border-b border-border-neutral-base pb-row">
145
150
  <li>
146
151
  <RunSectionLink
147
152
  workspaceSlug={workspaceSlug}
@@ -157,7 +162,7 @@ function RunWorkspaceNavContent({
157
162
 
158
163
  <section
159
164
  aria-labelledby="run-workspace-jobs-heading"
160
- className="flex min-h-0 min-w-0 flex-1 flex-col"
165
+ className="flex min-h-0 min-w-0 flex-1 flex-col border-b border-border-neutral-base py-row"
161
166
  >
162
167
  <div className="flex items-center justify-between gap-inline px-tight pb-[6px]">
163
168
  <Text
@@ -189,10 +194,12 @@ function RunWorkspaceNavContent({
189
194
  onClick={onNavigate}
190
195
  aria-current={current ? 'page' : undefined}
191
196
  className={cn(
192
- 'flex min-h-32 min-w-0 items-center gap-inline rounded-4 px-tight text-left outline-none transition-colors hover:bg-background-neutral-hover focus-visible:shadow-border-interactive-with-active @max-[767px]:min-h-44 [@media(pointer:coarse)]:min-h-44',
197
+ RUN_WORKSPACE_LINK_CLASS_NAME,
198
+ 'min-w-0 text-left',
193
199
  current && 'bg-background-neutral-hover',
194
200
  )}
195
201
  >
202
+ {current ? <RunWorkspaceActiveBar /> : null}
196
203
  <WorkflowStatusIcon
197
204
  status={deriveJobDisplayStatus(job)}
198
205
  size={12}
@@ -211,7 +218,7 @@ function RunWorkspaceNavContent({
211
218
  </ol>
212
219
  </section>
213
220
 
214
- <section aria-labelledby="run-workspace-details-heading">
221
+ <section aria-labelledby="run-workspace-details-heading" className="pt-row">
215
222
  <Text
216
223
  as="h2"
217
224
  id="run-workspace-details-heading"
@@ -292,10 +299,12 @@ function RunSectionLink({
292
299
  : undefined
293
300
  }
294
301
  className={cn(
295
- 'flex min-h-32 items-center gap-inline rounded-4 px-tight text-xs font-medium text-foreground-neutral-base outline-none transition-colors hover:bg-background-neutral-hover focus-visible:shadow-border-interactive-with-active @max-[767px]:min-h-44 [@media(pointer:coarse)]:min-h-44',
302
+ RUN_WORKSPACE_LINK_CLASS_NAME,
303
+ 'text-xs font-medium text-foreground-neutral-base',
296
304
  current && 'bg-background-neutral-hover',
297
305
  )}
298
306
  >
307
+ {current ? <RunWorkspaceActiveBar /> : null}
299
308
  {sectionLabel(section)}
300
309
  {count ? (
301
310
  <span
@@ -310,6 +319,16 @@ function RunSectionLink({
310
319
  );
311
320
  }
312
321
 
322
+ function RunWorkspaceActiveBar() {
323
+ return (
324
+ <span
325
+ aria-hidden="true"
326
+ data-run-workspace-active-bar
327
+ className="absolute inset-y-0 left-0 w-2 rounded-l-4 bg-border-highlights-interactive"
328
+ />
329
+ );
330
+ }
331
+
313
332
  function sectionLabel(section: RunWorkspaceSection): string {
314
333
  if (section === 'annotations') return 'Annotations';
315
334
  if (section === 'source') return 'Source';
@@ -7,10 +7,7 @@ import {Text} from '@shipfox/react-ui/typography';
7
7
 
8
8
  export function WorkflowRunSkeleton() {
9
9
  return (
10
- <section
11
- aria-label="Loading workflow run"
12
- className="bg-background-neutral-background px-row py-row"
13
- >
10
+ <section aria-label="Loading workflow run" className="px-row py-row">
14
11
  <div className="flex min-w-0 flex-wrap items-center gap-x-cluster gap-y-inline">
15
12
  <div className="flex min-w-0 items-center gap-inline">
16
13
  <Skeleton className="size-8 rounded-full" />