@shipfox/client-workflows 14.0.1 → 16.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (272) hide show
  1. package/.storybook/preview.css +4 -0
  2. package/.storybook/preview.tsx +7 -2
  3. package/.turbo/turbo-build.log +1 -1
  4. package/CHANGELOG.md +42 -0
  5. package/dist/components/job-detail/agent-config-failure-callout.d.ts.map +1 -0
  6. package/dist/components/{workflow-run-view → job-detail}/agent-config-failure-callout.js +2 -2
  7. package/dist/components/job-detail/agent-config-failure-callout.js.map +1 -0
  8. package/dist/components/job-detail/job-detail-header.d.ts +15 -0
  9. package/dist/components/job-detail/job-detail-header.d.ts.map +1 -0
  10. package/dist/components/job-detail/job-detail-header.js +124 -0
  11. package/dist/components/job-detail/job-detail-header.js.map +1 -0
  12. package/dist/components/job-detail/job-detail-view.d.ts +16 -0
  13. package/dist/components/job-detail/job-detail-view.d.ts.map +1 -0
  14. package/dist/components/job-detail/job-detail-view.js +420 -0
  15. package/dist/components/job-detail/job-detail-view.js.map +1 -0
  16. package/dist/components/job-detail/job-empty-states.d.ts +10 -0
  17. package/dist/components/job-detail/job-empty-states.d.ts.map +1 -0
  18. package/dist/components/job-detail/job-empty-states.js +195 -0
  19. package/dist/components/job-detail/job-empty-states.js.map +1 -0
  20. package/dist/components/job-detail/job-execution-switcher.d.ts.map +1 -0
  21. package/dist/components/job-detail/job-execution-switcher.js.map +1 -0
  22. package/dist/components/job-detail/job-execution-time-text.d.ts.map +1 -0
  23. package/dist/components/job-detail/job-execution-time-text.js.map +1 -0
  24. package/dist/components/job-detail/job-selection.d.ts +24 -0
  25. package/dist/components/job-detail/job-selection.d.ts.map +1 -0
  26. package/dist/components/job-detail/job-selection.js +66 -0
  27. package/dist/components/job-detail/job-selection.js.map +1 -0
  28. package/dist/components/job-detail/step-attempt-log-panel.d.ts +15 -0
  29. package/dist/components/job-detail/step-attempt-log-panel.d.ts.map +1 -0
  30. package/dist/components/{workflow-run-view → job-detail}/step-attempt-log-panel.js +61 -16
  31. package/dist/components/job-detail/step-attempt-log-panel.js.map +1 -0
  32. package/dist/components/job-graph/job-graph-content.js +1 -1
  33. package/dist/components/job-graph/job-graph-content.js.map +1 -1
  34. package/dist/components/step-list/step-list-model.js +2 -2
  35. package/dist/components/step-list/step-list-model.js.map +1 -1
  36. package/dist/components/step-list/step-list.d.ts +1 -0
  37. package/dist/components/step-list/step-list.d.ts.map +1 -1
  38. package/dist/components/step-list/step-list.js +29 -4
  39. package/dist/components/step-list/step-list.js.map +1 -1
  40. package/dist/components/workflow-run-duration-label.d.ts +9 -4
  41. package/dist/components/workflow-run-duration-label.d.ts.map +1 -1
  42. package/dist/components/workflow-run-duration-label.js +27 -7
  43. package/dist/components/workflow-run-duration-label.js.map +1 -1
  44. package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
  45. package/dist/components/workflow-run-list/workflow-run-row.js +6 -5
  46. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  47. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  48. package/dist/components/workflow-run-summary/workflow-run-summary.js +69 -64
  49. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  50. package/dist/components/workflow-run-tabs/index.d.ts +3 -3
  51. package/dist/components/workflow-run-tabs/index.d.ts.map +1 -1
  52. package/dist/components/workflow-run-tabs/index.js +3 -3
  53. package/dist/components/workflow-run-tabs/index.js.map +1 -1
  54. package/dist/components/workflow-run-tabs/run-annotation-count-chip.d.ts +16 -0
  55. package/dist/components/workflow-run-tabs/run-annotation-count-chip.d.ts.map +1 -0
  56. package/dist/components/workflow-run-tabs/run-annotation-count-chip.js +50 -0
  57. package/dist/components/workflow-run-tabs/run-annotation-count-chip.js.map +1 -0
  58. package/dist/components/workflow-run-tabs/run-annotation-item.d.ts +20 -0
  59. package/dist/components/workflow-run-tabs/run-annotation-item.d.ts.map +1 -0
  60. package/dist/components/workflow-run-tabs/run-annotation-item.js +102 -0
  61. package/dist/components/workflow-run-tabs/run-annotation-item.js.map +1 -0
  62. package/dist/components/workflow-run-tabs/run-annotation-list.d.ts +32 -0
  63. package/dist/components/workflow-run-tabs/run-annotation-list.d.ts.map +1 -0
  64. package/dist/components/workflow-run-tabs/run-annotation-list.js +202 -0
  65. package/dist/components/workflow-run-tabs/run-annotation-list.js.map +1 -0
  66. package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts +1 -1
  67. package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts.map +1 -1
  68. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js +87 -22
  69. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js.map +1 -1
  70. package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts +3 -1
  71. package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts.map +1 -1
  72. package/dist/components/workflow-run-tabs/run-annotations-empty.js +2 -2
  73. package/dist/components/workflow-run-tabs/run-annotations-empty.js.map +1 -1
  74. package/dist/components/workflow-run-tabs/severity-visuals.d.ts +20 -0
  75. package/dist/components/workflow-run-tabs/severity-visuals.d.ts.map +1 -0
  76. package/dist/components/workflow-run-tabs/severity-visuals.js +31 -0
  77. package/dist/components/workflow-run-tabs/severity-visuals.js.map +1 -0
  78. package/dist/components/workflow-run-view/index.d.ts +0 -1
  79. package/dist/components/workflow-run-view/index.d.ts.map +1 -1
  80. package/dist/components/workflow-run-view/index.js +0 -1
  81. package/dist/components/workflow-run-view/index.js.map +1 -1
  82. package/dist/components/workflow-run-view/run-workspace-nav.d.ts +20 -0
  83. package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -0
  84. package/dist/components/workflow-run-view/run-workspace-nav.js +277 -0
  85. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -0
  86. package/dist/components/workflow-run-view/workflow-run-selection.d.ts.map +1 -1
  87. package/dist/components/workflow-run-view/workflow-run-selection.js +1 -14
  88. package/dist/components/workflow-run-view/workflow-run-selection.js.map +1 -1
  89. package/dist/components/workflow-run-view/workflow-run-view.d.ts +10 -10
  90. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  91. package/dist/components/workflow-run-view/workflow-run-view.js +351 -239
  92. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  93. package/dist/components/workflow-status/status-visuals.d.ts.map +1 -1
  94. package/dist/components/workflow-status/status-visuals.js +10 -2
  95. package/dist/components/workflow-status/status-visuals.js.map +1 -1
  96. package/dist/components/workflow-status/workflow-status-icon.d.ts +3 -1
  97. package/dist/components/workflow-status/workflow-status-icon.d.ts.map +1 -1
  98. package/dist/components/workflow-status/workflow-status-icon.js +13 -2
  99. package/dist/components/workflow-status/workflow-status-icon.js.map +1 -1
  100. package/dist/core/entities/job-execution.d.ts +9 -0
  101. package/dist/core/entities/job-execution.d.ts.map +1 -1
  102. package/dist/core/entities/job-execution.js +13 -3
  103. package/dist/core/entities/job-execution.js.map +1 -1
  104. package/dist/core/entities/job.d.ts.map +1 -1
  105. package/dist/core/entities/step-attempt.d.ts.map +1 -1
  106. package/dist/core/entities/step.d.ts +2 -0
  107. package/dist/core/entities/step.d.ts.map +1 -1
  108. package/dist/core/entities/step.js +13 -0
  109. package/dist/core/entities/step.js.map +1 -1
  110. package/dist/core/entities/workflow-run-attempt.d.ts.map +1 -1
  111. package/dist/core/entities/workflow-run.d.ts +56 -1
  112. package/dist/core/entities/workflow-run.d.ts.map +1 -1
  113. package/dist/core/entities/workflow-run.js +98 -0
  114. package/dist/core/entities/workflow-run.js.map +1 -1
  115. package/dist/core/run-annotation.d.ts +73 -0
  116. package/dist/core/run-annotation.d.ts.map +1 -0
  117. package/dist/core/run-annotation.js +125 -0
  118. package/dist/core/run-annotation.js.map +1 -0
  119. package/dist/core/workflow-run.d.ts +4 -4
  120. package/dist/core/workflow-run.d.ts.map +1 -1
  121. package/dist/core/workflow-run.js +3 -3
  122. package/dist/core/workflow-run.js.map +1 -1
  123. package/dist/feature.d.ts +20 -16
  124. package/dist/feature.d.ts.map +1 -1
  125. package/dist/feature.js +5 -0
  126. package/dist/feature.js.map +1 -1
  127. package/dist/hooks/api/run-annotation-mapper.d.ts +10 -0
  128. package/dist/hooks/api/run-annotation-mapper.d.ts.map +1 -0
  129. package/dist/hooks/api/run-annotation-mapper.js +22 -0
  130. package/dist/hooks/api/run-annotation-mapper.js.map +1 -0
  131. package/dist/hooks/api/run-annotations.d.ts +25 -0
  132. package/dist/hooks/api/run-annotations.d.ts.map +1 -0
  133. package/dist/hooks/api/run-annotations.js +78 -0
  134. package/dist/hooks/api/run-annotations.js.map +1 -0
  135. package/dist/hooks/api/workflow-runs.d.ts +5 -3
  136. package/dist/hooks/api/workflow-runs.d.ts.map +1 -1
  137. package/dist/hooks/api/workflow-runs.js +5 -4
  138. package/dist/hooks/api/workflow-runs.js.map +1 -1
  139. package/dist/index.d.ts +1 -0
  140. package/dist/index.d.ts.map +1 -1
  141. package/dist/index.js +1 -0
  142. package/dist/index.js.map +1 -1
  143. package/dist/pages/workflow-job-detail-page.d.ts +11 -0
  144. package/dist/pages/workflow-job-detail-page.d.ts.map +1 -0
  145. package/dist/pages/workflow-job-detail-page.js +66 -0
  146. package/dist/pages/workflow-job-detail-page.js.map +1 -0
  147. package/dist/pages/workflow-run-detail-page.d.ts.map +1 -1
  148. package/dist/pages/workflow-run-detail-page.js +4 -37
  149. package/dist/pages/workflow-run-detail-page.js.map +1 -1
  150. package/dist/routes/inputs.d.ts +52 -34
  151. package/dist/routes/inputs.d.ts.map +1 -1
  152. package/dist/routes/inputs.js +74 -43
  153. package/dist/routes/inputs.js.map +1 -1
  154. package/dist/routes/job-detail.d.ts +10 -0
  155. package/dist/routes/job-detail.d.ts.map +1 -0
  156. package/dist/routes/job-detail.js +27 -0
  157. package/dist/routes/job-detail.js.map +1 -0
  158. package/dist/routes/project-route.d.ts +1 -1
  159. package/dist/routes/run-detail.d.ts.map +1 -1
  160. package/dist/routes/runs.d.ts.map +1 -1
  161. package/dist/routes/workflows.d.ts.map +1 -1
  162. package/dist/tsconfig.test.tsbuildinfo +1 -1
  163. package/package.json +10 -9
  164. package/src/components/{workflow-run-view → job-detail}/agent-config-failure-callout.stories.tsx +1 -1
  165. package/src/components/{workflow-run-view → job-detail}/agent-config-failure-callout.tsx +2 -2
  166. package/src/components/job-detail/job-detail-header.test.tsx +34 -0
  167. package/src/components/job-detail/job-detail-header.tsx +136 -0
  168. package/src/components/job-detail/job-detail-view.stories.tsx +531 -0
  169. package/src/components/job-detail/job-detail-view.tsx +467 -0
  170. package/src/components/job-detail/job-empty-states.test.ts +27 -0
  171. package/src/components/job-detail/job-empty-states.tsx +248 -0
  172. package/src/components/job-detail/job-selection.test.ts +188 -0
  173. package/src/components/job-detail/job-selection.ts +96 -0
  174. package/src/components/{workflow-run-view → job-detail}/step-attempt-log-panel.test.tsx +32 -9
  175. package/src/components/{workflow-run-view → job-detail}/step-attempt-log-panel.tsx +52 -18
  176. package/src/components/job-graph/job-graph-content.tsx +1 -1
  177. package/src/components/step-list/step-list-model.test.ts +1 -1
  178. package/src/components/step-list/step-list-model.ts +2 -2
  179. package/src/components/step-list/step-list.stories.tsx +1 -1
  180. package/src/components/step-list/step-list.test.tsx +33 -0
  181. package/src/components/step-list/step-list.tsx +40 -9
  182. package/src/components/workflow-run-duration-label.tsx +50 -12
  183. package/src/components/workflow-run-list/workflow-run-row.tsx +6 -4
  184. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +38 -0
  185. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +79 -5
  186. package/src/components/workflow-run-summary/workflow-run-summary.tsx +56 -60
  187. package/src/components/workflow-run-tabs/index.ts +3 -3
  188. package/src/components/workflow-run-tabs/run-annotation-count-chip.tsx +63 -0
  189. package/src/components/workflow-run-tabs/run-annotation-item.tsx +123 -0
  190. package/src/components/workflow-run-tabs/run-annotation-list.tsx +258 -0
  191. package/src/components/workflow-run-tabs/run-annotation-summary-line.test.tsx +77 -3
  192. package/src/components/workflow-run-tabs/run-annotation-summary-line.tsx +92 -17
  193. package/src/components/workflow-run-tabs/run-annotations-empty.tsx +6 -2
  194. package/src/components/workflow-run-tabs/run-annotations.stories.tsx +293 -0
  195. package/src/components/workflow-run-tabs/severity-visuals.ts +37 -0
  196. package/src/components/workflow-run-view/index.ts +0 -1
  197. package/src/components/workflow-run-view/run-workspace-nav.test.tsx +286 -0
  198. package/src/components/workflow-run-view/run-workspace-nav.tsx +314 -0
  199. package/src/components/workflow-run-view/workflow-run-selection.ts +1 -23
  200. package/src/components/workflow-run-view/workflow-run-view.test.tsx +237 -1085
  201. package/src/components/workflow-run-view/workflow-run-view.tsx +450 -323
  202. package/src/components/workflow-status/status-visuals.ts +5 -2
  203. package/src/components/workflow-status/workflow-status-icon.stories.tsx +1 -0
  204. package/src/components/workflow-status/workflow-status-icon.tsx +26 -1
  205. package/src/core/entities/job-execution.ts +17 -2
  206. package/src/core/entities/step.ts +26 -0
  207. package/src/core/entities/workflow-run-display.test.ts +159 -0
  208. package/src/core/entities/workflow-run.ts +139 -1
  209. package/src/core/run-annotation.test.ts +315 -0
  210. package/src/core/run-annotation.ts +238 -0
  211. package/src/core/workflow-run.ts +16 -1
  212. package/src/feature.ts +5 -0
  213. package/src/hooks/api/run-annotation-mapper.ts +30 -0
  214. package/src/hooks/api/run-annotations.ts +137 -0
  215. package/src/hooks/api/workflow-runs.ts +6 -2
  216. package/src/index.ts +1 -0
  217. package/src/page-harness-budget.test.ts +1 -0
  218. package/src/pages/workflow-job-detail-page.test.tsx +605 -0
  219. package/src/pages/workflow-job-detail-page.tsx +84 -0
  220. package/src/pages/workflow-run-detail-page.tsx +3 -43
  221. package/src/pages/workflow-run-pages.test.tsx +77 -332
  222. package/src/routes/inputs.test.ts +56 -6
  223. package/src/routes/inputs.ts +71 -19
  224. package/src/routes/job-detail.tsx +29 -0
  225. package/test/pages.tsx +25 -3
  226. package/test/render.tsx +6 -1
  227. package/tsconfig.build.tsbuildinfo +1 -1
  228. package/dist/components/workflow-run-tabs/run-jobs-list.d.ts +0 -18
  229. package/dist/components/workflow-run-tabs/run-jobs-list.d.ts.map +0 -1
  230. package/dist/components/workflow-run-tabs/run-jobs-list.js +0 -107
  231. package/dist/components/workflow-run-tabs/run-jobs-list.js.map +0 -1
  232. package/dist/components/workflow-run-tabs/run-tab-count.d.ts +0 -6
  233. package/dist/components/workflow-run-tabs/run-tab-count.d.ts.map +0 -1
  234. package/dist/components/workflow-run-tabs/run-tab-count.js +0 -27
  235. package/dist/components/workflow-run-tabs/run-tab-count.js.map +0 -1
  236. package/dist/components/workflow-run-tabs/run-tab-strip.d.ts +0 -8
  237. package/dist/components/workflow-run-tabs/run-tab-strip.d.ts.map +0 -1
  238. package/dist/components/workflow-run-tabs/run-tab-strip.js +0 -82
  239. package/dist/components/workflow-run-tabs/run-tab-strip.js.map +0 -1
  240. package/dist/components/workflow-run-view/agent-config-failure-callout.d.ts.map +0 -1
  241. package/dist/components/workflow-run-view/agent-config-failure-callout.js.map +0 -1
  242. package/dist/components/workflow-run-view/job-card.d.ts +0 -17
  243. package/dist/components/workflow-run-view/job-card.d.ts.map +0 -1
  244. package/dist/components/workflow-run-view/job-card.js +0 -496
  245. package/dist/components/workflow-run-view/job-card.js.map +0 -1
  246. package/dist/components/workflow-run-view/job-execution-switcher.d.ts.map +0 -1
  247. package/dist/components/workflow-run-view/job-execution-switcher.js.map +0 -1
  248. package/dist/components/workflow-run-view/job-execution-time-text.d.ts.map +0 -1
  249. package/dist/components/workflow-run-view/job-execution-time-text.js.map +0 -1
  250. package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts +0 -9
  251. package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts.map +0 -1
  252. package/dist/components/workflow-run-view/step-attempt-log-panel.js.map +0 -1
  253. package/dist/core/workflow-run-tabs.d.ts +0 -9
  254. package/dist/core/workflow-run-tabs.d.ts.map +0 -1
  255. package/dist/core/workflow-run-tabs.js +0 -3
  256. package/dist/core/workflow-run-tabs.js.map +0 -1
  257. package/src/components/workflow-run-tabs/run-jobs-list.tsx +0 -151
  258. package/src/components/workflow-run-tabs/run-tab-count.tsx +0 -28
  259. package/src/components/workflow-run-tabs/run-tab-strip.stories.tsx +0 -52
  260. package/src/components/workflow-run-tabs/run-tab-strip.test.tsx +0 -117
  261. package/src/components/workflow-run-tabs/run-tab-strip.tsx +0 -97
  262. package/src/components/workflow-run-view/job-card.stories.tsx +0 -789
  263. package/src/components/workflow-run-view/job-card.test.tsx +0 -115
  264. package/src/components/workflow-run-view/job-card.tsx +0 -614
  265. package/src/core/workflow-run-tabs.ts +0 -8
  266. /package/dist/components/{workflow-run-view → job-detail}/agent-config-failure-callout.d.ts +0 -0
  267. /package/dist/components/{workflow-run-view → job-detail}/job-execution-switcher.d.ts +0 -0
  268. /package/dist/components/{workflow-run-view → job-detail}/job-execution-switcher.js +0 -0
  269. /package/dist/components/{workflow-run-view → job-detail}/job-execution-time-text.d.ts +0 -0
  270. /package/dist/components/{workflow-run-view → job-detail}/job-execution-time-text.js +0 -0
  271. /package/src/components/{workflow-run-view → job-detail}/job-execution-switcher.tsx +0 -0
  272. /package/src/components/{workflow-run-view → job-detail}/job-execution-time-text.tsx +0 -0
@@ -0,0 +1,258 @@
1
+ import {QueryLoadError, type QueryLoadErrorQuery} from '@shipfox/client-ui';
2
+ import {Button} from '@shipfox/react-ui/button';
3
+ import {Callout} from '@shipfox/react-ui/callout';
4
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
5
+ import {Skeleton} from '@shipfox/react-ui/skeleton';
6
+ import {Text} from '@shipfox/react-ui/typography';
7
+ import {useState} from 'react';
8
+ import type {RunAnnotationEntry} from '#core/run-annotation.js';
9
+ import {RunAnnotationItem} from './run-annotation-item.js';
10
+ import {RunAnnotationsEmpty} from './run-annotations-empty.js';
11
+
12
+ export interface RunAnnotationListQuery extends QueryLoadErrorQuery {
13
+ isPending: boolean;
14
+ hasNextPage: boolean;
15
+ isFetchingNextPage: boolean;
16
+ fetchNextPage: () => unknown;
17
+ }
18
+
19
+ export interface RunAnnotationListProps {
20
+ query: RunAnnotationListQuery;
21
+ entries: RunAnnotationEntry[] | undefined;
22
+ workflowRunId: string;
23
+ workspaceSlug?: string | undefined;
24
+ projectSlug?: string | undefined;
25
+ runAttempt?: number | undefined;
26
+ /** Name of the job the list is filtered to, used only for empty-state copy. */
27
+ filteredJobName?: string | undefined;
28
+ /** Active severity filter, named in the empty state so the reader knows what to clear. */
29
+ filteredSeverity?: string | undefined;
30
+ filtered: boolean;
31
+ onClearFilters?: (() => void) | undefined;
32
+ /** Annotation id from `?annotation=`, which the matching card scrolls to and focuses. */
33
+ selectedAnnotationId?: string | undefined;
34
+ }
35
+
36
+ /**
37
+ * How many annotations render at once.
38
+ *
39
+ * A page holds up to 500 rows and each one mounts a Markdown pipeline, so the fetch budget is
40
+ * not a render budget. The window grows on demand and the server page only loads once the
41
+ * reader has actually seen everything already fetched.
42
+ */
43
+ const RENDER_WINDOW = 25;
44
+
45
+ /**
46
+ * The annotations list.
47
+ *
48
+ * Loading, empty, error, and stale are four distinct renderings on purpose: coalescing a failed
49
+ * fetch into an empty result tells a reader their run is clean when the truth is unknown.
50
+ */
51
+ export function RunAnnotationList({
52
+ query,
53
+ entries,
54
+ workflowRunId,
55
+ workspaceSlug,
56
+ projectSlug,
57
+ runAttempt,
58
+ filteredJobName,
59
+ filteredSeverity,
60
+ filtered,
61
+ onClearFilters,
62
+ selectedAnnotationId,
63
+ }: RunAnnotationListProps) {
64
+ const [visibleCount, setVisibleCount] = useState(RENDER_WINDOW);
65
+
66
+ // A deep-linked annotation past the window would otherwise be unreachable: it is in the data,
67
+ // absent from the DOM, and nothing on screen says so.
68
+ const selectedIndex = selectedAnnotationId
69
+ ? (entries?.findIndex((entry) => entry.annotation.id === selectedAnnotationId) ?? -1)
70
+ : -1;
71
+ const renderCount = Math.max(visibleCount, selectedIndex + 1);
72
+
73
+ if (query.isPending) return <RunAnnotationListSkeleton />;
74
+
75
+ if (query.isError && entries === undefined) {
76
+ return <QueryLoadError query={query} subject="annotations" icon="fileDamageLine" />;
77
+ }
78
+
79
+ if (entries === undefined) return <RunAnnotationListSkeleton />;
80
+
81
+ const visible = entries.slice(0, renderCount);
82
+ const hiddenCount = entries.length - visible.length;
83
+
84
+ return (
85
+ <div className="flex min-w-0 flex-col gap-12">
86
+ {query.isError ? <RunAnnotationStaleError query={query} /> : null}
87
+
88
+ {entries.length === 0 ? (
89
+ filtered ? (
90
+ <RunAnnotationsFilteredEmpty
91
+ jobName={filteredJobName}
92
+ severity={filteredSeverity}
93
+ incomplete={query.hasNextPage}
94
+ onClearFilters={onClearFilters}
95
+ />
96
+ ) : (
97
+ <RunAnnotationsEmpty />
98
+ )
99
+ ) : (
100
+ <ol className="flex min-w-0 flex-col gap-12">
101
+ {visible.map((entry) => (
102
+ <RunAnnotationItem
103
+ key={entry.annotation.id}
104
+ entry={entry}
105
+ workspaceSlug={workspaceSlug}
106
+ projectSlug={projectSlug}
107
+ workflowRunId={workflowRunId}
108
+ runAttempt={runAttempt}
109
+ selected={entry.annotation.id === selectedAnnotationId}
110
+ />
111
+ ))}
112
+ </ol>
113
+ )}
114
+
115
+ {hiddenCount > 0 ? (
116
+ <Button
117
+ type="button"
118
+ size="sm"
119
+ variant="secondary"
120
+ className="self-start [@media(pointer:coarse)]:min-h-44"
121
+ onClick={() => setVisibleCount((current) => current + RENDER_WINDOW)}
122
+ >
123
+ Show {Math.min(hiddenCount, RENDER_WINDOW)} more of {entries.length}
124
+ </Button>
125
+ ) : query.hasNextPage ? (
126
+ <Button
127
+ type="button"
128
+ size="sm"
129
+ variant="secondary"
130
+ className="self-start [@media(pointer:coarse)]:min-h-44"
131
+ isLoading={query.isFetchingNextPage}
132
+ onClick={() => {
133
+ void query.fetchNextPage();
134
+ }}
135
+ >
136
+ Load more annotations
137
+ </Button>
138
+ ) : null}
139
+ </div>
140
+ );
141
+ }
142
+
143
+ /**
144
+ * Names the filters that are actually active.
145
+ *
146
+ * Blaming severity for a job-only filter sends the reader to change the wrong control, which
147
+ * is worse than saying nothing.
148
+ */
149
+ function filteredEmptyDescription({
150
+ jobName,
151
+ severity,
152
+ incomplete,
153
+ }: {
154
+ jobName: string | undefined;
155
+ severity: string | undefined;
156
+ incomplete: boolean;
157
+ }): string {
158
+ if (incomplete) {
159
+ if (jobName && severity) {
160
+ return `None of the loaded annotations are from ${jobName} at ${severity} severity. Load more annotations to continue searching.`;
161
+ }
162
+ if (jobName) {
163
+ return `None of the loaded annotations are from ${jobName}. Load more annotations to continue searching.`;
164
+ }
165
+ if (severity) {
166
+ return `None of the loaded annotations are at ${severity} severity. Load more annotations to continue searching.`;
167
+ }
168
+ return 'None of the loaded annotations match the current filters. Load more annotations to continue searching.';
169
+ }
170
+ if (jobName && severity) {
171
+ return `This run has annotations, but none from ${jobName} at ${severity} severity.`;
172
+ }
173
+ if (jobName) return `This run has annotations, but none from ${jobName}.`;
174
+ if (severity) return `This run has annotations, but none at ${severity} severity.`;
175
+ return 'This run has annotations, but none match the current filters.';
176
+ }
177
+
178
+ function RunAnnotationsFilteredEmpty({
179
+ jobName,
180
+ severity,
181
+ incomplete,
182
+ onClearFilters,
183
+ }: {
184
+ jobName: string | undefined;
185
+ severity: string | undefined;
186
+ incomplete: boolean;
187
+ onClearFilters: (() => void) | undefined;
188
+ }) {
189
+ return (
190
+ <div className="flex flex-col items-center gap-12">
191
+ <EmptyState
192
+ icon="fileDamageLine"
193
+ title={incomplete ? 'No matches in loaded annotations' : 'No matching annotations'}
194
+ description={filteredEmptyDescription({jobName, severity, incomplete})}
195
+ />
196
+ {onClearFilters ? (
197
+ <Button
198
+ type="button"
199
+ size="sm"
200
+ variant="secondary"
201
+ className="[@media(pointer:coarse)]:min-h-44"
202
+ onClick={onClearFilters}
203
+ >
204
+ Clear filters
205
+ </Button>
206
+ ) : null}
207
+ </div>
208
+ );
209
+ }
210
+
211
+ /**
212
+ * A failed poll keeps the annotations already on screen and marks them stale rather than
213
+ * clearing. Polite rather than assertive: the poll runs every 4s and a flapping API would
214
+ * otherwise interrupt a screen reader on every cycle.
215
+ */
216
+ function RunAnnotationStaleError({query}: {query: QueryLoadErrorQuery}) {
217
+ return (
218
+ <Callout role="status" aria-live="polite" type="error">
219
+ <div className="flex w-full items-center justify-between gap-8">
220
+ <Text size="xs">Could not refresh annotations.</Text>
221
+ <Button
222
+ type="button"
223
+ size="2xs"
224
+ variant="secondary"
225
+ className="[@media(pointer:coarse)]:min-h-44"
226
+ isLoading={query.isFetching}
227
+ onClick={() => {
228
+ void query.refetch();
229
+ }}
230
+ >
231
+ Retry
232
+ </Button>
233
+ </div>
234
+ </Callout>
235
+ );
236
+ }
237
+
238
+ const ANNOTATION_SKELETON_ROWS = ['first', 'second', 'third'];
239
+
240
+ function RunAnnotationListSkeleton() {
241
+ return (
242
+ <section aria-label="Loading annotations" className="flex flex-col gap-12">
243
+ {ANNOTATION_SKELETON_ROWS.map((row) => (
244
+ <div
245
+ key={row}
246
+ className="flex gap-12 rounded-8 border border-border-neutral-base bg-background-components-base px-12 py-8"
247
+ >
248
+ <Skeleton className="h-40 w-4 rounded-full" />
249
+ <div className="flex min-w-0 flex-1 flex-col gap-6">
250
+ <Skeleton className="h-20 w-160 rounded-4" />
251
+ <Skeleton className="h-16 w-240 rounded-4" />
252
+ <Skeleton className="h-40 w-full rounded-4" />
253
+ </div>
254
+ </div>
255
+ ))}
256
+ </section>
257
+ );
258
+ }
@@ -7,7 +7,7 @@ import {
7
7
  RouterProvider,
8
8
  } from '@tanstack/react-router';
9
9
  import {act, render, screen} from '@testing-library/react';
10
- import type {RunAnnotationSummary} from '#core/workflow-run-tabs.js';
10
+ import type {RunAnnotationSummary} from '#core/run-annotation.js';
11
11
  import type {WorkflowRunsSearch} from '#routes/inputs.js';
12
12
  import {RunAnnotationSummaryLine} from './run-annotation-summary-line.js';
13
13
 
@@ -17,8 +17,12 @@ const ANNOTATION_SUMMARY: RunAnnotationSummary = {
17
17
  warning: 1,
18
18
  info: 2,
19
19
  success: 0,
20
+ truncated: false,
20
21
  };
21
22
 
23
+ const INFO_OR_SUCCESS_PATTERN = /info|success/;
24
+ const SHOW_ALL_PATTERN = /Show all/;
25
+
22
26
  describe('RunAnnotationSummaryLine', () => {
23
27
  test('links the severity breakdown into the Annotations panel', async () => {
24
28
  await renderSummaryLine();
@@ -30,6 +34,66 @@ describe('RunAnnotationSummaryLine', () => {
30
34
  );
31
35
  });
32
36
 
37
+ test('renders every count at one type scale', async () => {
38
+ // The severity counts previously bypassed `Text` and inherited the ambient 14px, so they
39
+ // rendered a step larger and heavier than the total they sit beside.
40
+ await renderSummaryLine();
41
+
42
+ const total = screen.getByText('5 annotations');
43
+ const errors = screen.getByText('2 errors');
44
+
45
+ expect(total).toHaveClass('text-xs', 'font-display');
46
+ expect(errors).toHaveClass('text-xs', 'font-display');
47
+ expect(errors).not.toHaveClass('text-foreground-highlight-interactive');
48
+ });
49
+
50
+ test('distinguishes severities by glyph rather than by label colour', async () => {
51
+ const {container} = await renderSummaryLine();
52
+
53
+ const tones = [...container.querySelectorAll('svg')].map((glyph) =>
54
+ glyph.getAttribute('class'),
55
+ );
56
+
57
+ expect(tones.some((tone) => tone?.includes('text-tag-error-icon'))).toBe(true);
58
+ expect(tones.some((tone) => tone?.includes('text-tag-warning-icon'))).toBe(true);
59
+ });
60
+
61
+ test('names only the severities that need attention', async () => {
62
+ await renderSummaryLine();
63
+
64
+ expect(screen.getByRole('link', {name: '2 errors'})).toBeInTheDocument();
65
+ expect(screen.getByRole('link', {name: '1 warning'})).toBeInTheDocument();
66
+ // The 2 info annotations are inside the total; naming them again would give a clean
67
+ // result the same weight and the same accent as a failure.
68
+ expect(screen.queryByRole('link', {name: '2 infos'})).not.toBeInTheDocument();
69
+ expect(screen.queryByText(INFO_OR_SUCCESS_PATTERN)).not.toBeInTheDocument();
70
+ });
71
+
72
+ test('turns the total into the way out of an active severity filter', async () => {
73
+ await renderSummaryLine({severity: 'error'});
74
+
75
+ expect(screen.getByRole('link', {name: 'Show all 5 annotations'})).toHaveAttribute(
76
+ 'href',
77
+ '/w/acme/p/project/runs/run-1?tab=annotations',
78
+ );
79
+ });
80
+
81
+ test('leaves the total as plain text when no severity filter is active', async () => {
82
+ await renderSummaryLine();
83
+
84
+ expect(screen.queryByRole('link', {name: SHOW_ALL_PATTERN})).not.toBeInTheDocument();
85
+ });
86
+
87
+ test('falls back to the bare total when nothing needs attention', async () => {
88
+ await renderSummaryLine(
89
+ {},
90
+ {total: 3, error: 0, warning: 0, info: 3, success: 0, truncated: false},
91
+ );
92
+
93
+ expect(screen.getByText('3 annotations')).toBeInTheDocument();
94
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
95
+ });
96
+
33
97
  test('clears a stale annotation when linking to a severity', async () => {
34
98
  await renderSummaryLine({annotation: 'annotation-old'});
35
99
 
@@ -38,16 +102,26 @@ describe('RunAnnotationSummaryLine', () => {
38
102
  '/w/acme/p/project/runs/run-1?tab=annotations&severity=error',
39
103
  );
40
104
  });
105
+
106
+ test('marks a truncated read as a lower bound', async () => {
107
+ await renderSummaryLine({}, {...ANNOTATION_SUMMARY, truncated: true});
108
+
109
+ expect(screen.getByText('5+ annotations')).toBeInTheDocument();
110
+ expect(screen.getByRole('link', {name: '2+ errors'})).toBeInTheDocument();
111
+ });
41
112
  });
42
113
 
43
- async function renderSummaryLine(search: WorkflowRunsSearch = {}) {
114
+ async function renderSummaryLine(
115
+ search: WorkflowRunsSearch = {},
116
+ summary: RunAnnotationSummary = ANNOTATION_SUMMARY,
117
+ ) {
44
118
  const rootRoute = createRootRoute({component: Outlet});
45
119
  const runRoute = createRoute({
46
120
  getParentRoute: () => rootRoute,
47
121
  path: '/w/$workspaceSlug/p/$projectSlug/runs/$workflowRunId',
48
122
  component: () => (
49
123
  <RunAnnotationSummaryLine
50
- summary={ANNOTATION_SUMMARY}
124
+ summary={summary}
51
125
  workspaceSlug="acme"
52
126
  projectSlug="project"
53
127
  workflowRunId="run-1"
@@ -1,13 +1,28 @@
1
+ import {Icon} from '@shipfox/react-ui/icon';
1
2
  import {Text} from '@shipfox/react-ui/typography';
3
+ import {cn} from '@shipfox/react-ui/utils';
2
4
  import {Link} from '@tanstack/react-router';
3
- import type {RunAnnotationSummary} from '#core/workflow-run-tabs.js';
5
+ import type {RunAnnotationSummary} from '#core/run-annotation.js';
4
6
  import {
5
- WORKFLOW_RUN_ANNOTATION_SEVERITIES,
6
7
  type WorkflowRunAnnotationSeverity,
7
8
  type WorkflowRunsSearch,
8
9
  workflowRunSearchParams,
9
10
  } from '#routes/inputs.js';
10
11
  import {MetadataSeparator} from '../workflow-run-summary/workflow-run-summary.js';
12
+ import {SEVERITY_ICON, SEVERITY_ICON_TONE} from './severity-visuals.js';
13
+
14
+ /**
15
+ * The severities this line names.
16
+ *
17
+ * The line exists so a failing run cannot look clean while the explanation sits behind an
18
+ * inactive section, which makes its subject "what needs attention", not "what happened".
19
+ * `info` and `success` are already inside the total, and repeating them gives "nothing is
20
+ * wrong" the same weight and the same accent as "something failed".
21
+ */
22
+ const ATTENTION_SEVERITIES = [
23
+ 'error',
24
+ 'warning',
25
+ ] as const satisfies readonly WorkflowRunAnnotationSeverity[];
11
26
 
12
27
  export interface RunAnnotationSummaryLineProps {
13
28
  summary?: RunAnnotationSummary | undefined;
@@ -24,22 +39,49 @@ export function RunAnnotationSummaryLine({
24
39
  workflowRunId,
25
40
  search = {},
26
41
  }: RunAnnotationSummaryLineProps) {
27
- if (!summary) return null;
42
+ // A run with no annotations says so in its empty state; a "0 annotations" line beside it is
43
+ // the same fact twice.
44
+ if (!summary || summary.total === 0) return null;
28
45
 
29
- const visibleSeverities = WORKFLOW_RUN_ANNOTATION_SEVERITIES.filter(
30
- (severity) => summary[severity] > 0,
31
- );
46
+ const visibleSeverities = ATTENTION_SEVERITIES.filter((severity) => summary[severity] > 0);
47
+
48
+ const totalLabel = countLabel(summary.total, 'annotation', summary.truncated);
32
49
 
33
50
  return (
34
51
  <div className="flex flex-wrap items-center gap-x-8 gap-y-4 text-foreground-neutral-subtle">
35
- <Text as="span" size="xs" className="shrink-0 text-foreground-neutral-subtle">
36
- {summary.total} {pluralize('annotation', summary.total)}
37
- </Text>
52
+ {search.severity && workspaceSlug && projectSlug && workflowRunId ? (
53
+ // The severity filter has no control of its own, so while one is active the total is
54
+ // the way back out. Unfiltered it stays plain text: a link to where you already are
55
+ // is noise.
56
+ <Link
57
+ to="/w/$workspaceSlug/p/$projectSlug/runs/$workflowRunId"
58
+ params={{workspaceSlug, projectSlug, workflowRunId}}
59
+ search={
60
+ workflowRunSearchParams(
61
+ {...withoutSeverity(search), tab: 'annotations'},
62
+ search,
63
+ ) as never
64
+ }
65
+ aria-label={`Show all ${totalLabel}`}
66
+ className="shrink-0 rounded-4 outline-none focus-visible:shadow-border-interactive-with-active"
67
+ >
68
+ {/* Underlined rather than accented: the escape hatch has to read as a link without
69
+ becoming the loudest thing on a line whose job is to be scannable. */}
70
+ <Text as="span" size="xs" className="text-foreground-neutral-subtle underline">
71
+ {totalLabel}
72
+ </Text>
73
+ </Link>
74
+ ) : (
75
+ <Text as="span" size="xs" className="shrink-0 text-foreground-neutral-subtle">
76
+ {totalLabel}
77
+ </Text>
78
+ )}
38
79
  {visibleSeverities.map((severity) => (
39
80
  <span key={severity} className="inline-flex shrink-0 items-center gap-8">
40
81
  <MetadataSeparator />
41
82
  <SeverityLink
42
83
  count={summary[severity]}
84
+ truncated={summary.truncated}
43
85
  severity={severity}
44
86
  workspaceSlug={workspaceSlug}
45
87
  projectSlug={projectSlug}
@@ -54,6 +96,7 @@ export function RunAnnotationSummaryLine({
54
96
 
55
97
  function SeverityLink({
56
98
  count,
99
+ truncated,
57
100
  severity,
58
101
  workspaceSlug,
59
102
  projectSlug,
@@ -61,19 +104,33 @@ function SeverityLink({
61
104
  search,
62
105
  }: {
63
106
  count: number;
107
+ truncated: boolean;
64
108
  severity: WorkflowRunAnnotationSeverity;
65
109
  workspaceSlug?: string | undefined;
66
110
  projectSlug?: string | undefined;
67
111
  workflowRunId?: string | undefined;
68
112
  search: WorkflowRunsSearch;
69
113
  }) {
70
- const label = `${count} ${pluralize(severity, count)}`;
114
+ const label = countLabel(count, severity, truncated);
115
+ const content = (
116
+ <>
117
+ <Icon
118
+ name={SEVERITY_ICON[severity]}
119
+ size={12}
120
+ aria-hidden="true"
121
+ className={cn('shrink-0', SEVERITY_ICON_TONE[severity])}
122
+ />
123
+ <Text as="span" size="xs" className="text-foreground-neutral-subtle">
124
+ {label}
125
+ </Text>
126
+ </>
127
+ );
128
+
71
129
  if (!workspaceSlug || !projectSlug || !workflowRunId) {
72
- return <span className="text-foreground-highlight-interactive">{label}</span>;
130
+ return <span className="inline-flex items-center gap-4">{content}</span>;
73
131
  }
74
132
 
75
- const searchWithoutAnnotation = {...search};
76
- delete searchWithoutAnnotation.annotation;
133
+ const searchWithoutAnnotation = withoutAnnotation(search);
77
134
 
78
135
  return (
79
136
  <Link
@@ -85,13 +142,31 @@ function SeverityLink({
85
142
  search,
86
143
  ) as never
87
144
  }
88
- className="text-foreground-highlight-interactive outline-none hover:underline focus-visible:shadow-border-interactive-with-active"
145
+ className="inline-flex items-center gap-4 rounded-4 outline-none hover:underline focus-visible:shadow-border-interactive-with-active"
89
146
  >
90
- {label}
147
+ {content}
91
148
  </Link>
92
149
  );
93
150
  }
94
151
 
95
- function pluralize(word: string, count: number): string {
96
- return count === 1 ? word : `${word}s`;
152
+ /** A selected annotation is stale the moment the list it was chosen from is refiltered. */
153
+ function withoutAnnotation(search: WorkflowRunsSearch): WorkflowRunsSearch {
154
+ const next = {...search};
155
+ delete next.annotation;
156
+ return next;
157
+ }
158
+
159
+ function withoutSeverity(search: WorkflowRunsSearch): WorkflowRunsSearch {
160
+ const next = withoutAnnotation(search);
161
+ delete next.severity;
162
+ return next;
163
+ }
164
+
165
+ /**
166
+ * A truncated read renders `500+ annotations`: the counts are a lower bound once the page budget
167
+ * is hit, and presenting them as exact would understate a run the reader is trying to trust.
168
+ */
169
+ function countLabel(count: number, word: string, truncated: boolean): string {
170
+ const plural = count === 1 && !truncated ? word : `${word}s`;
171
+ return `${count}${truncated ? '+' : ''} ${plural}`;
97
172
  }
@@ -1,11 +1,15 @@
1
1
  import {EmptyState} from '@shipfox/react-ui/empty-state';
2
2
 
3
- export function RunAnnotationsEmpty() {
3
+ export function RunAnnotationsEmpty({jobName}: {jobName?: string | undefined} = {}) {
4
4
  return (
5
5
  <EmptyState
6
6
  icon="fileDamageLine"
7
7
  title="No annotations"
8
- description="This run has no annotations to show."
8
+ description={
9
+ jobName
10
+ ? `${jobName} has no annotations to show in this run.`
11
+ : 'This run has no annotations to show.'
12
+ }
9
13
  />
10
14
  );
11
15
  }