@shipfox/client-workflows 13.0.0 → 14.0.1

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 (133) hide show
  1. package/.storybook/main.ts +16 -1
  2. package/.storybook/workspace-source.ts +31 -0
  3. package/.turbo/turbo-build.log +1 -1
  4. package/CHANGELOG.md +37 -0
  5. package/dist/components/job-graph/job-duration-label.js +1 -1
  6. package/dist/components/job-graph/job-duration-label.js.map +1 -1
  7. package/dist/components/job-graph/job-graph-content.d.ts +2 -1
  8. package/dist/components/job-graph/job-graph-content.d.ts.map +1 -1
  9. package/dist/components/job-graph/job-graph-content.js +3 -3
  10. package/dist/components/job-graph/job-graph-content.js.map +1 -1
  11. package/dist/components/job-graph/job-graph-view.d.ts +2 -1
  12. package/dist/components/job-graph/job-graph-view.d.ts.map +1 -1
  13. package/dist/components/job-graph/job-graph-view.js +2 -2
  14. package/dist/components/job-graph/job-graph-view.js.map +1 -1
  15. package/dist/components/job-graph/job-node.js +5 -5
  16. package/dist/components/job-graph/job-node.js.map +1 -1
  17. package/dist/components/job-graph/types.d.ts +2 -1
  18. package/dist/components/job-graph/types.d.ts.map +1 -1
  19. package/dist/components/job-graph/types.js.map +1 -1
  20. package/dist/components/workflow-run-duration-label.js +1 -1
  21. package/dist/components/workflow-run-duration-label.js.map +1 -1
  22. package/dist/components/workflow-run-number-label.js +1 -1
  23. package/dist/components/workflow-run-number-label.js.map +1 -1
  24. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js +2 -2
  25. package/dist/components/workflow-run-summary/workflow-run-attempt-switcher.js.map +1 -1
  26. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts +2 -7
  27. package/dist/components/workflow-run-summary/workflow-run-summary.d.ts.map +1 -1
  28. package/dist/components/workflow-run-summary/workflow-run-summary.js +92 -68
  29. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  30. package/dist/components/workflow-run-tabs/index.d.ts +6 -0
  31. package/dist/components/workflow-run-tabs/index.d.ts.map +1 -0
  32. package/dist/components/workflow-run-tabs/index.js +7 -0
  33. package/dist/components/workflow-run-tabs/index.js.map +1 -0
  34. package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts +11 -0
  35. package/dist/components/workflow-run-tabs/run-annotation-summary-line.d.ts.map +1 -0
  36. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js +71 -0
  37. package/dist/components/workflow-run-tabs/run-annotation-summary-line.js.map +1 -0
  38. package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts +2 -0
  39. package/dist/components/workflow-run-tabs/run-annotations-empty.d.ts.map +1 -0
  40. package/dist/components/workflow-run-tabs/run-annotations-empty.js +11 -0
  41. package/dist/components/workflow-run-tabs/run-annotations-empty.js.map +1 -0
  42. package/dist/components/workflow-run-tabs/run-jobs-list.d.ts +18 -0
  43. package/dist/components/workflow-run-tabs/run-jobs-list.d.ts.map +1 -0
  44. package/dist/components/workflow-run-tabs/run-jobs-list.js +107 -0
  45. package/dist/components/workflow-run-tabs/run-jobs-list.js.map +1 -0
  46. package/dist/components/workflow-run-tabs/run-tab-count.d.ts +6 -0
  47. package/dist/components/workflow-run-tabs/run-tab-count.d.ts.map +1 -0
  48. package/dist/components/workflow-run-tabs/run-tab-count.js +27 -0
  49. package/dist/components/workflow-run-tabs/run-tab-count.js.map +1 -0
  50. package/dist/components/workflow-run-tabs/run-tab-strip.d.ts +8 -0
  51. package/dist/components/workflow-run-tabs/run-tab-strip.d.ts.map +1 -0
  52. package/dist/components/workflow-run-tabs/run-tab-strip.js +82 -0
  53. package/dist/components/workflow-run-tabs/run-tab-strip.js.map +1 -0
  54. package/dist/components/workflow-run-view/workflow-run-selection.d.ts.map +1 -1
  55. package/dist/components/workflow-run-view/workflow-run-selection.js +40 -1
  56. package/dist/components/workflow-run-view/workflow-run-selection.js.map +1 -1
  57. package/dist/components/workflow-run-view/workflow-run-states.d.ts +1 -0
  58. package/dist/components/workflow-run-view/workflow-run-states.d.ts.map +1 -1
  59. package/dist/components/workflow-run-view/workflow-run-states.js +10 -1
  60. package/dist/components/workflow-run-view/workflow-run-states.js.map +1 -1
  61. package/dist/components/workflow-run-view/workflow-run-view.d.ts +8 -4
  62. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  63. package/dist/components/workflow-run-view/workflow-run-view.js +125 -78
  64. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  65. package/dist/components/workflow-source-panel/index.d.ts +1 -0
  66. package/dist/components/workflow-source-panel/index.d.ts.map +1 -1
  67. package/dist/components/workflow-source-panel/index.js +1 -0
  68. package/dist/components/workflow-source-panel/index.js.map +1 -1
  69. package/dist/components/workflow-source-panel/workflow-source-content.d.ts +12 -0
  70. package/dist/components/workflow-source-panel/workflow-source-content.d.ts.map +1 -0
  71. package/dist/components/workflow-source-panel/workflow-source-content.js +53 -0
  72. package/dist/components/workflow-source-panel/workflow-source-content.js.map +1 -0
  73. package/dist/components/workflow-source-panel/workflow-source-panel.d.ts +1 -1
  74. package/dist/components/workflow-source-panel/workflow-source-panel.d.ts.map +1 -1
  75. package/dist/components/workflow-source-panel/workflow-source-panel.js +7 -54
  76. package/dist/components/workflow-source-panel/workflow-source-panel.js.map +1 -1
  77. package/dist/core/entities/step.d.ts +1 -1
  78. package/dist/core/entities/step.d.ts.map +1 -1
  79. package/dist/core/entities/step.js +2 -0
  80. package/dist/core/entities/step.js.map +1 -1
  81. package/dist/core/workflow-run-tabs.d.ts +9 -0
  82. package/dist/core/workflow-run-tabs.d.ts.map +1 -0
  83. package/dist/core/workflow-run-tabs.js +3 -0
  84. package/dist/core/workflow-run-tabs.js.map +1 -0
  85. package/dist/pages/workflow-run-detail-page.d.ts.map +1 -1
  86. package/dist/pages/workflow-run-detail-page.js +28 -6
  87. package/dist/pages/workflow-run-detail-page.js.map +1 -1
  88. package/dist/routes/inputs.d.ts +33 -0
  89. package/dist/routes/inputs.d.ts.map +1 -1
  90. package/dist/routes/inputs.js +49 -0
  91. package/dist/routes/inputs.js.map +1 -1
  92. package/dist/tsconfig.test.tsbuildinfo +1 -1
  93. package/package.json +9 -9
  94. package/src/components/job-graph/job-duration-label.tsx +5 -1
  95. package/src/components/job-graph/job-graph-content.tsx +7 -4
  96. package/src/components/job-graph/job-graph-view.tsx +6 -3
  97. package/src/components/job-graph/job-graph.stories.tsx +1 -1
  98. package/src/components/job-graph/job-node.stories.tsx +1 -1
  99. package/src/components/job-graph/job-node.test.tsx +27 -2
  100. package/src/components/job-graph/job-node.tsx +6 -6
  101. package/src/components/job-graph/types.ts +5 -1
  102. package/src/components/workflow-run-duration-label.tsx +1 -1
  103. package/src/components/workflow-run-number-label.tsx +1 -1
  104. package/src/components/workflow-run-summary/workflow-run-attempt-switcher.tsx +2 -2
  105. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +23 -30
  106. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +3 -22
  107. package/src/components/workflow-run-summary/workflow-run-summary.tsx +84 -69
  108. package/src/components/workflow-run-tabs/index.ts +5 -0
  109. package/src/components/workflow-run-tabs/run-annotation-summary-line.test.tsx +69 -0
  110. package/src/components/workflow-run-tabs/run-annotation-summary-line.tsx +97 -0
  111. package/src/components/workflow-run-tabs/run-annotations-empty.tsx +11 -0
  112. package/src/components/workflow-run-tabs/run-jobs-list.tsx +151 -0
  113. package/src/components/workflow-run-tabs/run-tab-count.tsx +28 -0
  114. package/src/components/workflow-run-tabs/run-tab-strip.stories.tsx +52 -0
  115. package/src/components/workflow-run-tabs/run-tab-strip.test.tsx +117 -0
  116. package/src/components/workflow-run-tabs/run-tab-strip.tsx +97 -0
  117. package/src/components/workflow-run-view/workflow-run-selection.test.ts +48 -0
  118. package/src/components/workflow-run-view/workflow-run-selection.ts +48 -1
  119. package/src/components/workflow-run-view/workflow-run-states.tsx +12 -1
  120. package/src/components/workflow-run-view/workflow-run-view.test.tsx +64 -64
  121. package/src/components/workflow-run-view/workflow-run-view.tsx +199 -85
  122. package/src/components/workflow-source-panel/index.ts +1 -0
  123. package/src/components/workflow-source-panel/workflow-source-content.tsx +84 -0
  124. package/src/components/workflow-source-panel/workflow-source-panel.tsx +14 -81
  125. package/src/core/entities/step.ts +4 -0
  126. package/src/core/workflow-run-tabs.ts +8 -0
  127. package/src/pages/workflow-run-detail-page.tsx +31 -4
  128. package/src/pages/workflow-run-pages.test.tsx +185 -45
  129. package/src/routes/inputs.test.ts +47 -0
  130. package/src/routes/inputs.ts +45 -0
  131. package/src/storybook-config.test.ts +27 -0
  132. package/tsconfig.build.tsbuildinfo +1 -1
  133. package/tsconfig.test.json +7 -1
@@ -1,6 +1,8 @@
1
1
  import {ApiError} from '@shipfox/client-api';
2
2
  import {QueryLoadError} from '@shipfox/client-ui';
3
+ import {EmptyState} from '@shipfox/react-ui/empty-state';
3
4
  import {RelativeTimeProvider} from '@shipfox/react-ui/relative-time';
5
+ import {Tabs, TabsContent, TabsContents} from '@shipfox/react-ui/tabs';
4
6
  import {toast} from '@shipfox/react-ui/toast';
5
7
  import {useNavigate} from '@tanstack/react-router';
6
8
  import {useEffect, useId, useRef, useState} from 'react';
@@ -10,6 +12,7 @@ import {
10
12
  type StepSourceLocation,
11
13
  type WorkflowRunRerunMode,
12
14
  } from '#core/workflow-run.js';
15
+ import type {RunAnnotationSummary} from '#core/workflow-run-tabs.js';
13
16
  import {
14
17
  type WorkflowRunSelectionInput,
15
18
  withoutWorkflowRunSelectionSearch,
@@ -19,12 +22,20 @@ import {
19
22
  useRerunWorkflowRunMutation,
20
23
  useWorkflowRunAttemptQuery,
21
24
  } from '#hooks/api/workflow-runs.js';
25
+ import {WORKFLOW_RUN_TABS, type WorkflowRunTab} from '#routes/inputs.js';
22
26
  import {JobGraph} from '../job-graph/index.js';
27
+ import type {JobGraphSelectionSource} from '../job-graph/types.js';
23
28
  import {WorkflowRunSummary} from '../workflow-run-summary/index.js';
24
- import {WorkflowSourcePanel} from '../workflow-source-panel/index.js';
25
- import {JobCard} from './job-card.js';
29
+ import {
30
+ RunAnnotationSummaryLine,
31
+ RunAnnotationsEmpty,
32
+ RunJobsList,
33
+ RunTabStrip,
34
+ } from '../workflow-run-tabs/index.js';
35
+ import {WorkflowSourceContent, WorkflowSourcePanel} from '../workflow-source-panel/index.js';
26
36
  import {resolveWorkflowRunSelection} from './workflow-run-selection.js';
27
37
  import {
38
+ WorkflowRunContentSkeleton,
28
39
  WorkflowRunNotFound,
29
40
  WorkflowRunSkeleton,
30
41
  WorkflowRunStaleError,
@@ -42,12 +53,14 @@ export interface WorkflowRunViewProps {
42
53
  workflowRunId?: string | undefined;
43
54
  selection?: WorkflowRunSelectionInput | undefined;
44
55
  onSelectionChange?: ((selection: WorkflowRunSelectionInput) => void) | undefined;
56
+ tab?: WorkflowRunTab | undefined;
57
+ onTabChange?: ((tab: WorkflowRunTab) => void) | undefined;
58
+ annotationSummary?: RunAnnotationSummary | undefined;
45
59
  }
46
60
 
47
61
  /**
48
- * Renders the run for `workflowRunId`, or a skeleton while `workflowRunId` is still unknown (the page is
49
- * resolving which run to show) or the run is loading, so the page never branches on the
50
- * loading state itself.
62
+ * Renders the run for `workflowRunId`, keeping the local tab strip mounted while the run
63
+ * request is pending so polling never inserts navigation under the user's cursor.
51
64
  */
52
65
  export function WorkflowRunView({
53
66
  projectId,
@@ -56,6 +69,9 @@ export function WorkflowRunView({
56
69
  workflowRunId,
57
70
  selection,
58
71
  onSelectionChange,
72
+ tab,
73
+ onTabChange,
74
+ annotationSummary,
59
75
  }: WorkflowRunViewProps) {
60
76
  const runQuery = useWorkflowRunAttemptQuery({workflowRunId, runAttempt: selection?.runAttempt});
61
77
  const rerunMutation = useRerunWorkflowRunMutation(projectId);
@@ -70,6 +86,9 @@ export function WorkflowRunView({
70
86
  rerunMutation={rerunMutation}
71
87
  selection={selection}
72
88
  onSelectionChange={onSelectionChange}
89
+ tab={tab}
90
+ onTabChange={onTabChange}
91
+ annotationSummary={annotationSummary}
73
92
  />
74
93
  </div>
75
94
  </RelativeTimeProvider>
@@ -83,6 +102,9 @@ function RunViewContent({
83
102
  rerunMutation,
84
103
  selection,
85
104
  onSelectionChange,
105
+ tab,
106
+ onTabChange,
107
+ annotationSummary,
86
108
  }: {
87
109
  workspaceSlug: string | undefined;
88
110
  projectSlug: string | undefined;
@@ -90,18 +112,22 @@ function RunViewContent({
90
112
  rerunMutation: ReturnType<typeof useRerunWorkflowRunMutation>;
91
113
  selection: WorkflowRunSelectionInput | undefined;
92
114
  onSelectionChange: ((selection: WorkflowRunSelectionInput) => void) | undefined;
115
+ tab: WorkflowRunTab | undefined;
116
+ onTabChange: ((tab: WorkflowRunTab) => void) | undefined;
117
+ annotationSummary: RunAnnotationSummary | undefined;
93
118
  }) {
94
119
  const navigate = useNavigate();
95
120
  const [selectedJobId, setSelectedJobId] = useState<string | undefined>();
96
121
  const [selectedJobExecutionId, setSelectedJobExecutionId] = useState<string | undefined>();
122
+ const [localTab, setLocalTab] = useState<WorkflowRunTab>(tab ?? 'summary');
97
123
  const [sourcePanelOpen, setSourcePanelOpen] = useState(false);
98
124
  const [sourceFocus, setSourceFocus] = useState<WorkflowSourceFocus | null>(null);
99
125
  const sourcePanelId = useId();
100
- const sourceButtonRef = useRef<HTMLButtonElement>(null);
101
- // The button that last opened the panel (summary or a step detail), so Escape /
102
- // Close returns focus to whoever opened it.
126
+ // The step Source button that last opened the panel, so Escape / Close returns focus to it.
103
127
  const lastSourceTriggerRef = useRef<HTMLButtonElement | null>(null);
104
128
  const selectionControlled = selection !== undefined;
129
+ const tabControlled = onTabChange !== undefined;
130
+ const activeTab = tabControlled ? (tab ?? 'summary') : localTab;
105
131
  const sourceAvailable =
106
132
  query.data?.sourceSnapshot !== null && query.data?.sourceSnapshot !== undefined;
107
133
  const cancelMutation = useCancelWorkflowRunMutation(query.data);
@@ -113,8 +139,8 @@ function RunViewContent({
113
139
  }
114
140
  }, [sourceAvailable]);
115
141
 
116
- // If a refetch drops the focused step or its location, degrade to whole-workflow
117
- // focus so the panel never points at an unmounted Source button.
142
+ // If a refetch drops the focused step or its location, close the panel so it never points at
143
+ // an unmounted Source button.
118
144
  useEffect(() => {
119
145
  if (!sourceFocus) return;
120
146
  const stillLocated = query.data?.jobs.some((job) =>
@@ -124,31 +150,26 @@ function RunViewContent({
124
150
  );
125
151
  if (!stillLocated) {
126
152
  setSourceFocus(null);
127
- lastSourceTriggerRef.current = sourceButtonRef.current;
153
+ setSourcePanelOpen(false);
128
154
  }
129
155
  }, [sourceFocus, query.data]);
130
156
 
131
- if (query.isPending) return <WorkflowRunSkeleton />;
132
-
133
- // Only show the full error placeholder when nothing ever loaded. A refetch that fails after
134
- // a prior success keeps the loaded run on screen (see below) instead of wiping the pane,
135
- // since active-run polling can hit a transient API error.
136
- if (query.isError && query.data === undefined) {
137
- if (query.error instanceof ApiError && query.error.status === 404) {
138
- return <WorkflowRunNotFound />;
139
- }
140
- return <QueryLoadError query={query} subject="workflow run" icon="pulseLine" />;
141
- }
142
-
143
- if (query.data === undefined) return <WorkflowRunSkeleton />;
144
-
145
157
  const runData = query.data;
146
- const resolvedSelection = selectionControlled
147
- ? resolveWorkflowRunSelection({run: runData, selection})
148
- : undefined;
158
+ const resolvedSelection =
159
+ selectionControlled && runData
160
+ ? resolveWorkflowRunSelection({
161
+ run: runData,
162
+ selection: selection as WorkflowRunSelectionInput,
163
+ })
164
+ : undefined;
165
+ const hasExplicitJobSelection = Boolean(
166
+ selection?.jobId || selection?.jobExecutionId || selection?.stepId || selection?.stepAttemptId,
167
+ );
149
168
  const selectedJob = selectionControlled
150
- ? resolvedSelection?.job
151
- : (runData.jobs.find((job) => job.id === selectedJobId) ?? runData.jobs.at(0));
169
+ ? hasExplicitJobSelection
170
+ ? resolvedSelection?.job
171
+ : undefined
172
+ : (runData?.jobs.find((job) => job.id === selectedJobId) ?? runData?.jobs.at(0));
152
173
  const selectedJobExecution = selectionControlled
153
174
  ? resolvedSelection?.jobExecution
154
175
  : selectedJob
@@ -157,13 +178,32 @@ function RunViewContent({
157
178
  const selectedAttemptId = selectionControlled
158
179
  ? (resolvedSelection?.selectedAttemptId ?? null)
159
180
  : undefined;
160
- // Explicit per-step focus wins; fall back to the URL-selected step so deep links
161
- // still pre-highlight when the summary opens the whole-workflow source.
181
+ // Explicit per-step focus wins; fall back to the URL-selected step so deep links still
182
+ // pre-highlight source in the Source tab.
162
183
  const highlightedLineRange =
163
184
  sourceFocus?.location ?? resolvedSelection?.step?.sourceLocation ?? null;
164
- const sourceSnapshot = runData.sourceSnapshot;
185
+ const sourceSnapshot = runData?.sourceSnapshot ?? null;
186
+
187
+ function changeTab(nextTab: WorkflowRunTab) {
188
+ if (!tabControlled) {
189
+ setLocalTab(nextTab);
190
+ } else if (activeTab !== nextTab) {
191
+ onTabChange?.(nextTab);
192
+ }
193
+
194
+ if (nextTab !== 'jobs') {
195
+ setSourcePanelOpen(false);
196
+ setSourceFocus(null);
197
+ }
198
+ }
199
+
200
+ function handleTabChange(nextTab: string) {
201
+ if (!WORKFLOW_RUN_TABS.some((value) => value === nextTab)) return;
202
+ changeTab(nextTab as WorkflowRunTab);
203
+ }
204
+
165
205
  async function rerun(mode: WorkflowRunRerunMode) {
166
- if (!workspaceSlug || !projectSlug) {
206
+ if (!runData || !workspaceSlug || !projectSlug) {
167
207
  toast.error('Could not start re-run from this route.');
168
208
  return;
169
209
  }
@@ -185,12 +225,16 @@ function RunViewContent({
185
225
  }
186
226
  }
187
227
 
188
- function selectJob(jobId: string | undefined) {
228
+ function selectJob(jobId: string | undefined, source: JobGraphSelectionSource = 'pointer') {
229
+ if (source === 'pointer') changeTab('jobs');
189
230
  if (!selectionControlled) {
190
231
  setSelectedJobId(jobId);
191
232
  setSelectedJobExecutionId(undefined);
192
233
  return;
193
234
  }
235
+ // Keyboard roving focus is an interaction within the Summary graph. Keep that panel mounted
236
+ // and let JobGraphView own the visual selection so focus is not moved to a different tab.
237
+ if (source === 'keyboard') return;
194
238
 
195
239
  onSelectionChange?.(
196
240
  jobId ? {jobId, runAttempt: selection?.runAttempt} : {runAttempt: selection?.runAttempt},
@@ -235,20 +279,6 @@ function RunViewContent({
235
279
  });
236
280
  }
237
281
 
238
- function openWholeWorkflowSource() {
239
- setSourceFocus(null);
240
- lastSourceTriggerRef.current = sourceButtonRef.current;
241
- setSourcePanelOpen(true);
242
- }
243
-
244
- function toggleWholeWorkflowSource() {
245
- if (sourcePanelOpen && sourceFocus === null) {
246
- closeSourcePanel();
247
- return;
248
- }
249
- openWholeWorkflowSource();
250
- }
251
-
252
282
  function openStepSource(
253
283
  stepId: string,
254
284
  location: StepSourceLocation,
@@ -261,13 +291,11 @@ function RunViewContent({
261
291
 
262
292
  function closeSourcePanel() {
263
293
  const trigger = lastSourceTriggerRef.current;
264
- const fallbackTrigger = sourceButtonRef.current;
265
294
  setSourcePanelOpen(false);
266
- // Defer so focus lands after the panel unmounts; clear the focus only after
267
- // focusing so the opener button is still expanded (force-visible) on return.
295
+ // Defer so focus lands after the panel unmounts; clear the focus only after focusing so the
296
+ // opener button is still expanded (force-visible) on return.
268
297
  window.setTimeout(() => {
269
- const focusTarget = trigger?.isConnected ? trigger : fallbackTrigger;
270
- focusTarget?.focus();
298
+ if (trigger?.isConnected) trigger.focus();
271
299
  setSourceFocus(null);
272
300
  }, 0);
273
301
  }
@@ -280,38 +308,73 @@ function RunViewContent({
280
308
  });
281
309
  }
282
310
 
311
+ const fatalError = query.isError && runData === undefined;
312
+ const tabState = fatalError ? (
313
+ query.error instanceof ApiError && query.error.status === 404 ? (
314
+ <WorkflowRunNotFound />
315
+ ) : (
316
+ <QueryLoadError query={query} subject="workflow run" icon="pulseLine" />
317
+ )
318
+ ) : query.isPending || runData === undefined ? (
319
+ <WorkflowRunContentSkeleton />
320
+ ) : null;
321
+
283
322
  return (
284
323
  <>
285
- <div className="flex min-w-0 flex-1 flex-col">
286
- <WorkflowRunSummary
287
- workspaceSlug={workspaceSlug}
288
- projectSlug={projectSlug}
289
- run={runData}
290
- sourceAvailable={sourceAvailable}
291
- sourceOpen={sourcePanelOpen && sourceFocus === null}
292
- sourcePanelId={sourcePanelId}
293
- sourceButtonRef={sourceButtonRef}
294
- onSourceToggle={toggleWholeWorkflowSource}
295
- cancelling={cancelMutation.isPending}
296
- onCancel={cancelRun}
297
- rerunPending={rerunMutation.isPending}
298
- onRerun={(mode) => void rerun(mode)}
299
- latestAttempt={runData.latestAttempt}
324
+ <Tabs
325
+ value={activeTab}
326
+ onValueChange={handleTabChange}
327
+ className="flex min-w-0 flex-1 flex-col gap-0"
328
+ >
329
+ {runData ? (
330
+ <WorkflowRunSummary
331
+ workspaceSlug={workspaceSlug}
332
+ projectSlug={projectSlug}
333
+ run={runData}
334
+ cancelling={cancelMutation.isPending}
335
+ onCancel={cancelRun}
336
+ rerunPending={rerunMutation.isPending}
337
+ onRerun={(mode) => void rerun(mode)}
338
+ latestAttempt={runData.latestAttempt}
339
+ />
340
+ ) : (
341
+ <WorkflowRunSkeleton />
342
+ )}
343
+ {runData && query.isError ? <WorkflowRunStaleError query={query} /> : null}
344
+ <RunTabStrip
345
+ jobCount={runData?.jobs.length}
346
+ jobsFailed={runData?.jobs.filter((job) => job.status === 'failed').length}
347
+ annotationSummary={annotationSummary}
300
348
  />
301
- {query.isError ? <WorkflowRunStaleError query={query} /> : null}
302
- <div className="min-h-0 flex-1 overflow-auto bg-background-neutral-base p-16">
303
- <div className="mx-auto flex w-full max-w-[1120px] flex-col gap-16">
304
- <JobGraph
305
- run={runData}
306
- selectedJobId={selectedJob?.id}
307
- onSelectedJobChange={selectJob}
308
- />
309
- {selectedJob ? (
310
- <JobCard
349
+ <TabsContents className="min-h-0 flex-1 overflow-auto bg-background-neutral-background pb-24 pt-16">
350
+ <TabsContent
351
+ value="summary"
352
+ tabIndex={-1}
353
+ className="mx-auto flex w-full max-w-[1120px] flex-col gap-16 px-24 outline-none"
354
+ >
355
+ {runData ? (
356
+ <JobGraph
357
+ run={runData}
358
+ selectedJobId={selectedJob?.id}
359
+ onSelectedJobChange={selectJob}
360
+ />
361
+ ) : (
362
+ tabState
363
+ )}
364
+ </TabsContent>
365
+ <TabsContent
366
+ value="jobs"
367
+ tabIndex={-1}
368
+ className="mx-auto w-full max-w-[1120px] px-24 outline-none"
369
+ >
370
+ {runData ? (
371
+ <RunJobsList
372
+ jobs={runData.jobs}
373
+ selectedJobId={selectedJob?.id}
374
+ onSelectedJobChange={selectJob}
311
375
  workspaceSlug={workspaceSlug}
312
- job={selectedJob}
313
376
  selectedJobExecution={selectedJobExecution}
314
- selectedAttemptId={selectedJob.carriedOver ? undefined : selectedAttemptId}
377
+ selectedAttemptId={selectedAttemptId}
315
378
  onSelectedJobExecutionChange={selectJobExecution}
316
379
  onSelectedAttemptChange={selectionControlled ? selectAttempt : undefined}
317
380
  sourcePanelId={sourcePanelId}
@@ -319,10 +382,61 @@ function RunViewContent({
319
382
  focusedSourceStepId={sourceFocus?.stepId ?? null}
320
383
  onOpenStepSource={openStepSource}
321
384
  />
322
- ) : null}
323
- </div>
324
- </div>
325
- </div>
385
+ ) : (
386
+ tabState
387
+ )}
388
+ </TabsContent>
389
+ <TabsContent
390
+ value="annotations"
391
+ tabIndex={-1}
392
+ className="mx-auto flex w-full max-w-[1120px] flex-col gap-16 px-24 outline-none"
393
+ >
394
+ {runData ? (
395
+ <>
396
+ <RunAnnotationSummaryLine
397
+ summary={annotationSummary}
398
+ workspaceSlug={workspaceSlug}
399
+ projectSlug={projectSlug}
400
+ workflowRunId={runData.id}
401
+ search={selection}
402
+ />
403
+ <RunAnnotationsEmpty />
404
+ </>
405
+ ) : (
406
+ tabState
407
+ )}
408
+ </TabsContent>
409
+ <TabsContent
410
+ value="source"
411
+ keepMounted={sourceSnapshot !== null}
412
+ tabIndex={-1}
413
+ className="mx-auto flex min-h-full w-full max-w-[1120px] px-24 outline-none"
414
+ >
415
+ {runData ? (
416
+ sourceSnapshot ? (
417
+ <WorkflowSourceContent
418
+ source={sourceSnapshot}
419
+ highlightedLineRange={highlightedLineRange}
420
+ scrollHighlightedIntoView
421
+ />
422
+ ) : (
423
+ <EmptyState
424
+ className="min-h-160 w-full"
425
+ icon="fileDamageLine"
426
+ title="Source snapshot unavailable"
427
+ description={
428
+ runData.isTemporary
429
+ ? 'Temporary runs do not capture workflow source.'
430
+ : 'This run was created before workflow source snapshots were captured.'
431
+ }
432
+ />
433
+ )
434
+ ) : (
435
+ tabState
436
+ )}
437
+ </TabsContent>
438
+ </TabsContents>
439
+ </Tabs>
326
440
  <WorkflowSourcePanel
327
441
  id={sourcePanelId}
328
442
  source={sourceSnapshot}
@@ -1 +1,2 @@
1
+ export * from './workflow-source-content.js';
1
2
  export * from './workflow-source-panel.js';
@@ -0,0 +1,84 @@
1
+ import {
2
+ CodeBlock,
3
+ CodeBlockBody,
4
+ CodeBlockContent,
5
+ CodeBlockCopyButton,
6
+ CodeBlockFilename,
7
+ CodeBlockFiles,
8
+ CodeBlockHeader,
9
+ type CodeBlockHighlightedLineRange,
10
+ CodeBlockItem,
11
+ } from '@shipfox/react-ui/code-block';
12
+ import {cn} from '@shipfox/react-ui/utils';
13
+ import type {ReactNode} from 'react';
14
+ import type {WorkflowSourceSnapshot} from '#core/workflow-run.js';
15
+
16
+ const WORKFLOW_SOURCE_FILENAME = 'workflow.yaml';
17
+ const WORKFLOW_SOURCE_CODE_THEMES = {
18
+ light: 'vitesse-dark',
19
+ dark: 'vitesse-dark',
20
+ };
21
+
22
+ export interface WorkflowSourceContentProps {
23
+ source: WorkflowSourceSnapshot;
24
+ highlightedLineRange?: CodeBlockHighlightedLineRange | null | undefined;
25
+ scrollHighlightedIntoView?: boolean | undefined;
26
+ headerAction?: ReactNode;
27
+ className?: string | undefined;
28
+ }
29
+
30
+ export function WorkflowSourceContent({
31
+ source,
32
+ highlightedLineRange,
33
+ scrollHighlightedIntoView,
34
+ headerAction,
35
+ className,
36
+ }: WorkflowSourceContentProps) {
37
+ const data = [
38
+ {
39
+ language: 'yaml',
40
+ filename: WORKFLOW_SOURCE_FILENAME,
41
+ code: source.content,
42
+ },
43
+ ];
44
+
45
+ return (
46
+ <CodeBlock
47
+ data={data}
48
+ className={cn(
49
+ 'flex size-full flex-col rounded-none bg-background-contrast-base shadow-none',
50
+ className,
51
+ )}
52
+ >
53
+ <CodeBlockHeader className="shrink-0 border-b border-border-contrast-base bg-background-contrast-base">
54
+ <CodeBlockFiles>
55
+ {(item) => <CodeBlockFilename value={item.filename}>{item.filename}</CodeBlockFilename>}
56
+ </CodeBlockFiles>
57
+ <CodeBlockCopyButton />
58
+ {headerAction}
59
+ </CodeBlockHeader>
60
+ <CodeBlockBody className="flex min-h-0 flex-1 overflow-auto scrollbar">
61
+ {(item) => (
62
+ <CodeBlockItem
63
+ value={item.filename}
64
+ className={cn(
65
+ 'min-h-full px-0 pb-0',
66
+ '[&>div]:rounded-none [&>div]:border-0 [&>div]:bg-background-contrast-base [&>div]:dark:bg-background-contrast-base',
67
+ '[&_code]:!text-sm [&_code]:!text-foreground-neutral-on-inverted [&_.line]:!text-sm [&_.line]:before:!text-sm [&_.line]:before:!text-foreground-neutral-muted',
68
+ )}
69
+ >
70
+ <CodeBlockContent
71
+ language="yaml"
72
+ themes={WORKFLOW_SOURCE_CODE_THEMES}
73
+ syntaxHighlighting
74
+ highlightedLineRange={highlightedLineRange}
75
+ scrollHighlightedIntoView={scrollHighlightedIntoView}
76
+ >
77
+ {item.code}
78
+ </CodeBlockContent>
79
+ </CodeBlockItem>
80
+ )}
81
+ </CodeBlockBody>
82
+ </CodeBlock>
83
+ );
84
+ }
@@ -1,24 +1,9 @@
1
1
  import {Button} from '@shipfox/react-ui/button';
2
- import {
3
- CodeBlock,
4
- CodeBlockBody,
5
- CodeBlockContent,
6
- CodeBlockCopyButton,
7
- CodeBlockFilename,
8
- CodeBlockFiles,
9
- CodeBlockHeader,
10
- type CodeBlockHighlightedLineRange,
11
- CodeBlockItem,
12
- } from '@shipfox/react-ui/code-block';
2
+ import type {CodeBlockHighlightedLineRange} from '@shipfox/react-ui/code-block';
13
3
  import {Sheet, SheetClose, SheetContent, SheetTitle} from '@shipfox/react-ui/sheet';
14
4
  import {cn} from '@shipfox/react-ui/utils';
15
5
  import type {WorkflowSourceSnapshot} from '#core/workflow-run.js';
16
-
17
- const WORKFLOW_SOURCE_FILENAME = 'workflow.yaml';
18
- const WORKFLOW_SOURCE_CODE_THEMES = {
19
- light: 'vitesse-dark',
20
- dark: 'vitesse-dark',
21
- };
6
+ import {WorkflowSourceContent} from './workflow-source-content.js';
22
7
 
23
8
  export interface WorkflowSourcePanelProps {
24
9
  id: string;
@@ -61,76 +46,24 @@ export function WorkflowSourcePanel({
61
46
  )}
62
47
  >
63
48
  <SheetTitle className="sr-only">Workflow source</SheetTitle>
64
- <WorkflowSourcePanelContent
49
+ <WorkflowSourceContent
65
50
  source={source}
66
51
  highlightedLineRange={highlightedLineRange}
67
52
  scrollHighlightedIntoView={scrollHighlightedIntoView}
53
+ headerAction={
54
+ <SheetClose asChild>
55
+ <Button
56
+ type="button"
57
+ variant="transparentMuted"
58
+ size="sm"
59
+ iconLeft="close"
60
+ aria-label="Close source"
61
+ />
62
+ </SheetClose>
63
+ }
68
64
  />
69
65
  </SheetContent>
70
66
  ) : null}
71
67
  </Sheet>
72
68
  );
73
69
  }
74
-
75
- function WorkflowSourcePanelContent({
76
- source,
77
- highlightedLineRange,
78
- scrollHighlightedIntoView,
79
- }: {
80
- source: WorkflowSourceSnapshot;
81
- highlightedLineRange: CodeBlockHighlightedLineRange | null | undefined;
82
- scrollHighlightedIntoView: boolean | undefined;
83
- }) {
84
- const data = [
85
- {
86
- language: 'yaml',
87
- filename: WORKFLOW_SOURCE_FILENAME,
88
- code: source.content,
89
- },
90
- ];
91
-
92
- return (
93
- <CodeBlock
94
- data={data}
95
- className="flex size-full flex-col rounded-none bg-background-contrast-base shadow-none"
96
- >
97
- <CodeBlockHeader className="shrink-0 border-b border-border-contrast-base bg-background-contrast-base">
98
- <CodeBlockFiles>
99
- {(item) => <CodeBlockFilename value={item.filename}>{item.filename}</CodeBlockFilename>}
100
- </CodeBlockFiles>
101
- <CodeBlockCopyButton />
102
- <SheetClose asChild>
103
- <Button
104
- type="button"
105
- variant="transparentMuted"
106
- size="sm"
107
- iconLeft="close"
108
- aria-label="Close source"
109
- />
110
- </SheetClose>
111
- </CodeBlockHeader>
112
- <CodeBlockBody className="flex min-h-0 flex-1 overflow-auto scrollbar">
113
- {(item) => (
114
- <CodeBlockItem
115
- value={item.filename}
116
- className={cn(
117
- 'min-h-full px-0 pb-0',
118
- '[&>div]:rounded-none [&>div]:border-0 [&>div]:bg-background-contrast-base [&>div]:dark:bg-background-contrast-base',
119
- '[&_code]:!text-sm [&_code]:!text-foreground-neutral-on-inverted [&_.line]:!text-sm [&_.line]:before:!text-sm [&_.line]:before:!text-foreground-neutral-muted',
120
- )}
121
- >
122
- <CodeBlockContent
123
- language="yaml"
124
- themes={WORKFLOW_SOURCE_CODE_THEMES}
125
- syntaxHighlighting
126
- highlightedLineRange={highlightedLineRange}
127
- scrollHighlightedIntoView={scrollHighlightedIntoView}
128
- >
129
- {item.code}
130
- </CodeBlockContent>
131
- </CodeBlockItem>
132
- )}
133
- </CodeBlockBody>
134
- </CodeBlock>
135
- );
136
- }
@@ -4,6 +4,8 @@ export type StepErrorReason =
4
4
  | 'checkout_failed'
5
5
  | 'checkout_auth_failed'
6
6
  | 'checkout_unavailable'
7
+ | 'checkout_path_invalid'
8
+ | 'checkout_destination_occupied'
7
9
  | 'git_unavailable'
8
10
  | 'workspace_prep_failed'
9
11
  | 'setup_aborted'
@@ -23,6 +25,8 @@ export const STEP_ERROR_REASONS = new Set<StepErrorReason>([
23
25
  'checkout_failed',
24
26
  'checkout_auth_failed',
25
27
  'checkout_unavailable',
28
+ 'checkout_path_invalid',
29
+ 'checkout_destination_occupied',
26
30
  'git_unavailable',
27
31
  'workspace_prep_failed',
28
32
  'setup_aborted',
@@ -0,0 +1,8 @@
1
+ /** Counts used by the run detail tab strip before the annotations query owns the data. */
2
+ export interface RunAnnotationSummary {
3
+ total: number;
4
+ error: number;
5
+ warning: number;
6
+ info: number;
7
+ success: number;
8
+ }