@shipfox/client-workflows 20.0.0 → 22.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 (118) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +51 -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 +232 -44
  5. package/dist/components/job-detail/job-detail-view.js.map +1 -1
  6. package/dist/components/job-detail/job-empty-states.js +2 -2
  7. package/dist/components/job-detail/job-empty-states.js.map +1 -1
  8. package/dist/components/job-detail/step-attempt-log-panel.d.ts +7 -1
  9. package/dist/components/job-detail/step-attempt-log-panel.d.ts.map +1 -1
  10. package/dist/components/job-detail/step-attempt-log-panel.js +37 -10
  11. package/dist/components/job-detail/step-attempt-log-panel.js.map +1 -1
  12. package/dist/components/job-graph/job-graph-content.js +2 -2
  13. package/dist/components/job-graph/job-graph-content.js.map +1 -1
  14. package/dist/components/step-list/step-list-model.d.ts +1 -0
  15. package/dist/components/step-list/step-list-model.d.ts.map +1 -1
  16. package/dist/components/step-list/step-list-model.js +1 -1
  17. package/dist/components/step-list/step-list-model.js.map +1 -1
  18. package/dist/components/step-list/step-list.d.ts +2 -1
  19. package/dist/components/step-list/step-list.d.ts.map +1 -1
  20. package/dist/components/step-list/step-list.js +25 -9
  21. package/dist/components/step-list/step-list.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-content.d.ts.map +1 -1
  29. package/dist/components/workflow-run-list/workflow-run-list-content.js +13 -7
  30. package/dist/components/workflow-run-list/workflow-run-list-content.js.map +1 -1
  31. package/dist/components/workflow-run-list/workflow-run-list-states.d.ts.map +1 -1
  32. package/dist/components/workflow-run-list/workflow-run-list-states.js +43 -38
  33. package/dist/components/workflow-run-list/workflow-run-list-states.js.map +1 -1
  34. package/dist/components/workflow-run-list/workflow-run-list-view.d.ts.map +1 -1
  35. package/dist/components/workflow-run-list/workflow-run-list-view.js +34 -22
  36. package/dist/components/workflow-run-list/workflow-run-list-view.js.map +1 -1
  37. package/dist/components/workflow-run-list/workflow-run-row.d.ts +3 -3
  38. package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
  39. package/dist/components/workflow-run-list/workflow-run-row.js +25 -18
  40. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  41. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  42. package/dist/components/workflow-run-summary/workflow-run-summary.js +4 -4
  43. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  44. package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -1
  45. package/dist/components/workflow-run-view/run-workspace-nav.js +20 -7
  46. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -1
  47. package/dist/components/workflow-run-view/workflow-run-states.d.ts.map +1 -1
  48. package/dist/components/workflow-run-view/workflow-run-states.js +1 -1
  49. package/dist/components/workflow-run-view/workflow-run-states.js.map +1 -1
  50. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  51. package/dist/components/workflow-run-view/workflow-run-view.js +103 -78
  52. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  53. package/dist/components/workflow-source-panel/workflow-source-content.js +1 -1
  54. package/dist/components/workflow-source-panel/workflow-source-content.js.map +1 -1
  55. package/dist/pages/project-workflows-page.d.ts.map +1 -1
  56. package/dist/pages/project-workflows-page.js +171 -158
  57. package/dist/pages/project-workflows-page.js.map +1 -1
  58. package/dist/pages/workflow-job-detail-page.d.ts.map +1 -1
  59. package/dist/pages/workflow-job-detail-page.js +3 -2
  60. package/dist/pages/workflow-job-detail-page.js.map +1 -1
  61. package/dist/pages/workflow-run-detail-page.js +2 -1
  62. package/dist/pages/workflow-run-detail-page.js.map +1 -1
  63. package/dist/pages/workflow-run-list-page.d.ts.map +1 -1
  64. package/dist/pages/workflow-run-list-page.js +9 -11
  65. package/dist/pages/workflow-run-list-page.js.map +1 -1
  66. package/dist/routes/job-detail.d.ts +1 -1
  67. package/dist/routes/job-detail.js +1 -1
  68. package/dist/routes/job-detail.js.map +1 -1
  69. package/dist/routes/run-detail.d.ts +1 -1
  70. package/dist/routes/run-detail.js +1 -1
  71. package/dist/routes/run-detail.js.map +1 -1
  72. package/dist/routes/runs.d.ts +1 -1
  73. package/dist/routes/runs.js +1 -1
  74. package/dist/routes/runs.js.map +1 -1
  75. package/dist/routes/workflows.d.ts +3 -0
  76. package/dist/routes/workflows.js +3 -0
  77. package/dist/routes/workflows.js.map +1 -1
  78. package/dist/tsconfig.test.tsbuildinfo +1 -1
  79. package/package.json +8 -8
  80. package/src/components/job-detail/job-detail-view.tsx +264 -49
  81. package/src/components/job-detail/job-empty-states.tsx +2 -2
  82. package/src/components/job-detail/step-attempt-log-panel.test.tsx +30 -12
  83. package/src/components/job-detail/step-attempt-log-panel.tsx +68 -10
  84. package/src/components/job-graph/job-graph-content.tsx +2 -2
  85. package/src/components/job-graph/job-graph.stories.tsx +1 -1
  86. package/src/components/job-graph/job-node.stories.tsx +1 -1
  87. package/src/components/step-list/step-list-model.ts +1 -1
  88. package/src/components/step-list/step-list.test.tsx +29 -1
  89. package/src/components/step-list/step-list.tsx +29 -11
  90. package/src/components/workflow-run-list/job-status-strip.tsx +25 -12
  91. package/src/components/workflow-run-list/workflow-run-filters.tsx +1 -1
  92. package/src/components/workflow-run-list/workflow-run-list-content.tsx +7 -2
  93. package/src/components/workflow-run-list/workflow-run-list-states.tsx +42 -41
  94. package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +53 -4
  95. package/src/components/workflow-run-list/workflow-run-list-view.tsx +28 -22
  96. package/src/components/workflow-run-list/workflow-run-list.stories.tsx +9 -20
  97. package/src/components/workflow-run-list/workflow-run-row.tsx +26 -19
  98. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +3 -3
  99. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +11 -2
  100. package/src/components/workflow-run-summary/workflow-run-summary.tsx +4 -7
  101. package/src/components/workflow-run-view/run-workspace-nav.test.tsx +40 -2
  102. package/src/components/workflow-run-view/run-workspace-nav.tsx +28 -9
  103. package/src/components/workflow-run-view/workflow-run-states.tsx +1 -4
  104. package/src/components/workflow-run-view/workflow-run-view.stories.tsx +142 -0
  105. package/src/components/workflow-run-view/workflow-run-view.test.tsx +42 -8
  106. package/src/components/workflow-run-view/workflow-run-view.tsx +112 -96
  107. package/src/components/workflow-source-panel/workflow-source-content.tsx +2 -2
  108. package/src/pages/project-workflows-page.tsx +135 -125
  109. package/src/pages/workflow-job-detail-page.test.tsx +32 -0
  110. package/src/pages/workflow-job-detail-page.tsx +5 -1
  111. package/src/pages/workflow-run-detail-page.tsx +1 -1
  112. package/src/pages/workflow-run-list-page.tsx +9 -11
  113. package/src/pages/workflow-run-pages.test.tsx +26 -0
  114. package/src/routes/job-detail.tsx +1 -1
  115. package/src/routes/run-detail.tsx +1 -1
  116. package/src/routes/runs.tsx +1 -1
  117. package/src/routes/workflows.tsx +1 -0
  118. package/tsconfig.build.tsbuildinfo +1 -1
@@ -13,7 +13,7 @@ import {JobExecutionTimeText} from './job-execution-time-text.js';
13
13
  const TAIL_FOLLOW_THRESHOLD_PX = 24;
14
14
  const INITIAL_LOG_ERROR_RETRY_COUNT = 5;
15
15
  const INITIAL_LOG_ERROR_RETRY_DELAY_MS = 1_500;
16
- const defaultLogSurfaceClasses = 'rounded-8';
16
+ const defaultLogSurfaceClasses = 'rounded-none border-0 bg-background-contrast-base shadow-none';
17
17
 
18
18
  export interface StepAttemptLogPanelProps {
19
19
  stepId: string;
@@ -24,6 +24,12 @@ export interface StepAttemptLogPanelProps {
24
24
  /** The page scroll column used by the uncapped job-detail log surface. */
25
25
  pageScrollRef: RefObject<HTMLElement | null>;
26
26
  surfaceClassName?: string | undefined;
27
+ search?: string | undefined;
28
+ wrap?: boolean | undefined;
29
+ showLineNumbers?: boolean | undefined;
30
+ attemptId?: string | undefined;
31
+ refreshToken?: number | undefined;
32
+ onFetchingChange?: ((attemptId: string, isFetching: boolean) => void) | undefined;
27
33
  initialErrorRetryCount?: number | undefined;
28
34
  initialErrorRetryDelayMs?: number | undefined;
29
35
  }
@@ -35,6 +41,12 @@ export function StepAttemptLogPanel({
35
41
  attemptStartedAt,
36
42
  pageScrollRef,
37
43
  surfaceClassName = defaultLogSurfaceClasses,
44
+ search = '',
45
+ wrap = false,
46
+ showLineNumbers = true,
47
+ attemptId,
48
+ refreshToken = 0,
49
+ onFetchingChange,
38
50
  initialErrorRetryCount = INITIAL_LOG_ERROR_RETRY_COUNT,
39
51
  initialErrorRetryDelayMs = INITIAL_LOG_ERROR_RETRY_DELAY_MS,
40
52
  }: StepAttemptLogPanelProps) {
@@ -55,6 +67,18 @@ export function StepAttemptLogPanel({
55
67
  retryMissingStream && query.data === undefined && isMissingStepLogStreamError(query.error);
56
68
  const initialError = query.isError && query.data === undefined && !missingActiveStream;
57
69
  const staleError = query.isError && query.data !== undefined;
70
+ const lastRefreshTokenRef = useRef(refreshToken);
71
+
72
+ useEffect(() => {
73
+ if (refreshToken === lastRefreshTokenRef.current) return;
74
+ lastRefreshTokenRef.current = refreshToken;
75
+ void query.refetchLogs();
76
+ }, [query.refetchLogs, refreshToken]);
77
+
78
+ useEffect(() => {
79
+ if (!attemptId) return;
80
+ onFetchingChange?.(attemptId, query.isFetching);
81
+ }, [attemptId, onFetchingChange, query.isFetching]);
58
82
 
59
83
  useEffect(() => {
60
84
  const scrollElement = pageScrollRef.current;
@@ -85,18 +109,32 @@ export function StepAttemptLogPanel({
85
109
  }, [anchorToFailure, pageScrollRef, recordCount]);
86
110
 
87
111
  if (query.isPending) {
88
- return <StepLogsLoadingSurface label="Loading logs" className={surfaceClassName} />;
112
+ return (
113
+ <StepLogsLoadingSurface
114
+ label="Loading logs"
115
+ className={surfaceClassName}
116
+ wrap={wrap}
117
+ showLineNumbers={showLineNumbers}
118
+ />
119
+ );
89
120
  }
90
121
 
91
122
  if (missingActiveStream) {
92
123
  if (attemptStatus === 'running' && attemptStartedAt) {
93
124
  return <StepLogsWaitingSurface startedAt={attemptStartedAt} className={surfaceClassName} />;
94
125
  }
95
- return <StepLogsLoadingSurface label="Waiting for logs" className={surfaceClassName} />;
126
+ return (
127
+ <StepLogsLoadingSurface
128
+ label="Waiting for logs"
129
+ className={surfaceClassName}
130
+ wrap={wrap}
131
+ showLineNumbers={showLineNumbers}
132
+ />
133
+ );
96
134
  }
97
135
 
98
136
  if (initialError) {
99
- return <StepLogsError retrying={query.isFetching} onRetry={() => void query.refetch()} />;
137
+ return <StepLogsError retrying={query.isFetching} onRetry={() => void query.refetchLogs()} />;
100
138
  }
101
139
 
102
140
  return (
@@ -115,7 +153,7 @@ export function StepAttemptLogPanel({
115
153
  size="2xs"
116
154
  variant="secondary"
117
155
  isLoading={query.isFetching}
118
- onClick={() => void query.refetch()}
156
+ onClick={() => void query.refetchLogs()}
119
157
  >
120
158
  Retry
121
159
  </Button>
@@ -124,19 +162,32 @@ export function StepAttemptLogPanel({
124
162
  ) : null}
125
163
  <LogView
126
164
  records={records}
165
+ search={search}
166
+ wrap={wrap}
167
+ showLineNumbers={showLineNumbers}
127
168
  emptyState={query.data?.complete ? 'complete' : 'pending'}
128
169
  anchorToFailure={anchorToFailure}
129
- ariaLive={attemptStatus === 'running' ? 'polite' : 'off'}
170
+ ariaLive={search.trim() ? 'off' : attemptStatus === 'running' ? 'polite' : 'off'}
130
171
  className={surfaceClassName}
131
172
  />
132
173
  </div>
133
174
  );
134
175
  }
135
176
 
136
- function StepLogsLoadingSurface({label, className}: {label: string; className: string}) {
177
+ function StepLogsLoadingSurface({
178
+ label,
179
+ className,
180
+ wrap,
181
+ showLineNumbers,
182
+ }: {
183
+ label: string;
184
+ className: string;
185
+ wrap: boolean;
186
+ showLineNumbers: boolean;
187
+ }) {
137
188
  return (
138
189
  <div role="status" aria-label={label}>
139
- <LogViewSkeleton className={className} />
190
+ <LogViewSkeleton className={className} wrap={wrap} showLineNumbers={showLineNumbers} />
140
191
  </div>
141
192
  );
142
193
  }
@@ -144,7 +195,7 @@ function StepLogsLoadingSurface({label, className}: {label: string; className: s
144
195
  function StepLogsWaitingSurface({startedAt, className}: {startedAt: string; className: string}) {
145
196
  return (
146
197
  <div role="status" aria-label="Waiting for logs" className={className}>
147
- <Text size="xs" className="px-tight py-row text-foreground-neutral-muted">
198
+ <Text size="xs" className="px-tight py-row text-foreground-contrast-secondary">
148
199
  Waiting for output ·{' '}
149
200
  <span className="font-code tabular-nums" aria-hidden="true">
150
201
  <JobExecutionTimeText time={{state: 'live', fromIso: startedAt}} />
@@ -188,7 +239,14 @@ function StepLogsError({retrying, onRetry}: {retrying: boolean; onRetry: () => v
188
239
  >
189
240
  <div className="flex min-w-0 flex-1 items-center justify-between gap-inline">
190
241
  <Text size="xs">Could not load logs.</Text>
191
- <Button type="button" size="2xs" variant="secondary" isLoading={retrying} onClick={onRetry}>
242
+ <Button
243
+ type="button"
244
+ size="2xs"
245
+ variant="secondary"
246
+ isLoading={retrying}
247
+ disabled={retrying}
248
+ onClick={onRetry}
249
+ >
192
250
  Retry
193
251
  </Button>
194
252
  </div>
@@ -37,11 +37,11 @@ export function JobGraphContent({
37
37
  if (model.nodes.length === 0) {
38
38
  return (
39
39
  <EmptyState
40
- className="min-h-160 p-panel"
40
+ className="min-h-160"
41
41
  icon="componentLine"
42
42
  title="No jobs yet"
43
43
  description="This run has not materialized jobs."
44
- variant="compact"
44
+ variant="panel"
45
45
  />
46
46
  );
47
47
  }
@@ -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
  >
@@ -307,11 +326,10 @@ function StepListEmptyStateView({
307
326
  if (!emptyState.status) {
308
327
  return (
309
328
  <EmptyState
310
- className="min-h-120 p-panel"
311
329
  icon="componentLine"
312
330
  title={emptyState.title}
313
331
  description={emptyState.description}
314
- variant="compact"
332
+ variant="panel"
315
333
  />
316
334
  );
317
335
  }
@@ -425,8 +443,8 @@ function StepRow({
425
443
  <>
426
444
  <div
427
445
  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',
446
+ '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',
447
+ selected && 'bg-background-neutral-hover',
430
448
  !hasExpandedContent && ['border-b border-border-neutral-base', isLast && 'border-b-0'],
431
449
  )}
432
450
  >
@@ -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})}
@@ -1,4 +1,5 @@
1
1
  import {QueryLoadError} from '@shipfox/client-ui';
2
+ import {Panel} from '@shipfox/react-ui/panel';
2
3
  import type {WorkflowRunListItem} from '#core/workflow-run.js';
3
4
  import type {WorkflowRunListQuery} from './types.js';
4
5
  import {
@@ -55,11 +56,15 @@ export function WorkflowRunListContent({
55
56
  <div className="min-h-0 flex-1 overflow-y-auto">
56
57
  {isPending ? <WorkflowRunListSkeleton /> : null}
57
58
  {!isPending ? (
58
- <QueryLoadError query={query} subject="workflow runs" icon="pulseLine" />
59
+ <Panel>
60
+ <QueryLoadError query={query} subject="workflow runs" icon="pulseLine" variant="panel" />
61
+ </Panel>
59
62
  ) : null}
60
63
  {!isPending && refreshFailed ? <WorkflowRunListStaleError query={query} /> : null}
61
64
  {showEmptyState ? (
62
- <WorkflowRunListEmpty workspaceSlug={workspaceSlug} projectSlug={projectSlug} />
65
+ <Panel>
66
+ <WorkflowRunListEmpty workspaceSlug={workspaceSlug} projectSlug={projectSlug} />
67
+ </Panel>
63
68
  ) : null}
64
69
  {showNoMatches ? (
65
70
  <WorkflowRunListNoMatches
@@ -2,6 +2,7 @@ import type {QueryLoadErrorQuery} from '@shipfox/client-ui';
2
2
  import {Button} from '@shipfox/react-ui/button';
3
3
  import {Callout} from '@shipfox/react-ui/callout';
4
4
  import {EmptyState} from '@shipfox/react-ui/empty-state';
5
+ import {Panel} from '@shipfox/react-ui/panel';
5
6
  import {Skeleton} from '@shipfox/react-ui/skeleton';
6
7
  import {Text} from '@shipfox/react-ui/typography';
7
8
  import {Link} from '@tanstack/react-router';
@@ -15,25 +16,21 @@ const SKELETON_ROW_COUNT = 8;
15
16
  */
16
17
  export function WorkflowRunListSkeleton() {
17
18
  return (
18
- <div
19
- className="@container divide-y divide-border-neutral-base border-t border-border-neutral-base"
20
- role="status"
21
- aria-label="Loading runs"
22
- >
19
+ <Panel role="status" aria-label="Loading runs" className="@container divide-y">
23
20
  {Array.from({length: SKELETON_ROW_COUNT}).map((_, index) => (
24
21
  <div
25
22
  // biome-ignore lint/suspicious/noArrayIndexKey: skeleton row, stable position
26
23
  key={index}
27
- className="flex items-center gap-inline px-row py-row @min-[976px]:h-44 @min-[976px]:py-0"
24
+ className="flex min-h-44 items-center gap-inline px-row py-row @min-[976px]:h-44 @min-[976px]:py-0"
28
25
  >
29
26
  <Skeleton className="size-14 shrink-0 rounded-full" />
30
27
  <Skeleton className="h-12 w-[220px] max-w-[40%]" />
31
- <Skeleton className="ml-auto hidden h-12 w-96 @min-[1040px]:block" />
28
+ <Skeleton className="ml-auto hidden h-12 w-96 @min-[1200px]:block" />
32
29
  <Skeleton className="h-12 w-48" />
33
30
  <Skeleton className="h-12 w-48" />
34
31
  </div>
35
32
  ))}
36
- </div>
33
+ </Panel>
37
34
  );
38
35
  }
39
36
 
@@ -89,6 +86,7 @@ export function WorkflowRunListEmpty({
89
86
  </Button>
90
87
  ) : null
91
88
  }
89
+ variant="panel"
92
90
  />
93
91
  );
94
92
  }
@@ -116,40 +114,43 @@ export function WorkflowRunListNoMatches({
116
114
  onLoadMore?: () => void;
117
115
  }) {
118
116
  return (
119
- <div className="flex flex-col gap-inline">
120
- {isFetchNextPageError ? (
121
- <Callout role="alert" type="error">
122
- Could not load more workflow runs. Try again to continue searching older runs.
123
- </Callout>
124
- ) : null}
125
- <EmptyState
126
- icon="filterOffLine"
127
- title={hasNextPage ? 'No matches in loaded history' : 'No matching runs'}
128
- description={
129
- hasNextPage
130
- ? 'No loaded run matches these filters. Load more to search further back.'
131
- : 'No run matches these filters.'
132
- }
133
- action={
134
- <div className="flex flex-wrap justify-center gap-inline">
135
- {hasNextPage && onLoadMore ? (
136
- <Button
137
- type="button"
138
- size="sm"
139
- variant="primary"
140
- isLoading={isFetchingNextPage}
141
- onClick={onLoadMore}
142
- >
143
- Load more runs
117
+ <Panel>
118
+ <div className="flex flex-col gap-inline">
119
+ {isFetchNextPageError ? (
120
+ <Callout role="alert" type="error">
121
+ Could not load more workflow runs. Try again to continue searching older runs.
122
+ </Callout>
123
+ ) : null}
124
+ <EmptyState
125
+ icon="filterOffLine"
126
+ title={hasNextPage ? 'No matches in loaded history' : 'No matching runs'}
127
+ description={
128
+ hasNextPage
129
+ ? 'No loaded run matches these filters. Load more to search further back.'
130
+ : 'No run matches these filters.'
131
+ }
132
+ action={
133
+ <div className="flex flex-wrap justify-center gap-inline">
134
+ {hasNextPage && onLoadMore ? (
135
+ <Button
136
+ type="button"
137
+ size="sm"
138
+ variant="primary"
139
+ isLoading={isFetchingNextPage}
140
+ onClick={onLoadMore}
141
+ >
142
+ Load more runs
143
+ </Button>
144
+ ) : null}
145
+ <Button type="button" size="sm" variant="secondary" onClick={onClear}>
146
+ Show all runs
144
147
  </Button>
145
- ) : null}
146
- <Button type="button" size="sm" variant="secondary" onClick={onClear}>
147
- Show all runs
148
- </Button>
149
- </div>
150
- }
151
- />
152
- </div>
148
+ </div>
149
+ }
150
+ variant="panel"
151
+ />
152
+ </div>
153
+ </Panel>
153
154
  );
154
155
  }
155
156