@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,170 @@
1
+ import {
2
+ DropdownMenu,
3
+ DropdownMenuContent,
4
+ DropdownMenuItem,
5
+ DropdownMenuLabel,
6
+ DropdownMenuTrigger,
7
+ } from '@shipfox/react-ui/dropdown-menu';
8
+ import {Icon} from '@shipfox/react-ui/icon';
9
+ import {Text} from '@shipfox/react-ui/typography';
10
+ import {cn} from '@shipfox/react-ui/utils';
11
+ import {useMemo} from 'react';
12
+ import {WorkflowStatusIcon} from '#components/workflow-status/workflow-status-icon.js';
13
+ import type {Job, JobExecution} from '#core/workflow-run.js';
14
+ import {JobExecutionTimeText} from './job-execution-time-text.js';
15
+
16
+ export interface JobExecutionSwitcherProps {
17
+ job: Job;
18
+ selectedJobExecution: string | null;
19
+ onSelectedJobExecutionChange: (jobExecutionId: string) => void;
20
+ variant?: 'compact' | 'title' | undefined;
21
+ className?: string | undefined;
22
+ }
23
+
24
+ export function JobExecutionSwitcher({
25
+ job,
26
+ selectedJobExecution,
27
+ onSelectedJobExecutionChange,
28
+ variant = 'compact',
29
+ className,
30
+ }: JobExecutionSwitcherProps) {
31
+ const executions = useMemo(
32
+ () => [...job.jobExecutions].sort((left, right) => right.sequence - left.sequence),
33
+ [job.jobExecutions],
34
+ );
35
+ const selected =
36
+ executions.find((jobExecution) => jobExecution.id === selectedJobExecution) ??
37
+ executions[0] ??
38
+ null;
39
+
40
+ if (selected === null) return null;
41
+
42
+ if (!job.executionCountVisible) {
43
+ return (
44
+ <div
45
+ className={cn(
46
+ 'flex min-w-0 items-center gap-6 text-sm leading-20 text-foreground-neutral-subtle',
47
+ className,
48
+ )}
49
+ >
50
+ <ExecutionSummary execution={selected} />
51
+ </div>
52
+ );
53
+ }
54
+
55
+ return (
56
+ <DropdownMenu>
57
+ <DropdownMenuTrigger
58
+ className={cn(
59
+ 'inline-flex min-w-0 max-w-full items-center rounded-6 text-left transition-colors focus-visible:shadow-border-interactive-with-active focus-visible:outline-none',
60
+ variant === 'title'
61
+ ? '-mx-6 -my-4 gap-8 px-6 py-4 hover:bg-background-components-hover'
62
+ : 'min-h-28 gap-6 px-8 py-4 text-sm leading-20 text-foreground-neutral-subtle hover:bg-background-components-hover',
63
+ className,
64
+ )}
65
+ aria-label={`Switch job execution, currently execution ${selected.sequence}`}
66
+ >
67
+ {variant === 'title' ? (
68
+ <TitleExecutionSummary job={job} execution={selected} />
69
+ ) : (
70
+ <ExecutionSummary execution={selected} />
71
+ )}
72
+ <Icon name="arrowDownSLine" className="size-14 shrink-0 text-foreground-neutral-muted" />
73
+ </DropdownMenuTrigger>
74
+ <DropdownMenuContent align="start" size="lg" className="max-h-[320px] overflow-y-auto">
75
+ <DropdownMenuLabel>
76
+ {executions.length} execution{executions.length === 1 ? '' : 's'}
77
+ </DropdownMenuLabel>
78
+ {executions.map((jobExecution) => {
79
+ const isSelected = jobExecution.id === selected.id;
80
+
81
+ return (
82
+ <DropdownMenuItem
83
+ key={jobExecution.id}
84
+ asChild
85
+ onSelect={() => onSelectedJobExecutionChange(jobExecution.id)}
86
+ >
87
+ <button
88
+ type="button"
89
+ aria-current={isSelected ? 'true' : undefined}
90
+ className="w-full text-left"
91
+ >
92
+ <WorkflowStatusIcon status={jobExecution.status} size={14} tooltip={false} />
93
+ <span className="font-code text-xs leading-20 text-foreground-neutral-base tabular-nums">
94
+ #{jobExecution.sequence}
95
+ </span>
96
+ <span className="min-w-0 truncate text-xs leading-20 text-foreground-neutral-base">
97
+ {job.displayName}
98
+ </span>
99
+ {!isSelected && jobExecution.statusReason ? (
100
+ <span className="min-w-0 flex-1 truncate text-xs leading-20 text-foreground-neutral-muted">
101
+ {jobExecution.statusReason}
102
+ </span>
103
+ ) : (
104
+ <span className="min-w-0 flex-1" />
105
+ )}
106
+ {isSelected ? (
107
+ <Icon name="check" className="size-14 shrink-0 text-foreground-neutral-base" />
108
+ ) : null}
109
+ <JobExecutionDuration
110
+ execution={jobExecution}
111
+ className="ml-auto shrink-0 font-code text-xs leading-20 text-foreground-neutral-muted tabular-nums"
112
+ />
113
+ </button>
114
+ </DropdownMenuItem>
115
+ );
116
+ })}
117
+ </DropdownMenuContent>
118
+ </DropdownMenu>
119
+ );
120
+ }
121
+
122
+ function TitleExecutionSummary({job, execution}: {job: Job; execution: JobExecution}) {
123
+ return (
124
+ <span className="flex min-w-0 items-center gap-8">
125
+ <Text as="span" size="sm" bold className="shrink-0 text-foreground-neutral-base">
126
+ #{execution.sequence}
127
+ </Text>
128
+ <Text as="span" size="sm" bold className="min-w-0 truncate text-foreground-neutral-base">
129
+ {job.displayName}
130
+ </Text>
131
+ </span>
132
+ );
133
+ }
134
+
135
+ function ExecutionSummary({execution}: {execution: JobExecution}) {
136
+ return (
137
+ <span className="flex min-w-0 items-center gap-6">
138
+ <WorkflowStatusIcon status={execution.status} size={14} tooltip={false} />
139
+ <span className="shrink-0 font-code text-xs leading-20 text-foreground-neutral-base tabular-nums">
140
+ Execution #{execution.sequence}
141
+ </span>
142
+ {execution.statusReason ? (
143
+ <Text as="span" size="xs" className="min-w-0 truncate text-foreground-neutral-muted">
144
+ · {execution.statusReason}
145
+ </Text>
146
+ ) : null}
147
+ <JobExecutionDuration
148
+ execution={execution}
149
+ className="shrink-0 font-code text-xs leading-20 text-foreground-neutral-muted tabular-nums"
150
+ />
151
+ </span>
152
+ );
153
+ }
154
+
155
+ function JobExecutionDuration({
156
+ execution,
157
+ className,
158
+ }: {
159
+ execution: JobExecution;
160
+ className?: string | undefined;
161
+ }) {
162
+ const duration = execution.displayDuration;
163
+ if (!duration) return null;
164
+
165
+ return (
166
+ <span className={className}>
167
+ <JobExecutionTimeText time={duration} />
168
+ </span>
169
+ );
170
+ }
@@ -0,0 +1,34 @@
1
+ import {useTimeTick} from '@shipfox/react-ui/time-ticker';
2
+ import {formatDuration} from '@shipfox/react-ui/utils';
3
+ import type {JobExecutionTime} from '#core/workflow-run.js';
4
+
5
+ export function JobExecutionTimeText({time}: {time: JobExecutionTime}) {
6
+ useTimeTick();
7
+
8
+ return formatJobExecutionTime(time);
9
+ }
10
+
11
+ export function formatJobExecutionTime(time: JobExecutionTime): string {
12
+ if (time.state === 'live') {
13
+ return formatDuration(Date.now() - Date.parse(time.fromIso));
14
+ }
15
+
16
+ return formatElapsedDuration(time.elapsed);
17
+ }
18
+
19
+ function formatElapsedDuration(duration: {
20
+ days?: number | undefined;
21
+ hours?: number | undefined;
22
+ minutes?: number | undefined;
23
+ seconds?: number | undefined;
24
+ }): string {
25
+ const days = duration.days ?? 0;
26
+ const hours = duration.hours ?? 0;
27
+ const minutes = duration.minutes ?? 0;
28
+ const seconds = duration.seconds ?? 0;
29
+
30
+ if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
31
+ if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
32
+ if (minutes > 0) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
33
+ return `${seconds}s`;
34
+ }
@@ -0,0 +1,290 @@
1
+ import {configureApiClient} from '@shipfox/client-api';
2
+ import {type StepLogSnapshot, stepLogsQueryKeys} from '@shipfox/client-logs';
3
+ import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
4
+ import {act, cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
5
+ import type {ReactNode} from 'react';
6
+ import {inlineLogBody, outputLine} from '#test/fixtures/logs.js';
7
+ import {StepAttemptLogPanel} from './step-attempt-log-panel.js';
8
+
9
+ const STEP_ID = '99999999-9999-4999-8999-999999999999';
10
+ type TestLogRecord = StepLogSnapshot['records'][number];
11
+
12
+ function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
13
+ return new Response(JSON.stringify(body), {
14
+ headers: {'content-type': 'application/json'},
15
+ status: 200,
16
+ ...init,
17
+ });
18
+ }
19
+
20
+ const outputRecord = (data: string, ts = 1): TestLogRecord => ({
21
+ v: 1,
22
+ ts,
23
+ type: 'output',
24
+ stream: 'stdout',
25
+ data,
26
+ });
27
+
28
+ function snapshot(records: TestLogRecord[]): StepLogSnapshot {
29
+ return {
30
+ records,
31
+ nextCursor: records.length,
32
+ source: 'inline',
33
+ state: 'closed',
34
+ complete: true,
35
+ hasMore: false,
36
+ truncated: false,
37
+ totalBytes: null,
38
+ expiresAt: null,
39
+ };
40
+ }
41
+
42
+ function renderPanel(
43
+ props: Partial<Parameters<typeof StepAttemptLogPanel>[0]> = {},
44
+ options: {queryClient?: QueryClient} = {},
45
+ ) {
46
+ const queryClient =
47
+ options.queryClient ??
48
+ new QueryClient({
49
+ defaultOptions: {queries: {retry: false}},
50
+ });
51
+ const wrapper = ({children}: {children: ReactNode}) => (
52
+ <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
53
+ );
54
+
55
+ return {
56
+ queryClient,
57
+ ...render(
58
+ <StepAttemptLogPanel stepId={STEP_ID} attempt={1} attemptStatus="running" {...props} />,
59
+ {wrapper},
60
+ ),
61
+ };
62
+ }
63
+
64
+ describe('StepAttemptLogPanel', () => {
65
+ afterEach(() => {
66
+ cleanup();
67
+ vi.useRealTimers();
68
+ vi.unstubAllGlobals();
69
+ vi.restoreAllMocks();
70
+ configureApiClient({baseUrl: '', fetchImpl: undefined});
71
+ });
72
+
73
+ test('waits for missing logs while the attempt is running', async () => {
74
+ configureApiClient({
75
+ baseUrl: 'https://api.example.test',
76
+ fetchImpl: vi.fn(async () => jsonResponse({code: 'not-found'}, {status: 404})),
77
+ });
78
+
79
+ const {container} = renderPanel();
80
+
81
+ expect(await screen.findByRole('status', {name: 'Waiting for logs'})).toBeInTheDocument();
82
+ expect(screen.queryByRole('log')).not.toBeInTheDocument();
83
+ expect(container.querySelector('[data-slot="log-rows"]')).toHaveAttribute(
84
+ 'aria-hidden',
85
+ 'true',
86
+ );
87
+ });
88
+
89
+ test('waits for missing logs on a terminal attempt until the stream appears', async () => {
90
+ const fetchImpl = vi
91
+ .fn()
92
+ .mockResolvedValueOnce(jsonResponse({code: 'not-found'}, {status: 404}))
93
+ .mockResolvedValueOnce(
94
+ jsonResponse(inlineLogBody(outputLine('eventual terminal logs\n'), 1)),
95
+ );
96
+ configureApiClient({
97
+ baseUrl: 'https://api.example.test',
98
+ fetchImpl,
99
+ });
100
+
101
+ const {queryClient} = renderPanel({
102
+ attemptStatus: 'succeeded',
103
+ initialErrorRetryCount: 2,
104
+ initialErrorRetryDelayMs: 60_000,
105
+ });
106
+
107
+ expect(await screen.findByRole('status', {name: 'Waiting for logs'})).toBeInTheDocument();
108
+ await act(async () => {
109
+ await queryClient.refetchQueries({queryKey: stepLogsQueryKeys.detail(STEP_ID, 1)});
110
+ });
111
+ expect(await screen.findByText('eventual terminal logs')).toBeInTheDocument();
112
+ expect(screen.queryByText('Could not load logs.')).not.toBeInTheDocument();
113
+ });
114
+
115
+ test('settles a terminal missing log stream to no output instead of an error', async () => {
116
+ configureApiClient({
117
+ baseUrl: 'https://api.example.test',
118
+ fetchImpl: vi.fn(async () => jsonResponse({code: 'not-found'}, {status: 404})),
119
+ });
120
+
121
+ const {queryClient} = renderPanel({
122
+ attemptStatus: 'succeeded',
123
+ initialErrorRetryCount: 1,
124
+ initialErrorRetryDelayMs: 60_000,
125
+ });
126
+
127
+ expect(await screen.findByRole('status', {name: 'Waiting for logs'})).toBeInTheDocument();
128
+ await act(async () => {
129
+ await queryClient.refetchQueries({queryKey: stepLogsQueryKeys.detail(STEP_ID, 1)});
130
+ });
131
+ expect(await screen.findByText('Step produced no output')).toBeInTheDocument();
132
+ expect(screen.queryByText('Could not load logs.')).not.toBeInTheDocument();
133
+ });
134
+
135
+ test('keeps the log loading surface through transient initial server errors', async () => {
136
+ const fetchImpl = vi
137
+ .fn()
138
+ .mockResolvedValueOnce(jsonResponse({code: 'server-error'}, {status: 500}))
139
+ .mockResolvedValueOnce(jsonResponse(inlineLogBody(outputLine('eventual logs\n'), 1)));
140
+ configureApiClient({
141
+ baseUrl: 'https://api.example.test',
142
+ fetchImpl,
143
+ });
144
+
145
+ const {container} = renderPanel({
146
+ attemptStatus: 'failed',
147
+ initialErrorRetryCount: 1,
148
+ initialErrorRetryDelayMs: 10,
149
+ });
150
+ expect(screen.getByRole('status', {name: 'Loading logs'})).toBeInTheDocument();
151
+ expect(screen.queryByRole('log')).not.toBeInTheDocument();
152
+ expect(container.querySelector('[data-slot="log-rows"]')).toHaveAttribute(
153
+ 'aria-hidden',
154
+ 'true',
155
+ );
156
+
157
+ expect(await screen.findByText('eventual logs')).toBeInTheDocument();
158
+ expect(screen.getByRole('log')).toBeInTheDocument();
159
+ });
160
+
161
+ test('shows a compact retry state after the initial error retry budget is exhausted', async () => {
162
+ configureApiClient({
163
+ baseUrl: 'https://api.example.test',
164
+ fetchImpl: vi.fn(async () => jsonResponse({code: 'server-error'}, {status: 500})),
165
+ });
166
+
167
+ renderPanel({attemptStatus: 'failed', initialErrorRetryCount: 1, initialErrorRetryDelayMs: 10});
168
+ expect(screen.getByRole('status', {name: 'Loading logs'})).toBeInTheDocument();
169
+
170
+ expect(await screen.findByText('Could not load logs.')).toBeInTheDocument();
171
+ expect(screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument();
172
+ });
173
+
174
+ test('renders loaded logs inline', async () => {
175
+ configureApiClient({
176
+ baseUrl: 'https://api.example.test',
177
+ fetchImpl: vi.fn(async () => jsonResponse(inlineLogBody(outputLine('hello\n'), 1))),
178
+ });
179
+
180
+ renderPanel({attemptStatus: 'succeeded'});
181
+
182
+ expect(await screen.findByText('hello')).toBeInTheDocument();
183
+ });
184
+
185
+ test('renders a terminal closed empty stream as no output', async () => {
186
+ configureApiClient({
187
+ baseUrl: 'https://api.example.test',
188
+ fetchImpl: vi.fn(async () => jsonResponse(inlineLogBody('', 0))),
189
+ });
190
+
191
+ renderPanel({attemptStatus: 'succeeded'});
192
+
193
+ expect(await screen.findByText('Step produced no output')).toBeInTheDocument();
194
+ expect(
195
+ screen.getByText('This log stream closed without session entries or process output.'),
196
+ ).toBeInTheDocument();
197
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
198
+ expect(screen.getByRole('log')).toBeInTheDocument();
199
+ });
200
+
201
+ test('keeps stale logs visible when a refresh fails', async () => {
202
+ const fetchImpl = vi
203
+ .fn()
204
+ .mockResolvedValueOnce(jsonResponse(inlineLogBody(outputLine('first\n'), 1)))
205
+ .mockResolvedValueOnce(jsonResponse({code: 'server-error'}, {status: 500}));
206
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl});
207
+ const {queryClient} = renderPanel({attemptStatus: 'running'});
208
+ await screen.findByText('first');
209
+
210
+ await act(async () => {
211
+ await queryClient.refetchQueries({queryKey: stepLogsQueryKeys.detail(STEP_ID, 1)});
212
+ });
213
+
214
+ expect(screen.getByText('first')).toBeInTheDocument();
215
+ expect(await screen.findByText('Could not refresh logs.')).toBeInTheDocument();
216
+ expect(screen.getByRole('button', {name: 'Retry'})).toBeInTheDocument();
217
+ });
218
+
219
+ test('does not follow new log output after the user scrolls away from the tail', async () => {
220
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
221
+ callback(0);
222
+ return 1;
223
+ });
224
+ vi.stubGlobal('cancelAnimationFrame', vi.fn());
225
+ configureApiClient({baseUrl: 'https://api.example.test', fetchImpl: vi.fn()});
226
+ const queryClient = new QueryClient({
227
+ defaultOptions: {queries: {retry: false, staleTime: Number.POSITIVE_INFINITY}},
228
+ });
229
+ const queryKey = stepLogsQueryKeys.detail(STEP_ID, 1);
230
+ queryClient.setQueryData(queryKey, snapshot([outputRecord('first\n')]));
231
+ renderPanel({attemptStatus: 'succeeded'}, {queryClient});
232
+ const logRows = await screen.findByRole('log');
233
+ Object.defineProperty(logRows, 'scrollHeight', {configurable: true, value: 600});
234
+ Object.defineProperty(logRows, 'clientHeight', {configurable: true, value: 200});
235
+ logRows.scrollTop = 320;
236
+ fireEvent.scroll(logRows);
237
+
238
+ act(() => {
239
+ queryClient.setQueryData(
240
+ queryKey,
241
+ snapshot([outputRecord('first\n'), outputRecord('second\n', 2)]),
242
+ );
243
+ });
244
+
245
+ await waitFor(() => expect(screen.getByText('second')).toBeInTheDocument());
246
+ expect(logRows.scrollTop).toBe(320);
247
+ });
248
+
249
+ test('passes failure anchoring for failed attempts', async () => {
250
+ vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
251
+ callback(0);
252
+ return 1;
253
+ });
254
+ vi.stubGlobal('cancelAnimationFrame', vi.fn());
255
+ const scrollIntoView = vi.fn();
256
+ Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
257
+ configurable: true,
258
+ value: scrollIntoView,
259
+ });
260
+ configureApiClient({
261
+ baseUrl: 'https://api.example.test',
262
+ fetchImpl: vi.fn(async () =>
263
+ jsonResponse(
264
+ inlineLogBody(
265
+ JSON.stringify({
266
+ v: 1,
267
+ ts: 1,
268
+ type: 'agent_session',
269
+ row: {
270
+ kind: 'message',
271
+ timestamp: 1,
272
+ role: 'assistant',
273
+ label: 'assistant',
274
+ meta: [],
275
+ text: 'I cannot continue.',
276
+ terminalFailure: true,
277
+ },
278
+ }),
279
+ 1,
280
+ ),
281
+ ),
282
+ ),
283
+ });
284
+
285
+ renderPanel({attemptStatus: 'failed'});
286
+
287
+ expect(await screen.findByText('I cannot continue.')).toBeInTheDocument();
288
+ await waitFor(() => expect(scrollIntoView).toHaveBeenCalledWith({block: 'center'}));
289
+ });
290
+ });
@@ -0,0 +1,152 @@
1
+ import {
2
+ isMissingStepLogStreamError,
3
+ LogView,
4
+ LogViewSkeleton,
5
+ useStepAttemptLogsQuery,
6
+ } from '@shipfox/client-logs';
7
+ import {Button} from '@shipfox/react-ui/button';
8
+ import {Callout} from '@shipfox/react-ui/callout';
9
+ import {Text} from '@shipfox/react-ui/typography';
10
+ import {type UIEvent, useEffect, useRef} from 'react';
11
+
12
+ const TAIL_FOLLOW_THRESHOLD_PX = 24;
13
+ const INITIAL_LOG_ERROR_RETRY_COUNT = 5;
14
+ const INITIAL_LOG_ERROR_RETRY_DELAY_MS = 1_500;
15
+ const logSurfaceClasses = 'max-h-[40vh] rounded-8 md:max-h-[280px]';
16
+
17
+ export interface StepAttemptLogPanelProps {
18
+ stepId: string;
19
+ attempt: number;
20
+ attemptStatus: string;
21
+ initialErrorRetryCount?: number | undefined;
22
+ initialErrorRetryDelayMs?: number | undefined;
23
+ }
24
+
25
+ export function StepAttemptLogPanel({
26
+ stepId,
27
+ attempt,
28
+ attemptStatus,
29
+ initialErrorRetryCount = INITIAL_LOG_ERROR_RETRY_COUNT,
30
+ initialErrorRetryDelayMs = INITIAL_LOG_ERROR_RETRY_DELAY_MS,
31
+ }: StepAttemptLogPanelProps) {
32
+ const panelRef = useRef<HTMLDivElement>(null);
33
+ const shouldFollowTailRef = useRef(true);
34
+ const missingStreamRetryCount = attemptStatus === 'running' ? undefined : initialErrorRetryCount;
35
+ const retryMissingStream = attemptStatus === 'running' || isTerminalAttemptStatus(attemptStatus);
36
+ const query = useStepAttemptLogsQuery(stepId, attempt, {
37
+ retryMissingStream,
38
+ missingStreamRetryCount,
39
+ missingStreamRetryDelayMs: initialErrorRetryDelayMs,
40
+ initialErrorRetryCount,
41
+ initialErrorRetryDelayMs,
42
+ });
43
+ const records = query.data?.records ?? [];
44
+ const recordCount = records.length;
45
+ const anchorToFailure = attemptStatus === 'failed';
46
+ const missingActiveStream =
47
+ retryMissingStream && query.data === undefined && isMissingStepLogStreamError(query.error);
48
+ const initialError = query.isError && query.data === undefined && !missingActiveStream;
49
+ const staleError = query.isError && query.data !== undefined;
50
+
51
+ useEffect(() => {
52
+ if (recordCount === 0) return undefined;
53
+ if (anchorToFailure) return undefined;
54
+ if (!shouldFollowTailRef.current) return undefined;
55
+
56
+ const frame = scheduleAnimationFrame(() => {
57
+ const scrollElement = panelRef.current?.querySelector<HTMLElement>('[data-slot="log-rows"]');
58
+ if (!scrollElement) return;
59
+ scrollElement.scrollTop = scrollElement.scrollHeight;
60
+ });
61
+
62
+ return () => {
63
+ cancelScheduledFrame(frame);
64
+ };
65
+ }, [anchorToFailure, recordCount]);
66
+
67
+ function handleLogScroll(event: UIEvent<HTMLDivElement>) {
68
+ const element = event.currentTarget;
69
+ const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
70
+ shouldFollowTailRef.current = distanceFromBottom <= TAIL_FOLLOW_THRESHOLD_PX;
71
+ }
72
+
73
+ if (query.isPending) {
74
+ return <StepLogsLoadingSurface label="Loading logs" />;
75
+ }
76
+
77
+ if (missingActiveStream) {
78
+ return <StepLogsLoadingSurface label="Waiting for logs" />;
79
+ }
80
+
81
+ if (initialError) {
82
+ return <StepLogsError retrying={query.isFetching} onRetry={() => void query.refetch()} />;
83
+ }
84
+
85
+ return (
86
+ <div ref={panelRef} className="flex min-w-0 flex-col gap-8">
87
+ {staleError ? (
88
+ <Callout role="alert" type="warning" className="px-10 py-8">
89
+ <div className="flex min-w-0 flex-1 items-center justify-between gap-8">
90
+ <Text size="xs">Could not refresh logs.</Text>
91
+ <Button
92
+ type="button"
93
+ size="2xs"
94
+ variant="secondary"
95
+ isLoading={query.isFetching}
96
+ onClick={() => void query.refetch()}
97
+ >
98
+ Retry
99
+ </Button>
100
+ </div>
101
+ </Callout>
102
+ ) : null}
103
+ <LogView
104
+ records={records}
105
+ emptyState={query.data?.complete ? 'complete' : 'pending'}
106
+ anchorToFailure={anchorToFailure}
107
+ className={logSurfaceClasses}
108
+ onScroll={handleLogScroll}
109
+ />
110
+ </div>
111
+ );
112
+ }
113
+
114
+ function StepLogsLoadingSurface({label}: {label: string}) {
115
+ return (
116
+ <div role="status" aria-label={label}>
117
+ <LogViewSkeleton className={logSurfaceClasses} />
118
+ </div>
119
+ );
120
+ }
121
+
122
+ function scheduleAnimationFrame(callback: FrameRequestCallback): number {
123
+ if (typeof globalThis.requestAnimationFrame === 'function') {
124
+ return globalThis.requestAnimationFrame(callback);
125
+ }
126
+ return window.setTimeout(() => callback(Date.now()), 0);
127
+ }
128
+
129
+ function cancelScheduledFrame(frame: number) {
130
+ if (typeof globalThis.cancelAnimationFrame === 'function') {
131
+ globalThis.cancelAnimationFrame(frame);
132
+ return;
133
+ }
134
+ window.clearTimeout(frame);
135
+ }
136
+
137
+ function isTerminalAttemptStatus(status: string): boolean {
138
+ return status === 'succeeded' || status === 'failed' || status === 'cancelled';
139
+ }
140
+
141
+ function StepLogsError({retrying, onRetry}: {retrying: boolean; onRetry: () => void}) {
142
+ return (
143
+ <Callout role="alert" type="error" className="px-10 py-8">
144
+ <div className="flex min-w-0 flex-1 items-center justify-between gap-8">
145
+ <Text size="xs">Could not load logs.</Text>
146
+ <Button type="button" size="2xs" variant="secondary" isLoading={retrying} onClick={onRetry}>
147
+ Retry
148
+ </Button>
149
+ </div>
150
+ </Callout>
151
+ );
152
+ }