@shipfox/client-workflows 21.0.0 → 22.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 (57) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +33 -0
  3. package/dist/components/job-detail/job-detail-view.js +1 -2
  4. package/dist/components/job-detail/job-detail-view.js.map +1 -1
  5. package/dist/components/job-detail/job-empty-states.js +2 -2
  6. package/dist/components/job-detail/job-empty-states.js.map +1 -1
  7. package/dist/components/job-detail/job-execution-time-text.d.ts.map +1 -1
  8. package/dist/components/job-detail/job-execution-time-text.js +2 -15
  9. package/dist/components/job-detail/job-execution-time-text.js.map +1 -1
  10. package/dist/components/job-graph/job-duration-format.js +1 -1
  11. package/dist/components/job-graph/job-duration-format.js.map +1 -1
  12. package/dist/components/job-graph/job-graph-content.js +2 -2
  13. package/dist/components/job-graph/job-graph-content.js.map +1 -1
  14. package/dist/components/step-list/step-list.js +1 -2
  15. package/dist/components/step-list/step-list.js.map +1 -1
  16. package/dist/components/workflow-run-list/workflow-run-list-content.d.ts.map +1 -1
  17. package/dist/components/workflow-run-list/workflow-run-list-content.js +13 -7
  18. package/dist/components/workflow-run-list/workflow-run-list-content.js.map +1 -1
  19. package/dist/components/workflow-run-list/workflow-run-list-states.d.ts.map +1 -1
  20. package/dist/components/workflow-run-list/workflow-run-list-states.js +42 -37
  21. package/dist/components/workflow-run-list/workflow-run-list-states.js.map +1 -1
  22. package/dist/components/workflow-run-list/workflow-run-row.d.ts.map +1 -1
  23. package/dist/components/workflow-run-list/workflow-run-row.js +14 -10
  24. package/dist/components/workflow-run-list/workflow-run-row.js.map +1 -1
  25. package/dist/components/workflow-run-summary/workflow-run-summary.js +3 -3
  26. package/dist/components/workflow-run-summary/workflow-run-summary.js.map +1 -1
  27. package/dist/components/workflow-run-view/run-workspace-nav.d.ts.map +1 -1
  28. package/dist/components/workflow-run-view/run-workspace-nav.js +20 -7
  29. package/dist/components/workflow-run-view/run-workspace-nav.js.map +1 -1
  30. package/dist/components/workflow-run-view/workflow-run-view.d.ts.map +1 -1
  31. package/dist/components/workflow-run-view/workflow-run-view.js +68 -50
  32. package/dist/components/workflow-run-view/workflow-run-view.js.map +1 -1
  33. package/dist/pages/project-workflows-page.js +181 -172
  34. package/dist/pages/project-workflows-page.js.map +1 -1
  35. package/dist/tsconfig.test.tsbuildinfo +1 -1
  36. package/package.json +8 -8
  37. package/src/components/job-detail/job-detail-view.tsx +1 -2
  38. package/src/components/job-detail/job-empty-states.tsx +2 -2
  39. package/src/components/job-detail/job-execution-time-text.test.ts +46 -0
  40. package/src/components/job-detail/job-execution-time-text.tsx +2 -23
  41. package/src/components/job-graph/job-duration-format.ts +1 -1
  42. package/src/components/job-graph/job-graph-content.tsx +2 -2
  43. package/src/components/step-list/step-list.tsx +1 -2
  44. package/src/components/workflow-run-list/workflow-run-list-content.tsx +7 -2
  45. package/src/components/workflow-run-list/workflow-run-list-states.tsx +41 -40
  46. package/src/components/workflow-run-list/workflow-run-row.tsx +11 -8
  47. package/src/components/workflow-run-summary/workflow-run-summary.stories.tsx +1 -1
  48. package/src/components/workflow-run-summary/workflow-run-summary.test.tsx +9 -1
  49. package/src/components/workflow-run-summary/workflow-run-summary.tsx +3 -3
  50. package/src/components/workflow-run-view/run-workspace-nav.test.tsx +40 -2
  51. package/src/components/workflow-run-view/run-workspace-nav.tsx +28 -9
  52. package/src/components/workflow-run-view/workflow-run-view.stories.tsx +3 -1
  53. package/src/components/workflow-run-view/workflow-run-view.test.tsx +11 -8
  54. package/src/components/workflow-run-view/workflow-run-view.tsx +78 -70
  55. package/src/pages/project-workflows-page.test.tsx +14 -4
  56. package/src/pages/project-workflows-page.tsx +138 -132
  57. package/tsconfig.build.tsbuildinfo +1 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-workflows",
3
3
  "license": "MIT",
4
- "version": "21.0.0",
4
+ "version": "22.0.1",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -29,14 +29,14 @@
29
29
  "date-fns": "^4.1.0",
30
30
  "@shipfox/annotations-dto": "12.3.0",
31
31
  "@shipfox/api-definitions-dto": "12.3.0",
32
- "@shipfox/api-workflows-dto": "12.7.0",
32
+ "@shipfox/api-workflows-dto": "13.0.0",
33
33
  "@shipfox/client-api": "6.0.1",
34
- "@shipfox/client-logs": "21.0.0",
35
- "@shipfox/client-projects": "21.0.0",
36
- "@shipfox/client-shell": "21.0.0",
37
- "@shipfox/client-triggers": "21.0.0",
38
- "@shipfox/client-ui": "21.0.0",
39
- "@shipfox/react-ui": "2.0.0"
34
+ "@shipfox/client-logs": "22.0.0",
35
+ "@shipfox/client-projects": "22.0.1",
36
+ "@shipfox/client-shell": "22.0.1",
37
+ "@shipfox/client-triggers": "22.0.1",
38
+ "@shipfox/react-ui": "2.1.0",
39
+ "@shipfox/client-ui": "22.0.0"
40
40
  },
41
41
  "peerDependencies": {
42
42
  "@tanstack/react-query": "^5.101.0",
@@ -558,11 +558,10 @@ function EmptyStateForMissingExecution({job}: {job: Job}) {
558
558
  const emptyState = emptyStateForMissingExecution(job);
559
559
  return (
560
560
  <EmptyState
561
- className="min-h-120 p-panel"
562
561
  icon="componentLine"
563
562
  title={emptyState.title}
564
563
  description={emptyState.description}
565
- variant="compact"
564
+ variant="panel"
566
565
  />
567
566
  );
568
567
  }
@@ -256,11 +256,11 @@ function humanizeFailureReason(reason: string): string {
256
256
  export function CarriedOverStepPanel() {
257
257
  return (
258
258
  <EmptyState
259
- className="min-h-120 rounded-8 border border-border-neutral-base bg-background-components-base p-panel"
259
+ className="rounded-8 border border-border-neutral-base bg-background-components-base"
260
260
  icon="componentLine"
261
261
  title="Carried over from a previous attempt"
262
262
  description="Not executed in this run."
263
- variant="compact"
263
+ variant="panel"
264
264
  />
265
265
  );
266
266
  }
@@ -0,0 +1,46 @@
1
+ import {formatJobExecutionTime} from './job-execution-time-text.js';
2
+
3
+ const FROM_ISO = '2026-08-15T08:51:00.000Z';
4
+ const FROM_MS = Date.parse(FROM_ISO);
5
+
6
+ describe('formatJobExecutionTime', () => {
7
+ afterEach(() => {
8
+ vi.useRealTimers();
9
+ });
10
+
11
+ test.each([
12
+ [999, '0s'],
13
+ [37_999, '37s'],
14
+ [60_000, '1m 00s'],
15
+ [124_999, '2m 04s'],
16
+ [3_780_999, '1h 03m'],
17
+ ])('formats a live %i ms timer as %s', (elapsedMs, expected) => {
18
+ vi.useFakeTimers();
19
+ vi.setSystemTime(FROM_MS + elapsedMs);
20
+
21
+ const result = formatJobExecutionTime({
22
+ state: 'live',
23
+ fromIso: FROM_ISO,
24
+ });
25
+
26
+ expect(result).toBe(expected);
27
+ });
28
+
29
+ test('falls back to zero seconds for an invalid live timestamp', () => {
30
+ const result = formatJobExecutionTime({state: 'live', fromIso: 'not-a-date'});
31
+
32
+ expect(result).toBe('0s');
33
+ });
34
+
35
+ test.each([
36
+ [{seconds: 37}, '37s'],
37
+ [{minutes: 1}, '1m 00s'],
38
+ [{minutes: 2, seconds: 4}, '2m 04s'],
39
+ [{hours: 1}, '1h 00m'],
40
+ [{hours: 1, minutes: 3}, '1h 03m'],
41
+ ])('formats a fixed timer consistently as %s', (elapsed, expected) => {
42
+ const result = formatJobExecutionTime({state: 'fixed', elapsed});
43
+
44
+ expect(result).toBe(expected);
45
+ });
46
+ });
@@ -1,6 +1,6 @@
1
1
  import {useTimeTick} from '@shipfox/react-ui/time-ticker';
2
- import {formatDuration} from '@shipfox/react-ui/utils';
3
2
  import type {JobExecutionTime} from '#core/workflow-run.js';
3
+ import {formatJobExecutionTimeLabel} from '../job-graph/job-duration-format.js';
4
4
 
5
5
  export function JobExecutionTimeText({time}: {time: JobExecutionTime}) {
6
6
  useTimeTick();
@@ -9,26 +9,5 @@ export function JobExecutionTimeText({time}: {time: JobExecutionTime}) {
9
9
  }
10
10
 
11
11
  export function formatJobExecutionTime(time: JobExecutionTime): string {
12
- if (time.state === 'live') {
13
- return formatDuration(Date.now() - Date.parse(time.fromIso));
14
- }
15
-
16
- return formatElapsedDuration(time.elapsed);
17
- }
18
-
19
- function formatElapsedDuration(duration: {
20
- days?: number | undefined;
21
- hours?: number | undefined;
22
- minutes?: number | undefined;
23
- seconds?: number | undefined;
24
- }): string {
25
- const days = duration.days ?? 0;
26
- const hours = duration.hours ?? 0;
27
- const minutes = duration.minutes ?? 0;
28
- const seconds = duration.seconds ?? 0;
29
-
30
- if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
31
- if (hours > 0) return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
32
- if (minutes > 0) return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`;
33
- return `${seconds}s`;
12
+ return formatJobExecutionTimeLabel(time);
34
13
  }
@@ -8,7 +8,7 @@ import type {
8
8
  export function formatJobExecutionTimeLabel(time: JobExecutionTime): string {
9
9
  switch (time.state) {
10
10
  case 'live':
11
- return humanDuration(time.fromIso);
11
+ return humanDuration(time.fromIso) || '0s';
12
12
  case 'fixed':
13
13
  return formatFixedDurationLabel(time.elapsed);
14
14
  default: {
@@ -37,11 +37,11 @@ export function JobGraphContent({
37
37
  if (model.nodes.length === 0) {
38
38
  return (
39
39
  <EmptyState
40
- className="min-h-160 p-panel"
40
+ className="min-h-160"
41
41
  icon="componentLine"
42
42
  title="No jobs yet"
43
43
  description="This run has not materialized jobs."
44
- variant="compact"
44
+ variant="panel"
45
45
  />
46
46
  );
47
47
  }
@@ -326,11 +326,10 @@ function StepListEmptyStateView({
326
326
  if (!emptyState.status) {
327
327
  return (
328
328
  <EmptyState
329
- className="min-h-120 p-panel"
330
329
  icon="componentLine"
331
330
  title={emptyState.title}
332
331
  description={emptyState.description}
333
- variant="compact"
332
+ variant="panel"
334
333
  />
335
334
  );
336
335
  }
@@ -1,4 +1,5 @@
1
1
  import {QueryLoadError} from '@shipfox/client-ui';
2
+ import {Panel} from '@shipfox/react-ui/panel';
2
3
  import type {WorkflowRunListItem} from '#core/workflow-run.js';
3
4
  import type {WorkflowRunListQuery} from './types.js';
4
5
  import {
@@ -55,11 +56,15 @@ export function WorkflowRunListContent({
55
56
  <div className="min-h-0 flex-1 overflow-y-auto">
56
57
  {isPending ? <WorkflowRunListSkeleton /> : null}
57
58
  {!isPending ? (
58
- <QueryLoadError query={query} subject="workflow runs" icon="pulseLine" />
59
+ <Panel>
60
+ <QueryLoadError query={query} subject="workflow runs" icon="pulseLine" variant="panel" />
61
+ </Panel>
59
62
  ) : null}
60
63
  {!isPending && refreshFailed ? <WorkflowRunListStaleError query={query} /> : null}
61
64
  {showEmptyState ? (
62
- <WorkflowRunListEmpty workspaceSlug={workspaceSlug} projectSlug={projectSlug} />
65
+ <Panel>
66
+ <WorkflowRunListEmpty workspaceSlug={workspaceSlug} projectSlug={projectSlug} />
67
+ </Panel>
63
68
  ) : null}
64
69
  {showNoMatches ? (
65
70
  <WorkflowRunListNoMatches
@@ -2,6 +2,7 @@ import type {QueryLoadErrorQuery} from '@shipfox/client-ui';
2
2
  import {Button} from '@shipfox/react-ui/button';
3
3
  import {Callout} from '@shipfox/react-ui/callout';
4
4
  import {EmptyState} from '@shipfox/react-ui/empty-state';
5
+ import {Panel} from '@shipfox/react-ui/panel';
5
6
  import {Skeleton} from '@shipfox/react-ui/skeleton';
6
7
  import {Text} from '@shipfox/react-ui/typography';
7
8
  import {Link} from '@tanstack/react-router';
@@ -15,16 +16,12 @@ const SKELETON_ROW_COUNT = 8;
15
16
  */
16
17
  export function WorkflowRunListSkeleton() {
17
18
  return (
18
- <div
19
- className="@container divide-y divide-border-neutral-base"
20
- role="status"
21
- aria-label="Loading runs"
22
- >
19
+ <Panel role="status" aria-label="Loading runs" className="@container divide-y">
23
20
  {Array.from({length: SKELETON_ROW_COUNT}).map((_, index) => (
24
21
  <div
25
22
  // biome-ignore lint/suspicious/noArrayIndexKey: skeleton row, stable position
26
23
  key={index}
27
- className="flex items-center gap-inline px-row py-row @min-[976px]:h-44 @min-[976px]:py-0"
24
+ className="flex min-h-44 items-center gap-inline px-row py-row @min-[976px]:h-44 @min-[976px]:py-0"
28
25
  >
29
26
  <Skeleton className="size-14 shrink-0 rounded-full" />
30
27
  <Skeleton className="h-12 w-[220px] max-w-[40%]" />
@@ -33,7 +30,7 @@ export function WorkflowRunListSkeleton() {
33
30
  <Skeleton className="h-12 w-48" />
34
31
  </div>
35
32
  ))}
36
- </div>
33
+ </Panel>
37
34
  );
38
35
  }
39
36
 
@@ -89,6 +86,7 @@ export function WorkflowRunListEmpty({
89
86
  </Button>
90
87
  ) : null
91
88
  }
89
+ variant="panel"
92
90
  />
93
91
  );
94
92
  }
@@ -116,40 +114,43 @@ export function WorkflowRunListNoMatches({
116
114
  onLoadMore?: () => void;
117
115
  }) {
118
116
  return (
119
- <div className="flex flex-col gap-inline">
120
- {isFetchNextPageError ? (
121
- <Callout role="alert" type="error">
122
- Could not load more workflow runs. Try again to continue searching older runs.
123
- </Callout>
124
- ) : null}
125
- <EmptyState
126
- icon="filterOffLine"
127
- title={hasNextPage ? 'No matches in loaded history' : 'No matching runs'}
128
- description={
129
- hasNextPage
130
- ? 'No loaded run matches these filters. Load more to search further back.'
131
- : 'No run matches these filters.'
132
- }
133
- action={
134
- <div className="flex flex-wrap justify-center gap-inline">
135
- {hasNextPage && onLoadMore ? (
136
- <Button
137
- type="button"
138
- size="sm"
139
- variant="primary"
140
- isLoading={isFetchingNextPage}
141
- onClick={onLoadMore}
142
- >
143
- Load more runs
117
+ <Panel>
118
+ <div className="flex flex-col gap-inline">
119
+ {isFetchNextPageError ? (
120
+ <Callout role="alert" type="error">
121
+ Could not load more workflow runs. Try again to continue searching older runs.
122
+ </Callout>
123
+ ) : null}
124
+ <EmptyState
125
+ icon="filterOffLine"
126
+ title={hasNextPage ? 'No matches in loaded history' : 'No matching runs'}
127
+ description={
128
+ hasNextPage
129
+ ? 'No loaded run matches these filters. Load more to search further back.'
130
+ : 'No run matches these filters.'
131
+ }
132
+ action={
133
+ <div className="flex flex-wrap justify-center gap-inline">
134
+ {hasNextPage && onLoadMore ? (
135
+ <Button
136
+ type="button"
137
+ size="sm"
138
+ variant="primary"
139
+ isLoading={isFetchingNextPage}
140
+ onClick={onLoadMore}
141
+ >
142
+ Load more runs
143
+ </Button>
144
+ ) : null}
145
+ <Button type="button" size="sm" variant="secondary" onClick={onClear}>
146
+ Show all runs
144
147
  </Button>
145
- ) : null}
146
- <Button type="button" size="sm" variant="secondary" onClick={onClear}>
147
- Show all runs
148
- </Button>
149
- </div>
150
- }
151
- />
152
- </div>
148
+ </div>
149
+ }
150
+ variant="panel"
151
+ />
152
+ </div>
153
+ </Panel>
153
154
  );
154
155
  }
155
156
 
@@ -1,5 +1,6 @@
1
1
  import {TriggerSourceIcon} from '@shipfox/client-triggers';
2
2
  import {Icon, type IconName} from '@shipfox/react-ui/icon';
3
+ import {Panel} from '@shipfox/react-ui/panel';
3
4
  import {RelativeTime} from '@shipfox/react-ui/relative-time';
4
5
  import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
5
6
  import {Code, Text} from '@shipfox/react-ui/typography';
@@ -37,13 +38,15 @@ export function WorkflowRunRowList({
37
38
  // A container, not the viewport, drives the row's breakpoints. What a row can afford is its
38
39
  // own width; keying off the viewport would keep the job strip hidden on a wide screen or
39
40
  // crush it on a narrow one.
40
- <ul className="@container divide-y divide-border-neutral-base">
41
- {runs.map((run) => (
42
- <li key={run.id}>
43
- <WorkflowRunRow run={run} workspaceSlug={workspaceSlug} projectSlug={projectSlug} />
44
- </li>
45
- ))}
46
- </ul>
41
+ <Panel className="@container">
42
+ <ul className="divide-y divide-border-neutral-base">
43
+ {runs.map((run) => (
44
+ <li key={run.id}>
45
+ <WorkflowRunRow run={run} workspaceSlug={workspaceSlug} projectSlug={projectSlug} />
46
+ </li>
47
+ ))}
48
+ </ul>
49
+ </Panel>
47
50
  );
48
51
  }
49
52
 
@@ -125,7 +128,7 @@ export function WorkflowRunRow({
125
128
  // Rows run edge to edge inside the list's scroll container, which would clip the standard
126
129
  // outset focus ring, so this one is inset per the design system's focus-ring rule.
127
130
  const rowClassName =
128
- 'flex w-full min-w-0 items-center gap-inline px-row py-row text-left transition-colors hover:bg-background-neutral-hover focus-visible:shadow-[inset_0_0_0_2px_var(--color-primary-500)] focus-visible:outline-none @min-[976px]:h-44 @min-[976px]:py-0';
131
+ 'flex w-full min-w-0 items-center gap-inline px-row py-row text-left transition-colors hover:bg-background-neutral-hover focus-visible:shadow-focus-inset focus-visible:outline-none @min-[976px]:h-44 @min-[976px]:py-0';
129
132
 
130
133
  // Optimistic manual runs (temp-<uuid>) have no detail page until the canonical row
131
134
  // replaces them on the next poll, so they render non-interactively instead of as a link
@@ -57,7 +57,7 @@ const RUN_ATTEMPTS_RESPONSE = runAttemptsResponseDto({
57
57
 
58
58
  const withFrame: Decorator = (Story) => (
59
59
  <div className="min-h-screen bg-background-subtle-base">
60
- <div className="mx-auto flex min-h-screen w-full max-w-[1120px] flex-col overflow-hidden border-x border-border-neutral-base bg-background-subtle-base">
60
+ <div className="flex min-h-screen w-full flex-col px-frame py-frame">
61
61
  <Story />
62
62
  <div className="min-h-0 flex-1 bg-background-subtle-base p-16" />
63
63
  </div>
@@ -299,7 +299,15 @@ describe('WorkflowRunSummary', () => {
299
299
  const onCancel = vi.fn();
300
300
  renderSummary({}, {onCancel});
301
301
 
302
- await user.click(await screen.findByRole('button', {name: 'Cancel workflow'}));
302
+ const button = await screen.findByRole('button', {name: 'Cancel workflow'});
303
+ expect(button).toHaveClass(
304
+ 'bg-background-button-danger-default',
305
+ 'text-foreground-neutral-on-color',
306
+ 'shadow-button-danger',
307
+ );
308
+ expect(button).not.toHaveClass('bg-background-neutral-base');
309
+
310
+ await user.click(button);
303
311
 
304
312
  expect(onCancel).toHaveBeenCalledTimes(1);
305
313
  });
@@ -29,7 +29,7 @@ import {WorkflowRunAttemptSwitcher} from './workflow-run-attempt-switcher.js';
29
29
  const STATUS_BADGE_LABEL_WIDTH_CH = Math.max(
30
30
  ...WORKFLOW_RUN_STATUSES.map((status) => getWorkflowStatusVisual(status).label.length),
31
31
  );
32
- const RERUN_BUTTON_SURFACE_CLASS_NAME =
32
+ const NEUTRAL_ACTION_SURFACE_CLASS_NAME =
33
33
  'bg-background-neutral-base hover:bg-background-neutral-hover active:bg-background-neutral-pressed';
34
34
 
35
35
  type WorkflowRunAction = 'cancel' | 'rerun-all' | 'rerun-menu' | 'none';
@@ -225,7 +225,7 @@ function WorkflowRunActionSlot({
225
225
  type="button"
226
226
  variant="secondary"
227
227
  size="xs"
228
- className={RERUN_BUTTON_SURFACE_CLASS_NAME}
228
+ className={NEUTRAL_ACTION_SURFACE_CLASS_NAME}
229
229
  isLoading={rerunPending}
230
230
  disabled={rerunPending}
231
231
  onClick={() => onRerun('all')}
@@ -244,7 +244,7 @@ function WorkflowRunActionSlot({
244
244
  type="button"
245
245
  variant="secondary"
246
246
  size="xs"
247
- className={RERUN_BUTTON_SURFACE_CLASS_NAME}
247
+ className={NEUTRAL_ACTION_SURFACE_CLASS_NAME}
248
248
  iconRight="arrowDownSLine"
249
249
  isLoading={rerunPending}
250
250
  disabled={rerunPending}
@@ -13,6 +13,7 @@ const RUN_ID = '66666666-6666-4666-8666-666666666666';
13
13
  const CURRENT_JOB_ID = '88888888-8888-4888-8888-888888888888';
14
14
  const BUILD_LINK_PATTERN = /build/;
15
15
  const DEPLOY_LINK_PATTERN = /deploy/;
16
+ const SETUP_LINK_PATTERN = /setup/;
16
17
  const NOW = Date.parse('2026-06-26T12:00:00.000Z');
17
18
 
18
19
  describe('RunWorkspaceNav', () => {
@@ -92,7 +93,8 @@ describe('RunWorkspaceNav', () => {
92
93
  />,
93
94
  );
94
95
 
95
- expect(await screen.findByRole('link', {name: 'Summary'})).toBeInTheDocument();
96
+ const summary = await screen.findByRole('link', {name: 'Summary'});
97
+ expect(summary).toBeInTheDocument();
96
98
  expect(screen.getByRole('heading', {name: 'Jobs'})).toBeInTheDocument();
97
99
  expect(screen.getByRole('heading', {name: 'Run details'})).toBeInTheDocument();
98
100
  expect(screen.getByRole('link', {name: 'Annotations'})).toBeInTheDocument();
@@ -112,6 +114,13 @@ describe('RunWorkspaceNav', () => {
112
114
 
113
115
  const current = within(jobs).getByRole('link', {name: BUILD_LINK_PATTERN});
114
116
  expect(current).toHaveAttribute('aria-current', 'page');
117
+ expect(current.querySelector('[data-run-workspace-active-bar]')).not.toBeNull();
118
+ expect(
119
+ within(jobs)
120
+ .getByRole('link', {name: SETUP_LINK_PATTERN})
121
+ .querySelector('[data-run-workspace-active-bar]'),
122
+ ).toBeNull();
123
+ expect(jobs.querySelectorAll('[data-run-workspace-active-bar]')).toHaveLength(1);
115
124
  expect(current).toHaveAttribute(
116
125
  'href',
117
126
  expect.stringContaining(`/runs/${RUN_ID}/jobs/${CURRENT_JOB_ID}`),
@@ -137,7 +146,36 @@ describe('RunWorkspaceNav', () => {
137
146
 
138
147
  expect(await screen.findByRole('heading', {name: 'Jobs'})).toBeInTheDocument();
139
148
  expect(screen.getByRole('link', {name: BUILD_LINK_PATTERN})).toBeInTheDocument();
140
- expect(screen.getByRole('link', {name: 'Summary'})).toHaveAttribute('aria-current', 'page');
149
+ const summary = screen.getByRole('link', {name: 'Summary'});
150
+ expect(summary).toHaveAttribute('aria-current', 'page');
151
+ expect(summary.querySelector('[data-run-workspace-active-bar]')).not.toBeNull();
152
+ });
153
+
154
+ test('falls back to the first job when the current job id is stale', async () => {
155
+ const run = workflowRunDetail({
156
+ id: RUN_ID,
157
+ jobs: [
158
+ workflowJobDto({id: 'job-deploy', name: 'deploy', position: 1}),
159
+ workflowJobDto({id: 'job-setup', name: 'setup', position: 0}),
160
+ ],
161
+ });
162
+
163
+ renderWithRouter(
164
+ <RunWorkspaceNav
165
+ workspaceSlug="acme"
166
+ projectSlug="project"
167
+ run={run}
168
+ activeSection="summary"
169
+ currentJobId="stale-job-id"
170
+ />,
171
+ );
172
+
173
+ const jobs = (await screen.findByRole('heading', {name: 'Jobs'})).closest('section');
174
+ if (!jobs) throw new Error('Missing jobs section');
175
+
176
+ const fallback = within(jobs).getByRole('link', {name: SETUP_LINK_PATTERN});
177
+ expect(fallback).toHaveAttribute('aria-current', 'page');
178
+ expect(jobs.querySelectorAll('[data-run-workspace-active-bar]')).toHaveLength(1);
141
179
  });
142
180
 
143
181
  test('scrolls the current job into view when the mobile rail opens', async () => {
@@ -23,6 +23,9 @@ import {JobExecutionTimeText} from '../job-detail/job-execution-time-text.js';
23
23
 
24
24
  type RunWorkspaceSection = Exclude<WorkflowRunTab, 'jobs'>;
25
25
 
26
+ const RUN_WORKSPACE_LINK_CLASS_NAME =
27
+ 'relative flex min-h-32 items-center gap-inline rounded-4 px-tight outline-none transition-colors hover:bg-background-neutral-hover focus-visible:shadow-border-interactive-with-active @max-[767px]:min-h-44 [@media(pointer:coarse)]:min-h-44';
28
+
26
29
  export interface RunWorkspaceNavProps {
27
30
  workspaceSlug: string;
28
31
  projectSlug: string;
@@ -48,12 +51,14 @@ export function RunWorkspaceNav({
48
51
  }: RunWorkspaceNavProps) {
49
52
  const jobs = useMemo(() => [...run.jobs].sort(compareJobs), [run.jobs]);
50
53
  const [mobileOpen, setMobileOpen] = useState(false);
51
- const currentJob = jobs.find((job) => job.id === currentJobId);
54
+ const currentJob =
55
+ jobs.find((job) => job.id === currentJobId) ?? (currentJobId ? jobs[0] : undefined);
56
+ const resolvedCurrentJobId = currentJob?.id;
52
57
  const currentLabel = currentJob?.displayName ?? sectionLabel(activeSection);
53
58
 
54
59
  return (
55
60
  <TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
56
- <aside className="flex min-h-0 w-full shrink-0 flex-col border-b border-border-neutral-base min-[768px]:w-240 min-[768px]:border-b-0 min-[768px]:border-r">
61
+ <aside className="flex min-h-0 w-full shrink-0 flex-col border-b border-border-neutral-base min-[768px]:w-240 min-[768px]:border-b-0">
57
62
  <Collapsible
58
63
  open={mobileOpen}
59
64
  onOpenChange={setMobileOpen}
@@ -96,7 +101,7 @@ export function RunWorkspaceNav({
96
101
  run={run}
97
102
  jobs={jobs}
98
103
  activeSection={activeSection}
99
- currentJobId={currentJobId}
104
+ currentJobId={resolvedCurrentJobId}
100
105
  jobSearch={jobSearch}
101
106
  annotationSummary={annotationSummary}
102
107
  mobileOpen={mobileOpen}
@@ -140,8 +145,8 @@ function RunWorkspaceNavContent({
140
145
  }, [currentJobId, mobileOpen]);
141
146
 
142
147
  return (
143
- <nav aria-label="Run workspace" className="flex min-h-0 min-w-0 flex-1 flex-col gap-group">
144
- <ul>
148
+ <nav aria-label="Run workspace" className="flex min-h-0 min-w-0 flex-1 flex-col">
149
+ <ul className="border-b border-border-neutral-base pb-row">
145
150
  <li>
146
151
  <RunSectionLink
147
152
  workspaceSlug={workspaceSlug}
@@ -157,7 +162,7 @@ function RunWorkspaceNavContent({
157
162
 
158
163
  <section
159
164
  aria-labelledby="run-workspace-jobs-heading"
160
- className="flex min-h-0 min-w-0 flex-1 flex-col"
165
+ className="flex min-h-0 min-w-0 flex-1 flex-col border-b border-border-neutral-base py-row"
161
166
  >
162
167
  <div className="flex items-center justify-between gap-inline px-tight pb-[6px]">
163
168
  <Text
@@ -189,10 +194,12 @@ function RunWorkspaceNavContent({
189
194
  onClick={onNavigate}
190
195
  aria-current={current ? 'page' : undefined}
191
196
  className={cn(
192
- 'flex min-h-32 min-w-0 items-center gap-inline rounded-4 px-tight text-left outline-none transition-colors hover:bg-background-neutral-hover focus-visible:shadow-border-interactive-with-active @max-[767px]:min-h-44 [@media(pointer:coarse)]:min-h-44',
197
+ RUN_WORKSPACE_LINK_CLASS_NAME,
198
+ 'min-w-0 text-left',
193
199
  current && 'bg-background-neutral-hover',
194
200
  )}
195
201
  >
202
+ {current ? <RunWorkspaceActiveBar /> : null}
196
203
  <WorkflowStatusIcon
197
204
  status={deriveJobDisplayStatus(job)}
198
205
  size={12}
@@ -211,7 +218,7 @@ function RunWorkspaceNavContent({
211
218
  </ol>
212
219
  </section>
213
220
 
214
- <section aria-labelledby="run-workspace-details-heading">
221
+ <section aria-labelledby="run-workspace-details-heading" className="pt-row">
215
222
  <Text
216
223
  as="h2"
217
224
  id="run-workspace-details-heading"
@@ -292,10 +299,12 @@ function RunSectionLink({
292
299
  : undefined
293
300
  }
294
301
  className={cn(
295
- 'flex min-h-32 items-center gap-inline rounded-4 px-tight text-xs font-medium text-foreground-neutral-base outline-none transition-colors hover:bg-background-neutral-hover focus-visible:shadow-border-interactive-with-active @max-[767px]:min-h-44 [@media(pointer:coarse)]:min-h-44',
302
+ RUN_WORKSPACE_LINK_CLASS_NAME,
303
+ 'text-xs font-medium text-foreground-neutral-base',
296
304
  current && 'bg-background-neutral-hover',
297
305
  )}
298
306
  >
307
+ {current ? <RunWorkspaceActiveBar /> : null}
299
308
  {sectionLabel(section)}
300
309
  {count ? (
301
310
  <span
@@ -310,6 +319,16 @@ function RunSectionLink({
310
319
  );
311
320
  }
312
321
 
322
+ function RunWorkspaceActiveBar() {
323
+ return (
324
+ <span
325
+ aria-hidden="true"
326
+ data-run-workspace-active-bar
327
+ className="absolute inset-y-0 left-0 w-2 rounded-l-4 bg-border-highlights-interactive"
328
+ />
329
+ );
330
+ }
331
+
313
332
  function sectionLabel(section: RunWorkspaceSection): string {
314
333
  if (section === 'annotations') return 'Annotations';
315
334
  if (section === 'source') return 'Source';
@@ -133,7 +133,9 @@ function RunWorkspaceStoryProviders({children}: {children: ReactNode}) {
133
133
  return (
134
134
  <QueryClientProvider client={queryClient}>
135
135
  <div className="flex h-[720px] min-w-[1440px] w-[1440px] bg-background-subtle-base">
136
- {children}
136
+ <div className="flex min-h-0 min-w-0 w-full flex-1 flex-col px-frame py-frame">
137
+ {children}
138
+ </div>
137
139
  </div>
138
140
  </QueryClientProvider>
139
141
  );
@@ -54,16 +54,18 @@ describe('WorkflowRunView', () => {
54
54
  .closest('[data-run-workspace-layout]');
55
55
  expect(workspaceLayout).toHaveClass('border-t', 'border-border-neutral-base');
56
56
  expect(workspaceLayout).not.toHaveClass('max-w-[1360px]');
57
- expect(workspaceLayout?.querySelector('[data-run-workspace-frame]')).toHaveClass(
58
- 'mx-auto',
59
- 'w-full',
60
- 'max-w-[calc(240px_+_1120px)]',
61
- 'min-[768px]:flex-row',
62
- );
57
+ const workspaceFrame = workspaceLayout?.querySelector('[data-run-workspace-frame]');
58
+ expect(workspaceFrame).toHaveClass('w-full', 'min-[768px]:flex-row');
59
+ expect(workspaceFrame).not.toHaveClass('mx-auto', 'max-w-[calc(240px_+_1120px)]');
63
60
  expect(screen.getByRole('link', {name: 'Summary'})).toHaveAttribute('aria-current', 'page');
64
61
  expect(screen.getByRole('heading', {name: 'Jobs'})).toBeInTheDocument();
65
62
  expect(screen.getByRole('heading', {name: 'Run details'})).toBeInTheDocument();
66
- expect(screen.getByRole('region', {name: 'All jobs summary'})).toBeInTheDocument();
63
+ const allJobsSummary = screen.getByRole('region', {name: 'All jobs summary'});
64
+ expect(allJobsSummary).toBeInTheDocument();
65
+ expect(allJobsSummary.querySelector('[data-slot="panel"]')).not.toBeNull();
66
+ expect(allJobsSummary.querySelector('[data-slot="panel-body"]')).toHaveClass(
67
+ 'bg-background-components-base',
68
+ );
67
69
  expect(screen.getByRole('region', {name: 'Workflow jobs'})).toBeInTheDocument();
68
70
  expect(container.querySelector('[data-run-workspace-content]')).toHaveClass('flex-1');
69
71
  expect(container.querySelector('[data-run-workspace-content]')).not.toHaveClass(
@@ -82,7 +84,8 @@ describe('WorkflowRunView', () => {
82
84
  renderView({tab});
83
85
 
84
86
  const section = await screen.findByRole('region', {name: region});
85
- expect(section.firstElementChild).not.toHaveClass('max-w-[1120px]');
87
+ expect(section.firstElementChild).not.toHaveClass('mx-auto', 'px-frame', 'max-w-[1120px]');
88
+ expect(section.querySelector('[data-slot="panel"]')).not.toBeNull();
86
89
  });
87
90
 
88
91
  test('keeps dedicated job content on the full-width data surface', async () => {