@shipfox/client-workflows 14.0.1 → 15.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 (223) hide show
  1. package/.storybook/preview.tsx +7 -2
  2. package/.turbo/turbo-build.log +1 -1
  3. package/CHANGELOG.md +21 -0
  4. package/dist/components/job-detail/agent-config-failure-callout.d.ts.map +1 -0
  5. package/dist/components/{workflow-run-view → job-detail}/agent-config-failure-callout.js +2 -2
  6. package/dist/components/job-detail/agent-config-failure-callout.js.map +1 -0
  7. package/dist/components/job-detail/job-detail-header.d.ts +8 -0
  8. package/dist/components/job-detail/job-detail-header.d.ts.map +1 -0
  9. package/dist/components/job-detail/job-detail-header.js +111 -0
  10. package/dist/components/job-detail/job-detail-header.js.map +1 -0
  11. package/dist/components/job-detail/job-detail-view.d.ts +16 -0
  12. package/dist/components/job-detail/job-detail-view.d.ts.map +1 -0
  13. package/dist/components/job-detail/job-detail-view.js +405 -0
  14. package/dist/components/job-detail/job-detail-view.js.map +1 -0
  15. package/dist/components/job-detail/job-empty-states.d.ts +10 -0
  16. package/dist/components/job-detail/job-empty-states.d.ts.map +1 -0
  17. package/dist/components/job-detail/job-empty-states.js +195 -0
  18. package/dist/components/job-detail/job-empty-states.js.map +1 -0
  19. package/dist/components/job-detail/job-execution-switcher.d.ts.map +1 -0
  20. package/dist/components/job-detail/job-execution-switcher.js.map +1 -0
  21. package/dist/components/job-detail/job-execution-time-text.d.ts.map +1 -0
  22. package/dist/components/job-detail/job-execution-time-text.js.map +1 -0
  23. package/dist/components/job-detail/job-selection.d.ts +24 -0
  24. package/dist/components/job-detail/job-selection.d.ts.map +1 -0
  25. package/dist/components/job-detail/job-selection.js +66 -0
  26. package/dist/components/job-detail/job-selection.js.map +1 -0
  27. package/dist/components/job-detail/step-attempt-log-panel.d.ts +15 -0
  28. package/dist/components/job-detail/step-attempt-log-panel.d.ts.map +1 -0
  29. package/dist/components/{workflow-run-view → job-detail}/step-attempt-log-panel.js +61 -16
  30. package/dist/components/job-detail/step-attempt-log-panel.js.map +1 -0
  31. package/dist/components/job-graph/job-graph-content.js +1 -1
  32. package/dist/components/job-graph/job-graph-content.js.map +1 -1
  33. package/dist/components/step-list/step-list-model.js +2 -2
  34. package/dist/components/step-list/step-list-model.js.map +1 -1
  35. package/dist/components/step-list/step-list.d.ts +1 -0
  36. package/dist/components/step-list/step-list.d.ts.map +1 -1
  37. package/dist/components/step-list/step-list.js +29 -4
  38. package/dist/components/step-list/step-list.js.map +1 -1
  39. package/dist/components/workflow-run-duration-label.d.ts +9 -4
  40. package/dist/components/workflow-run-duration-label.d.ts.map +1 -1
  41. package/dist/components/workflow-run-duration-label.js +27 -7
  42. package/dist/components/workflow-run-duration-label.js.map +1 -1
  43. package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
  44. package/dist/components/workflow-run-list/workflow-run-row.js +6 -5
  45. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  46. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  47. package/dist/components/workflow-run-summary/workflow-run-summary.js +68 -64
  48. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  49. package/dist/components/workflow-run-tabs/index.d.ts +0 -3
  50. package/dist/components/workflow-run-tabs/index.d.ts.map +1 -1
  51. package/dist/components/workflow-run-tabs/index.js +0 -3
  52. package/dist/components/workflow-run-tabs/index.js.map +1 -1
  53. package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts +3 -1
  54. package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts.map +1 -1
  55. package/dist/components/workflow-run-tabs/run-annotations-empty.js +2 -2
  56. package/dist/components/workflow-run-tabs/run-annotations-empty.js.map +1 -1
  57. package/dist/components/workflow-run-view/index.d.ts +0 -1
  58. package/dist/components/workflow-run-view/index.d.ts.map +1 -1
  59. package/dist/components/workflow-run-view/index.js +0 -1
  60. package/dist/components/workflow-run-view/index.js.map +1 -1
  61. package/dist/components/workflow-run-view/run-workspace-nav.d.ts +20 -0
  62. package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -0
  63. package/dist/components/workflow-run-view/run-workspace-nav.js +271 -0
  64. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -0
  65. package/dist/components/workflow-run-view/workflow-run-selection.d.ts.map +1 -1
  66. package/dist/components/workflow-run-view/workflow-run-selection.js +1 -14
  67. package/dist/components/workflow-run-view/workflow-run-selection.js.map +1 -1
  68. package/dist/components/workflow-run-view/workflow-run-view.d.ts +10 -8
  69. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  70. package/dist/components/workflow-run-view/workflow-run-view.js +250 -236
  71. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  72. package/dist/components/workflow-status/status-visuals.d.ts.map +1 -1
  73. package/dist/components/workflow-status/status-visuals.js +10 -2
  74. package/dist/components/workflow-status/status-visuals.js.map +1 -1
  75. package/dist/components/workflow-status/workflow-status-icon.d.ts +3 -1
  76. package/dist/components/workflow-status/workflow-status-icon.d.ts.map +1 -1
  77. package/dist/components/workflow-status/workflow-status-icon.js +13 -2
  78. package/dist/components/workflow-status/workflow-status-icon.js.map +1 -1
  79. package/dist/core/entities/job-execution.d.ts +9 -0
  80. package/dist/core/entities/job-execution.d.ts.map +1 -1
  81. package/dist/core/entities/job-execution.js +13 -3
  82. package/dist/core/entities/job-execution.js.map +1 -1
  83. package/dist/core/entities/job.d.ts.map +1 -1
  84. package/dist/core/entities/step-attempt.d.ts.map +1 -1
  85. package/dist/core/entities/step.d.ts +2 -0
  86. package/dist/core/entities/step.d.ts.map +1 -1
  87. package/dist/core/entities/step.js +13 -0
  88. package/dist/core/entities/step.js.map +1 -1
  89. package/dist/core/entities/workflow-run-attempt.d.ts.map +1 -1
  90. package/dist/core/entities/workflow-run.d.ts +56 -1
  91. package/dist/core/entities/workflow-run.d.ts.map +1 -1
  92. package/dist/core/entities/workflow-run.js +98 -0
  93. package/dist/core/entities/workflow-run.js.map +1 -1
  94. package/dist/core/workflow-run.d.ts +4 -4
  95. package/dist/core/workflow-run.d.ts.map +1 -1
  96. package/dist/core/workflow-run.js +3 -3
  97. package/dist/core/workflow-run.js.map +1 -1
  98. package/dist/feature.d.ts +20 -16
  99. package/dist/feature.d.ts.map +1 -1
  100. package/dist/feature.js +5 -0
  101. package/dist/feature.js.map +1 -1
  102. package/dist/hooks/api/workflow-runs.d.ts +5 -3
  103. package/dist/hooks/api/workflow-runs.d.ts.map +1 -1
  104. package/dist/hooks/api/workflow-runs.js +5 -4
  105. package/dist/hooks/api/workflow-runs.js.map +1 -1
  106. package/dist/index.d.ts +1 -0
  107. package/dist/index.d.ts.map +1 -1
  108. package/dist/index.js +1 -0
  109. package/dist/index.js.map +1 -1
  110. package/dist/pages/workflow-job-detail-page.d.ts +11 -0
  111. package/dist/pages/workflow-job-detail-page.d.ts.map +1 -0
  112. package/dist/pages/workflow-job-detail-page.js +66 -0
  113. package/dist/pages/workflow-job-detail-page.js.map +1 -0
  114. package/dist/pages/workflow-run-detail-page.d.ts.map +1 -1
  115. package/dist/pages/workflow-run-detail-page.js +4 -37
  116. package/dist/pages/workflow-run-detail-page.js.map +1 -1
  117. package/dist/routes/inputs.d.ts +47 -34
  118. package/dist/routes/inputs.d.ts.map +1 -1
  119. package/dist/routes/inputs.js +69 -37
  120. package/dist/routes/inputs.js.map +1 -1
  121. package/dist/routes/job-detail.d.ts +10 -0
  122. package/dist/routes/job-detail.d.ts.map +1 -0
  123. package/dist/routes/job-detail.js +27 -0
  124. package/dist/routes/job-detail.js.map +1 -0
  125. package/dist/routes/project-route.d.ts +1 -1
  126. package/dist/routes/run-detail.d.ts.map +1 -1
  127. package/dist/routes/runs.d.ts.map +1 -1
  128. package/dist/routes/workflows.d.ts.map +1 -1
  129. package/dist/tsconfig.test.tsbuildinfo +1 -1
  130. package/package.json +3 -3
  131. package/src/components/{workflow-run-view → job-detail}/agent-config-failure-callout.stories.tsx +1 -1
  132. package/src/components/{workflow-run-view → job-detail}/agent-config-failure-callout.tsx +2 -2
  133. package/src/components/job-detail/job-detail-header.test.tsx +31 -0
  134. package/src/components/job-detail/job-detail-header.tsx +111 -0
  135. package/src/components/job-detail/job-detail-view.stories.tsx +526 -0
  136. package/src/components/job-detail/job-detail-view.tsx +450 -0
  137. package/src/components/job-detail/job-empty-states.test.ts +27 -0
  138. package/src/components/job-detail/job-empty-states.tsx +248 -0
  139. package/src/components/job-detail/job-selection.test.ts +188 -0
  140. package/src/components/job-detail/job-selection.ts +96 -0
  141. package/src/components/{workflow-run-view → job-detail}/step-attempt-log-panel.test.tsx +32 -9
  142. package/src/components/{workflow-run-view → job-detail}/step-attempt-log-panel.tsx +52 -18
  143. package/src/components/job-graph/job-graph-content.tsx +1 -1
  144. package/src/components/step-list/step-list-model.test.ts +1 -1
  145. package/src/components/step-list/step-list-model.ts +2 -2
  146. package/src/components/step-list/step-list.stories.tsx +1 -1
  147. package/src/components/step-list/step-list.test.tsx +33 -0
  148. package/src/components/step-list/step-list.tsx +40 -9
  149. package/src/components/workflow-run-duration-label.tsx +50 -12
  150. package/src/components/workflow-run-list/workflow-run-row.tsx +6 -4
  151. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +38 -0
  152. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +79 -5
  153. package/src/components/workflow-run-summary/workflow-run-summary.tsx +56 -60
  154. package/src/components/workflow-run-tabs/index.ts +0 -3
  155. package/src/components/workflow-run-tabs/run-annotations-empty.tsx +6 -2
  156. package/src/components/workflow-run-view/index.ts +0 -1
  157. package/src/components/workflow-run-view/run-workspace-nav.test.tsx +242 -0
  158. package/src/components/workflow-run-view/run-workspace-nav.tsx +301 -0
  159. package/src/components/workflow-run-view/workflow-run-selection.ts +1 -23
  160. package/src/components/workflow-run-view/workflow-run-view.test.tsx +62 -1136
  161. package/src/components/workflow-run-view/workflow-run-view.tsx +330 -321
  162. package/src/components/workflow-status/status-visuals.ts +5 -2
  163. package/src/components/workflow-status/workflow-status-icon.stories.tsx +1 -0
  164. package/src/components/workflow-status/workflow-status-icon.tsx +26 -1
  165. package/src/core/entities/job-execution.ts +17 -2
  166. package/src/core/entities/step.ts +26 -0
  167. package/src/core/entities/workflow-run-display.test.ts +159 -0
  168. package/src/core/entities/workflow-run.ts +139 -1
  169. package/src/core/workflow-run.ts +16 -1
  170. package/src/feature.ts +5 -0
  171. package/src/hooks/api/workflow-runs.ts +6 -2
  172. package/src/index.ts +1 -0
  173. package/src/page-harness-budget.test.ts +1 -0
  174. package/src/pages/workflow-job-detail-page.test.tsx +539 -0
  175. package/src/pages/workflow-job-detail-page.tsx +84 -0
  176. package/src/pages/workflow-run-detail-page.tsx +3 -43
  177. package/src/pages/workflow-run-pages.test.tsx +77 -332
  178. package/src/routes/inputs.test.ts +56 -6
  179. package/src/routes/inputs.ts +64 -17
  180. package/src/routes/job-detail.tsx +29 -0
  181. package/test/pages.tsx +25 -3
  182. package/test/render.tsx +6 -1
  183. package/tsconfig.build.tsbuildinfo +1 -1
  184. package/dist/components/workflow-run-tabs/run-jobs-list.d.ts +0 -18
  185. package/dist/components/workflow-run-tabs/run-jobs-list.d.ts.map +0 -1
  186. package/dist/components/workflow-run-tabs/run-jobs-list.js +0 -107
  187. package/dist/components/workflow-run-tabs/run-jobs-list.js.map +0 -1
  188. package/dist/components/workflow-run-tabs/run-tab-count.d.ts +0 -6
  189. package/dist/components/workflow-run-tabs/run-tab-count.d.ts.map +0 -1
  190. package/dist/components/workflow-run-tabs/run-tab-count.js +0 -27
  191. package/dist/components/workflow-run-tabs/run-tab-count.js.map +0 -1
  192. package/dist/components/workflow-run-tabs/run-tab-strip.d.ts +0 -8
  193. package/dist/components/workflow-run-tabs/run-tab-strip.d.ts.map +0 -1
  194. package/dist/components/workflow-run-tabs/run-tab-strip.js +0 -82
  195. package/dist/components/workflow-run-tabs/run-tab-strip.js.map +0 -1
  196. package/dist/components/workflow-run-view/agent-config-failure-callout.d.ts.map +0 -1
  197. package/dist/components/workflow-run-view/agent-config-failure-callout.js.map +0 -1
  198. package/dist/components/workflow-run-view/job-card.d.ts +0 -17
  199. package/dist/components/workflow-run-view/job-card.d.ts.map +0 -1
  200. package/dist/components/workflow-run-view/job-card.js +0 -496
  201. package/dist/components/workflow-run-view/job-card.js.map +0 -1
  202. package/dist/components/workflow-run-view/job-execution-switcher.d.ts.map +0 -1
  203. package/dist/components/workflow-run-view/job-execution-switcher.js.map +0 -1
  204. package/dist/components/workflow-run-view/job-execution-time-text.d.ts.map +0 -1
  205. package/dist/components/workflow-run-view/job-execution-time-text.js.map +0 -1
  206. package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts +0 -9
  207. package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts.map +0 -1
  208. package/dist/components/workflow-run-view/step-attempt-log-panel.js.map +0 -1
  209. package/src/components/workflow-run-tabs/run-jobs-list.tsx +0 -151
  210. package/src/components/workflow-run-tabs/run-tab-count.tsx +0 -28
  211. package/src/components/workflow-run-tabs/run-tab-strip.stories.tsx +0 -52
  212. package/src/components/workflow-run-tabs/run-tab-strip.test.tsx +0 -117
  213. package/src/components/workflow-run-tabs/run-tab-strip.tsx +0 -97
  214. package/src/components/workflow-run-view/job-card.stories.tsx +0 -789
  215. package/src/components/workflow-run-view/job-card.test.tsx +0 -115
  216. package/src/components/workflow-run-view/job-card.tsx +0 -614
  217. /package/dist/components/{workflow-run-view → job-detail}/agent-config-failure-callout.d.ts +0 -0
  218. /package/dist/components/{workflow-run-view → job-detail}/job-execution-switcher.d.ts +0 -0
  219. /package/dist/components/{workflow-run-view → job-detail}/job-execution-switcher.js +0 -0
  220. /package/dist/components/{workflow-run-view → job-detail}/job-execution-time-text.d.ts +0 -0
  221. /package/dist/components/{workflow-run-view → job-detail}/job-execution-time-text.js +0 -0
  222. /package/src/components/{workflow-run-view → job-detail}/job-execution-switcher.tsx +0 -0
  223. /package/src/components/{workflow-run-view → job-detail}/job-execution-time-text.tsx +0 -0
@@ -1,7 +1,11 @@
1
1
  import {render, screen, waitFor, within} from '@testing-library/react';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import type {workflowRunDetailDto} from '#test/fixtures/workflow-run.js';
4
- import {workflowJobDto, workflowRunDetail} from '#test/fixtures/workflow-run.js';
4
+ import {
5
+ workflowJobDto,
6
+ workflowJobExecutionDto,
7
+ workflowRunDetail,
8
+ } from '#test/fixtures/workflow-run.js';
5
9
  import {WorkflowRunSummary} from './workflow-run-summary.js';
6
10
 
7
11
  const RUN_ID = '66666666-6666-4666-8666-666666666666';
@@ -125,9 +129,9 @@ describe('WorkflowRunSummary', () => {
125
129
  const heading = await screen.findByRole('heading', {name: runName});
126
130
 
127
131
  expect(heading).toHaveAttribute('data-slot', 'tooltip-trigger');
128
- await waitFor(() =>
129
- expect(within(heading).getByText(runName)).toHaveAttribute('title', runName),
130
- );
132
+ // The Radix tooltip is the only name affordance: a native `title` beside it would fire a
133
+ // second, unstyled OS tooltip with the same text.
134
+ await waitFor(() => expect(within(heading).getByText(runName)).not.toHaveAttribute('title'));
131
135
  });
132
136
 
133
137
  test('shows the selected attempt duration, not the top-level run duration', async () => {
@@ -174,6 +178,61 @@ describe('WorkflowRunSummary', () => {
174
178
  expect(duration).toHaveAttribute('aria-label', 'running 2m 14s');
175
179
  });
176
180
 
181
+ test('reads a running run whose jobs have not started as queued', async () => {
182
+ vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-05-07T01:02:14.000Z'));
183
+ renderSummary({
184
+ jobs: [workflowJobDto({status: 'pending'})],
185
+ run_attempt: runningAttemptDto(),
186
+ });
187
+
188
+ const summary = await screen.findByRole('region', {name: 'deploy-web'});
189
+
190
+ expect(within(summary).getAllByText('Queued')).not.toHaveLength(0);
191
+ expect(within(summary).queryByText('Running')).not.toBeInTheDocument();
192
+ expect(within(summary).getByText('2m 14s')).toHaveAttribute('aria-label', 'queued 2m 14s');
193
+ });
194
+
195
+ test('names the job a queued run is waiting on when there is more than one', async () => {
196
+ renderSummary({
197
+ jobs: [
198
+ workflowJobDto({
199
+ name: 'lint',
200
+ status: 'pending',
201
+ job_executions: [workflowJobExecutionDto({queued_at: '2026-05-07T01:00:00.000Z'})],
202
+ }),
203
+ workflowJobDto({
204
+ name: 'build',
205
+ status: 'pending',
206
+ job_executions: [workflowJobExecutionDto({queued_at: '2026-05-07T00:59:00.000Z'})],
207
+ }),
208
+ ],
209
+ run_attempt: runningAttemptDto(),
210
+ });
211
+
212
+ const summary = await screen.findByRole('region', {name: 'deploy-web'});
213
+
214
+ expect(within(summary).getByText('waiting on')).toBeInTheDocument();
215
+ expect(within(summary).getByText('build')).toBeInTheDocument();
216
+ });
217
+
218
+ test('leaves the waiting job unnamed when the run has a single job', async () => {
219
+ renderSummary({
220
+ jobs: [
221
+ workflowJobDto({
222
+ name: 'build',
223
+ status: 'pending',
224
+ job_executions: [workflowJobExecutionDto({queued_at: '2026-05-07T01:00:00.000Z'})],
225
+ }),
226
+ ],
227
+ run_attempt: runningAttemptDto(),
228
+ });
229
+
230
+ const summary = await screen.findByRole('region', {name: 'deploy-web'});
231
+
232
+ expect(within(summary).getAllByText('Queued')).not.toHaveLength(0);
233
+ expect(within(summary).queryByText('waiting on')).not.toBeInTheDocument();
234
+ });
235
+
177
236
  test('does not render a whole-run source control', async () => {
178
237
  renderSummary();
179
238
 
@@ -250,7 +309,9 @@ describe('WorkflowRunSummary', () => {
250
309
  const onRerun = vi.fn();
251
310
  renderSummary({status: 'failed', jobs: [workflowJobDto({status: 'failed'})]}, {onRerun});
252
311
 
253
- await user.click(await screen.findByRole('button', {name: 'Re-run jobs'}));
312
+ const rerunButton = await screen.findByRole('button', {name: 'Re-run jobs'});
313
+ expect(rerunButton).toHaveClass('bg-background-neutral-base');
314
+ await user.click(rerunButton);
254
315
  expect(await screen.findByRole('menuitem', {name: 'Re-run all jobs'})).toBeInTheDocument();
255
316
  await user.click(await screen.findByRole('menuitem', {name: 'Re-run failed jobs'}));
256
317
 
@@ -328,6 +389,19 @@ function renderSummary(
328
389
  render(<WorkflowRunSummary run={run} {...props} />);
329
390
  }
330
391
 
392
+ function runningAttemptDto() {
393
+ return {
394
+ id: '11111111-1111-4111-8111-000000000001',
395
+ workflow_run_id: RUN_ID,
396
+ attempt: 1,
397
+ status: 'running' as const,
398
+ created_at: '2026-05-07T01:01:00.000Z',
399
+ started_at: '2026-05-07T01:00:00.000Z',
400
+ finished_at: null,
401
+ rerun_mode: null,
402
+ };
403
+ }
404
+
331
405
  function setElementWidths({scrollWidth, clientWidth}: {scrollWidth: number; clientWidth: number}) {
332
406
  Object.defineProperty(window.HTMLElement.prototype, 'scrollWidth', {
333
407
  configurable: true,
@@ -11,8 +11,7 @@ import {useIsTextTruncated} from '@shipfox/react-ui/hooks';
11
11
  import {RelativeTime} from '@shipfox/react-ui/relative-time';
12
12
  import {TimeTickerProvider} from '@shipfox/react-ui/time-ticker';
13
13
  import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
14
- import {Header, Text} from '@shipfox/react-ui/typography';
15
- import {Link} from '@tanstack/react-router';
14
+ import {Code, Header, Text} from '@shipfox/react-ui/typography';
16
15
  import {useId} from 'react';
17
16
  import {WorkflowRunNumberLabel} from '#components/workflow-run-number-label.js';
18
17
  import {
@@ -21,15 +20,20 @@ import {
21
20
  WORKFLOW_RUN_STATUSES,
22
21
  type WorkflowRunDetail,
23
22
  type WorkflowRunRerunMode,
23
+ workflowRunBlockingJob,
24
+ workflowRunDetailDisplay,
24
25
  } from '#core/workflow-run.js';
25
- import {validateWorkflowRunsSearch, workflowRunListSearchParams} from '#routes/inputs.js';
26
26
  import {WorkflowRunDurationLabel} from '../workflow-run-duration-label.js';
27
27
  import {getWorkflowStatusVisual} from '../workflow-status/status-visuals.js';
28
28
  import {WorkflowRunAttemptSwitcher} from './workflow-run-attempt-switcher.js';
29
29
 
30
30
  const STATUS_BADGE_LABEL_WIDTH_CH = Math.max(
31
- ...WORKFLOW_RUN_STATUSES.map((status) => getWorkflowStatusVisual(status).label.length),
31
+ ...[...WORKFLOW_RUN_STATUSES, 'queued' as const].map(
32
+ (status) => getWorkflowStatusVisual(status).label.length,
33
+ ),
32
34
  );
35
+ const RERUN_BUTTON_SURFACE_CLASS_NAME =
36
+ 'bg-background-neutral-base hover:bg-background-neutral-hover active:bg-background-neutral-pressed';
33
37
 
34
38
  type WorkflowRunAction = 'cancel' | 'rerun-all' | 'rerun-menu' | 'none';
35
39
 
@@ -55,14 +59,19 @@ export function WorkflowRunSummary({
55
59
  latestAttempt,
56
60
  }: WorkflowRunSummaryProps) {
57
61
  const headingId = useId();
58
- const status = getWorkflowStatusVisual(run.runAttempt.status);
62
+ const display = workflowRunDetailDisplay(run);
63
+ const status = getWorkflowStatusVisual(display.status);
59
64
  const action = workflowRunActionForRun(run);
60
65
  const hasAction = canRenderWorkflowRunAction(action, onCancel, onRerun);
61
66
  const attemptSwitcher =
62
67
  latestAttempt && latestAttempt > 1 && workspaceSlug && projectSlug
63
68
  ? {workspaceSlug, projectSlug, latestAttempt}
64
69
  : null;
65
- const displayDuration = run.runAttempt.displayDuration;
70
+ const displayDuration = display.duration;
71
+ // Named only while the run is waiting and has more than one job: with a single job the rail
72
+ // and the graph already say which one it is.
73
+ const blockingJob =
74
+ display.status === 'queued' && run.jobs.length > 1 ? workflowRunBlockingJob(run.jobs) : null;
66
75
  const {ref: headingTextRef, isTruncated: isHeadingTruncated} =
67
76
  useIsTextTruncated<HTMLSpanElement>(run.name);
68
77
 
@@ -70,61 +79,32 @@ export function WorkflowRunSummary({
70
79
  <TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
71
80
  <section aria-labelledby={headingId} className="bg-background-neutral-background px-16 py-12">
72
81
  <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-12 gap-y-8 overflow-hidden max-[480px]:grid-cols-1">
73
- <nav
74
- aria-label="Breadcrumb"
75
- className="col-start-1 row-start-1 min-w-0 max-[480px]:col-start-auto max-[480px]:row-start-auto"
76
- >
77
- <ol className="flex min-w-0 items-center gap-8">
78
- {workspaceSlug && projectSlug ? (
79
- <li className="shrink-0">
80
- <Link
81
- to="/w/$workspaceSlug/p/$projectSlug/runs"
82
- params={{workspaceSlug, projectSlug}}
83
- search={
84
- ((previous: Record<string, unknown>) =>
85
- workflowRunListSearchParams(validateWorkflowRunsSearch(previous))) as never
86
- }
87
- className="rounded-4 text-xs font-medium text-foreground-neutral-subtle underline decoration-border-neutral-strong underline-offset-4 outline-none hover:text-foreground-neutral-base focus-visible:shadow-border-interactive-with-active"
88
- >
89
- Runs
90
- </Link>
91
- </li>
92
- ) : null}
93
- {workspaceSlug && projectSlug ? (
94
- <li aria-hidden="true" className="shrink-0 text-xs text-foreground-neutral-subtle">
95
- /
96
- </li>
97
- ) : null}
98
- <li aria-current="page" className="flex min-w-0 items-center gap-8">
99
- <Badge variant={status.badge} size="xs">
100
- <span className="text-center" style={{width: `${STATUS_BADGE_LABEL_WIDTH_CH}ch`}}>
101
- {status.label}
102
- </span>
103
- </Badge>
82
+ <div className="col-start-1 row-start-1 min-w-0 max-[480px]:col-start-auto max-[480px]:row-start-auto">
83
+ <div className="flex min-w-0 items-center gap-8">
84
+ <Badge variant={status.badge} size="xs">
85
+ <span className="text-center" style={{width: `${STATUS_BADGE_LABEL_WIDTH_CH}ch`}}>
86
+ {status.label}
87
+ </span>
88
+ </Badge>
104
89
 
105
- <Tooltip>
106
- <TooltipTrigger asChild>
107
- <Header id={headingId} variant="h3" className="min-w-0 truncate">
108
- <span
109
- ref={headingTextRef}
110
- title={isHeadingTruncated ? run.name : undefined}
111
- className="block min-w-0 truncate"
112
- >
113
- {run.name}
114
- </span>
115
- </Header>
116
- </TooltipTrigger>
117
- {isHeadingTruncated ? (
118
- <TooltipContent>
119
- <Text as="span" size="xs" className="max-w-[360px] break-words">
120
- {run.name}
121
- </Text>
122
- </TooltipContent>
123
- ) : null}
124
- </Tooltip>
125
- </li>
126
- </ol>
127
- </nav>
90
+ <Tooltip>
91
+ <TooltipTrigger asChild>
92
+ <Header id={headingId} variant="h3" className="min-w-0 truncate">
93
+ <span ref={headingTextRef} className="block min-w-0 truncate">
94
+ {run.name}
95
+ </span>
96
+ </Header>
97
+ </TooltipTrigger>
98
+ {isHeadingTruncated ? (
99
+ <TooltipContent>
100
+ <Text as="span" size="xs" className="max-w-[360px] break-words">
101
+ {run.name}
102
+ </Text>
103
+ </TooltipContent>
104
+ ) : null}
105
+ </Tooltip>
106
+ </div>
107
+ </div>
128
108
 
129
109
  {hasAction ? (
130
110
  <div className="col-start-2 row-start-1 flex min-w-max items-center gap-6 justify-self-end max-[480px]:col-start-auto max-[480px]:row-start-auto max-[480px]:justify-self-start">
@@ -204,6 +184,20 @@ export function WorkflowRunSummary({
204
184
  />
205
185
  </>
206
186
  ) : null}
187
+
188
+ {blockingJob ? (
189
+ <>
190
+ <MetadataSeparator />
191
+ <span className="flex min-w-0 items-center gap-4">
192
+ <Text as="span" size="xs" className="shrink-0">
193
+ waiting on
194
+ </Text>
195
+ <Code as="span" variant="label" className="min-w-0 truncate">
196
+ {blockingJob.displayName}
197
+ </Code>
198
+ </span>
199
+ </>
200
+ ) : null}
207
201
  </div>
208
202
  </div>
209
203
  </section>
@@ -251,6 +245,7 @@ function WorkflowRunActionSlot({
251
245
  type="button"
252
246
  variant="secondary"
253
247
  size="xs"
248
+ className={RERUN_BUTTON_SURFACE_CLASS_NAME}
254
249
  isLoading={rerunPending}
255
250
  disabled={rerunPending}
256
251
  onClick={() => onRerun('all')}
@@ -269,6 +264,7 @@ function WorkflowRunActionSlot({
269
264
  type="button"
270
265
  variant="secondary"
271
266
  size="xs"
267
+ className={RERUN_BUTTON_SURFACE_CLASS_NAME}
272
268
  iconRight="arrowDownSLine"
273
269
  isLoading={rerunPending}
274
270
  disabled={rerunPending}
@@ -1,5 +1,2 @@
1
1
  export {RunAnnotationSummaryLine} from './run-annotation-summary-line.js';
2
2
  export {RunAnnotationsEmpty} from './run-annotations-empty.js';
3
- export {RunJobsList} from './run-jobs-list.js';
4
- export {RunTabCount} from './run-tab-count.js';
5
- export {RunTabStrip} from './run-tab-strip.js';
@@ -1,11 +1,15 @@
1
1
  import {EmptyState} from '@shipfox/react-ui/empty-state';
2
2
 
3
- export function RunAnnotationsEmpty() {
3
+ export function RunAnnotationsEmpty({jobName}: {jobName?: string | undefined} = {}) {
4
4
  return (
5
5
  <EmptyState
6
6
  icon="fileDamageLine"
7
7
  title="No annotations"
8
- description="This run has no annotations to show."
8
+ description={
9
+ jobName
10
+ ? `${jobName} has no annotations to show in this run.`
11
+ : 'This run has no annotations to show.'
12
+ }
9
13
  />
10
14
  );
11
15
  }
@@ -1,2 +1 @@
1
- export {JobCard} from './job-card.js';
2
1
  export {WorkflowRunView} from './workflow-run-view.js';
@@ -0,0 +1,242 @@
1
+ import {act, screen, within} from '@testing-library/react';
2
+ import userEvent from '@testing-library/user-event';
3
+ import {useState} from 'react';
4
+ import {
5
+ workflowJobDto,
6
+ workflowJobExecutionDto,
7
+ workflowRunDetail,
8
+ } from '#test/fixtures/workflow-run.js';
9
+ import {renderWithRouter} from '#test/render.js';
10
+ import {RunWorkspaceNav} from './run-workspace-nav.js';
11
+
12
+ const RUN_ID = '66666666-6666-4666-8666-666666666666';
13
+ const CURRENT_JOB_ID = '88888888-8888-4888-8888-888888888888';
14
+ const BUILD_LINK_PATTERN = /build/;
15
+ const DEPLOY_LINK_PATTERN = /deploy/;
16
+ const NOW = Date.parse('2026-06-26T12:00:00.000Z');
17
+
18
+ describe('RunWorkspaceNav', () => {
19
+ beforeEach(() => {
20
+ Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
21
+ configurable: true,
22
+ value: vi.fn(),
23
+ });
24
+ });
25
+
26
+ afterEach(() => {
27
+ vi.useRealTimers();
28
+ });
29
+
30
+ test('shows the complete run hierarchy and marks the current job', async () => {
31
+ const run = workflowRunDetail({
32
+ id: RUN_ID,
33
+ jobs: [
34
+ workflowJobDto({id: 'job-deploy', name: 'deploy', position: 2, status: 'failed'}),
35
+ workflowJobDto({id: CURRENT_JOB_ID, name: 'build', position: 1, status: 'running'}),
36
+ workflowJobDto({id: 'job-setup', name: 'setup', position: 0, status: 'succeeded'}),
37
+ ],
38
+ });
39
+
40
+ renderWithRouter(
41
+ <RunWorkspaceNav
42
+ workspaceSlug="acme"
43
+ projectSlug="project"
44
+ run={run}
45
+ activeSection="summary"
46
+ currentJobId={CURRENT_JOB_ID}
47
+ jobSearch={{runAttempt: 2}}
48
+ />,
49
+ );
50
+
51
+ expect(await screen.findByRole('link', {name: 'Summary'})).toBeInTheDocument();
52
+ expect(screen.getByRole('heading', {name: 'Jobs'})).toBeInTheDocument();
53
+ expect(screen.getByRole('heading', {name: 'Run details'})).toBeInTheDocument();
54
+ expect(screen.getByRole('link', {name: 'Annotations'})).toBeInTheDocument();
55
+ expect(screen.getByRole('link', {name: 'Source'})).toBeInTheDocument();
56
+
57
+ const jobs = screen.getByRole('heading', {name: 'Jobs'}).closest('section');
58
+ if (!jobs) throw new Error('Missing jobs section');
59
+ expect(
60
+ within(jobs)
61
+ .getAllByRole('link')
62
+ .map((link) => link.textContent?.trim()),
63
+ ).toEqual([
64
+ expect.stringContaining('setup'),
65
+ expect.stringContaining('build'),
66
+ expect.stringContaining('deploy'),
67
+ ]);
68
+
69
+ const current = within(jobs).getByRole('link', {name: BUILD_LINK_PATTERN});
70
+ expect(current).toHaveAttribute('aria-current', 'page');
71
+ expect(current).toHaveAttribute(
72
+ 'href',
73
+ expect.stringContaining(`/runs/${RUN_ID}/jobs/${CURRENT_JOB_ID}`),
74
+ );
75
+ expect(current).not.toHaveAttribute('href', expect.stringContaining('tab='));
76
+ expect(current).toHaveAttribute('href', expect.stringContaining('runAttempt=%222%22'));
77
+ });
78
+
79
+ test('keeps the job index visible for a single-job run', async () => {
80
+ const run = workflowRunDetail({
81
+ id: RUN_ID,
82
+ jobs: [workflowJobDto({id: CURRENT_JOB_ID, name: 'build', status: 'succeeded'})],
83
+ });
84
+
85
+ renderWithRouter(
86
+ <RunWorkspaceNav
87
+ workspaceSlug="acme"
88
+ projectSlug="project"
89
+ run={run}
90
+ activeSection="summary"
91
+ />,
92
+ );
93
+
94
+ expect(await screen.findByRole('heading', {name: 'Jobs'})).toBeInTheDocument();
95
+ expect(screen.getByRole('link', {name: BUILD_LINK_PATTERN})).toBeInTheDocument();
96
+ expect(screen.getByRole('link', {name: 'Summary'})).toHaveAttribute('aria-current', 'page');
97
+ });
98
+
99
+ test('scrolls the current job into view when the mobile rail opens', async () => {
100
+ const user = userEvent.setup();
101
+ const scrollIntoView = vi.fn();
102
+ Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
103
+ configurable: true,
104
+ value: scrollIntoView,
105
+ });
106
+ const run = workflowRunDetail({
107
+ id: RUN_ID,
108
+ jobs: [workflowJobDto({id: CURRENT_JOB_ID, name: 'build', status: 'running'})],
109
+ });
110
+
111
+ renderWithRouter(
112
+ <RunWorkspaceNav
113
+ workspaceSlug="acme"
114
+ projectSlug="project"
115
+ run={run}
116
+ activeSection="summary"
117
+ currentJobId={CURRENT_JOB_ID}
118
+ />,
119
+ );
120
+
121
+ await screen.findByRole('link', {name: BUILD_LINK_PATTERN});
122
+ await user.click(await screen.findByRole('button', {name: 'Toggle run navigation'}));
123
+
124
+ expect(scrollIntoView).toHaveBeenCalledTimes(2);
125
+ });
126
+
127
+ test('closes the mobile rail after navigating to a job', async () => {
128
+ const user = userEvent.setup();
129
+ const run = workflowRunDetail({
130
+ id: RUN_ID,
131
+ jobs: [
132
+ workflowJobDto({id: CURRENT_JOB_ID, name: 'build', status: 'running'}),
133
+ workflowJobDto({id: 'job-deploy', name: 'deploy', position: 1, status: 'succeeded'}),
134
+ ],
135
+ });
136
+
137
+ renderWithRouter(
138
+ <RunWorkspaceNav
139
+ workspaceSlug="acme"
140
+ projectSlug="project"
141
+ run={run}
142
+ activeSection="summary"
143
+ currentJobId={CURRENT_JOB_ID}
144
+ />,
145
+ );
146
+
147
+ const trigger = await screen.findByRole('button', {name: 'Toggle run navigation'});
148
+ await user.click(trigger);
149
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
150
+
151
+ await user.click(screen.getByRole('link', {name: DEPLOY_LINK_PATTERN}));
152
+
153
+ expect(trigger).toHaveAttribute('aria-expanded', 'false');
154
+ });
155
+
156
+ test('scrolls once when the active job changes while the rail is open', async () => {
157
+ const user = userEvent.setup();
158
+ const scrollIntoView = vi.fn();
159
+ Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
160
+ configurable: true,
161
+ value: scrollIntoView,
162
+ });
163
+ const firstJob = workflowJobDto({id: CURRENT_JOB_ID, name: 'build', status: 'running'});
164
+ const secondJob = workflowJobDto({
165
+ id: 'job-deploy',
166
+ name: 'deploy',
167
+ position: 1,
168
+ status: 'running',
169
+ });
170
+ const initialRun = workflowRunDetail({id: RUN_ID, jobs: [firstJob, secondJob]});
171
+ renderWithRouter(<ActiveJobHarness run={initialRun} />);
172
+
173
+ await screen.findByRole('link', {name: BUILD_LINK_PATTERN});
174
+ await user.click(await screen.findByRole('button', {name: 'Toggle run navigation'}));
175
+ const callsBeforeJobChange = scrollIntoView.mock.calls.length;
176
+
177
+ await user.click(screen.getByRole('button', {name: 'Activate deploy'}));
178
+
179
+ expect(scrollIntoView).toHaveBeenCalledTimes(callsBeforeJobChange + 1);
180
+ });
181
+
182
+ test('updates live job durations on the ticker cadence', async () => {
183
+ vi.useFakeTimers({shouldAdvanceTime: true});
184
+ vi.setSystemTime(NOW);
185
+ const run = workflowRunDetail({
186
+ id: RUN_ID,
187
+ jobs: [
188
+ workflowJobDto({
189
+ id: CURRENT_JOB_ID,
190
+ name: 'build',
191
+ status: 'running',
192
+ job_executions: [
193
+ workflowJobExecutionDto({
194
+ job_id: CURRENT_JOB_ID,
195
+ status: 'running',
196
+ queued_at: '2026-06-26T11:54:00.000Z',
197
+ started_at: '2026-06-26T11:57:46.000Z',
198
+ }),
199
+ ],
200
+ }),
201
+ ],
202
+ });
203
+
204
+ renderWithRouter(
205
+ <RunWorkspaceNav
206
+ workspaceSlug="acme"
207
+ projectSlug="project"
208
+ run={run}
209
+ activeSection="summary"
210
+ currentJobId={CURRENT_JOB_ID}
211
+ />,
212
+ );
213
+
214
+ const jobLink = await screen.findByRole('link', {name: BUILD_LINK_PATTERN});
215
+ expect(jobLink).toHaveTextContent('2m 14s');
216
+
217
+ act(() => {
218
+ vi.advanceTimersByTime(1000);
219
+ });
220
+
221
+ expect(jobLink).toHaveTextContent('2m 15s');
222
+ });
223
+ });
224
+
225
+ function ActiveJobHarness({run}: {run: ReturnType<typeof workflowRunDetail>}) {
226
+ const [currentJobId, setCurrentJobId] = useState(CURRENT_JOB_ID);
227
+
228
+ return (
229
+ <>
230
+ <RunWorkspaceNav
231
+ workspaceSlug="acme"
232
+ projectSlug="project"
233
+ run={run}
234
+ activeSection="summary"
235
+ currentJobId={currentJobId}
236
+ />
237
+ <button type="button" onClick={() => setCurrentJobId('job-deploy')}>
238
+ Activate deploy
239
+ </button>
240
+ </>
241
+ );
242
+ }