@shipfox/client-workflows 0.2.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 (327) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +10 -0
  3. package/.storybook/preview.tsx +97 -0
  4. package/.swcrc +42 -0
  5. package/.turbo/turbo-build.log +2 -0
  6. package/.turbo/turbo-type$colon$emit.log +1 -0
  7. package/.turbo/turbo-type.log +1 -0
  8. package/CHANGELOG.md +162 -0
  9. package/LICENSE +21 -0
  10. package/dist/components/identifier/identifier.d.ts +6 -0
  11. package/dist/components/identifier/identifier.d.ts.map +1 -0
  12. package/dist/components/identifier/identifier.js +144 -0
  13. package/dist/components/identifier/identifier.js.map +1 -0
  14. package/dist/components/identifier/index.d.ts +2 -0
  15. package/dist/components/identifier/index.d.ts.map +1 -0
  16. package/dist/components/identifier/index.js +3 -0
  17. package/dist/components/identifier/index.js.map +1 -0
  18. package/dist/components/job-graph/graph-model.d.ts +27 -0
  19. package/dist/components/job-graph/graph-model.d.ts.map +1 -0
  20. package/dist/components/job-graph/graph-model.js +107 -0
  21. package/dist/components/job-graph/graph-model.js.map +1 -0
  22. package/dist/components/job-graph/index.d.ts +3 -0
  23. package/dist/components/job-graph/index.d.ts.map +1 -0
  24. package/dist/components/job-graph/index.js +3 -0
  25. package/dist/components/job-graph/index.js.map +1 -0
  26. package/dist/components/job-graph/job-duration-format.d.ts +4 -0
  27. package/dist/components/job-graph/job-duration-format.d.ts.map +1 -0
  28. package/dist/components/job-graph/job-duration-format.js +41 -0
  29. package/dist/components/job-graph/job-duration-format.js.map +1 -0
  30. package/dist/components/job-graph/job-duration-label.d.ts +5 -0
  31. package/dist/components/job-graph/job-duration-label.d.ts.map +1 -0
  32. package/dist/components/job-graph/job-duration-label.js +33 -0
  33. package/dist/components/job-graph/job-duration-label.js.map +1 -0
  34. package/dist/components/job-graph/job-graph-content.d.ts +9 -0
  35. package/dist/components/job-graph/job-graph-content.d.ts.map +1 -0
  36. package/dist/components/job-graph/job-graph-content.js +181 -0
  37. package/dist/components/job-graph/job-graph-content.js.map +1 -0
  38. package/dist/components/job-graph/job-graph-view.d.ts +11 -0
  39. package/dist/components/job-graph/job-graph-view.d.ts.map +1 -0
  40. package/dist/components/job-graph/job-graph-view.js +24 -0
  41. package/dist/components/job-graph/job-graph-view.js.map +1 -0
  42. package/dist/components/job-graph/job-graph.d.ts +3 -0
  43. package/dist/components/job-graph/job-graph.d.ts.map +1 -0
  44. package/dist/components/job-graph/job-graph.js +21 -0
  45. package/dist/components/job-graph/job-graph.js.map +1 -0
  46. package/dist/components/job-graph/job-node.d.ts +16 -0
  47. package/dist/components/job-graph/job-node.d.ts.map +1 -0
  48. package/dist/components/job-graph/job-node.js +196 -0
  49. package/dist/components/job-graph/job-node.js.map +1 -0
  50. package/dist/components/job-graph/types.d.ts +9 -0
  51. package/dist/components/job-graph/types.d.ts.map +1 -0
  52. package/dist/components/job-graph/types.js +3 -0
  53. package/dist/components/job-graph/types.js.map +1 -0
  54. package/dist/components/step-list/index.d.ts +3 -0
  55. package/dist/components/step-list/index.d.ts.map +1 -0
  56. package/dist/components/step-list/index.js +3 -0
  57. package/dist/components/step-list/index.js.map +1 -0
  58. package/dist/components/step-list/step-list-model.d.ts +36 -0
  59. package/dist/components/step-list/step-list-model.d.ts.map +1 -0
  60. package/dist/components/step-list/step-list-model.js +135 -0
  61. package/dist/components/step-list/step-list-model.js.map +1 -0
  62. package/dist/components/step-list/step-list.d.ts +32 -0
  63. package/dist/components/step-list/step-list.d.ts.map +1 -0
  64. package/dist/components/step-list/step-list.js +409 -0
  65. package/dist/components/step-list/step-list.js.map +1 -0
  66. package/dist/components/workflow-run-duration-label.d.ts +8 -0
  67. package/dist/components/workflow-run-duration-label.d.ts.map +1 -0
  68. package/dist/components/workflow-run-duration-label.js +84 -0
  69. package/dist/components/workflow-run-duration-label.js.map +1 -0
  70. package/dist/components/workflow-run-list/index.d.ts +2 -0
  71. package/dist/components/workflow-run-list/index.d.ts.map +1 -0
  72. package/dist/components/workflow-run-list/index.js +3 -0
  73. package/dist/components/workflow-run-list/index.js.map +1 -0
  74. package/dist/components/workflow-run-list/run-display.d.ts +5 -0
  75. package/dist/components/workflow-run-list/run-display.d.ts.map +1 -0
  76. package/dist/components/workflow-run-list/run-display.js +19 -0
  77. package/dist/components/workflow-run-list/run-display.js.map +1 -0
  78. package/dist/components/workflow-run-list/types.d.ts +21 -0
  79. package/dist/components/workflow-run-list/types.d.ts.map +1 -0
  80. package/dist/components/workflow-run-list/types.js +3 -0
  81. package/dist/components/workflow-run-list/types.js.map +1 -0
  82. package/dist/components/workflow-run-list/workflow-run-list-content.d.ts +14 -0
  83. package/dist/components/workflow-run-list/workflow-run-list-content.d.ts.map +1 -0
  84. package/dist/components/workflow-run-list/workflow-run-list-content.js +37 -0
  85. package/dist/components/workflow-run-list/workflow-run-list-content.js.map +1 -0
  86. package/dist/components/workflow-run-list/workflow-run-list-header.d.ts +10 -0
  87. package/dist/components/workflow-run-list/workflow-run-list-header.d.ts.map +1 -0
  88. package/dist/components/workflow-run-list/workflow-run-list-header.js +55 -0
  89. package/dist/components/workflow-run-list/workflow-run-list-header.js.map +1 -0
  90. package/dist/components/workflow-run-list/workflow-run-list-states.d.ts +15 -0
  91. package/dist/components/workflow-run-list/workflow-run-list-states.d.ts.map +1 -0
  92. package/dist/components/workflow-run-list/workflow-run-list-states.js +103 -0
  93. package/dist/components/workflow-run-list/workflow-run-list-states.js.map +1 -0
  94. package/dist/components/workflow-run-list/workflow-run-list-view.d.ts +3 -0
  95. package/dist/components/workflow-run-list/workflow-run-list-view.d.ts.map +1 -0
  96. package/dist/components/workflow-run-list/workflow-run-list-view.js +46 -0
  97. package/dist/components/workflow-run-list/workflow-run-list-view.js.map +1 -0
  98. package/dist/components/workflow-run-list/workflow-run-list.d.ts +3 -0
  99. package/dist/components/workflow-run-list/workflow-run-list.d.ts.map +1 -0
  100. package/dist/components/workflow-run-list/workflow-run-list.js +20 -0
  101. package/dist/components/workflow-run-list/workflow-run-list.js.map +1 -0
  102. package/dist/components/workflow-run-list/workflow-run-row.d.ts +14 -0
  103. package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -0
  104. package/dist/components/workflow-run-list/workflow-run-row.js +172 -0
  105. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -0
  106. package/dist/components/workflow-run-summary/index.d.ts +2 -0
  107. package/dist/components/workflow-run-summary/index.d.ts.map +1 -0
  108. package/dist/components/workflow-run-summary/index.js +3 -0
  109. package/dist/components/workflow-run-summary/index.js.map +1 -0
  110. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.d.ts +9 -0
  111. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.d.ts.map +1 -0
  112. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js +138 -0
  113. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js.map +1 -0
  114. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts +20 -0
  115. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -0
  116. package/dist/components/workflow-run-summary/workflow-run-summary.js +252 -0
  117. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -0
  118. package/dist/components/workflow-run-view/agent-config-failure-callout.d.ts +7 -0
  119. package/dist/components/workflow-run-view/agent-config-failure-callout.d.ts.map +1 -0
  120. package/dist/components/workflow-run-view/agent-config-failure-callout.js +84 -0
  121. package/dist/components/workflow-run-view/agent-config-failure-callout.js.map +1 -0
  122. package/dist/components/workflow-run-view/index.d.ts +3 -0
  123. package/dist/components/workflow-run-view/index.d.ts.map +1 -0
  124. package/dist/components/workflow-run-view/index.js +4 -0
  125. package/dist/components/workflow-run-view/index.js.map +1 -0
  126. package/dist/components/workflow-run-view/job-card.d.ts +17 -0
  127. package/dist/components/workflow-run-view/job-card.d.ts.map +1 -0
  128. package/dist/components/workflow-run-view/job-card.js +476 -0
  129. package/dist/components/workflow-run-view/job-card.js.map +1 -0
  130. package/dist/components/workflow-run-view/job-execution-switcher.d.ts +10 -0
  131. package/dist/components/workflow-run-view/job-execution-switcher.d.ts.map +1 -0
  132. package/dist/components/workflow-run-view/job-execution-switcher.js +171 -0
  133. package/dist/components/workflow-run-view/job-execution-switcher.js.map +1 -0
  134. package/dist/components/workflow-run-view/job-execution-time-text.d.ts +6 -0
  135. package/dist/components/workflow-run-view/job-execution-time-text.d.ts.map +1 -0
  136. package/dist/components/workflow-run-view/job-execution-time-text.js +24 -0
  137. package/dist/components/workflow-run-view/job-execution-time-text.js.map +1 -0
  138. package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts +9 -0
  139. package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts.map +1 -0
  140. package/dist/components/workflow-run-view/step-attempt-log-panel.js +152 -0
  141. package/dist/components/workflow-run-view/step-attempt-log-panel.js.map +1 -0
  142. package/dist/components/workflow-run-view/workflow-run-selection.d.ts +14 -0
  143. package/dist/components/workflow-run-view/workflow-run-selection.d.ts.map +1 -0
  144. package/dist/components/workflow-run-view/workflow-run-selection.js +56 -0
  145. package/dist/components/workflow-run-view/workflow-run-selection.js.map +1 -0
  146. package/dist/components/workflow-run-view/workflow-run-states.d.ts +12 -0
  147. package/dist/components/workflow-run-view/workflow-run-states.d.ts.map +1 -0
  148. package/dist/components/workflow-run-view/workflow-run-states.js +91 -0
  149. package/dist/components/workflow-run-view/workflow-run-states.js.map +1 -0
  150. package/dist/components/workflow-run-view/workflow-run-view.d.ts +15 -0
  151. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -0
  152. package/dist/components/workflow-run-view/workflow-run-view.js +285 -0
  153. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -0
  154. package/dist/components/workflow-source-panel/index.d.ts +2 -0
  155. package/dist/components/workflow-source-panel/index.d.ts.map +1 -0
  156. package/dist/components/workflow-source-panel/index.js +3 -0
  157. package/dist/components/workflow-source-panel/index.js.map +1 -0
  158. package/dist/components/workflow-source-panel/workflow-source-panel.d.ts +13 -0
  159. package/dist/components/workflow-source-panel/workflow-source-panel.d.ts.map +1 -0
  160. package/dist/components/workflow-source-panel/workflow-source-panel.js +91 -0
  161. package/dist/components/workflow-source-panel/workflow-source-panel.js.map +1 -0
  162. package/dist/components/workflow-status/status-visuals.d.ts +12 -0
  163. package/dist/components/workflow-status/status-visuals.d.ts.map +1 -0
  164. package/dist/components/workflow-status/status-visuals.js +54 -0
  165. package/dist/components/workflow-status/status-visuals.js.map +1 -0
  166. package/dist/components/workflow-status/workflow-status-icon.d.ts +22 -0
  167. package/dist/components/workflow-status/workflow-status-icon.d.ts.map +1 -0
  168. package/dist/components/workflow-status/workflow-status-icon.js +100 -0
  169. package/dist/components/workflow-status/workflow-status-icon.js.map +1 -0
  170. package/dist/core/entities/job-execution.d.ts +69 -0
  171. package/dist/core/entities/job-execution.d.ts.map +1 -0
  172. package/dist/core/entities/job-execution.js +83 -0
  173. package/dist/core/entities/job-execution.js.map +1 -0
  174. package/dist/core/entities/job.d.ts +56 -0
  175. package/dist/core/entities/job.d.ts.map +1 -0
  176. package/dist/core/entities/job.js +71 -0
  177. package/dist/core/entities/job.js.map +1 -0
  178. package/dist/core/entities/step-attempt.d.ts +49 -0
  179. package/dist/core/entities/step-attempt.d.ts.map +1 -0
  180. package/dist/core/entities/step-attempt.js +48 -0
  181. package/dist/core/entities/step-attempt.js.map +1 -0
  182. package/dist/core/entities/step.d.ts +41 -0
  183. package/dist/core/entities/step.d.ts.map +1 -0
  184. package/dist/core/entities/step.js +51 -0
  185. package/dist/core/entities/step.js.map +1 -0
  186. package/dist/core/entities/workflow-run-attempt.d.ts +44 -0
  187. package/dist/core/entities/workflow-run-attempt.d.ts.map +1 -0
  188. package/dist/core/entities/workflow-run-attempt.js +51 -0
  189. package/dist/core/entities/workflow-run-attempt.js.map +1 -0
  190. package/dist/core/entities/workflow-run.d.ts +61 -0
  191. package/dist/core/entities/workflow-run.d.ts.map +1 -0
  192. package/dist/core/entities/workflow-run.js +104 -0
  193. package/dist/core/entities/workflow-run.js.map +1 -0
  194. package/dist/core/workflow-run-url-state.d.ts +13 -0
  195. package/dist/core/workflow-run-url-state.d.ts.map +1 -0
  196. package/dist/core/workflow-run-url-state.js +43 -0
  197. package/dist/core/workflow-run-url-state.js.map +1 -0
  198. package/dist/core/workflow-run.d.ts +13 -0
  199. package/dist/core/workflow-run.d.ts.map +1 -0
  200. package/dist/core/workflow-run.js +8 -0
  201. package/dist/core/workflow-run.js.map +1 -0
  202. package/dist/env.d.js +2 -0
  203. package/dist/env.d.js.map +1 -0
  204. package/dist/feature.d.ts +20 -0
  205. package/dist/feature.d.ts.map +1 -0
  206. package/dist/feature.js +27 -0
  207. package/dist/feature.js.map +1 -0
  208. package/dist/hooks/api/workflow-runs.d.ts +109 -0
  209. package/dist/hooks/api/workflow-runs.d.ts.map +1 -0
  210. package/dist/hooks/api/workflow-runs.js +394 -0
  211. package/dist/hooks/api/workflow-runs.js.map +1 -0
  212. package/dist/index.d.ts +5 -0
  213. package/dist/index.d.ts.map +1 -0
  214. package/dist/index.js +5 -0
  215. package/dist/index.js.map +1 -0
  216. package/dist/pages/workflow-run-first-time-use.d.ts +6 -0
  217. package/dist/pages/workflow-run-first-time-use.d.ts.map +1 -0
  218. package/dist/pages/workflow-run-first-time-use.js +17 -0
  219. package/dist/pages/workflow-run-first-time-use.js.map +1 -0
  220. package/dist/pages/workflow-run-page.d.ts +8 -0
  221. package/dist/pages/workflow-run-page.d.ts.map +1 -0
  222. package/dist/pages/workflow-run-page.js +83 -0
  223. package/dist/pages/workflow-run-page.js.map +1 -0
  224. package/dist/routes/run-detail.d.ts +8 -0
  225. package/dist/routes/run-detail.d.ts.map +1 -0
  226. package/dist/routes/run-detail.js +21 -0
  227. package/dist/routes/run-detail.js.map +1 -0
  228. package/dist/routes/runs.d.ts +8 -0
  229. package/dist/routes/runs.d.ts.map +1 -0
  230. package/dist/routes/runs.js +20 -0
  231. package/dist/routes/runs.js.map +1 -0
  232. package/dist/tsconfig.test.tsbuildinfo +1 -0
  233. package/package.json +114 -0
  234. package/src/components/identifier/identifier.tsx +178 -0
  235. package/src/components/identifier/index.ts +1 -0
  236. package/src/components/job-graph/graph-model.test.ts +189 -0
  237. package/src/components/job-graph/graph-model.ts +178 -0
  238. package/src/components/job-graph/index.ts +2 -0
  239. package/src/components/job-graph/job-duration-format.ts +54 -0
  240. package/src/components/job-graph/job-duration-label.tsx +29 -0
  241. package/src/components/job-graph/job-graph-content.tsx +225 -0
  242. package/src/components/job-graph/job-graph-view.test.tsx +298 -0
  243. package/src/components/job-graph/job-graph-view.tsx +45 -0
  244. package/src/components/job-graph/job-graph.stories.tsx +182 -0
  245. package/src/components/job-graph/job-graph.tsx +24 -0
  246. package/src/components/job-graph/job-node.stories.tsx +364 -0
  247. package/src/components/job-graph/job-node.test.tsx +347 -0
  248. package/src/components/job-graph/job-node.tsx +202 -0
  249. package/src/components/job-graph/types.ts +9 -0
  250. package/src/components/step-list/index.ts +6 -0
  251. package/src/components/step-list/step-list-model.test.ts +369 -0
  252. package/src/components/step-list/step-list-model.ts +205 -0
  253. package/src/components/step-list/step-list.stories.tsx +419 -0
  254. package/src/components/step-list/step-list.test.tsx +566 -0
  255. package/src/components/step-list/step-list.tsx +494 -0
  256. package/src/components/workflow-run-duration-label.tsx +118 -0
  257. package/src/components/workflow-run-list/index.ts +1 -0
  258. package/src/components/workflow-run-list/run-display.test.ts +53 -0
  259. package/src/components/workflow-run-list/run-display.ts +22 -0
  260. package/src/components/workflow-run-list/types.ts +22 -0
  261. package/src/components/workflow-run-list/workflow-run-list-content.tsx +58 -0
  262. package/src/components/workflow-run-list/workflow-run-list-header.tsx +53 -0
  263. package/src/components/workflow-run-list/workflow-run-list-states.tsx +86 -0
  264. package/src/components/workflow-run-list/workflow-run-list-view.test.tsx +178 -0
  265. package/src/components/workflow-run-list/workflow-run-list-view.tsx +57 -0
  266. package/src/components/workflow-run-list/workflow-run-list.stories.tsx +111 -0
  267. package/src/components/workflow-run-list/workflow-run-list.tsx +29 -0
  268. package/src/components/workflow-run-list/workflow-run-row.tsx +176 -0
  269. package/src/components/workflow-run-summary/index.ts +1 -0
  270. package/src/components/workflow-run-summary/workflow-run-attempt-switcher.test.tsx +230 -0
  271. package/src/components/workflow-run-summary/workflow-run-attempt-switcher.tsx +146 -0
  272. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +414 -0
  273. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +354 -0
  274. package/src/components/workflow-run-summary/workflow-run-summary.tsx +295 -0
  275. package/src/components/workflow-run-view/agent-config-failure-callout.stories.tsx +140 -0
  276. package/src/components/workflow-run-view/agent-config-failure-callout.tsx +93 -0
  277. package/src/components/workflow-run-view/index.ts +2 -0
  278. package/src/components/workflow-run-view/job-card.stories.tsx +788 -0
  279. package/src/components/workflow-run-view/job-card.tsx +566 -0
  280. package/src/components/workflow-run-view/job-execution-switcher.tsx +170 -0
  281. package/src/components/workflow-run-view/job-execution-time-text.tsx +34 -0
  282. package/src/components/workflow-run-view/step-attempt-log-panel.test.tsx +290 -0
  283. package/src/components/workflow-run-view/step-attempt-log-panel.tsx +152 -0
  284. package/src/components/workflow-run-view/workflow-run-selection.test.ts +280 -0
  285. package/src/components/workflow-run-view/workflow-run-selection.ts +89 -0
  286. package/src/components/workflow-run-view/workflow-run-states.tsx +70 -0
  287. package/src/components/workflow-run-view/workflow-run-view.test.tsx +1214 -0
  288. package/src/components/workflow-run-view/workflow-run-view.tsx +341 -0
  289. package/src/components/workflow-source-panel/index.ts +1 -0
  290. package/src/components/workflow-source-panel/workflow-source-panel.stories.tsx +110 -0
  291. package/src/components/workflow-source-panel/workflow-source-panel.test.tsx +96 -0
  292. package/src/components/workflow-source-panel/workflow-source-panel.tsx +136 -0
  293. package/src/components/workflow-status/status-visuals.test.ts +38 -0
  294. package/src/components/workflow-status/status-visuals.ts +36 -0
  295. package/src/components/workflow-status/workflow-status-icon.stories.tsx +90 -0
  296. package/src/components/workflow-status/workflow-status-icon.tsx +135 -0
  297. package/src/core/entities/job-execution.ts +139 -0
  298. package/src/core/entities/job.ts +149 -0
  299. package/src/core/entities/step-attempt.ts +89 -0
  300. package/src/core/entities/step.ts +105 -0
  301. package/src/core/entities/workflow-run-attempt.ts +87 -0
  302. package/src/core/entities/workflow-run.ts +183 -0
  303. package/src/core/workflow-run-url-state.ts +61 -0
  304. package/src/core/workflow-run.test.ts +640 -0
  305. package/src/core/workflow-run.ts +66 -0
  306. package/src/env.d.ts +8 -0
  307. package/src/feature.ts +26 -0
  308. package/src/hooks/api/workflow-runs.test.tsx +410 -0
  309. package/src/hooks/api/workflow-runs.ts +485 -0
  310. package/src/index.ts +45 -0
  311. package/src/page-harness-budget.test.ts +33 -0
  312. package/src/pages/workflow-run-first-time-use.tsx +17 -0
  313. package/src/pages/workflow-run-page.test.tsx +517 -0
  314. package/src/pages/workflow-run-page.tsx +89 -0
  315. package/src/routes/run-detail.tsx +15 -0
  316. package/src/routes/runs.tsx +11 -0
  317. package/test/fixtures/logs.ts +13 -0
  318. package/test/fixtures/workflow-run.ts +311 -0
  319. package/test/pages.tsx +79 -0
  320. package/test/render.tsx +40 -0
  321. package/test/setup.ts +3 -0
  322. package/tsconfig.build.json +9 -0
  323. package/tsconfig.build.tsbuildinfo +1 -0
  324. package/tsconfig.json +3 -0
  325. package/tsconfig.test.json +8 -0
  326. package/vercel.json +8 -0
  327. package/vitest.config.ts +70 -0
@@ -0,0 +1,494 @@
1
+ import {
2
+ Accordion,
3
+ AccordionContent,
4
+ AccordionItem,
5
+ AccordionTrigger,
6
+ } from '@shipfox/react-ui/accordion';
7
+ import {Badge, type BadgeVariant} from '@shipfox/react-ui/badge';
8
+ import {Dot} from '@shipfox/react-ui/dot';
9
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
10
+ import {Icon} from '@shipfox/react-ui/icon';
11
+ import {TimeTickerProvider, useTimeTick} from '@shipfox/react-ui/time-ticker';
12
+ import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
13
+ import {Code, Text} from '@shipfox/react-ui/typography';
14
+ import {cn, humanDuration} from '@shipfox/react-ui/utils';
15
+ import type {ReactNode} from 'react';
16
+ import {useEffect, useId, useMemo, useRef, useState} from 'react';
17
+ import {WorkflowStatusIcon} from '#components/workflow-status/workflow-status-icon.js';
18
+ import {
19
+ isWorkflowStatus,
20
+ type Job,
21
+ type JobExecution,
22
+ type Step,
23
+ type StepSourceLocation,
24
+ } from '#core/workflow-run.js';
25
+ import {formatJobExecutionTimeLabel} from '../job-graph/job-duration-format.js';
26
+ import {
27
+ buildStepListModel,
28
+ defaultStepListJobExecution,
29
+ humanizeStatus,
30
+ type StepAttemptModel,
31
+ type StepListEntryModel,
32
+ type StepListModel,
33
+ } from './step-list-model.js';
34
+
35
+ export interface StepExpandedContext {
36
+ step: Step;
37
+ stepId: string;
38
+ stepLabel: string;
39
+ sourceLocation: StepSourceLocation | null;
40
+ attempt: number;
41
+ attemptId: string;
42
+ attemptError: Record<string, unknown> | null;
43
+ attemptStatus: string;
44
+ carriedOver: boolean;
45
+ }
46
+
47
+ export interface StepListEmptyState {
48
+ title: string;
49
+ description: string;
50
+ status?: Job['status'] | undefined;
51
+ }
52
+
53
+ export interface StepListProps {
54
+ job: Job;
55
+ jobExecution?: JobExecution | undefined;
56
+ selectedAttemptId?: string | null | undefined;
57
+ defaultSelectedAttemptId?: string | undefined;
58
+ onSelectedAttemptChange?: ((attemptId: string | undefined) => void) | undefined;
59
+ autoSelectActiveAttempt?: boolean | undefined;
60
+ emptyState?: StepListEmptyState | undefined;
61
+ renderExpandedStep?: ((context: StepExpandedContext) => ReactNode) | undefined;
62
+ showHeader?: boolean | undefined;
63
+ className?: string | undefined;
64
+ }
65
+
66
+ export function StepList({
67
+ job,
68
+ jobExecution,
69
+ selectedAttemptId,
70
+ defaultSelectedAttemptId,
71
+ onSelectedAttemptChange,
72
+ autoSelectActiveAttempt = false,
73
+ emptyState,
74
+ renderExpandedStep,
75
+ showHeader = true,
76
+ className,
77
+ }: StepListProps) {
78
+ const selectedJobExecution = jobExecution ?? defaultStepListJobExecution(job);
79
+ const model = useMemo(
80
+ () => buildStepListModel({job, jobExecution: selectedJobExecution}),
81
+ [job, selectedJobExecution],
82
+ );
83
+
84
+ return (
85
+ <StepListContent
86
+ key={model.jobExecutionId}
87
+ model={model}
88
+ selectedAttemptId={selectedAttemptId}
89
+ defaultSelectedAttemptId={defaultSelectedAttemptId}
90
+ onSelectedAttemptChange={onSelectedAttemptChange}
91
+ autoSelectActiveAttempt={autoSelectActiveAttempt}
92
+ emptyState={emptyState}
93
+ renderExpandedStep={renderExpandedStep}
94
+ showHeader={showHeader}
95
+ className={className}
96
+ />
97
+ );
98
+ }
99
+
100
+ function StepListContent({
101
+ model,
102
+ selectedAttemptId,
103
+ defaultSelectedAttemptId,
104
+ onSelectedAttemptChange,
105
+ autoSelectActiveAttempt,
106
+ emptyState,
107
+ renderExpandedStep,
108
+ showHeader,
109
+ className,
110
+ }: Omit<StepListProps, 'job' | 'jobExecution'> & {model: StepListModel}) {
111
+ const titleId = useId();
112
+ const [localSelectedAttemptIds, setLocalSelectedAttemptIds] = useState<string[]>(() =>
113
+ selectedAttemptId
114
+ ? [selectedAttemptId]
115
+ : defaultSelectedAttemptId
116
+ ? [defaultSelectedAttemptId]
117
+ : [],
118
+ );
119
+ const [userSelectedAttempt, setUserSelectedAttempt] = useState(false);
120
+ const lastNotifiedSelectedAttemptId = useRef<string | null>(null);
121
+ const shouldUseControlledCollapsedState =
122
+ selectedAttemptId === null && lastNotifiedSelectedAttemptId.current === null;
123
+ const autoSelectedAttemptIds =
124
+ selectedAttemptId === undefined &&
125
+ autoSelectActiveAttempt &&
126
+ !userSelectedAttempt &&
127
+ model.activeEntryId
128
+ ? [model.activeEntryId]
129
+ : [];
130
+ const selectedAttemptIds = shouldUseControlledCollapsedState
131
+ ? []
132
+ : localSelectedAttemptIds.length > 0
133
+ ? localSelectedAttemptIds
134
+ : autoSelectedAttemptIds;
135
+ const hasExpandedContent = renderExpandedStep !== undefined;
136
+
137
+ useEffect(() => {
138
+ if (selectedAttemptId !== undefined) return;
139
+
140
+ setLocalSelectedAttemptIds(defaultSelectedAttemptId ? [defaultSelectedAttemptId] : []);
141
+ setUserSelectedAttempt(false);
142
+ }, [defaultSelectedAttemptId, selectedAttemptId]);
143
+
144
+ useEffect(() => {
145
+ if (selectedAttemptId === undefined) return;
146
+
147
+ const nextSelectedAttemptId = selectedAttemptId ?? null;
148
+ if (lastNotifiedSelectedAttemptId.current === nextSelectedAttemptId) {
149
+ lastNotifiedSelectedAttemptId.current = null;
150
+ return;
151
+ }
152
+
153
+ setLocalSelectedAttemptIds(selectedAttemptId ? [selectedAttemptId] : []);
154
+ setUserSelectedAttempt(true);
155
+ }, [selectedAttemptId]);
156
+
157
+ function selectAttempt(nextAttemptIds: string[]) {
158
+ const nextAttemptId = nextSelectedAttemptId(selectedAttemptIds, nextAttemptIds);
159
+ setUserSelectedAttempt(true);
160
+ setLocalSelectedAttemptIds(nextAttemptIds);
161
+ lastNotifiedSelectedAttemptId.current = nextAttemptId ?? null;
162
+ onSelectedAttemptChange?.(nextAttemptId);
163
+ }
164
+
165
+ return (
166
+ <TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
167
+ <section
168
+ aria-labelledby={showHeader ? titleId : undefined}
169
+ className={cn(
170
+ 'flex min-h-0 flex-col rounded-8 border border-border-neutral-base bg-background-components-base',
171
+ className,
172
+ )}
173
+ >
174
+ {showHeader ? (
175
+ <div className="flex min-h-40 items-center border-b border-border-neutral-base px-16 py-8">
176
+ <Text as="h2" id={titleId} size="sm" bold className="text-foreground-neutral-base">
177
+ {model.jobName}
178
+ </Text>
179
+ </div>
180
+ ) : null}
181
+
182
+ {model.entries.length === 0 ? (
183
+ <StepListEmptyStateView emptyState={emptyState} />
184
+ ) : (
185
+ <Accordion
186
+ type="multiple"
187
+ value={selectedAttemptIds}
188
+ onValueChange={selectAttempt}
189
+ asChild
190
+ >
191
+ <ol>
192
+ {model.entries.map((entry) => {
193
+ const selected = selectedAttemptIds.includes(entry.id);
194
+ return (
195
+ <StepRow
196
+ key={entry.id}
197
+ entry={entry}
198
+ selected={selected}
199
+ hasExpandedContent={hasExpandedContent}
200
+ onSelect={() => {
201
+ selectAttempt(
202
+ hasExpandedContent
203
+ ? toggleAttemptId(selectedAttemptIds, entry.id)
204
+ : selected
205
+ ? []
206
+ : [entry.id],
207
+ );
208
+ }}
209
+ expandedContent={
210
+ selected
211
+ ? renderExpandedStep?.({
212
+ step: entry.step,
213
+ stepId: entry.step.id,
214
+ stepLabel: entry.step.label,
215
+ sourceLocation: entry.step.sourceLocation,
216
+ attempt: entry.attempt,
217
+ attemptId: entry.id,
218
+ attemptError: entry.error,
219
+ attemptStatus: entry.statusVisual.kind,
220
+ carriedOver: entry.carriedOver,
221
+ })
222
+ : null
223
+ }
224
+ />
225
+ );
226
+ })}
227
+ </ol>
228
+ </Accordion>
229
+ )}
230
+ </section>
231
+ </TimeTickerProvider>
232
+ );
233
+ }
234
+
235
+ function StepListEmptyStateView({
236
+ emptyState = {
237
+ title: 'No steps recorded',
238
+ description: 'This job has not recorded any steps.',
239
+ },
240
+ }: {
241
+ emptyState?: StepListEmptyState | undefined;
242
+ }) {
243
+ if (!emptyState.status) {
244
+ return (
245
+ <EmptyState
246
+ className="min-h-120 px-16 py-20"
247
+ icon="componentLine"
248
+ title={emptyState.title}
249
+ description={emptyState.description}
250
+ variant="compact"
251
+ />
252
+ );
253
+ }
254
+
255
+ return (
256
+ <div className="flex min-h-120 flex-col items-center justify-center gap-10 px-16 py-20">
257
+ <StepListEmptyStateIcon status={emptyState.status} />
258
+ <div className="text-center">
259
+ <Text size="sm" className="text-foreground-neutral-subtle">
260
+ {emptyState.title}
261
+ </Text>
262
+ <Text size="xs" className="text-foreground-neutral-muted">
263
+ {emptyState.description}
264
+ </Text>
265
+ </div>
266
+ </div>
267
+ );
268
+ }
269
+
270
+ function StepListEmptyStateIcon({status}: {status: Job['status']}) {
271
+ if (status !== 'running') {
272
+ return (
273
+ <div className="flex size-32 items-center justify-center rounded-6 border border-border-neutral-strong bg-background-neutral-base p-8">
274
+ <WorkflowStatusIcon status={status} size={20} tooltip={false} />
275
+ </div>
276
+ );
277
+ }
278
+
279
+ return (
280
+ <div className="flex size-32 items-center justify-center rounded-6 border border-border-neutral-strong bg-background-neutral-base p-8 text-foreground-neutral-muted">
281
+ <Icon name="timerLine" size={18} aria-hidden="true" />
282
+ </div>
283
+ );
284
+ }
285
+
286
+ function StepRow({
287
+ entry,
288
+ selected,
289
+ hasExpandedContent,
290
+ onSelect,
291
+ expandedContent,
292
+ }: {
293
+ entry: StepListEntryModel;
294
+ selected: boolean;
295
+ hasExpandedContent: boolean;
296
+ onSelect: () => void;
297
+ expandedContent: ReactNode;
298
+ }) {
299
+ const shouldShowLabelTooltip = entry.step.label.length > 32;
300
+ const rowContent = (
301
+ <>
302
+ <Icon
303
+ name="chevronRight"
304
+ aria-hidden="true"
305
+ className={cn(
306
+ 'size-14 shrink-0 text-foreground-neutral-muted transition-transform',
307
+ selected && 'rotate-90',
308
+ )}
309
+ />
310
+ <StepStatusIcon entry={entry} />
311
+ <div className="min-w-0 flex-1">
312
+ <div className="flex min-w-0 items-center gap-8">
313
+ <Text size="sm" bold className="truncate text-foreground-neutral-base">
314
+ {entry.step.label}
315
+ </Text>
316
+ {entry.step.attempts.length > 1 ? <StepAttemptChip attempt={entry} /> : null}
317
+ {entry.carriedOver ? <CarriedOverBadge /> : null}
318
+ </div>
319
+ </div>
320
+ <StepAttemptDurationLabel attempt={entry} />
321
+ </>
322
+ );
323
+ const rowClasses = cn(
324
+ 'group grid min-h-44 w-full grid-cols-[14px_14px_minmax(0,1fr)_auto] items-center gap-x-8 px-12 py-6 text-left transition-colors hover:bg-background-components-hover focus-visible:shadow-border-interactive-with-active focus-visible:outline-none',
325
+ selected && 'bg-background-components-hover',
326
+ entry.carriedOver && 'opacity-[0.55]',
327
+ );
328
+ const button = hasExpandedContent ? (
329
+ <AccordionTrigger
330
+ showIcon={false}
331
+ aria-label={entryAccessibleLabel(entry)}
332
+ className={rowClasses}
333
+ >
334
+ {rowContent}
335
+ </AccordionTrigger>
336
+ ) : (
337
+ <button
338
+ type="button"
339
+ aria-expanded={false}
340
+ aria-label={entryAccessibleLabel(entry)}
341
+ onClick={onSelect}
342
+ className={rowClasses}
343
+ >
344
+ {rowContent}
345
+ </button>
346
+ );
347
+ const triggerNode = shouldShowLabelTooltip ? (
348
+ <Tooltip>
349
+ <TooltipTrigger asChild>{button}</TooltipTrigger>
350
+ <TooltipContent>
351
+ <span className="block max-w-320 break-words">{entry.step.label}</span>
352
+ </TooltipContent>
353
+ </Tooltip>
354
+ ) : (
355
+ button
356
+ );
357
+ const row = (
358
+ <>
359
+ {triggerNode}
360
+ {selected && expandedContent ? (
361
+ <AccordionContent className="border-t border-border-neutral-base bg-background-neutral-base px-12 py-12">
362
+ <div className="grid grid-cols-[14px_14px_minmax(0,1fr)_auto] gap-x-8">
363
+ <span aria-hidden="true" />
364
+ <span aria-hidden="true" />
365
+ <div className="min-w-0">{expandedContent}</div>
366
+ <span aria-hidden="true" />
367
+ </div>
368
+ </AccordionContent>
369
+ ) : null}
370
+ </>
371
+ );
372
+
373
+ if (hasExpandedContent) {
374
+ return (
375
+ <AccordionItem value={entry.id} asChild>
376
+ <li>{row}</li>
377
+ </AccordionItem>
378
+ );
379
+ }
380
+
381
+ return <li className="border-b border-border-neutral-base last:border-b-0">{row}</li>;
382
+ }
383
+
384
+ function CarriedOverBadge() {
385
+ return (
386
+ <Tooltip>
387
+ <TooltipTrigger asChild>
388
+ <span className="shrink-0">
389
+ <Badge variant="neutral" size="2xs">
390
+ reused
391
+ </Badge>
392
+ </span>
393
+ </TooltipTrigger>
394
+ <TooltipContent>
395
+ Carried over from a previous attempt; did not run in this attempt.
396
+ </TooltipContent>
397
+ </Tooltip>
398
+ );
399
+ }
400
+
401
+ function toggleAttemptId(selectedAttemptIds: readonly string[], attemptId: string): string[] {
402
+ if (selectedAttemptIds.includes(attemptId)) {
403
+ return selectedAttemptIds.filter((selectedAttemptId) => selectedAttemptId !== attemptId);
404
+ }
405
+ return [...selectedAttemptIds, attemptId];
406
+ }
407
+
408
+ function nextSelectedAttemptId(
409
+ selectedAttemptIds: readonly string[],
410
+ nextAttemptIds: readonly string[],
411
+ ): string | undefined {
412
+ if (nextAttemptIds.length === 0) return undefined;
413
+
414
+ const openedAttemptId = nextAttemptIds.find(
415
+ (attemptId) => !selectedAttemptIds.includes(attemptId),
416
+ );
417
+ return openedAttemptId ?? nextAttemptIds.at(-1);
418
+ }
419
+
420
+ function StepStatusIcon({entry}: {entry: StepListEntryModel}) {
421
+ if (isWorkflowStatus(entry.statusVisual.kind)) {
422
+ return (
423
+ <WorkflowStatusIcon
424
+ status={entry.statusVisual.kind}
425
+ ripple={entry.statusVisual.ripple}
426
+ size={14}
427
+ />
428
+ );
429
+ }
430
+
431
+ return (
432
+ <span role="img" aria-label={entry.statusVisual.label} className="inline-flex shrink-0">
433
+ <Dot
434
+ variant={entry.statusVisual.dot}
435
+ ripple={entry.statusVisual.ripple}
436
+ className="size-12 shrink-0"
437
+ />
438
+ </span>
439
+ );
440
+ }
441
+
442
+ const attemptChipClasses: Record<NonNullable<BadgeVariant>, string> = {
443
+ neutral: 'bg-tag-neutral-bg border-tag-neutral-border',
444
+ info: 'bg-tag-blue-bg border-tag-blue-border',
445
+ feature: 'bg-tag-purple-bg border-tag-purple-border',
446
+ success: 'bg-tag-success-bg border-tag-success-border',
447
+ warning: 'bg-tag-warning-bg border-tag-warning-border',
448
+ error: 'bg-tag-error-bg border-tag-error-border',
449
+ };
450
+
451
+ function StepAttemptChip({attempt}: {attempt: StepAttemptModel}) {
452
+ return (
453
+ <div className="flex shrink-0 items-center gap-4" aria-hidden="true">
454
+ <span
455
+ className={cn(
456
+ 'inline-flex h-18 min-w-24 items-center justify-center rounded-4 border px-5 font-code text-xs leading-16 text-foreground-neutral-base',
457
+ attemptChipClasses[attempt.statusVisual.badge ?? 'neutral'],
458
+ )}
459
+ >
460
+ #{attempt.attempt}
461
+ </span>
462
+ </div>
463
+ );
464
+ }
465
+
466
+ function entryAccessibleLabel(entry: StepListEntryModel): string {
467
+ const parts = [entry.step.label, entry.statusVisual.label, `attempt ${entry.attempt}`];
468
+ if (entry.step.error?.category) parts.push(humanizeStatus(entry.step.error.category));
469
+ return parts.join(', ');
470
+ }
471
+
472
+ function StepAttemptDurationLabel({attempt}: {attempt: StepAttemptModel}) {
473
+ const duration = attempt.displayDuration;
474
+ if (duration === null) return null;
475
+
476
+ if (duration.state === 'live') {
477
+ return <LiveDurationText fromIso={duration.fromIso} />;
478
+ }
479
+
480
+ return <DurationText>{formatJobExecutionTimeLabel(duration)}</DurationText>;
481
+ }
482
+
483
+ function LiveDurationText({fromIso}: {fromIso: string}) {
484
+ useTimeTick();
485
+ return <DurationText>{humanDuration(fromIso)}</DurationText>;
486
+ }
487
+
488
+ function DurationText({children}: {children: string}) {
489
+ return (
490
+ <Code as="span" variant="label" className="shrink-0 tabular-nums text-foreground-neutral-muted">
491
+ {children}
492
+ </Code>
493
+ );
494
+ }
@@ -0,0 +1,118 @@
1
+ import {Icon} from '@shipfox/react-ui/icon';
2
+ import {useTimeTick} from '@shipfox/react-ui/time-ticker';
3
+ import {Code} from '@shipfox/react-ui/typography';
4
+ import {cn, humanDuration} from '@shipfox/react-ui/utils';
5
+ import type {WorkflowRunAttemptDisplayDuration} from '#core/workflow-run.js';
6
+
7
+ export function WorkflowRunDurationLabel({
8
+ duration,
9
+ className,
10
+ }: {
11
+ duration: WorkflowRunAttemptDisplayDuration | null;
12
+ className?: string | undefined;
13
+ }) {
14
+ if (duration === null) return null;
15
+
16
+ switch (duration.state) {
17
+ case 'fixed': {
18
+ const display = formatFixedDurationLabel(duration.elapsed);
19
+ return (
20
+ <DurationText className={className} ariaLabel={`ran ${display}`}>
21
+ {display}
22
+ </DurationText>
23
+ );
24
+ }
25
+ case 'live':
26
+ return <LiveDurationText duration={duration} className={className} />;
27
+ default: {
28
+ const exhaustive: never = duration;
29
+ return exhaustive;
30
+ }
31
+ }
32
+ }
33
+
34
+ export function useWorkflowRunDurationAccessibleLabel(
35
+ duration: WorkflowRunAttemptDisplayDuration | null,
36
+ ): string | undefined {
37
+ useTimeTick();
38
+ return workflowRunDurationAccessibleLabel(duration);
39
+ }
40
+
41
+ export function workflowRunDurationAccessibleLabel(
42
+ duration: WorkflowRunAttemptDisplayDuration | null,
43
+ ): string | undefined {
44
+ if (duration === null) return undefined;
45
+
46
+ switch (duration.state) {
47
+ case 'live':
48
+ return `running ${humanDuration(duration.fromIso)}`;
49
+ case 'fixed':
50
+ return `ran ${formatFixedDurationLabel(duration.elapsed)}`;
51
+ default: {
52
+ const exhaustive: never = duration;
53
+ return exhaustive;
54
+ }
55
+ }
56
+ }
57
+
58
+ function LiveDurationText({
59
+ duration,
60
+ className,
61
+ }: {
62
+ duration: Extract<WorkflowRunAttemptDisplayDuration, {state: 'live'}>;
63
+ className?: string | undefined;
64
+ }) {
65
+ useTimeTick();
66
+ const display = humanDuration(duration.fromIso);
67
+ return (
68
+ <DurationText className={className} ariaLabel={`running ${display}`}>
69
+ {display}
70
+ </DurationText>
71
+ );
72
+ }
73
+
74
+ function DurationText({
75
+ children,
76
+ className,
77
+ ariaLabel,
78
+ }: {
79
+ children: string;
80
+ className?: string | undefined;
81
+ ariaLabel?: string | undefined;
82
+ }) {
83
+ return (
84
+ <Code
85
+ as="span"
86
+ variant="label"
87
+ aria-label={ariaLabel}
88
+ className={cn(
89
+ 'inline-flex shrink-0 items-center gap-4 tabular-nums text-foreground-neutral-muted',
90
+ className,
91
+ )}
92
+ >
93
+ <Icon name="timerLine" className="size-12 shrink-0" aria-hidden="true" />
94
+ {children}
95
+ </Code>
96
+ );
97
+ }
98
+
99
+ function formatFixedDurationLabel({
100
+ years = 0,
101
+ months = 0,
102
+ weeks = 0,
103
+ days = 0,
104
+ hours = 0,
105
+ minutes = 0,
106
+ seconds = 0,
107
+ }: Extract<WorkflowRunAttemptDisplayDuration, {state: 'fixed'}>['elapsed']): string {
108
+ const totalDays = years * 365 + months * 30 + weeks * 7 + days;
109
+ const totalHours = totalDays * 24 + hours;
110
+
111
+ if (totalHours > 0) return `${totalHours}h ${pad2(minutes)}m`;
112
+ if (minutes > 0) return `${minutes}m ${pad2(seconds)}s`;
113
+ return `${seconds}s`;
114
+ }
115
+
116
+ function pad2(value: number): string {
117
+ return value.toString().padStart(2, '0');
118
+ }
@@ -0,0 +1 @@
1
+ export {WorkflowRunList} from './workflow-run-list.js';
@@ -0,0 +1,53 @@
1
+ import {workflowRunListItem} from '#test/fixtures/workflow-run.js';
2
+ import {runMatchesSearch, runMatchesStatusFilter} from './run-display.js';
3
+
4
+ describe('runMatchesSearch', () => {
5
+ test('matches every run on a blank query', () => {
6
+ const matches = runMatchesSearch(workflowRunListItem(), ' ');
7
+
8
+ expect(matches).toBe(true);
9
+ });
10
+
11
+ test('matches case-insensitively across id, name, status, and trigger', () => {
12
+ const run = workflowRunListItem({
13
+ id: 'ABCD1234-X',
14
+ name: 'Deploy Production',
15
+ status: 'running',
16
+ trigger_provider: 'github',
17
+ trigger_source: 'github_acme',
18
+ trigger_event: 'push',
19
+ });
20
+
21
+ expect(runMatchesSearch(run, 'abcd1234-x')).toBe(true);
22
+ expect(runMatchesSearch(run, 'deploy production')).toBe(true);
23
+ expect(runMatchesSearch(run, 'RUNNING')).toBe(true);
24
+ expect(runMatchesSearch(run, 'github_acme · push')).toBe(true);
25
+ });
26
+
27
+ test('returns false when nothing in the run contains the query', () => {
28
+ const matches = runMatchesSearch(workflowRunListItem(), 'no-such-run');
29
+
30
+ expect(matches).toBe(false);
31
+ });
32
+ });
33
+
34
+ describe('runMatchesStatusFilter', () => {
35
+ test('matches every status when the filter is "all"', () => {
36
+ expect(runMatchesStatusFilter('succeeded', 'all')).toBe(true);
37
+ expect(runMatchesStatusFilter('cancelled', 'all')).toBe(true);
38
+ });
39
+
40
+ test('"failed" matches only failed runs', () => {
41
+ expect(runMatchesStatusFilter('failed', 'failed')).toBe(true);
42
+ expect(runMatchesStatusFilter('running', 'failed')).toBe(false);
43
+ expect(runMatchesStatusFilter('succeeded', 'failed')).toBe(false);
44
+ });
45
+
46
+ test('"running" reads as in-progress and also covers pending runs', () => {
47
+ expect(runMatchesStatusFilter('running', 'running')).toBe(true);
48
+ expect(runMatchesStatusFilter('pending', 'running')).toBe(true);
49
+ expect(runMatchesStatusFilter('succeeded', 'running')).toBe(false);
50
+ expect(runMatchesStatusFilter('failed', 'running')).toBe(false);
51
+ expect(runMatchesStatusFilter('cancelled', 'running')).toBe(false);
52
+ });
53
+ });
@@ -0,0 +1,22 @@
1
+ import type {WorkflowRunListItem, WorkflowRunStatus} from '#core/workflow-run.js';
2
+ import type {WorkflowRunListStatusFilter} from './types.js';
3
+
4
+ export function runMatchesSearch(run: WorkflowRunListItem, query: string): boolean {
5
+ const needle = query.trim().toLowerCase();
6
+ if (needle === '') return true;
7
+ const haystack = `${run.id} ${run.name} ${run.status} ${run.triggerLabel}`.toLowerCase();
8
+ return haystack.includes(needle);
9
+ }
10
+
11
+ const IN_PROGRESS_STATUSES: ReadonlySet<WorkflowRunStatus> = new Set(['pending', 'running']);
12
+
13
+ export function runMatchesStatusFilter(
14
+ status: WorkflowRunStatus,
15
+ filter: WorkflowRunListStatusFilter,
16
+ ): boolean {
17
+ if (filter === 'all') return true;
18
+ // "Running" reads as in-progress: it covers freshly-queued `pending` runs (including the
19
+ // optimistic manual run inserted on fire) so they are not hidden the moment the filter is on.
20
+ if (filter === 'running') return IN_PROGRESS_STATUSES.has(status);
21
+ return status === filter;
22
+ }