@shipfox/client-workflows 19.0.0 → 21.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 (139) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +43 -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 +231 -42
  5. package/dist/components/job-detail/job-detail-view.js.map +1 -1
  6. package/dist/components/job-detail/step-attempt-log-panel.d.ts +7 -1
  7. package/dist/components/job-detail/step-attempt-log-panel.d.ts.map +1 -1
  8. package/dist/components/job-detail/step-attempt-log-panel.js +37 -10
  9. package/dist/components/job-detail/step-attempt-log-panel.js.map +1 -1
  10. package/dist/components/step-list/step-list-model.d.ts +1 -0
  11. package/dist/components/step-list/step-list-model.d.ts.map +1 -1
  12. package/dist/components/step-list/step-list-model.js +1 -1
  13. package/dist/components/step-list/step-list-model.js.map +1 -1
  14. package/dist/components/step-list/step-list.d.ts +2 -1
  15. package/dist/components/step-list/step-list.d.ts.map +1 -1
  16. package/dist/components/step-list/step-list.js +24 -7
  17. package/dist/components/step-list/step-list.js.map +1 -1
  18. package/dist/components/workflow-run-duration-label.d.ts +8 -7
  19. package/dist/components/workflow-run-duration-label.d.ts.map +1 -1
  20. package/dist/components/workflow-run-duration-label.js +15 -31
  21. package/dist/components/workflow-run-duration-label.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-states.js +2 -2
  29. package/dist/components/workflow-run-list/workflow-run-list-states.js.map +1 -1
  30. package/dist/components/workflow-run-list/workflow-run-list-view.d.ts.map +1 -1
  31. package/dist/components/workflow-run-list/workflow-run-list-view.js +34 -22
  32. package/dist/components/workflow-run-list/workflow-run-list-view.js.map +1 -1
  33. package/dist/components/workflow-run-list/workflow-run-row.d.ts +3 -3
  34. package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
  35. package/dist/components/workflow-run-list/workflow-run-row.js +22 -16
  36. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  37. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  38. package/dist/components/workflow-run-summary/workflow-run-summary.js +8 -35
  39. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  40. package/dist/components/workflow-run-view/run-workspace-nav.js +1 -1
  41. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -1
  42. package/dist/components/workflow-run-view/workflow-run-states.d.ts.map +1 -1
  43. package/dist/components/workflow-run-view/workflow-run-states.js +1 -1
  44. package/dist/components/workflow-run-view/workflow-run-states.js.map +1 -1
  45. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  46. package/dist/components/workflow-run-view/workflow-run-view.js +43 -36
  47. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  48. package/dist/components/workflow-source-panel/workflow-source-content.js +1 -1
  49. package/dist/components/workflow-source-panel/workflow-source-content.js.map +1 -1
  50. package/dist/components/workflow-status/status-visuals.d.ts.map +1 -1
  51. package/dist/components/workflow-status/status-visuals.js +0 -9
  52. package/dist/components/workflow-status/status-visuals.js.map +1 -1
  53. package/dist/components/workflow-status/workflow-status-icon.d.ts.map +1 -1
  54. package/dist/components/workflow-status/workflow-status-icon.js +0 -11
  55. package/dist/components/workflow-status/workflow-status-icon.js.map +1 -1
  56. package/dist/core/entities/workflow-run.d.ts +10 -55
  57. package/dist/core/entities/workflow-run.d.ts.map +1 -1
  58. package/dist/core/entities/workflow-run.js +1 -94
  59. package/dist/core/entities/workflow-run.js.map +1 -1
  60. package/dist/core/workflow-run.d.ts +2 -2
  61. package/dist/core/workflow-run.d.ts.map +1 -1
  62. package/dist/core/workflow-run.js +1 -1
  63. package/dist/core/workflow-run.js.map +1 -1
  64. package/dist/hooks/api/workflow-run-mapper.d.ts.map +1 -1
  65. package/dist/hooks/api/workflow-run-mapper.js +3 -1
  66. package/dist/hooks/api/workflow-run-mapper.js.map +1 -1
  67. package/dist/hooks/api/workflow-runs.js +1 -0
  68. package/dist/hooks/api/workflow-runs.js.map +1 -1
  69. package/dist/pages/project-workflows-page.d.ts.map +1 -1
  70. package/dist/pages/project-workflows-page.js +3 -2
  71. package/dist/pages/project-workflows-page.js.map +1 -1
  72. package/dist/pages/workflow-job-detail-page.d.ts.map +1 -1
  73. package/dist/pages/workflow-job-detail-page.js +3 -2
  74. package/dist/pages/workflow-job-detail-page.js.map +1 -1
  75. package/dist/pages/workflow-run-detail-page.js +2 -1
  76. package/dist/pages/workflow-run-detail-page.js.map +1 -1
  77. package/dist/pages/workflow-run-list-page.d.ts.map +1 -1
  78. package/dist/pages/workflow-run-list-page.js +9 -11
  79. package/dist/pages/workflow-run-list-page.js.map +1 -1
  80. package/dist/routes/job-detail.d.ts +1 -1
  81. package/dist/routes/job-detail.js +1 -1
  82. package/dist/routes/job-detail.js.map +1 -1
  83. package/dist/routes/run-detail.d.ts +1 -1
  84. package/dist/routes/run-detail.js +1 -1
  85. package/dist/routes/run-detail.js.map +1 -1
  86. package/dist/routes/runs.d.ts +1 -1
  87. package/dist/routes/runs.js +1 -1
  88. package/dist/routes/runs.js.map +1 -1
  89. package/dist/routes/workflows.d.ts +3 -0
  90. package/dist/routes/workflows.js +3 -0
  91. package/dist/routes/workflows.js.map +1 -1
  92. package/dist/tsconfig.test.tsbuildinfo +1 -1
  93. package/package.json +8 -8
  94. package/src/components/job-detail/job-detail-view.tsx +263 -47
  95. package/src/components/job-detail/step-attempt-log-panel.test.tsx +30 -12
  96. package/src/components/job-detail/step-attempt-log-panel.tsx +68 -10
  97. package/src/components/job-graph/job-graph.stories.tsx +1 -1
  98. package/src/components/job-graph/job-node.stories.tsx +1 -1
  99. package/src/components/step-list/step-list-model.ts +1 -1
  100. package/src/components/step-list/step-list.test.tsx +29 -1
  101. package/src/components/step-list/step-list.tsx +28 -9
  102. package/src/components/workflow-run-duration-label.tsx +21 -44
  103. package/src/components/workflow-run-list/job-status-strip.tsx +25 -12
  104. package/src/components/workflow-run-list/workflow-run-filters.tsx +1 -1
  105. package/src/components/workflow-run-list/workflow-run-list-states.tsx +2 -2
  106. package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +101 -4
  107. package/src/components/workflow-run-list/workflow-run-list-view.tsx +28 -22
  108. package/src/components/workflow-run-list/workflow-run-list.stories.tsx +9 -20
  109. package/src/components/workflow-run-list/workflow-run-row.tsx +24 -19
  110. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +2 -40
  111. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +75 -22
  112. package/src/components/workflow-run-summary/workflow-run-summary.tsx +7 -31
  113. package/src/components/workflow-run-view/run-workspace-nav.tsx +1 -1
  114. package/src/components/workflow-run-view/workflow-run-states.tsx +1 -4
  115. package/src/components/workflow-run-view/workflow-run-view.stories.tsx +140 -0
  116. package/src/components/workflow-run-view/workflow-run-view.test.tsx +38 -7
  117. package/src/components/workflow-run-view/workflow-run-view.tsx +45 -37
  118. package/src/components/workflow-source-panel/workflow-source-content.tsx +2 -2
  119. package/src/components/workflow-status/status-visuals.ts +0 -4
  120. package/src/components/workflow-status/workflow-status-icon.stories.tsx +0 -1
  121. package/src/components/workflow-status/workflow-status-icon.tsx +0 -18
  122. package/src/core/entities/workflow-run.ts +9 -136
  123. package/src/core/workflow-run.test.ts +2 -0
  124. package/src/core/workflow-run.ts +0 -8
  125. package/src/hooks/api/workflow-run-mapper.ts +2 -0
  126. package/src/hooks/api/workflow-runs.ts +1 -1
  127. package/src/pages/project-workflows-page.tsx +3 -2
  128. package/src/pages/workflow-job-detail-page.test.tsx +32 -0
  129. package/src/pages/workflow-job-detail-page.tsx +5 -1
  130. package/src/pages/workflow-run-detail-page.tsx +1 -1
  131. package/src/pages/workflow-run-list-page.tsx +9 -11
  132. package/src/pages/workflow-run-pages.test.tsx +26 -0
  133. package/src/routes/job-detail.tsx +1 -1
  134. package/src/routes/run-detail.tsx +1 -1
  135. package/src/routes/runs.tsx +1 -1
  136. package/src/routes/workflows.tsx +1 -0
  137. package/test/fixtures/workflow-run.ts +30 -3
  138. package/tsconfig.build.tsbuildinfo +1 -1
  139. package/src/core/entities/workflow-run-display.test.ts +0 -187
@@ -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
  }
@@ -20,10 +20,9 @@ import {
20
20
  workflowRunActor,
21
21
  workflowRunBranchLabel,
22
22
  workflowRunCommitLabel,
23
- workflowRunListItemDisplay,
24
23
  } from '#core/workflow-run.js';
25
24
  import {withoutWorkflowRunSelectionSearch} from '#core/workflow-run-url-state.js';
26
- import {JobStatusStrip, jobStatusSummary} from './job-status-strip.js';
25
+ import {JOB_STATUS_STRIP_WIDTH, JobStatusStrip, jobStatusSummary} from './job-status-strip.js';
27
26
 
28
27
  export function WorkflowRunRowList({
29
28
  runs,
@@ -35,10 +34,10 @@ export function WorkflowRunRowList({
35
34
  projectSlug?: string | undefined;
36
35
  }) {
37
36
  return (
38
- // A container, not the viewport, drives the row's breakpoints. The list is capped at
39
- // 1120px inside a wider page, so what a row can afford is its own width; keying off the
40
- // viewport would keep the job strip hidden on a wide screen or crush it on a narrow one.
41
- <ul className="@container divide-y divide-border-neutral-base border-t border-border-neutral-base">
37
+ // A container, not the viewport, drives the row's breakpoints. What a row can afford is its
38
+ // own width; keying off the viewport would keep the job strip hidden on a wide screen or
39
+ // crush it on a narrow one.
40
+ <ul className="@container divide-y divide-border-neutral-base">
42
41
  {runs.map((run) => (
43
42
  <li key={run.id}>
44
43
  <WorkflowRunRow run={run} workspaceSlug={workspaceSlug} projectSlug={projectSlug} />
@@ -56,9 +55,9 @@ export function WorkflowRunRowList({
56
55
  * drops beneath. The numeric columns are fixed-width so duration and time form real columns
57
56
  * down the list rather than tracking each row's name length.
58
57
  *
59
- * The thresholds are the container widths the spec's viewport breakpoints produce, given a
60
- * column capped at 1120px with 24px of page padding on each side: a 1280px viewport leaves
61
- * the row 1072px, and a 1024px viewport leaves it 976px.
58
+ * The 976px threshold keeps identity and provenance on one line; below it, provenance drops
59
+ * beneath the identity. The 1200px threshold keeps the full job preview and numeric columns
60
+ * readable together; below it, the strip is hidden so the numeric columns remain aligned.
62
61
  */
63
62
  export function WorkflowRunRow({
64
63
  run,
@@ -69,9 +68,11 @@ export function WorkflowRunRow({
69
68
  workspaceSlug?: string | undefined;
70
69
  projectSlug?: string | undefined;
71
70
  }) {
72
- const display = workflowRunListItemDisplay(run);
73
- const durationLabel = useWorkflowRunDurationAccessibleLabel(display.duration);
74
- const statusLabel = getWorkflowStatusVisual(display.status).label;
71
+ const duration = run.runAttempt.displayDuration;
72
+ const hasStarted = run.jobs.hasStartedJobExecution;
73
+ const durationLabel = useWorkflowRunDurationAccessibleLabel(duration, hasStarted);
74
+ const status = run.runAttempt.status;
75
+ const statusLabel = getWorkflowStatusVisual(status).label;
75
76
  const runNumberLabel = formatWorkflowRunNumberLabel(run);
76
77
  const branch = workflowRunBranchLabel(run);
77
78
  const commit = workflowRunCommitLabel(run);
@@ -79,7 +80,7 @@ export function WorkflowRunRow({
79
80
 
80
81
  const body = (
81
82
  <>
82
- <WorkflowStatusIcon status={display.status} size={14} className="shrink-0" />
83
+ <WorkflowStatusIcon status={status} size={14} className="shrink-0" />
83
84
 
84
85
  <div className="flex min-w-0 flex-1 flex-col gap-tight @min-[976px]:flex-row @min-[976px]:items-center @min-[976px]:gap-cluster">
85
86
  <span className="flex min-w-0 items-center gap-inline @min-[976px]:flex-1">
@@ -98,14 +99,18 @@ export function WorkflowRunRow({
98
99
  </div>
99
100
 
100
101
  <span className="flex shrink-0 items-center gap-cluster">
101
- {/* Fixed at the strip's worst case (see MAX_VISIBLE_JOBS) so the overflow count cannot
102
- paint over the duration, and kept even when a run has no jobs planned yet, so the
103
- numerics stay in line down the list instead of stepping in and out. */}
104
- <span className="hidden w-160 @min-[1040px]:flex">
102
+ {/* Reserve the API preview's full width so the overflow count cannot paint over the
103
+ duration, and keep it even when a run has no jobs planned yet so the numerics stay in
104
+ line down the list instead of stepping in and out. The max-content width expands if
105
+ the payload ever exceeds the current API preview bound. */}
106
+ <span
107
+ className="hidden w-max shrink-0 @min-[1200px]:flex"
108
+ style={{minWidth: JOB_STATUS_STRIP_WIDTH}}
109
+ >
105
110
  <JobStatusStrip jobs={run.jobs} />
106
111
  </span>
107
112
  <span className="flex w-64 justify-end">
108
- <WorkflowRunDurationLabel duration={display.duration} />
113
+ <WorkflowRunDurationLabel duration={duration} hasStarted={hasStarted} />
109
114
  </span>
110
115
  <Code
111
116
  variant="label"
@@ -120,7 +125,7 @@ export function WorkflowRunRow({
120
125
  // Rows run edge to edge inside the list's scroll container, which would clip the standard
121
126
  // outset focus ring, so this one is inset per the design system's focus-ring rule.
122
127
  const rowClassName =
123
- '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';
128
+ '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-[inset_0_0_0_2px_var(--color-primary-500)] focus-visible:outline-none @min-[976px]:h-44 @min-[976px]:py-0';
124
129
 
125
130
  // Optimistic manual runs (temp-<uuid>) have no detail page until the canonical row
126
131
  // replaces them on the next poll, so they render non-interactively instead of as a link
@@ -18,7 +18,6 @@ import {WorkflowRunAttempt, type WorkflowRunStatus} from '#core/workflow-run.js'
18
18
  import {
19
19
  runAttemptsResponseDto,
20
20
  workflowJobDto,
21
- workflowJobExecutionDto,
22
21
  workflowRunAttemptDto,
23
22
  workflowRunDetail,
24
23
  } from '#test/fixtures/workflow-run.js';
@@ -57,10 +56,10 @@ const RUN_ATTEMPTS_RESPONSE = runAttemptsResponseDto({
57
56
  });
58
57
 
59
58
  const withFrame: Decorator = (Story) => (
60
- <div className="min-h-screen bg-background-neutral-background">
59
+ <div className="min-h-screen bg-background-subtle-base">
61
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">
62
61
  <Story />
63
- <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" />
64
63
  </div>
65
64
  </div>
66
65
  );
@@ -203,19 +202,6 @@ export const Durations: Story = {
203
202
  ),
204
203
  };
205
204
 
206
- /**
207
- * A live attempt whose jobs have not started. The header reports the wait rather than a run
208
- * that never began, and names the job the run is blocked on once there is more than one.
209
- */
210
- export const Queued: Story = {
211
- render: () => (
212
- <div className="flex flex-col">
213
- <WorkflowRunSummary run={queuedRun('release-queued', ['build'])} />
214
- <WorkflowRunSummary run={queuedRun('release-queued-fan-out', ['build', 'lint', 'test'])} />
215
- </div>
216
- ),
217
- };
218
-
219
205
  export const Cancellable: Story = {
220
206
  args: {
221
207
  run: workflowRunDetail({status: 'running'}),
@@ -223,30 +209,6 @@ export const Cancellable: Story = {
223
209
  },
224
210
  };
225
211
 
226
- function queuedRun(name: string, jobNames: string[]) {
227
- return workflowRunDetail({
228
- status: 'running',
229
- name,
230
- run_attempt: workflowRunAttemptDto({
231
- status: 'running',
232
- created_at: RUN_STARTED_AT,
233
- started_at: RUN_STARTED_AT,
234
- finished_at: null,
235
- }),
236
- jobs: jobNames.map((jobName, index) =>
237
- workflowJobDto({
238
- name: jobName,
239
- status: 'pending',
240
- job_executions: [
241
- workflowJobExecutionDto({
242
- queued_at: new Date(Date.parse(RUN_STARTED_AT) + index * 1000).toISOString(),
243
- }),
244
- ],
245
- }),
246
- ),
247
- });
248
- }
249
-
250
212
  export const Cancelling: Story = {
251
213
  args: {
252
214
  run: workflowRunDetail({status: 'running'}),
@@ -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);
@@ -159,6 +160,17 @@ describe('WorkflowRunSummary', () => {
159
160
  test('shows a live selected attempt duration for running runs', async () => {
160
161
  vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-05-07T01:02:14.000Z'));
161
162
  renderSummary({
163
+ jobs: [
164
+ workflowJobDto({
165
+ status: 'running',
166
+ job_executions: [
167
+ workflowJobExecutionDto({
168
+ status: 'running',
169
+ started_at: '2026-05-07T01:02:00.000Z',
170
+ }),
171
+ ],
172
+ }),
173
+ ],
162
174
  run_attempt: {
163
175
  id: '11111111-1111-4111-8111-000000000001',
164
176
  workflow_run_id: RUN_ID,
@@ -178,7 +190,9 @@ describe('WorkflowRunSummary', () => {
178
190
  expect(duration).toHaveAttribute('aria-label', 'running 2m 14s');
179
191
  });
180
192
 
181
- test('reads a running run whose jobs have not started as queued', async () => {
193
+ // The header reports the attempt the API returned. Whether a job has reached a runner yet is
194
+ // the job surfaces' business, so an attempt whose jobs are all pending still reads as running.
195
+ test('reads a running attempt as running whatever its jobs have reached', async () => {
182
196
  vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-05-07T01:02:14.000Z'));
183
197
  renderSummary({
184
198
  jobs: [workflowJobDto({status: 'pending'})],
@@ -187,50 +201,89 @@ describe('WorkflowRunSummary', () => {
187
201
 
188
202
  const summary = await screen.findByRole('region', {name: 'deploy-web'});
189
203
 
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');
204
+ expect(within(summary).getAllByText('Running')).not.toHaveLength(0);
205
+ expect(within(summary).queryByText('Queued')).not.toBeInTheDocument();
206
+ expect(within(summary).getByText('2m 14s')).toHaveAttribute('aria-label', 'running 2m 14s');
193
207
  });
194
208
 
195
- test('names the job a queued run is waiting on when there is more than one', async () => {
209
+ test('uses a neutral verb when an attempt finished before any job execution started', async () => {
196
210
  renderSummary({
211
+ status: 'cancelled',
197
212
  jobs: [
198
213
  workflowJobDto({
199
- name: 'lint',
200
214
  status: 'pending',
201
215
  job_executions: [workflowJobExecutionDto({queued_at: '2026-05-07T01:00:00.000Z'})],
202
216
  }),
203
- workflowJobDto({
204
- name: 'build',
205
- status: 'pending',
206
- job_executions: [workflowJobExecutionDto({queued_at: '2026-05-07T00:59:00.000Z'})],
207
- }),
208
217
  ],
209
- run_attempt: runningAttemptDto(),
218
+ run_attempt: {
219
+ id: '11111111-1111-4111-8111-000000000001',
220
+ workflow_run_id: RUN_ID,
221
+ attempt: 1,
222
+ status: 'cancelled',
223
+ created_at: '2026-05-07T01:00:00.000Z',
224
+ started_at: '2026-05-07T01:00:00.000Z',
225
+ finished_at: '2026-05-07T01:02:14.000Z',
226
+ rerun_mode: null,
227
+ },
210
228
  });
211
229
 
212
230
  const summary = await screen.findByRole('region', {name: 'deploy-web'});
213
231
 
214
- expect(within(summary).getByText('waiting on')).toBeInTheDocument();
215
- expect(within(summary).getByText('build')).toBeInTheDocument();
232
+ expect(within(summary).getAllByText('Cancelled')).not.toHaveLength(0);
233
+ expect(within(summary).getByText('2m 14s')).toHaveAttribute('aria-label', 'lasted 2m 14s');
216
234
  });
217
235
 
218
- test('leaves the waiting job unnamed when the run has a single job', async () => {
236
+ test('uses the run verb when a cancelled job execution started', async () => {
219
237
  renderSummary({
238
+ status: 'cancelled',
220
239
  jobs: [
221
240
  workflowJobDto({
222
- name: 'build',
223
- status: 'pending',
224
- job_executions: [workflowJobExecutionDto({queued_at: '2026-05-07T01:00:00.000Z'})],
241
+ status: 'cancelled',
242
+ job_executions: [
243
+ workflowJobExecutionDto({
244
+ status: 'cancelled',
245
+ started_at: '2026-05-07T01:00:05.000Z',
246
+ }),
247
+ ],
225
248
  }),
226
249
  ],
227
- run_attempt: runningAttemptDto(),
250
+ run_attempt: {
251
+ id: '11111111-1111-4111-8111-000000000001',
252
+ workflow_run_id: RUN_ID,
253
+ attempt: 1,
254
+ status: 'cancelled',
255
+ created_at: '2026-05-07T01:00:00.000Z',
256
+ started_at: '2026-05-07T01:00:00.000Z',
257
+ finished_at: '2026-05-07T01:02:14.000Z',
258
+ rerun_mode: null,
259
+ },
260
+ });
261
+
262
+ const summary = await screen.findByRole('region', {name: 'deploy-web'});
263
+
264
+ expect(within(summary).getByText('2m 14s')).toHaveAttribute('aria-label', 'ran 2m 14s');
265
+ });
266
+
267
+ test('uses a neutral verb when every job was skipped', async () => {
268
+ renderSummary({
269
+ status: 'succeeded',
270
+ jobs: [workflowJobDto({status: 'skipped'})],
271
+ run_attempt: {
272
+ id: '11111111-1111-4111-8111-000000000001',
273
+ workflow_run_id: RUN_ID,
274
+ attempt: 1,
275
+ status: 'succeeded',
276
+ created_at: '2026-05-07T01:00:00.000Z',
277
+ started_at: '2026-05-07T01:00:00.000Z',
278
+ finished_at: '2026-05-07T01:02:14.000Z',
279
+ rerun_mode: null,
280
+ },
228
281
  });
229
282
 
230
283
  const summary = await screen.findByRole('region', {name: 'deploy-web'});
231
284
 
232
- expect(within(summary).getAllByText('Queued')).not.toHaveLength(0);
233
- expect(within(summary).queryByText('waiting on')).not.toBeInTheDocument();
285
+ expect(within(summary).getAllByText('Succeeded')).not.toHaveLength(0);
286
+ expect(within(summary).getByText('2m 14s')).toHaveAttribute('aria-label', 'lasted 2m 14s');
234
287
  });
235
288
 
236
289
  test('does not render a whole-run source control', async () => {
@@ -12,7 +12,7 @@ import {useIsTextTruncated} from '@shipfox/react-ui/hooks';
12
12
  import {RelativeTime} from '@shipfox/react-ui/relative-time';
13
13
  import {TimeTickerProvider} from '@shipfox/react-ui/time-ticker';
14
14
  import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
15
- import {Code, Header, Text} from '@shipfox/react-ui/typography';
15
+ import {Header, Text} from '@shipfox/react-ui/typography';
16
16
  import {useId} from 'react';
17
17
  import {WorkflowRunNumberLabel} from '#components/workflow-run-number-label.js';
18
18
  import {
@@ -21,17 +21,13 @@ import {
21
21
  WORKFLOW_RUN_STATUSES,
22
22
  type WorkflowRunDetail,
23
23
  type WorkflowRunRerunMode,
24
- workflowRunBlockingJob,
25
- workflowRunDetailDisplay,
26
24
  } from '#core/workflow-run.js';
27
25
  import {WorkflowRunDurationLabel} from '../workflow-run-duration-label.js';
28
26
  import {getWorkflowStatusVisual} from '../workflow-status/status-visuals.js';
29
27
  import {WorkflowRunAttemptSwitcher} from './workflow-run-attempt-switcher.js';
30
28
 
31
29
  const STATUS_BADGE_LABEL_WIDTH_CH = Math.max(
32
- ...[...WORKFLOW_RUN_STATUSES, 'queued' as const].map(
33
- (status) => getWorkflowStatusVisual(status).label.length,
34
- ),
30
+ ...WORKFLOW_RUN_STATUSES.map((status) => getWorkflowStatusVisual(status).label.length),
35
31
  );
36
32
  const RERUN_BUTTON_SURFACE_CLASS_NAME =
37
33
  'bg-background-neutral-base hover:bg-background-neutral-hover active:bg-background-neutral-pressed';
@@ -60,28 +56,21 @@ export function WorkflowRunSummary({
60
56
  latestAttempt,
61
57
  }: WorkflowRunSummaryProps) {
62
58
  const headingId = useId();
63
- const display = workflowRunDetailDisplay(run);
64
- const status = getWorkflowStatusVisual(display.status);
59
+ const status = getWorkflowStatusVisual(run.runAttempt.status);
65
60
  const action = workflowRunActionForRun(run);
66
61
  const hasAction = canRenderWorkflowRunAction(action, onCancel, onRerun);
67
62
  const attemptSwitcher =
68
63
  latestAttempt && latestAttempt > 1 && workspaceSlug && projectSlug
69
64
  ? {workspaceSlug, projectSlug, latestAttempt}
70
65
  : null;
71
- const displayDuration = display.duration;
72
- // Named only while the run is waiting and has more than one job: with a single job the rail
73
- // and the graph already say which one it is.
74
- const blockingJob =
75
- display.status === 'queued' && run.jobs.length > 1 ? workflowRunBlockingJob(run.jobs) : null;
66
+ const displayDuration = run.runAttempt.displayDuration;
67
+ const hasStarted = run.hasStartedJobExecution;
76
68
  const {ref: headingTextRef, isTruncated: isHeadingTruncated} =
77
69
  useIsTextTruncated<HTMLSpanElement>(run.name);
78
70
 
79
71
  return (
80
72
  <TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
81
- <section
82
- aria-labelledby={headingId}
83
- className="bg-background-neutral-background px-row py-row"
84
- >
73
+ <section aria-labelledby={headingId} className="px-row py-row">
85
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">
86
75
  <div className="col-start-1 row-start-1 min-w-0 max-[480px]:col-start-auto max-[480px]:row-start-auto">
87
76
  <div className="flex min-w-0 items-center gap-inline">
@@ -184,24 +173,11 @@ export function WorkflowRunSummary({
184
173
  <MetadataSeparator />
185
174
  <WorkflowRunDurationLabel
186
175
  duration={displayDuration}
176
+ hasStarted={hasStarted}
187
177
  className="text-foreground-neutral-subtle"
188
178
  />
189
179
  </>
190
180
  ) : null}
191
-
192
- {blockingJob ? (
193
- <>
194
- <MetadataSeparator />
195
- <span className="flex min-w-0 items-center gap-tight">
196
- <Text as="span" size="xs" className="shrink-0">
197
- waiting on
198
- </Text>
199
- <Code as="span" variant="label" className="min-w-0 truncate">
200
- {blockingJob.displayName}
201
- </Code>
202
- </span>
203
- </>
204
- ) : null}
205
181
  </div>
206
182
  </div>
207
183
  </section>
@@ -53,7 +53,7 @@ export function RunWorkspaceNav({
53
53
 
54
54
  return (
55
55
  <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">
56
+ <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 min-[768px]:border-r">
57
57
  <Collapsible
58
58
  open={mobileOpen}
59
59
  onOpenChange={setMobileOpen}
@@ -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" />
@@ -0,0 +1,140 @@
1
+ import type {WorkflowRunDetailResponseDto} from '@shipfox/api-workflows-dto';
2
+ import {configureApiClient} from '@shipfox/client-api';
3
+ import type {Decorator, Meta, StoryObj} from '@storybook/react';
4
+ import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
5
+ import {type ReactNode, useEffect, useState} from 'react';
6
+ import {
7
+ runAttemptsResponseDto,
8
+ workflowJobDto,
9
+ workflowRunDetailDto,
10
+ } from '#test/fixtures/workflow-run.js';
11
+ import {WorkflowRunView} from './workflow-run-view.js';
12
+
13
+ const PROJECT_ID = '22222222-2222-4222-8222-222222222222';
14
+ const RUN_ID = '11111111-1111-4111-8111-111111111111';
15
+ const RUN_STARTED_AT = '2026-06-26T11:57:00.000Z';
16
+ const RUN_FINISHED_AT = '2026-06-26T11:59:00.000Z';
17
+
18
+ const RUN_RESPONSE: WorkflowRunDetailResponseDto = workflowRunDetailDto({
19
+ id: RUN_ID,
20
+ project_id: PROJECT_ID,
21
+ name: 'deploy-web',
22
+ status: 'succeeded',
23
+ started_at: RUN_STARTED_AT,
24
+ finished_at: RUN_FINISHED_AT,
25
+ has_started_job_execution: true,
26
+ jobs: [
27
+ workflowJobDto({
28
+ key: 'build',
29
+ name: 'build',
30
+ status: 'succeeded',
31
+ position: 0,
32
+ }),
33
+ workflowJobDto({
34
+ key: 'deploy',
35
+ name: 'deploy',
36
+ status: 'succeeded',
37
+ position: 1,
38
+ dependencies: ['build'],
39
+ }),
40
+ workflowJobDto({
41
+ key: 'notify',
42
+ name: 'notify',
43
+ status: 'succeeded',
44
+ position: 2,
45
+ dependencies: ['deploy'],
46
+ }),
47
+ ],
48
+ });
49
+ const RUN_ATTEMPTS_RESPONSE = runAttemptsResponseDto({attempts: []});
50
+
51
+ const withRunApi: Decorator = (Story) => (
52
+ <RunWorkspaceStoryProviders>
53
+ <Story />
54
+ </RunWorkspaceStoryProviders>
55
+ );
56
+
57
+ const meta = {
58
+ title: 'Workflows/WorkflowRunView',
59
+ component: WorkflowRunView,
60
+ parameters: {
61
+ layout: 'fullscreen',
62
+ viewport: {
63
+ defaultViewport: 'wide',
64
+ options: {
65
+ wide: {
66
+ name: 'Wide',
67
+ styles: {width: '1440px', height: '720px'},
68
+ type: 'desktop',
69
+ },
70
+ },
71
+ },
72
+ globals: {
73
+ viewport: {value: 'wide'},
74
+ },
75
+ argos: {
76
+ modes: {dark: {theme: 'dark'}},
77
+ fitToContent: false,
78
+ },
79
+ },
80
+ decorators: [withRunApi],
81
+ args: {
82
+ projectId: PROJECT_ID,
83
+ workspaceSlug: 'acme',
84
+ projectSlug: 'platform',
85
+ workflowRunId: RUN_ID,
86
+ },
87
+ } satisfies Meta<typeof WorkflowRunView>;
88
+
89
+ export default meta;
90
+ type Story = StoryObj<typeof meta>;
91
+
92
+ export const WideViewport: Story = {};
93
+
94
+ function RunWorkspaceStoryProviders({children}: {children: ReactNode}) {
95
+ const [queryClient] = useState(
96
+ () => new QueryClient({defaultOptions: {queries: {retry: false}}}),
97
+ );
98
+ const [configured, setConfigured] = useState(false);
99
+
100
+ useEffect(() => {
101
+ configureApiClient({
102
+ baseUrl: 'https://api.example.test',
103
+ fetchImpl: (input) => {
104
+ const url =
105
+ input instanceof Request ? input.url : input instanceof URL ? input.href : String(input);
106
+ const path = new URL(url, 'https://api.example.test').pathname;
107
+ const response =
108
+ path === '/annotations'
109
+ ? {
110
+ body: {annotations: [], has_more: false, next_cursor: null},
111
+ status: 200,
112
+ }
113
+ : path === `/workflows/runs/${RUN_ID}/attempts`
114
+ ? {body: RUN_ATTEMPTS_RESPONSE, status: 200}
115
+ : path === `/workflows/runs/${RUN_ID}`
116
+ ? {body: RUN_RESPONSE, status: 200}
117
+ : {body: {code: 'not-found'}, status: 404};
118
+ return new Response(JSON.stringify(response.body), {
119
+ status: response.status,
120
+ headers: {'content-type': 'application/json'},
121
+ });
122
+ },
123
+ });
124
+ setConfigured(true);
125
+
126
+ return () => {
127
+ configureApiClient({baseUrl: '', fetchImpl: undefined});
128
+ };
129
+ }, []);
130
+
131
+ if (!configured) return null;
132
+
133
+ return (
134
+ <QueryClientProvider client={queryClient}>
135
+ <div className="flex h-[720px] min-w-[1440px] w-[1440px] bg-background-subtle-base">
136
+ {children}
137
+ </div>
138
+ </QueryClientProvider>
139
+ );
140
+ }