@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
@@ -49,23 +49,54 @@ describe('WorkflowRunView', () => {
49
49
  const summary = await screen.findByRole('region', {name: 'deploy-web'});
50
50
  expect(within(summary).getByRole('heading', {name: 'deploy-web'})).toBeInTheDocument();
51
51
  expect(screen.getByRole('navigation', {name: 'Run workspace'})).toBeInTheDocument();
52
- expect(
53
- screen
54
- .getByRole('navigation', {name: 'Run workspace'})
55
- .closest('[data-run-workspace-layout]'),
56
- ).toHaveClass('border-t', 'border-border-neutral-base', 'min-[768px]:flex-row');
52
+ const workspaceLayout = screen
53
+ .getByRole('navigation', {name: 'Run workspace'})
54
+ .closest('[data-run-workspace-layout]');
55
+ expect(workspaceLayout).toHaveClass('border-t', 'border-border-neutral-base');
56
+ expect(workspaceLayout).not.toHaveClass('max-w-[1360px]');
57
+ expect(workspaceLayout?.querySelector('[data-run-workspace-frame]')).toHaveClass(
58
+ 'mx-auto',
59
+ 'w-full',
60
+ 'max-w-[calc(240px_+_1120px)]',
61
+ 'min-[768px]:flex-row',
62
+ );
57
63
  expect(screen.getByRole('link', {name: 'Summary'})).toHaveAttribute('aria-current', 'page');
58
64
  expect(screen.getByRole('heading', {name: 'Jobs'})).toBeInTheDocument();
59
65
  expect(screen.getByRole('heading', {name: 'Run details'})).toBeInTheDocument();
60
66
  expect(screen.getByRole('region', {name: 'All jobs summary'})).toBeInTheDocument();
61
67
  expect(screen.getByRole('region', {name: 'Workflow jobs'})).toBeInTheDocument();
62
- expect(container.querySelector('[data-run-workspace-content]')).toHaveClass(
68
+ expect(container.querySelector('[data-run-workspace-content]')).toHaveClass('flex-1');
69
+ expect(container.querySelector('[data-run-workspace-content]')).not.toHaveClass(
63
70
  'bg-background-neutral-base',
64
- 'flex-1',
65
71
  );
66
72
  expect(screen.queryByRole('tab', {name: 'Jobs'})).not.toBeInTheDocument();
67
73
  });
68
74
 
75
+ test.each([
76
+ {tab: undefined, region: 'All jobs summary'},
77
+ {tab: 'source', region: 'Workflow source'},
78
+ {tab: 'annotations', region: 'Run annotations'},
79
+ ] as const)('does not cap the $region section at 1120px', async ({tab, region}) => {
80
+ configureRunFetch();
81
+
82
+ renderView({tab});
83
+
84
+ const section = await screen.findByRole('region', {name: region});
85
+ expect(section.firstElementChild).not.toHaveClass('max-w-[1120px]');
86
+ });
87
+
88
+ test('keeps dedicated job content on the full-width data surface', async () => {
89
+ configureRunFetch();
90
+
91
+ renderView({jobContent: <div>Job logs</div>});
92
+
93
+ const jobContent = await screen.findByText('Job logs');
94
+ const workspaceFrame = jobContent.closest('[data-run-workspace-frame]');
95
+
96
+ expect(workspaceFrame).toHaveClass('w-full', 'min-[768px]:flex-row');
97
+ expect(workspaceFrame).not.toHaveClass('max-w-[calc(240px_+_1120px)]');
98
+ });
99
+
69
100
  test('treats the removed Jobs tab URL as the graph Summary', async () => {
70
101
  configureRunFetch();
71
102
 
@@ -11,6 +11,7 @@ import {
11
11
  } from '@shipfox/react-ui/select';
12
12
  import {toast} from '@shipfox/react-ui/toast';
13
13
  import {Text} from '@shipfox/react-ui/typography';
14
+ import {cn} from '@shipfox/react-ui/utils';
14
15
  import {useNavigate} from '@tanstack/react-router';
15
16
  import {type ReactNode, useEffect, useMemo} from 'react';
16
17
  import {buildRunAnnotationList, type RunAnnotationSummary} from '#core/run-annotation.js';
@@ -53,6 +54,7 @@ import {
53
54
  } from './workflow-run-states.js';
54
55
 
55
56
  type RunWorkspaceSection = Exclude<WorkflowRunTab, 'jobs'>;
57
+ const RUN_WORKSPACE_FRAME_CLASS_NAME = 'mx-auto w-full max-w-[calc(240px_+_1120px)]';
56
58
 
57
59
  export interface WorkflowRunViewProps {
58
60
  projectId: string;
@@ -157,6 +159,7 @@ function RunViewContent({
157
159
  const activeSection = runWorkspaceSection(tab);
158
160
  const cancelMutation = useCancelWorkflowRunMutation(runData);
159
161
  const sourceSnapshot = runData?.sourceSnapshot ?? null;
162
+ const hasJobContent = Boolean(jobContent);
160
163
  const resolvedSelection =
161
164
  runData && selection ? resolveWorkflowRunSelection({run: runData, selection}) : undefined;
162
165
  const highlightedLineRange = resolvedSelection?.step?.sourceLocation ?? null;
@@ -309,47 +312,52 @@ function RunViewContent({
309
312
 
310
313
  <div
311
314
  data-run-workspace-layout
312
- className="flex min-h-0 min-w-0 flex-1 flex-col border-t border-border-neutral-base min-[768px]:flex-row"
315
+ className="flex min-h-0 min-w-0 flex-1 flex-col border-t border-border-neutral-base"
313
316
  >
314
- {runData && workspaceSlug && projectSlug ? (
315
- <RunWorkspaceNav
316
- workspaceSlug={workspaceSlug}
317
- projectSlug={projectSlug}
318
- run={runData}
319
- activeSection={activeSection}
320
- currentJobId={activeJobId}
321
- jobSearch={jobSearch}
322
- annotationSummary={annotationSummary}
323
- />
324
- ) : (
325
- <RunWorkspaceNavSkeleton />
326
- )}
327
-
328
317
  <div
329
- data-run-workspace-content
330
- className="flex min-h-0 min-w-0 flex-1 flex-col bg-background-neutral-base"
318
+ data-run-workspace-frame
319
+ className={cn(
320
+ 'flex min-h-0 min-w-0 w-full flex-1 flex-col min-[768px]:flex-row',
321
+ !hasJobContent && RUN_WORKSPACE_FRAME_CLASS_NAME,
322
+ )}
331
323
  >
332
- {runData && jobContent ? (
333
- jobContent
334
- ) : runData ? (
335
- <RunSectionContent
336
- section={activeSection}
337
- run={runData}
338
- annotations={annotations}
339
- annotationSummary={annotationSummary}
324
+ {runData && workspaceSlug && projectSlug ? (
325
+ <RunWorkspaceNav
340
326
  workspaceSlug={workspaceSlug}
341
327
  projectSlug={projectSlug}
342
- selection={selection}
343
- selectedJobId={selection?.jobId}
344
- onSelectGraphJob={selectGraphJob}
345
- onSelectAnnotationJob={selectAnnotationJob}
346
- onClearAnnotationFilters={clearAnnotationFilters}
347
- sourceSnapshot={sourceSnapshot}
348
- highlightedLineRange={highlightedLineRange}
328
+ run={runData}
329
+ activeSection={activeSection}
330
+ currentJobId={activeJobId}
331
+ jobSearch={jobSearch}
332
+ annotationSummary={annotationSummary}
349
333
  />
350
334
  ) : (
351
- <div className="min-h-0 flex-1 overflow-auto p-panel">{loadingOrError}</div>
335
+ <RunWorkspaceNavSkeleton />
352
336
  )}
337
+
338
+ <div data-run-workspace-content className="flex min-h-0 min-w-0 flex-1 flex-col">
339
+ {runData && jobContent ? (
340
+ jobContent
341
+ ) : runData ? (
342
+ <RunSectionContent
343
+ section={activeSection}
344
+ run={runData}
345
+ annotations={annotations}
346
+ annotationSummary={annotationSummary}
347
+ workspaceSlug={workspaceSlug}
348
+ projectSlug={projectSlug}
349
+ selection={selection}
350
+ selectedJobId={selection?.jobId}
351
+ onSelectGraphJob={selectGraphJob}
352
+ onSelectAnnotationJob={selectAnnotationJob}
353
+ onClearAnnotationFilters={clearAnnotationFilters}
354
+ sourceSnapshot={sourceSnapshot}
355
+ highlightedLineRange={highlightedLineRange}
356
+ />
357
+ ) : (
358
+ <div className="min-h-0 flex-1 overflow-auto p-panel">{loadingOrError}</div>
359
+ )}
360
+ </div>
353
361
  </div>
354
362
  </div>
355
363
  </div>
@@ -395,7 +403,7 @@ function RunSectionContent({
395
403
  aria-label="All jobs summary"
396
404
  className="min-h-0 flex-1 overflow-auto pb-panel pt-[16px]"
397
405
  >
398
- <div className="mx-auto flex w-full max-w-[1120px] flex-col gap-group px-frame">
406
+ <div className="mx-auto flex w-full flex-col gap-group px-frame">
399
407
  <Text as="h2" className="sr-only">
400
408
  All jobs summary
401
409
  </Text>
@@ -431,7 +439,7 @@ function RunSectionContent({
431
439
  aria-label="Workflow source"
432
440
  className="min-h-0 flex-1 overflow-auto pb-panel pt-[16px]"
433
441
  >
434
- <div className="mx-auto flex min-h-full w-full max-w-[1120px] flex-col px-frame">
442
+ <div className="mx-auto flex min-h-full w-full flex-col px-frame">
435
443
  <Text as="h2" className="sr-only">
436
444
  Workflow source
437
445
  </Text>
@@ -526,7 +534,7 @@ function RunAnnotationsSection({
526
534
  aria-label="Run annotations"
527
535
  className="min-h-0 flex-1 overflow-auto pb-panel pt-[16px]"
528
536
  >
529
- <div className="mx-auto flex w-full max-w-[1120px] flex-col gap-group px-frame">
537
+ <div className="mx-auto flex w-full flex-col gap-group px-frame">
530
538
  <Text as="h2" className="sr-only">
531
539
  Annotations
532
540
  </Text>
@@ -666,7 +674,7 @@ function RunWorkspaceNavSkeleton() {
666
674
  return (
667
675
  <aside
668
676
  aria-label="Loading run navigation"
669
- className="hidden w-240 shrink-0 border-r border-border-neutral-base bg-background-neutral-background p-panel-compact min-[768px]:block"
677
+ className="hidden w-240 shrink-0 border-r border-border-neutral-base p-panel-compact min-[768px]:block"
670
678
  >
671
679
  <div className="flex flex-col gap-group">
672
680
  <div className="h-32 rounded-4 bg-background-components-subtle" />
@@ -63,8 +63,8 @@ export function WorkflowSourceContent({
63
63
  value={item.filename}
64
64
  className={cn(
65
65
  'min-h-full px-0 pb-0',
66
- '[&>div]:rounded-none [&>div]:border-0 [&>div]:bg-background-contrast-base [&>div]:dark:bg-background-contrast-base',
67
- '[&_code]:!text-sm [&_code]:!text-foreground-neutral-on-inverted [&_.line]:!text-sm [&_.line]:before:!text-sm [&_.line]:before:!text-foreground-neutral-muted',
66
+ '[&>div]:rounded-none [&>div]:border-0 [&>div]:bg-background-contrast-base',
67
+ '[&_code]:!text-sm [&_code]:!text-foreground-contrast-primary [&_.line]:!text-sm [&_.line]:before:!text-sm [&_.line]:before:!text-foreground-contrast-secondary',
68
68
  )}
69
69
  >
70
70
  <CodeBlockContent
@@ -18,10 +18,6 @@ export function getWorkflowStatusVisual(status: WorkflowDisplayStatus): Workflow
18
18
  switch (status) {
19
19
  case 'pending':
20
20
  return {kind: 'pending', label: 'Pending', dot: 'neutral', badge: 'neutral'};
21
- // Waiting on a runner is the awaiting-* state the warning tone was held for: the run is
22
- // live but nothing is executing, which is the operator's problem to see, not ours to hide.
23
- case 'queued':
24
- return {kind: 'queued', label: 'Queued', dot: 'warning', badge: 'warning'};
25
21
  case 'running':
26
22
  return {kind: 'running', label: 'Running', dot: 'info', badge: 'info'};
27
23
  case 'listening':
@@ -6,7 +6,6 @@ import {WorkflowStatusIcon} from './workflow-status-icon.js';
6
6
 
7
7
  const statuses: WorkflowDisplayStatus[] = [
8
8
  'pending',
9
- 'queued',
10
9
  'running',
11
10
  'listening',
12
11
  'succeeded',
@@ -89,24 +89,6 @@ export function WorkflowStatusIcon({
89
89
  <Icon name="pulseLine" size={Math.max(8, Math.round(size * 0.7))} className="text-white" />
90
90
  </span>
91
91
  );
92
- } else if (visual.kind === 'queued') {
93
- // An hourglass in the same filled disc the listening state uses, so waiting reads as its
94
- // own shape rather than as a pending ring that happens to be a different colour.
95
- glyph = (
96
- <span
97
- className={cn(
98
- 'inline-flex shrink-0 items-center justify-center rounded-full bg-current',
99
- box,
100
- toneByVariant.warning,
101
- )}
102
- >
103
- <Icon
104
- name="hourglassLine"
105
- size={Math.max(8, Math.round(size * 0.7))}
106
- className="text-white"
107
- />
108
- </span>
109
- );
110
92
  } else if (visual.kind === 'running') {
111
93
  glyph = <Dot variant="info" ripple={ripple} className={box} />;
112
94
  } else if (visual.kind === 'pending') {
@@ -6,21 +6,17 @@ import {
6
6
  type ListenerStatus,
7
7
  WORKFLOW_JOB_STATUSES,
8
8
  } from './job.js';
9
- import {
10
- elapsedTimeFromTimestamps,
11
- type JobExecutionDisplayDuration,
12
- type JobExecutionStatus,
13
- } from './job-execution.js';
9
+ import type {JobExecutionStatus} from './job-execution.js';
14
10
  import type {WorkflowRunAttempt, WorkflowRunAttemptSummary} from './workflow-run-attempt.js';
15
11
 
16
12
  export type WorkflowRunStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled';
17
13
  export type WorkflowRunRerunMode = 'all' | 'failed';
18
14
  export type WorkflowStatus = WorkflowRunStatus | (typeof WORKFLOW_JOB_STATUSES)[number];
19
15
  /**
20
- * `listening` and `queued` are display-only: the API never returns them, and both are derived
21
- * from the jobs a run already carries.
16
+ * `listening` is display-only: the API never returns it, and it is derived from the listener
17
+ * state a job already carries.
22
18
  */
23
- export type WorkflowDisplayStatus = WorkflowStatus | 'listening' | 'queued';
19
+ export type WorkflowDisplayStatus = WorkflowStatus | 'listening';
24
20
 
25
21
  export const WORKFLOW_RUN_STATUSES = [
26
22
  'pending',
@@ -34,7 +30,6 @@ export const WORKFLOW_DISPLAY_STATUSES = [
34
30
  ...WORKFLOW_RUN_STATUSES,
35
31
  ...WORKFLOW_JOB_STATUSES,
36
32
  'listening',
37
- 'queued',
38
33
  ] as const satisfies readonly WorkflowDisplayStatus[];
39
34
 
40
35
  export const TERMINAL_WORKFLOW_RUN_STATUSES = [
@@ -87,11 +82,14 @@ export interface WorkflowRunJobStatusCount {
87
82
  * of them.
88
83
  *
89
84
  * `preview` is capped by the API, so `preview.length` is not the job count and the strip
90
- * reads `total` and `statusCounts` for anything it says rather than anything it draws.
85
+ * reads `total` and `statusCounts` for anything it says rather than anything it draws. The API
86
+ * also reports whether any job execution has started because a terminal `cancelled` job can have
87
+ * started work even though its job status does not preserve that distinction.
91
88
  */
92
89
  export interface WorkflowRunJobs {
93
90
  preview: WorkflowRunJobSummary[];
94
91
  statusCounts: WorkflowRunJobStatusCount[];
92
+ hasStartedJobExecution: boolean;
95
93
  total: number;
96
94
  }
97
95
 
@@ -132,6 +130,7 @@ export interface WorkflowRunDetail extends WorkflowRun {
132
130
  latestAttempt: number;
133
131
  runAttempt: WorkflowRunAttempt;
134
132
  jobs: Job[];
133
+ hasStartedJobExecution: boolean;
135
134
  }
136
135
 
137
136
  export interface WorkflowRunListPage {
@@ -198,129 +197,3 @@ export function isWorkflowRunTerminal(status: WorkflowRunStatus): boolean {
198
197
  export function isWorkflowStatus(status: string): status is WorkflowStatus {
199
198
  return WORKFLOW_STATUSES.has(status as WorkflowStatus);
200
199
  }
201
-
202
- /**
203
- * A run's headline duration, split the way a job's already is. An attempt's `startedAt` marks
204
- * when the orchestrator picked the run up, not when work began, so a run whose jobs are all
205
- * still waiting for a runner reads as queue time rather than as run time.
206
- */
207
- export type WorkflowRunDisplayDuration = JobExecutionDisplayDuration;
208
-
209
- /** What every surface shows for a run, derived together so status and duration cannot disagree. */
210
- export interface WorkflowRunDisplay {
211
- status: WorkflowDisplayStatus;
212
- duration: WorkflowRunDisplayDuration | null;
213
- }
214
-
215
- /**
216
- * The evidence a surface can offer about a run's progress.
217
- *
218
- * `jobStatuses` is what every surface has: the list carries status counts, the detail carries
219
- * whole jobs. `firstStartedAt` is the detail's extra precision, and where it is absent the
220
- * attempt's own mark stands in.
221
- */
222
- export interface WorkflowRunProgress {
223
- runStatus: WorkflowRunStatus;
224
- startedAt: string | null;
225
- finishedAt: string | null;
226
- jobStatuses: JobDisplayStatus[];
227
- firstStartedAt?: string | null | undefined;
228
- }
229
-
230
- /** When the run's earliest execution began, or null while none of them has. */
231
- export function workflowRunFirstStartedAt(jobs: Job[]): string | null {
232
- let earliest: string | null = null;
233
- let earliestMs = Number.POSITIVE_INFINITY;
234
-
235
- for (const job of jobs) {
236
- for (const {startedAt} of job.jobExecutions) {
237
- if (startedAt === null) continue;
238
- const startedMs = new Date(startedAt).getTime();
239
- if (!Number.isFinite(startedMs) || startedMs >= earliestMs) continue;
240
- earliest = startedAt;
241
- earliestMs = startedMs;
242
- }
243
- }
244
-
245
- return earliest;
246
- }
247
-
248
- /**
249
- * Whether any work has begun. A run with no jobs on hand is a run whose jobs were not fetched,
250
- * which is no evidence that nothing started, so it counts as started rather than claiming a
251
- * queue this surface cannot see.
252
- */
253
- function workflowRunHasStarted({jobStatuses, firstStartedAt}: WorkflowRunProgress): boolean {
254
- if (firstStartedAt != null) return true;
255
- if (jobStatuses.length === 0) return true;
256
- return jobStatuses.some((status) => status !== 'pending' && status !== 'skipped');
257
- }
258
-
259
- /**
260
- * The single rule behind every run readout. An attempt's `startedAt` marks when the
261
- * orchestrator picked the run up, not when work began, so a run whose jobs are all still
262
- * waiting reads as `Queued` for queue time. Calling that "running for 2h" sends an operator to
263
- * debug a build that never began.
264
- */
265
- export function deriveWorkflowRunDisplay(progress: WorkflowRunProgress): WorkflowRunDisplay {
266
- const {runStatus, startedAt, finishedAt, firstStartedAt} = progress;
267
- const hasStarted = workflowRunHasStarted(progress);
268
- const status = runStatus === 'running' && !hasStarted ? 'queued' : runStatus;
269
-
270
- if (startedAt === null) return {status, duration: null};
271
-
272
- // A run cancelled before anything started keeps its queue reading rather than reporting a
273
- // run that never was.
274
- const time = elapsedTimeFromTimestamps({from: firstStartedAt ?? startedAt, to: finishedAt});
275
- if (time === null) return {status, duration: null};
276
-
277
- return {status, duration: {kind: hasStarted ? 'run' : 'queue', ...time}};
278
- }
279
-
280
- /** The run detail's reading: whole jobs, so queue and run time split on the first execution. */
281
- export function workflowRunDetailDisplay(run: {
282
- runAttempt: Pick<WorkflowRunAttemptSummary, 'status' | 'startedAt' | 'finishedAt'>;
283
- jobs: Job[];
284
- }): WorkflowRunDisplay {
285
- return deriveWorkflowRunDisplay({
286
- runStatus: run.runAttempt.status,
287
- startedAt: run.runAttempt.startedAt,
288
- finishedAt: run.runAttempt.finishedAt,
289
- jobStatuses: run.jobs.map((job) => job.status),
290
- firstStartedAt: workflowRunFirstStartedAt(run.jobs),
291
- });
292
- }
293
-
294
- /**
295
- * The run list's reading: status counts cover every job, not just the drawn preview, so a row
296
- * reaches the same verdict as the detail page without fetching a single timestamp more.
297
- */
298
- export function workflowRunListItemDisplay(run: WorkflowRunListItem): WorkflowRunDisplay {
299
- return deriveWorkflowRunDisplay({
300
- runStatus: run.status,
301
- startedAt: run.runAttempt.startedAt,
302
- finishedAt: run.runAttempt.finishedAt,
303
- jobStatuses: run.jobs.statusCounts.filter(({count}) => count > 0).map(({status}) => status),
304
- });
305
- }
306
-
307
- /**
308
- * The job the run is waiting on: the one queued longest without starting. Named only so a
309
- * queued run says what it waits for instead of showing a number with no subject.
310
- */
311
- export function workflowRunBlockingJob(jobs: Job[]): Job | null {
312
- let blocking: Job | null = null;
313
- let queuedMs = Number.POSITIVE_INFINITY;
314
-
315
- for (const job of jobs) {
316
- for (const {queuedAt, startedAt, finishedAt} of job.jobExecutions) {
317
- if (queuedAt === null || startedAt !== null || finishedAt !== null) continue;
318
- const candidateMs = new Date(queuedAt).getTime();
319
- if (!Number.isFinite(candidateMs) || candidateMs >= queuedMs) continue;
320
- blocking = job;
321
- queuedMs = candidateMs;
322
- }
323
- }
324
-
325
- return blocking;
326
- }
@@ -103,6 +103,7 @@ describe('workflow run model mapping', () => {
103
103
  latest_attempt: 4,
104
104
  started_at: '2026-05-07T01:01:10.000Z',
105
105
  finished_at: null,
106
+ has_started_job_execution: true,
106
107
  });
107
108
 
108
109
  const run = toWorkflowRunListItem(dto);
@@ -122,6 +123,7 @@ describe('workflow run model mapping', () => {
122
123
  expect(run).not.toHaveProperty('startedAt');
123
124
  expect(run).not.toHaveProperty('finishedAt');
124
125
  expect(run).not.toHaveProperty('displayDuration');
126
+ expect(run.jobs.hasStartedJobExecution).toBe(true);
125
127
  expect(run.runAttempt.displayDuration).toEqual({
126
128
  state: 'live',
127
129
  fromIso: '2026-05-07T01:01:10.000Z',
@@ -59,14 +59,11 @@ export type {
59
59
  WorkflowDisplayStatus,
60
60
  WorkflowRun,
61
61
  WorkflowRunDetail,
62
- WorkflowRunDisplay,
63
- WorkflowRunDisplayDuration,
64
62
  WorkflowRunJobStatusCount,
65
63
  WorkflowRunJobSummary,
66
64
  WorkflowRunJobs,
67
65
  WorkflowRunListItem,
68
66
  WorkflowRunListPage,
69
- WorkflowRunProgress,
70
67
  WorkflowRunRecord,
71
68
  WorkflowRunRerunMode,
72
69
  WorkflowRunStatus,
@@ -75,19 +72,14 @@ export type {
75
72
  WorkflowStatus,
76
73
  } from './entities/workflow-run.js';
77
74
  export {
78
- deriveWorkflowRunDisplay,
79
75
  isWorkflowRunTerminal,
80
76
  isWorkflowStatus,
81
77
  TERMINAL_WORKFLOW_RUN_STATUSES,
82
78
  WORKFLOW_DISPLAY_STATUSES,
83
79
  WORKFLOW_RUN_STATUSES,
84
80
  workflowRunActor,
85
- workflowRunBlockingJob,
86
81
  workflowRunBranchLabel,
87
82
  workflowRunCommitLabel,
88
- workflowRunDetailDisplay,
89
- workflowRunFirstStartedAt,
90
- workflowRunListItemDisplay,
91
83
  workflowRunTriggerDisplayLabel,
92
84
  workflowRunTriggerLabel,
93
85
  } from './entities/workflow-run.js';
@@ -119,6 +119,7 @@ export function toWorkflowRunListItem(dto: WorkflowRunListItemDto): WorkflowRunL
119
119
  position: job.position,
120
120
  })),
121
121
  statusCounts,
122
+ hasStartedJobExecution: dto.has_started_job_execution ?? true,
122
123
  // Derived rather than sent: the counts already cover every job, and a separate total
123
124
  // would be a second source of truth that could disagree with them.
124
125
  total: statusCounts.reduce((sum, entry) => sum + entry.count, 0),
@@ -146,6 +147,7 @@ export function toWorkflowRunDetail(dto: WorkflowRunDetailResponseDto): Workflow
146
147
  latestAttempt: dto.latest_attempt,
147
148
  runAttempt: toWorkflowRunAttempt(dto.run_attempt),
148
149
  jobs: dto.jobs.map(toJob),
150
+ hasStartedJobExecution: dto.has_started_job_execution ?? true,
149
151
  };
150
152
  }
151
153
 
@@ -358,7 +358,7 @@ function buildTempRun({
358
358
  updatedAt: createdAt,
359
359
  isTemporary: true,
360
360
  // The optimistic row genuinely has no jobs yet: the server has not planned the graph.
361
- jobs: {preview: [], statusCounts: [], total: 0},
361
+ jobs: {preview: [], statusCounts: [], hasStartedJobExecution: false, total: 0},
362
362
  runAttempt: new WorkflowRunAttemptSummary({
363
363
  workflowRunId: id,
364
364
  attempt: 1,
@@ -12,6 +12,7 @@ import {Callout} from '@shipfox/react-ui/callout';
12
12
  import {EmptyState} from '@shipfox/react-ui/empty-state';
13
13
  import {Icon, type IconName} from '@shipfox/react-ui/icon';
14
14
  import {LoadErrorState} from '@shipfox/react-ui/load-error-state';
15
+ import {Panel} from '@shipfox/react-ui/panel';
15
16
  import {RelativeTime, RelativeTimeProvider} from '@shipfox/react-ui/relative-time';
16
17
  import {
17
18
  Sheet,
@@ -195,7 +196,7 @@ function WorkflowDefinitionsList({
195
196
 
196
197
  return (
197
198
  <>
198
- <div className="hidden rounded-8 border border-border-neutral-base md:block">
199
+ <Panel className="hidden md:flex">
199
200
  <Table>
200
201
  <TableHeader>
201
202
  <TableRow>
@@ -265,7 +266,7 @@ function WorkflowDefinitionsList({
265
266
  })}
266
267
  </TableBody>
267
268
  </Table>
268
- </div>
269
+ </Panel>
269
270
 
270
271
  <div className="flex flex-col rounded-8 border border-border-neutral-base md:hidden">
271
272
  {definitions.map((definition) => {
@@ -41,6 +41,19 @@ describe('WorkflowJobDetailPage', () => {
41
41
  jobAnnotations.value = [];
42
42
  });
43
43
 
44
+ test('lets the job detail route inherit its shell canvas', async () => {
45
+ configureApiClient({fetchImpl: vi.fn(() => new Promise<Response>(() => undefined))});
46
+
47
+ const {container} = renderJobPath();
48
+
49
+ expect(await screen.findByRole('region', {name: 'Loading workflow run'})).toBeInTheDocument();
50
+
51
+ const pageRoot = container.querySelector('[data-workflow-page-root="job-detail"]');
52
+
53
+ expect(pageRoot).not.toBeNull();
54
+ expect(pageRoot).not.toHaveClass('bg-background-subtle-base');
55
+ });
56
+
44
57
  test('links the job to its annotations without rendering one', async () => {
45
58
  jobAnnotations.value = [
46
59
  {
@@ -88,6 +101,7 @@ describe('WorkflowJobDetailPage', () => {
88
101
  });
89
102
 
90
103
  test('resolves an exact job execution and step attempt from a deep link', async () => {
104
+ const user = userEvent.setup();
91
105
  configureApiClient({fetchImpl: vi.fn(jobDetailFetch)});
92
106
 
93
107
  renderJobPath(
@@ -104,6 +118,24 @@ describe('WorkflowJobDetailPage', () => {
104
118
  await screen.findByRole('region', {name: 'tests output, attempt 2'}),
105
119
  ).toBeInTheDocument();
106
120
  expect(screen.getByRole('region', {name: 'release logs'})).toBeInTheDocument();
121
+ expect(screen.getByRole('textbox', {name: 'Search logs'})).toBeInTheDocument();
122
+ expect(screen.getByRole('button', {name: 'Refresh logs'})).toBeInTheDocument();
123
+ expect(screen.getByRole('button', {name: 'Log settings'})).toBeInTheDocument();
124
+ const testsAttempt = screen.getByRole('button', {name: 'tests, Succeeded, attempt 2'});
125
+ await user.click(testsAttempt);
126
+ await waitFor(() =>
127
+ expect(
128
+ screen.queryByRole('region', {name: 'tests output, attempt 2'}),
129
+ ).not.toBeInTheDocument(),
130
+ );
131
+ expect(screen.getByRole('textbox', {name: 'Search logs'})).toBeDisabled();
132
+ expect(screen.getByRole('button', {name: 'Refresh logs'})).toBeDisabled();
133
+
134
+ await user.click(testsAttempt);
135
+ expect(
136
+ await screen.findByRole('region', {name: 'tests output, attempt 2'}),
137
+ ).toBeInTheDocument();
138
+ expect(screen.getByRole('textbox', {name: 'Search logs'})).not.toBeDisabled();
107
139
  expect(screen.queryByRole('tab')).not.toBeInTheDocument();
108
140
  expect(screen.getByRole('img', {name: 'Job status: Succeeded'})).toBeInTheDocument();
109
141
  expect(screen.getByRole('heading', {name: 'deploy-web'})).toBeInTheDocument();
@@ -56,7 +56,10 @@ export function WorkflowJobDetailPage({
56
56
  [navigate],
57
57
  );
58
58
  return (
59
- <div className="flex min-h-0 flex-1 overflow-hidden bg-background-neutral-background">
59
+ <div
60
+ data-workflow-page-root="job-detail"
61
+ className="flex min-h-0 min-w-0 flex-1 overflow-hidden"
62
+ >
60
63
  <WorkflowRunView
61
64
  projectId={projectId}
62
65
  workspaceSlug={workspaceSlug}
@@ -67,6 +70,7 @@ export function WorkflowJobDetailPage({
67
70
  jobSearch={search}
68
71
  jobContent={
69
72
  <JobDetailView
73
+ key={jobId}
70
74
  workspaceSlug={workspaceSlug}
71
75
  projectSlug={projectSlug}
72
76
  workflowRunId={workflowRunId}
@@ -17,7 +17,7 @@ export function WorkflowRunDetailPage({
17
17
  search = {},
18
18
  }: WorkflowRunDetailPageProps) {
19
19
  return (
20
- <div className="flex min-h-0 flex-1 overflow-hidden bg-background-neutral-background">
20
+ <div data-workflow-page-root="run-detail" className="flex min-h-0 flex-1 overflow-hidden">
21
21
  <WorkflowRunView
22
22
  projectId={projectId}
23
23
  workspaceSlug={workspaceSlug}