@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,347 @@
1
+ import type {WorkflowRunJobDetailDto} from '@shipfox/api-workflows-dto';
2
+ import {TimeTickerProvider} from '@shipfox/react-ui/time-ticker';
3
+ import {act, render, screen, within} from '@testing-library/react';
4
+ import userEvent from '@testing-library/user-event';
5
+ import {workflowJob, workflowJobExecutionDto} from '#test/fixtures/workflow-run.js';
6
+ import type {JobGraphNode} from './graph-model.js';
7
+ import {JobNode} from './job-node.js';
8
+
9
+ const NOW = Date.parse('2026-06-26T12:00:00.000Z');
10
+
11
+ type NodeOverrides = Omit<Partial<WorkflowRunJobDetailDto>, 'job_executions'> & {
12
+ name: string;
13
+ job_executions?: WorkflowRunJobDetailDto['job_executions'];
14
+ queued_at?: string | null;
15
+ started_at?: string | null;
16
+ finished_at?: string | null;
17
+ };
18
+
19
+ function makeNode(overrides: NodeOverrides): JobGraphNode {
20
+ const {queued_at, started_at, finished_at, job_executions, ...jobOverrides} = overrides;
21
+ const shouldCreateExecution =
22
+ job_executions === undefined &&
23
+ (queued_at !== undefined || started_at !== undefined || finished_at !== undefined);
24
+ const jobOverrideWithExecutions: NodeOverrides = {...jobOverrides};
25
+ if (shouldCreateExecution) {
26
+ jobOverrideWithExecutions.job_executions = [
27
+ workflowJobExecutionDto({
28
+ ...(jobOverrides.id === undefined ? {} : {job_id: jobOverrides.id}),
29
+ ...(jobOverrides.status === undefined
30
+ ? {}
31
+ : {status: jobOverrides.status === 'skipped' ? 'cancelled' : jobOverrides.status}),
32
+ queued_at: queued_at ?? null,
33
+ started_at: started_at ?? null,
34
+ finished_at: finished_at ?? null,
35
+ }),
36
+ ];
37
+ } else if (job_executions !== undefined) {
38
+ jobOverrideWithExecutions.job_executions = job_executions;
39
+ }
40
+ const job = workflowJob(jobOverrideWithExecutions);
41
+ return Object.assign(Object.create(Object.getPrototypeOf(job)), job, {
42
+ column: 0,
43
+ row: 0,
44
+ currentDependencyCount: 0,
45
+ });
46
+ }
47
+
48
+ function renderNode(node: JobGraphNode, {live = false}: {live?: boolean} = {}) {
49
+ const element = (
50
+ <JobNode
51
+ node={node}
52
+ selected={false}
53
+ onSelect={() => undefined}
54
+ onKeyDown={() => undefined}
55
+ onHoverStart={() => undefined}
56
+ onHoverEnd={() => undefined}
57
+ />
58
+ );
59
+
60
+ if (live) {
61
+ return render(<TimeTickerProvider intervalMs={1000}>{element}</TimeTickerProvider>);
62
+ }
63
+
64
+ return render(element);
65
+ }
66
+
67
+ function setMatchMedia(reduced: boolean) {
68
+ Object.defineProperty(window, 'matchMedia', {
69
+ configurable: true,
70
+ value: (query: string) => ({
71
+ matches: query.includes('reduce') ? reduced : query.includes('min-width'),
72
+ media: query,
73
+ onchange: null,
74
+ addEventListener: () => undefined,
75
+ removeEventListener: () => undefined,
76
+ addListener: () => undefined,
77
+ removeListener: () => undefined,
78
+ dispatchEvent: () => false,
79
+ }),
80
+ });
81
+ }
82
+
83
+ function setVisibility(state: 'visible' | 'hidden') {
84
+ Object.defineProperty(document, 'visibilityState', {configurable: true, value: state});
85
+ }
86
+
87
+ describe('JobNode duration', () => {
88
+ beforeEach(() => {
89
+ setMatchMedia(false);
90
+ setVisibility('visible');
91
+ });
92
+
93
+ afterEach(() => {
94
+ vi.useRealTimers();
95
+ vi.restoreAllMocks();
96
+ setMatchMedia(false);
97
+ setVisibility('visible');
98
+ });
99
+
100
+ test('shows the static span for a finished job', () => {
101
+ const node = makeNode({
102
+ name: 'build',
103
+ status: 'succeeded',
104
+ started_at: '2026-06-26T11:57:46.000Z',
105
+ finished_at: '2026-06-26T12:00:00.000Z',
106
+ });
107
+
108
+ renderNode(node);
109
+
110
+ expect(screen.getByText('2m 14s')).toBeInTheDocument();
111
+ expect(screen.getByRole('button', {name: 'build, Succeeded, ran 2m 14s'})).toBeInTheDocument();
112
+ });
113
+
114
+ test('clamps a skewed finished span (finishedAt before startedAt) to 0s', () => {
115
+ const node = makeNode({
116
+ name: 'build',
117
+ status: 'succeeded',
118
+ started_at: '2026-06-26T12:00:00.000Z',
119
+ finished_at: '2026-06-26T11:59:55.000Z',
120
+ });
121
+
122
+ renderNode(node);
123
+
124
+ expect(screen.getByText('0s')).toBeInTheDocument();
125
+ });
126
+
127
+ test('shows no duration for a skipped job that never executed', () => {
128
+ const node = makeNode({name: 'lint', status: 'skipped'});
129
+
130
+ renderNode(node);
131
+
132
+ // Exact accessible name proves no duration phrase was appended.
133
+ expect(screen.getByRole('button', {name: 'lint, Skipped'})).toBeInTheDocument();
134
+ });
135
+
136
+ test('shows live elapsed from startedAt for a running job', () => {
137
+ vi.spyOn(Date, 'now').mockReturnValue(NOW);
138
+ const node = makeNode({
139
+ name: 'test',
140
+ status: 'running',
141
+ queued_at: '2026-06-26T11:54:00.000Z',
142
+ started_at: '2026-06-26T11:57:46.000Z',
143
+ });
144
+
145
+ renderNode(node);
146
+
147
+ expect(screen.getByText('2m 14s')).toBeInTheDocument();
148
+ expect(screen.getByRole('button', {name: 'test, Running, running 2m 14s'})).toBeInTheDocument();
149
+ });
150
+
151
+ test('keeps live accessible duration in sync with visible duration', () => {
152
+ vi.useFakeTimers();
153
+ vi.setSystemTime(NOW);
154
+ const node = makeNode({
155
+ name: 'test',
156
+ status: 'running',
157
+ queued_at: '2026-06-26T11:54:00.000Z',
158
+ started_at: '2026-06-26T11:57:46.000Z',
159
+ });
160
+
161
+ renderNode(node, {live: true});
162
+
163
+ expect(screen.getByText('2m 14s')).toBeInTheDocument();
164
+ expect(screen.getByRole('button', {name: 'test, Running, running 2m 14s'})).toBeInTheDocument();
165
+
166
+ act(() => {
167
+ vi.advanceTimersByTime(1000);
168
+ });
169
+
170
+ expect(screen.getByText('2m 15s')).toBeInTheDocument();
171
+ expect(screen.getByRole('button', {name: 'test, Running, running 2m 15s'})).toBeInTheDocument();
172
+ });
173
+
174
+ test('shows live elapsed from queuedAt for a job waiting in the queue', () => {
175
+ vi.spyOn(Date, 'now').mockReturnValue(NOW);
176
+ const node = makeNode({
177
+ name: 'deploy',
178
+ status: 'pending',
179
+ queued_at: '2026-06-26T11:54:00.000Z',
180
+ started_at: null,
181
+ });
182
+
183
+ renderNode(node);
184
+
185
+ expect(screen.getByText('6m 00s')).toBeInTheDocument();
186
+ expect(
187
+ screen.getByRole('button', {name: 'deploy, Pending, queueing 6m 00s'}),
188
+ ).toBeInTheDocument();
189
+ });
190
+
191
+ test('does not show a duration for listening jobs', () => {
192
+ vi.spyOn(Date, 'now').mockReturnValue(NOW);
193
+ const node = makeNode({
194
+ name: 'deploy-window',
195
+ mode: 'listening',
196
+ status: 'running',
197
+ listener_status: 'listening',
198
+ });
199
+
200
+ renderNode(node);
201
+
202
+ expect(screen.queryByText('2m 14s')).not.toBeInTheDocument();
203
+ expect(screen.getByRole('button', {name: 'deploy-window, Running'})).toBeInTheDocument();
204
+ });
205
+ });
206
+
207
+ describe('JobNode listening indicator', () => {
208
+ beforeEach(() => {
209
+ setMatchMedia(false);
210
+ setVisibility('visible');
211
+ });
212
+
213
+ afterEach(() => {
214
+ vi.restoreAllMocks();
215
+ setMatchMedia(false);
216
+ setVisibility('visible');
217
+ });
218
+
219
+ test('shows active listening jobs through the status icon', async () => {
220
+ const user = userEvent.setup();
221
+ const node = makeNode({
222
+ name: 'deploy-window',
223
+ mode: 'listening',
224
+ status: 'running',
225
+ listener_status: 'listening',
226
+ });
227
+
228
+ renderNode(node);
229
+
230
+ expect(screen.getByRole('button', {name: 'deploy-window, Running'})).toBeInTheDocument();
231
+ expect(screen.queryByLabelText('Waiting for events to start job')).not.toBeInTheDocument();
232
+
233
+ await user.hover(screen.getByRole('img', {name: 'Running'}));
234
+
235
+ expect(await screen.findByRole('tooltip')).toHaveTextContent('Running');
236
+ });
237
+
238
+ test('does not show the listener icon before the listener is armed or after it resolves', () => {
239
+ const pendingNode = makeNode({
240
+ name: 'release-gates',
241
+ mode: 'listening',
242
+ status: 'pending',
243
+ listener_status: 'inactive',
244
+ });
245
+ const resolvedNode = makeNode({
246
+ name: 'release-gates',
247
+ mode: 'listening',
248
+ status: 'succeeded',
249
+ listener_status: 'resolved',
250
+ });
251
+
252
+ const {unmount} = renderNode(pendingNode);
253
+ expect(screen.queryByLabelText('Waiting for events to start job')).not.toBeInTheDocument();
254
+ expect(screen.getByRole('button', {name: 'release-gates, Pending'})).toBeInTheDocument();
255
+ unmount();
256
+
257
+ renderNode(resolvedNode);
258
+ expect(screen.queryByLabelText('Waiting for events to start job')).not.toBeInTheDocument();
259
+ expect(screen.getByRole('button', {name: 'release-gates, Succeeded'})).toBeInTheDocument();
260
+ });
261
+ });
262
+
263
+ describe('JobNode status indicator', () => {
264
+ beforeEach(() => {
265
+ setMatchMedia(false);
266
+ setVisibility('visible');
267
+ });
268
+
269
+ afterEach(() => {
270
+ vi.restoreAllMocks();
271
+ setMatchMedia(false);
272
+ setVisibility('visible');
273
+ });
274
+
275
+ test('shows a tooltip with the human-readable job status', async () => {
276
+ const user = userEvent.setup();
277
+ const node = makeNode({
278
+ name: 'deploy',
279
+ status: 'running',
280
+ });
281
+
282
+ renderNode(node);
283
+
284
+ await user.hover(screen.getByRole('img', {name: 'Running'}));
285
+
286
+ expect(await screen.findByRole('tooltip')).toHaveTextContent('Running');
287
+ });
288
+ });
289
+
290
+ describe('JobNode execution count indicator', () => {
291
+ beforeEach(() => {
292
+ setMatchMedia(false);
293
+ setVisibility('visible');
294
+ });
295
+
296
+ afterEach(() => {
297
+ vi.restoreAllMocks();
298
+ setMatchMedia(false);
299
+ setVisibility('visible');
300
+ });
301
+
302
+ test('shows a muted execution count with status tooltip', async () => {
303
+ const user = userEvent.setup();
304
+ const node = makeNode({
305
+ name: 'release-gates',
306
+ mode: 'listening',
307
+ status: 'running',
308
+ job_executions: [
309
+ workflowJobExecutionDto({status: 'pending'}),
310
+ workflowJobExecutionDto({status: 'running'}),
311
+ workflowJobExecutionDto({status: 'running'}),
312
+ workflowJobExecutionDto({status: 'succeeded'}),
313
+ workflowJobExecutionDto({status: 'succeeded'}),
314
+ workflowJobExecutionDto({status: 'succeeded'}),
315
+ workflowJobExecutionDto({status: 'failed'}),
316
+ workflowJobExecutionDto({status: 'cancelled'}),
317
+ ],
318
+ });
319
+
320
+ renderNode(node);
321
+
322
+ const button = screen.getByRole('button', {name: 'release-gates, Running, 8 executions'});
323
+ expect(within(button).getByText('8')).toBeInTheDocument();
324
+ expect(within(button).queryByText('8 exec')).not.toBeInTheDocument();
325
+ expect(button.querySelector('[data-execution-status-segment]')).not.toBeInTheDocument();
326
+
327
+ await user.hover(within(button).getByText('8'));
328
+
329
+ expect(await screen.findByRole('tooltip')).toHaveTextContent(
330
+ '1 pending, 2 running, 3 succeeded, 1 failed, 1 cancelled',
331
+ );
332
+ });
333
+
334
+ test('hides the execution badge when there are no executions', () => {
335
+ const node = makeNode({
336
+ name: 'release-gates',
337
+ mode: 'listening',
338
+ status: 'pending',
339
+ job_executions: [],
340
+ });
341
+
342
+ renderNode(node);
343
+
344
+ const button = screen.getByRole('button', {name: 'release-gates, Pending'});
345
+ expect(within(button).queryByText('0')).not.toBeInTheDocument();
346
+ });
347
+ });
@@ -0,0 +1,202 @@
1
+ import {TriggerSourceIcon} from '@shipfox/client-triggers';
2
+ import {Badge} from '@shipfox/react-ui/badge';
3
+ import {useIsTextTruncated} from '@shipfox/react-ui/hooks';
4
+ import {Icon} from '@shipfox/react-ui/icon';
5
+ import {useTimeTick} from '@shipfox/react-ui/time-ticker';
6
+ import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
7
+ import {Code} from '@shipfox/react-ui/typography';
8
+ import {cn} from '@shipfox/react-ui/utils';
9
+ import type {KeyboardEventHandler, Ref} from 'react';
10
+ import {getWorkflowStatusVisual} from '#components/workflow-status/status-visuals.js';
11
+ import {WorkflowStatusIcon} from '#components/workflow-status/workflow-status-icon.js';
12
+ import type {JobExecution, JobExecutionStatus, WorkflowRunDetail} from '#core/workflow-run.js';
13
+ import type {JobGraphNode} from './graph-model.js';
14
+ import {formatJobDurationAccessibleLabel} from './job-duration-format.js';
15
+ import {JobDurationLabel} from './job-duration-label.js';
16
+
17
+ const TRIGGER_SIZE = 36;
18
+
19
+ export function TriggerNode({
20
+ trigger,
21
+ }: {
22
+ trigger: Pick<
23
+ WorkflowRunDetail,
24
+ 'triggerDisplayLabel' | 'triggerLabel' | 'triggerProvider' | 'triggerSource'
25
+ >;
26
+ }) {
27
+ const label = trigger.triggerDisplayLabel || 'trigger';
28
+ const tooltip = trigger.triggerLabel || label;
29
+
30
+ return (
31
+ <Tooltip>
32
+ <TooltipTrigger asChild>
33
+ <button
34
+ type="button"
35
+ aria-label={label}
36
+ className="flex items-center justify-center rounded-full border border-border-neutral-base bg-background-components-base transition-colors hover:bg-background-components-hover focus-visible:shadow-border-interactive-with-active focus-visible:outline-none"
37
+ style={{width: TRIGGER_SIZE, height: TRIGGER_SIZE}}
38
+ >
39
+ <TriggerSourceIcon
40
+ provider={trigger.triggerProvider}
41
+ source={trigger.triggerSource}
42
+ aria-hidden
43
+ className="size-14 shrink-0 text-foreground-neutral-muted"
44
+ />
45
+ </button>
46
+ </TooltipTrigger>
47
+ <TooltipContent>
48
+ <span>{tooltip}</span>
49
+ </TooltipContent>
50
+ </Tooltip>
51
+ );
52
+ }
53
+
54
+ export function JobNode({
55
+ node,
56
+ selected,
57
+ onSelect,
58
+ onKeyDown,
59
+ onHoverStart,
60
+ onHoverEnd,
61
+ ref,
62
+ }: {
63
+ node: JobGraphNode;
64
+ selected: boolean;
65
+ onSelect: () => void;
66
+ onKeyDown: KeyboardEventHandler<HTMLButtonElement>;
67
+ onHoverStart: () => void;
68
+ onHoverEnd: () => void;
69
+ ref?: Ref<HTMLButtonElement>;
70
+ }) {
71
+ useTimeTick();
72
+ const visual = getWorkflowStatusVisual(node.status);
73
+ const accessibleLabel = [
74
+ node.displayName,
75
+ visual.label,
76
+ formatJobDurationAccessibleLabel(node.displayDuration),
77
+ node.executionCountVisible
78
+ ? executionCountAccessibleLabel(node.jobExecutions.length)
79
+ : undefined,
80
+ node.carriedOver ? 'reused' : undefined,
81
+ ]
82
+ .filter((part): part is string => Boolean(part))
83
+ .join(', ');
84
+
85
+ return (
86
+ <button
87
+ ref={ref}
88
+ type="button"
89
+ aria-pressed={selected}
90
+ aria-label={accessibleLabel}
91
+ data-job-id={node.id}
92
+ onClick={onSelect}
93
+ onKeyDown={onKeyDown}
94
+ onPointerEnter={onHoverStart}
95
+ onPointerLeave={onHoverEnd}
96
+ className={cn(
97
+ 'group relative flex h-48 w-208 items-center gap-8 rounded-8 border border-border-neutral-base bg-background-components-base px-10 text-left transition-colors hover:bg-background-components-hover focus-visible:shadow-border-interactive-with-active focus-visible:outline-none',
98
+ selected && 'bg-background-components-hover',
99
+ node.carriedOver && 'opacity-[0.55]',
100
+ )}
101
+ >
102
+ {selected ? (
103
+ <span
104
+ aria-hidden="true"
105
+ className="absolute inset-y-6 left-0 w-3 rounded-full bg-border-highlights-interactive"
106
+ />
107
+ ) : null}
108
+ <div className="flex min-w-0 flex-1 items-center gap-8">
109
+ <WorkflowStatusIcon status={node.status} jobMode={node.mode} size={14} />
110
+ <JobLabel label={node.displayName} />
111
+ </div>
112
+ <JobDurationLabel duration={node.displayDuration} />
113
+ {node.executionCountVisible ? <ExecutionCountText executions={node.jobExecutions} /> : null}
114
+ {node.carriedOver ? <CarriedOverBadge /> : null}
115
+ </button>
116
+ );
117
+ }
118
+
119
+ function ExecutionCountText({executions}: {executions: JobExecution[]}) {
120
+ const count = executions.length;
121
+
122
+ return (
123
+ <Tooltip>
124
+ <TooltipTrigger asChild>
125
+ <span
126
+ aria-hidden="true"
127
+ className="inline-flex h-20 min-w-28 shrink-0 items-center justify-end gap-4 text-foreground-neutral-muted"
128
+ >
129
+ <Icon name="loopRightLine" className="size-12" />
130
+ <Code as="span" variant="label" className="text-current">
131
+ {count}
132
+ </Code>
133
+ </span>
134
+ </TooltipTrigger>
135
+ <TooltipContent>{executionCountTooltip(executions)}</TooltipContent>
136
+ </Tooltip>
137
+ );
138
+ }
139
+
140
+ function CarriedOverBadge() {
141
+ return (
142
+ <Tooltip>
143
+ <TooltipTrigger asChild>
144
+ <span className="shrink-0">
145
+ <Badge variant="neutral" size="2xs">
146
+ reused
147
+ </Badge>
148
+ </span>
149
+ </TooltipTrigger>
150
+ <TooltipContent>
151
+ Carried over from a previous attempt; did not run in this attempt
152
+ </TooltipContent>
153
+ </Tooltip>
154
+ );
155
+ }
156
+
157
+ function JobLabel({label}: {label: string}) {
158
+ const {ref: labelRef, isTruncated} = useIsTextTruncated<HTMLSpanElement>(label);
159
+
160
+ return (
161
+ <Tooltip>
162
+ <TooltipTrigger asChild>
163
+ <span ref={labelRef} className="block min-w-0 truncate">
164
+ <Code as="span" variant="label" bold className="text-foreground-neutral-base">
165
+ {label}
166
+ </Code>
167
+ </span>
168
+ </TooltipTrigger>
169
+ {isTruncated ? <TooltipContent>{label}</TooltipContent> : null}
170
+ </Tooltip>
171
+ );
172
+ }
173
+
174
+ function executionCountAccessibleLabel(count: number): string {
175
+ return `${count} ${count === 1 ? 'execution' : 'executions'}`;
176
+ }
177
+
178
+ function executionCountTooltip(executions: JobExecution[]): string {
179
+ const counts = executionStatusCounts(executions);
180
+ const parts = EXECUTION_STATUSES.map((status) =>
181
+ counts[status] > 0 ? `${counts[status]} ${status}` : undefined,
182
+ ).filter((part): part is string => part !== undefined);
183
+ return parts.length > 0 ? parts.join(', ') : 'No executions';
184
+ }
185
+
186
+ const EXECUTION_STATUSES = [
187
+ 'pending',
188
+ 'running',
189
+ 'succeeded',
190
+ 'failed',
191
+ 'cancelled',
192
+ ] as const satisfies readonly JobExecutionStatus[];
193
+
194
+ function executionStatusCounts(executions: JobExecution[]) {
195
+ return executions.reduce(
196
+ (counts, execution) => {
197
+ counts[execution.status] += 1;
198
+ return counts;
199
+ },
200
+ {pending: 0, running: 0, succeeded: 0, failed: 0, cancelled: 0},
201
+ );
202
+ }
@@ -0,0 +1,9 @@
1
+ import type {WorkflowRunDetail} from '#core/workflow-run.js';
2
+
3
+ export interface JobGraphProps {
4
+ run: WorkflowRunDetail;
5
+ selectedJobId?: string | undefined;
6
+ defaultSelectedJobId?: string | undefined;
7
+ onSelectedJobChange?: ((jobId: string | undefined) => void) | undefined;
8
+ className?: string | undefined;
9
+ }
@@ -0,0 +1,6 @@
1
+ export type {
2
+ StepExpandedContext,
3
+ StepListEmptyState,
4
+ StepListProps,
5
+ } from './step-list.js';
6
+ export {StepList} from './step-list.js';