@shipfox/client-workflows 20.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 (105) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +29 -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-list/job-status-strip.d.ts +9 -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 +22 -11
  21. package/dist/components/workflow-run-list/job-status-strip.js.map +1 -1
  22. package/dist/components/workflow-run-list/workflow-run-filters.js +1 -1
  23. package/dist/components/workflow-run-list/workflow-run-filters.js.map +1 -1
  24. package/dist/components/workflow-run-list/workflow-run-list-states.js +2 -2
  25. package/dist/components/workflow-run-list/workflow-run-list-states.js.map +1 -1
  26. package/dist/components/workflow-run-list/workflow-run-list-view.d.ts.map +1 -1
  27. package/dist/components/workflow-run-list/workflow-run-list-view.js +34 -22
  28. package/dist/components/workflow-run-list/workflow-run-list-view.js.map +1 -1
  29. package/dist/components/workflow-run-list/workflow-run-row.d.ts +3 -3
  30. package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
  31. package/dist/components/workflow-run-list/workflow-run-row.js +13 -10
  32. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  33. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  34. package/dist/components/workflow-run-summary/workflow-run-summary.js +1 -1
  35. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  36. package/dist/components/workflow-run-view/run-workspace-nav.js +1 -1
  37. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -1
  38. package/dist/components/workflow-run-view/workflow-run-states.d.ts.map +1 -1
  39. package/dist/components/workflow-run-view/workflow-run-states.js +1 -1
  40. package/dist/components/workflow-run-view/workflow-run-states.js.map +1 -1
  41. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  42. package/dist/components/workflow-run-view/workflow-run-view.js +43 -36
  43. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  44. package/dist/components/workflow-source-panel/workflow-source-content.js +1 -1
  45. package/dist/components/workflow-source-panel/workflow-source-content.js.map +1 -1
  46. package/dist/pages/project-workflows-page.d.ts.map +1 -1
  47. package/dist/pages/project-workflows-page.js +3 -2
  48. package/dist/pages/project-workflows-page.js.map +1 -1
  49. package/dist/pages/workflow-job-detail-page.d.ts.map +1 -1
  50. package/dist/pages/workflow-job-detail-page.js +3 -2
  51. package/dist/pages/workflow-job-detail-page.js.map +1 -1
  52. package/dist/pages/workflow-run-detail-page.js +2 -1
  53. package/dist/pages/workflow-run-detail-page.js.map +1 -1
  54. package/dist/pages/workflow-run-list-page.d.ts.map +1 -1
  55. package/dist/pages/workflow-run-list-page.js +9 -11
  56. package/dist/pages/workflow-run-list-page.js.map +1 -1
  57. package/dist/routes/job-detail.d.ts +1 -1
  58. package/dist/routes/job-detail.js +1 -1
  59. package/dist/routes/job-detail.js.map +1 -1
  60. package/dist/routes/run-detail.d.ts +1 -1
  61. package/dist/routes/run-detail.js +1 -1
  62. package/dist/routes/run-detail.js.map +1 -1
  63. package/dist/routes/runs.d.ts +1 -1
  64. package/dist/routes/runs.js +1 -1
  65. package/dist/routes/runs.js.map +1 -1
  66. package/dist/routes/workflows.d.ts +3 -0
  67. package/dist/routes/workflows.js +3 -0
  68. package/dist/routes/workflows.js.map +1 -1
  69. package/dist/tsconfig.test.tsbuildinfo +1 -1
  70. package/package.json +7 -7
  71. package/src/components/job-detail/job-detail-view.tsx +263 -47
  72. package/src/components/job-detail/step-attempt-log-panel.test.tsx +30 -12
  73. package/src/components/job-detail/step-attempt-log-panel.tsx +68 -10
  74. package/src/components/job-graph/job-graph.stories.tsx +1 -1
  75. package/src/components/job-graph/job-node.stories.tsx +1 -1
  76. package/src/components/step-list/step-list-model.ts +1 -1
  77. package/src/components/step-list/step-list.test.tsx +29 -1
  78. package/src/components/step-list/step-list.tsx +28 -9
  79. package/src/components/workflow-run-list/job-status-strip.tsx +25 -12
  80. package/src/components/workflow-run-list/workflow-run-filters.tsx +1 -1
  81. package/src/components/workflow-run-list/workflow-run-list-states.tsx +2 -2
  82. package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +53 -4
  83. package/src/components/workflow-run-list/workflow-run-list-view.tsx +28 -22
  84. package/src/components/workflow-run-list/workflow-run-list.stories.tsx +9 -20
  85. package/src/components/workflow-run-list/workflow-run-row.tsx +17 -13
  86. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +2 -2
  87. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +2 -1
  88. package/src/components/workflow-run-summary/workflow-run-summary.tsx +1 -4
  89. package/src/components/workflow-run-view/run-workspace-nav.tsx +1 -1
  90. package/src/components/workflow-run-view/workflow-run-states.tsx +1 -4
  91. package/src/components/workflow-run-view/workflow-run-view.stories.tsx +140 -0
  92. package/src/components/workflow-run-view/workflow-run-view.test.tsx +38 -7
  93. package/src/components/workflow-run-view/workflow-run-view.tsx +45 -37
  94. package/src/components/workflow-source-panel/workflow-source-content.tsx +2 -2
  95. package/src/pages/project-workflows-page.tsx +3 -2
  96. package/src/pages/workflow-job-detail-page.test.tsx +32 -0
  97. package/src/pages/workflow-job-detail-page.tsx +5 -1
  98. package/src/pages/workflow-run-detail-page.tsx +1 -1
  99. package/src/pages/workflow-run-list-page.tsx +9 -11
  100. package/src/pages/workflow-run-pages.test.tsx +26 -0
  101. package/src/routes/job-detail.tsx +1 -1
  102. package/src/routes/run-detail.tsx +1 -1
  103. package/src/routes/runs.tsx +1 -1
  104. package/src/routes/workflows.tsx +1 -0
  105. package/tsconfig.build.tsbuildinfo +1 -1
@@ -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
  >
@@ -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);
@@ -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
  );
@@ -75,15 +75,15 @@ const SAMPLE_RUNS: WorkflowRunListItem[] = [
75
75
 
76
76
  // One story on the full-width page. The data states (loading / empty / errors / runs) are
77
77
  // driven by args; search, the filter menus, "clear filters" and "no matches" are live in the
78
- // rendered toolbar since the view owns that state. The decorator gives the list the 1120px
79
- // content cap and a real height, so `flex-1` scrolls the way it does in the app shell.
78
+ // rendered toolbar since the view owns that state. The decorator gives the list a full-width
79
+ // subtle canvas and a real height, so `flex-1` scrolls the way it does in the app shell.
80
80
  const meta = {
81
81
  title: 'Workflows/WorkflowRunList',
82
82
  component: WorkflowRunListView,
83
83
  parameters: {layout: 'centered'},
84
84
  decorators: [
85
85
  (Story) => (
86
- <div className="flex h-600 w-[1120px] max-w-full bg-background-neutral-base p-24">
86
+ <div className="flex h-600 w-full bg-background-subtle-base p-24">
87
87
  <Story />
88
88
  </div>
89
89
  ),
@@ -137,27 +137,18 @@ export const NarrowLayout: Story = {
137
137
  };
138
138
 
139
139
  /**
140
- * Row density against the three strip sizes the threshold has to survive: comfortably under
141
- * it, comfortably over it, and far enough over that the overflow count carries the failure.
140
+ * Row density against the three strip sizes the preview bound has to survive: comfortably under
141
+ * it, just over it, and far enough over that the overflow count carries the failure.
142
142
  */
143
143
  export const JobStripDensity: Story = {
144
144
  args: {
145
145
  runs: [
146
146
  makeRun('succeeded', 'two-jobs', 2, {jobs: ['succeeded', 'succeeded']}),
147
- makeRun('failed', 'twelve-jobs', 6, {
147
+ makeRun('failed', 'seventeen-jobs', 6, {
148
148
  jobs: [
149
- 'succeeded',
150
- 'succeeded',
151
- 'succeeded',
149
+ ...(Array.from({length: 12}, () => 'succeeded') as JobStatusDto[]),
152
150
  'failed',
153
- 'succeeded',
154
- 'succeeded',
155
- 'skipped',
156
- 'skipped',
157
- 'succeeded',
158
- 'succeeded',
159
- 'succeeded',
160
- 'succeeded',
151
+ ...(Array.from({length: 4}, () => 'skipped') as JobStatusDto[]),
161
152
  ],
162
153
  }),
163
154
  makeRun('running', 'forty-jobs', 9, {
@@ -229,9 +220,7 @@ function StateExample({label, children}: {label: string; children: ReactNode}) {
229
220
  <Code variant="label" className="text-foreground-neutral-subtle">
230
221
  {label}
231
222
  </Code>
232
- <div className="flex h-560 rounded-8 border border-border-neutral-base bg-background-neutral-base p-16">
233
- {children}
234
- </div>
223
+ <div className="flex h-560 min-w-0 bg-background-subtle-base p-16">{children}</div>
235
224
  </div>
236
225
  );
237
226
  }
@@ -22,7 +22,7 @@ import {
22
22
  workflowRunCommitLabel,
23
23
  } from '#core/workflow-run.js';
24
24
  import {withoutWorkflowRunSelectionSearch} from '#core/workflow-run-url-state.js';
25
- import {JobStatusStrip, jobStatusSummary} from './job-status-strip.js';
25
+ import {JOB_STATUS_STRIP_WIDTH, JobStatusStrip, jobStatusSummary} from './job-status-strip.js';
26
26
 
27
27
  export function WorkflowRunRowList({
28
28
  runs,
@@ -34,10 +34,10 @@ export function WorkflowRunRowList({
34
34
  projectSlug?: string | undefined;
35
35
  }) {
36
36
  return (
37
- // A container, not the viewport, drives the row's breakpoints. The list is capped at
38
- // 1120px inside a wider page, so what a row can afford is its own width; keying off the
39
- // viewport would keep the job strip hidden on a wide screen or crush it on a narrow one.
40
- <ul className="@container divide-y divide-border-neutral-base border-t border-border-neutral-base">
37
+ // A container, not the viewport, drives the row's breakpoints. What a row can afford is its
38
+ // own width; keying off the viewport would keep the job strip hidden on a wide screen or
39
+ // crush it on a narrow one.
40
+ <ul className="@container divide-y divide-border-neutral-base">
41
41
  {runs.map((run) => (
42
42
  <li key={run.id}>
43
43
  <WorkflowRunRow run={run} workspaceSlug={workspaceSlug} projectSlug={projectSlug} />
@@ -55,9 +55,9 @@ export function WorkflowRunRowList({
55
55
  * drops beneath. The numeric columns are fixed-width so duration and time form real columns
56
56
  * down the list rather than tracking each row's name length.
57
57
  *
58
- * The thresholds are the container widths the spec's viewport breakpoints produce, given a
59
- * column capped at 1120px with 24px of page padding on each side: a 1280px viewport leaves
60
- * the row 1072px, and a 1024px viewport leaves it 976px.
58
+ * The 976px threshold keeps identity and provenance on one line; below it, provenance drops
59
+ * beneath the identity. The 1200px threshold keeps the full job preview and numeric columns
60
+ * readable together; below it, the strip is hidden so the numeric columns remain aligned.
61
61
  */
62
62
  export function WorkflowRunRow({
63
63
  run,
@@ -99,10 +99,14 @@ export function WorkflowRunRow({
99
99
  </div>
100
100
 
101
101
  <span className="flex shrink-0 items-center gap-cluster">
102
- {/* Fixed at the strip's worst case (see MAX_VISIBLE_JOBS) so the overflow count cannot
103
- paint over the duration, and kept even when a run has no jobs planned yet, so the
104
- numerics stay in line down the list instead of stepping in and out. */}
105
- <span className="hidden w-160 @min-[1040px]:flex">
102
+ {/* Reserve the API preview's full width so the overflow count cannot paint over the
103
+ duration, and keep it even when a run has no jobs planned yet so the numerics stay in
104
+ line down the list instead of stepping in and out. The max-content width expands if
105
+ the payload ever exceeds the current API preview bound. */}
106
+ <span
107
+ className="hidden w-max shrink-0 @min-[1200px]:flex"
108
+ style={{minWidth: JOB_STATUS_STRIP_WIDTH}}
109
+ >
106
110
  <JobStatusStrip jobs={run.jobs} />
107
111
  </span>
108
112
  <span className="flex w-64 justify-end">
@@ -121,7 +125,7 @@ export function WorkflowRunRow({
121
125
  // Rows run edge to edge inside the list's scroll container, which would clip the standard
122
126
  // outset focus ring, so this one is inset per the design system's focus-ring rule.
123
127
  const rowClassName =
124
- 'flex w-full min-w-0 items-center gap-inline px-row py-row text-left transition-colors hover:bg-background-components-hover focus-visible:shadow-[inset_0_0_0_2px_var(--color-primary-500)] focus-visible:outline-none @min-[976px]:h-44 @min-[976px]:py-0';
128
+ 'flex w-full min-w-0 items-center gap-inline px-row py-row text-left transition-colors hover:bg-background-neutral-hover focus-visible:shadow-[inset_0_0_0_2px_var(--color-primary-500)] focus-visible:outline-none @min-[976px]:h-44 @min-[976px]:py-0';
125
129
 
126
130
  // Optimistic manual runs (temp-<uuid>) have no detail page until the canonical row
127
131
  // replaces them on the next poll, so they render non-interactively instead of as a link
@@ -56,10 +56,10 @@ const RUN_ATTEMPTS_RESPONSE = runAttemptsResponseDto({
56
56
  });
57
57
 
58
58
  const withFrame: Decorator = (Story) => (
59
- <div className="min-h-screen bg-background-neutral-background">
59
+ <div className="min-h-screen bg-background-subtle-base">
60
60
  <div className="mx-auto flex min-h-screen w-full max-w-[1120px] flex-col overflow-hidden border-x border-border-neutral-base bg-background-subtle-base">
61
61
  <Story />
62
- <div className="min-h-0 flex-1 bg-background-neutral-background p-16" />
62
+ <div className="min-h-0 flex-1 bg-background-subtle-base p-16" />
63
63
  </div>
64
64
  </div>
65
65
  );
@@ -38,7 +38,8 @@ describe('WorkflowRunSummary', () => {
38
38
 
39
39
  const summary = await screen.findByRole('region', {name: 'deploy-web'});
40
40
 
41
- expect(summary).toHaveClass('bg-background-neutral-background', 'px-row', 'py-row');
41
+ expect(summary).toHaveClass('px-row', 'py-row');
42
+ expect(summary).not.toHaveClass('bg-background-subtle-base');
42
43
  expect(summary.firstElementChild).not.toHaveClass('max-w-[1120px]');
43
44
  expect(within(summary).getByRole('heading', {name: 'deploy-web'})).toBeInTheDocument();
44
45
  expect(within(summary).getAllByText('Running')).not.toHaveLength(0);
@@ -70,10 +70,7 @@ export function WorkflowRunSummary({
70
70
 
71
71
  return (
72
72
  <TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
73
- <section
74
- aria-labelledby={headingId}
75
- className="bg-background-neutral-background px-row py-row"
76
- >
73
+ <section aria-labelledby={headingId} className="px-row py-row">
77
74
  <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-cluster gap-y-inline overflow-hidden max-[480px]:grid-cols-1">
78
75
  <div className="col-start-1 row-start-1 min-w-0 max-[480px]:col-start-auto max-[480px]:row-start-auto">
79
76
  <div className="flex min-w-0 items-center gap-inline">
@@ -53,7 +53,7 @@ export function RunWorkspaceNav({
53
53
 
54
54
  return (
55
55
  <TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
56
- <aside className="flex min-h-0 w-full shrink-0 flex-col border-b border-border-neutral-base bg-background-neutral-background min-[768px]:w-240 min-[768px]:border-b-0 min-[768px]:border-r">
56
+ <aside className="flex min-h-0 w-full shrink-0 flex-col border-b border-border-neutral-base min-[768px]:w-240 min-[768px]:border-b-0 min-[768px]:border-r">
57
57
  <Collapsible
58
58
  open={mobileOpen}
59
59
  onOpenChange={setMobileOpen}
@@ -7,10 +7,7 @@ import {Text} from '@shipfox/react-ui/typography';
7
7
 
8
8
  export function WorkflowRunSkeleton() {
9
9
  return (
10
- <section
11
- aria-label="Loading workflow run"
12
- className="bg-background-neutral-background px-row py-row"
13
- >
10
+ <section aria-label="Loading workflow run" className="px-row py-row">
14
11
  <div className="flex min-w-0 flex-wrap items-center gap-x-cluster gap-y-inline">
15
12
  <div className="flex min-w-0 items-center gap-inline">
16
13
  <Skeleton className="size-8 rounded-full" />