@shipfox/client-workflows 13.0.0 → 14.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 (133) hide show
  1. package/.storybook/main.ts +16 -1
  2. package/.storybook/workspace-source.ts +31 -0
  3. package/.turbo/turbo-build.log +1 -1
  4. package/CHANGELOG.md +23 -0
  5. package/dist/components/job-graph/job-duration-label.js +1 -1
  6. package/dist/components/job-graph/job-duration-label.js.map +1 -1
  7. package/dist/components/job-graph/job-graph-content.d.ts +2 -1
  8. package/dist/components/job-graph/job-graph-content.d.ts.map +1 -1
  9. package/dist/components/job-graph/job-graph-content.js +3 -3
  10. package/dist/components/job-graph/job-graph-content.js.map +1 -1
  11. package/dist/components/job-graph/job-graph-view.d.ts +2 -1
  12. package/dist/components/job-graph/job-graph-view.d.ts.map +1 -1
  13. package/dist/components/job-graph/job-graph-view.js +2 -2
  14. package/dist/components/job-graph/job-graph-view.js.map +1 -1
  15. package/dist/components/job-graph/job-node.js +5 -5
  16. package/dist/components/job-graph/job-node.js.map +1 -1
  17. package/dist/components/job-graph/types.d.ts +2 -1
  18. package/dist/components/job-graph/types.d.ts.map +1 -1
  19. package/dist/components/job-graph/types.js.map +1 -1
  20. package/dist/components/workflow-run-duration-label.js +1 -1
  21. package/dist/components/workflow-run-duration-label.js.map +1 -1
  22. package/dist/components/workflow-run-number-label.js +1 -1
  23. package/dist/components/workflow-run-number-label.js.map +1 -1
  24. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js +2 -2
  25. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js.map +1 -1
  26. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts +2 -7
  27. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  28. package/dist/components/workflow-run-summary/workflow-run-summary.js +92 -68
  29. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  30. package/dist/components/workflow-run-tabs/index.d.ts +6 -0
  31. package/dist/components/workflow-run-tabs/index.d.ts.map +1 -0
  32. package/dist/components/workflow-run-tabs/index.js +7 -0
  33. package/dist/components/workflow-run-tabs/index.js.map +1 -0
  34. package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts +11 -0
  35. package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts.map +1 -0
  36. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js +71 -0
  37. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js.map +1 -0
  38. package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts +2 -0
  39. package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts.map +1 -0
  40. package/dist/components/workflow-run-tabs/run-annotations-empty.js +11 -0
  41. package/dist/components/workflow-run-tabs/run-annotations-empty.js.map +1 -0
  42. package/dist/components/workflow-run-tabs/run-jobs-list.d.ts +18 -0
  43. package/dist/components/workflow-run-tabs/run-jobs-list.d.ts.map +1 -0
  44. package/dist/components/workflow-run-tabs/run-jobs-list.js +107 -0
  45. package/dist/components/workflow-run-tabs/run-jobs-list.js.map +1 -0
  46. package/dist/components/workflow-run-tabs/run-tab-count.d.ts +6 -0
  47. package/dist/components/workflow-run-tabs/run-tab-count.d.ts.map +1 -0
  48. package/dist/components/workflow-run-tabs/run-tab-count.js +27 -0
  49. package/dist/components/workflow-run-tabs/run-tab-count.js.map +1 -0
  50. package/dist/components/workflow-run-tabs/run-tab-strip.d.ts +8 -0
  51. package/dist/components/workflow-run-tabs/run-tab-strip.d.ts.map +1 -0
  52. package/dist/components/workflow-run-tabs/run-tab-strip.js +82 -0
  53. package/dist/components/workflow-run-tabs/run-tab-strip.js.map +1 -0
  54. package/dist/components/workflow-run-view/workflow-run-selection.d.ts.map +1 -1
  55. package/dist/components/workflow-run-view/workflow-run-selection.js +40 -1
  56. package/dist/components/workflow-run-view/workflow-run-selection.js.map +1 -1
  57. package/dist/components/workflow-run-view/workflow-run-states.d.ts +1 -0
  58. package/dist/components/workflow-run-view/workflow-run-states.d.ts.map +1 -1
  59. package/dist/components/workflow-run-view/workflow-run-states.js +10 -1
  60. package/dist/components/workflow-run-view/workflow-run-states.js.map +1 -1
  61. package/dist/components/workflow-run-view/workflow-run-view.d.ts +8 -4
  62. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  63. package/dist/components/workflow-run-view/workflow-run-view.js +125 -78
  64. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  65. package/dist/components/workflow-source-panel/index.d.ts +1 -0
  66. package/dist/components/workflow-source-panel/index.d.ts.map +1 -1
  67. package/dist/components/workflow-source-panel/index.js +1 -0
  68. package/dist/components/workflow-source-panel/index.js.map +1 -1
  69. package/dist/components/workflow-source-panel/workflow-source-content.d.ts +12 -0
  70. package/dist/components/workflow-source-panel/workflow-source-content.d.ts.map +1 -0
  71. package/dist/components/workflow-source-panel/workflow-source-content.js +53 -0
  72. package/dist/components/workflow-source-panel/workflow-source-content.js.map +1 -0
  73. package/dist/components/workflow-source-panel/workflow-source-panel.d.ts +1 -1
  74. package/dist/components/workflow-source-panel/workflow-source-panel.d.ts.map +1 -1
  75. package/dist/components/workflow-source-panel/workflow-source-panel.js +7 -54
  76. package/dist/components/workflow-source-panel/workflow-source-panel.js.map +1 -1
  77. package/dist/core/entities/step.d.ts +1 -1
  78. package/dist/core/entities/step.d.ts.map +1 -1
  79. package/dist/core/entities/step.js +2 -0
  80. package/dist/core/entities/step.js.map +1 -1
  81. package/dist/core/workflow-run-tabs.d.ts +9 -0
  82. package/dist/core/workflow-run-tabs.d.ts.map +1 -0
  83. package/dist/core/workflow-run-tabs.js +3 -0
  84. package/dist/core/workflow-run-tabs.js.map +1 -0
  85. package/dist/pages/workflow-run-detail-page.d.ts.map +1 -1
  86. package/dist/pages/workflow-run-detail-page.js +28 -6
  87. package/dist/pages/workflow-run-detail-page.js.map +1 -1
  88. package/dist/routes/inputs.d.ts +33 -0
  89. package/dist/routes/inputs.d.ts.map +1 -1
  90. package/dist/routes/inputs.js +49 -0
  91. package/dist/routes/inputs.js.map +1 -1
  92. package/dist/tsconfig.test.tsbuildinfo +1 -1
  93. package/package.json +9 -9
  94. package/src/components/job-graph/job-duration-label.tsx +5 -1
  95. package/src/components/job-graph/job-graph-content.tsx +7 -4
  96. package/src/components/job-graph/job-graph-view.tsx +6 -3
  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/job-graph/job-node.test.tsx +27 -2
  100. package/src/components/job-graph/job-node.tsx +6 -6
  101. package/src/components/job-graph/types.ts +5 -1
  102. package/src/components/workflow-run-duration-label.tsx +1 -1
  103. package/src/components/workflow-run-number-label.tsx +1 -1
  104. package/src/components/workflow-run-summary/workflow-run-attempt-switcher.tsx +2 -2
  105. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +23 -30
  106. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +3 -22
  107. package/src/components/workflow-run-summary/workflow-run-summary.tsx +84 -69
  108. package/src/components/workflow-run-tabs/index.ts +5 -0
  109. package/src/components/workflow-run-tabs/run-annotation-summary-line.test.tsx +69 -0
  110. package/src/components/workflow-run-tabs/run-annotation-summary-line.tsx +97 -0
  111. package/src/components/workflow-run-tabs/run-annotations-empty.tsx +11 -0
  112. package/src/components/workflow-run-tabs/run-jobs-list.tsx +151 -0
  113. package/src/components/workflow-run-tabs/run-tab-count.tsx +28 -0
  114. package/src/components/workflow-run-tabs/run-tab-strip.stories.tsx +52 -0
  115. package/src/components/workflow-run-tabs/run-tab-strip.test.tsx +117 -0
  116. package/src/components/workflow-run-tabs/run-tab-strip.tsx +97 -0
  117. package/src/components/workflow-run-view/workflow-run-selection.test.ts +48 -0
  118. package/src/components/workflow-run-view/workflow-run-selection.ts +48 -1
  119. package/src/components/workflow-run-view/workflow-run-states.tsx +12 -1
  120. package/src/components/workflow-run-view/workflow-run-view.test.tsx +64 -64
  121. package/src/components/workflow-run-view/workflow-run-view.tsx +199 -85
  122. package/src/components/workflow-source-panel/index.ts +1 -0
  123. package/src/components/workflow-source-panel/workflow-source-content.tsx +84 -0
  124. package/src/components/workflow-source-panel/workflow-source-panel.tsx +14 -81
  125. package/src/core/entities/step.ts +4 -0
  126. package/src/core/workflow-run-tabs.ts +8 -0
  127. package/src/pages/workflow-run-detail-page.tsx +31 -4
  128. package/src/pages/workflow-run-pages.test.tsx +185 -45
  129. package/src/routes/inputs.test.ts +47 -0
  130. package/src/routes/inputs.ts +45 -0
  131. package/src/storybook-config.test.ts +27 -0
  132. package/tsconfig.build.tsbuildinfo +1 -1
  133. package/tsconfig.test.json +7 -1
@@ -2,7 +2,13 @@ import {useNavigate} from '@tanstack/react-router';
2
2
  import {useCallback} from 'react';
3
3
  import {WorkflowRunView} from '#components/workflow-run-view/index.js';
4
4
  import type {WorkflowRunSelectionInput} from '#core/workflow-run-url-state.js';
5
- import {type WorkflowRunsSearch, workflowRunSearchParams} from '#routes/inputs.js';
5
+ import {
6
+ validateWorkflowRunsSearch,
7
+ type WorkflowRunsSearch,
8
+ type WorkflowRunTab,
9
+ workflowRunSearchParams,
10
+ workflowRunTab,
11
+ } from '#routes/inputs.js';
6
12
 
7
13
  interface WorkflowRunDetailPageProps {
8
14
  projectId: string;
@@ -24,14 +30,33 @@ export function WorkflowRunDetailPage({
24
30
  const onSelectionChange = useCallback(
25
31
  (nextSelection: WorkflowRunSelectionInput) => {
26
32
  navigate({
27
- search: workflowRunSearchParams(search, nextSelection) as never,
33
+ search: ((previous: Record<string, unknown>) => {
34
+ const current = validateWorkflowRunsSearch(previous);
35
+ return workflowRunSearchParams(current, nextSelection);
36
+ }) as never,
37
+ replace: true,
28
38
  });
29
39
  },
30
- [navigate, search],
40
+ [navigate],
41
+ );
42
+ const onTabChange = useCallback(
43
+ (nextTab: WorkflowRunTab) => {
44
+ navigate({
45
+ search: ((previous: Record<string, unknown>) => {
46
+ const current = validateWorkflowRunsSearch(previous);
47
+ const nextSearch = workflowRunSearchParams({...current, tab: nextTab}, current);
48
+ return nextTab === 'summary' &&
49
+ (current.jobId || current.jobExecutionId || current.stepId || current.stepAttemptId)
50
+ ? {...nextSearch, tab: nextTab}
51
+ : nextSearch;
52
+ }) as never,
53
+ });
54
+ },
55
+ [navigate],
31
56
  );
32
57
 
33
58
  return (
34
- <div className="flex min-h-0 flex-1 overflow-hidden bg-background-neutral-base">
59
+ <div className="flex min-h-0 flex-1 overflow-hidden bg-background-neutral-background">
35
60
  <WorkflowRunView
36
61
  projectId={projectId}
37
62
  workspaceSlug={workspaceSlug}
@@ -39,6 +64,8 @@ export function WorkflowRunDetailPage({
39
64
  workflowRunId={workflowRunId}
40
65
  selection={selection}
41
66
  onSelectionChange={onSelectionChange}
67
+ tab={workflowRunTab(search)}
68
+ onTabChange={onTabChange}
42
69
  />
43
70
  </div>
44
71
  );
@@ -40,6 +40,10 @@ const INTEGRATION_TESTS_RE = /integration-tests/u;
40
40
  const BUILD_IMAGE_RE = /build-image/u;
41
41
  const STATUS_FILTER_RE = /^Status\b.*filter$/u;
42
42
  const EXECUTION_ONE_MENU_ITEM = /Execution #1: deploy review #1/u;
43
+ const JOBS_TAB_NAME = /^Jobs/u;
44
+ const BUILD_JOB_BUTTON_NAME = 'build, Succeeded';
45
+ const DEPLOY_JOB_BUTTON_NAME = 'deploy, Running';
46
+ const LIVE_DEPLOY_JOB_BUTTON_NAME = /^deploy, Running, running /u;
43
47
  const RUN_DETAIL_PATH_RE = /^\/workflows\/runs\/([^/]+)$/u;
44
48
  const RUN_OVERRIDES = {
45
49
  id: RUN_ID,
@@ -171,25 +175,191 @@ describe('WorkflowRunPages', () => {
171
175
  test('renders the detail route without mounting the run list', async () => {
172
176
  configureApiClient({fetchImpl: createRunDetailFetch()});
173
177
 
174
- renderRunPath();
178
+ renderRunPath('?tab=jobs');
175
179
 
176
- expect(await screen.findByRole('button', {name: 'deploy, Running'})).toBeInTheDocument();
180
+ expect(await screen.findByRole('button', {name: DEPLOY_JOB_BUTTON_NAME})).toBeInTheDocument();
181
+ expect(screen.getByRole('link', {name: 'Runs'})).toHaveAttribute(
182
+ 'href',
183
+ `/w/${PROJECT_TEST_WSLUG}/p/project/runs`,
184
+ );
177
185
  expect(screen.queryByLabelText('Workflow runs')).not.toBeInTheDocument();
178
186
  });
179
187
 
188
+ test('back to runs preserves list filters and drops run detail state', async () => {
189
+ configureApiClient({fetchImpl: createRunDetailFetch()});
190
+
191
+ renderRunPath(`?search=deploy-web&status=running&tab=jobs&job=${DEPLOY_JOB_ID}`);
192
+
193
+ expect(await screen.findByRole('link', {name: 'Runs'})).toHaveAttribute(
194
+ 'href',
195
+ `/w/${PROJECT_TEST_WSLUG}/p/project/runs?search=deploy-web&status=running`,
196
+ );
197
+ });
198
+
199
+ test('defaults the detail route to the Summary tab', async () => {
200
+ configureApiClient({fetchImpl: createRunDetailFetch()});
201
+
202
+ const {router} = renderRunPath();
203
+
204
+ expect(await screen.findByRole('region', {name: 'deploy-web'})).toBeInTheDocument();
205
+ const summaryTab = screen.getByRole('tab', {name: 'Summary'});
206
+ expect(summaryTab).toHaveAttribute('aria-selected', 'true');
207
+ expect(summaryTab).toHaveAttribute('aria-controls', screen.getByRole('tabpanel').id);
208
+ expect(screen.queryByRole('list', {name: 'Run jobs'})).not.toBeInTheDocument();
209
+ expect(currentSearch(router).tab).toBeUndefined();
210
+ });
211
+
212
+ test('opens legacy selection-only detail URLs on the Jobs tab', async () => {
213
+ configureApiClient({fetchImpl: createRunDetailFetch()});
214
+
215
+ renderRunPath(`?job=${DEPLOY_JOB_ID}`);
216
+
217
+ expect(await screen.findByRole('list', {name: 'Run jobs'})).toBeInTheDocument();
218
+ expect(screen.getByRole('tab', {name: JOBS_TAB_NAME})).toHaveAttribute('aria-selected', 'true');
219
+ });
220
+
221
+ test('opens a legacy job execution URL with its owning job selected', async () => {
222
+ configureApiClient({
223
+ fetchImpl: createRunDetailFetch({details: {[RUN_ID]: retryRunDetailDto()}}),
224
+ });
225
+
226
+ renderRunPath(`?jobExecution=${DEPLOY_EXECUTION_TWO_ID}`);
227
+
228
+ expect(await screen.findByRole('region', {name: 'deploy review #2'})).toBeInTheDocument();
229
+ });
230
+
231
+ test('uses the derived execution status in a live job duration label', async () => {
232
+ configureApiClient({
233
+ fetchImpl: createRunDetailFetch({
234
+ details: {
235
+ [RUN_ID]: defaultRunDetailDto({
236
+ jobs: [
237
+ workflowJobDto({
238
+ id: DEPLOY_JOB_ID,
239
+ run_attempt_id: RUN_ID,
240
+ name: 'deploy',
241
+ status: 'running',
242
+ job_executions: [
243
+ workflowJobExecutionDto({
244
+ job_id: DEPLOY_JOB_ID,
245
+ status: 'pending',
246
+ started_at: '2026-08-04T00:00:00.000Z',
247
+ steps: [workflowStepDto({status: 'running'})],
248
+ }),
249
+ ],
250
+ }),
251
+ ],
252
+ }),
253
+ },
254
+ }),
255
+ });
256
+
257
+ renderRunPath(`?tab=jobs&job=${DEPLOY_JOB_ID}`);
258
+
259
+ expect(
260
+ await screen.findByRole('button', {name: LIVE_DEPLOY_JOB_BUTTON_NAME}),
261
+ ).toBeInTheDocument();
262
+ });
263
+
264
+ test('pushes tab changes and restores them with browser history', async () => {
265
+ const user = userEvent.setup();
266
+ configureApiClient({fetchImpl: createRunDetailFetch()});
267
+
268
+ const {router} = renderRunPath();
269
+ const initialHistoryLength = router.history.length;
270
+
271
+ await user.click(await screen.findByRole('tab', {name: JOBS_TAB_NAME}));
272
+
273
+ await waitFor(() => expect(currentSearch(router).tab).toBe('jobs'));
274
+ expect(router.history.length).toBe(initialHistoryLength + 1);
275
+ expect(screen.getByRole('tab', {name: JOBS_TAB_NAME})).toHaveAttribute('aria-selected', 'true');
276
+ expect(screen.getByRole('list', {name: 'Run jobs'})).toBeInTheDocument();
277
+
278
+ await user.click(screen.getByRole('tab', {name: 'Summary'}));
279
+
280
+ await waitFor(() => expect(currentSearch(router).tab).toBeUndefined());
281
+ expect(router.history.length).toBe(initialHistoryLength + 2);
282
+
283
+ await act(() => {
284
+ router.history.back();
285
+ });
286
+ await waitFor(() => expect(currentSearch(router).tab).toBe('jobs'));
287
+ expect(screen.getByRole('list', {name: 'Run jobs'})).toBeInTheDocument();
288
+ });
289
+
290
+ test('opens the Jobs tab and selects a job from the Summary graph', async () => {
291
+ const user = userEvent.setup();
292
+ configureApiClient({fetchImpl: createRunDetailFetch()});
293
+
294
+ const {router} = renderRunPath();
295
+ const initialHistoryLength = router.history.length;
296
+
297
+ await user.click(await screen.findByRole('button', {name: 'deploy, Running'}));
298
+
299
+ await waitFor(() => {
300
+ expect(currentSearch(router)).toMatchObject({tab: 'jobs', job: DEPLOY_JOB_ID});
301
+ });
302
+ expect(router.history.length).toBe(initialHistoryLength + 1);
303
+ expect(screen.getByRole('button', {name: 'deploy, Running'})).toHaveAttribute(
304
+ 'aria-expanded',
305
+ 'true',
306
+ );
307
+ expect(screen.getByRole('button', {name: 'deploy, Running'})).toHaveAttribute(
308
+ 'aria-controls',
309
+ `job-card-${DEPLOY_JOB_ID}`,
310
+ );
311
+ expect(screen.getByRole('button', {name: BUILD_JOB_BUTTON_NAME})).not.toHaveAttribute(
312
+ 'aria-controls',
313
+ );
314
+ });
315
+
316
+ test('preserves an explicit Summary tab with a selected job', async () => {
317
+ const user = userEvent.setup();
318
+ configureApiClient({fetchImpl: createRunDetailFetch()});
319
+
320
+ const {router} = renderRunPath(`?tab=jobs&job=${DEPLOY_JOB_ID}`);
321
+
322
+ await screen.findByRole('button', {name: DEPLOY_JOB_BUTTON_NAME});
323
+ await user.click(screen.getByRole('tab', {name: 'Summary'}));
324
+
325
+ await waitFor(() => {
326
+ expect(currentSearch(router)).toMatchObject({tab: 'summary', job: DEPLOY_JOB_ID});
327
+ });
328
+ expect(screen.getByRole('tab', {name: 'Summary'})).toHaveAttribute('aria-selected', 'true');
329
+ expect(screen.queryByRole('list', {name: 'Run jobs'})).not.toBeInTheDocument();
330
+ });
331
+
332
+ test('keeps Summary active while keyboard navigation moves graph focus', async () => {
333
+ const user = userEvent.setup();
334
+ configureApiClient({fetchImpl: createRunDetailFetch()});
335
+
336
+ const {router} = renderRunPath();
337
+ const build = await screen.findByRole('button', {name: BUILD_JOB_BUTTON_NAME});
338
+ const deploy = screen.getByRole('button', {name: DEPLOY_JOB_BUTTON_NAME});
339
+
340
+ build.focus();
341
+ await user.keyboard('{ArrowRight}');
342
+
343
+ expect(deploy).toHaveFocus();
344
+ expect(deploy).toHaveAttribute('aria-pressed', 'true');
345
+ expect(screen.getByRole('tab', {name: 'Summary'})).toHaveAttribute('aria-selected', 'true');
346
+ expect(screen.queryByRole('list', {name: 'Run jobs'})).not.toBeInTheDocument();
347
+ expect(currentSearch(router).job).toBeUndefined();
348
+ });
349
+
180
350
  test('restores a deep-linked job and exact attempt after data loads', async () => {
181
351
  configureApiClient({fetchImpl: createRunDetailFetch()});
182
352
 
183
353
  renderRunPath(
184
- `?job=${BUILD_JOB_ID}&step=${DEPLOY_STEP_ID}&stepAttempt=${DEPLOY_ATTEMPT_TWO_ID}`,
354
+ `?tab=jobs&job=${BUILD_JOB_ID}&step=${DEPLOY_STEP_ID}&stepAttempt=${DEPLOY_ATTEMPT_TWO_ID}`,
185
355
  );
186
356
 
187
- const deployJob = await screen.findByRole('button', {name: 'deploy, Running'});
357
+ const deployJob = await screen.findByRole('button', {name: DEPLOY_JOB_BUTTON_NAME});
188
358
  const deployAttempt = await screen.findByRole('button', {
189
359
  name: 'deploy, Running, attempt 2',
190
360
  });
191
361
 
192
- expect(deployJob).toHaveAttribute('aria-pressed', 'true');
362
+ expect(deployJob).toHaveAttribute('aria-expanded', 'true');
193
363
  expect(deployAttempt).toHaveAttribute('aria-expanded', 'true');
194
364
  expect(await screen.findByText('attempt two log')).toBeInTheDocument();
195
365
  });
@@ -198,14 +368,17 @@ describe('WorkflowRunPages', () => {
198
368
  const user = userEvent.setup();
199
369
  configureApiClient({fetchImpl: createRunDetailFetch()});
200
370
  const {router} = renderRunPath(
201
- `?step=${DEPLOY_STEP_ID}&stepAttempt=${DEPLOY_ATTEMPT_TWO_ID}&runAttempt=1`,
371
+ `?tab=jobs&step=${DEPLOY_STEP_ID}&stepAttempt=${DEPLOY_ATTEMPT_TWO_ID}&runAttempt=1`,
202
372
  );
373
+ const initialHistoryLength = router.history.length;
203
374
 
204
- await user.click(await screen.findByRole('button', {name: 'build, Succeeded'}));
375
+ await user.click(await screen.findByRole('button', {name: BUILD_JOB_BUTTON_NAME}));
205
376
 
206
377
  await waitFor(() => {
207
378
  expect(currentSearch(router)).toMatchObject({job: BUILD_JOB_ID});
208
379
  });
380
+ expect(currentSearch(router).tab).toBe('jobs');
381
+ expect(router.history.length).toBe(initialHistoryLength);
209
382
  expect(currentSearch(router).step).toBeUndefined();
210
383
  expect(currentSearch(router).stepAttempt).toBeUndefined();
211
384
  expect(screen.getByRole('button', {name: 'checkout, Succeeded, attempt 1'})).toHaveAttribute(
@@ -217,7 +390,7 @@ describe('WorkflowRunPages', () => {
217
390
  test('selecting an attempt writes job, step, and attempt search state', async () => {
218
391
  const user = userEvent.setup();
219
392
  configureApiClient({fetchImpl: createRunDetailFetch()});
220
- const {router} = renderRunPath(`?job=${DEPLOY_JOB_ID}`);
393
+ const {router} = renderRunPath(`?tab=jobs&job=${DEPLOY_JOB_ID}`);
221
394
 
222
395
  await user.click(await screen.findByRole('button', {name: 'deploy, Running, attempt 2'}));
223
396
 
@@ -235,7 +408,7 @@ describe('WorkflowRunPages', () => {
235
408
  configureApiClient({
236
409
  fetchImpl: createRunDetailFetch({details: {[RUN_ID]: retryRunDetailDto()}}),
237
410
  });
238
- const {router} = renderRunPath(`?job=${DEPLOY_JOB_ID}`);
411
+ const {router} = renderRunPath(`?tab=jobs&job=${DEPLOY_JOB_ID}`);
239
412
 
240
413
  await user.click(
241
414
  await screen.findByRole('button', {
@@ -273,7 +446,9 @@ describe('WorkflowRunPages', () => {
273
446
  test('collapsing an attempt removes step and attempt while preserving job', async () => {
274
447
  const user = userEvent.setup();
275
448
  configureApiClient({fetchImpl: createRunDetailFetch()});
276
- const {router} = renderRunPath(`?step=${DEPLOY_STEP_ID}&stepAttempt=${DEPLOY_ATTEMPT_TWO_ID}`);
449
+ const {router} = renderRunPath(
450
+ `?tab=jobs&step=${DEPLOY_STEP_ID}&stepAttempt=${DEPLOY_ATTEMPT_TWO_ID}`,
451
+ );
277
452
 
278
453
  const deployAttempt = await screen.findByRole('button', {
279
454
  name: 'deploy, Running, attempt 2',
@@ -288,41 +463,6 @@ describe('WorkflowRunPages', () => {
288
463
  expect(deployAttempt).toHaveAttribute('aria-expanded', 'false');
289
464
  });
290
465
 
291
- test('back and forward navigation restores prior selections', async () => {
292
- const user = userEvent.setup();
293
- configureApiClient({fetchImpl: createRunDetailFetch()});
294
- const {router} = renderRunPath(`?job=${DEPLOY_JOB_ID}`);
295
-
296
- await user.click(await screen.findByRole('button', {name: 'deploy, Running, attempt 2'}));
297
- await waitFor(() => {
298
- expect(currentSearch(router).stepAttempt).toBe(DEPLOY_ATTEMPT_TWO_ID);
299
- });
300
-
301
- await act(() => {
302
- router.history.back();
303
- });
304
- await waitFor(() => {
305
- expect(currentSearch(router)).toMatchObject({job: DEPLOY_JOB_ID});
306
- });
307
- expect(currentSearch(router).step).toBeUndefined();
308
- expect(currentSearch(router).stepAttempt).toBeUndefined();
309
- expect(screen.getByRole('button', {name: 'deploy, Running, attempt 2'})).toHaveAttribute(
310
- 'aria-expanded',
311
- 'false',
312
- );
313
-
314
- await act(() => {
315
- router.history.forward();
316
- });
317
- await waitFor(() => {
318
- expect(currentSearch(router).stepAttempt).toBe(DEPLOY_ATTEMPT_TWO_ID);
319
- });
320
- expect(screen.getByRole('button', {name: 'deploy, Running, attempt 2'})).toHaveAttribute(
321
- 'aria-expanded',
322
- 'true',
323
- );
324
- });
325
-
326
466
  test('run list links navigate to detail and back restores list filters', async () => {
327
467
  const user = userEvent.setup();
328
468
  configureApiClient({
@@ -6,7 +6,9 @@ import {
6
6
  validateWorkflowRunsSearch,
7
7
  type WorkflowRunsSearch,
8
8
  workflowRouteParams,
9
+ workflowRunListSearchParams,
9
10
  workflowRunSearchParams,
11
+ workflowRunTab,
10
12
  } from './inputs.js';
11
13
 
12
14
  /** Everything a filter change writes, then reads back, exactly as the router would. */
@@ -21,6 +23,8 @@ describe('validateWorkflowRunsSearch', () => {
21
23
  validateWorkflowRunsSearch({
22
24
  search: ['unexpected'],
23
25
  status: 'unknown',
26
+ tab: 'unknown',
27
+ severity: 'critical',
24
28
  after: 'yesterday',
25
29
  before: '2026-13-01',
26
30
  }),
@@ -65,6 +69,28 @@ describe('validateWorkflowRunsSearch', () => {
65
69
  jobId: 'job-1',
66
70
  });
67
71
  });
72
+
73
+ test('accepts known tabs and annotation severities while dropping unknown values', () => {
74
+ expect(
75
+ validateWorkflowRunsSearch({tab: 'annotations', annotation: 'run-1', severity: 'error'}),
76
+ ).toEqual({tab: 'annotations', annotation: 'run-1', severity: 'error'});
77
+ expect(validateWorkflowRunsSearch({tab: 'unknown', severity: 'critical'})).toEqual({});
78
+ expect(workflowRunTab({})).toBe('summary');
79
+ expect(workflowRunTab({tab: 'source'})).toBe('source');
80
+ });
81
+
82
+ test.each([
83
+ ['job', {jobId: 'job-1'}],
84
+ ['job execution', {jobExecutionId: 'execution-1'}],
85
+ ['step', {stepId: 'step-1'}],
86
+ ['step attempt', {stepAttemptId: 'attempt-1'}],
87
+ ])('defaults a legacy %s selection URL to Jobs', (_selection, search) => {
88
+ expect(workflowRunTab(search)).toBe('jobs');
89
+ });
90
+
91
+ test('keeps an explicit Summary tab ahead of legacy selection inference', () => {
92
+ expect(workflowRunTab({tab: 'summary', jobId: 'job-1'})).toBe('summary');
93
+ });
68
94
  });
69
95
 
70
96
  describe('the run list URL contract', () => {
@@ -88,6 +114,9 @@ describe('the run list URL contract', () => {
88
114
  event: ['push'],
89
115
  after: '2026-05-01',
90
116
  before: '2026-05-31',
117
+ tab: 'annotations',
118
+ annotation: 'annotation-1',
119
+ severity: 'warning',
91
120
  };
92
121
 
93
122
  expect(roundTrip(search)).toEqual(search);
@@ -119,6 +148,24 @@ describe('the run list URL contract', () => {
119
148
  expect(stringifyAppSearch(workflowRunSearchParams({}, {}))).toBe('');
120
149
  });
121
150
 
151
+ test('omits the default Summary tab from the URL', () => {
152
+ expect(stringifyAppSearch(workflowRunSearchParams({tab: 'summary'}, {}))).toBe('');
153
+ expect(stringifyAppSearch(workflowRunSearchParams({tab: 'jobs'}, {}))).toBe('?tab=jobs');
154
+ });
155
+
156
+ test('serializes only list filters for the detail back link', () => {
157
+ expect(
158
+ workflowRunListSearchParams({
159
+ search: 'deploy',
160
+ status: ['running'],
161
+ tab: 'jobs',
162
+ annotation: 'annotation-1',
163
+ severity: 'error',
164
+ jobId: 'job-1',
165
+ }),
166
+ ).toEqual({search: 'deploy', status: ['running']});
167
+ });
168
+
122
169
  test('has no page or cursor parameter to carry', () => {
123
170
  const query = stringifyAppSearch(workflowRunSearchParams({search: 'deploy'}, {}));
124
171
 
@@ -8,6 +8,14 @@ export const WORKFLOW_RUN_LIST_STATUSES = ['succeeded', 'failed', 'running', 'ca
8
8
 
9
9
  export type WorkflowRunListStatus = (typeof WORKFLOW_RUN_LIST_STATUSES)[number];
10
10
 
11
+ export const WORKFLOW_RUN_TABS = ['summary', 'jobs', 'annotations', 'source'] as const;
12
+
13
+ export type WorkflowRunTab = (typeof WORKFLOW_RUN_TABS)[number];
14
+
15
+ export const WORKFLOW_RUN_ANNOTATION_SEVERITIES = ['error', 'warning', 'info', 'success'] as const;
16
+
17
+ export type WorkflowRunAnnotationSeverity = (typeof WORKFLOW_RUN_ANNOTATION_SEVERITIES)[number];
18
+
11
19
  export interface WorkflowRunsSearch extends WorkflowRunSelectionInput {
12
20
  search?: string;
13
21
  status?: WorkflowRunListStatus[];
@@ -18,9 +26,14 @@ export interface WorkflowRunsSearch extends WorkflowRunSelectionInput {
18
26
  after?: string;
19
27
  /** Inclusive upper bound on the run's local calendar date, `YYYY-MM-DD`. */
20
28
  before?: string;
29
+ tab?: WorkflowRunTab;
30
+ annotation?: string;
31
+ severity?: WorkflowRunAnnotationSeverity;
21
32
  }
22
33
 
23
34
  const STATUS_VALUES = new Set<string>(WORKFLOW_RUN_LIST_STATUSES);
35
+ const TAB_VALUES = new Set<string>(WORKFLOW_RUN_TABS);
36
+ const ANNOTATION_SEVERITY_VALUES = new Set<string>(WORKFLOW_RUN_ANNOTATION_SEVERITIES);
24
37
  const CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/u;
25
38
 
26
39
  /**
@@ -39,6 +52,14 @@ export function validateWorkflowRunsSearch(input: Record<string, unknown>): Work
39
52
  const after = calendarDate(input.after);
40
53
  const before = calendarDate(input.before);
41
54
  const runAttempt = positiveInteger(input.runAttempt);
55
+ const tabValue = string(input.tab);
56
+ const tab = tabValue && TAB_VALUES.has(tabValue) ? (tabValue as WorkflowRunTab) : undefined;
57
+ const annotation = string(input.annotation);
58
+ const severityValue = string(input.severity);
59
+ const severity =
60
+ severityValue && ANNOTATION_SEVERITY_VALUES.has(severityValue)
61
+ ? (severityValue as WorkflowRunAnnotationSeverity)
62
+ : undefined;
42
63
 
43
64
  return {
44
65
  ...(search ? {search} : {}),
@@ -48,6 +69,9 @@ export function validateWorkflowRunsSearch(input: Record<string, unknown>): Work
48
69
  ...(event.length > 0 ? {event} : {}),
49
70
  ...(after ? {after} : {}),
50
71
  ...(before ? {before} : {}),
72
+ ...(tab ? {tab} : {}),
73
+ ...(annotation ? {annotation} : {}),
74
+ ...(severity ? {severity} : {}),
51
75
  ...(string(input.job) ? {jobId: string(input.job)} : {}),
52
76
  ...(string(input.jobExecution) ? {jobExecutionId: string(input.jobExecution)} : {}),
53
77
  ...(string(input.step) ? {stepId: string(input.step)} : {}),
@@ -74,6 +98,9 @@ export function workflowRunSearchParams(
74
98
  ...(search.event?.length ? {event: search.event} : {}),
75
99
  ...(search.after ? {after: search.after} : {}),
76
100
  ...(search.before ? {before: search.before} : {}),
101
+ ...(search.tab && search.tab !== 'summary' ? {tab: search.tab} : {}),
102
+ ...(search.annotation ? {annotation: search.annotation} : {}),
103
+ ...(search.severity ? {severity: search.severity} : {}),
77
104
  ...(selection.jobId ? {job: selection.jobId} : {}),
78
105
  ...(selection.jobExecutionId ? {jobExecution: selection.jobExecutionId} : {}),
79
106
  ...(selection.stepId ? {step: selection.stepId} : {}),
@@ -82,6 +109,24 @@ export function workflowRunSearchParams(
82
109
  };
83
110
  }
84
111
 
112
+ /**
113
+ * Resolves the detail surface for a URL. Explicit tabs win; legacy selection-only URLs belong
114
+ * to Jobs so their selected detail remains visible instead of silently landing on Summary.
115
+ */
116
+ export function workflowRunTab(
117
+ search: Pick<WorkflowRunsSearch, 'tab' | 'jobId' | 'jobExecutionId' | 'stepId' | 'stepAttemptId'>,
118
+ ): WorkflowRunTab {
119
+ if (search.tab) return search.tab;
120
+ if (search.jobId || search.jobExecutionId || search.stepId || search.stepAttemptId) return 'jobs';
121
+ return 'summary';
122
+ }
123
+
124
+ /** Serializes only list filters when leaving a run detail page for the run list. */
125
+ export function workflowRunListSearchParams(search: WorkflowRunsSearch) {
126
+ const {tab: _tab, annotation: _annotation, severity: _severity, ...listSearch} = search;
127
+ return workflowRunSearchParams(listSearch, {});
128
+ }
129
+
85
130
  /**
86
131
  * A filter change. Each member is explicitly optional-or-undefined so that "present and
87
132
  * undefined" (clear this filter) stays distinguishable from "absent" (leave it alone) under
@@ -0,0 +1,27 @@
1
+ import {describe, expect, it} from '@shipfox/vitest/vi';
2
+ import {withWorkspaceSource} from '../.storybook/workspace-source';
3
+
4
+ describe('client-workflows Storybook Vite configuration', () => {
5
+ it('keeps workspace-source active for client and SSR package resolution', () => {
6
+ const config = withWorkspaceSource({
7
+ resolve: {conditions: ['browser']},
8
+ ssr: {resolve: {conditions: ['node']}},
9
+ });
10
+
11
+ expect(config.resolve?.conditions).toEqual(['browser', 'workspace-source']);
12
+ expect(config.ssr?.resolve?.conditions).toEqual(['node', 'workspace-source']);
13
+ expect(config.plugins?.map((plugin) => plugin && 'name' in plugin && plugin.name)).toContain(
14
+ 'shipfox:workspace-source-resolver',
15
+ );
16
+ });
17
+
18
+ it('does not duplicate an existing workspace-source condition', () => {
19
+ const config = withWorkspaceSource({
20
+ resolve: {conditions: ['browser', 'workspace-source']},
21
+ ssr: {resolve: {conditions: ['node', 'workspace-source']}},
22
+ });
23
+
24
+ expect(config.resolve?.conditions).toEqual(['browser', 'workspace-source']);
25
+ expect(config.ssr?.resolve?.conditions).toEqual(['node', 'workspace-source']);
26
+ });
27
+ });