@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
@@ -0,0 +1,450 @@
1
+ // biome-ignore-all lint/a11y/noRedundantRoles: the job log region keeps an explicit role for the public accessibility contract.
2
+ // biome-ignore-all lint/a11y/noNoninteractiveTabindex: the job log region is intentionally keyboard focusable.
3
+
4
+ import {ApiError} from '@shipfox/client-api';
5
+ import {QueryLoadError} from '@shipfox/client-ui';
6
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
7
+ import {Icon} from '@shipfox/react-ui/icon';
8
+ import {Skeleton} from '@shipfox/react-ui/skeleton';
9
+ import {TimeTickerProvider} from '@shipfox/react-ui/time-ticker';
10
+ import {Text} from '@shipfox/react-ui/typography';
11
+ import {Link} from '@tanstack/react-router';
12
+ import {type RefObject, useEffect, useRef} from 'react';
13
+ import type {Job, JobExecution} from '#core/workflow-run.js';
14
+ import type {useWorkflowRunAttemptQuery} from '#hooks/api/workflow-runs.js';
15
+ import {
16
+ type WorkflowJobSearch,
17
+ workflowJobSearchParams,
18
+ workflowRunSearchParams,
19
+ } from '#routes/inputs.js';
20
+ import {type StepExpandedContext, StepList} from '../step-list/index.js';
21
+ import {
22
+ WorkflowRunNotFound,
23
+ WorkflowRunStaleError,
24
+ } from '../workflow-run-view/workflow-run-states.js';
25
+ import {AgentConfigFailureCallout} from './agent-config-failure-callout.js';
26
+ import {JobDetailHeader} from './job-detail-header.js';
27
+ import {
28
+ CarriedOverStepPanel,
29
+ emptyStateForJob,
30
+ emptyStateForMissingExecution,
31
+ isAgentConfigFailure,
32
+ jobSucceededSummary,
33
+ toSelectedAttemptError,
34
+ } from './job-empty-states.js';
35
+ import {
36
+ resolveWorkflowJobSelection,
37
+ type WorkflowJobLandingSelection,
38
+ workflowJobLandingSelection,
39
+ } from './job-selection.js';
40
+ import {StepAttemptLogPanel} from './step-attempt-log-panel.js';
41
+
42
+ export interface JobDetailViewProps {
43
+ workspaceSlug: string;
44
+ projectSlug: string;
45
+ workflowRunId: string;
46
+ jobId: string;
47
+ search: WorkflowJobSearch;
48
+ query: ReturnType<typeof useWorkflowRunAttemptQuery>;
49
+ newerAttempt?: number | undefined;
50
+ newerJob?: Job | undefined;
51
+ onSelectionChange: (selection: WorkflowJobSearch) => void;
52
+ }
53
+
54
+ export function JobDetailView({
55
+ workspaceSlug,
56
+ projectSlug,
57
+ workflowRunId,
58
+ jobId,
59
+ search,
60
+ query,
61
+ newerAttempt,
62
+ newerJob,
63
+ onSelectionChange,
64
+ }: JobDetailViewProps) {
65
+ const rootRef = useRef<HTMLDivElement>(null);
66
+ const pageScrollRef = useRef<HTMLDivElement>(null);
67
+ const landingSelectionRef = useRef<FrozenLandingSelection | undefined>(undefined);
68
+ const hasLoadedData = query.data !== undefined;
69
+
70
+ useEffect(() => {
71
+ if (!jobId || !hasLoadedData) return;
72
+ const heading = rootRef.current?.querySelector<HTMLElement>('[data-job-heading]');
73
+ // The heading is intentionally the route-change focus target: it announces the new job
74
+ // after rail navigation without putting focus on a decorative status element.
75
+ heading?.focus({preventScroll: true});
76
+ }, [hasLoadedData, jobId]);
77
+
78
+ if (query.isPending) return <JobDetailSkeleton />;
79
+
80
+ if (query.isError && query.data === undefined) {
81
+ if (query.error instanceof ApiError && query.error.status === 404) {
82
+ return <WorkflowRunNotFound />;
83
+ }
84
+ return <QueryLoadError query={query} subject="workflow run" icon="pulseLine" />;
85
+ }
86
+
87
+ if (query.data === undefined) return <JobDetailSkeleton />;
88
+
89
+ const run = query.data;
90
+ const job = run.jobs.find((candidate) => candidate.id === jobId);
91
+ if (!job) {
92
+ return (
93
+ <JobNotFoundState
94
+ workspaceSlug={workspaceSlug}
95
+ projectSlug={projectSlug}
96
+ workflowRunId={workflowRunId}
97
+ search={search}
98
+ />
99
+ );
100
+ }
101
+
102
+ const resolvedSelection = resolveWorkflowJobSelection({job, selection: search});
103
+ const selectedJobExecution = resolvedSelection.jobExecution;
104
+ const hasExplicitStep = Boolean(search.stepId && resolvedSelection.step);
105
+ const currentLandingSelection = workflowJobLandingSelection(selectedJobExecution);
106
+ if (selectedJobExecution) {
107
+ const frozenLanding = landingSelectionRef.current;
108
+ if (
109
+ !frozenLanding ||
110
+ frozenLanding.jobId !== job.id ||
111
+ frozenLanding.jobExecutionId !== selectedJobExecution.id
112
+ ) {
113
+ landingSelectionRef.current = {
114
+ jobId: job.id,
115
+ jobExecutionId: selectedJobExecution.id,
116
+ selection: currentLandingSelection,
117
+ hasSelection: currentLandingSelection !== undefined,
118
+ };
119
+ } else if (!frozenLanding.hasSelection && currentLandingSelection) {
120
+ landingSelectionRef.current = {
121
+ ...frozenLanding,
122
+ selection: currentLandingSelection,
123
+ hasSelection: true,
124
+ };
125
+ }
126
+ } else {
127
+ landingSelectionRef.current = undefined;
128
+ }
129
+ const landingSelection = landingSelectionRef.current?.selection;
130
+ const selectedAttemptId = hasExplicitStep ? resolvedSelection.selectedAttemptId : undefined;
131
+ const runningSelection = runningStepSelection(selectedJobExecution);
132
+ const selectedStepId = resolvedSelection.step?.id ?? landingSelection?.stepId;
133
+ const selectedAttemptForNotice = hasExplicitStep
134
+ ? resolvedSelection.selectedAttemptId
135
+ : landingSelection?.attemptId;
136
+ const showRetargetNotice =
137
+ runningSelection !== undefined &&
138
+ (selectedStepId !== runningSelection.stepId ||
139
+ selectedAttemptForNotice !== runningSelection.attemptId);
140
+ const succeededSummary =
141
+ !hasExplicitStep && landingSelection === undefined && selectedJobExecution
142
+ ? jobSucceededSummary(job, selectedJobExecution)
143
+ : undefined;
144
+
145
+ function selectExecution(jobExecutionId: string) {
146
+ onSelectionChange({
147
+ ...search,
148
+ jobExecutionId,
149
+ stepId: undefined,
150
+ stepAttemptId: undefined,
151
+ });
152
+ }
153
+
154
+ function selectAttempt(attemptId: string | undefined) {
155
+ if (!selectedJobExecution) return;
156
+ if (!attemptId) {
157
+ onSelectionChange({
158
+ ...search,
159
+ jobExecutionId: selectedJobExecution.id,
160
+ stepId: undefined,
161
+ stepAttemptId: undefined,
162
+ });
163
+ return;
164
+ }
165
+
166
+ const match = findAttempt(selectedJobExecution, attemptId);
167
+ if (!match) return;
168
+ onSelectionChange({
169
+ ...search,
170
+ jobExecutionId: selectedJobExecution.id,
171
+ stepId: match.step.id,
172
+ stepAttemptId: match.attemptId,
173
+ });
174
+ }
175
+
176
+ function retargetToRunningStep() {
177
+ if (!runningSelection || !selectedJobExecution) return;
178
+ onSelectionChange({
179
+ ...search,
180
+ jobExecutionId: selectedJobExecution.id,
181
+ stepId: runningSelection.stepId,
182
+ stepAttemptId: runningSelection.attemptId,
183
+ });
184
+ }
185
+
186
+ return (
187
+ <TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
188
+ <div ref={rootRef} className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
189
+ {query.isError ? <WorkflowRunStaleError query={query} /> : null}
190
+ {newerJob && newerAttempt && newerAttempt > run.runAttempt.attempt ? (
191
+ <NewerAttemptNotice
192
+ workspaceSlug={workspaceSlug}
193
+ projectSlug={projectSlug}
194
+ runId={run.id}
195
+ jobId={newerJob.id}
196
+ attempt={newerAttempt}
197
+ />
198
+ ) : null}
199
+ <div ref={pageScrollRef} className="@container min-h-0 flex-1 overflow-auto pb-24">
200
+ <section
201
+ aria-label={`${job.displayName} logs`}
202
+ className="mx-auto flex w-full max-w-[1120px] flex-col gap-8 px-24"
203
+ >
204
+ <section className="min-w-0 overflow-hidden">
205
+ <JobDetailHeader
206
+ job={job}
207
+ selectedJobExecution={selectedJobExecution}
208
+ onSelectedJobExecutionChange={selectExecution}
209
+ />
210
+ <Text as="h2" className="sr-only">
211
+ Logs
212
+ </Text>
213
+ {selectedJobExecution ? (
214
+ <StepList
215
+ job={job}
216
+ jobExecution={selectedJobExecution}
217
+ selectedAttemptId={selectedAttemptId}
218
+ defaultSelectedAttemptId={landingSelection?.attemptId}
219
+ onSelectedAttemptChange={selectAttempt}
220
+ autoSelectActiveAttempt
221
+ emptyState={emptyStateForJob(job, selectedJobExecution)}
222
+ showHeader={false}
223
+ className="rounded-none border-0"
224
+ renderExpandedStep={(context) => (
225
+ <ExpandedStep
226
+ context={context}
227
+ workspaceSlug={workspaceSlug}
228
+ pageScrollRef={pageScrollRef}
229
+ />
230
+ )}
231
+ />
232
+ ) : (
233
+ <EmptyStateForMissingExecution job={job} />
234
+ )}
235
+ </section>
236
+ {showRetargetNotice && runningSelection ? (
237
+ <div
238
+ role="status"
239
+ aria-live="polite"
240
+ className="flex min-w-0 items-center justify-between gap-8 rounded-8 border border-border-neutral-base bg-background-components-base px-10 py-8"
241
+ >
242
+ <Text size="xs" className="min-w-0 text-foreground-neutral-muted">
243
+ Run moved on to{' '}
244
+ <span className="font-code text-foreground-neutral-base">
245
+ {runningSelection.stepLabel}
246
+ </span>
247
+ .
248
+ </Text>
249
+ <button
250
+ type="button"
251
+ className="shrink-0 rounded-4 px-6 py-4 text-xs font-medium text-foreground-highlight-interactive outline-none focus-visible:shadow-border-interactive-with-active"
252
+ onClick={retargetToRunningStep}
253
+ >
254
+ Jump to it
255
+ </button>
256
+ </div>
257
+ ) : null}
258
+ {succeededSummary ? (
259
+ <Text size="xs" className="px-8 text-foreground-neutral-muted">
260
+ {succeededSummary}
261
+ </Text>
262
+ ) : null}
263
+ </section>
264
+ </div>
265
+ </div>
266
+ </TimeTickerProvider>
267
+ );
268
+ }
269
+
270
+ function ExpandedStep({
271
+ context,
272
+ workspaceSlug,
273
+ pageScrollRef,
274
+ }: {
275
+ context: StepExpandedContext;
276
+ workspaceSlug: string;
277
+ pageScrollRef: RefObject<HTMLDivElement | null>;
278
+ }) {
279
+ if (context.carriedOver) return <CarriedOverStepPanel />;
280
+
281
+ const selectedAttemptError =
282
+ toSelectedAttemptError(context.step, context.attemptError) ?? context.step.error;
283
+
284
+ return (
285
+ <section
286
+ role="region"
287
+ tabIndex={0}
288
+ aria-label={`${context.stepLabel} output, attempt ${context.attempt}`}
289
+ className="flex min-w-0 flex-col gap-10 border-t border-border-neutral-base bg-background-neutral-subtle px-16 py-12 outline-none focus-visible:shadow-border-interactive-with-active"
290
+ >
291
+ {isAgentConfigFailure(context.step, selectedAttemptError) ? (
292
+ <AgentConfigFailureCallout
293
+ workspaceSlug={workspaceSlug}
294
+ config={context.step.agentConfig}
295
+ error={selectedAttemptError}
296
+ />
297
+ ) : null}
298
+ <StepAttemptLogPanel
299
+ stepId={context.stepId}
300
+ attempt={context.attempt}
301
+ attemptStatus={context.attemptStatus}
302
+ attemptStartedAt={context.attemptStartedAt}
303
+ pageScrollRef={pageScrollRef}
304
+ surfaceClassName="rounded-8"
305
+ />
306
+ </section>
307
+ );
308
+ }
309
+
310
+ function EmptyStateForMissingExecution({job}: {job: Job}) {
311
+ const emptyState = emptyStateForMissingExecution(job);
312
+ return (
313
+ <EmptyState
314
+ className="min-h-120 px-16 py-20"
315
+ icon="componentLine"
316
+ title={emptyState.title}
317
+ description={emptyState.description}
318
+ variant="compact"
319
+ />
320
+ );
321
+ }
322
+
323
+ function JobNotFoundState({
324
+ workspaceSlug,
325
+ projectSlug,
326
+ workflowRunId,
327
+ search,
328
+ }: {
329
+ workspaceSlug: string;
330
+ projectSlug: string;
331
+ workflowRunId: string;
332
+ search: WorkflowJobSearch;
333
+ }) {
334
+ return (
335
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col items-center justify-center gap-12 p-24">
336
+ <EmptyState
337
+ icon="componentLine"
338
+ title="Job not found"
339
+ description="This job is not part of the selected workflow run."
340
+ />
341
+ <Link
342
+ to="/w/$workspaceSlug/p/$projectSlug/runs/$workflowRunId"
343
+ params={{workspaceSlug, projectSlug, workflowRunId}}
344
+ search={
345
+ workflowRunSearchParams(
346
+ {runAttempt: search.runAttempt},
347
+ {runAttempt: search.runAttempt},
348
+ ) as never
349
+ }
350
+ className="inline-flex items-center gap-6 rounded-6 px-8 py-6 text-sm text-foreground-highlight-interactive outline-none focus-visible:shadow-border-interactive-with-active"
351
+ >
352
+ <Icon name="arrowLeftLine" size={14} aria-hidden="true" />
353
+ Back to run summary
354
+ </Link>
355
+ </div>
356
+ );
357
+ }
358
+
359
+ function NewerAttemptNotice({
360
+ workspaceSlug,
361
+ projectSlug,
362
+ runId,
363
+ jobId,
364
+ attempt,
365
+ }: {
366
+ workspaceSlug: string;
367
+ projectSlug: string;
368
+ runId: string;
369
+ jobId: string;
370
+ attempt: number;
371
+ }) {
372
+ return (
373
+ <div role="status" className="border-b border-border-neutral-base px-16 py-8">
374
+ <div className="mx-auto flex max-w-[1120px] items-center justify-between gap-8 rounded-8 border border-border-neutral-base bg-background-components-base px-10 py-8">
375
+ <Text size="xs" className="text-foreground-neutral-muted">
376
+ A newer run attempt is available.
377
+ </Text>
378
+ <Link
379
+ to="/w/$workspaceSlug/p/$projectSlug/runs/$workflowRunId/jobs/$jobId"
380
+ params={{workspaceSlug, projectSlug, workflowRunId: runId, jobId}}
381
+ search={workflowJobSearchParams({runAttempt: attempt}) as never}
382
+ className="shrink-0 rounded-4 px-6 py-4 text-xs font-medium text-foreground-highlight-interactive outline-none focus-visible:shadow-border-interactive-with-active"
383
+ >
384
+ View attempt #{attempt}
385
+ </Link>
386
+ </div>
387
+ </div>
388
+ );
389
+ }
390
+
391
+ function runningStepSelection(jobExecution: JobExecution | undefined) {
392
+ if (!jobExecution) return undefined;
393
+ const steps = [...jobExecution.steps].sort(
394
+ (left, right) => left.position - right.position || left.id.localeCompare(right.id),
395
+ );
396
+ for (let stepIndex = steps.length - 1; stepIndex >= 0; stepIndex -= 1) {
397
+ const step = steps[stepIndex];
398
+ if (!step) continue;
399
+ const attempt = [...step.attempts]
400
+ .sort((left, right) => left.attempt - right.attempt || left.id.localeCompare(right.id))
401
+ .reverse()
402
+ .find((candidate) => candidate.status === 'running');
403
+ if (attempt)
404
+ return {stepId: step.id, attemptId: attempt.id, stepLabel: step.name || step.key || step.id};
405
+ }
406
+ return undefined;
407
+ }
408
+
409
+ function findAttempt(jobExecution: JobExecution, attemptId: string) {
410
+ for (const step of jobExecution.steps) {
411
+ const attempt = step.attempts.find((candidate) => candidate.id === attemptId);
412
+ if (attempt) return {step, attemptId: attempt.id};
413
+ }
414
+ return undefined;
415
+ }
416
+
417
+ function JobDetailSkeleton() {
418
+ return (
419
+ <div className="min-h-0 min-w-0 flex-1 overflow-auto px-24 pb-24">
420
+ <div className="mx-auto max-w-[1120px] overflow-hidden">
421
+ <header className="flex items-center gap-12 border-b border-border-neutral-base px-16 py-12">
422
+ <Skeleton className="size-20 rounded-full" />
423
+ <Skeleton className="h-20 w-160 rounded-4" />
424
+ <Skeleton className="h-24 w-72 rounded-6" />
425
+ <Skeleton className="h-24 w-180 rounded-4" />
426
+ </header>
427
+ <div className="p-16">
428
+ {JOB_DETAIL_SKELETON_ROWS.map((row) => (
429
+ <div
430
+ key={row}
431
+ className="flex min-h-44 items-center gap-8 border-b border-border-neutral-base last:border-b-0"
432
+ >
433
+ <Skeleton className="size-14 rounded-full" />
434
+ <Skeleton className="h-16 w-180 rounded-4" />
435
+ </div>
436
+ ))}
437
+ </div>
438
+ </div>
439
+ </div>
440
+ );
441
+ }
442
+
443
+ const JOB_DETAIL_SKELETON_ROWS = ['step-1', 'step-2', 'step-3', 'step-4', 'step-5'];
444
+
445
+ interface FrozenLandingSelection {
446
+ jobId: string;
447
+ jobExecutionId: string;
448
+ selection: WorkflowJobLandingSelection | undefined;
449
+ hasSelection: boolean;
450
+ }
@@ -0,0 +1,27 @@
1
+ import {
2
+ workflowJob,
3
+ workflowJobExecutionDto,
4
+ workflowStepDto,
5
+ } from '#test/fixtures/workflow-run.js';
6
+ import {jobSucceededSummary} from './job-empty-states.js';
7
+
8
+ describe('jobSucceededSummary', () => {
9
+ test('counts only succeeded steps when skipped steps are present', () => {
10
+ const job = workflowJob({
11
+ status: 'succeeded',
12
+ job_executions: [
13
+ workflowJobExecutionDto({
14
+ status: 'succeeded',
15
+ steps: [
16
+ workflowStepDto({status: 'succeeded'}),
17
+ workflowStepDto({status: 'skipped', position: 1}),
18
+ ],
19
+ }),
20
+ ],
21
+ });
22
+ const execution = job.jobExecutions[0];
23
+ if (!execution) throw new Error('Expected a job execution');
24
+
25
+ expect(jobSucceededSummary(job, execution)).toBe('1 step succeeded');
26
+ });
27
+ });