@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,54 @@
1
+ import {humanDuration} from '@shipfox/react-ui/utils';
2
+ import type {JobDisplayDuration, JobExecutionTime} from '#core/workflow-run.js';
3
+
4
+ export function formatJobExecutionTimeLabel(time: JobExecutionTime): string {
5
+ switch (time.state) {
6
+ case 'live':
7
+ return humanDuration(time.fromIso);
8
+ case 'fixed':
9
+ return formatFixedDurationLabel(time.elapsed);
10
+ default: {
11
+ const exhaustive: never = time;
12
+ return exhaustive;
13
+ }
14
+ }
15
+ }
16
+
17
+ export function formatJobDurationAccessibleLabel(
18
+ duration: JobDisplayDuration | null,
19
+ ): string | undefined {
20
+ if (duration === null) return undefined;
21
+
22
+ const label = formatJobExecutionTimeLabel(duration);
23
+ switch (duration.kind) {
24
+ case 'queue':
25
+ return duration.state === 'live' ? `queueing ${label}` : `queued ${label}`;
26
+ case 'run':
27
+ return duration.state === 'live' ? `running ${label}` : `ran ${label}`;
28
+ default: {
29
+ const exhaustive: never = duration;
30
+ return exhaustive;
31
+ }
32
+ }
33
+ }
34
+
35
+ function formatFixedDurationLabel({
36
+ years = 0,
37
+ months = 0,
38
+ weeks = 0,
39
+ days = 0,
40
+ hours = 0,
41
+ minutes = 0,
42
+ seconds = 0,
43
+ }: Extract<JobExecutionTime, {state: 'fixed'}>['elapsed']): string {
44
+ const totalDays = years * 365 + months * 30 + weeks * 7 + days;
45
+ const totalHours = totalDays * 24 + hours;
46
+
47
+ if (totalHours > 0) return `${totalHours}h ${pad2(minutes)}m`;
48
+ if (minutes > 0) return `${minutes}m ${pad2(seconds)}s`;
49
+ return `${seconds}s`;
50
+ }
51
+
52
+ function pad2(value: number): string {
53
+ return value.toString().padStart(2, '0');
54
+ }
@@ -0,0 +1,29 @@
1
+ import {useTimeTick} from '@shipfox/react-ui/time-ticker';
2
+ import {Code} from '@shipfox/react-ui/typography';
3
+ import {humanDuration} from '@shipfox/react-ui/utils';
4
+ import type {JobDisplayDuration} from '#core/workflow-run.js';
5
+ import {formatJobExecutionTimeLabel} from './job-duration-format.js';
6
+
7
+ export function JobDurationLabel({duration}: {duration: JobDisplayDuration | null}) {
8
+ if (duration === null) return null;
9
+
10
+ if (duration.state === 'live') {
11
+ return <LiveDurationText fromIso={duration.fromIso} />;
12
+ }
13
+
14
+ return <DurationText>{formatJobExecutionTimeLabel(duration)}</DurationText>;
15
+ }
16
+
17
+ function LiveDurationText({fromIso}: {fromIso: string}) {
18
+ useTimeTick();
19
+ return <DurationText>{humanDuration(fromIso)}</DurationText>;
20
+ }
21
+
22
+ function DurationText({children}: {children: string}) {
23
+ // The status icon names the state; the node aria-label carries the verb.
24
+ return (
25
+ <Code as="span" variant="label" className="shrink-0 tabular-nums text-foreground-neutral-muted">
26
+ {children}
27
+ </Code>
28
+ );
29
+ }
@@ -0,0 +1,225 @@
1
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
2
+ import {TimeTickerProvider} from '@shipfox/react-ui/time-ticker';
3
+ import {cn} from '@shipfox/react-ui/utils';
4
+ import type {KeyboardEvent} from 'react';
5
+ import {useRef, useState} from 'react';
6
+ import type {WorkflowRunDetail} from '#core/workflow-run.js';
7
+ import type {JobGraphModel, JobGraphNavigationKey} from './graph-model.js';
8
+ import {nextJobGraphNodeId} from './graph-model.js';
9
+ import {JobNode, TriggerNode} from './job-node.js';
10
+
11
+ const NODE_WIDTH = 208;
12
+ const NODE_HEIGHT = 48;
13
+ const COLUMN_GAP = 64;
14
+ const ROW_GAP = 20;
15
+ const TRIGGER_WIDTH = 36;
16
+ const PADDING = 16;
17
+
18
+ export function JobGraphContent({
19
+ model,
20
+ trigger,
21
+ selectedJobId,
22
+ onSelectJob,
23
+ }: {
24
+ model: JobGraphModel;
25
+ trigger: Pick<
26
+ WorkflowRunDetail,
27
+ 'triggerDisplayLabel' | 'triggerLabel' | 'triggerProvider' | 'triggerSource'
28
+ >;
29
+ selectedJobId?: string | undefined;
30
+ onSelectJob: (jobId: string | undefined) => void;
31
+ }) {
32
+ const nodeRefs = useRef(new Map<string, HTMLButtonElement>());
33
+ const [hoveredJobId, setHoveredJobId] = useState<string | undefined>();
34
+
35
+ if (model.nodes.length === 0) {
36
+ return (
37
+ <EmptyState
38
+ className="min-h-160 px-16 py-24"
39
+ icon="componentLine"
40
+ title="No jobs yet"
41
+ description="This run has not materialized jobs."
42
+ variant="compact"
43
+ />
44
+ );
45
+ }
46
+
47
+ const maxRows = Math.max(1, ...model.columns.map((column) => column.length));
48
+ const contentWidth =
49
+ PADDING * 2 +
50
+ TRIGGER_WIDTH +
51
+ COLUMN_GAP +
52
+ model.columns.length * NODE_WIDTH +
53
+ Math.max(0, model.columns.length - 1) * COLUMN_GAP;
54
+ const contentHeight = PADDING * 2 + maxRows * NODE_HEIGHT + Math.max(0, maxRows - 1) * ROW_GAP;
55
+
56
+ function setNodeRef(jobId: string) {
57
+ return (element: HTMLButtonElement | null) => {
58
+ if (element) {
59
+ nodeRefs.current.set(jobId, element);
60
+ } else {
61
+ nodeRefs.current.delete(jobId);
62
+ }
63
+ };
64
+ }
65
+
66
+ function handleKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
67
+ const key = navigationKey(event.key);
68
+ if (!key) return;
69
+
70
+ const currentNodeId = event.currentTarget.dataset.jobId;
71
+ if (!currentNodeId) return;
72
+
73
+ const nextNodeId = nextJobGraphNodeId({model, currentNodeId, key});
74
+ if (!nextNodeId) return;
75
+
76
+ event.preventDefault();
77
+ onSelectJob(nextNodeId);
78
+ nodeRefs.current.get(nextNodeId)?.focus();
79
+ }
80
+
81
+ return (
82
+ <TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
83
+ <div className="min-h-0 overflow-auto bg-background-neutral-base">
84
+ <div className="relative" style={{width: contentWidth, minHeight: contentHeight}}>
85
+ <GraphEdges model={model} hoveredJobId={hoveredJobId} />
86
+ <div
87
+ className="absolute"
88
+ style={{left: PADDING, top: PADDING + (NODE_HEIGHT - TRIGGER_WIDTH) / 2}}
89
+ >
90
+ <TriggerNode trigger={trigger} />
91
+ </div>
92
+ {model.columns.map((column, columnIndex) => (
93
+ <div
94
+ key={column.map((node) => node.id).join(':')}
95
+ className="absolute flex flex-col gap-20"
96
+ style={{left: jobLeft(columnIndex), top: PADDING}}
97
+ >
98
+ {column.map((node) => (
99
+ <JobNode
100
+ key={node.id}
101
+ node={node}
102
+ selected={node.id === selectedJobId}
103
+ ref={setNodeRef(node.id)}
104
+ onSelect={() => onSelectJob(node.id === selectedJobId ? undefined : node.id)}
105
+ onKeyDown={handleKeyDown}
106
+ onHoverStart={() => setHoveredJobId(node.id)}
107
+ onHoverEnd={() =>
108
+ setHoveredJobId((current) => (current === node.id ? undefined : current))
109
+ }
110
+ />
111
+ ))}
112
+ </div>
113
+ ))}
114
+ </div>
115
+ </div>
116
+ </TimeTickerProvider>
117
+ );
118
+ }
119
+
120
+ function navigationKey(key: string): JobGraphNavigationKey | undefined {
121
+ if (
122
+ key === 'ArrowRight' ||
123
+ key === 'ArrowLeft' ||
124
+ key === 'ArrowDown' ||
125
+ key === 'ArrowUp' ||
126
+ key === 'j' ||
127
+ key === 'k'
128
+ ) {
129
+ return key;
130
+ }
131
+ return undefined;
132
+ }
133
+
134
+ function GraphEdges({
135
+ model,
136
+ hoveredJobId,
137
+ }: {
138
+ model: JobGraphModel;
139
+ hoveredJobId?: string | undefined;
140
+ }) {
141
+ const byId = new Map(model.nodes.map((node) => [node.id, node]));
142
+ const edgeViews = model.edges
143
+ .map((edge) => {
144
+ const fromNode = byId.get(edge.from);
145
+ const toNode = byId.get(edge.to);
146
+ const from = edge.from === 'trigger' ? triggerPoint() : jobRightPoint(fromNode);
147
+ const to = jobLeftPoint(toNode);
148
+ if (!from || !to) return undefined;
149
+
150
+ return {
151
+ id: edge.id,
152
+ highlighted: edge.from === hoveredJobId || edge.to === hoveredJobId,
153
+ path: edgePath({from, fromNode, to, toNode}),
154
+ };
155
+ })
156
+ .filter((edgeView): edgeView is NonNullable<typeof edgeView> => edgeView !== undefined)
157
+ .sort((left, right) => Number(left.highlighted) - Number(right.highlighted));
158
+
159
+ return (
160
+ <svg className="pointer-events-none absolute inset-0 size-full" aria-hidden="true">
161
+ {edgeViews.map((edge) => {
162
+ return (
163
+ <g
164
+ key={edge.id}
165
+ className={cn(
166
+ 'text-foreground-neutral-muted transition-colors',
167
+ edge.highlighted && 'text-foreground-neutral-base',
168
+ )}
169
+ >
170
+ <path
171
+ data-edge-id={edge.id}
172
+ d={edge.path}
173
+ fill="none"
174
+ stroke="currentColor"
175
+ strokeWidth={edge.highlighted ? 1.5 : 1}
176
+ />
177
+ </g>
178
+ );
179
+ })}
180
+ </svg>
181
+ );
182
+ }
183
+
184
+ function edgePath({
185
+ from,
186
+ fromNode,
187
+ to,
188
+ toNode,
189
+ }: {
190
+ from: {x: number; y: number};
191
+ fromNode: {column: number} | undefined;
192
+ to: {x: number; y: number};
193
+ toNode: {column: number} | undefined;
194
+ }) {
195
+ if (fromNode && toNode && toNode.column - fromNode.column > 1 && from.y !== to.y) {
196
+ const targetLaneX = to.x - COLUMN_GAP / 2;
197
+ return `M ${from.x} ${from.y} H ${targetLaneX} V ${to.y} H ${to.x}`;
198
+ }
199
+
200
+ const midX = from.x + (to.x - from.x) / 2;
201
+ return `M ${from.x} ${from.y} H ${midX} V ${to.y} H ${to.x}`;
202
+ }
203
+
204
+ function triggerPoint() {
205
+ return {x: PADDING + TRIGGER_WIDTH, y: PADDING + NODE_HEIGHT / 2};
206
+ }
207
+
208
+ function jobLeft(column: number): number {
209
+ // Dependency depths are contiguous, so a node's depth matches its rendered column index.
210
+ return PADDING + TRIGGER_WIDTH + COLUMN_GAP + column * (NODE_WIDTH + COLUMN_GAP);
211
+ }
212
+
213
+ function jobTop(row: number): number {
214
+ return PADDING + row * (NODE_HEIGHT + ROW_GAP);
215
+ }
216
+
217
+ function jobLeftPoint(node: {column: number; row: number} | undefined) {
218
+ if (!node) return undefined;
219
+ return {x: jobLeft(node.column), y: jobTop(node.row) + NODE_HEIGHT / 2};
220
+ }
221
+
222
+ function jobRightPoint(node: {column: number; row: number} | undefined) {
223
+ if (!node) return undefined;
224
+ return {x: jobLeft(node.column) + NODE_WIDTH, y: jobTop(node.row) + NODE_HEIGHT / 2};
225
+ }
@@ -0,0 +1,298 @@
1
+ import type {WorkflowRunJobDetailDto} from '@shipfox/api-workflows-dto';
2
+ import {fireEvent, render, screen, within} from '@testing-library/react';
3
+ import userEvent from '@testing-library/user-event';
4
+ import type {Job, WorkflowRunDetail} from '#core/workflow-run.js';
5
+ import {
6
+ workflowJob,
7
+ workflowJobExecutionDto,
8
+ workflowRunDetail,
9
+ } from '#test/fixtures/workflow-run.js';
10
+ import {JobGraph} from './job-graph.js';
11
+
12
+ describe('JobGraph', () => {
13
+ test('renders a graph region and trigger', () => {
14
+ render(<JobGraph run={makeRun({jobs: [makeJob({name: 'build'})]})} />);
15
+
16
+ expect(screen.getByRole('region', {name: 'Workflow jobs'})).toBeInTheDocument();
17
+ expect(screen.getByRole('button', {name: 'fire'})).toBeInTheDocument();
18
+ expect(screen.getByRole('button', {name: 'build, Pending'})).toBeInTheDocument();
19
+ });
20
+
21
+ test('selects and unselects a job locally', async () => {
22
+ const user = userEvent.setup();
23
+ render(<JobGraph run={makeRun({jobs: [makeJob({name: 'build'})]})} />);
24
+ const build = screen.getByRole('button', {name: 'build, Pending'});
25
+
26
+ await user.click(build);
27
+
28
+ expect(build).toHaveAttribute('aria-pressed', 'true');
29
+
30
+ await user.click(build);
31
+
32
+ expect(build).toHaveAttribute('aria-pressed', 'false');
33
+ });
34
+
35
+ test('supports an initially selected job', () => {
36
+ const buildJob = makeJob({name: 'build'});
37
+ render(<JobGraph run={makeRun({jobs: [buildJob]})} defaultSelectedJobId={buildJob.id} />);
38
+
39
+ expect(screen.getByRole('button', {name: 'build, Pending'})).toHaveAttribute(
40
+ 'aria-pressed',
41
+ 'true',
42
+ );
43
+ });
44
+
45
+ test('renders two running jobs at the same time', () => {
46
+ render(
47
+ <JobGraph
48
+ run={makeRun({
49
+ jobs: [
50
+ makeJob({name: 'linux', status: 'running'}),
51
+ makeJob({name: 'macos', status: 'running', position: 1}),
52
+ ],
53
+ })}
54
+ />,
55
+ );
56
+
57
+ expect(screen.getByRole('button', {name: 'linux, Running'})).toBeInTheDocument();
58
+ expect(screen.getByRole('button', {name: 'macos, Running'})).toBeInTheDocument();
59
+ });
60
+
61
+ test('renders a downstream job skipped after an upstream failure', () => {
62
+ render(
63
+ <JobGraph
64
+ run={makeRun({
65
+ jobs: [
66
+ makeJob({name: 'build', status: 'failed'}),
67
+ makeJob({
68
+ name: 'deploy',
69
+ status: 'skipped',
70
+ status_reason: 'dependency_not_completed',
71
+ position: 1,
72
+ dependencies: ['build'],
73
+ }),
74
+ ],
75
+ })}
76
+ />,
77
+ );
78
+
79
+ expect(screen.getByRole('button', {name: 'build, Failed'})).toBeInTheDocument();
80
+ expect(screen.getByRole('button', {name: 'deploy, Skipped'})).toBeInTheDocument();
81
+ });
82
+
83
+ test('renders an empty state', () => {
84
+ render(<JobGraph run={makeRun({jobs: []})} />);
85
+
86
+ expect(screen.getByText('No jobs yet')).toBeInTheDocument();
87
+ });
88
+
89
+ test('keeps long names accessible while truncating visually', () => {
90
+ const name = 'release-production-multi-region-with-canary-and-smoke-tests';
91
+ render(<JobGraph run={makeRun({jobs: [makeJob({name})]})} />);
92
+
93
+ expect(screen.getByRole('button', {name: `${name}, Pending`})).toBeInTheDocument();
94
+ });
95
+
96
+ test('does not show dependency counts in the job node label', () => {
97
+ render(
98
+ <JobGraph
99
+ run={makeRun({
100
+ jobs: [
101
+ makeJob({name: 'build'}),
102
+ makeJob({name: 'deploy', position: 1, dependencies: ['build'], status: 'running'}),
103
+ ],
104
+ })}
105
+ />,
106
+ );
107
+
108
+ expect(screen.getByRole('button', {name: 'deploy, Running'})).toBeInTheDocument();
109
+ expect(screen.queryByText('1')).not.toBeInTheDocument();
110
+ });
111
+
112
+ test('does not show multiple pending dependency counts', () => {
113
+ render(
114
+ <JobGraph
115
+ run={makeRun({
116
+ jobs: [
117
+ makeJob({name: 'build'}),
118
+ makeJob({name: 'lint', position: 1}),
119
+ makeJob({name: 'deploy', position: 2, dependencies: ['build', 'lint']}),
120
+ ],
121
+ })}
122
+ />,
123
+ );
124
+
125
+ const deploy = screen.getByRole('button', {name: 'deploy, Pending'});
126
+ expect(deploy).toBeInTheDocument();
127
+ expect(within(deploy).queryByText('2')).not.toBeInTheDocument();
128
+ });
129
+
130
+ test('hides the dependency pill when upstream jobs are resolved', () => {
131
+ render(
132
+ <JobGraph
133
+ run={makeRun({
134
+ jobs: [
135
+ makeJob({name: 'build', status: 'succeeded'}),
136
+ makeJob({name: 'deploy', position: 1, dependencies: ['build']}),
137
+ ],
138
+ })}
139
+ />,
140
+ );
141
+
142
+ const deploy = screen.getByRole('button', {name: 'deploy, Pending'});
143
+ expect(within(deploy).queryByText('1')).not.toBeInTheDocument();
144
+ });
145
+
146
+ test('marks jobs with multiple executions without showing dependency counts', () => {
147
+ render(
148
+ <JobGraph
149
+ run={makeRun({
150
+ jobs: [
151
+ makeJob({
152
+ name: 'build',
153
+ job_executions: [
154
+ workflowJobExecutionDto({sequence: 1, status: 'failed'}),
155
+ workflowJobExecutionDto({sequence: 2, status: 'succeeded'}),
156
+ ],
157
+ }),
158
+ makeJob({name: 'deploy', position: 1, dependencies: ['build']}),
159
+ ],
160
+ })}
161
+ />,
162
+ );
163
+
164
+ const build = screen.getByRole('button', {name: 'build, Pending, 2 executions'});
165
+ const deploy = screen.getByRole('button', {name: 'deploy, Pending'});
166
+ expect(within(build).getByText('2')).toBeInTheDocument();
167
+ expect(within(build).queryByText('2 exec')).not.toBeInTheDocument();
168
+ expect(within(deploy).queryByText('1')).not.toBeInTheDocument();
169
+ });
170
+
171
+ test('moves selection and focus with graph keyboard navigation', async () => {
172
+ const user = userEvent.setup();
173
+ render(
174
+ <JobGraph
175
+ run={makeRun({
176
+ jobs: [
177
+ makeJob({name: 'build'}),
178
+ makeJob({name: 'deploy', position: 1, dependencies: ['build']}),
179
+ ],
180
+ })}
181
+ />,
182
+ );
183
+
184
+ const build = screen.getByRole('button', {name: 'build, Pending'});
185
+ const deploy = screen.getByRole('button', {name: 'deploy, Pending'});
186
+ build.focus();
187
+
188
+ await user.keyboard('{ArrowRight}');
189
+
190
+ expect(deploy).toHaveFocus();
191
+ expect(deploy).toHaveAttribute('aria-pressed', 'true');
192
+ expect(build).toHaveAttribute('aria-pressed', 'false');
193
+ });
194
+
195
+ test('highlights adjacent edges only while hovering a job', async () => {
196
+ const user = userEvent.setup();
197
+ const build = makeJob({name: 'build'});
198
+ const deploy = makeJob({name: 'deploy', position: 1, dependencies: ['build']});
199
+ const {container} = render(<JobGraph run={makeRun({jobs: [build, deploy]})} />);
200
+ const triggerEdge = container.querySelector(`[data-edge-id="trigger:${build.id}"]`);
201
+ const deployEdge = container.querySelector(`[data-edge-id="${build.id}:${deploy.id}"]`);
202
+ const deployNode = screen.getByRole('button', {name: 'deploy, Pending'});
203
+
204
+ fireEvent.click(deployNode);
205
+
206
+ expect(deployEdge).toHaveAttribute('stroke-width', '1');
207
+
208
+ await user.hover(deployNode);
209
+
210
+ expect(deployEdge).toHaveAttribute('stroke-width', '1.5');
211
+ expect(triggerEdge).toHaveAttribute('stroke-width', '1');
212
+
213
+ await user.unhover(deployNode);
214
+
215
+ expect(deployEdge).toHaveAttribute('stroke-width', '1');
216
+ });
217
+
218
+ test('draws highlighted edges above overlapping normal edges', async () => {
219
+ const user = userEvent.setup();
220
+ const build = makeJob({name: 'build', status: 'succeeded'});
221
+ const lint = makeJob({
222
+ name: 'lint',
223
+ position: 1,
224
+ dependencies: ['build'],
225
+ status: 'succeeded',
226
+ });
227
+ const testJob = makeJob({
228
+ name: 'test',
229
+ position: 2,
230
+ dependencies: ['build'],
231
+ status: 'running',
232
+ });
233
+ const deploy = makeJob({
234
+ name: 'deploy',
235
+ position: 3,
236
+ dependencies: ['lint', 'test'],
237
+ });
238
+ const {container} = render(<JobGraph run={makeRun({jobs: [build, lint, testJob, deploy]})} />);
239
+ const buildToLintEdge = container.querySelector(`[data-edge-id="${build.id}:${lint.id}"]`);
240
+ const buildToTestEdge = container.querySelector(`[data-edge-id="${build.id}:${testJob.id}"]`);
241
+ const testNode = screen.getByRole('button', {
242
+ name: 'test, Running',
243
+ });
244
+
245
+ await user.hover(testNode);
246
+
247
+ expect(buildToTestEdge).toHaveAttribute('stroke-width', '1.5');
248
+ expect(buildToLintEdge).toHaveAttribute('stroke-width', '1');
249
+ if (!buildToLintEdge || !buildToTestEdge) {
250
+ throw new Error('Expected branch edges to render');
251
+ }
252
+ expect(buildToLintEdge.compareDocumentPosition(buildToTestEdge)).toBe(
253
+ Node.DOCUMENT_POSITION_FOLLOWING,
254
+ );
255
+ });
256
+
257
+ test('routes skipped-column join edges away from intermediate nodes', () => {
258
+ const packageJob = makeJob({name: 'package', position: 0, status: 'succeeded'});
259
+ const securityScan = makeJob({name: 'security-scan', position: 1, status: 'succeeded'});
260
+ const smokeTests = makeJob({
261
+ name: 'smoke-tests',
262
+ position: 2,
263
+ dependencies: ['package'],
264
+ status: 'running',
265
+ });
266
+ const deploy = makeJob({
267
+ name: 'deploy',
268
+ position: 3,
269
+ dependencies: ['smoke-tests', 'security-scan'],
270
+ });
271
+
272
+ const {container} = render(
273
+ <JobGraph run={makeRun({jobs: [packageJob, securityScan, smokeTests, deploy]})} />,
274
+ );
275
+
276
+ const skippedJoinEdge = container.querySelector(
277
+ `[data-edge-id="${securityScan.id}:${deploy.id}"]`,
278
+ );
279
+ expect(skippedJoinEdge).toHaveAttribute('d', 'M 324 108 H 628 V 40 H 660');
280
+ });
281
+ });
282
+
283
+ function makeRun(overrides: Partial<WorkflowRunDetail> = {}): WorkflowRunDetail {
284
+ return {
285
+ ...workflowRunDetail({
286
+ name: 'Deploy',
287
+ trigger_source: 'manual',
288
+ trigger_event: 'fire',
289
+ started_at: '2026-06-21T12:00:10.000Z',
290
+ jobs: [],
291
+ }),
292
+ ...overrides,
293
+ };
294
+ }
295
+
296
+ function makeJob(overrides: Partial<WorkflowRunJobDetailDto> & {name: string}): Job {
297
+ return workflowJob(overrides);
298
+ }
@@ -0,0 +1,45 @@
1
+ import {cn} from '@shipfox/react-ui/utils';
2
+ import {useState} from 'react';
3
+ import type {WorkflowRunDetail} from '#core/workflow-run.js';
4
+ import type {JobGraphModel} from './graph-model.js';
5
+ import {JobGraphContent} from './job-graph-content.js';
6
+
7
+ export function JobGraphView({
8
+ model,
9
+ trigger,
10
+ selectedJobId,
11
+ defaultSelectedJobId,
12
+ onSelectedJobChange,
13
+ className,
14
+ }: {
15
+ model: JobGraphModel;
16
+ trigger: Pick<
17
+ WorkflowRunDetail,
18
+ 'triggerDisplayLabel' | 'triggerLabel' | 'triggerProvider' | 'triggerSource'
19
+ >;
20
+ selectedJobId?: string | undefined;
21
+ defaultSelectedJobId?: string | undefined;
22
+ onSelectedJobChange?: ((jobId: string | undefined) => void) | undefined;
23
+ className?: string | undefined;
24
+ }) {
25
+ const [localSelectedJobId, setLocalSelectedJobId] = useState<string | undefined>(
26
+ defaultSelectedJobId,
27
+ );
28
+ const selected = selectedJobId ?? localSelectedJobId;
29
+
30
+ function selectJob(jobId: string | undefined) {
31
+ setLocalSelectedJobId(jobId);
32
+ onSelectedJobChange?.(jobId);
33
+ }
34
+
35
+ return (
36
+ <section aria-label="Workflow jobs" className={cn('min-h-0', className)}>
37
+ <JobGraphContent
38
+ model={model}
39
+ trigger={trigger}
40
+ selectedJobId={selected}
41
+ onSelectJob={selectJob}
42
+ />
43
+ </section>
44
+ );
45
+ }