@shipfox/client-workflows 14.0.0 → 15.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 (223) hide show
  1. package/.storybook/preview.tsx +7 -2
  2. package/.turbo/turbo-build.log +1 -1
  3. package/CHANGELOG.md +35 -0
  4. package/dist/components/job-detail/agent-config-failure-callout.d.ts.map +1 -0
  5. package/dist/components/{workflow-run-view → job-detail}/agent-config-failure-callout.js +2 -2
  6. package/dist/components/job-detail/agent-config-failure-callout.js.map +1 -0
  7. package/dist/components/job-detail/job-detail-header.d.ts +8 -0
  8. package/dist/components/job-detail/job-detail-header.d.ts.map +1 -0
  9. package/dist/components/job-detail/job-detail-header.js +111 -0
  10. package/dist/components/job-detail/job-detail-header.js.map +1 -0
  11. package/dist/components/job-detail/job-detail-view.d.ts +16 -0
  12. package/dist/components/job-detail/job-detail-view.d.ts.map +1 -0
  13. package/dist/components/job-detail/job-detail-view.js +405 -0
  14. package/dist/components/job-detail/job-detail-view.js.map +1 -0
  15. package/dist/components/job-detail/job-empty-states.d.ts +10 -0
  16. package/dist/components/job-detail/job-empty-states.d.ts.map +1 -0
  17. package/dist/components/job-detail/job-empty-states.js +195 -0
  18. package/dist/components/job-detail/job-empty-states.js.map +1 -0
  19. package/dist/components/job-detail/job-execution-switcher.d.ts.map +1 -0
  20. package/dist/components/job-detail/job-execution-switcher.js.map +1 -0
  21. package/dist/components/job-detail/job-execution-time-text.d.ts.map +1 -0
  22. package/dist/components/job-detail/job-execution-time-text.js.map +1 -0
  23. package/dist/components/job-detail/job-selection.d.ts +24 -0
  24. package/dist/components/job-detail/job-selection.d.ts.map +1 -0
  25. package/dist/components/job-detail/job-selection.js +66 -0
  26. package/dist/components/job-detail/job-selection.js.map +1 -0
  27. package/dist/components/job-detail/step-attempt-log-panel.d.ts +15 -0
  28. package/dist/components/job-detail/step-attempt-log-panel.d.ts.map +1 -0
  29. package/dist/components/{workflow-run-view → job-detail}/step-attempt-log-panel.js +61 -16
  30. package/dist/components/job-detail/step-attempt-log-panel.js.map +1 -0
  31. package/dist/components/job-graph/job-graph-content.js +1 -1
  32. package/dist/components/job-graph/job-graph-content.js.map +1 -1
  33. package/dist/components/step-list/step-list-model.js +2 -2
  34. package/dist/components/step-list/step-list-model.js.map +1 -1
  35. package/dist/components/step-list/step-list.d.ts +1 -0
  36. package/dist/components/step-list/step-list.d.ts.map +1 -1
  37. package/dist/components/step-list/step-list.js +29 -4
  38. package/dist/components/step-list/step-list.js.map +1 -1
  39. package/dist/components/workflow-run-duration-label.d.ts +9 -4
  40. package/dist/components/workflow-run-duration-label.d.ts.map +1 -1
  41. package/dist/components/workflow-run-duration-label.js +27 -7
  42. package/dist/components/workflow-run-duration-label.js.map +1 -1
  43. package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
  44. package/dist/components/workflow-run-list/workflow-run-row.js +6 -5
  45. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  46. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  47. package/dist/components/workflow-run-summary/workflow-run-summary.js +68 -64
  48. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  49. package/dist/components/workflow-run-tabs/index.d.ts +0 -3
  50. package/dist/components/workflow-run-tabs/index.d.ts.map +1 -1
  51. package/dist/components/workflow-run-tabs/index.js +0 -3
  52. package/dist/components/workflow-run-tabs/index.js.map +1 -1
  53. package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts +3 -1
  54. package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts.map +1 -1
  55. package/dist/components/workflow-run-tabs/run-annotations-empty.js +2 -2
  56. package/dist/components/workflow-run-tabs/run-annotations-empty.js.map +1 -1
  57. package/dist/components/workflow-run-view/index.d.ts +0 -1
  58. package/dist/components/workflow-run-view/index.d.ts.map +1 -1
  59. package/dist/components/workflow-run-view/index.js +0 -1
  60. package/dist/components/workflow-run-view/index.js.map +1 -1
  61. package/dist/components/workflow-run-view/run-workspace-nav.d.ts +20 -0
  62. package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -0
  63. package/dist/components/workflow-run-view/run-workspace-nav.js +271 -0
  64. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -0
  65. package/dist/components/workflow-run-view/workflow-run-selection.d.ts.map +1 -1
  66. package/dist/components/workflow-run-view/workflow-run-selection.js +1 -14
  67. package/dist/components/workflow-run-view/workflow-run-selection.js.map +1 -1
  68. package/dist/components/workflow-run-view/workflow-run-view.d.ts +10 -8
  69. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  70. package/dist/components/workflow-run-view/workflow-run-view.js +250 -236
  71. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  72. package/dist/components/workflow-status/status-visuals.d.ts.map +1 -1
  73. package/dist/components/workflow-status/status-visuals.js +10 -2
  74. package/dist/components/workflow-status/status-visuals.js.map +1 -1
  75. package/dist/components/workflow-status/workflow-status-icon.d.ts +3 -1
  76. package/dist/components/workflow-status/workflow-status-icon.d.ts.map +1 -1
  77. package/dist/components/workflow-status/workflow-status-icon.js +13 -2
  78. package/dist/components/workflow-status/workflow-status-icon.js.map +1 -1
  79. package/dist/core/entities/job-execution.d.ts +9 -0
  80. package/dist/core/entities/job-execution.d.ts.map +1 -1
  81. package/dist/core/entities/job-execution.js +13 -3
  82. package/dist/core/entities/job-execution.js.map +1 -1
  83. package/dist/core/entities/job.d.ts.map +1 -1
  84. package/dist/core/entities/step-attempt.d.ts.map +1 -1
  85. package/dist/core/entities/step.d.ts +2 -0
  86. package/dist/core/entities/step.d.ts.map +1 -1
  87. package/dist/core/entities/step.js +13 -0
  88. package/dist/core/entities/step.js.map +1 -1
  89. package/dist/core/entities/workflow-run-attempt.d.ts.map +1 -1
  90. package/dist/core/entities/workflow-run.d.ts +56 -1
  91. package/dist/core/entities/workflow-run.d.ts.map +1 -1
  92. package/dist/core/entities/workflow-run.js +98 -0
  93. package/dist/core/entities/workflow-run.js.map +1 -1
  94. package/dist/core/workflow-run.d.ts +4 -4
  95. package/dist/core/workflow-run.d.ts.map +1 -1
  96. package/dist/core/workflow-run.js +3 -3
  97. package/dist/core/workflow-run.js.map +1 -1
  98. package/dist/feature.d.ts +20 -16
  99. package/dist/feature.d.ts.map +1 -1
  100. package/dist/feature.js +5 -0
  101. package/dist/feature.js.map +1 -1
  102. package/dist/hooks/api/workflow-runs.d.ts +5 -3
  103. package/dist/hooks/api/workflow-runs.d.ts.map +1 -1
  104. package/dist/hooks/api/workflow-runs.js +5 -4
  105. package/dist/hooks/api/workflow-runs.js.map +1 -1
  106. package/dist/index.d.ts +1 -0
  107. package/dist/index.d.ts.map +1 -1
  108. package/dist/index.js +1 -0
  109. package/dist/index.js.map +1 -1
  110. package/dist/pages/workflow-job-detail-page.d.ts +11 -0
  111. package/dist/pages/workflow-job-detail-page.d.ts.map +1 -0
  112. package/dist/pages/workflow-job-detail-page.js +66 -0
  113. package/dist/pages/workflow-job-detail-page.js.map +1 -0
  114. package/dist/pages/workflow-run-detail-page.d.ts.map +1 -1
  115. package/dist/pages/workflow-run-detail-page.js +4 -37
  116. package/dist/pages/workflow-run-detail-page.js.map +1 -1
  117. package/dist/routes/inputs.d.ts +47 -34
  118. package/dist/routes/inputs.d.ts.map +1 -1
  119. package/dist/routes/inputs.js +69 -37
  120. package/dist/routes/inputs.js.map +1 -1
  121. package/dist/routes/job-detail.d.ts +10 -0
  122. package/dist/routes/job-detail.d.ts.map +1 -0
  123. package/dist/routes/job-detail.js +27 -0
  124. package/dist/routes/job-detail.js.map +1 -0
  125. package/dist/routes/project-route.d.ts +1 -1
  126. package/dist/routes/run-detail.d.ts.map +1 -1
  127. package/dist/routes/runs.d.ts.map +1 -1
  128. package/dist/routes/workflows.d.ts.map +1 -1
  129. package/dist/tsconfig.test.tsbuildinfo +1 -1
  130. package/package.json +7 -7
  131. package/src/components/{workflow-run-view → job-detail}/agent-config-failure-callout.stories.tsx +1 -1
  132. package/src/components/{workflow-run-view → job-detail}/agent-config-failure-callout.tsx +2 -2
  133. package/src/components/job-detail/job-detail-header.test.tsx +31 -0
  134. package/src/components/job-detail/job-detail-header.tsx +111 -0
  135. package/src/components/job-detail/job-detail-view.stories.tsx +526 -0
  136. package/src/components/job-detail/job-detail-view.tsx +450 -0
  137. package/src/components/job-detail/job-empty-states.test.ts +27 -0
  138. package/src/components/job-detail/job-empty-states.tsx +248 -0
  139. package/src/components/job-detail/job-selection.test.ts +188 -0
  140. package/src/components/job-detail/job-selection.ts +96 -0
  141. package/src/components/{workflow-run-view → job-detail}/step-attempt-log-panel.test.tsx +32 -9
  142. package/src/components/{workflow-run-view → job-detail}/step-attempt-log-panel.tsx +52 -18
  143. package/src/components/job-graph/job-graph-content.tsx +1 -1
  144. package/src/components/step-list/step-list-model.test.ts +1 -1
  145. package/src/components/step-list/step-list-model.ts +2 -2
  146. package/src/components/step-list/step-list.stories.tsx +1 -1
  147. package/src/components/step-list/step-list.test.tsx +33 -0
  148. package/src/components/step-list/step-list.tsx +40 -9
  149. package/src/components/workflow-run-duration-label.tsx +50 -12
  150. package/src/components/workflow-run-list/workflow-run-row.tsx +6 -4
  151. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +38 -0
  152. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +79 -5
  153. package/src/components/workflow-run-summary/workflow-run-summary.tsx +56 -60
  154. package/src/components/workflow-run-tabs/index.ts +0 -3
  155. package/src/components/workflow-run-tabs/run-annotations-empty.tsx +6 -2
  156. package/src/components/workflow-run-view/index.ts +0 -1
  157. package/src/components/workflow-run-view/run-workspace-nav.test.tsx +242 -0
  158. package/src/components/workflow-run-view/run-workspace-nav.tsx +301 -0
  159. package/src/components/workflow-run-view/workflow-run-selection.ts +1 -23
  160. package/src/components/workflow-run-view/workflow-run-view.test.tsx +62 -1136
  161. package/src/components/workflow-run-view/workflow-run-view.tsx +330 -321
  162. package/src/components/workflow-status/status-visuals.ts +5 -2
  163. package/src/components/workflow-status/workflow-status-icon.stories.tsx +1 -0
  164. package/src/components/workflow-status/workflow-status-icon.tsx +26 -1
  165. package/src/core/entities/job-execution.ts +17 -2
  166. package/src/core/entities/step.ts +26 -0
  167. package/src/core/entities/workflow-run-display.test.ts +159 -0
  168. package/src/core/entities/workflow-run.ts +139 -1
  169. package/src/core/workflow-run.ts +16 -1
  170. package/src/feature.ts +5 -0
  171. package/src/hooks/api/workflow-runs.ts +6 -2
  172. package/src/index.ts +1 -0
  173. package/src/page-harness-budget.test.ts +1 -0
  174. package/src/pages/workflow-job-detail-page.test.tsx +539 -0
  175. package/src/pages/workflow-job-detail-page.tsx +84 -0
  176. package/src/pages/workflow-run-detail-page.tsx +3 -43
  177. package/src/pages/workflow-run-pages.test.tsx +77 -332
  178. package/src/routes/inputs.test.ts +56 -6
  179. package/src/routes/inputs.ts +64 -17
  180. package/src/routes/job-detail.tsx +29 -0
  181. package/test/pages.tsx +25 -3
  182. package/test/render.tsx +6 -1
  183. package/tsconfig.build.tsbuildinfo +1 -1
  184. package/dist/components/workflow-run-tabs/run-jobs-list.d.ts +0 -18
  185. package/dist/components/workflow-run-tabs/run-jobs-list.d.ts.map +0 -1
  186. package/dist/components/workflow-run-tabs/run-jobs-list.js +0 -107
  187. package/dist/components/workflow-run-tabs/run-jobs-list.js.map +0 -1
  188. package/dist/components/workflow-run-tabs/run-tab-count.d.ts +0 -6
  189. package/dist/components/workflow-run-tabs/run-tab-count.d.ts.map +0 -1
  190. package/dist/components/workflow-run-tabs/run-tab-count.js +0 -27
  191. package/dist/components/workflow-run-tabs/run-tab-count.js.map +0 -1
  192. package/dist/components/workflow-run-tabs/run-tab-strip.d.ts +0 -8
  193. package/dist/components/workflow-run-tabs/run-tab-strip.d.ts.map +0 -1
  194. package/dist/components/workflow-run-tabs/run-tab-strip.js +0 -82
  195. package/dist/components/workflow-run-tabs/run-tab-strip.js.map +0 -1
  196. package/dist/components/workflow-run-view/agent-config-failure-callout.d.ts.map +0 -1
  197. package/dist/components/workflow-run-view/agent-config-failure-callout.js.map +0 -1
  198. package/dist/components/workflow-run-view/job-card.d.ts +0 -17
  199. package/dist/components/workflow-run-view/job-card.d.ts.map +0 -1
  200. package/dist/components/workflow-run-view/job-card.js +0 -496
  201. package/dist/components/workflow-run-view/job-card.js.map +0 -1
  202. package/dist/components/workflow-run-view/job-execution-switcher.d.ts.map +0 -1
  203. package/dist/components/workflow-run-view/job-execution-switcher.js.map +0 -1
  204. package/dist/components/workflow-run-view/job-execution-time-text.d.ts.map +0 -1
  205. package/dist/components/workflow-run-view/job-execution-time-text.js.map +0 -1
  206. package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts +0 -9
  207. package/dist/components/workflow-run-view/step-attempt-log-panel.d.ts.map +0 -1
  208. package/dist/components/workflow-run-view/step-attempt-log-panel.js.map +0 -1
  209. package/src/components/workflow-run-tabs/run-jobs-list.tsx +0 -151
  210. package/src/components/workflow-run-tabs/run-tab-count.tsx +0 -28
  211. package/src/components/workflow-run-tabs/run-tab-strip.stories.tsx +0 -52
  212. package/src/components/workflow-run-tabs/run-tab-strip.test.tsx +0 -117
  213. package/src/components/workflow-run-tabs/run-tab-strip.tsx +0 -97
  214. package/src/components/workflow-run-view/job-card.stories.tsx +0 -789
  215. package/src/components/workflow-run-view/job-card.test.tsx +0 -115
  216. package/src/components/workflow-run-view/job-card.tsx +0 -614
  217. /package/dist/components/{workflow-run-view → job-detail}/agent-config-failure-callout.d.ts +0 -0
  218. /package/dist/components/{workflow-run-view → job-detail}/job-execution-switcher.d.ts +0 -0
  219. /package/dist/components/{workflow-run-view → job-detail}/job-execution-switcher.js +0 -0
  220. /package/dist/components/{workflow-run-view → job-detail}/job-execution-time-text.d.ts +0 -0
  221. /package/dist/components/{workflow-run-view → job-detail}/job-execution-time-text.js +0 -0
  222. /package/src/components/{workflow-run-view → job-detail}/job-execution-switcher.tsx +0 -0
  223. /package/src/components/{workflow-run-view → job-detail}/job-execution-time-text.tsx +0 -0
@@ -7,17 +7,23 @@ import {
7
7
  import {Button} from '@shipfox/react-ui/button';
8
8
  import {Callout} from '@shipfox/react-ui/callout';
9
9
  import {Text} from '@shipfox/react-ui/typography';
10
- import {type UIEvent, useEffect, useRef} from 'react';
10
+ import {type RefObject, useEffect, useRef} from 'react';
11
+ import {JobExecutionTimeText} from './job-execution-time-text.js';
11
12
 
12
13
  const TAIL_FOLLOW_THRESHOLD_PX = 24;
13
14
  const INITIAL_LOG_ERROR_RETRY_COUNT = 5;
14
15
  const INITIAL_LOG_ERROR_RETRY_DELAY_MS = 1_500;
15
- const logSurfaceClasses = 'max-h-[40vh] rounded-8 md:max-h-[280px]';
16
+ const defaultLogSurfaceClasses = 'rounded-8';
16
17
 
17
18
  export interface StepAttemptLogPanelProps {
18
19
  stepId: string;
19
20
  attempt: number;
20
21
  attemptStatus: string;
22
+ /** The start time used by the page's quiet waiting-for-output state. */
23
+ attemptStartedAt?: string | undefined;
24
+ /** The page scroll column used by the uncapped job-detail log surface. */
25
+ pageScrollRef: RefObject<HTMLElement | null>;
26
+ surfaceClassName?: string | undefined;
21
27
  initialErrorRetryCount?: number | undefined;
22
28
  initialErrorRetryDelayMs?: number | undefined;
23
29
  }
@@ -26,10 +32,12 @@ export function StepAttemptLogPanel({
26
32
  stepId,
27
33
  attempt,
28
34
  attemptStatus,
35
+ attemptStartedAt,
36
+ pageScrollRef,
37
+ surfaceClassName = defaultLogSurfaceClasses,
29
38
  initialErrorRetryCount = INITIAL_LOG_ERROR_RETRY_COUNT,
30
39
  initialErrorRetryDelayMs = INITIAL_LOG_ERROR_RETRY_DELAY_MS,
31
40
  }: StepAttemptLogPanelProps) {
32
- const panelRef = useRef<HTMLDivElement>(null);
33
41
  const shouldFollowTailRef = useRef(true);
34
42
  const missingStreamRetryCount = attemptStatus === 'running' ? undefined : initialErrorRetryCount;
35
43
  const retryMissingStream = attemptStatus === 'running' || isTerminalAttemptStatus(attemptStatus);
@@ -48,13 +56,25 @@ export function StepAttemptLogPanel({
48
56
  const initialError = query.isError && query.data === undefined && !missingActiveStream;
49
57
  const staleError = query.isError && query.data !== undefined;
50
58
 
59
+ useEffect(() => {
60
+ const scrollElement = pageScrollRef.current;
61
+ if (!scrollElement) return undefined;
62
+
63
+ const onScroll = () => {
64
+ shouldFollowTailRef.current = isNearPageBottom(scrollElement);
65
+ };
66
+ scrollElement.addEventListener('scroll', onScroll, {passive: true});
67
+ onScroll();
68
+ return () => scrollElement.removeEventListener('scroll', onScroll);
69
+ }, [pageScrollRef]);
70
+
51
71
  useEffect(() => {
52
72
  if (recordCount === 0) return undefined;
53
73
  if (anchorToFailure) return undefined;
54
74
  if (!shouldFollowTailRef.current) return undefined;
55
75
 
56
76
  const frame = scheduleAnimationFrame(() => {
57
- const scrollElement = panelRef.current?.querySelector<HTMLElement>('[data-slot="log-rows"]');
77
+ const scrollElement = pageScrollRef.current;
58
78
  if (!scrollElement) return;
59
79
  scrollElement.scrollTop = scrollElement.scrollHeight;
60
80
  });
@@ -62,20 +82,17 @@ export function StepAttemptLogPanel({
62
82
  return () => {
63
83
  cancelScheduledFrame(frame);
64
84
  };
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
- }
85
+ }, [anchorToFailure, pageScrollRef, recordCount]);
72
86
 
73
87
  if (query.isPending) {
74
- return <StepLogsLoadingSurface label="Loading logs" />;
88
+ return <StepLogsLoadingSurface label="Loading logs" className={surfaceClassName} />;
75
89
  }
76
90
 
77
91
  if (missingActiveStream) {
78
- return <StepLogsLoadingSurface label="Waiting for logs" />;
92
+ if (attemptStatus === 'running' && attemptStartedAt) {
93
+ return <StepLogsWaitingSurface startedAt={attemptStartedAt} className={surfaceClassName} />;
94
+ }
95
+ return <StepLogsLoadingSurface label="Waiting for logs" className={surfaceClassName} />;
79
96
  }
80
97
 
81
98
  if (initialError) {
@@ -83,7 +100,7 @@ export function StepAttemptLogPanel({
83
100
  }
84
101
 
85
102
  return (
86
- <div ref={panelRef} className="flex min-w-0 flex-col gap-8">
103
+ <div className="flex min-w-0 flex-col gap-8">
87
104
  {staleError ? (
88
105
  <Callout role="alert" type="warning" className="px-10 py-8">
89
106
  <div className="flex min-w-0 flex-1 items-center justify-between gap-8">
@@ -104,17 +121,29 @@ export function StepAttemptLogPanel({
104
121
  records={records}
105
122
  emptyState={query.data?.complete ? 'complete' : 'pending'}
106
123
  anchorToFailure={anchorToFailure}
107
- className={logSurfaceClasses}
108
- onScroll={handleLogScroll}
124
+ className={surfaceClassName}
109
125
  />
110
126
  </div>
111
127
  );
112
128
  }
113
129
 
114
- function StepLogsLoadingSurface({label}: {label: string}) {
130
+ function StepLogsLoadingSurface({label, className}: {label: string; className: string}) {
115
131
  return (
116
132
  <div role="status" aria-label={label}>
117
- <LogViewSkeleton className={logSurfaceClasses} />
133
+ <LogViewSkeleton className={className} />
134
+ </div>
135
+ );
136
+ }
137
+
138
+ function StepLogsWaitingSurface({startedAt, className}: {startedAt: string; className: string}) {
139
+ return (
140
+ <div role="status" aria-label="Waiting for logs" className={className}>
141
+ <Text size="xs" className="px-10 py-12 text-foreground-neutral-muted">
142
+ Waiting for output ·{' '}
143
+ <span className="font-code tabular-nums" aria-hidden="true">
144
+ <JobExecutionTimeText time={{state: 'live', fromIso: startedAt}} />
145
+ </span>
146
+ </Text>
118
147
  </div>
119
148
  );
120
149
  }
@@ -134,6 +163,11 @@ function cancelScheduledFrame(frame: number) {
134
163
  window.clearTimeout(frame);
135
164
  }
136
165
 
166
+ function isNearPageBottom(element: HTMLElement): boolean {
167
+ const distanceFromBottom = element.scrollHeight - element.scrollTop - element.clientHeight;
168
+ return distanceFromBottom <= TAIL_FOLLOW_THRESHOLD_PX;
169
+ }
170
+
137
171
  function isTerminalAttemptStatus(status: string): boolean {
138
172
  return status === 'succeeded' || status === 'failed' || status === 'cancelled';
139
173
  }
@@ -81,7 +81,7 @@ export function JobGraphContent({
81
81
 
82
82
  return (
83
83
  <TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
84
- <div className="min-h-0 overflow-auto bg-background-neutral-background">
84
+ <div className="min-h-0 overflow-auto bg-transparent">
85
85
  <div className="relative" style={{width: contentWidth, minHeight: contentHeight}}>
86
86
  <GraphEdges model={model} hoveredJobId={hoveredJobId} />
87
87
  <div
@@ -108,7 +108,7 @@ describe('buildStepListModel', () => {
108
108
  expect(result.entries.map((entry) => entry.step.label)).toEqual(['build']);
109
109
  });
110
110
 
111
- test('has no active entry when no attempt is running', () => {
111
+ test('does not land on a failed entry when no attempt is running', () => {
112
112
  const step = makeStep({
113
113
  attempts: [
114
114
  makeAttempt({attempt: 1, status: 'failed'}),
@@ -60,7 +60,7 @@ export function buildStepListModel({
60
60
  jobName: job.name ?? job.key,
61
61
  jobExecutionId: selectedJobExecution.id,
62
62
  stepCount: steps.length,
63
- activeEntryId: latestRunningEntryId(entries),
63
+ activeEntryId: landingEntryId(entries),
64
64
  entries,
65
65
  };
66
66
  }
@@ -192,7 +192,7 @@ function compareEntries(left: StepListEntryModel, right: StepListEntryModel): nu
192
192
  );
193
193
  }
194
194
 
195
- function latestRunningEntryId(entries: readonly StepListEntryModel[]): string | undefined {
195
+ function landingEntryId(entries: readonly StepListEntryModel[]): string | undefined {
196
196
  for (let index = entries.length - 1; index >= 0; index -= 1) {
197
197
  const entry = entries[index];
198
198
  if (entry?.statusVisual.kind === 'running') return entry.id;
@@ -18,7 +18,7 @@ import {
18
18
  workflowStepAttemptDto,
19
19
  workflowStepDto,
20
20
  } from '#test/fixtures/workflow-run.js';
21
- import {AgentConfigFailureCallout as AgentConfigFailureCalloutView} from '../workflow-run-view/agent-config-failure-callout.js';
21
+ import {AgentConfigFailureCallout as AgentConfigFailureCalloutView} from '../job-detail/agent-config-failure-callout.js';
22
22
  import {StepList} from './step-list.js';
23
23
 
24
24
  const AGENTS_LINK_NAME = 'Configure Agents';
@@ -280,6 +280,39 @@ describe('StepList', () => {
280
280
  expect(screen.getByText(`logs for ${deployAttempt.id}`)).toBeInTheDocument();
281
281
  });
282
282
 
283
+ test('does not replace the landing selection when a live job reports a new candidate', () => {
284
+ const firstAttempt = makeAttempt({status: 'running'});
285
+ const secondAttempt = makeAttempt({status: 'running'});
286
+ const firstStep = makeStep({name: 'build', status: 'running', attempts: [firstAttempt]});
287
+ const secondStep = makeStep({
288
+ name: 'deploy',
289
+ position: 1,
290
+ status: 'running',
291
+ attempts: [secondAttempt],
292
+ });
293
+ const job = makeJob({steps: [firstStep, secondStep]});
294
+ const {rerender} = render(
295
+ <StepList
296
+ job={job}
297
+ autoSelectActiveAttempt
298
+ defaultSelectedAttemptId={firstAttempt.id}
299
+ renderExpandedStep={({attemptId}) => <Text size="sm">logs for {attemptId}</Text>}
300
+ />,
301
+ );
302
+
303
+ rerender(
304
+ <StepList
305
+ job={job}
306
+ autoSelectActiveAttempt
307
+ defaultSelectedAttemptId={secondAttempt.id}
308
+ renderExpandedStep={({attemptId}) => <Text size="sm">logs for {attemptId}</Text>}
309
+ />,
310
+ );
311
+
312
+ expect(screen.getByText(`logs for ${firstAttempt.id}`)).toBeInTheDocument();
313
+ expect(screen.queryByText(`logs for ${secondAttempt.id}`)).not.toBeInTheDocument();
314
+ });
315
+
283
316
  test('does not auto-open when no attempt is running', () => {
284
317
  render(
285
318
  <StepList
@@ -40,6 +40,7 @@ export interface StepExpandedContext {
40
40
  sourceLocation: StepSourceLocation | null;
41
41
  attempt: number;
42
42
  attemptId: string;
43
+ attemptStartedAt: string;
43
44
  attemptError: Record<string, unknown> | null;
44
45
  attemptStatus: string;
45
46
  carriedOver: boolean;
@@ -119,15 +120,15 @@ function StepListContent({
119
120
  );
120
121
  const [userSelectedAttempt, setUserSelectedAttempt] = useState(false);
121
122
  const lastNotifiedSelectedAttemptId = useRef<string | null>(null);
123
+ const autoSelectedAttemptIdRef = useRef<string | undefined>(undefined);
124
+ const previousSelectedAttemptIdRef = useRef<string | null | undefined>(selectedAttemptId);
122
125
  const shouldUseControlledCollapsedState =
123
126
  selectedAttemptId === null && lastNotifiedSelectedAttemptId.current === null;
124
- const autoSelectedAttemptIds =
125
- selectedAttemptId === undefined &&
126
- autoSelectActiveAttempt &&
127
- !userSelectedAttempt &&
128
- model.activeEntryId
129
- ? [model.activeEntryId]
130
- : [];
127
+ const autoSelectedAttemptId =
128
+ selectedAttemptId === undefined && autoSelectActiveAttempt && !userSelectedAttempt
129
+ ? (autoSelectedAttemptIdRef.current ?? model.activeEntryId)
130
+ : undefined;
131
+ const autoSelectedAttemptIds = autoSelectedAttemptId ? [autoSelectedAttemptId] : [];
131
132
  const selectedAttemptIds = shouldUseControlledCollapsedState
132
133
  ? []
133
134
  : localSelectedAttemptIds.length > 0
@@ -136,12 +137,41 @@ function StepListContent({
136
137
  const hasExpandedContent = renderExpandedStep !== undefined;
137
138
 
138
139
  useEffect(() => {
139
- if (selectedAttemptId !== undefined) return;
140
+ if (selectedAttemptId !== undefined) {
141
+ previousSelectedAttemptIdRef.current = selectedAttemptId;
142
+ return;
143
+ }
144
+
145
+ // A changing landing candidate is normal while a live job polls. Only reset the local
146
+ // accordion when the parent has just cleared a previously controlled URL selection.
147
+ const wasControlled = previousSelectedAttemptIdRef.current !== undefined;
148
+ previousSelectedAttemptIdRef.current = undefined;
149
+ if (!wasControlled) return;
140
150
 
141
151
  setLocalSelectedAttemptIds(defaultSelectedAttemptId ? [defaultSelectedAttemptId] : []);
142
152
  setUserSelectedAttempt(false);
153
+ autoSelectedAttemptIdRef.current = undefined;
143
154
  }, [defaultSelectedAttemptId, selectedAttemptId]);
144
155
 
156
+ useEffect(() => {
157
+ if (
158
+ selectedAttemptId !== undefined ||
159
+ !autoSelectActiveAttempt ||
160
+ userSelectedAttempt ||
161
+ localSelectedAttemptIds.length > 0 ||
162
+ autoSelectedAttemptIdRef.current !== undefined
163
+ ) {
164
+ return;
165
+ }
166
+ if (model.activeEntryId) autoSelectedAttemptIdRef.current = model.activeEntryId;
167
+ }, [
168
+ autoSelectActiveAttempt,
169
+ localSelectedAttemptIds.length,
170
+ model.activeEntryId,
171
+ selectedAttemptId,
172
+ userSelectedAttempt,
173
+ ]);
174
+
145
175
  useEffect(() => {
146
176
  if (selectedAttemptId === undefined) return;
147
177
 
@@ -216,6 +246,7 @@ function StepListContent({
216
246
  sourceLocation: entry.step.sourceLocation,
217
247
  attempt: entry.attempt,
218
248
  attemptId: entry.id,
249
+ attemptStartedAt: entry.startedAt,
219
250
  attemptError: entry.error,
220
251
  attemptStatus: entry.statusVisual.kind,
221
252
  carriedOver: entry.carriedOver,
@@ -454,7 +485,7 @@ function StepAttemptChip({attempt}: {attempt: StepAttemptModel}) {
454
485
  <div className="flex shrink-0 items-center gap-4" aria-hidden="true">
455
486
  <span
456
487
  className={cn(
457
- 'inline-flex h-18 min-w-24 items-center justify-center rounded-4 border px-5 font-code text-xs leading-16 text-foreground-neutral-base',
488
+ 'inline-flex h-18 min-w-24 items-center justify-center rounded-4 border px-6 font-code text-xs leading-16 text-foreground-neutral-base',
458
489
  attemptChipClasses[attempt.statusVisual.badge ?? 'neutral'],
459
490
  )}
460
491
  >
@@ -2,28 +2,50 @@ import {Icon} from '@shipfox/react-ui/icon';
2
2
  import {useTimeTick} from '@shipfox/react-ui/time-ticker';
3
3
  import {Code} from '@shipfox/react-ui/typography';
4
4
  import {cn, humanDuration} from '@shipfox/react-ui/utils';
5
- import type {WorkflowRunAttemptDisplayDuration} from '#core/workflow-run.js';
5
+ import type {
6
+ WorkflowRunAttemptDisplayDuration,
7
+ WorkflowRunDisplayDuration,
8
+ } from '#core/workflow-run.js';
9
+
10
+ /**
11
+ * The run list reads an attempt's own marks and cannot tell queue from run, so an unkinded
12
+ * duration still renders as run time. Only surfaces holding the run's jobs pass a kind.
13
+ */
14
+ export type WorkflowRunDurationDisplay =
15
+ | WorkflowRunAttemptDisplayDuration
16
+ | WorkflowRunDisplayDuration;
17
+
18
+ type DurationKind = 'queue' | 'run';
19
+
20
+ const GLYPH_BY_KIND = {queue: 'hourglassLine', run: 'timerLine'} as const;
21
+ const LIVE_VERB_BY_KIND = {queue: 'queued', run: 'running'} as const;
22
+ const FIXED_VERB_BY_KIND = {queue: 'queued', run: 'ran'} as const;
6
23
 
7
24
  export function WorkflowRunDurationLabel({
8
25
  duration,
9
26
  className,
10
27
  }: {
11
- duration: WorkflowRunAttemptDisplayDuration | null;
28
+ duration: WorkflowRunDurationDisplay | null;
12
29
  className?: string | undefined;
13
30
  }) {
14
31
  if (duration === null) return null;
32
+ const kind = durationKind(duration);
15
33
 
16
34
  switch (duration.state) {
17
35
  case 'fixed': {
18
36
  const display = formatFixedDurationLabel(duration.elapsed);
19
37
  return (
20
- <DurationText className={className} ariaLabel={`ran ${display}`}>
38
+ <DurationText
39
+ className={className}
40
+ kind={kind}
41
+ ariaLabel={`${FIXED_VERB_BY_KIND[kind]} ${display}`}
42
+ >
21
43
  {display}
22
44
  </DurationText>
23
45
  );
24
46
  }
25
47
  case 'live':
26
- return <LiveDurationText duration={duration} className={className} />;
48
+ return <LiveDurationText duration={duration} kind={kind} className={className} />;
27
49
  default: {
28
50
  const exhaustive: never = duration;
29
51
  return exhaustive;
@@ -32,22 +54,23 @@ export function WorkflowRunDurationLabel({
32
54
  }
33
55
 
34
56
  export function useWorkflowRunDurationAccessibleLabel(
35
- duration: WorkflowRunAttemptDisplayDuration | null,
57
+ duration: WorkflowRunDurationDisplay | null,
36
58
  ): string | undefined {
37
59
  useTimeTick();
38
60
  return workflowRunDurationAccessibleLabel(duration);
39
61
  }
40
62
 
41
63
  export function workflowRunDurationAccessibleLabel(
42
- duration: WorkflowRunAttemptDisplayDuration | null,
64
+ duration: WorkflowRunDurationDisplay | null,
43
65
  ): string | undefined {
44
66
  if (duration === null) return undefined;
67
+ const kind = durationKind(duration);
45
68
 
46
69
  switch (duration.state) {
47
70
  case 'live':
48
- return `running ${humanDuration(duration.fromIso)}`;
71
+ return `${LIVE_VERB_BY_KIND[kind]} ${humanDuration(duration.fromIso)}`;
49
72
  case 'fixed':
50
- return `ran ${formatFixedDurationLabel(duration.elapsed)}`;
73
+ return `${FIXED_VERB_BY_KIND[kind]} ${formatFixedDurationLabel(duration.elapsed)}`;
51
74
  default: {
52
75
  const exhaustive: never = duration;
53
76
  return exhaustive;
@@ -55,17 +78,27 @@ export function workflowRunDurationAccessibleLabel(
55
78
  }
56
79
  }
57
80
 
81
+ function durationKind(duration: WorkflowRunDurationDisplay): DurationKind {
82
+ return 'kind' in duration ? duration.kind : 'run';
83
+ }
84
+
58
85
  function LiveDurationText({
59
86
  duration,
87
+ kind,
60
88
  className,
61
89
  }: {
62
- duration: Extract<WorkflowRunAttemptDisplayDuration, {state: 'live'}>;
90
+ duration: Extract<WorkflowRunDurationDisplay, {state: 'live'}>;
91
+ kind: DurationKind;
63
92
  className?: string | undefined;
64
93
  }) {
65
94
  useTimeTick();
66
95
  const display = humanDuration(duration.fromIso);
67
96
  return (
68
- <DurationText className={className} ariaLabel={`running ${display}`}>
97
+ <DurationText
98
+ className={className}
99
+ kind={kind}
100
+ ariaLabel={`${LIVE_VERB_BY_KIND[kind]} ${display}`}
101
+ >
69
102
  {display}
70
103
  </DurationText>
71
104
  );
@@ -74,10 +107,12 @@ function LiveDurationText({
74
107
  function DurationText({
75
108
  children,
76
109
  className,
110
+ kind,
77
111
  ariaLabel,
78
112
  }: {
79
113
  children: string;
80
114
  className?: string | undefined;
115
+ kind: DurationKind;
81
116
  ariaLabel?: string | undefined;
82
117
  }) {
83
118
  return (
@@ -90,7 +125,10 @@ function DurationText({
90
125
  className,
91
126
  )}
92
127
  >
93
- <Icon name="timerLine" className="size-12 shrink-0" aria-hidden="true" />
128
+ {/* The glyph is the only visible mark of queue vs run: the status badge already writes
129
+ "Queued", and the numeric columns are fixed width. The verb rides the accessible
130
+ name instead. */}
131
+ <Icon name={GLYPH_BY_KIND[kind]} className="size-12 shrink-0" aria-hidden="true" />
94
132
  {children}
95
133
  </Code>
96
134
  );
@@ -104,7 +142,7 @@ function formatFixedDurationLabel({
104
142
  hours = 0,
105
143
  minutes = 0,
106
144
  seconds = 0,
107
- }: Extract<WorkflowRunAttemptDisplayDuration, {state: 'fixed'}>['elapsed']): string {
145
+ }: Extract<WorkflowRunDurationDisplay, {state: 'fixed'}>['elapsed']): string {
108
146
  const totalDays = years * 365 + months * 30 + weeks * 7 + days;
109
147
  const totalHours = totalDays * 24 + hours;
110
148
 
@@ -20,6 +20,7 @@ import {
20
20
  workflowRunActor,
21
21
  workflowRunBranchLabel,
22
22
  workflowRunCommitLabel,
23
+ workflowRunListItemDisplay,
23
24
  } from '#core/workflow-run.js';
24
25
  import {withoutWorkflowRunSelectionSearch} from '#core/workflow-run-url-state.js';
25
26
  import {JobStatusStrip, jobStatusSummary} from './job-status-strip.js';
@@ -68,8 +69,9 @@ export function WorkflowRunRow({
68
69
  workspaceSlug?: string | undefined;
69
70
  projectSlug?: string | undefined;
70
71
  }) {
71
- const durationLabel = useWorkflowRunDurationAccessibleLabel(run.runAttempt.displayDuration);
72
- const statusLabel = getWorkflowStatusVisual(run.status).label;
72
+ const display = workflowRunListItemDisplay(run);
73
+ const durationLabel = useWorkflowRunDurationAccessibleLabel(display.duration);
74
+ const statusLabel = getWorkflowStatusVisual(display.status).label;
73
75
  const runNumberLabel = formatWorkflowRunNumberLabel(run);
74
76
  const branch = workflowRunBranchLabel(run);
75
77
  const commit = workflowRunCommitLabel(run);
@@ -77,7 +79,7 @@ export function WorkflowRunRow({
77
79
 
78
80
  const body = (
79
81
  <>
80
- <WorkflowStatusIcon status={run.status} size={14} className="shrink-0" />
82
+ <WorkflowStatusIcon status={display.status} size={14} className="shrink-0" />
81
83
 
82
84
  <div className="flex min-w-0 flex-1 flex-col gap-2 @min-[976px]:flex-row @min-[976px]:items-center @min-[976px]:gap-12">
83
85
  <span className="flex min-w-0 items-center gap-8 @min-[976px]:flex-1">
@@ -103,7 +105,7 @@ export function WorkflowRunRow({
103
105
  <JobStatusStrip jobs={run.jobs} />
104
106
  </span>
105
107
  <span className="flex w-64 justify-end">
106
- <WorkflowRunDurationLabel duration={run.runAttempt.displayDuration} />
108
+ <WorkflowRunDurationLabel duration={display.duration} />
107
109
  </span>
108
110
  <Code
109
111
  variant="label"
@@ -18,6 +18,7 @@ import {WorkflowRunAttempt, type WorkflowRunStatus} from '#core/workflow-run.js'
18
18
  import {
19
19
  runAttemptsResponseDto,
20
20
  workflowJobDto,
21
+ workflowJobExecutionDto,
21
22
  workflowRunAttemptDto,
22
23
  workflowRunDetail,
23
24
  } from '#test/fixtures/workflow-run.js';
@@ -202,6 +203,19 @@ export const Durations: Story = {
202
203
  ),
203
204
  };
204
205
 
206
+ /**
207
+ * A live attempt whose jobs have not started. The header reports the wait rather than a run
208
+ * that never began, and names the job the run is blocked on once there is more than one.
209
+ */
210
+ export const Queued: Story = {
211
+ render: () => (
212
+ <div className="flex flex-col">
213
+ <WorkflowRunSummary run={queuedRun('release-queued', ['build'])} />
214
+ <WorkflowRunSummary run={queuedRun('release-queued-fan-out', ['build', 'lint', 'test'])} />
215
+ </div>
216
+ ),
217
+ };
218
+
205
219
  export const Cancellable: Story = {
206
220
  args: {
207
221
  run: workflowRunDetail({status: 'running'}),
@@ -209,6 +223,30 @@ export const Cancellable: Story = {
209
223
  },
210
224
  };
211
225
 
226
+ function queuedRun(name: string, jobNames: string[]) {
227
+ return workflowRunDetail({
228
+ status: 'running',
229
+ name,
230
+ run_attempt: workflowRunAttemptDto({
231
+ status: 'running',
232
+ created_at: RUN_STARTED_AT,
233
+ started_at: RUN_STARTED_AT,
234
+ finished_at: null,
235
+ }),
236
+ jobs: jobNames.map((jobName, index) =>
237
+ workflowJobDto({
238
+ name: jobName,
239
+ status: 'pending',
240
+ job_executions: [
241
+ workflowJobExecutionDto({
242
+ queued_at: new Date(Date.parse(RUN_STARTED_AT) + index * 1000).toISOString(),
243
+ }),
244
+ ],
245
+ }),
246
+ ),
247
+ });
248
+ }
249
+
212
250
  export const Cancelling: Story = {
213
251
  args: {
214
252
  run: workflowRunDetail({status: 'running'}),