@shipfox/client-workflows 12.0.1 → 13.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 (232) hide show
  1. package/.storybook/preview.tsx +3 -3
  2. package/.turbo/turbo-build.log +1 -1
  3. package/CHANGELOG.md +65 -0
  4. package/dist/components/job-graph/job-duration-format.d.ts +2 -2
  5. package/dist/components/job-graph/job-duration-format.d.ts.map +1 -1
  6. package/dist/components/job-graph/job-duration-format.js +4 -2
  7. package/dist/components/job-graph/job-duration-format.js.map +1 -1
  8. package/dist/components/job-graph/job-node.d.ts +1 -1
  9. package/dist/components/job-graph/job-node.d.ts.map +1 -1
  10. package/dist/components/job-graph/job-node.js +5 -4
  11. package/dist/components/job-graph/job-node.js.map +1 -1
  12. package/dist/components/step-list/step-list.d.ts +2 -2
  13. package/dist/components/step-list/step-list.d.ts.map +1 -1
  14. package/dist/components/step-list/step-list.js.map +1 -1
  15. package/dist/components/workflow-run-list/job-status-strip.d.ts +21 -0
  16. package/dist/components/workflow-run-list/job-status-strip.d.ts.map +1 -0
  17. package/dist/components/workflow-run-list/job-status-strip.js +185 -0
  18. package/dist/components/workflow-run-list/job-status-strip.js.map +1 -0
  19. package/dist/components/workflow-run-list/run-display.d.ts +27 -3
  20. package/dist/components/workflow-run-list/run-display.d.ts.map +1 -1
  21. package/dist/components/workflow-run-list/run-display.js +78 -7
  22. package/dist/components/workflow-run-list/run-display.js.map +1 -1
  23. package/dist/components/workflow-run-list/types.d.ts +23 -24
  24. package/dist/components/workflow-run-list/types.d.ts.map +1 -1
  25. package/dist/components/workflow-run-list/types.js.map +1 -1
  26. package/dist/components/workflow-run-list/workflow-run-filter-menu.d.ts +22 -0
  27. package/dist/components/workflow-run-list/workflow-run-filter-menu.d.ts.map +1 -0
  28. package/dist/components/workflow-run-list/workflow-run-filter-menu.js +68 -0
  29. package/dist/components/workflow-run-list/workflow-run-filter-menu.js.map +1 -0
  30. package/dist/components/workflow-run-list/workflow-run-filters.d.ts +18 -0
  31. package/dist/components/workflow-run-list/workflow-run-filters.d.ts.map +1 -0
  32. package/dist/components/workflow-run-list/workflow-run-filters.js +236 -0
  33. package/dist/components/workflow-run-list/workflow-run-filters.js.map +1 -0
  34. package/dist/components/workflow-run-list/workflow-run-list-content.d.ts +8 -4
  35. package/dist/components/workflow-run-list/workflow-run-list-content.d.ts.map +1 -1
  36. package/dist/components/workflow-run-list/workflow-run-list-content.js +40 -12
  37. package/dist/components/workflow-run-list/workflow-run-list-content.js.map +1 -1
  38. package/dist/components/workflow-run-list/workflow-run-list-states.d.ts +29 -2
  39. package/dist/components/workflow-run-list/workflow-run-list-states.d.ts.map +1 -1
  40. package/dist/components/workflow-run-list/workflow-run-list-states.js +103 -44
  41. package/dist/components/workflow-run-list/workflow-run-list-states.js.map +1 -1
  42. package/dist/components/workflow-run-list/workflow-run-list-view.d.ts +1 -1
  43. package/dist/components/workflow-run-list/workflow-run-list-view.d.ts.map +1 -1
  44. package/dist/components/workflow-run-list/workflow-run-list-view.js +60 -43
  45. package/dist/components/workflow-run-list/workflow-run-list-view.js.map +1 -1
  46. package/dist/components/workflow-run-list/workflow-run-list.d.ts +1 -1
  47. package/dist/components/workflow-run-list/workflow-run-list.d.ts.map +1 -1
  48. package/dist/components/workflow-run-list/workflow-run-list.js +20 -8
  49. package/dist/components/workflow-run-list/workflow-run-list.js.map +1 -1
  50. package/dist/components/workflow-run-list/workflow-run-row.d.ts +19 -9
  51. package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
  52. package/dist/components/workflow-run-list/workflow-run-row.js +179 -112
  53. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  54. package/dist/components/workflow-run-number-label.d.ts +15 -0
  55. package/dist/components/workflow-run-number-label.d.ts.map +1 -0
  56. package/dist/components/workflow-run-number-label.js +41 -0
  57. package/dist/components/workflow-run-number-label.js.map +1 -0
  58. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.d.ts +3 -3
  59. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.d.ts.map +1 -1
  60. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js +8 -7
  61. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js.map +1 -1
  62. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts +3 -3
  63. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  64. package/dist/components/workflow-run-summary/workflow-run-summary.js +16 -7
  65. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  66. package/dist/components/workflow-run-view/agent-config-failure-callout.d.ts +2 -2
  67. package/dist/components/workflow-run-view/agent-config-failure-callout.d.ts.map +1 -1
  68. package/dist/components/workflow-run-view/agent-config-failure-callout.js +4 -4
  69. package/dist/components/workflow-run-view/agent-config-failure-callout.js.map +1 -1
  70. package/dist/components/workflow-run-view/job-card.d.ts +2 -2
  71. package/dist/components/workflow-run-view/job-card.d.ts.map +1 -1
  72. package/dist/components/workflow-run-view/job-card.js +51 -32
  73. package/dist/components/workflow-run-view/job-card.js.map +1 -1
  74. package/dist/components/workflow-run-view/job-execution-switcher.d.ts +1 -1
  75. package/dist/components/workflow-run-view/job-execution-switcher.d.ts.map +1 -1
  76. package/dist/components/workflow-run-view/job-execution-switcher.js +20 -8
  77. package/dist/components/workflow-run-view/job-execution-switcher.js.map +1 -1
  78. package/dist/components/workflow-run-view/workflow-run-view.d.ts +3 -2
  79. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  80. package/dist/components/workflow-run-view/workflow-run-view.js +15 -11
  81. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  82. package/dist/components/workflow-status/status-visuals.d.ts +4 -4
  83. package/dist/components/workflow-status/status-visuals.d.ts.map +1 -1
  84. package/dist/components/workflow-status/status-visuals.js +7 -0
  85. package/dist/components/workflow-status/status-visuals.js.map +1 -1
  86. package/dist/components/workflow-status/workflow-status-icon.d.ts +3 -4
  87. package/dist/components/workflow-status/workflow-status-icon.d.ts.map +1 -1
  88. package/dist/components/workflow-status/workflow-status-icon.js +6 -4
  89. package/dist/components/workflow-status/workflow-status-icon.js.map +1 -1
  90. package/dist/core/entities/job-execution.d.ts +10 -0
  91. package/dist/core/entities/job-execution.d.ts.map +1 -1
  92. package/dist/core/entities/job-execution.js +11 -0
  93. package/dist/core/entities/job-execution.js.map +1 -1
  94. package/dist/core/entities/job.d.ts +4 -2
  95. package/dist/core/entities/job.d.ts.map +1 -1
  96. package/dist/core/entities/job.js +8 -1
  97. package/dist/core/entities/job.js.map +1 -1
  98. package/dist/core/entities/workflow-run.d.ts +52 -4
  99. package/dist/core/entities/workflow-run.d.ts.map +1 -1
  100. package/dist/core/entities/workflow-run.js +22 -3
  101. package/dist/core/entities/workflow-run.js.map +1 -1
  102. package/dist/core/workflow-run.d.ts +6 -6
  103. package/dist/core/workflow-run.d.ts.map +1 -1
  104. package/dist/core/workflow-run.js +3 -3
  105. package/dist/core/workflow-run.js.map +1 -1
  106. package/dist/feature.d.ts +7 -7
  107. package/dist/feature.js +5 -5
  108. package/dist/feature.js.map +1 -1
  109. package/dist/hooks/api/workflow-run-mapper.d.ts +4 -3
  110. package/dist/hooks/api/workflow-run-mapper.d.ts.map +1 -1
  111. package/dist/hooks/api/workflow-run-mapper.js +31 -3
  112. package/dist/hooks/api/workflow-run-mapper.js.map +1 -1
  113. package/dist/hooks/api/workflow-runs.d.ts +4 -4
  114. package/dist/hooks/api/workflow-runs.d.ts.map +1 -1
  115. package/dist/hooks/api/workflow-runs.js +12 -4
  116. package/dist/hooks/api/workflow-runs.js.map +1 -1
  117. package/dist/index.d.ts +4 -3
  118. package/dist/index.d.ts.map +1 -1
  119. package/dist/index.js +3 -2
  120. package/dist/index.js.map +1 -1
  121. package/dist/pages/project-workflows-page.js +46 -1
  122. package/dist/pages/project-workflows-page.js.map +1 -1
  123. package/dist/pages/workflow-run-detail-page.d.ts +11 -0
  124. package/dist/pages/workflow-run-detail-page.d.ts.map +1 -0
  125. package/dist/pages/workflow-run-detail-page.js +30 -0
  126. package/dist/pages/workflow-run-detail-page.js.map +1 -0
  127. package/dist/pages/workflow-run-list-page.d.ts +10 -0
  128. package/dist/pages/workflow-run-list-page.d.ts.map +1 -0
  129. package/dist/pages/workflow-run-list-page.js +43 -0
  130. package/dist/pages/workflow-run-list-page.js.map +1 -0
  131. package/dist/routes/inputs.d.ts +61 -5
  132. package/dist/routes/inputs.d.ts.map +1 -1
  133. package/dist/routes/inputs.js +150 -13
  134. package/dist/routes/inputs.js.map +1 -1
  135. package/dist/routes/project-route.d.ts +6 -0
  136. package/dist/routes/project-route.d.ts.map +1 -0
  137. package/dist/routes/project-route.js +25 -0
  138. package/dist/routes/project-route.js.map +1 -0
  139. package/dist/routes/run-detail.d.ts.map +1 -1
  140. package/dist/routes/run-detail.js +12 -7
  141. package/dist/routes/run-detail.js.map +1 -1
  142. package/dist/routes/runs.d.ts.map +1 -1
  143. package/dist/routes/runs.js +11 -6
  144. package/dist/routes/runs.js.map +1 -1
  145. package/dist/routes/workflows.d.ts.map +1 -1
  146. package/dist/routes/workflows.js +6 -5
  147. package/dist/routes/workflows.js.map +1 -1
  148. package/dist/tsconfig.test.tsbuildinfo +1 -1
  149. package/package.json +9 -9
  150. package/src/components/job-graph/job-duration-format.ts +11 -2
  151. package/src/components/job-graph/job-graph-view.test.tsx +19 -8
  152. package/src/components/job-graph/job-graph.stories.tsx +23 -19
  153. package/src/components/job-graph/job-node.stories.tsx +10 -5
  154. package/src/components/job-graph/job-node.test.tsx +55 -10
  155. package/src/components/job-graph/job-node.tsx +16 -9
  156. package/src/components/step-list/step-list.stories.tsx +2 -3
  157. package/src/components/step-list/step-list.tsx +3 -2
  158. package/src/components/workflow-run-list/job-status-strip.tsx +189 -0
  159. package/src/components/workflow-run-list/run-display.test.ts +169 -30
  160. package/src/components/workflow-run-list/run-display.ts +116 -9
  161. package/src/components/workflow-run-list/types.ts +21 -17
  162. package/src/components/workflow-run-list/workflow-run-filter-menu.tsx +100 -0
  163. package/src/components/workflow-run-list/workflow-run-filters.tsx +243 -0
  164. package/src/components/workflow-run-list/workflow-run-list-content.tsx +44 -16
  165. package/src/components/workflow-run-list/workflow-run-list-states.tsx +128 -27
  166. package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +441 -104
  167. package/src/components/workflow-run-list/workflow-run-list-view.tsx +73 -43
  168. package/src/components/workflow-run-list/workflow-run-list.stories.tsx +140 -20
  169. package/src/components/workflow-run-list/workflow-run-list.tsx +20 -10
  170. package/src/components/workflow-run-list/workflow-run-row.tsx +173 -106
  171. package/src/components/workflow-run-number-label.test.tsx +45 -0
  172. package/src/components/workflow-run-number-label.tsx +47 -0
  173. package/src/components/workflow-run-summary/workflow-run-attempt-switcher.test.tsx +7 -8
  174. package/src/components/workflow-run-summary/workflow-run-attempt-switcher.tsx +13 -12
  175. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +6 -8
  176. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +27 -2
  177. package/src/components/workflow-run-summary/workflow-run-summary.tsx +19 -9
  178. package/src/components/workflow-run-view/agent-config-failure-callout.stories.tsx +2 -3
  179. package/src/components/workflow-run-view/agent-config-failure-callout.tsx +4 -4
  180. package/src/components/workflow-run-view/job-card.stories.tsx +11 -10
  181. package/src/components/workflow-run-view/job-card.test.tsx +115 -0
  182. package/src/components/workflow-run-view/job-card.tsx +74 -35
  183. package/src/components/workflow-run-view/job-execution-switcher.tsx +31 -9
  184. package/src/components/workflow-run-view/workflow-run-view.test.tsx +88 -30
  185. package/src/components/workflow-run-view/workflow-run-view.tsx +24 -14
  186. package/src/components/workflow-status/status-visuals.test.ts +9 -0
  187. package/src/components/workflow-status/status-visuals.ts +6 -4
  188. package/src/components/workflow-status/workflow-status-icon.stories.tsx +10 -2
  189. package/src/components/workflow-status/workflow-status-icon.test.tsx +18 -0
  190. package/src/components/workflow-status/workflow-status-icon.tsx +17 -24
  191. package/src/core/entities/job-execution.ts +22 -0
  192. package/src/core/entities/job-status.test.ts +104 -0
  193. package/src/core/entities/job.ts +18 -3
  194. package/src/core/entities/workflow-run.ts +76 -7
  195. package/src/core/workflow-run.test.ts +24 -9
  196. package/src/core/workflow-run.ts +17 -2
  197. package/src/feature.ts +5 -5
  198. package/src/hooks/api/workflow-run-mapper.ts +30 -3
  199. package/src/hooks/api/workflow-runs.test.tsx +2 -0
  200. package/src/hooks/api/workflow-runs.ts +12 -7
  201. package/src/index.ts +7 -2
  202. package/src/page-harness-budget.test.ts +1 -1
  203. package/src/pages/project-workflows-page.test.tsx +59 -5
  204. package/src/pages/project-workflows-page.tsx +34 -1
  205. package/src/pages/workflow-run-detail-page.tsx +45 -0
  206. package/src/pages/workflow-run-list-page.tsx +65 -0
  207. package/src/pages/{workflow-run-page.test.tsx → workflow-run-pages.test.tsx} +211 -47
  208. package/src/routes/inputs.test.ts +193 -12
  209. package/src/routes/inputs.ts +170 -11
  210. package/src/routes/project-route.tsx +25 -0
  211. package/src/routes/run-detail.tsx +15 -8
  212. package/src/routes/runs.tsx +14 -7
  213. package/src/routes/workflows.tsx +5 -4
  214. package/test/fixtures/workflow-run.ts +76 -7
  215. package/test/pages.tsx +18 -8
  216. package/test/render.tsx +8 -2
  217. package/tsconfig.build.tsbuildinfo +1 -1
  218. package/dist/components/workflow-run-list/workflow-run-list-header.d.ts +0 -10
  219. package/dist/components/workflow-run-list/workflow-run-list-header.d.ts.map +0 -1
  220. package/dist/components/workflow-run-list/workflow-run-list-header.js +0 -55
  221. package/dist/components/workflow-run-list/workflow-run-list-header.js.map +0 -1
  222. package/dist/pages/workflow-run-first-time-use.d.ts +0 -6
  223. package/dist/pages/workflow-run-first-time-use.d.ts.map +0 -1
  224. package/dist/pages/workflow-run-first-time-use.js +0 -17
  225. package/dist/pages/workflow-run-first-time-use.js.map +0 -1
  226. package/dist/pages/workflow-run-page.d.ts +0 -10
  227. package/dist/pages/workflow-run-page.d.ts.map +0 -1
  228. package/dist/pages/workflow-run-page.js +0 -90
  229. package/dist/pages/workflow-run-page.js.map +0 -1
  230. package/src/components/workflow-run-list/workflow-run-list-header.tsx +0 -53
  231. package/src/pages/workflow-run-first-time-use.tsx +0 -17
  232. package/src/pages/workflow-run-page.tsx +0 -94
@@ -8,7 +8,7 @@ const PAGE_HARNESS_ALLOWLIST = [
8
8
  'src/components/workflow-run-summary/workflow-run-attempt-switcher.test.tsx',
9
9
  'src/components/workflow-run-view/workflow-run-view.test.tsx',
10
10
  'src/pages/project-workflows-page.test.tsx',
11
- 'src/pages/workflow-run-page.test.tsx',
11
+ 'src/pages/workflow-run-pages.test.tsx',
12
12
  ];
13
13
 
14
14
  const srcDir = dirname(fileURLToPath(import.meta.url));
@@ -1,6 +1,11 @@
1
1
  import {configureApiClient} from '@shipfox/client-api';
2
2
  import {fireEvent, screen, waitFor} from '@testing-library/react';
3
- import {jsonResponse, PROJECT_TEST_WID, renderProjectPage} from '#test/pages.js';
3
+ import {
4
+ jsonResponse,
5
+ PROJECT_TEST_WID,
6
+ PROJECT_TEST_WSLUG,
7
+ renderProjectPage,
8
+ } from '#test/pages.js';
4
9
  import {ProjectWorkflowsPage} from './project-workflows-page.js';
5
10
 
6
11
  const PROJECT_ID = '44444444-4444-4444-8444-444444444444';
@@ -54,6 +59,13 @@ describe('ProjectWorkflowsPage', () => {
54
59
  finished_at: null,
55
60
  last_error_code: 'no-workflow-files',
56
61
  last_error_message: 'No workflow files found',
62
+ warnings: [
63
+ {
64
+ code: 're-evaluating-command',
65
+ message: 'Workflow data is re-executed as shell code.',
66
+ path: 'jobs.build.steps.0.run',
67
+ },
68
+ ],
57
69
  },
58
70
  }),
59
71
  ),
@@ -66,6 +78,47 @@ describe('ProjectWorkflowsPage', () => {
66
78
  await screen.findByText('No workflow files found under .shipfox/workflows/.'),
67
79
  ).toBeInTheDocument();
68
80
  expect(screen.getByText('Workflow sync failed')).toBeInTheDocument();
81
+ expect(screen.queryByText('Workflow definition warnings')).not.toBeInTheDocument();
82
+ });
83
+
84
+ test('shows definition warnings without rendering a sync failure', async () => {
85
+ configureApiClient({
86
+ fetchImpl: createProjectDetailFetch({
87
+ definitions: jsonResponse(
88
+ definitionsDto({
89
+ sync: {
90
+ ref: 'main',
91
+ status: 'succeeded',
92
+ last_sync_at: '2026-05-07T01:00:00.000Z',
93
+ started_at: '2026-05-07T00:59:55.000Z',
94
+ finished_at: '2026-05-07T01:00:00.000Z',
95
+ last_error_code: null,
96
+ last_error_message: null,
97
+ warnings: [
98
+ {
99
+ code: 're-evaluating-command',
100
+ message: 'Workflow data is re-executed as shell code.',
101
+ path: 'jobs.build.steps.0.run',
102
+ },
103
+ {
104
+ code: 're-evaluating-command',
105
+ message: 'Workflow data is re-executed as shell code.',
106
+ path: 'jobs.build.steps.0.run',
107
+ },
108
+ ],
109
+ },
110
+ }),
111
+ ),
112
+ }),
113
+ });
114
+
115
+ renderWorkflowsPage();
116
+
117
+ expect(await screen.findByText('Workflow definition warnings')).toBeInTheDocument();
118
+ expect(screen.getAllByText('Workflow data is re-executed as shell code.')).toHaveLength(2);
119
+ expect(screen.getAllByRole('listitem')).toHaveLength(2);
120
+ expect(screen.getAllByText('jobs.build.steps.0.run')).toHaveLength(2);
121
+ expect(screen.queryByText('Workflow sync failed')).not.toBeInTheDocument();
69
122
  });
70
123
 
71
124
  test('opens and closes the definition drawer by clicking the row', async () => {
@@ -123,10 +176,9 @@ describe('ProjectWorkflowsPage', () => {
123
176
  });
124
177
 
125
178
  function renderWorkflowsPage() {
126
- return renderProjectPage(
127
- `/workspaces/${PROJECT_TEST_WID}/projects/${PROJECT_ID}/workflows`,
128
- () => <ProjectWorkflowsPage projectId={PROJECT_ID} />,
129
- );
179
+ return renderProjectPage(`/w/${PROJECT_TEST_WSLUG}/p/project/workflows`, () => (
180
+ <ProjectWorkflowsPage projectId={PROJECT_ID} />
181
+ ));
130
182
  }
131
183
 
132
184
  function createProjectDetailFetch({
@@ -174,6 +226,7 @@ function projectDto() {
174
226
  id: PROJECT_ID,
175
227
  workspace_id: PROJECT_TEST_WID,
176
228
  name: 'Platform',
229
+ slug: 'platform',
177
230
  source: {
178
231
  connection_id: CONNECTION_ID,
179
232
  external_repository_id: 'platform',
@@ -238,6 +291,7 @@ function baseDefinitionsDto() {
238
291
  finished_at: '2026-05-07T01:00:00.000Z',
239
292
  last_error_code: null,
240
293
  last_error_message: null,
294
+ warnings: [],
241
295
  },
242
296
  };
243
297
  }
@@ -103,6 +103,7 @@ function ProjectWorkflowsPageInner({projectId}: {projectId: string}) {
103
103
  />
104
104
 
105
105
  <WorkflowSyncAlert sync={sync} />
106
+ <WorkflowSyncWarnings sync={sync} />
106
107
 
107
108
  <WorkflowDefinitionsList
108
109
  definitions={definitions}
@@ -214,7 +215,7 @@ function WorkflowDefinitionsList({
214
215
  // The workflow-name cell holds a `<button>` so the row is
215
216
  // keyboard-reachable (Tab focuses, Enter/Space activates
216
217
  // via native button semantics). The TableRow itself is no
217
- // longer clickable a row-level onClick would be invisible
218
+ // longer clickable: a row-level onClick would be invisible
218
219
  // to keyboard users and require custom keydown handling.
219
220
  // The `group` class on the row still drives the Run button
220
221
  // reveal on hover or focus-within.
@@ -374,6 +375,38 @@ function WorkflowSyncAlert({sync}: {sync: DefinitionSyncSummary | null | undefin
374
375
  );
375
376
  }
376
377
 
378
+ function WorkflowSyncWarnings({sync}: {sync: DefinitionSyncSummary | null | undefined}) {
379
+ if (sync?.status !== 'succeeded' || sync.warnings.length === 0) return null;
380
+
381
+ const seenKeys = new Map<string, number>();
382
+ const warningItems = sync.warnings.map((warning) => {
383
+ const baseKey = `${warning.code}-${warning.path ?? 'workflow'}-${warning.message}`;
384
+ const occurrence = seenKeys.get(baseKey) ?? 0;
385
+ seenKeys.set(baseKey, occurrence + 1);
386
+ return {key: `${baseKey}-${occurrence}`, warning};
387
+ });
388
+
389
+ return (
390
+ <Callout role="status" type="warning">
391
+ <div className="flex flex-col gap-6">
392
+ <Text size="sm" bold>
393
+ Workflow definition warnings
394
+ </Text>
395
+ <ul className="flex flex-col gap-4">
396
+ {warningItems.map(({key, warning}) => (
397
+ <li key={key}>
398
+ <Text size="sm">{warning.message}</Text>
399
+ {warning.path ? (
400
+ <Code className="text-foreground-neutral-muted">{warning.path}</Code>
401
+ ) : null}
402
+ </li>
403
+ ))}
404
+ </ul>
405
+ </div>
406
+ </Callout>
407
+ );
408
+ }
409
+
377
410
  function DefinitionSheet({
378
411
  definition,
379
412
  onOpenChange,
@@ -0,0 +1,45 @@
1
+ import {useNavigate} from '@tanstack/react-router';
2
+ import {useCallback} from 'react';
3
+ import {WorkflowRunView} from '#components/workflow-run-view/index.js';
4
+ import type {WorkflowRunSelectionInput} from '#core/workflow-run-url-state.js';
5
+ import {type WorkflowRunsSearch, workflowRunSearchParams} from '#routes/inputs.js';
6
+
7
+ interface WorkflowRunDetailPageProps {
8
+ projectId: string;
9
+ workspaceSlug: string;
10
+ projectSlug: string;
11
+ workflowRunId?: string | undefined;
12
+ search?: WorkflowRunsSearch;
13
+ }
14
+
15
+ export function WorkflowRunDetailPage({
16
+ projectId,
17
+ workspaceSlug,
18
+ projectSlug,
19
+ workflowRunId,
20
+ search = {},
21
+ }: WorkflowRunDetailPageProps) {
22
+ const navigate = useNavigate();
23
+ const selection: WorkflowRunSelectionInput = search;
24
+ const onSelectionChange = useCallback(
25
+ (nextSelection: WorkflowRunSelectionInput) => {
26
+ navigate({
27
+ search: workflowRunSearchParams(search, nextSelection) as never,
28
+ });
29
+ },
30
+ [navigate, search],
31
+ );
32
+
33
+ return (
34
+ <div className="flex min-h-0 flex-1 overflow-hidden bg-background-neutral-base">
35
+ <WorkflowRunView
36
+ projectId={projectId}
37
+ workspaceSlug={workspaceSlug}
38
+ projectSlug={projectSlug}
39
+ workflowRunId={workflowRunId}
40
+ selection={selection}
41
+ onSelectionChange={onSelectionChange}
42
+ />
43
+ </div>
44
+ );
45
+ }
@@ -0,0 +1,65 @@
1
+ import {useNavigate} from '@tanstack/react-router';
2
+ import {useCallback} from 'react';
3
+ import {WorkflowRunList} from '#components/workflow-run-list/workflow-run-list.js';
4
+ import {
5
+ applyWorkflowRunFilterPatch,
6
+ clearWorkflowRunFilters,
7
+ type WorkflowRunFilterPatch,
8
+ type WorkflowRunsSearch,
9
+ workflowRunSearchParams,
10
+ } from '#routes/inputs.js';
11
+
12
+ interface WorkflowRunsPageProps {
13
+ projectId: string;
14
+ workspaceSlug: string;
15
+ projectSlug: string;
16
+ search?: WorkflowRunsSearch;
17
+ }
18
+
19
+ const EMPTY_SEARCH: WorkflowRunsSearch = {};
20
+
21
+ export function WorkflowRunsPage({
22
+ projectId,
23
+ workspaceSlug,
24
+ projectSlug,
25
+ search = EMPTY_SEARCH,
26
+ }: WorkflowRunsPageProps) {
27
+ const navigate = useNavigate();
28
+
29
+ // Filter changes replace history instead of pushing it, so Back leaves the list rather than
30
+ // walking every keystroke of a search box.
31
+ const commitSearch = useCallback(
32
+ (next: WorkflowRunsSearch) => {
33
+ navigate({
34
+ search: workflowRunSearchParams(next, {}) as never,
35
+ replace: true,
36
+ });
37
+ },
38
+ [navigate],
39
+ );
40
+
41
+ const onFiltersChange = useCallback(
42
+ (patch: WorkflowRunFilterPatch) => commitSearch(applyWorkflowRunFilterPatch(search, patch)),
43
+ [commitSearch, search],
44
+ );
45
+
46
+ const onClearFilters = useCallback(
47
+ () => commitSearch(clearWorkflowRunFilters(search)),
48
+ [commitSearch, search],
49
+ );
50
+
51
+ return (
52
+ <div className="flex min-h-0 flex-1 overflow-hidden bg-background-neutral-base">
53
+ <div className="mx-auto flex min-h-0 w-full max-w-[1120px] flex-1 flex-col px-24 py-24">
54
+ <WorkflowRunList
55
+ projectId={projectId}
56
+ workspaceSlug={workspaceSlug}
57
+ projectSlug={projectSlug}
58
+ search={search}
59
+ onFiltersChange={onFiltersChange}
60
+ onClearFilters={onClearFilters}
61
+ />
62
+ </div>
63
+ </div>
64
+ );
65
+ }
@@ -14,13 +14,15 @@ import {
14
14
  workflowStepAttemptDto,
15
15
  workflowStepDto,
16
16
  } from '#test/fixtures/workflow-run.js';
17
- import {jsonResponse, PROJECT_TEST_WID, renderProjectPage} from '#test/pages.js';
18
- import {WorkflowRunPage} from './workflow-run-page.js';
17
+ import {jsonResponse, PROJECT_TEST_WSLUG, renderProjectPage} from '#test/pages.js';
18
+ import {WorkflowRunDetailPage} from './workflow-run-detail-page.js';
19
+ import {WorkflowRunsPage} from './workflow-run-list-page.js';
19
20
 
20
21
  const PROJECT_ID = '44444444-4444-4444-8444-444444444444';
21
22
  const DEFINITION_ID = '55555555-5555-4555-8555-555555555555';
22
23
  const RUN_ID = '66666666-6666-4666-8666-666666666666';
23
24
  const SECOND_RUN_ID = '66666666-6666-4666-8666-000000000002';
25
+ const OLDER_RUN_ID = '66666666-6666-4666-8666-000000000003';
24
26
  const BUILD_JOB_ID = '77777777-7777-4777-8777-777777777777';
25
27
  const BUILD_STEP_ID = '99999999-9999-4999-8999-000000000000';
26
28
  const BUILD_ATTEMPT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-000000000000';
@@ -32,7 +34,12 @@ const DEPLOY_ATTEMPT_TWO_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-000000000002';
32
34
  const DEPLOY_EXECUTION_ONE_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-000000000001';
33
35
  const DEPLOY_EXECUTION_TWO_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-000000000002';
34
36
  const SMOKE_WEB_RE = /smoke-web/u;
35
- const EXECUTION_ONE_MENU_ITEM_PATTERN = /#1/;
37
+ const DEPLOY_WEB_RE = /deploy-web/u;
38
+ const OLDER_RUN_RE = /older-run/u;
39
+ const INTEGRATION_TESTS_RE = /integration-tests/u;
40
+ const BUILD_IMAGE_RE = /build-image/u;
41
+ const STATUS_FILTER_RE = /^Status\b.*filter$/u;
42
+ const EXECUTION_ONE_MENU_ITEM = /Execution #1: deploy review #1/u;
36
43
  const RUN_DETAIL_PATH_RE = /^\/workflows\/runs\/([^/]+)$/u;
37
44
  const RUN_OVERRIDES = {
38
45
  id: RUN_ID,
@@ -47,47 +54,129 @@ const SECOND_RUN_OVERRIDES = {
47
54
  id: SECOND_RUN_ID,
48
55
  name: 'smoke-web',
49
56
  } satisfies Partial<WorkflowRunResponseDto>;
57
+ const OLDER_RUN_OVERRIDES = {
58
+ ...RUN_OVERRIDES,
59
+ id: OLDER_RUN_ID,
60
+ name: 'older-run',
61
+ } satisfies Partial<WorkflowRunResponseDto>;
50
62
 
51
- describe('WorkflowRunPage', () => {
52
- test('keeps the runs list mounted and only skeletons the run view until a run is selected', async () => {
63
+ describe('WorkflowRunPages', () => {
64
+ test('renders the list route without mounting run detail', async () => {
53
65
  configureApiClient({fetchImpl: vi.fn(() => new Promise<Response>(() => undefined))});
54
66
 
55
67
  renderRunsPath();
56
68
 
57
- // The run view has nothing to show until a run is selected, so it skeletons...
58
- expect(await screen.findByLabelText('Loading workflow run')).toBeInTheDocument();
59
- // ...but the runs list itself stays mounted; it is never replaced by a page skeleton.
69
+ expect(await screen.findByLabelText('Loading runs')).toBeInTheDocument();
60
70
  expect(screen.getByLabelText('Workflow runs')).toBeInTheDocument();
71
+ expect(screen.queryByLabelText('Loading workflow run')).not.toBeInTheDocument();
61
72
  });
62
73
 
63
- test('redirects to the most recent run when opened without a workflow run id', async () => {
74
+ test('keeps the list route and filters in place instead of redirecting to a run', async () => {
64
75
  configureApiClient({fetchImpl: createRunsListFetch()});
65
76
 
66
- const {router} = renderRunsPath(
67
- `?job=${DEPLOY_JOB_ID}&step=${DEPLOY_STEP_ID}&stepAttempt=${DEPLOY_ATTEMPT_TWO_ID}&runAttempt=1`,
68
- );
77
+ const {router} = renderRunsPath('?search=deploy-web&status=running');
69
78
 
70
- // Landing on /runs with runs present redirects to the newest run, so its row becomes the
71
- // selected (current) row in the rail even though the opened URL carried no workflow run id.
72
- const selectedRow = await screen.findByRole('link', {current: 'page'});
73
- expect(selectedRow).toHaveTextContent('deploy-web');
74
- expect(currentSearch(router).job).toBeUndefined();
75
- expect(currentSearch(router).step).toBeUndefined();
76
- expect(currentSearch(router).stepAttempt).toBeUndefined();
77
- expect(currentSearch(router).runAttempt).toBeUndefined();
79
+ expect(await screen.findByRole('link', {name: DEPLOY_WEB_RE})).toBeInTheDocument();
80
+ expect(router.state.location.pathname).toBe(`/w/${PROJECT_TEST_WSLUG}/p/project/runs`);
81
+ expect(currentSearch(router)).toMatchObject({search: 'deploy-web', status: 'running'});
82
+ expect(screen.queryByLabelText('Loading workflow run')).not.toBeInTheDocument();
83
+ });
84
+
85
+ test('clearing filters on the list route resets search and status in the URL', async () => {
86
+ const user = userEvent.setup();
87
+ configureApiClient({fetchImpl: createRunsListFetch()});
88
+
89
+ const {router} = renderRunsPath('?search=no-such-run&status=failed');
90
+
91
+ expect(await screen.findByText('No matching runs')).toBeInTheDocument();
92
+
93
+ await user.click(screen.getByRole('button', {name: 'Clear filters'}));
94
+
95
+ await waitFor(() => {
96
+ expect(currentSearch(router).search).toBeUndefined();
97
+ });
98
+ expect(currentSearch(router).status).toBeUndefined();
99
+ expect(await screen.findByRole('link', {name: DEPLOY_WEB_RE})).toBeInTheDocument();
78
100
  });
79
101
 
80
- test('shows the first-time-use surface when the project has no runs', async () => {
102
+ test('honors a deep link that repeats a filter key', async () => {
103
+ configureApiClient({fetchImpl: createMixedStatusRunsFetch()});
104
+
105
+ renderRunsPath('?status=failed&status=running');
106
+
107
+ expect(await screen.findByRole('link', {name: DEPLOY_WEB_RE})).toBeInTheDocument();
108
+ expect(await screen.findByRole('link', {name: INTEGRATION_TESTS_RE})).toBeInTheDocument();
109
+ expect(screen.queryByRole('link', {name: BUILD_IMAGE_RE})).not.toBeInTheDocument();
110
+ });
111
+
112
+ test('writes a multi-select as repeated keys rather than one joined value', async () => {
113
+ const user = userEvent.setup();
114
+ configureApiClient({fetchImpl: createMixedStatusRunsFetch()});
115
+
116
+ const {router} = renderRunsPath('?status=failed');
117
+
118
+ await user.click(await screen.findByRole('button', {name: STATUS_FILTER_RE}));
119
+ await user.click(await screen.findByRole('menuitemcheckbox', {name: 'Running'}));
120
+
121
+ await waitFor(() => {
122
+ expect(currentSearch(router).status).toEqual(['failed', 'running']);
123
+ });
124
+ expect(router.state.location.searchStr).toBe('?status=failed&status=running');
125
+ });
126
+
127
+ test('replaces history on a filter change so back leaves the list', async () => {
128
+ const user = userEvent.setup();
129
+ configureApiClient({fetchImpl: createMixedStatusRunsFetch()});
130
+
131
+ const {router} = renderRunsPath();
132
+ const initialLength = router.history.length;
133
+
134
+ await user.type(await screen.findByLabelText('Search runs'), 'deploy');
135
+
136
+ await waitFor(() => {
137
+ expect(currentSearch(router).search).toBe('deploy');
138
+ });
139
+ expect(router.history.length).toBe(initialLength);
140
+ });
141
+
142
+ test('loads older pages before reporting that a search has no matches', async () => {
143
+ const user = userEvent.setup();
144
+ const fetchImpl = createPaginatedRunsFetch();
145
+ configureApiClient({fetchImpl});
146
+
147
+ renderRunsPath('?search=older-run');
148
+
149
+ expect(await screen.findByText('No matches in loaded history')).toBeInTheDocument();
150
+ await user.click(screen.getByRole('button', {name: 'Load more runs'}));
151
+
152
+ expect(await screen.findByRole('link', {name: OLDER_RUN_RE})).toBeInTheDocument();
153
+ expect(
154
+ fetchImpl.mock.calls.some((call) => {
155
+ const url = new URL(requestInputUrl(call[0]));
156
+ return url.searchParams.get('cursor') === 'cursor-2';
157
+ }),
158
+ ).toBe(true);
159
+ });
160
+
161
+ test('shows the list empty state without mounting run detail', async () => {
81
162
  configureApiClient({fetchImpl: createEmptyRunsFetch()});
82
163
 
83
164
  renderRunsPath();
84
165
 
85
- expect(await screen.findByText('No workflow runs yet')).toBeInTheDocument();
86
- // The rail and the perpetual detail skeleton give way to the onboarding surface entirely.
87
- expect(screen.queryByLabelText('Workflow runs')).not.toBeInTheDocument();
166
+ expect(await screen.findByText('No runs yet')).toBeInTheDocument();
167
+ expect(screen.getByLabelText('Workflow runs')).toBeInTheDocument();
88
168
  expect(screen.queryByLabelText('Loading workflow run')).not.toBeInTheDocument();
89
169
  });
90
170
 
171
+ test('renders the detail route without mounting the run list', async () => {
172
+ configureApiClient({fetchImpl: createRunDetailFetch()});
173
+
174
+ renderRunPath();
175
+
176
+ expect(await screen.findByRole('button', {name: 'deploy, Running'})).toBeInTheDocument();
177
+ expect(screen.queryByLabelText('Workflow runs')).not.toBeInTheDocument();
178
+ });
179
+
91
180
  test('restores a deep-linked job and exact attempt after data loads', async () => {
92
181
  configureApiClient({fetchImpl: createRunDetailFetch()});
93
182
 
@@ -150,10 +239,14 @@ describe('WorkflowRunPage', () => {
150
239
 
151
240
  await user.click(
152
241
  await screen.findByRole('button', {
153
- name: 'Switch job execution, currently execution 2',
242
+ name: 'Switch job execution, currently execution 2: deploy review #2',
243
+ }),
244
+ );
245
+ await user.click(
246
+ screen.getByRole('menuitem', {
247
+ name: EXECUTION_ONE_MENU_ITEM,
154
248
  }),
155
249
  );
156
- await user.click(screen.getByRole('menuitem', {name: EXECUTION_ONE_MENU_ITEM_PATTERN}));
157
250
 
158
251
  await waitFor(() => {
159
252
  expect(currentSearch(router)).toMatchObject({
@@ -162,6 +255,7 @@ describe('WorkflowRunPage', () => {
162
255
  });
163
256
  });
164
257
  expect(screen.getByRole('button', {name: 'deploy, Failed, attempt 1'})).toBeInTheDocument();
258
+ expect(screen.getAllByText('deploy review #1')).not.toHaveLength(0);
165
259
 
166
260
  await user.click(screen.getByRole('button', {name: 'deploy, Failed, attempt 1'}));
167
261
 
@@ -229,7 +323,7 @@ describe('WorkflowRunPage', () => {
229
323
  );
230
324
  });
231
325
 
232
- test('run rail links clear job, step, and attempt when switching runs', async () => {
326
+ test('run list links navigate to detail and back restores list filters', async () => {
233
327
  const user = userEvent.setup();
234
328
  configureApiClient({
235
329
  fetchImpl: createRunDetailFetch({
@@ -240,41 +334,62 @@ describe('WorkflowRunPage', () => {
240
334
  },
241
335
  }),
242
336
  });
243
- const {router} = renderRunPath(`?step=${DEPLOY_STEP_ID}&stepAttempt=${DEPLOY_ATTEMPT_TWO_ID}`);
337
+ const {router} = renderRunsPath('?search=smoke&status=running');
244
338
 
245
339
  await user.click(await screen.findByRole('link', {name: SMOKE_WEB_RE}));
246
340
 
247
341
  await waitFor(() => {
248
- expect(router.state.location.pathname).toContain(SECOND_RUN_ID);
342
+ expect(router.state.location.pathname).toBe(
343
+ `/w/${PROJECT_TEST_WSLUG}/p/project/runs/${SECOND_RUN_ID}`,
344
+ );
249
345
  });
250
- expect(currentSearch(router).job).toBeUndefined();
251
- expect(currentSearch(router).step).toBeUndefined();
252
- expect(currentSearch(router).stepAttempt).toBeUndefined();
253
- expect(currentSearch(router).runAttempt).toBeUndefined();
346
+ expect(screen.queryByLabelText('Workflow runs')).not.toBeInTheDocument();
347
+ expect(currentSearch(router)).toMatchObject({search: 'smoke', status: 'running'});
348
+
349
+ await act(() => {
350
+ router.history.back();
351
+ });
352
+
353
+ await waitFor(() => {
354
+ expect(router.state.location.pathname).toBe(`/w/${PROJECT_TEST_WSLUG}/p/project/runs`);
355
+ });
356
+ expect(currentSearch(router)).toMatchObject({search: 'smoke', status: 'running'});
357
+ expect(screen.getByLabelText('Workflow runs')).toBeInTheDocument();
358
+ expect(screen.getByRole('link', {name: SMOKE_WEB_RE})).toBeInTheDocument();
254
359
  });
255
360
  });
256
361
 
257
362
  function renderRunsPath(search = '') {
258
363
  return renderProjectPage(
259
- `/workspaces/${PROJECT_TEST_WID}/projects/${PROJECT_ID}/runs${search}`,
260
- ({workflowRunId, search}) => (
261
- <WorkflowRunPage
262
- workspaceId={PROJECT_TEST_WID}
263
- projectId={PROJECT_ID}
264
- workflowRunId={workflowRunId}
265
- search={search}
266
- />
267
- ),
364
+ `/w/${PROJECT_TEST_WSLUG}/p/project/runs${search}`,
365
+ ({workflowRunId, search}) =>
366
+ workflowRunId ? (
367
+ <WorkflowRunDetailPage
368
+ projectId={PROJECT_ID}
369
+ workspaceSlug={PROJECT_TEST_WSLUG}
370
+ projectSlug="project"
371
+ workflowRunId={workflowRunId}
372
+ search={search}
373
+ />
374
+ ) : (
375
+ <WorkflowRunsPage
376
+ projectId={PROJECT_ID}
377
+ workspaceSlug={PROJECT_TEST_WSLUG}
378
+ projectSlug="project"
379
+ search={search}
380
+ />
381
+ ),
268
382
  );
269
383
  }
270
384
 
271
385
  function renderRunPath(search = '') {
272
386
  return renderProjectPage(
273
- `/workspaces/${PROJECT_TEST_WID}/projects/${PROJECT_ID}/runs/${RUN_ID}${search}`,
387
+ `/w/${PROJECT_TEST_WSLUG}/p/project/runs/${RUN_ID}${search}`,
274
388
  ({workflowRunId, search}) => (
275
- <WorkflowRunPage
276
- workspaceId={PROJECT_TEST_WID}
389
+ <WorkflowRunDetailPage
277
390
  projectId={PROJECT_ID}
391
+ workspaceSlug={PROJECT_TEST_WSLUG}
392
+ projectSlug="project"
278
393
  workflowRunId={workflowRunId}
279
394
  search={search}
280
395
  />
@@ -303,6 +418,55 @@ function createRunsListFetch() {
303
418
  });
304
419
  }
305
420
 
421
+ function createMixedStatusRunsFetch() {
422
+ const runs = [
423
+ workflowRunDto({...RUN_OVERRIDES, status: 'running'}),
424
+ workflowRunDto({
425
+ ...RUN_OVERRIDES,
426
+ id: SECOND_RUN_ID,
427
+ name: 'integration-tests',
428
+ workflow_name: 'integration-tests',
429
+ status: 'failed',
430
+ }),
431
+ workflowRunDto({
432
+ ...RUN_OVERRIDES,
433
+ id: OLDER_RUN_ID,
434
+ name: 'build-image',
435
+ workflow_name: 'build-image',
436
+ status: 'succeeded',
437
+ }),
438
+ ];
439
+
440
+ return vi.fn((input: RequestInfo | URL) => {
441
+ const url = new URL(requestInputUrl(input));
442
+
443
+ if (url.pathname === '/workflows/runs') {
444
+ return Promise.resolve(jsonResponse({runs, next_cursor: null, filtered_total_count: 3}));
445
+ }
446
+
447
+ return Promise.resolve(jsonResponse({code: 'not-found'}, {status: 404}));
448
+ });
449
+ }
450
+
451
+ function createPaginatedRunsFetch() {
452
+ return vi.fn((input: RequestInfo | URL) => {
453
+ const url = new URL(requestInputUrl(input));
454
+
455
+ if (url.pathname === '/workflows/runs') {
456
+ const isSecondPage = url.searchParams.get('cursor') === 'cursor-2';
457
+ return Promise.resolve(
458
+ jsonResponse({
459
+ runs: [workflowRunDto(isSecondPage ? OLDER_RUN_OVERRIDES : RUN_OVERRIDES)],
460
+ next_cursor: isSecondPage ? null : 'cursor-2',
461
+ filtered_total_count: isSecondPage ? null : 2,
462
+ }),
463
+ );
464
+ }
465
+
466
+ return Promise.resolve(jsonResponse({code: 'not-found'}, {status: 404}));
467
+ });
468
+ }
469
+
306
470
  function createRunDetailFetch({
307
471
  runs = [workflowRunDto(RUN_OVERRIDES)],
308
472
  details = {[RUN_ID]: defaultRunDetailDto()},
@@ -464,7 +628,7 @@ function retryRunDetailDto(): WorkflowRunDetailResponseDto {
464
628
  id: DEPLOY_EXECUTION_ONE_ID,
465
629
  job_id: DEPLOY_JOB_ID,
466
630
  sequence: 1,
467
- name: 'deploy',
631
+ name: 'deploy review #1',
468
632
  status: 'failed',
469
633
  started_at: '2026-05-07T01:01:00.000Z',
470
634
  finished_at: '2026-05-07T01:02:00.000Z',
@@ -488,7 +652,7 @@ function retryRunDetailDto(): WorkflowRunDetailResponseDto {
488
652
  id: DEPLOY_EXECUTION_TWO_ID,
489
653
  job_id: DEPLOY_JOB_ID,
490
654
  sequence: 2,
491
- name: 'deploy',
655
+ name: 'deploy review #2',
492
656
  status: 'running',
493
657
  started_at: '2026-05-07T01:03:00.000Z',
494
658
  steps: [