@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
@@ -2,37 +2,37 @@ import {ApiError} from '@shipfox/client-api';
2
2
  import {QueryLoadError} from '@shipfox/client-ui';
3
3
  import {EmptyState} from '@shipfox/react-ui/empty-state';
4
4
  import {RelativeTimeProvider} from '@shipfox/react-ui/relative-time';
5
- import {Tabs, TabsContent, TabsContents} from '@shipfox/react-ui/tabs';
5
+ import {
6
+ Select,
7
+ SelectContent,
8
+ SelectItem,
9
+ SelectTrigger,
10
+ SelectValue,
11
+ } from '@shipfox/react-ui/select';
6
12
  import {toast} from '@shipfox/react-ui/toast';
7
13
  import {useNavigate} from '@tanstack/react-router';
8
- import {useEffect, useId, useRef, useState} from 'react';
9
- import {
10
- type JobExecution,
11
- resolveJobExecution,
12
- type StepSourceLocation,
13
- type WorkflowRunRerunMode,
14
- } from '#core/workflow-run.js';
14
+ import {type ReactNode, useEffect} from 'react';
15
+ import type {Job, WorkflowRunRerunMode} from '#core/workflow-run.js';
15
16
  import type {RunAnnotationSummary} from '#core/workflow-run-tabs.js';
16
- import {
17
- type WorkflowRunSelectionInput,
18
- withoutWorkflowRunSelectionSearch,
19
- } from '#core/workflow-run-url-state.js';
17
+ import {withoutWorkflowRunSelectionSearch} from '#core/workflow-run-url-state.js';
20
18
  import {
21
19
  useCancelWorkflowRunMutation,
22
20
  useRerunWorkflowRunMutation,
23
21
  useWorkflowRunAttemptQuery,
24
22
  } from '#hooks/api/workflow-runs.js';
25
- import {WORKFLOW_RUN_TABS, type WorkflowRunTab} from '#routes/inputs.js';
23
+ import {
24
+ type WorkflowJobSearch,
25
+ type WorkflowRunsSearch,
26
+ type WorkflowRunTab,
27
+ workflowJobSearchParams,
28
+ workflowRunSearchParams,
29
+ } from '#routes/inputs.js';
26
30
  import {JobGraph} from '../job-graph/index.js';
27
31
  import type {JobGraphSelectionSource} from '../job-graph/types.js';
28
32
  import {WorkflowRunSummary} from '../workflow-run-summary/index.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';
33
+ import {RunAnnotationSummaryLine, RunAnnotationsEmpty} from '../workflow-run-tabs/index.js';
34
+ import {WorkflowSourceContent} from '../workflow-source-panel/index.js';
35
+ import {RunWorkspaceNav} from './run-workspace-nav.js';
36
36
  import {resolveWorkflowRunSelection} from './workflow-run-selection.js';
37
37
  import {
38
38
  WorkflowRunContentSkeleton,
@@ -41,39 +41,43 @@ import {
41
41
  WorkflowRunStaleError,
42
42
  } from './workflow-run-states.js';
43
43
 
44
- interface WorkflowSourceFocus {
45
- stepId: string;
46
- location: StepSourceLocation;
47
- }
44
+ type RunWorkspaceSection = Exclude<WorkflowRunTab, 'jobs'>;
48
45
 
49
46
  export interface WorkflowRunViewProps {
50
47
  projectId: string;
51
48
  workspaceSlug?: string | undefined;
52
49
  projectSlug?: string | undefined;
53
50
  workflowRunId?: string | undefined;
54
- selection?: WorkflowRunSelectionInput | undefined;
55
- onSelectionChange?: ((selection: WorkflowRunSelectionInput) => void) | undefined;
51
+ runAttempt?: number | undefined;
52
+ selection?: WorkflowRunsSearch | undefined;
56
53
  tab?: WorkflowRunTab | undefined;
57
- onTabChange?: ((tab: WorkflowRunTab) => void) | undefined;
58
54
  annotationSummary?: RunAnnotationSummary | undefined;
55
+ activeJobId?: string | undefined;
56
+ jobSearch?: WorkflowJobSearch | undefined;
57
+ jobContent?: ReactNode | undefined;
59
58
  }
60
59
 
61
60
  /**
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.
61
+ * The persistent run workspace. Run-level sections and dedicated job routes share the same
62
+ * header and job index, while the all-jobs Summary always opens on the dependency graph.
64
63
  */
65
64
  export function WorkflowRunView({
66
65
  projectId,
67
66
  workspaceSlug,
68
67
  projectSlug,
69
68
  workflowRunId,
69
+ runAttempt,
70
70
  selection,
71
- onSelectionChange,
72
71
  tab,
73
- onTabChange,
74
72
  annotationSummary,
73
+ activeJobId,
74
+ jobSearch,
75
+ jobContent,
75
76
  }: WorkflowRunViewProps) {
76
- const runQuery = useWorkflowRunAttemptQuery({workflowRunId, runAttempt: selection?.runAttempt});
77
+ const runQuery = useWorkflowRunAttemptQuery({
78
+ workflowRunId,
79
+ runAttempt: selection?.runAttempt ?? runAttempt,
80
+ });
77
81
  const rerunMutation = useRerunWorkflowRunMutation(projectId);
78
82
 
79
83
  return (
@@ -84,11 +88,13 @@ export function WorkflowRunView({
84
88
  projectSlug={projectSlug}
85
89
  query={runQuery}
86
90
  rerunMutation={rerunMutation}
91
+ runAttempt={runAttempt}
87
92
  selection={selection}
88
- onSelectionChange={onSelectionChange}
89
93
  tab={tab}
90
- onTabChange={onTabChange}
91
94
  annotationSummary={annotationSummary}
95
+ activeJobId={activeJobId}
96
+ jobSearch={jobSearch}
97
+ jobContent={jobContent}
92
98
  />
93
99
  </div>
94
100
  </RelativeTimeProvider>
@@ -100,107 +106,80 @@ function RunViewContent({
100
106
  projectSlug,
101
107
  query,
102
108
  rerunMutation,
109
+ runAttempt,
103
110
  selection,
104
- onSelectionChange,
105
111
  tab,
106
- onTabChange,
107
112
  annotationSummary,
113
+ activeJobId,
114
+ jobSearch,
115
+ jobContent,
108
116
  }: {
109
117
  workspaceSlug: string | undefined;
110
118
  projectSlug: string | undefined;
111
119
  query: ReturnType<typeof useWorkflowRunAttemptQuery>;
112
120
  rerunMutation: ReturnType<typeof useRerunWorkflowRunMutation>;
113
- selection: WorkflowRunSelectionInput | undefined;
114
- onSelectionChange: ((selection: WorkflowRunSelectionInput) => void) | undefined;
121
+ runAttempt: number | undefined;
122
+ selection: WorkflowRunsSearch | undefined;
115
123
  tab: WorkflowRunTab | undefined;
116
- onTabChange: ((tab: WorkflowRunTab) => void) | undefined;
117
124
  annotationSummary: RunAnnotationSummary | undefined;
125
+ activeJobId: string | undefined;
126
+ jobSearch: WorkflowJobSearch | undefined;
127
+ jobContent: ReactNode | undefined;
118
128
  }) {
119
129
  const navigate = useNavigate();
120
- const [selectedJobId, setSelectedJobId] = useState<string | undefined>();
121
- const [selectedJobExecutionId, setSelectedJobExecutionId] = useState<string | undefined>();
122
- const [localTab, setLocalTab] = useState<WorkflowRunTab>(tab ?? 'summary');
123
- const [sourcePanelOpen, setSourcePanelOpen] = useState(false);
124
- const [sourceFocus, setSourceFocus] = useState<WorkflowSourceFocus | null>(null);
125
- const sourcePanelId = useId();
126
- // The step Source button that last opened the panel, so Escape / Close returns focus to it.
127
- const lastSourceTriggerRef = useRef<HTMLButtonElement | null>(null);
128
- const selectionControlled = selection !== undefined;
129
- const tabControlled = onTabChange !== undefined;
130
- const activeTab = tabControlled ? (tab ?? 'summary') : localTab;
131
- const sourceAvailable =
132
- query.data?.sourceSnapshot !== null && query.data?.sourceSnapshot !== undefined;
133
- const cancelMutation = useCancelWorkflowRunMutation(query.data);
134
-
135
- useEffect(() => {
136
- if (!sourceAvailable) {
137
- setSourcePanelOpen(false);
138
- setSourceFocus(null);
139
- }
140
- }, [sourceAvailable]);
141
-
142
- // If a refetch drops the focused step or its location, close the panel so it never points at
143
- // an unmounted Source button.
144
- useEffect(() => {
145
- if (!sourceFocus) return;
146
- const stillLocated = query.data?.jobs.some((job) =>
147
- job.jobExecutions.some((execution) =>
148
- execution.steps.some((step) => step.id === sourceFocus.stepId && step.sourceLocation),
149
- ),
150
- );
151
- if (!stillLocated) {
152
- setSourceFocus(null);
153
- setSourcePanelOpen(false);
154
- }
155
- }, [sourceFocus, query.data]);
156
-
157
130
  const runData = query.data;
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
- );
168
- const selectedJob = selectionControlled
169
- ? hasExplicitJobSelection
170
- ? resolvedSelection?.job
171
- : undefined
172
- : (runData?.jobs.find((job) => job.id === selectedJobId) ?? runData?.jobs.at(0));
173
- const selectedJobExecution = selectionControlled
174
- ? resolvedSelection?.jobExecution
175
- : selectedJob
176
- ? resolveJobExecution(selectedJob, selectedJobExecutionId)
177
- : undefined;
178
- const selectedAttemptId = selectionControlled
179
- ? (resolvedSelection?.selectedAttemptId ?? null)
180
- : undefined;
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.
183
- const highlightedLineRange =
184
- sourceFocus?.location ?? resolvedSelection?.step?.sourceLocation ?? null;
131
+ const activeSection = runWorkspaceSection(tab);
132
+ const cancelMutation = useCancelWorkflowRunMutation(runData);
185
133
  const sourceSnapshot = runData?.sourceSnapshot ?? null;
134
+ const resolvedSelection =
135
+ runData && selection ? resolveWorkflowRunSelection({run: runData, selection}) : undefined;
136
+ const highlightedLineRange = resolvedSelection?.step?.sourceLocation ?? null;
186
137
 
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);
138
+ useEffect(() => {
139
+ const hasLegacyJobSelection = Boolean(
140
+ selection?.jobId ||
141
+ selection?.jobExecutionId ||
142
+ selection?.stepId ||
143
+ selection?.stepAttemptId,
144
+ );
145
+ if (
146
+ activeJobId ||
147
+ activeSection !== 'summary' ||
148
+ !hasLegacyJobSelection ||
149
+ !resolvedSelection?.job ||
150
+ !runData ||
151
+ !workspaceSlug ||
152
+ !projectSlug
153
+ ) {
154
+ return;
197
155
  }
198
- }
199
156
 
200
- function handleTabChange(nextTab: string) {
201
- if (!WORKFLOW_RUN_TABS.some((value) => value === nextTab)) return;
202
- changeTab(nextTab as WorkflowRunTab);
203
- }
157
+ void navigate({
158
+ to: '/w/$workspaceSlug/p/$projectSlug/runs/$workflowRunId/jobs/$jobId',
159
+ params: {
160
+ workspaceSlug,
161
+ projectSlug,
162
+ workflowRunId: runData.id,
163
+ jobId: resolvedSelection.job.id,
164
+ },
165
+ search: workflowJobSearchParams({
166
+ jobExecutionId: selection?.jobExecutionId,
167
+ stepId: selection?.stepId,
168
+ stepAttemptId: selection?.stepAttemptId,
169
+ runAttempt: selection?.runAttempt,
170
+ }) as never,
171
+ replace: true,
172
+ });
173
+ }, [
174
+ activeJobId,
175
+ activeSection,
176
+ navigate,
177
+ projectSlug,
178
+ resolvedSelection?.job,
179
+ runData,
180
+ selection,
181
+ workspaceSlug,
182
+ ]);
204
183
 
205
184
  async function rerun(mode: WorkflowRunRerunMode) {
206
185
  if (!runData || !workspaceSlug || !projectSlug) {
@@ -212,11 +191,7 @@ function RunViewContent({
212
191
  toast.success('Re-run started');
213
192
  await navigate({
214
193
  to: '/w/$workspaceSlug/p/$projectSlug/runs/$workflowRunId',
215
- params: {
216
- workspaceSlug,
217
- projectSlug,
218
- workflowRunId: run.id,
219
- },
194
+ params: {workspaceSlug, projectSlug, workflowRunId: run.id},
220
195
  search: ((previous: Record<string, unknown>) =>
221
196
  withoutWorkflowRunSelectionSearch(previous)) as never,
222
197
  });
@@ -225,91 +200,42 @@ function RunViewContent({
225
200
  }
226
201
  }
227
202
 
228
- function selectJob(jobId: string | undefined, source: JobGraphSelectionSource = 'pointer') {
229
- if (source === 'pointer') changeTab('jobs');
230
- if (!selectionControlled) {
231
- setSelectedJobId(jobId);
232
- setSelectedJobExecutionId(undefined);
233
- return;
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;
238
-
239
- onSelectionChange?.(
240
- jobId ? {jobId, runAttempt: selection?.runAttempt} : {runAttempt: selection?.runAttempt},
241
- );
242
- }
203
+ function selectGraphJob(jobId: string | undefined, source: JobGraphSelectionSource = 'pointer') {
204
+ if (source !== 'pointer' || !jobId || !runData || !workspaceSlug || !projectSlug) return;
243
205
 
244
- function selectJobExecution(jobExecutionId: string | undefined) {
245
- if (!selectionControlled) {
246
- setSelectedJobExecutionId(jobExecutionId);
247
- return;
248
- }
249
- if (!selectedJob) return;
250
-
251
- onSelectionChange?.({
252
- jobId: selectedJob.id,
253
- jobExecutionId,
254
- runAttempt: selection?.runAttempt,
206
+ void navigate({
207
+ to: '/w/$workspaceSlug/p/$projectSlug/runs/$workflowRunId/jobs/$jobId',
208
+ params: {workspaceSlug, projectSlug, workflowRunId: runData.id, jobId},
209
+ search: workflowJobSearchParams({
210
+ runAttempt: selection?.runAttempt ?? runAttempt ?? runData.runAttempt.attempt,
211
+ }) as never,
255
212
  });
256
213
  }
257
214
 
258
- function selectAttempt(attemptId: string | undefined) {
259
- if (!selectionControlled || !selectedJob || !selectedJobExecution) return;
215
+ function selectAnnotationJob(jobId: string | undefined) {
216
+ if (!runData || !workspaceSlug || !projectSlug) return;
217
+ const nextSearch: WorkflowRunsSearch = {...selection, tab: 'annotations'};
218
+ if (jobId) nextSearch.jobId = jobId;
219
+ else delete nextSearch.jobId;
220
+ delete nextSearch.jobExecutionId;
221
+ delete nextSearch.stepId;
222
+ delete nextSearch.stepAttemptId;
260
223
 
261
- if (!attemptId) {
262
- onSelectionChange?.({
263
- jobId: selectedJob.id,
264
- jobExecutionId: selectedJobExecution.id,
265
- runAttempt: selection?.runAttempt,
266
- });
267
- return;
268
- }
269
-
270
- const match = findAttemptSelection(selectedJobExecution, attemptId);
271
- if (!match) return;
272
-
273
- onSelectionChange?.({
274
- jobId: selectedJob.id,
275
- jobExecutionId: selectedJobExecution.id,
276
- stepId: match.stepId,
277
- stepAttemptId: match.attemptId,
278
- runAttempt: selection?.runAttempt,
224
+ void navigate({
225
+ to: '/w/$workspaceSlug/p/$projectSlug/runs/$workflowRunId',
226
+ params: {workspaceSlug, projectSlug, workflowRunId: runData.id},
227
+ search: workflowRunSearchParams(nextSearch, nextSearch) as never,
279
228
  });
280
229
  }
281
230
 
282
- function openStepSource(
283
- stepId: string,
284
- location: StepSourceLocation,
285
- trigger: HTMLButtonElement | null,
286
- ) {
287
- setSourceFocus({stepId, location});
288
- lastSourceTriggerRef.current = trigger;
289
- setSourcePanelOpen(true);
290
- }
291
-
292
- function closeSourcePanel() {
293
- const trigger = lastSourceTriggerRef.current;
294
- setSourcePanelOpen(false);
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.
297
- window.setTimeout(() => {
298
- if (trigger?.isConnected) trigger.focus();
299
- setSourceFocus(null);
300
- }, 0);
301
- }
302
-
303
231
  function cancelRun() {
304
232
  cancelMutation.mutate(undefined, {
305
- onError: (error) => {
306
- toast.error(cancelErrorMessage(error));
307
- },
233
+ onError: (error) => toast.error(cancelErrorMessage(error)),
308
234
  });
309
235
  }
310
236
 
311
237
  const fatalError = query.isError && runData === undefined;
312
- const tabState = fatalError ? (
238
+ const loadingOrError = fatalError ? (
313
239
  query.error instanceof ApiError && query.error.status === 404 ? (
314
240
  <WorkflowRunNotFound />
315
241
  ) : (
@@ -320,146 +246,229 @@ function RunViewContent({
320
246
  ) : null;
321
247
 
322
248
  return (
323
- <>
324
- <Tabs
325
- value={activeTab}
326
- onValueChange={handleTabChange}
327
- className="flex min-w-0 flex-1 flex-col gap-0"
249
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col">
250
+ {runData ? (
251
+ <WorkflowRunSummary
252
+ workspaceSlug={workspaceSlug}
253
+ projectSlug={projectSlug}
254
+ run={runData}
255
+ cancelling={cancelMutation.isPending}
256
+ onCancel={cancelRun}
257
+ rerunPending={rerunMutation.isPending}
258
+ onRerun={(mode) => void rerun(mode)}
259
+ latestAttempt={runData.latestAttempt}
260
+ />
261
+ ) : (
262
+ <WorkflowRunSkeleton />
263
+ )}
264
+ {runData && query.isError ? <WorkflowRunStaleError query={query} /> : null}
265
+
266
+ <div
267
+ data-run-workspace-layout
268
+ className="flex min-h-0 min-w-0 flex-1 flex-col border-t border-border-neutral-base min-[768px]:flex-row"
328
269
  >
329
- {runData ? (
330
- <WorkflowRunSummary
270
+ {runData && workspaceSlug && projectSlug ? (
271
+ <RunWorkspaceNav
331
272
  workspaceSlug={workspaceSlug}
332
273
  projectSlug={projectSlug}
333
274
  run={runData}
334
- cancelling={cancelMutation.isPending}
335
- onCancel={cancelRun}
336
- rerunPending={rerunMutation.isPending}
337
- onRerun={(mode) => void rerun(mode)}
338
- latestAttempt={runData.latestAttempt}
275
+ activeSection={activeSection}
276
+ currentJobId={activeJobId}
277
+ jobSearch={jobSearch}
278
+ annotationSummary={annotationSummary}
339
279
  />
340
280
  ) : (
341
- <WorkflowRunSkeleton />
281
+ <RunWorkspaceNavSkeleton />
342
282
  )}
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}
348
- />
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}
375
- workspaceSlug={workspaceSlug}
376
- selectedJobExecution={selectedJobExecution}
377
- selectedAttemptId={selectedAttemptId}
378
- onSelectedJobExecutionChange={selectJobExecution}
379
- onSelectedAttemptChange={selectionControlled ? selectAttempt : undefined}
380
- sourcePanelId={sourcePanelId}
381
- sourceAvailable={sourceAvailable}
382
- focusedSourceStepId={sourceFocus?.stepId ?? null}
383
- onOpenStepSource={openStepSource}
384
- />
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>
440
- <WorkflowSourcePanel
441
- id={sourcePanelId}
442
- source={sourceSnapshot}
443
- open={sourcePanelOpen && sourceAvailable}
444
- onClose={closeSourcePanel}
445
- highlightedLineRange={highlightedLineRange}
446
- scrollHighlightedIntoView
447
- />
448
- </>
283
+
284
+ <div
285
+ data-run-workspace-content
286
+ className="flex min-h-0 min-w-0 flex-1 flex-col bg-background-neutral-base"
287
+ >
288
+ {runData && jobContent ? (
289
+ jobContent
290
+ ) : runData ? (
291
+ <RunSectionContent
292
+ section={activeSection}
293
+ run={runData}
294
+ annotationSummary={annotationSummary}
295
+ workspaceSlug={workspaceSlug}
296
+ projectSlug={projectSlug}
297
+ selection={selection}
298
+ selectedJobId={selection?.jobId}
299
+ onSelectGraphJob={selectGraphJob}
300
+ onSelectAnnotationJob={selectAnnotationJob}
301
+ sourceSnapshot={sourceSnapshot}
302
+ highlightedLineRange={highlightedLineRange}
303
+ />
304
+ ) : (
305
+ <div className="min-h-0 flex-1 overflow-auto p-24">{loadingOrError}</div>
306
+ )}
307
+ </div>
308
+ </div>
309
+ </div>
449
310
  );
450
311
  }
451
312
 
313
+ function RunSectionContent({
314
+ section,
315
+ run,
316
+ annotationSummary,
317
+ workspaceSlug,
318
+ projectSlug,
319
+ selection,
320
+ selectedJobId,
321
+ onSelectGraphJob,
322
+ onSelectAnnotationJob,
323
+ sourceSnapshot,
324
+ highlightedLineRange,
325
+ }: {
326
+ section: RunWorkspaceSection;
327
+ run: NonNullable<ReturnType<typeof useWorkflowRunAttemptQuery>['data']>;
328
+ annotationSummary: RunAnnotationSummary | undefined;
329
+ workspaceSlug: string | undefined;
330
+ projectSlug: string | undefined;
331
+ selection: WorkflowRunsSearch | undefined;
332
+ selectedJobId: string | undefined;
333
+ onSelectGraphJob: (jobId: string | undefined, source?: JobGraphSelectionSource) => void;
334
+ onSelectAnnotationJob: (jobId: string | undefined) => void;
335
+ sourceSnapshot: NonNullable<
336
+ ReturnType<typeof useWorkflowRunAttemptQuery>['data']
337
+ >['sourceSnapshot'];
338
+ highlightedLineRange: NonNullable<
339
+ ReturnType<typeof useWorkflowRunAttemptQuery>['data']
340
+ >['jobs'][number]['jobExecutions'][number]['steps'][number]['sourceLocation'];
341
+ }) {
342
+ if (section === 'summary') {
343
+ return (
344
+ <section aria-label="All jobs summary" className="min-h-0 flex-1 overflow-auto pb-24 pt-16">
345
+ <div className="mx-auto flex w-full max-w-[1120px] flex-col gap-16 px-24">
346
+ <JobGraph
347
+ run={run}
348
+ selectedJobId={selectedJobId}
349
+ onSelectedJobChange={onSelectGraphJob}
350
+ className="min-h-160 overflow-hidden"
351
+ />
352
+ </div>
353
+ </section>
354
+ );
355
+ }
356
+
357
+ if (section === 'annotations') {
358
+ const selectedJob = run.jobs.find((job) => job.id === selectedJobId);
359
+ return (
360
+ <section aria-label="Run annotations" className="min-h-0 flex-1 overflow-auto pb-24 pt-16">
361
+ <div className="mx-auto flex w-full max-w-[1120px] flex-col gap-16 px-24">
362
+ <div className="flex flex-wrap items-center justify-between gap-8">
363
+ <RunAnnotationSummaryLine
364
+ summary={annotationSummary}
365
+ workspaceSlug={workspaceSlug}
366
+ projectSlug={projectSlug}
367
+ workflowRunId={run.id}
368
+ search={selection}
369
+ />
370
+ <AnnotationJobFilter
371
+ jobs={run.jobs}
372
+ selectedJobId={selectedJob?.id}
373
+ onSelect={onSelectAnnotationJob}
374
+ />
375
+ </div>
376
+ <RunAnnotationsEmpty jobName={selectedJob?.displayName} />
377
+ </div>
378
+ </section>
379
+ );
380
+ }
381
+
382
+ return (
383
+ <section aria-label="Workflow source" className="min-h-0 flex-1 overflow-auto pb-24 pt-16">
384
+ <div className="mx-auto flex min-h-full w-full max-w-[1120px] px-24">
385
+ {sourceSnapshot ? (
386
+ <WorkflowSourceContent
387
+ source={sourceSnapshot}
388
+ highlightedLineRange={highlightedLineRange}
389
+ scrollHighlightedIntoView
390
+ />
391
+ ) : (
392
+ <EmptyState
393
+ className="min-h-160 w-full"
394
+ icon="fileDamageLine"
395
+ title="Source snapshot unavailable"
396
+ description={
397
+ run.isTemporary
398
+ ? 'Temporary runs do not capture workflow source.'
399
+ : 'This run was created before workflow source snapshots were captured.'
400
+ }
401
+ />
402
+ )}
403
+ </div>
404
+ </section>
405
+ );
406
+ }
407
+
408
+ const ALL_ANNOTATION_JOBS = 'all-jobs';
409
+
410
+ function AnnotationJobFilter({
411
+ jobs,
412
+ selectedJobId,
413
+ onSelect,
414
+ }: {
415
+ jobs: Job[];
416
+ selectedJobId: string | undefined;
417
+ onSelect: (jobId: string | undefined) => void;
418
+ }) {
419
+ const sortedJobs = [...jobs].sort(
420
+ (left, right) => left.position - right.position || left.id.localeCompare(right.id),
421
+ );
422
+
423
+ return (
424
+ <Select
425
+ value={selectedJobId ?? ALL_ANNOTATION_JOBS}
426
+ onValueChange={(value) => onSelect(value === ALL_ANNOTATION_JOBS ? undefined : value)}
427
+ >
428
+ <SelectTrigger
429
+ size="small"
430
+ aria-label="Filter annotations by job"
431
+ className="w-full min-[480px]:w-240"
432
+ >
433
+ <SelectValue />
434
+ </SelectTrigger>
435
+ <SelectContent align="end">
436
+ <SelectItem value={ALL_ANNOTATION_JOBS}>All jobs</SelectItem>
437
+ {sortedJobs.map((job) => (
438
+ <SelectItem key={job.id} value={job.id}>
439
+ {job.displayName}
440
+ </SelectItem>
441
+ ))}
442
+ </SelectContent>
443
+ </Select>
444
+ );
445
+ }
446
+
447
+ function RunWorkspaceNavSkeleton() {
448
+ return (
449
+ <aside
450
+ aria-label="Loading run navigation"
451
+ className="hidden w-240 shrink-0 border-r border-border-neutral-base bg-background-neutral-background p-12 min-[768px]:block"
452
+ >
453
+ <div className="flex flex-col gap-16">
454
+ <div className="h-32 rounded-4 bg-background-components-subtle" />
455
+ <div className="flex flex-col gap-6">
456
+ <div className="h-16 w-64 rounded-4 bg-background-components-subtle" />
457
+ <div className="h-32 rounded-4 bg-background-components-subtle" />
458
+ <div className="h-32 rounded-4 bg-background-components-subtle" />
459
+ </div>
460
+ </div>
461
+ </aside>
462
+ );
463
+ }
464
+
465
+ function runWorkspaceSection(tab: WorkflowRunTab | undefined): RunWorkspaceSection {
466
+ return tab === 'annotations' || tab === 'source' ? tab : 'summary';
467
+ }
468
+
452
469
  function cancelErrorMessage(error: unknown): string {
453
470
  if (error instanceof ApiError && error.code === 'run-already-finished') {
454
471
  return 'This workflow run has already finished.';
455
472
  }
456
473
  return 'Could not cancel workflow run.';
457
474
  }
458
-
459
- function findAttemptSelection(jobExecution: JobExecution, attemptId: string) {
460
- for (const step of jobExecution.steps) {
461
- const attempt = step.attempts.find((candidate) => candidate.id === attemptId);
462
- if (attempt) return {stepId: step.id, attemptId: attempt.id};
463
- }
464
- return undefined;
465
- }