@shipfox/client-workflows 16.0.0 → 17.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 (180) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/.turbo/turbo-type.log +1 -0
  3. package/CHANGELOG.md +20 -0
  4. package/dist/components/identifier/identifier.js +2 -2
  5. package/dist/components/identifier/identifier.js.map +1 -1
  6. package/dist/components/job-detail/agent-config-failure-callout.d.ts.map +1 -1
  7. package/dist/components/job-detail/agent-config-failure-callout.js +1 -1
  8. package/dist/components/job-detail/agent-config-failure-callout.js.map +1 -1
  9. package/dist/components/job-detail/job-context-panel.d.ts +6 -0
  10. package/dist/components/job-detail/job-context-panel.d.ts.map +1 -0
  11. package/dist/components/job-detail/job-context-panel.js +224 -0
  12. package/dist/components/job-detail/job-context-panel.js.map +1 -0
  13. package/dist/components/job-detail/job-detail-header.d.ts +3 -1
  14. package/dist/components/job-detail/job-detail-header.d.ts.map +1 -1
  15. package/dist/components/job-detail/job-detail-header.js +54 -48
  16. package/dist/components/job-detail/job-detail-header.js.map +1 -1
  17. package/dist/components/job-detail/job-detail-view.d.ts +1 -1
  18. package/dist/components/job-detail/job-detail-view.d.ts.map +1 -1
  19. package/dist/components/job-detail/job-detail-view.js +80 -44
  20. package/dist/components/job-detail/job-detail-view.js.map +1 -1
  21. package/dist/components/job-detail/job-empty-states.d.ts.map +1 -1
  22. package/dist/components/job-detail/job-empty-states.js +19 -10
  23. package/dist/components/job-detail/job-empty-states.js.map +1 -1
  24. package/dist/components/job-detail/job-execution-switcher.js +4 -4
  25. package/dist/components/job-detail/job-execution-switcher.js.map +1 -1
  26. package/dist/components/job-detail/json-code.d.ts +14 -0
  27. package/dist/components/job-detail/json-code.d.ts.map +1 -0
  28. package/dist/components/job-detail/json-code.js +93 -0
  29. package/dist/components/job-detail/json-code.js.map +1 -0
  30. package/dist/components/job-detail/step-attempt-log-panel.d.ts.map +1 -1
  31. package/dist/components/job-detail/step-attempt-log-panel.js +9 -6
  32. package/dist/components/job-detail/step-attempt-log-panel.js.map +1 -1
  33. package/dist/components/job-detail/step-troubleshooting.d.ts +18 -0
  34. package/dist/components/job-detail/step-troubleshooting.d.ts.map +1 -0
  35. package/dist/components/job-detail/step-troubleshooting.js +493 -0
  36. package/dist/components/job-detail/step-troubleshooting.js.map +1 -0
  37. package/dist/components/job-graph/job-graph-content.d.ts.map +1 -1
  38. package/dist/components/job-graph/job-graph-content.js +5 -4
  39. package/dist/components/job-graph/job-graph-content.js.map +1 -1
  40. package/dist/components/job-graph/job-node.js +3 -3
  41. package/dist/components/job-graph/job-node.js.map +1 -1
  42. package/dist/components/step-list/index.d.ts +1 -0
  43. package/dist/components/step-list/index.d.ts.map +1 -1
  44. package/dist/components/step-list/index.js.map +1 -1
  45. package/dist/components/step-list/step-list-model.d.ts.map +1 -1
  46. package/dist/components/step-list/step-list-model.js +5 -0
  47. package/dist/components/step-list/step-list-model.js.map +1 -1
  48. package/dist/components/step-list/step-list.d.ts +5 -1
  49. package/dist/components/step-list/step-list.d.ts.map +1 -1
  50. package/dist/components/step-list/step-list.js +117 -82
  51. package/dist/components/step-list/step-list.js.map +1 -1
  52. package/dist/components/workflow-run-duration-label.js +1 -1
  53. package/dist/components/workflow-run-duration-label.js.map +1 -1
  54. package/dist/components/workflow-run-list/job-status-strip.js +6 -6
  55. package/dist/components/workflow-run-list/job-status-strip.js.map +1 -1
  56. package/dist/components/workflow-run-list/workflow-run-filter-menu.js +1 -1
  57. package/dist/components/workflow-run-list/workflow-run-filter-menu.js.map +1 -1
  58. package/dist/components/workflow-run-list/workflow-run-filters.js +4 -4
  59. package/dist/components/workflow-run-list/workflow-run-filters.js.map +1 -1
  60. package/dist/components/workflow-run-list/workflow-run-list-states.js +6 -6
  61. package/dist/components/workflow-run-list/workflow-run-list-states.js.map +1 -1
  62. package/dist/components/workflow-run-list/workflow-run-list-view.js +1 -1
  63. package/dist/components/workflow-run-list/workflow-run-list-view.js.map +1 -1
  64. package/dist/components/workflow-run-list/workflow-run-row.js +7 -7
  65. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  66. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js +1 -1
  67. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js.map +1 -1
  68. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts +0 -1
  69. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  70. package/dist/components/workflow-run-summary/workflow-run-summary.js +8 -13
  71. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  72. package/dist/components/workflow-run-tabs/index.d.ts +1 -1
  73. package/dist/components/workflow-run-tabs/index.d.ts.map +1 -1
  74. package/dist/components/workflow-run-tabs/index.js.map +1 -1
  75. package/dist/components/workflow-run-tabs/run-annotation-count-chip.js +2 -2
  76. package/dist/components/workflow-run-tabs/run-annotation-count-chip.js.map +1 -1
  77. package/dist/components/workflow-run-tabs/run-annotation-list.d.ts +9 -2
  78. package/dist/components/workflow-run-tabs/run-annotation-list.d.ts.map +1 -1
  79. package/dist/components/workflow-run-tabs/run-annotation-list.js +28 -19
  80. package/dist/components/workflow-run-tabs/run-annotation-list.js.map +1 -1
  81. package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts.map +1 -1
  82. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js +5 -5
  83. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js.map +1 -1
  84. package/dist/components/workflow-run-view/run-workspace-nav.js +7 -7
  85. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -1
  86. package/dist/components/workflow-run-view/workflow-run-states.js +6 -6
  87. package/dist/components/workflow-run-view/workflow-run-states.js.map +1 -1
  88. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  89. package/dist/components/workflow-run-view/workflow-run-view.js +72 -12
  90. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  91. package/dist/core/entities/job-execution.d.ts +7 -0
  92. package/dist/core/entities/job-execution.d.ts.map +1 -1
  93. package/dist/core/entities/job-execution.js.map +1 -1
  94. package/dist/core/entities/job.d.ts +9 -0
  95. package/dist/core/entities/job.d.ts.map +1 -1
  96. package/dist/core/entities/job.js.map +1 -1
  97. package/dist/core/entities/step-attempt.d.ts +29 -0
  98. package/dist/core/entities/step-attempt.d.ts.map +1 -1
  99. package/dist/core/entities/step-attempt.js.map +1 -1
  100. package/dist/core/entities/step.d.ts +3 -1
  101. package/dist/core/entities/step.d.ts.map +1 -1
  102. package/dist/core/entities/step.js.map +1 -1
  103. package/dist/core/run-annotation.d.ts +6 -0
  104. package/dist/core/run-annotation.d.ts.map +1 -1
  105. package/dist/core/run-annotation.js.map +1 -1
  106. package/dist/core/workflow-run.d.ts +1 -1
  107. package/dist/core/workflow-run.d.ts.map +1 -1
  108. package/dist/core/workflow-run.js.map +1 -1
  109. package/dist/hooks/api/annotations.d.ts +63 -0
  110. package/dist/hooks/api/annotations.d.ts.map +1 -0
  111. package/dist/hooks/api/annotations.js +145 -0
  112. package/dist/hooks/api/annotations.js.map +1 -0
  113. package/dist/hooks/api/step-attempt-detail.d.ts +14 -0
  114. package/dist/hooks/api/step-attempt-detail.d.ts.map +1 -0
  115. package/dist/hooks/api/step-attempt-detail.js +43 -0
  116. package/dist/hooks/api/step-attempt-detail.js.map +1 -0
  117. package/dist/hooks/api/workflow-run-mapper.d.ts +9 -2
  118. package/dist/hooks/api/workflow-run-mapper.d.ts.map +1 -1
  119. package/dist/hooks/api/workflow-run-mapper.js +47 -0
  120. package/dist/hooks/api/workflow-run-mapper.js.map +1 -1
  121. package/dist/pages/project-workflows-page.js +19 -19
  122. package/dist/pages/project-workflows-page.js.map +1 -1
  123. package/dist/pages/workflow-run-list-page.js +1 -1
  124. package/dist/pages/workflow-run-list-page.js.map +1 -1
  125. package/dist/tsconfig.test.tsbuildinfo +1 -1
  126. package/package.json +10 -10
  127. package/src/components/identifier/identifier.tsx +2 -2
  128. package/src/components/job-detail/agent-config-failure-callout.tsx +5 -1
  129. package/src/components/job-detail/job-context-panel.test.tsx +72 -0
  130. package/src/components/job-detail/job-context-panel.tsx +237 -0
  131. package/src/components/job-detail/job-detail-header.tsx +52 -46
  132. package/src/components/job-detail/job-detail-view.stories.tsx +555 -53
  133. package/src/components/job-detail/job-detail-view.tsx +87 -45
  134. package/src/components/job-detail/job-empty-states.tsx +23 -10
  135. package/src/components/job-detail/job-execution-switcher.tsx +5 -5
  136. package/src/components/job-detail/json-code.tsx +120 -0
  137. package/src/components/job-detail/step-attempt-log-panel.test.tsx +1 -0
  138. package/src/components/job-detail/step-attempt-log-panel.tsx +17 -6
  139. package/src/components/job-detail/step-troubleshooting.stories.tsx +120 -0
  140. package/src/components/job-detail/step-troubleshooting.test.tsx +337 -0
  141. package/src/components/job-detail/step-troubleshooting.tsx +537 -0
  142. package/src/components/job-graph/job-graph-content.tsx +4 -3
  143. package/src/components/job-graph/job-node.tsx +3 -3
  144. package/src/components/step-list/index.ts +1 -0
  145. package/src/components/step-list/step-list-model.ts +5 -0
  146. package/src/components/step-list/step-list.test.tsx +75 -2
  147. package/src/components/step-list/step-list.tsx +73 -20
  148. package/src/components/workflow-run-duration-label.tsx +1 -1
  149. package/src/components/workflow-run-list/job-status-strip.tsx +6 -6
  150. package/src/components/workflow-run-list/workflow-run-filter-menu.tsx +1 -1
  151. package/src/components/workflow-run-list/workflow-run-filters.tsx +4 -4
  152. package/src/components/workflow-run-list/workflow-run-list-states.tsx +6 -6
  153. package/src/components/workflow-run-list/workflow-run-list-view.tsx +1 -1
  154. package/src/components/workflow-run-list/workflow-run-row.tsx +7 -7
  155. package/src/components/workflow-run-summary/workflow-run-attempt-switcher.tsx +1 -1
  156. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +1 -1
  157. package/src/components/workflow-run-summary/workflow-run-summary.tsx +11 -11
  158. package/src/components/workflow-run-tabs/index.ts +5 -1
  159. package/src/components/workflow-run-tabs/run-annotation-count-chip.tsx +2 -2
  160. package/src/components/workflow-run-tabs/run-annotation-list.tsx +25 -10
  161. package/src/components/workflow-run-tabs/run-annotation-summary-line.tsx +5 -5
  162. package/src/components/workflow-run-view/run-workspace-nav.tsx +7 -7
  163. package/src/components/workflow-run-view/workflow-run-states.tsx +6 -6
  164. package/src/components/workflow-run-view/workflow-run-view.tsx +114 -14
  165. package/src/core/entities/job-execution.ts +7 -0
  166. package/src/core/entities/job.ts +9 -0
  167. package/src/core/entities/step-attempt.ts +33 -0
  168. package/src/core/entities/step.ts +3 -1
  169. package/src/core/run-annotation.ts +6 -0
  170. package/src/core/workflow-run.ts +4 -0
  171. package/src/hooks/api/annotations.test.ts +84 -0
  172. package/src/hooks/api/annotations.ts +240 -0
  173. package/src/hooks/api/step-attempt-detail.ts +67 -0
  174. package/src/hooks/api/workflow-run-mapper.ts +46 -0
  175. package/src/hooks/api/workflow-runs.test.tsx +61 -1
  176. package/src/pages/project-workflows-page.tsx +19 -19
  177. package/src/pages/workflow-job-detail-page.test.tsx +52 -1
  178. package/src/pages/workflow-run-list-page.tsx +1 -1
  179. package/test/fixtures/workflow-run.ts +7 -0
  180. package/tsconfig.build.tsbuildinfo +1 -1
@@ -9,9 +9,11 @@ import {Skeleton} from '@shipfox/react-ui/skeleton';
9
9
  import {TimeTickerProvider} from '@shipfox/react-ui/time-ticker';
10
10
  import {Text} from '@shipfox/react-ui/typography';
11
11
  import {Link} from '@tanstack/react-router';
12
- import {type RefObject, useEffect, useRef} from 'react';
12
+ import {type RefObject, useEffect, useRef, useState} from 'react';
13
+ import type {RunAnnotationSummary} from '#core/run-annotation.js';
13
14
  import {summarizeJobAnnotations} from '#core/run-annotation.js';
14
- import type {Job, JobExecution} from '#core/workflow-run.js';
15
+ import {isWorkflowRunTerminal, type Job, type JobExecution} from '#core/workflow-run.js';
16
+ import {useWorkflowRunAnnotationSummaryQuery} from '#hooks/api/annotations.js';
15
17
  import {useRunAnnotationsQuery} from '#hooks/api/run-annotations.js';
16
18
  import type {useWorkflowRunAttemptQuery} from '#hooks/api/workflow-runs.js';
17
19
  import {
@@ -24,15 +26,13 @@ import {
24
26
  WorkflowRunNotFound,
25
27
  WorkflowRunStaleError,
26
28
  } from '../workflow-run-view/workflow-run-states.js';
27
- import {AgentConfigFailureCallout} from './agent-config-failure-callout.js';
29
+ import {JobContextPanel} from './job-context-panel.js';
28
30
  import {JobDetailHeader} from './job-detail-header.js';
29
31
  import {
30
32
  CarriedOverStepPanel,
31
33
  emptyStateForJob,
32
34
  emptyStateForMissingExecution,
33
- isAgentConfigFailure,
34
35
  jobSucceededSummary,
35
- toSelectedAttemptError,
36
36
  } from './job-empty-states.js';
37
37
  import {
38
38
  resolveWorkflowJobSelection,
@@ -40,6 +40,9 @@ import {
40
40
  workflowJobLandingSelection,
41
41
  } from './job-selection.js';
42
42
  import {StepAttemptLogPanel} from './step-attempt-log-panel.js';
43
+ import {StepInspectorSheet} from './step-troubleshooting.js';
44
+
45
+ type InspectorState = {key: string; attemptId: string | null};
43
46
 
44
47
  export interface JobDetailViewProps {
45
48
  workspaceSlug: string;
@@ -68,7 +71,8 @@ export function JobDetailView({
68
71
  const pageScrollRef = useRef<HTMLDivElement>(null);
69
72
  const landingSelectionRef = useRef<FrozenLandingSelection | undefined>(undefined);
70
73
  const hasLoadedData = query.data !== undefined;
71
- // Same query key as the run workspace, so this reads the cache instead of fetching again.
74
+ // Reuse the run workspace's bounded annotation read for the job header chip. The separate
75
+ // summary query below stays counts-only and is scoped to the inspector's selected execution.
72
76
  const annotations = useRunAnnotationsQuery({
73
77
  workflowRunId,
74
78
  runAttempt: query.data?.runAttempt.attempt,
@@ -78,6 +82,26 @@ export function JobDetailView({
78
82
  truncated: annotations.summary?.truncated ?? false,
79
83
  })
80
84
  : undefined;
85
+ const inspectorResetKey = `${jobId}:${search.jobExecutionId ?? ''}`;
86
+ const [inspectorState, setInspectorState] = useState<InspectorState>(() => ({
87
+ key: inspectorResetKey,
88
+ attemptId: null,
89
+ }));
90
+ const inspectorOpenAttemptId =
91
+ inspectorState.key === inspectorResetKey ? inspectorState.attemptId : null;
92
+ const annotationJob = query.data?.jobs.find((candidate) => candidate.id === jobId);
93
+ const annotationExecutionId = annotationJob
94
+ ? resolveWorkflowJobSelection({job: annotationJob, selection: search}).jobExecution?.id
95
+ : search.jobExecutionId;
96
+ const annotationPolling = query.data
97
+ ? !isWorkflowRunTerminal(query.data.runAttempt.status)
98
+ : true;
99
+ const annotationSummaryQuery = useWorkflowRunAnnotationSummaryQuery(
100
+ query.data?.id,
101
+ query.data?.runAttempt.attempt,
102
+ annotationExecutionId,
103
+ {polling: annotationPolling},
104
+ );
81
105
 
82
106
  useEffect(() => {
83
107
  if (!jobId || !hasLoadedData) return;
@@ -185,6 +209,10 @@ export function JobDetailView({
185
209
  });
186
210
  }
187
211
 
212
+ function onInspectorOpenChange(attemptId: string | null) {
213
+ setInspectorState({key: inspectorResetKey, attemptId});
214
+ }
215
+
188
216
  function retargetToRunningStep() {
189
217
  if (!runningSelection || !selectedJobExecution) return;
190
218
  onSelectionChange({
@@ -208,11 +236,8 @@ export function JobDetailView({
208
236
  attempt={newerAttempt}
209
237
  />
210
238
  ) : null}
211
- <div ref={pageScrollRef} className="@container min-h-0 flex-1 overflow-auto pb-24">
212
- <section
213
- aria-label={`${job.displayName} logs`}
214
- className="mx-auto flex w-full max-w-[1120px] flex-col gap-8 px-24"
215
- >
239
+ <div ref={pageScrollRef} className="@container min-h-0 flex-1 overflow-auto pb-panel">
240
+ <section aria-label={`${job.displayName} logs`} className="flex w-full flex-col">
216
241
  <section className="min-w-0 overflow-hidden">
217
242
  <JobDetailHeader
218
243
  job={job}
@@ -220,11 +245,16 @@ export function JobDetailView({
220
245
  onSelectedJobExecutionChange={selectExecution}
221
246
  workspaceSlug={workspaceSlug}
222
247
  projectSlug={projectSlug}
223
- workflowRunId={workflowRunId}
248
+ workflowRunId={run.id}
224
249
  runAttempt={run.runAttempt.attempt}
225
250
  annotationSummary={jobAnnotationSummary}
251
+ jobContext={
252
+ selectedJobExecution ? (
253
+ <JobContextPanel job={job} execution={selectedJobExecution} />
254
+ ) : undefined
255
+ }
226
256
  />
227
- <Text as="h3" className="sr-only">
257
+ <Text as="h2" className="sr-only">
228
258
  Logs
229
259
  </Text>
230
260
  {selectedJobExecution ? (
@@ -234,15 +264,30 @@ export function JobDetailView({
234
264
  selectedAttemptId={selectedAttemptId}
235
265
  defaultSelectedAttemptId={landingSelection?.attemptId}
236
266
  onSelectedAttemptChange={selectAttempt}
267
+ inspectorOpenAttemptId={inspectorOpenAttemptId}
268
+ onInspectorOpenChange={onInspectorOpenChange}
237
269
  autoSelectActiveAttempt
238
270
  emptyState={emptyStateForJob(job, selectedJobExecution)}
239
271
  showHeader={false}
240
- className="rounded-none border-0"
272
+ className="rounded-none border-0 bg-transparent"
241
273
  renderExpandedStep={(context) => (
242
- <ExpandedStep
243
- context={context}
274
+ <ExpandedStep context={context} pageScrollRef={pageScrollRef} />
275
+ )}
276
+ renderInspector={(entry) => (
277
+ <StepInspectorSheet
278
+ entry={entry}
279
+ open
280
+ onOpenChange={(open) => onInspectorOpenChange(open ? entry.id : null)}
244
281
  workspaceSlug={workspaceSlug}
245
- pageScrollRef={pageScrollRef}
282
+ projectSlug={projectSlug}
283
+ workflowRunId={run.id}
284
+ runAttempt={run.runAttempt.attempt}
285
+ jobId={job.id}
286
+ annotationCount={annotationCountForStep(
287
+ annotationSummaryQuery.data,
288
+ entry.step.id,
289
+ entry.attempt,
290
+ )}
246
291
  />
247
292
  )}
248
293
  />
@@ -254,7 +299,7 @@ export function JobDetailView({
254
299
  <div
255
300
  role="status"
256
301
  aria-live="polite"
257
- 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"
302
+ className="flex min-w-0 items-center justify-between gap-inline border-t border-border-neutral-base px-row py-row"
258
303
  >
259
304
  <Text size="xs" className="min-w-0 text-foreground-neutral-muted">
260
305
  Run moved on to{' '}
@@ -265,7 +310,7 @@ export function JobDetailView({
265
310
  </Text>
266
311
  <button
267
312
  type="button"
268
- 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"
313
+ className="shrink-0 rounded-4 px-tight py-[4px] text-xs font-medium text-foreground-highlight-interactive outline-none focus-visible:shadow-border-interactive-with-active"
269
314
  onClick={retargetToRunningStep}
270
315
  >
271
316
  Jump to it
@@ -273,7 +318,7 @@ export function JobDetailView({
273
318
  </div>
274
319
  ) : null}
275
320
  {succeededSummary ? (
276
- <Text size="xs" className="px-8 text-foreground-neutral-muted">
321
+ <Text size="xs" className="px-row py-row text-foreground-neutral-muted">
277
322
  {succeededSummary}
278
323
  </Text>
279
324
  ) : null}
@@ -286,39 +331,27 @@ export function JobDetailView({
286
331
 
287
332
  function ExpandedStep({
288
333
  context,
289
- workspaceSlug,
290
334
  pageScrollRef,
291
335
  }: {
292
336
  context: StepExpandedContext;
293
- workspaceSlug: string;
294
337
  pageScrollRef: RefObject<HTMLDivElement | null>;
295
338
  }) {
296
339
  if (context.carriedOver) return <CarriedOverStepPanel />;
297
340
 
298
- const selectedAttemptError =
299
- toSelectedAttemptError(context.step, context.attemptError) ?? context.step.error;
300
-
301
341
  return (
302
342
  <section
303
343
  role="region"
304
344
  tabIndex={0}
305
345
  aria-label={`${context.stepLabel} output, attempt ${context.attempt}`}
306
- 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"
346
+ className="flex min-w-0 flex-col border-t border-border-neutral-base bg-background-neutral-base outline-none focus-visible:shadow-border-interactive-with-active dark:bg-background-contrast-subtle"
307
347
  >
308
- {isAgentConfigFailure(context.step, selectedAttemptError) ? (
309
- <AgentConfigFailureCallout
310
- workspaceSlug={workspaceSlug}
311
- config={context.step.agentConfig}
312
- error={selectedAttemptError}
313
- />
314
- ) : null}
315
348
  <StepAttemptLogPanel
316
349
  stepId={context.stepId}
317
350
  attempt={context.attempt}
318
351
  attemptStatus={context.attemptStatus}
319
352
  attemptStartedAt={context.attemptStartedAt}
320
353
  pageScrollRef={pageScrollRef}
321
- surfaceClassName="rounded-8"
354
+ surfaceClassName="rounded-none border-0 bg-transparent shadow-none"
322
355
  />
323
356
  </section>
324
357
  );
@@ -328,7 +361,7 @@ function EmptyStateForMissingExecution({job}: {job: Job}) {
328
361
  const emptyState = emptyStateForMissingExecution(job);
329
362
  return (
330
363
  <EmptyState
331
- className="min-h-120 px-16 py-20"
364
+ className="min-h-120 p-panel"
332
365
  icon="componentLine"
333
366
  title={emptyState.title}
334
367
  description={emptyState.description}
@@ -349,7 +382,7 @@ function JobNotFoundState({
349
382
  search: WorkflowJobSearch;
350
383
  }) {
351
384
  return (
352
- <div className="flex min-h-0 min-w-0 flex-1 flex-col items-center justify-center gap-12 p-24">
385
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col items-center justify-center gap-cluster p-panel">
353
386
  <EmptyState
354
387
  icon="componentLine"
355
388
  title="Job not found"
@@ -364,7 +397,7 @@ function JobNotFoundState({
364
397
  {runAttempt: search.runAttempt},
365
398
  ) as never
366
399
  }
367
- 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"
400
+ className="inline-flex items-center gap-inline rounded-6 px-tight py-[6px] text-sm text-foreground-highlight-interactive outline-none focus-visible:shadow-border-interactive-with-active"
368
401
  >
369
402
  <Icon name="arrowLeftLine" size={14} aria-hidden="true" />
370
403
  Back to run summary
@@ -387,8 +420,8 @@ function NewerAttemptNotice({
387
420
  attempt: number;
388
421
  }) {
389
422
  return (
390
- <div role="status" className="border-b border-border-neutral-base px-16 py-8">
391
- <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">
423
+ <div role="status" className="border-b border-border-neutral-base px-row py-row">
424
+ <div className="flex items-center justify-between gap-inline">
392
425
  <Text size="xs" className="text-foreground-neutral-muted">
393
426
  A newer run attempt is available.
394
427
  </Text>
@@ -396,7 +429,7 @@ function NewerAttemptNotice({
396
429
  to="/w/$workspaceSlug/p/$projectSlug/runs/$workflowRunId/jobs/$jobId"
397
430
  params={{workspaceSlug, projectSlug, workflowRunId: runId, jobId}}
398
431
  search={workflowJobSearchParams({runAttempt: attempt}) as never}
399
- 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"
432
+ className="shrink-0 rounded-4 px-tight py-[4px] text-xs font-medium text-foreground-highlight-interactive outline-none focus-visible:shadow-border-interactive-with-active"
400
433
  >
401
434
  View attempt #{attempt}
402
435
  </Link>
@@ -431,21 +464,30 @@ function findAttempt(jobExecution: JobExecution, attemptId: string) {
431
464
  return undefined;
432
465
  }
433
466
 
467
+ function annotationCountForStep(
468
+ summary: RunAnnotationSummary | undefined,
469
+ stepId: string,
470
+ attempt: number,
471
+ ): number | undefined {
472
+ return summary?.stepCounts?.find((entry) => entry.stepId === stepId && entry.attempt === attempt)
473
+ ?.total;
474
+ }
475
+
434
476
  function JobDetailSkeleton() {
435
477
  return (
436
- <div className="min-h-0 min-w-0 flex-1 overflow-auto px-24 pb-24">
437
- <div className="mx-auto max-w-[1120px] overflow-hidden">
438
- <header className="flex items-center gap-12 border-b border-border-neutral-base px-16 py-12">
478
+ <div className="min-h-0 min-w-0 flex-1 overflow-auto pb-panel">
479
+ <div className="w-full">
480
+ <header className="flex items-center gap-cluster border-b border-border-neutral-base px-row py-row">
439
481
  <Skeleton className="size-20 rounded-full" />
440
482
  <Skeleton className="h-20 w-160 rounded-4" />
441
483
  <Skeleton className="h-24 w-72 rounded-6" />
442
484
  <Skeleton className="h-24 w-180 rounded-4" />
443
485
  </header>
444
- <div className="p-16">
486
+ <div className="p-panel-compact">
445
487
  {JOB_DETAIL_SKELETON_ROWS.map((row) => (
446
488
  <div
447
489
  key={row}
448
- className="flex min-h-44 items-center gap-8 border-b border-border-neutral-base last:border-b-0"
490
+ className="flex min-h-44 items-center gap-inline border-b border-border-neutral-base last:border-b-0"
449
491
  >
450
492
  <Skeleton className="size-14 rounded-full" />
451
493
  <Skeleton className="h-16 w-180 rounded-4" />
@@ -25,11 +25,14 @@ export function emptyStateForJob(
25
25
  job.mode === 'listening'
26
26
  ? deriveJobExecutionDisplayStatus(jobExecution)
27
27
  : deriveJobDisplayStatus(job);
28
+ const runner = jobExecution.runner?.length ? jobExecution.runner : job.runner;
28
29
 
29
30
  if (jobExecution.status === 'running' && displayStatus === 'pending') {
30
31
  return {
31
- title: 'Runner preparing job',
32
- description: 'A runner is assigned. Steps will appear here when work begins.',
32
+ title: runner?.length ? 'Runner preparing job' : 'Waiting for a runner',
33
+ description: runner?.length
34
+ ? `Runner ${runner.join(', ')} is preparing the job. Steps will appear here when work begins.`
35
+ : 'No runner has claimed this job yet. Steps will appear here when a runner starts work.',
33
36
  status: displayStatus,
34
37
  };
35
38
  }
@@ -61,7 +64,7 @@ export function emptyStateForJob(
61
64
  if (displayStatus === 'failed') {
62
65
  return {
63
66
  title: 'Job failed before its first step started',
64
- description: preStepFailureDescription(jobExecution.statusReason ?? job.statusReason),
67
+ description: preStepFailureDescription(jobExecution.statusReason ?? job.statusReason, runner),
65
68
  status: displayStatus,
66
69
  };
67
70
  }
@@ -126,6 +129,14 @@ export function emptyStateForMissingExecution(job: Job): StepListEmptyState {
126
129
  };
127
130
  }
128
131
 
132
+ if (job.status === 'failed') {
133
+ return {
134
+ title: 'Job failed before an execution was created',
135
+ description: preStepFailureDescription(job.statusReason, job.runner),
136
+ status: 'failed',
137
+ };
138
+ }
139
+
129
140
  return {
130
141
  title: 'Execution details unavailable',
131
142
  description: 'This job finished, but no job execution record is available.',
@@ -155,17 +166,19 @@ export function skippedJobDescription(reason: Job['statusReason']): string {
155
166
  }
156
167
  }
157
168
 
158
- function preStepFailureDescription(reason: string | null): string {
169
+ function preStepFailureDescription(reason: string | null, runner: string[] | null = null): string {
170
+ const runnerCopy = runner?.length ? ` Required runner labels: ${runner.join(', ')}.` : '';
171
+
159
172
  switch (reason) {
160
173
  case 'runner_lost':
161
- return 'The runner stopped responding before work began. Check runner availability before re-running the workflow.';
174
+ return `The runner stopped responding before work began.${runnerCopy} Check runner availability before re-running the workflow.`;
162
175
  case 'timed_out':
163
- return 'The job timed out before work began. Check runner capacity and timeout configuration before re-running the workflow.';
176
+ return `The job timed out before work began.${runnerCopy} Check runner capacity and timeout configuration before re-running the workflow.`;
164
177
  case 'user_cancelled':
165
178
  case 'run_cancelled':
166
179
  return 'The execution ended while the run was being cancelled.';
167
180
  case 'step_failed':
168
- return 'The execution failed before step details were recorded. Review run annotations before re-running the workflow.';
181
+ return `The execution failed before step details were recorded.${runnerCopy} Review run annotations before re-running the workflow.`;
169
182
  case 'condition_errored':
170
183
  return 'The job condition could not be evaluated. Review run annotations before re-running the workflow.';
171
184
  case 'dependency_not_completed':
@@ -176,9 +189,9 @@ function preStepFailureDescription(reason: string | null): string {
176
189
  return 'The job condition did not match, so no work started.';
177
190
  case 'unknown':
178
191
  case null:
179
- return 'Shipfox did not record a failure reason. Re-run only after checking the runner and workflow configuration.';
192
+ return `Shipfox did not record a failure reason.${runnerCopy} Re-run only after checking the runner and workflow configuration.`;
180
193
  default:
181
- return `The execution ended because ${humanizeFailureReason(reason)}. Resolve that condition before re-running the workflow.`;
194
+ return `The execution ended because ${humanizeFailureReason(reason)}.${runnerCopy} Resolve that condition before re-running the workflow.`;
182
195
  }
183
196
  }
184
197
 
@@ -189,7 +202,7 @@ function humanizeFailureReason(reason: string): string {
189
202
  export function CarriedOverStepPanel() {
190
203
  return (
191
204
  <EmptyState
192
- className="min-h-120 rounded-8 border border-border-neutral-base bg-background-components-base px-16 py-20"
205
+ className="min-h-120 rounded-8 border border-border-neutral-base bg-background-components-base p-panel"
193
206
  icon="componentLine"
194
207
  title="Carried over from a previous attempt"
195
208
  description="Not executed in this run."
@@ -44,7 +44,7 @@ export function JobExecutionSwitcher({
44
44
  return (
45
45
  <div
46
46
  className={cn(
47
- 'flex min-w-0 items-center gap-6 text-sm leading-20 text-foreground-neutral-subtle',
47
+ 'flex min-w-0 items-center gap-inline text-sm leading-20 text-foreground-neutral-subtle',
48
48
  className,
49
49
  )}
50
50
  >
@@ -59,8 +59,8 @@ export function JobExecutionSwitcher({
59
59
  className={cn(
60
60
  'inline-flex min-w-0 max-w-full items-center rounded-6 text-left transition-colors focus-visible:shadow-border-interactive-with-active focus-visible:outline-none',
61
61
  variant === 'title'
62
- ? '-mx-6 -my-4 gap-8 px-6 py-4 hover:bg-background-components-hover'
63
- : 'min-h-28 gap-6 px-8 py-4 text-sm leading-20 text-foreground-neutral-subtle hover:bg-background-components-hover',
62
+ ? '-mx-inline -my-[4px] gap-inline px-tight py-[4px] hover:bg-background-components-hover'
63
+ : 'min-h-28 gap-inline px-tight py-[4px] text-sm leading-20 text-foreground-neutral-subtle hover:bg-background-components-hover',
64
64
  className,
65
65
  )}
66
66
  aria-label={`Switch job execution, currently execution ${selected.sequence}: ${selected.displayName}`}
@@ -141,7 +141,7 @@ function executionAccessibleLabel(execution: JobExecution): string {
141
141
 
142
142
  function TitleExecutionSummary({execution}: {execution: JobExecution}) {
143
143
  return (
144
- <span className="flex min-w-0 items-center gap-8">
144
+ <span className="flex min-w-0 items-center gap-inline">
145
145
  <Text as="span" size="sm" bold className="shrink-0 text-foreground-neutral-base">
146
146
  #{execution.sequence}
147
147
  </Text>
@@ -156,7 +156,7 @@ function ExecutionSummary({execution}: {execution: JobExecution}) {
156
156
  const displayStatus = deriveJobExecutionDisplayStatus(execution);
157
157
 
158
158
  return (
159
- <span className="flex min-w-0 items-center gap-6">
159
+ <span className="flex min-w-0 items-center gap-inline">
160
160
  <WorkflowStatusIcon status={displayStatus} size={14} tooltip={false} />
161
161
  <span className="shrink-0 font-code text-xs leading-20 text-foreground-neutral-base tabular-nums">
162
162
  Execution #{execution.sequence}
@@ -0,0 +1,120 @@
1
+ import {
2
+ CodeBlock,
3
+ CodeBlockBody,
4
+ CodeBlockContent,
5
+ CodeBlockCopyButton,
6
+ CodeBlockFilename,
7
+ CodeBlockFiles,
8
+ CodeBlockHeader,
9
+ CodeBlockItem,
10
+ CodeTabs,
11
+ } from '@shipfox/react-ui/code-block';
12
+ import {Text} from '@shipfox/react-ui/typography';
13
+ import {useState} from 'react';
14
+
15
+ export interface JsonCodeEntry {
16
+ filename: string;
17
+ label?: string | undefined;
18
+ value: unknown;
19
+ }
20
+
21
+ export function JsonCode({
22
+ title,
23
+ value,
24
+ emptyMessage,
25
+ }: {
26
+ title?: string | undefined;
27
+ value: unknown;
28
+ emptyMessage?: string | undefined;
29
+ }) {
30
+ const filename = title ?? 'value.json';
31
+
32
+ return (
33
+ <div className="flex min-w-0 flex-col gap-tight">
34
+ {title ? (
35
+ <Text size="xs" className="text-foreground-neutral-muted">
36
+ {title}
37
+ </Text>
38
+ ) : null}
39
+ {emptyMessage && isEmptyObject(value) ? (
40
+ <Text size="xs" className="text-foreground-neutral-muted">
41
+ {emptyMessage}
42
+ </Text>
43
+ ) : (
44
+ <JsonCodeBlock entries={[{filename, value}]} />
45
+ )}
46
+ </div>
47
+ );
48
+ }
49
+
50
+ export function JsonCodeTabs({entries}: {entries: readonly JsonCodeEntry[]}) {
51
+ const entryKeys = entries.map((entry) => entry.label ?? entry.filename);
52
+ const [activeKey, setActiveKey] = useState(entryKeys[0] ?? '');
53
+ const activeValue = entryKeys.includes(activeKey) ? activeKey : (entryKeys[0] ?? '');
54
+ const codes = Object.fromEntries(
55
+ entries.map((entry) => [entry.label ?? entry.filename, stringifyJson(entry.value)]),
56
+ );
57
+
58
+ return (
59
+ <CodeTabs
60
+ value={activeValue}
61
+ onValueChange={setActiveKey}
62
+ codes={codes}
63
+ lang="json"
64
+ syntaxHighlighting={false}
65
+ className="h-auto min-h-0 rounded-8 bg-background-contrast-base shadow-button-neutral"
66
+ />
67
+ );
68
+ }
69
+
70
+ function JsonCodeBlock({entries}: {entries: readonly JsonCodeEntry[]}) {
71
+ const data = entries.map((entry) => ({
72
+ filename: entry.filename,
73
+ language: 'json',
74
+ code: stringifyJson(entry.value),
75
+ }));
76
+
77
+ return (
78
+ <CodeBlock
79
+ data={data}
80
+ className="h-auto min-h-0 rounded-6 bg-background-contrast-base shadow-button-neutral"
81
+ >
82
+ <CodeBlockHeader className="bg-background-contrast-base p-tight">
83
+ <CodeBlockFiles>
84
+ {(item) => {
85
+ const entry = entries.find((candidate) => candidate.filename === item.filename);
86
+ return (
87
+ <CodeBlockFilename value={item.filename}>
88
+ {entry?.label ?? item.filename}
89
+ </CodeBlockFilename>
90
+ );
91
+ }}
92
+ </CodeBlockFiles>
93
+ <CodeBlockCopyButton />
94
+ </CodeBlockHeader>
95
+ <CodeBlockBody>
96
+ {(item) => (
97
+ <CodeBlockItem value={item.filename}>
98
+ <CodeBlockContent language="json" syntaxHighlighting={false}>
99
+ {item.code}
100
+ </CodeBlockContent>
101
+ </CodeBlockItem>
102
+ )}
103
+ </CodeBlockBody>
104
+ </CodeBlock>
105
+ );
106
+ }
107
+
108
+ function isEmptyObject(value: unknown): boolean {
109
+ return (
110
+ typeof value === 'object' &&
111
+ value !== null &&
112
+ !Array.isArray(value) &&
113
+ Object.keys(value).length === 0
114
+ );
115
+ }
116
+
117
+ function stringifyJson(value: unknown): string {
118
+ const serialized = JSON.stringify(value, null, 2);
119
+ return serialized === undefined ? String(value) : serialized;
120
+ }
@@ -203,6 +203,7 @@ describe('StepAttemptLogPanel', () => {
203
203
  renderPanel({attemptStatus: 'succeeded'});
204
204
 
205
205
  expect(await screen.findByText('hello')).toBeInTheDocument();
206
+ expect(screen.getByRole('log')).toHaveAttribute('aria-live', 'off');
206
207
  });
207
208
 
208
209
  test('renders a terminal closed empty stream as no output', async () => {
@@ -100,10 +100,15 @@ export function StepAttemptLogPanel({
100
100
  }
101
101
 
102
102
  return (
103
- <div className="flex min-w-0 flex-col gap-8">
103
+ <div className="flex min-w-0 flex-col gap-inline">
104
104
  {staleError ? (
105
- <Callout role="alert" type="warning" className="px-10 py-8">
106
- <div className="flex min-w-0 flex-1 items-center justify-between gap-8">
105
+ <Callout
106
+ role="alert"
107
+ type="warning"
108
+ variant="secondary"
109
+ className="rounded-none border-b border-border-neutral-base px-0 py-row shadow-none"
110
+ >
111
+ <div className="flex min-w-0 flex-1 items-center justify-between gap-inline">
107
112
  <Text size="xs">Could not refresh logs.</Text>
108
113
  <Button
109
114
  type="button"
@@ -121,6 +126,7 @@ export function StepAttemptLogPanel({
121
126
  records={records}
122
127
  emptyState={query.data?.complete ? 'complete' : 'pending'}
123
128
  anchorToFailure={anchorToFailure}
129
+ ariaLive={attemptStatus === 'running' ? 'polite' : 'off'}
124
130
  className={surfaceClassName}
125
131
  />
126
132
  </div>
@@ -138,7 +144,7 @@ function StepLogsLoadingSurface({label, className}: {label: string; className: s
138
144
  function StepLogsWaitingSurface({startedAt, className}: {startedAt: string; className: string}) {
139
145
  return (
140
146
  <div role="status" aria-label="Waiting for logs" className={className}>
141
- <Text size="xs" className="px-10 py-12 text-foreground-neutral-muted">
147
+ <Text size="xs" className="px-tight py-row text-foreground-neutral-muted">
142
148
  Waiting for output ·{' '}
143
149
  <span className="font-code tabular-nums" aria-hidden="true">
144
150
  <JobExecutionTimeText time={{state: 'live', fromIso: startedAt}} />
@@ -174,8 +180,13 @@ function isTerminalAttemptStatus(status: string): boolean {
174
180
 
175
181
  function StepLogsError({retrying, onRetry}: {retrying: boolean; onRetry: () => void}) {
176
182
  return (
177
- <Callout role="alert" type="error" className="px-10 py-8">
178
- <div className="flex min-w-0 flex-1 items-center justify-between gap-8">
183
+ <Callout
184
+ role="alert"
185
+ type="error"
186
+ variant="secondary"
187
+ className="rounded-none border-b border-border-neutral-base px-0 py-row shadow-none"
188
+ >
189
+ <div className="flex min-w-0 flex-1 items-center justify-between gap-inline">
179
190
  <Text size="xs">Could not load logs.</Text>
180
191
  <Button type="button" size="2xs" variant="secondary" isLoading={retrying} onClick={onRetry}>
181
192
  Retry