@shipfox/client-workflows 17.0.0 → 19.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 (59) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-type.log +0 -1
  3. package/CHANGELOG.md +23 -0
  4. package/dist/components/job-detail/job-context-panel.d.ts.map +1 -1
  5. package/dist/components/job-detail/job-context-panel.js +5 -1
  6. package/dist/components/job-detail/job-context-panel.js.map +1 -1
  7. package/dist/components/job-detail/job-detail-view.d.ts.map +1 -1
  8. package/dist/components/job-detail/job-detail-view.js +34 -27
  9. package/dist/components/job-detail/job-detail-view.js.map +1 -1
  10. package/dist/components/job-detail/job-empty-states.d.ts +4 -0
  11. package/dist/components/job-detail/job-empty-states.d.ts.map +1 -1
  12. package/dist/components/job-detail/job-empty-states.js +43 -3
  13. package/dist/components/job-detail/job-empty-states.js.map +1 -1
  14. package/dist/components/job-detail/step-troubleshooting.js +4 -0
  15. package/dist/components/job-detail/step-troubleshooting.js.map +1 -1
  16. package/dist/components/step-list/step-list-model.d.ts.map +1 -1
  17. package/dist/components/step-list/step-list-model.js +1 -0
  18. package/dist/components/step-list/step-list-model.js.map +1 -1
  19. package/dist/components/workflow-run-list/job-status-strip.d.ts.map +1 -1
  20. package/dist/components/workflow-run-list/job-status-strip.js +20 -7
  21. package/dist/components/workflow-run-list/job-status-strip.js.map +1 -1
  22. package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -1
  23. package/dist/components/workflow-run-view/run-workspace-nav.js +6 -5
  24. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -1
  25. package/dist/core/entities/job-execution.d.ts +2 -0
  26. package/dist/core/entities/job-execution.d.ts.map +1 -1
  27. package/dist/core/entities/job-execution.js.map +1 -1
  28. package/dist/core/entities/job.d.ts +6 -3
  29. package/dist/core/entities/job.d.ts.map +1 -1
  30. package/dist/core/entities/job.js +8 -1
  31. package/dist/core/entities/job.js.map +1 -1
  32. package/dist/core/entities/workflow-run.d.ts +7 -4
  33. package/dist/core/entities/workflow-run.d.ts.map +1 -1
  34. package/dist/core/entities/workflow-run.js.map +1 -1
  35. package/dist/hooks/api/workflow-run-mapper.d.ts.map +1 -1
  36. package/dist/hooks/api/workflow-run-mapper.js +12 -1
  37. package/dist/hooks/api/workflow-run-mapper.js.map +1 -1
  38. package/dist/tsconfig.test.tsbuildinfo +1 -1
  39. package/package.json +2 -2
  40. package/src/components/job-detail/job-context-panel.tsx +4 -0
  41. package/src/components/job-detail/job-detail-view.tsx +37 -33
  42. package/src/components/job-detail/job-empty-states.test.ts +105 -1
  43. package/src/components/job-detail/job-empty-states.tsx +56 -2
  44. package/src/components/job-detail/step-troubleshooting.tsx +4 -0
  45. package/src/components/job-graph/job-node.test.tsx +2 -2
  46. package/src/components/step-list/step-list-model.ts +1 -0
  47. package/src/components/workflow-run-list/job-status-strip.tsx +25 -12
  48. package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +73 -0
  49. package/src/components/workflow-run-list/workflow-run-list.stories.tsx +31 -0
  50. package/src/components/workflow-run-view/run-workspace-nav.tsx +13 -6
  51. package/src/core/entities/job-execution.ts +2 -0
  52. package/src/core/entities/job-status.test.ts +47 -1
  53. package/src/core/entities/job.ts +18 -4
  54. package/src/core/entities/workflow-run-display.test.ts +28 -0
  55. package/src/core/entities/workflow-run.ts +18 -4
  56. package/src/core/workflow-run.test.ts +13 -0
  57. package/src/hooks/api/workflow-run-mapper.ts +19 -1
  58. package/test/fixtures/workflow-run.ts +32 -12
  59. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-workflows",
3
3
  "license": "MIT",
4
- "version": "17.0.0",
4
+ "version": "19.0.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -29,7 +29,7 @@
29
29
  "date-fns": "^4.1.0",
30
30
  "@shipfox/annotations-dto": "12.3.0",
31
31
  "@shipfox/api-definitions-dto": "12.3.0",
32
- "@shipfox/api-workflows-dto": "12.3.0",
32
+ "@shipfox/api-workflows-dto": "12.6.0",
33
33
  "@shipfox/client-api": "6.0.1",
34
34
  "@shipfox/client-logs": "17.0.0",
35
35
  "@shipfox/client-projects": "17.0.0",
@@ -27,6 +27,7 @@ export function JobContextPanel({job, execution}: {job: Job; execution: JobExecu
27
27
  execution.triggerEvents.length ||
28
28
  job.success ||
29
29
  statusReason ||
30
+ execution.statusReasonMessage ||
30
31
  trace?.length ||
31
32
  hasTiming,
32
33
  );
@@ -114,6 +115,9 @@ function JobContextSheet({
114
115
  {statusReason ? (
115
116
  <ContextValue label="Status reason" value={humanize(statusReason)} />
116
117
  ) : null}
118
+ {execution.statusReasonMessage ? (
119
+ <ContextValue label="Failure details" value={execution.statusReasonMessage} />
120
+ ) : null}
117
121
  {execution.triggerEvents.length ? (
118
122
  <JsonCode
119
123
  title={`Trigger events (${execution.triggerEvents.length})`}
@@ -33,6 +33,7 @@ import {
33
33
  emptyStateForJob,
34
34
  emptyStateForMissingExecution,
35
35
  jobSucceededSummary,
36
+ MaterializedOutputFailureNotice,
36
37
  } from './job-empty-states.js';
37
38
  import {
38
39
  resolveWorkflowJobSelection,
@@ -258,39 +259,42 @@ export function JobDetailView({
258
259
  Logs
259
260
  </Text>
260
261
  {selectedJobExecution ? (
261
- <StepList
262
- job={job}
263
- jobExecution={selectedJobExecution}
264
- selectedAttemptId={selectedAttemptId}
265
- defaultSelectedAttemptId={landingSelection?.attemptId}
266
- onSelectedAttemptChange={selectAttempt}
267
- inspectorOpenAttemptId={inspectorOpenAttemptId}
268
- onInspectorOpenChange={onInspectorOpenChange}
269
- autoSelectActiveAttempt
270
- emptyState={emptyStateForJob(job, selectedJobExecution)}
271
- showHeader={false}
272
- className="rounded-none border-0 bg-transparent"
273
- renderExpandedStep={(context) => (
274
- <ExpandedStep context={context} pageScrollRef={pageScrollRef} />
275
- )}
276
- renderInspector={(entry) => (
277
- <StepInspectorSheet
278
- entry={entry}
279
- open
280
- onOpenChange={(open) => onInspectorOpenChange(open ? entry.id : null)}
281
- workspaceSlug={workspaceSlug}
282
- projectSlug={projectSlug}
283
- workflowRunId={run.id}
284
- runAttempt={run.runAttempt.attempt}
285
- jobId={job.id}
286
- annotationCount={annotationCountForStep(
287
- annotationSummaryQuery.data,
288
- entry.step.id,
289
- entry.attempt,
290
- )}
291
- />
292
- )}
293
- />
262
+ <>
263
+ <MaterializedOutputFailureNotice jobExecution={selectedJobExecution} />
264
+ <StepList
265
+ job={job}
266
+ jobExecution={selectedJobExecution}
267
+ selectedAttemptId={selectedAttemptId}
268
+ defaultSelectedAttemptId={landingSelection?.attemptId}
269
+ onSelectedAttemptChange={selectAttempt}
270
+ inspectorOpenAttemptId={inspectorOpenAttemptId}
271
+ onInspectorOpenChange={onInspectorOpenChange}
272
+ autoSelectActiveAttempt
273
+ emptyState={emptyStateForJob(job, selectedJobExecution)}
274
+ showHeader={false}
275
+ className="rounded-none border-0 bg-transparent"
276
+ renderExpandedStep={(context) => (
277
+ <ExpandedStep context={context} pageScrollRef={pageScrollRef} />
278
+ )}
279
+ renderInspector={(entry) => (
280
+ <StepInspectorSheet
281
+ entry={entry}
282
+ open
283
+ onOpenChange={(open) => onInspectorOpenChange(open ? entry.id : null)}
284
+ workspaceSlug={workspaceSlug}
285
+ projectSlug={projectSlug}
286
+ workflowRunId={run.id}
287
+ runAttempt={run.runAttempt.attempt}
288
+ jobId={job.id}
289
+ annotationCount={annotationCountForStep(
290
+ annotationSummaryQuery.data,
291
+ entry.step.id,
292
+ entry.attempt,
293
+ )}
294
+ />
295
+ )}
296
+ />
297
+ </>
294
298
  ) : (
295
299
  <EmptyStateForMissingExecution job={job} />
296
300
  )}
@@ -3,7 +3,13 @@ import {
3
3
  workflowJobExecutionDto,
4
4
  workflowStepDto,
5
5
  } from '#test/fixtures/workflow-run.js';
6
- import {jobSucceededSummary} from './job-empty-states.js';
6
+ import {
7
+ emptyStateForJob,
8
+ emptyStateForMissingExecution,
9
+ jobSucceededSummary,
10
+ outputFailureDescriptionForExecution,
11
+ skippedJobDescription,
12
+ } from './job-empty-states.js';
7
13
 
8
14
  describe('jobSucceededSummary', () => {
9
15
  test('counts only succeeded steps when skipped steps are present', () => {
@@ -25,3 +31,101 @@ describe('jobSucceededSummary', () => {
25
31
  expect(jobSucceededSummary(job, execution)).toBe('1 step succeeded');
26
32
  });
27
33
  });
34
+
35
+ describe('skippedJobDescription', () => {
36
+ test('explains when materialized output exceeds the configured size limit', () => {
37
+ expect(skippedJobDescription('output_too_large')).toBe(
38
+ 'The materialized job output exceeded its configured size limit.',
39
+ );
40
+ });
41
+ });
42
+
43
+ describe('materialized output failure descriptions', () => {
44
+ const fallback =
45
+ 'A materialized job output could not be persisted: it exceeded a size or entry cap, contained a non-JSON-safe value, or referenced an unresolved value. Check the output mapping and values before re-running the workflow.';
46
+
47
+ test('uses the server-authored message for an execution with recorded steps', () => {
48
+ const job = workflowJob({
49
+ status: 'failed',
50
+ status_reason: 'output_invalid',
51
+ job_executions: [
52
+ workflowJobExecutionDto({
53
+ status: 'failed',
54
+ status_reason: 'output_invalid',
55
+ status_reason_message: 'Job output "payload" cannot be persisted as JSON: undefined.',
56
+ steps: [workflowStepDto({status: 'succeeded'})],
57
+ }),
58
+ ],
59
+ });
60
+ const execution = job.jobExecutions[0];
61
+ if (!execution) throw new Error('Expected a job execution');
62
+
63
+ expect(outputFailureDescriptionForExecution(execution)).toBe(
64
+ 'Job output "payload" cannot be persisted as JSON: undefined.',
65
+ );
66
+ });
67
+
68
+ test('keeps the generic fallback for old output-invalid rows without a message', () => {
69
+ const job = workflowJob({
70
+ status: 'failed',
71
+ status_reason: 'output_invalid',
72
+ job_executions: [
73
+ workflowJobExecutionDto({
74
+ status: 'failed',
75
+ status_reason: 'output_invalid',
76
+ steps: [workflowStepDto({status: 'succeeded'})],
77
+ }),
78
+ ],
79
+ });
80
+ const execution = job.jobExecutions[0];
81
+ if (!execution) throw new Error('Expected a job execution');
82
+
83
+ expect(outputFailureDescriptionForExecution(execution)).toBe(fallback);
84
+ });
85
+
86
+ test('uses the message in the empty state when output materialization failed before steps', () => {
87
+ const job = workflowJob({
88
+ status: 'failed',
89
+ status_reason: 'output_invalid',
90
+ job_executions: [
91
+ workflowJobExecutionDto({
92
+ status: 'failed',
93
+ status_reason: 'output_invalid',
94
+ status_reason_message: 'Job outputs cannot define more than 10 entries (found 11)',
95
+ steps: [],
96
+ }),
97
+ ],
98
+ });
99
+ const execution = job.jobExecutions[0];
100
+ if (!execution) throw new Error('Expected a job execution');
101
+
102
+ expect(emptyStateForJob(job, execution)).toMatchObject({
103
+ description: 'Job outputs cannot define more than 10 entries (found 11)',
104
+ });
105
+ });
106
+
107
+ test('keeps output failures scoped to the selected execution', () => {
108
+ const job = workflowJob({
109
+ status: 'failed',
110
+ status_reason: 'output_invalid',
111
+ job_executions: [
112
+ workflowJobExecutionDto({
113
+ status: 'succeeded',
114
+ status_reason: null,
115
+ steps: [workflowStepDto({status: 'succeeded'})],
116
+ }),
117
+ ],
118
+ });
119
+ const execution = job.jobExecutions[0];
120
+ if (!execution) throw new Error('Expected a job execution');
121
+
122
+ expect(outputFailureDescriptionForExecution(execution)).toBeUndefined();
123
+ expect(
124
+ emptyStateForMissingExecution(
125
+ workflowJob({status: 'failed', status_reason: 'output_invalid'}),
126
+ ),
127
+ ).toMatchObject({
128
+ description: fallback,
129
+ });
130
+ });
131
+ });
@@ -1,3 +1,4 @@
1
+ import {Callout, CalloutContent, CalloutDescription, CalloutTitle} from '@shipfox/react-ui/callout';
1
2
  import {EmptyState} from '@shipfox/react-ui/empty-state';
2
3
  import type {Job, JobExecution, Step, StepError} from '#core/workflow-run.js';
3
4
  import {
@@ -9,6 +10,44 @@ import {
9
10
  import type {StepListEmptyState} from '../step-list/index.js';
10
11
  import {formatJobExecutionTime} from './job-execution-time-text.js';
11
12
 
13
+ const MATERIALIZED_OUTPUT_FAILURE_DESCRIPTION =
14
+ 'A materialized job output could not be persisted: it exceeded a size or entry cap, contained a non-JSON-safe value, or referenced an unresolved value. Check the output mapping and values before re-running the workflow.';
15
+ const OUTPUT_TOO_LARGE_FAILURE_DESCRIPTION =
16
+ 'The materialized job output exceeded its configured size limit. Review the failure details before re-running the workflow.';
17
+
18
+ export function outputFailureDescriptionForExecution(
19
+ jobExecution: JobExecution,
20
+ ): string | undefined {
21
+ if (jobExecution.steps.length === 0) return undefined;
22
+
23
+ if (jobExecution.statusReason === 'output_invalid') {
24
+ return jobExecution.statusReasonMessage || MATERIALIZED_OUTPUT_FAILURE_DESCRIPTION;
25
+ }
26
+ if (jobExecution.statusReason === 'output_too_large') {
27
+ return jobExecution.statusReasonMessage || OUTPUT_TOO_LARGE_FAILURE_DESCRIPTION;
28
+ }
29
+ return undefined;
30
+ }
31
+
32
+ export function MaterializedOutputFailureNotice({jobExecution}: {jobExecution: JobExecution}) {
33
+ const description = outputFailureDescriptionForExecution(jobExecution);
34
+ if (!description) return null;
35
+
36
+ return (
37
+ <Callout
38
+ role="alert"
39
+ type="error"
40
+ variant="secondary"
41
+ className="border-b border-border-neutral-base px-row py-row"
42
+ >
43
+ <CalloutContent>
44
+ <CalloutTitle>Job output could not be persisted</CalloutTitle>
45
+ <CalloutDescription>{description}</CalloutDescription>
46
+ </CalloutContent>
47
+ </Callout>
48
+ );
49
+ }
50
+
12
51
  export function emptyStateForJob(
13
52
  job: Job,
14
53
  jobExecution: JobExecution,
@@ -64,7 +103,11 @@ export function emptyStateForJob(
64
103
  if (displayStatus === 'failed') {
65
104
  return {
66
105
  title: 'Job failed before its first step started',
67
- description: preStepFailureDescription(jobExecution.statusReason ?? job.statusReason, runner),
106
+ description: preStepFailureDescription(
107
+ jobExecution.statusReason ?? job.statusReason,
108
+ runner,
109
+ jobExecution.statusReasonMessage,
110
+ ),
68
111
  status: displayStatus,
69
112
  };
70
113
  }
@@ -159,14 +202,21 @@ export function skippedJobDescription(reason: Job['statusReason']): string {
159
202
  case 'run_cancelled':
160
203
  case 'timed_out':
161
204
  case 'runner_lost':
205
+ case 'output_invalid':
162
206
  case 'step_failed':
163
207
  case 'unknown':
164
208
  case null:
165
209
  return 'This job did not start.';
210
+ case 'output_too_large':
211
+ return 'The materialized job output exceeded its configured size limit.';
166
212
  }
167
213
  }
168
214
 
169
- function preStepFailureDescription(reason: string | null, runner: string[] | null = null): string {
215
+ function preStepFailureDescription(
216
+ reason: string | null,
217
+ runner: string[] | null = null,
218
+ statusReasonMessage: string | null = null,
219
+ ): string {
170
220
  const runnerCopy = runner?.length ? ` Required runner labels: ${runner.join(', ')}.` : '';
171
221
 
172
222
  switch (reason) {
@@ -179,6 +229,10 @@ function preStepFailureDescription(reason: string | null, runner: string[] | nul
179
229
  return 'The execution ended while the run was being cancelled.';
180
230
  case 'step_failed':
181
231
  return `The execution failed before step details were recorded.${runnerCopy} Review run annotations before re-running the workflow.`;
232
+ case 'output_too_large':
233
+ return statusReasonMessage || OUTPUT_TOO_LARGE_FAILURE_DESCRIPTION;
234
+ case 'output_invalid':
235
+ return statusReasonMessage || MATERIALIZED_OUTPUT_FAILURE_DESCRIPTION;
182
236
  case 'condition_errored':
183
237
  return 'The job condition could not be evaluated. Review run annotations before re-running the workflow.';
184
238
  case 'dependency_not_completed':
@@ -442,6 +442,8 @@ function failureTitle(reason: string | JobStatusReason): string {
442
442
  return 'Agent harness was unavailable';
443
443
  case 'runner_lost':
444
444
  return 'Runner stopped responding';
445
+ case 'output_too_large':
446
+ return 'Job output exceeded its size limit';
445
447
  case 'timed_out':
446
448
  return 'Step timed out';
447
449
  case 'step_failed':
@@ -489,6 +491,8 @@ function failureDescription(reason: string | JobStatusReason): string {
489
491
  return 'The runner could not start the agent harness.';
490
492
  case 'runner_lost':
491
493
  return 'The runner stopped responding before the step completed.';
494
+ case 'output_too_large':
495
+ return 'The materialized job output exceeded its configured size limit.';
492
496
  case 'timed_out':
493
497
  return 'The step exceeded its configured time limit.';
494
498
  case 'dependency_not_completed':
@@ -332,7 +332,7 @@ describe('JobNode status indicator', () => {
332
332
  expect(screen.getByRole('button', {name: 'deploy-prod, Succeeded'})).toBeInTheDocument();
333
333
  });
334
334
 
335
- test('shows a running lifecycle without a running step as pending', () => {
335
+ test('shows a running lifecycle without a running step as running', () => {
336
336
  const node = makeNode({
337
337
  name: 'deploy',
338
338
  status: 'pending',
@@ -341,7 +341,7 @@ describe('JobNode status indicator', () => {
341
341
 
342
342
  renderNode(node);
343
343
 
344
- expect(screen.getByRole('button', {name: 'deploy, Pending'})).toBeInTheDocument();
344
+ expect(screen.getByRole('button', {name: 'deploy, Running'})).toBeInTheDocument();
345
345
  });
346
346
 
347
347
  test('shows a pending lifecycle as running while a step is active', () => {
@@ -77,6 +77,7 @@ export function emptyJobExecutionForJob(job: Job): JobExecution {
77
77
  name: job.name ?? job.key,
78
78
  status: job.status === 'skipped' ? 'cancelled' : job.status,
79
79
  statusReason: job.statusReason,
80
+ statusReasonMessage: null,
80
81
  runner: job.runner,
81
82
  outputs: job.outputs,
82
83
  triggerEvents: [],
@@ -3,7 +3,8 @@ import {Code, Text} from '@shipfox/react-ui/typography';
3
3
  import {cn} from '@shipfox/react-ui/utils';
4
4
  import {getWorkflowStatusVisual} from '#components/workflow-status/status-visuals.js';
5
5
  import {WorkflowStatusIcon} from '#components/workflow-status/workflow-status-icon.js';
6
- import type {JobStatus, WorkflowRunJobSummary, WorkflowRunJobs} from '#core/workflow-run.js';
6
+ import {deriveJobDisplayStatus, type JobDisplayStatus} from '#core/entities/job.js';
7
+ import type {WorkflowRunJobSummary, WorkflowRunJobs} from '#core/workflow-run.js';
7
8
 
8
9
  /**
9
10
  * How many glyphs fit the strip's column before it starts costing the run name width.
@@ -33,16 +34,17 @@ const COMPACT_COUNT_FORMAT = new Intl.NumberFormat('en', {
33
34
 
34
35
  // Worst-first, so the overflow glyph reports the most alarming thing it is standing in for.
35
36
  // A strip that hides a failure behind eight green discs would be worse than no strip at all.
36
- const STATUS_SEVERITY: Record<JobStatus, number> = {
37
+ const STATUS_SEVERITY: Record<JobDisplayStatus, number> = {
37
38
  failed: 5,
38
39
  running: 4,
40
+ listening: 4,
39
41
  pending: 3,
40
42
  cancelled: 2,
41
43
  skipped: 1,
42
44
  succeeded: 0,
43
45
  };
44
46
 
45
- const NOTABLE_STATUSES: readonly JobStatus[] = ['failed', 'running'];
47
+ const NOTABLE_STATUSES: readonly JobDisplayStatus[] = ['failed', 'running', 'listening'];
46
48
 
47
49
  export interface JobStatusStripProps {
48
50
  jobs: WorkflowRunJobs;
@@ -80,7 +82,7 @@ export function JobStatusStrip({jobs, className}: JobStatusStripProps) {
80
82
  {visible.map((job) => (
81
83
  <WorkflowStatusIcon
82
84
  key={job.id}
83
- status={job.status}
85
+ status={displayStatus(job)}
84
86
  size={GLYPH_SIZE}
85
87
  // One ripple per running job, on every row, would turn a calm list into a
86
88
  // field of pulses. The run's own glyph already carries the live edge.
@@ -121,7 +123,7 @@ function JobStatusStripTooltip({jobs, summary}: {jobs: WorkflowRunJobs; summary:
121
123
  // can be named, but the count of those left over is read off the totals, so a failure past
122
124
  // the preview is still accounted for rather than silently missing.
123
125
  const named = jobs.preview
124
- .filter((job) => NOTABLE_STATUSES.includes(job.status))
126
+ .filter((job) => NOTABLE_STATUSES.includes(displayStatus(job)))
125
127
  .slice(0, MAX_TOOLTIP_JOB_NAMES);
126
128
  const notableTotal = NOTABLE_STATUSES.reduce((total, status) => total + countOf(jobs, status), 0);
127
129
  const remaining = notableTotal - named.length;
@@ -133,7 +135,7 @@ function JobStatusStripTooltip({jobs, summary}: {jobs: WorkflowRunJobs; summary:
133
135
  </Text>
134
136
  {named.map((job) => (
135
137
  <Code as="span" variant="label" key={job.id} className="block truncate">
136
- {getWorkflowStatusVisual(job.status).label.toLowerCase()} · {job.name ?? job.key}
138
+ {getWorkflowStatusVisual(displayStatus(job)).label.toLowerCase()} · {job.name ?? job.key}
137
139
  </Code>
138
140
  ))}
139
141
  {remaining > 0 ? (
@@ -162,15 +164,16 @@ export function jobStatusSummary(jobs: WorkflowRunJobs): string {
162
164
  function worstHiddenStatus(
163
165
  jobs: WorkflowRunJobs,
164
166
  visible: readonly WorkflowRunJobSummary[],
165
- ): JobStatus | null {
167
+ ): JobDisplayStatus | null {
166
168
  const hidden = new Map(jobs.statusCounts.map(({status, count}) => [status, count]));
167
169
  for (const job of visible) {
168
- const remaining = (hidden.get(job.status) ?? 0) - 1;
169
- if (remaining > 0) hidden.set(job.status, remaining);
170
- else hidden.delete(job.status);
170
+ const status = displayStatus(job);
171
+ const remaining = (hidden.get(status) ?? 0) - 1;
172
+ if (remaining > 0) hidden.set(status, remaining);
173
+ else hidden.delete(status);
171
174
  }
172
175
 
173
- let worst: JobStatus | null = null;
176
+ let worst: JobDisplayStatus | null = null;
174
177
  for (const [status, count] of hidden) {
175
178
  if (count <= 0) continue;
176
179
  if (worst === null || STATUS_SEVERITY[status] > STATUS_SEVERITY[worst]) worst = status;
@@ -184,6 +187,16 @@ export function overflowCountLabel(hiddenCount: number): string {
184
187
  : COMPACT_COUNT_FORMAT.format(hiddenCount);
185
188
  }
186
189
 
187
- function countOf(jobs: WorkflowRunJobs, status: JobStatus): number {
190
+ function countOf(jobs: WorkflowRunJobs, status: JobDisplayStatus): number {
188
191
  return jobs.statusCounts.find((entry) => entry.status === status)?.count ?? 0;
189
192
  }
193
+
194
+ function displayStatus(job: WorkflowRunJobSummary): JobDisplayStatus {
195
+ return deriveJobDisplayStatus({
196
+ mode: job.mode,
197
+ status: job.status,
198
+ listenerStatus: job.listenerStatus,
199
+ executionStatus: job.executionStatus,
200
+ jobExecutions: [],
201
+ });
202
+ }
@@ -307,6 +307,53 @@ describe('WorkflowRunListView', () => {
307
307
  ).toBeInTheDocument();
308
308
  });
309
309
 
310
+ test('shows an executing one-shot job instead of its pending verdict', async () => {
311
+ const {jobs} = workflowRunJobsFixture(['pending']);
312
+ renderListView([
313
+ run('running', 'deploy-web', 'run-1', {
314
+ jobs: jobs.map((job) => ({...job, execution_status: 'running'})),
315
+ job_status_counts: [{status: 'pending', count: 1}],
316
+ job_display_status_counts: [{status: 'running', count: 1}],
317
+ }),
318
+ ]);
319
+
320
+ const strip = await screen.findByRole('img', {name: '1 job: 1 running'});
321
+ expect(within(strip).getByLabelText('Running')).toBeInTheDocument();
322
+ });
323
+
324
+ test('shows an active listener as listening in the job strip', async () => {
325
+ const {jobs} = workflowRunJobsFixture(['running']);
326
+ renderListView([
327
+ run('running', 'event-driven', 'run-1', {
328
+ jobs: jobs.map((job) => ({
329
+ ...job,
330
+ mode: 'listening',
331
+ listener_status: 'listening',
332
+ execution_status: null,
333
+ })),
334
+ job_status_counts: [{status: 'running', count: 1}],
335
+ job_display_status_counts: [{status: 'listening', count: 1}],
336
+ }),
337
+ ]);
338
+
339
+ const strip = await screen.findByRole('img', {name: '1 job: 1 listening'});
340
+ expect(within(strip).getByLabelText('Listening')).toBeInTheDocument();
341
+ });
342
+
343
+ test('shows a failed execution when the job verdict is still pending', async () => {
344
+ const {jobs} = workflowRunJobsFixture(['pending']);
345
+ renderListView([
346
+ run('running', 'deploy-web', 'run-1', {
347
+ jobs: jobs.map((job) => ({...job, execution_status: 'failed'})),
348
+ job_status_counts: [{status: 'pending', count: 1}],
349
+ job_display_status_counts: [{status: 'failed', count: 1}],
350
+ }),
351
+ ]);
352
+
353
+ const strip = await screen.findByRole('img', {name: '1 job: 1 failed'});
354
+ expect(within(strip).getByLabelText('Failed')).toBeInTheDocument();
355
+ });
356
+
310
357
  // The link's aria-label replaces its contents, so the strip's own label is not spoken
311
358
  // when the row is announced as a link. Where a run failed has to survive that.
312
359
  test('carries the job breakdown into the row link name', async () => {
@@ -355,6 +402,32 @@ describe('WorkflowRunListView', () => {
355
402
  expect(within(strip).getByLabelText('Failed')).toBeInTheDocument();
356
403
  });
357
404
 
405
+ test('reports listening jobs in the overflow glyph and tooltip', async () => {
406
+ const {jobs} = workflowRunJobsFixture(
407
+ Array.from({length: 16}, () => 'pending') as JobStatusDto[],
408
+ );
409
+ renderListView([
410
+ run('running', 'event-driven', 'run-listener', {
411
+ jobs: jobs.map((job) => ({
412
+ ...job,
413
+ mode: 'listening',
414
+ listener_status: 'listening',
415
+ execution_status: null,
416
+ })),
417
+ job_status_counts: [{status: 'pending', count: 20}],
418
+ job_display_status_counts: [{status: 'listening', count: 20}],
419
+ }),
420
+ ]);
421
+
422
+ 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);
425
+
426
+ const user = userEvent.setup();
427
+ await user.hover(strip);
428
+ expect(await screen.findByRole('tooltip')).toHaveTextContent('and 14 more');
429
+ });
430
+
358
431
  // Nothing caps a workflow's job count, so an exact overflow count is unbounded in width
359
432
  // and would eventually paint over the duration column beside it.
360
433
  test('abbreviates an overflow count too wide to print exactly', async () => {
@@ -101,6 +101,12 @@ type Story = StoryObj<typeof meta>;
101
101
 
102
102
  export const Playground: Story = {};
103
103
 
104
+ export const ExecutionStates: Story = {
105
+ args: {
106
+ runs: [makeExecutionStateRun(), makeListeningStateRun()],
107
+ },
108
+ };
109
+
104
110
  /**
105
111
  * The row under its one-line threshold, at the 720px a 768px viewport leaves the column.
106
112
  *
@@ -229,3 +235,28 @@ function StateExample({label, children}: {label: string; children: ReactNode}) {
229
235
  </div>
230
236
  );
231
237
  }
238
+
239
+ function makeExecutionStateRun(): WorkflowRunListItem {
240
+ const fixture = workflowRunJobsFixture(['pending']);
241
+ return sequencedWorkflowRunListItem('running', 'one-shot-executing', 1, {
242
+ ...fixture,
243
+ jobs: fixture.jobs.map((job) => ({...job, execution_status: 'running'})),
244
+ job_status_counts: [{status: 'pending', count: 1}],
245
+ job_display_status_counts: [{status: 'running', count: 1}],
246
+ });
247
+ }
248
+
249
+ function makeListeningStateRun(): WorkflowRunListItem {
250
+ const fixture = workflowRunJobsFixture(['pending']);
251
+ return sequencedWorkflowRunListItem('running', 'event-driven-listener', 3, {
252
+ ...fixture,
253
+ jobs: fixture.jobs.map((job) => ({
254
+ ...job,
255
+ mode: 'listening',
256
+ listener_status: 'listening',
257
+ execution_status: null,
258
+ })),
259
+ job_status_counts: [{status: 'pending', count: 1}],
260
+ job_display_status_counts: [{status: 'listening', count: 1}],
261
+ });
262
+ }
@@ -53,8 +53,12 @@ export function RunWorkspaceNav({
53
53
 
54
54
  return (
55
55
  <TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
56
- <aside className="w-full shrink-0 border-b border-border-neutral-base bg-background-neutral-background min-[768px]:w-240 min-[768px]:border-b-0 min-[768px]:border-r">
57
- <Collapsible open={mobileOpen} onOpenChange={setMobileOpen}>
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">
57
+ <Collapsible
58
+ open={mobileOpen}
59
+ onOpenChange={setMobileOpen}
60
+ className="flex min-h-0 flex-col min-[768px]:flex-1"
61
+ >
58
62
  <CollapsibleTrigger asChild>
59
63
  <button
60
64
  type="button"
@@ -82,7 +86,7 @@ export function RunWorkspaceNav({
82
86
  </CollapsibleTrigger>
83
87
  <div
84
88
  className={cn(
85
- 'max-h-[50vh] overflow-y-auto p-tight min-[768px]:block min-[768px]:max-h-none min-[768px]:p-[12px]',
89
+ 'flex min-h-0 max-h-[50vh] flex-col overflow-y-auto p-tight scrollbar min-[768px]:min-h-0 min-[768px]:max-h-none min-[768px]:flex-1 min-[768px]:flex min-[768px]:overflow-hidden min-[768px]:p-[12px]',
86
90
  !mobileOpen && 'hidden',
87
91
  )}
88
92
  >
@@ -136,7 +140,7 @@ function RunWorkspaceNavContent({
136
140
  }, [currentJobId, mobileOpen]);
137
141
 
138
142
  return (
139
- <nav aria-label="Run workspace" className="flex min-w-0 flex-col gap-group">
143
+ <nav aria-label="Run workspace" className="flex min-h-0 min-w-0 flex-1 flex-col gap-group">
140
144
  <ul>
141
145
  <li>
142
146
  <RunSectionLink
@@ -151,7 +155,10 @@ function RunWorkspaceNavContent({
151
155
  </li>
152
156
  </ul>
153
157
 
154
- <section aria-labelledby="run-workspace-jobs-heading" className="min-w-0">
158
+ <section
159
+ aria-labelledby="run-workspace-jobs-heading"
160
+ className="flex min-h-0 min-w-0 flex-1 flex-col"
161
+ >
155
162
  <div className="flex items-center justify-between gap-inline px-tight pb-[6px]">
156
163
  <Text
157
164
  as="h2"
@@ -166,7 +173,7 @@ function RunWorkspaceNavContent({
166
173
  {jobs.length}
167
174
  </Text>
168
175
  </div>
169
- <ol className="max-h-[320px] overflow-y-auto">
176
+ <ol className="min-h-0 flex-1 overflow-y-auto scrollbar">
170
177
  {jobs.map((job) => {
171
178
  const current = job.id === currentJobId;
172
179
  const execution = defaultJobExecution(job);