@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
@@ -11,7 +11,7 @@ const meta = {
11
11
  parameters: {layout: 'centered'},
12
12
  decorators: [
13
13
  (Story) => (
14
- <div className="h-520 w-900 overflow-auto bg-background-neutral-background p-16">
14
+ <div className="h-520 w-900 overflow-auto bg-background-subtle-base p-16">
15
15
  <Story />
16
16
  </div>
17
17
  ),
@@ -20,7 +20,7 @@ const meta = {
20
20
  parameters: {layout: 'centered'},
21
21
  decorators: [
22
22
  (Story) => (
23
- <div className="bg-background-neutral-background p-16">
23
+ <div className="bg-background-subtle-base p-16">
24
24
  <Story />
25
25
  </div>
26
26
  ),
@@ -132,7 +132,7 @@ function toStepModel(step: Step, index: number): StepModel {
132
132
  };
133
133
  }
134
134
 
135
- function stepLabel(step: Step, index: number): string {
135
+ export function stepLabel(step: Step, index: number): string {
136
136
  const name = step.name.trim();
137
137
  if (name) return name;
138
138
 
@@ -145,6 +145,34 @@ describe('StepList', () => {
145
145
  expect(screen.getByText(`logs for ${deployAttempt.id}`)).toBeInTheDocument();
146
146
  });
147
147
 
148
+ test('reports the expanded attempt ids as rows change', async () => {
149
+ const user = userEvent.setup();
150
+ const onExpandedAttemptIdsChange = vi.fn();
151
+ const buildAttempt = makeAttempt({status: 'succeeded'});
152
+ const deployAttempt = makeAttempt({status: 'running'});
153
+ const build = makeStep({name: 'build', attempts: [buildAttempt]});
154
+ const deploy = makeStep({name: 'deploy', position: 1, attempts: [deployAttempt]});
155
+ render(
156
+ <StepList
157
+ job={makeJob({steps: [build, deploy]})}
158
+ onExpandedAttemptIdsChange={onExpandedAttemptIdsChange}
159
+ renderExpandedStep={({attemptId}) => <Text size="sm">logs for {attemptId}</Text>}
160
+ />,
161
+ );
162
+
163
+ await user.click(screen.getByRole('button', {name: 'build, Succeeded, attempt 1'}));
164
+ expect(onExpandedAttemptIdsChange).toHaveBeenLastCalledWith([buildAttempt.id]);
165
+
166
+ await user.click(screen.getByRole('button', {name: 'deploy, Running, attempt 1'}));
167
+ expect(onExpandedAttemptIdsChange).toHaveBeenLastCalledWith([
168
+ buildAttempt.id,
169
+ deployAttempt.id,
170
+ ]);
171
+
172
+ await user.click(screen.getByRole('button', {name: 'build, Succeeded, attempt 1'}));
173
+ expect(onExpandedAttemptIdsChange).toHaveBeenLastCalledWith([deployAttempt.id]);
174
+ });
175
+
148
176
  test('keeps multiple expanded rows open when external attempt selection is singular', async () => {
149
177
  const user = userEvent.setup();
150
178
  const buildAttempt = makeAttempt({status: 'succeeded'});
@@ -210,7 +238,7 @@ describe('StepList', () => {
210
238
  await user.click(deployRow);
211
239
 
212
240
  expect(buildRow.parentElement?.parentElement).not.toHaveClass('bg-background-components-hover');
213
- expect(deployRow.parentElement?.parentElement).toHaveClass('bg-background-components-hover');
241
+ expect(deployRow.parentElement?.parentElement).toHaveClass('bg-background-neutral-hover');
214
242
  });
215
243
 
216
244
  test('opens a default selected attempt', () => {
@@ -58,6 +58,7 @@ export interface StepListProps {
58
58
  selectedAttemptId?: string | null | undefined;
59
59
  defaultSelectedAttemptId?: string | undefined;
60
60
  onSelectedAttemptChange?: ((attemptId: string | undefined) => void) | undefined;
61
+ onExpandedAttemptIdsChange?: ((attemptIds: readonly string[]) => void) | undefined;
61
62
  inspectorOpenAttemptId?: string | null | undefined;
62
63
  onInspectorOpenChange?: ((attemptId: string | null) => void) | undefined;
63
64
  autoSelectActiveAttempt?: boolean | undefined;
@@ -74,6 +75,7 @@ export function StepList({
74
75
  selectedAttemptId,
75
76
  defaultSelectedAttemptId,
76
77
  onSelectedAttemptChange,
78
+ onExpandedAttemptIdsChange,
77
79
  inspectorOpenAttemptId = null,
78
80
  onInspectorOpenChange,
79
81
  autoSelectActiveAttempt = false,
@@ -96,6 +98,7 @@ export function StepList({
96
98
  selectedAttemptId={selectedAttemptId}
97
99
  defaultSelectedAttemptId={defaultSelectedAttemptId}
98
100
  onSelectedAttemptChange={onSelectedAttemptChange}
101
+ onExpandedAttemptIdsChange={onExpandedAttemptIdsChange}
99
102
  inspectorOpenAttemptId={inspectorOpenAttemptId}
100
103
  onInspectorOpenChange={onInspectorOpenChange}
101
104
  autoSelectActiveAttempt={autoSelectActiveAttempt}
@@ -113,6 +116,7 @@ function StepListContent({
113
116
  selectedAttemptId,
114
117
  defaultSelectedAttemptId,
115
118
  onSelectedAttemptChange,
119
+ onExpandedAttemptIdsChange,
116
120
  inspectorOpenAttemptId = null,
117
121
  onInspectorOpenChange,
118
122
  autoSelectActiveAttempt,
@@ -134,20 +138,35 @@ function StepListContent({
134
138
  const lastNotifiedSelectedAttemptId = useRef<string | null>(null);
135
139
  const autoSelectedAttemptIdRef = useRef<string | undefined>(undefined);
136
140
  const previousSelectedAttemptIdRef = useRef<string | null | undefined>(selectedAttemptId);
141
+ const lastReportedExpandedAttemptIdsKeyRef = useRef<string | null>(null);
137
142
  const shouldUseControlledCollapsedState =
138
143
  selectedAttemptId === null && lastNotifiedSelectedAttemptId.current === null;
139
144
  const autoSelectedAttemptId =
140
145
  selectedAttemptId === undefined && autoSelectActiveAttempt && !userSelectedAttempt
141
146
  ? (autoSelectedAttemptIdRef.current ?? model.activeEntryId)
142
147
  : undefined;
143
- const autoSelectedAttemptIds = autoSelectedAttemptId ? [autoSelectedAttemptId] : [];
144
- const selectedAttemptIds = shouldUseControlledCollapsedState
145
- ? []
146
- : localSelectedAttemptIds.length > 0
147
- ? localSelectedAttemptIds
148
- : autoSelectedAttemptIds;
148
+ const autoSelectedAttemptIds = useMemo(
149
+ () => (autoSelectedAttemptId ? [autoSelectedAttemptId] : []),
150
+ [autoSelectedAttemptId],
151
+ );
152
+ const selectedAttemptIds = useMemo(
153
+ () =>
154
+ shouldUseControlledCollapsedState
155
+ ? []
156
+ : localSelectedAttemptIds.length > 0
157
+ ? localSelectedAttemptIds
158
+ : autoSelectedAttemptIds,
159
+ [autoSelectedAttemptIds, localSelectedAttemptIds, shouldUseControlledCollapsedState],
160
+ );
149
161
  const hasExpandedContent = renderExpandedStep !== undefined;
150
162
 
163
+ useEffect(() => {
164
+ const key = selectedAttemptIds.join('|');
165
+ if (lastReportedExpandedAttemptIdsKeyRef.current === key) return;
166
+ lastReportedExpandedAttemptIdsKeyRef.current = key;
167
+ onExpandedAttemptIdsChange?.(selectedAttemptIds);
168
+ }, [onExpandedAttemptIdsChange, selectedAttemptIds]);
169
+
151
170
  useEffect(() => {
152
171
  if (selectedAttemptId !== undefined) {
153
172
  previousSelectedAttemptIdRef.current = selectedAttemptId;
@@ -223,7 +242,7 @@ function StepListContent({
223
242
  <section
224
243
  aria-labelledby={showHeader ? titleId : undefined}
225
244
  className={cn(
226
- 'flex min-h-0 flex-col rounded-8 border border-border-neutral-base bg-background-components-base',
245
+ 'flex min-h-0 flex-col rounded-8 border border-border-neutral-base bg-background-neutral-base',
227
246
  className,
228
247
  )}
229
248
  >
@@ -425,8 +444,8 @@ function StepRow({
425
444
  <>
426
445
  <div
427
446
  className={cn(
428
- 'group flex min-w-0 items-center gap-inline pr-[8px] transition-colors hover:bg-background-components-hover active:bg-background-components-pressed',
429
- selected && 'bg-background-components-hover',
447
+ 'group flex min-w-0 items-center gap-inline bg-background-neutral-base pr-[8px] transition-colors hover:bg-background-neutral-hover active:bg-background-neutral-pressed',
448
+ selected && 'bg-background-neutral-hover',
430
449
  !hasExpandedContent && ['border-b border-border-neutral-base', isLast && 'border-b-0'],
431
450
  )}
432
451
  >
@@ -2,50 +2,37 @@ import {Icon} from '@shipfox/react-ui/icon';
2
2
  import {useTimeTick} from '@shipfox/react-ui/time-ticker';
3
3
  import {Code} from '@shipfox/react-ui/typography';
4
4
  import {cn, humanDuration} from '@shipfox/react-ui/utils';
5
- import type {
6
- WorkflowRunAttemptDisplayDuration,
7
- WorkflowRunDisplayDuration,
8
- } from '#core/workflow-run.js';
5
+ import type {WorkflowRunAttemptDisplayDuration} from '#core/workflow-run.js';
9
6
 
10
7
  /**
11
- * The run list reads an attempt's own marks and cannot tell queue from run, so an unkinded
12
- * duration still renders as run time. Only surfaces holding the run's jobs pass a kind.
8
+ * A run reports one span: how long its attempt has been under way. Queueing is a job
9
+ * execution's own affair, and the job surfaces report it there.
13
10
  */
14
- export type WorkflowRunDurationDisplay =
15
- | WorkflowRunAttemptDisplayDuration
16
- | WorkflowRunDisplayDuration;
17
-
18
- type DurationKind = 'queue' | 'run';
19
-
20
- const GLYPH_BY_KIND = {queue: 'hourglassLine', run: 'timerLine'} as const;
21
- const LIVE_VERB_BY_KIND = {queue: 'queued', run: 'running'} as const;
22
- const FIXED_VERB_BY_KIND = {queue: 'queued', run: 'ran'} as const;
11
+ export type WorkflowRunDurationDisplay = WorkflowRunAttemptDisplayDuration;
23
12
 
24
13
  export function WorkflowRunDurationLabel({
25
14
  duration,
15
+ hasStarted,
26
16
  className,
27
17
  }: {
28
18
  duration: WorkflowRunDurationDisplay | null;
19
+ hasStarted: boolean;
29
20
  className?: string | undefined;
30
21
  }) {
31
22
  if (duration === null) return null;
32
- const kind = durationKind(duration);
33
23
 
34
24
  switch (duration.state) {
35
25
  case 'fixed': {
36
26
  const display = formatFixedDurationLabel(duration.elapsed);
27
+ const verb = hasStarted ? 'ran' : 'lasted';
37
28
  return (
38
- <DurationText
39
- className={className}
40
- kind={kind}
41
- ariaLabel={`${FIXED_VERB_BY_KIND[kind]} ${display}`}
42
- >
29
+ <DurationText className={className} ariaLabel={`${verb} ${display}`}>
43
30
  {display}
44
31
  </DurationText>
45
32
  );
46
33
  }
47
34
  case 'live':
48
- return <LiveDurationText duration={duration} kind={kind} className={className} />;
35
+ return <LiveDurationText duration={duration} className={className} />;
49
36
  default: {
50
37
  const exhaustive: never = duration;
51
38
  return exhaustive;
@@ -55,22 +42,25 @@ export function WorkflowRunDurationLabel({
55
42
 
56
43
  export function useWorkflowRunDurationAccessibleLabel(
57
44
  duration: WorkflowRunDurationDisplay | null,
45
+ hasStarted: boolean,
58
46
  ): string | undefined {
59
47
  useTimeTick();
60
- return workflowRunDurationAccessibleLabel(duration);
48
+ return workflowRunDurationAccessibleLabel(duration, hasStarted);
61
49
  }
62
50
 
63
51
  export function workflowRunDurationAccessibleLabel(
64
52
  duration: WorkflowRunDurationDisplay | null,
53
+ hasStarted: boolean,
65
54
  ): string | undefined {
66
55
  if (duration === null) return undefined;
67
- const kind = durationKind(duration);
68
56
 
69
57
  switch (duration.state) {
70
58
  case 'live':
71
- return `${LIVE_VERB_BY_KIND[kind]} ${humanDuration(duration.fromIso)}`;
72
- case 'fixed':
73
- return `${FIXED_VERB_BY_KIND[kind]} ${formatFixedDurationLabel(duration.elapsed)}`;
59
+ return `running ${humanDuration(duration.fromIso)}`;
60
+ case 'fixed': {
61
+ const verb = hasStarted ? 'ran' : 'lasted';
62
+ return `${verb} ${formatFixedDurationLabel(duration.elapsed)}`;
63
+ }
74
64
  default: {
75
65
  const exhaustive: never = duration;
76
66
  return exhaustive;
@@ -78,27 +68,17 @@ export function workflowRunDurationAccessibleLabel(
78
68
  }
79
69
  }
80
70
 
81
- function durationKind(duration: WorkflowRunDurationDisplay): DurationKind {
82
- return 'kind' in duration ? duration.kind : 'run';
83
- }
84
-
85
71
  function LiveDurationText({
86
72
  duration,
87
- kind,
88
73
  className,
89
74
  }: {
90
75
  duration: Extract<WorkflowRunDurationDisplay, {state: 'live'}>;
91
- kind: DurationKind;
92
76
  className?: string | undefined;
93
77
  }) {
94
78
  useTimeTick();
95
79
  const display = humanDuration(duration.fromIso);
96
80
  return (
97
- <DurationText
98
- className={className}
99
- kind={kind}
100
- ariaLabel={`${LIVE_VERB_BY_KIND[kind]} ${display}`}
101
- >
81
+ <DurationText className={className} ariaLabel={`running ${display}`}>
102
82
  {display}
103
83
  </DurationText>
104
84
  );
@@ -107,12 +87,10 @@ function LiveDurationText({
107
87
  function DurationText({
108
88
  children,
109
89
  className,
110
- kind,
111
90
  ariaLabel,
112
91
  }: {
113
92
  children: string;
114
93
  className?: string | undefined;
115
- kind: DurationKind;
116
94
  ariaLabel?: string | undefined;
117
95
  }) {
118
96
  return (
@@ -125,10 +103,9 @@ function DurationText({
125
103
  className,
126
104
  )}
127
105
  >
128
- {/* The glyph is the only visible mark of queue vs run: the status badge already writes
129
- "Queued", and the numeric columns are fixed width. The verb rides the accessible
130
- name instead. */}
131
- <Icon name={GLYPH_BY_KIND[kind]} className="size-12 shrink-0" aria-hidden="true" />
106
+ {/* The verb rides the accessible name: the numeric columns are fixed width and the
107
+ status badge already says whether the run is still going. */}
108
+ <Icon name="timerLine" className="size-12 shrink-0" aria-hidden="true" />
132
109
  {children}
133
110
  </Code>
134
111
  );
@@ -1,21 +1,17 @@
1
+ import {WORKFLOW_RUN_JOB_PREVIEW_LIMIT} from '@shipfox/api-workflows-dto';
1
2
  import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
2
3
  import {Code, Text} from '@shipfox/react-ui/typography';
3
4
  import {cn} from '@shipfox/react-ui/utils';
5
+ import {memo} from 'react';
4
6
  import {getWorkflowStatusVisual} from '#components/workflow-status/status-visuals.js';
5
7
  import {WorkflowStatusIcon} from '#components/workflow-status/workflow-status-icon.js';
6
8
  import {deriveJobDisplayStatus, type JobDisplayStatus} from '#core/entities/job.js';
7
9
  import type {WorkflowRunJobSummary, WorkflowRunJobs} from '#core/workflow-run.js';
8
10
 
9
- /**
10
- * How many glyphs fit the strip's column before it starts costing the run name width.
11
- *
12
- * Sized so the widest case still fits the row's 160px strip column: seven 12px glyphs with
13
- * 3px gaps is 102px, and the overflow indicator adds a glyph plus a count of at most five
14
- * monospace characters, about 56px. Overshooting does not wrap, it paints over the duration
15
- * column. This sits under the API's preview bound, so resizing the strip is a change here.
16
- */
17
- const MAX_VISIBLE_JOBS = 7;
18
11
  const GLYPH_SIZE = 12;
12
+ const GLYPH_GAP = 3;
13
+ const OVERFLOW_GAP = 2;
14
+ const OVERFLOW_LABEL_WIDTH = 40;
19
15
  const MAX_TOOLTIP_JOB_NAMES = 6;
20
16
 
21
17
  /**
@@ -32,6 +28,20 @@ const COMPACT_COUNT_FORMAT = new Intl.NumberFormat('en', {
32
28
  maximumFractionDigits: 1,
33
29
  });
34
30
 
31
+ /**
32
+ * Reserves one full API preview and its overflow marker in a run row.
33
+ *
34
+ * Keep this derived from the API preview bound so the row reservation grows with the payload
35
+ * contract instead of silently drifting into the duration and timestamp columns.
36
+ */
37
+ export function jobStatusStripWidthForPreview(previewLimit: number): number {
38
+ const previewWidth = previewLimit * GLYPH_SIZE + Math.max(previewLimit - 1, 0) * GLYPH_GAP;
39
+ const overflowWidth = GLYPH_GAP + GLYPH_SIZE + OVERFLOW_GAP + OVERFLOW_LABEL_WIDTH;
40
+ return previewWidth + overflowWidth;
41
+ }
42
+
43
+ export const JOB_STATUS_STRIP_WIDTH = jobStatusStripWidthForPreview(WORKFLOW_RUN_JOB_PREVIEW_LIMIT);
44
+
35
45
  // Worst-first, so the overflow glyph reports the most alarming thing it is standing in for.
36
46
  // A strip that hides a failure behind eight green discs would be worse than no strip at all.
37
47
  const STATUS_SEVERITY: Record<JobDisplayStatus, number> = {
@@ -62,10 +72,13 @@ export interface JobStatusStripProps {
62
72
  * job including those the server never sent. That split is what lets the overflow glyph
63
73
  * report a failure sitting past the preview instead of quietly dropping it.
64
74
  */
65
- export function JobStatusStrip({jobs, className}: JobStatusStripProps) {
75
+ export const JobStatusStrip = memo(function JobStatusStrip({jobs, className}: JobStatusStripProps) {
66
76
  if (jobs.total === 0) return null;
67
77
 
68
- const visible = jobs.preview.slice(0, MAX_VISIBLE_JOBS);
78
+ // The data frame gives the row enough room for the whole API preview. Only jobs beyond that
79
+ // server-side preview need an overflow marker; the old seven-glyph cap belonged to the
80
+ // previous 1120px page width.
81
+ const visible = jobs.preview;
69
82
  const hiddenCount = jobs.total - visible.length;
70
83
  const overflowStatus = worstHiddenStatus(jobs, visible);
71
84
  const summary = jobStatusSummary(jobs);
@@ -115,7 +128,7 @@ export function JobStatusStrip({jobs, className}: JobStatusStripProps) {
115
128
  </TooltipContent>
116
129
  </Tooltip>
117
130
  );
118
- }
131
+ });
119
132
 
120
133
  function JobStatusStripTooltip({jobs, summary}: {jobs: WorkflowRunJobs; summary: string}) {
121
134
  // Naming the jobs that need attention is the whole point of hovering; succeeded jobs are
@@ -53,7 +53,7 @@ export function WorkflowRunFilters({
53
53
  const [sheetOpen, setSheetOpen] = useState(false);
54
54
 
55
55
  return (
56
- <div className="flex flex-wrap items-center gap-inline">
56
+ <div className="flex w-full flex-wrap items-center gap-inline">
57
57
  <Input
58
58
  value={search.search ?? ''}
59
59
  onChange={(event) => onChange({search: event.target.value})}
@@ -16,7 +16,7 @@ const SKELETON_ROW_COUNT = 8;
16
16
  export function WorkflowRunListSkeleton() {
17
17
  return (
18
18
  <div
19
- className="@container divide-y divide-border-neutral-base border-t border-border-neutral-base"
19
+ className="@container divide-y divide-border-neutral-base"
20
20
  role="status"
21
21
  aria-label="Loading runs"
22
22
  >
@@ -28,7 +28,7 @@ export function WorkflowRunListSkeleton() {
28
28
  >
29
29
  <Skeleton className="size-14 shrink-0 rounded-full" />
30
30
  <Skeleton className="h-12 w-[220px] max-w-[40%]" />
31
- <Skeleton className="ml-auto hidden h-12 w-96 @min-[1040px]:block" />
31
+ <Skeleton className="ml-auto hidden h-12 w-96 @min-[1200px]:block" />
32
32
  <Skeleton className="h-12 w-48" />
33
33
  <Skeleton className="h-12 w-48" />
34
34
  </div>
@@ -1,4 +1,5 @@
1
1
  import type {JobStatusDto} from '@shipfox/api-workflows-dto';
2
+ import {WORKFLOW_RUN_JOB_PREVIEW_LIMIT} from '@shipfox/api-workflows-dto';
2
3
  import {screen, within} from '@testing-library/react';
3
4
  import userEvent from '@testing-library/user-event';
4
5
  import type {WorkflowRunListItem, WorkflowRunStatus} from '#core/workflow-run.js';
@@ -8,12 +9,14 @@ import {
8
9
  workflowRunListItem,
9
10
  } from '#test/fixtures/workflow-run.js';
10
11
  import {renderWithRouter} from '#test/render.js';
12
+ import {JOB_STATUS_STRIP_WIDTH, jobStatusStripWidthForPreview} from './job-status-strip.js';
11
13
  import type {WorkflowRunListQuery, WorkflowRunListViewProps} from './types.js';
12
14
  import {WorkflowRunListView} from './workflow-run-list-view.js';
13
15
 
14
16
  const PROJECT_ID = '44444444-4444-4444-8444-444444444444';
15
17
  const JOB_STRIP_LABEL_RE = /jobs:/u;
16
18
  const JOB_SUMMARY_NAME_RE = /queued-build/u;
19
+ const OVERFLOW_COUNT_RE = /^\+/u;
17
20
  const WORKSPACE_SLUG = 'acme';
18
21
  const PROJECT_SLUG = 'checkout-api';
19
22
 
@@ -35,6 +38,38 @@ describe('WorkflowRunListView', () => {
35
38
  vi.restoreAllMocks();
36
39
  });
37
40
 
41
+ describe('panel composition', () => {
42
+ test('keeps the controls in the panel header and states in its body', async () => {
43
+ renderListView([run('succeeded', 'build-image')]);
44
+ await screen.findByText('build-image');
45
+
46
+ const panel = document.querySelector<HTMLElement>('[data-slot="panel"]');
47
+ const header = panel?.querySelector<HTMLElement>('[data-slot="panel-header"]');
48
+ const body = panel?.querySelector<HTMLElement>('[data-slot="panel-body"]');
49
+
50
+ expect(panel).not.toBeNull();
51
+ expect(header).not.toBeNull();
52
+ expect(body).not.toBeNull();
53
+ expect(within(header as HTMLElement).getByLabelText('Search runs')).toBeInTheDocument();
54
+ expect(
55
+ within(header as HTMLElement).getByRole('button', {name: filterTrigger('Status')}),
56
+ ).toBeInTheDocument();
57
+ expect(
58
+ within(header as HTMLElement).getByRole('button', {name: filterTrigger('Branch')}),
59
+ ).toBeInTheDocument();
60
+ expect(
61
+ within(header as HTMLElement).getByRole('button', {name: filterTrigger('Actor')}),
62
+ ).toBeInTheDocument();
63
+ expect(
64
+ within(header as HTMLElement).getByRole('button', {name: filterTrigger('Event')}),
65
+ ).toBeInTheDocument();
66
+ expect(
67
+ within(header as HTMLElement).getByLabelText('Filter runs by creation date'),
68
+ ).toBeInTheDocument();
69
+ expect(within(body as HTMLElement).getByText('build-image')).toBeInTheDocument();
70
+ });
71
+ });
72
+
38
73
  describe('filtering', () => {
39
74
  test('narrows the list to the selected status', async () => {
40
75
  const user = userEvent.setup();
@@ -380,10 +415,24 @@ describe('WorkflowRunListView', () => {
380
415
  test('counts the jobs it had to hide beyond the strip threshold', async () => {
381
416
  renderListView([run('running', 'deploy-web', 'run-1', {...workflowRunJobsOfStatus(40)})]);
382
417
 
383
- expect(await screen.findByText('+33')).toBeInTheDocument();
418
+ expect(await screen.findByText('+24')).toBeInTheDocument();
384
419
  expect(screen.getByRole('img', {name: '40 jobs: 40 succeeded'})).toBeInTheDocument();
385
420
  });
386
421
 
422
+ test('renders every job in the API preview without a client-side cap', async () => {
423
+ renderListView([run('running', 'deploy-web', 'run-1', {...workflowRunJobsOfStatus(16)})]);
424
+
425
+ const strip = await screen.findByRole('img', {name: '16 jobs: 16 succeeded'});
426
+
427
+ expect(within(strip).getAllByLabelText('Succeeded')).toHaveLength(16);
428
+ expect(within(strip).queryByText(OVERFLOW_COUNT_RE)).not.toBeInTheDocument();
429
+ });
430
+
431
+ test('reserves the full API preview and overflow marker width', () => {
432
+ expect(jobStatusStripWidthForPreview(WORKFLOW_RUN_JOB_PREVIEW_LIMIT)).toBe(294);
433
+ expect(JOB_STATUS_STRIP_WIDTH).toBe(294);
434
+ });
435
+
387
436
  // The API sends a bounded preview, so a run whose only failure sits past it has no failed
388
437
  // job in the payload at all. The counts still carry it, and the overflow glyph has to say
389
438
  // so rather than showing an unbroken row of green.
@@ -398,7 +447,7 @@ describe('WorkflowRunListView', () => {
398
447
  name: '21 jobs: 1 failed, 20 succeeded',
399
448
  });
400
449
  expect(strip).toBeInTheDocument();
401
- expect(screen.getByText('+14')).toBeInTheDocument();
450
+ expect(screen.getByText('+5')).toBeInTheDocument();
402
451
  expect(within(strip).getByLabelText('Failed')).toBeInTheDocument();
403
452
  });
404
453
 
@@ -420,8 +469,8 @@ describe('WorkflowRunListView', () => {
420
469
  ]);
421
470
 
422
471
  const strip = await screen.findByRole('img', {name: '20 jobs: 20 listening'});
423
- expect(screen.getByText('+13')).toBeInTheDocument();
424
- expect(within(strip).getAllByLabelText('Listening')).toHaveLength(8);
472
+ expect(screen.getByText('+4')).toBeInTheDocument();
473
+ expect(within(strip).getAllByLabelText('Listening')).toHaveLength(17);
425
474
 
426
475
  const user = userEvent.setup();
427
476
  await user.hover(strip);
@@ -470,6 +519,7 @@ describe('WorkflowRunListView', () => {
470
519
  run('succeeded', 'build-image', 'run-build-image', {
471
520
  started_at: '2026-05-07T01:00:00.000Z',
472
521
  finished_at: '2026-05-07T01:02:14.000Z',
522
+ has_started_job_execution: true,
473
523
  }),
474
524
  ]);
475
525
 
@@ -496,6 +546,53 @@ describe('WorkflowRunListView', () => {
496
546
  expect(duration).toHaveAttribute('aria-label', 'running 2m 14s');
497
547
  });
498
548
 
549
+ test('uses the attempt status when all listed jobs are pending', async () => {
550
+ vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-05-07T01:02:14.000Z'));
551
+
552
+ renderListView([
553
+ run('running', 'waiting-for-runner', 'run-waiting-for-runner', {
554
+ ...workflowRunJobsFixture(['pending', 'pending']),
555
+ started_at: '2026-05-07T01:00:00.000Z',
556
+ finished_at: null,
557
+ }),
558
+ ]);
559
+
560
+ const duration = await screen.findByText('2m 14s');
561
+ expect(duration).toHaveAttribute('aria-label', 'running 2m 14s');
562
+ expect(
563
+ screen.getByRole('link', {
564
+ name: (name) => name.includes('Running') && name.includes('running 2m 14s'),
565
+ }),
566
+ ).toBeInTheDocument();
567
+ });
568
+
569
+ test('uses a neutral verb when no listed job execution started', async () => {
570
+ renderListView([
571
+ run('cancelled', 'cancelled-before-runner', 'run-cancelled-before-runner', {
572
+ ...workflowRunJobsFixture(['pending']),
573
+ started_at: '2026-05-07T01:00:00.000Z',
574
+ finished_at: '2026-05-07T01:02:14.000Z',
575
+ }),
576
+ ]);
577
+
578
+ const duration = await screen.findByText('2m 14s');
579
+ expect(duration).toHaveAttribute('aria-label', 'lasted 2m 14s');
580
+ });
581
+
582
+ test('uses the started-execution flag when a cancelled job reached a runner', async () => {
583
+ renderListView([
584
+ run('cancelled', 'cancelled-after-start', 'run-cancelled-after-start', {
585
+ ...workflowRunJobsFixture(['cancelled']),
586
+ has_started_job_execution: true,
587
+ started_at: '2026-05-07T01:00:00.000Z',
588
+ finished_at: '2026-05-07T01:02:14.000Z',
589
+ }),
590
+ ]);
591
+
592
+ const duration = await screen.findByText('2m 14s');
593
+ expect(duration).toHaveAttribute('aria-label', 'ran 2m 14s');
594
+ });
595
+
499
596
  test('renders the workflow name beside the run number', async () => {
500
597
  renderListView([
501
598
  run('succeeded', 'Deploy production', 'run-deploy-production', {
@@ -1,3 +1,4 @@
1
+ import {Panel, PanelBody, PanelHeader} from '@shipfox/react-ui/panel';
1
2
  import {TimeTickerProvider} from '@shipfox/react-ui/time-ticker';
2
3
  import {Header} from '@shipfox/react-ui/typography';
3
4
  import {cn} from '@shipfox/react-ui/utils';
@@ -58,7 +59,7 @@ export function WorkflowRunListView({
58
59
  <TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
59
60
  <section
60
61
  aria-labelledby={headingId}
61
- className={cn('flex min-h-0 min-w-0 flex-1 flex-col gap-cluster', className)}
62
+ className={cn('flex min-h-0 min-w-0 flex-1 flex-col', className)}
62
63
  >
63
64
  {/* The tab strip above already reads "Runs", so the page heading is for structure and
64
65
  assistive tech rather than a second visible title competing with it. */}
@@ -66,27 +67,32 @@ export function WorkflowRunListView({
66
67
  Workflow runs
67
68
  </Header>
68
69
 
69
- <WorkflowRunFilters
70
- search={currentSearch}
71
- facets={facets}
72
- onChange={handleFiltersChange}
73
- onClear={handleClearFilters}
74
- hasActiveFilters={hasActiveFilters}
75
- />
76
-
77
- <WorkflowRunListContent
78
- query={query}
79
- totalRuns={runs.length}
80
- runs={filteredRuns}
81
- workspaceSlug={workspaceSlug}
82
- projectSlug={projectSlug}
83
- hasActiveFilters={hasActiveFilters}
84
- onClearFilters={handleClearFilters}
85
- hasNextPage={hasNextPage}
86
- isFetchingNextPage={isFetchingNextPage}
87
- isFetchNextPageError={isFetchNextPageError}
88
- {...(onLoadMore ? {onLoadMore} : {})}
89
- />
70
+ <Panel className="min-h-0 flex-1">
71
+ <PanelHeader className="flex-wrap">
72
+ <WorkflowRunFilters
73
+ search={currentSearch}
74
+ facets={facets}
75
+ onChange={handleFiltersChange}
76
+ onClear={handleClearFilters}
77
+ hasActiveFilters={hasActiveFilters}
78
+ />
79
+ </PanelHeader>
80
+ <PanelBody className="min-h-0 flex-1">
81
+ <WorkflowRunListContent
82
+ query={query}
83
+ totalRuns={runs.length}
84
+ runs={filteredRuns}
85
+ workspaceSlug={workspaceSlug}
86
+ projectSlug={projectSlug}
87
+ hasActiveFilters={hasActiveFilters}
88
+ onClearFilters={handleClearFilters}
89
+ hasNextPage={hasNextPage}
90
+ isFetchingNextPage={isFetchingNextPage}
91
+ isFetchNextPageError={isFetchNextPageError}
92
+ {...(onLoadMore ? {onLoadMore} : {})}
93
+ />
94
+ </PanelBody>
95
+ </Panel>
90
96
  </section>
91
97
  </TimeTickerProvider>
92
98
  );