@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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@shipfox/client-workflows",
3
3
  "license": "MIT",
4
- "version": "13.0.0",
4
+ "version": "14.0.1",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/ShipfoxHQ/shipfox.git",
@@ -27,15 +27,15 @@
27
27
  "dependencies": {
28
28
  "@swc/helpers": "^0.5.17",
29
29
  "date-fns": "^4.1.0",
30
- "@shipfox/api-workflows-dto": "12.0.0",
31
- "@shipfox/client-api": "6.0.1",
32
- "@shipfox/client-logs": "13.0.0",
33
30
  "@shipfox/api-definitions-dto": "12.0.0",
34
- "@shipfox/client-projects": "13.0.0",
35
- "@shipfox/client-shell": "13.0.0",
36
- "@shipfox/client-triggers": "13.0.0",
37
- "@shipfox/react-ui": "0.4.0",
38
- "@shipfox/client-ui": "13.0.0"
31
+ "@shipfox/api-workflows-dto": "12.1.0",
32
+ "@shipfox/client-api": "6.0.1",
33
+ "@shipfox/client-logs": "14.0.1",
34
+ "@shipfox/client-projects": "14.0.1",
35
+ "@shipfox/client-shell": "14.0.1",
36
+ "@shipfox/client-triggers": "14.0.1",
37
+ "@shipfox/client-ui": "14.0.1",
38
+ "@shipfox/react-ui": "1.0.0"
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@tanstack/react-query": "^5.101.0",
@@ -22,7 +22,11 @@ function LiveDurationText({fromIso}: {fromIso: string}) {
22
22
  function DurationText({children}: {children: string}) {
23
23
  // The status icon names the state; the node aria-label carries the verb.
24
24
  return (
25
- <Code as="span" variant="label" className="shrink-0 tabular-nums text-foreground-neutral-muted">
25
+ <Code
26
+ as="span"
27
+ variant="label"
28
+ className="shrink-0 tabular-nums text-foreground-neutral-subtle"
29
+ >
26
30
  {children}
27
31
  </Code>
28
32
  );
@@ -7,6 +7,7 @@ import type {WorkflowRunDetail} from '#core/workflow-run.js';
7
7
  import type {JobGraphModel, JobGraphNavigationKey} from './graph-model.js';
8
8
  import {nextJobGraphNodeId} from './graph-model.js';
9
9
  import {JobNode, TriggerNode} from './job-node.js';
10
+ import type {JobGraphSelectionSource} from './types.js';
10
11
 
11
12
  const NODE_WIDTH = 208;
12
13
  const NODE_HEIGHT = 48;
@@ -27,7 +28,7 @@ export function JobGraphContent({
27
28
  'triggerDisplayLabel' | 'triggerLabel' | 'triggerProvider' | 'triggerSource'
28
29
  >;
29
30
  selectedJobId?: string | undefined;
30
- onSelectJob: (jobId: string | undefined) => void;
31
+ onSelectJob: (jobId: string | undefined, source?: JobGraphSelectionSource) => void;
31
32
  }) {
32
33
  const nodeRefs = useRef(new Map<string, HTMLButtonElement>());
33
34
  const [hoveredJobId, setHoveredJobId] = useState<string | undefined>();
@@ -74,13 +75,13 @@ export function JobGraphContent({
74
75
  if (!nextNodeId) return;
75
76
 
76
77
  event.preventDefault();
77
- onSelectJob(nextNodeId);
78
+ onSelectJob(nextNodeId, 'keyboard');
78
79
  nodeRefs.current.get(nextNodeId)?.focus();
79
80
  }
80
81
 
81
82
  return (
82
83
  <TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
83
- <div className="min-h-0 overflow-auto bg-background-neutral-base">
84
+ <div className="min-h-0 overflow-auto bg-background-neutral-background">
84
85
  <div className="relative" style={{width: contentWidth, minHeight: contentHeight}}>
85
86
  <GraphEdges model={model} hoveredJobId={hoveredJobId} />
86
87
  <div
@@ -101,7 +102,9 @@ export function JobGraphContent({
101
102
  node={node}
102
103
  selected={node.id === selectedJobId}
103
104
  ref={setNodeRef(node.id)}
104
- onSelect={() => onSelectJob(node.id === selectedJobId ? undefined : node.id)}
105
+ onSelect={() =>
106
+ onSelectJob(node.id === selectedJobId ? undefined : node.id, 'pointer')
107
+ }
105
108
  onKeyDown={handleKeyDown}
106
109
  onHoverStart={() => setHoveredJobId(node.id)}
107
110
  onHoverEnd={() =>
@@ -3,6 +3,7 @@ import {useState} from 'react';
3
3
  import type {WorkflowRunDetail} from '#core/workflow-run.js';
4
4
  import type {JobGraphModel} from './graph-model.js';
5
5
  import {JobGraphContent} from './job-graph-content.js';
6
+ import type {JobGraphSelectionSource} from './types.js';
6
7
 
7
8
  export function JobGraphView({
8
9
  model,
@@ -19,7 +20,9 @@ export function JobGraphView({
19
20
  >;
20
21
  selectedJobId?: string | undefined;
21
22
  defaultSelectedJobId?: string | undefined;
22
- onSelectedJobChange?: ((jobId: string | undefined) => void) | undefined;
23
+ onSelectedJobChange?:
24
+ | ((jobId: string | undefined, source?: JobGraphSelectionSource) => void)
25
+ | undefined;
23
26
  className?: string | undefined;
24
27
  }) {
25
28
  const [localSelectedJobId, setLocalSelectedJobId] = useState<string | undefined>(
@@ -27,9 +30,9 @@ export function JobGraphView({
27
30
  );
28
31
  const selected = selectedJobId ?? localSelectedJobId;
29
32
 
30
- function selectJob(jobId: string | undefined) {
33
+ function selectJob(jobId: string | undefined, source?: JobGraphSelectionSource) {
31
34
  setLocalSelectedJobId(jobId);
32
- onSelectedJobChange?.(jobId);
35
+ onSelectedJobChange?.(jobId, source);
33
36
  }
34
37
 
35
38
  return (
@@ -11,7 +11,7 @@ const meta = {
11
11
  parameters: {layout: 'centered'},
12
12
  decorators: [
13
13
  (Story) => (
14
- <div className="h-520 w-900 overflow-auto bg-background-neutral-base p-16">
14
+ <div className="h-520 w-900 overflow-auto bg-background-neutral-background p-16">
15
15
  <Story />
16
16
  </div>
17
17
  ),
@@ -20,7 +20,7 @@ const meta = {
20
20
  parameters: {layout: 'centered'},
21
21
  decorators: [
22
22
  (Story) => (
23
- <div className="bg-background-neutral-base p-16">
23
+ <div className="bg-background-neutral-background p-16">
24
24
  <Story />
25
25
  </div>
26
26
  ),
@@ -52,11 +52,14 @@ function makeNode(overrides: NodeOverrides): JobGraphNode {
52
52
  });
53
53
  }
54
54
 
55
- function renderNode(node: JobGraphNode, {live = false}: {live?: boolean} = {}) {
55
+ function renderNode(
56
+ node: JobGraphNode,
57
+ {live = false, selected = false}: {live?: boolean; selected?: boolean} = {},
58
+ ) {
56
59
  const element = (
57
60
  <JobNode
58
61
  node={node}
59
- selected={false}
62
+ selected={selected}
60
63
  onSelect={() => undefined}
61
64
  onKeyDown={() => undefined}
62
65
  onHoverStart={() => undefined}
@@ -91,6 +94,28 @@ function setVisibility(state: 'visible' | 'hidden') {
91
94
  Object.defineProperty(document, 'visibilityState', {configurable: true, value: state});
92
95
  }
93
96
 
97
+ describe('JobNode interaction', () => {
98
+ test('presents the selectable node as an elevated interactive surface', () => {
99
+ renderNode(makeNode({name: 'build', status: 'pending'}));
100
+
101
+ expect(screen.getByRole('button', {name: 'build, Pending'})).toHaveClass(
102
+ 'cursor-pointer',
103
+ 'bg-background-neutral-base',
104
+ 'shadow-button-neutral',
105
+ 'hover:bg-background-components-hover',
106
+ );
107
+ });
108
+
109
+ test('uses a quiet neutral surface for the selected node', () => {
110
+ renderNode(makeNode({name: 'build', status: 'pending'}), {selected: true});
111
+
112
+ expect(screen.getByRole('button', {name: 'build, Pending'})).toHaveClass(
113
+ 'bg-background-components-hover',
114
+ 'hover:bg-background-components-pressed',
115
+ );
116
+ });
117
+ });
118
+
94
119
  describe('JobNode duration', () => {
95
120
  beforeEach(() => {
96
121
  setMatchMedia(false);
@@ -40,14 +40,14 @@ export function TriggerNode({
40
40
  <button
41
41
  type="button"
42
42
  aria-label={label}
43
- className="flex items-center justify-center rounded-full border border-border-neutral-base bg-background-components-base transition-colors hover:bg-background-components-hover focus-visible:shadow-border-interactive-with-active focus-visible:outline-none"
43
+ className="flex cursor-help items-center justify-center rounded-full bg-background-neutral-base shadow-button-neutral transition-colors hover:bg-background-components-hover focus-visible:shadow-button-neutral-focus focus-visible:outline-none"
44
44
  style={{width: TRIGGER_SIZE, height: TRIGGER_SIZE}}
45
45
  >
46
46
  <TriggerSourceIcon
47
47
  provider={trigger.triggerProvider}
48
48
  source={trigger.triggerSource}
49
49
  aria-hidden
50
- className="size-14 shrink-0 text-foreground-neutral-muted"
50
+ className="size-14 shrink-0 text-foreground-neutral-subtle"
51
51
  />
52
52
  </button>
53
53
  </TooltipTrigger>
@@ -106,15 +106,15 @@ export function JobNode({
106
106
  onPointerEnter={onHoverStart}
107
107
  onPointerLeave={onHoverEnd}
108
108
  className={cn(
109
- 'group relative flex h-48 w-208 items-center gap-8 rounded-8 border border-border-neutral-base bg-background-components-base px-10 text-left transition-colors hover:bg-background-components-hover focus-visible:shadow-border-interactive-with-active focus-visible:outline-none',
110
- selected && 'bg-background-components-hover',
109
+ 'group relative flex h-48 w-208 cursor-pointer items-center gap-8 rounded-8 bg-background-neutral-base px-10 text-left shadow-button-neutral transition-colors hover:bg-background-components-hover focus-visible:shadow-button-neutral-focus focus-visible:outline-none',
110
+ selected && 'bg-background-components-hover hover:bg-background-components-pressed',
111
111
  node.carriedOver && 'opacity-[0.55]',
112
112
  )}
113
113
  >
114
114
  {selected ? (
115
115
  <span
116
116
  aria-hidden="true"
117
- className="absolute inset-y-6 left-0 w-3 rounded-full bg-border-highlights-interactive"
117
+ className="absolute inset-y-6 left-0 w-px rounded-full bg-border-highlights-interactive"
118
118
  />
119
119
  ) : null}
120
120
  <div className="flex min-w-0 flex-1 items-center gap-8">
@@ -136,7 +136,7 @@ function ExecutionCountText({executions}: {executions: JobExecution[]}) {
136
136
  <TooltipTrigger asChild>
137
137
  <span
138
138
  aria-hidden="true"
139
- className="inline-flex h-20 min-w-28 shrink-0 items-center justify-end gap-4 text-foreground-neutral-muted"
139
+ className="inline-flex h-20 min-w-28 shrink-0 items-center justify-end gap-4 text-foreground-neutral-subtle"
140
140
  >
141
141
  <Icon name="loopRightLine" className="size-12" />
142
142
  <Code as="span" variant="label" className="text-current">
@@ -1,9 +1,13 @@
1
1
  import type {WorkflowRunDetail} from '#core/workflow-run.js';
2
2
 
3
+ export type JobGraphSelectionSource = 'pointer' | 'keyboard';
4
+
3
5
  export interface JobGraphProps {
4
6
  run: WorkflowRunDetail;
5
7
  selectedJobId?: string | undefined;
6
8
  defaultSelectedJobId?: string | undefined;
7
- onSelectedJobChange?: ((jobId: string | undefined) => void) | undefined;
9
+ onSelectedJobChange?:
10
+ | ((jobId: string | undefined, source?: JobGraphSelectionSource) => void)
11
+ | undefined;
8
12
  className?: string | undefined;
9
13
  }
@@ -86,7 +86,7 @@ function DurationText({
86
86
  variant="label"
87
87
  aria-label={ariaLabel}
88
88
  className={cn(
89
- 'inline-flex shrink-0 items-center gap-4 tabular-nums text-foreground-neutral-muted',
89
+ 'inline-flex shrink-0 items-center gap-4 tabular-nums text-foreground-neutral-subtle',
90
90
  className,
91
91
  )}
92
92
  >
@@ -32,7 +32,7 @@ export function WorkflowRunNumberLabel({run}: {run: WorkflowRunNumberLabelRun})
32
32
  <Code
33
33
  as="span"
34
34
  variant="label"
35
- className="min-w-0 max-w-[240px] truncate text-foreground-neutral-muted"
35
+ className="min-w-0 max-w-[240px] truncate text-foreground-neutral-subtle"
36
36
  >
37
37
  {visibleLabel}
38
38
  </Code>
@@ -52,7 +52,7 @@ export function WorkflowRunAttemptSwitcher({
52
52
  size="2xs"
53
53
  iconRight="arrowDownSLine"
54
54
  aria-label={`Switch attempt, currently ${run.runAttempt.attempt} of ${maxAttempt}`}
55
- className="h-20 px-4 text-foreground-neutral-muted hover:text-foreground-neutral-base"
55
+ className="h-20 px-4 text-foreground-neutral-subtle hover:text-foreground-neutral-base"
56
56
  >
57
57
  <Text as="span" size="xs" className="text-inherit">
58
58
  Attempt {run.runAttempt.attempt} of {maxAttempt}
@@ -139,7 +139,7 @@ function AttemptItem({
139
139
  </Code>
140
140
  <RelativeTime
141
141
  value={attempt.createdAt}
142
- className="shrink-0 whitespace-nowrap font-code text-xs leading-20 text-foreground-neutral-muted"
142
+ className="shrink-0 whitespace-nowrap font-code text-xs leading-20 text-foreground-neutral-subtle"
143
143
  />
144
144
  </Link>
145
145
  </DropdownMenuItem>
@@ -56,10 +56,10 @@ const RUN_ATTEMPTS_RESPONSE = runAttemptsResponseDto({
56
56
  });
57
57
 
58
58
  const withFrame: Decorator = (Story) => (
59
- <div className="min-h-screen bg-background-neutral-base">
59
+ <div className="min-h-screen bg-background-neutral-background">
60
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">
61
61
  <Story />
62
- <div className="min-h-0 flex-1 bg-background-neutral-base p-16" />
62
+ <div className="min-h-0 flex-1 bg-background-neutral-background p-16" />
63
63
  </div>
64
64
  </div>
65
65
  );
@@ -160,30 +160,6 @@ const ATTEMPT_SUMMARY_ARGS = {
160
160
 
161
161
  export const Playground: Story = {};
162
162
 
163
- export const WithSourceButton: Story = {
164
- args: {
165
- run: workflowRunDetail({
166
- status: 'succeeded',
167
- source_snapshot: {format: 'yaml', content: 'jobs:\n build:\n steps: []'},
168
- }),
169
- sourceAvailable: true,
170
- sourceOpen: false,
171
- sourcePanelId: 'workflow-source-panel',
172
- },
173
- };
174
-
175
- export const SourceOpen: Story = {
176
- args: {
177
- run: workflowRunDetail({
178
- status: 'succeeded',
179
- source_snapshot: {format: 'yaml', content: 'jobs:\n build:\n steps: []'},
180
- }),
181
- sourceAvailable: true,
182
- sourceOpen: true,
183
- sourcePanelId: 'workflow-source-panel',
184
- },
185
- };
186
-
187
163
  export const WithAttempts: Story = {
188
164
  decorators: [withAttemptApi],
189
165
  args: ATTEMPT_SUMMARY_ARGS,
@@ -323,7 +299,7 @@ const ACTION_VARIANTS = [
323
299
  >;
324
300
  }>;
325
301
 
326
- export const ActionVariantsWithSource: Story = {
302
+ export const ActionVariants: Story = {
327
303
  render: () => (
328
304
  <div className="flex flex-col">
329
305
  {ACTION_VARIANTS.map(({label, run, props}, index) => (
@@ -333,9 +309,6 @@ export const ActionVariantsWithSource: Story = {
333
309
  ...run,
334
310
  id: `22222222-2222-4222-8222-${String(index + 2).padStart(12, '0')}`,
335
311
  }}
336
- sourceAvailable
337
- sourceOpen={label === 'Running'}
338
- sourcePanelId={`workflow-source-panel-${index}`}
339
312
  {...props}
340
313
  />
341
314
  ))}
@@ -410,3 +383,23 @@ export const LongTriggerMetadata: Story = {
410
383
  }),
411
384
  },
412
385
  };
386
+
387
+ export const NarrowLongContent: Story = {
388
+ decorators: [
389
+ (Story) => (
390
+ <div className="w-[360px] max-w-full overflow-hidden border-x border-border-neutral-base">
391
+ <Story />
392
+ </div>
393
+ ),
394
+ ],
395
+ args: {
396
+ run: workflowRunDetail({
397
+ status: 'running',
398
+ name: 'release-production-multi-region-with-canary-and-post-deploy-validation',
399
+ trigger_provider: 'github',
400
+ trigger_source: 'github-enterprise-cloud-production-organization',
401
+ trigger_event: 'workflow_dispatch_with_release_candidate_payload',
402
+ }),
403
+ onCancel: noop,
404
+ },
405
+ };
@@ -34,6 +34,8 @@ describe('WorkflowRunSummary', () => {
34
34
 
35
35
  const summary = await screen.findByRole('region', {name: 'deploy-web'});
36
36
 
37
+ expect(summary).toHaveClass('bg-background-neutral-background', 'px-16');
38
+ expect(summary.firstElementChild).not.toHaveClass('max-w-[1120px]');
37
39
  expect(within(summary).getByRole('heading', {name: 'deploy-web'})).toBeInTheDocument();
38
40
  expect(within(summary).getAllByText('Running')).not.toHaveLength(0);
39
41
  expect(within(summary).getByText('CI #5184')).toBeInTheDocument();
@@ -128,27 +130,6 @@ describe('WorkflowRunSummary', () => {
128
130
  );
129
131
  });
130
132
 
131
- test('renders source control only when source is available', async () => {
132
- const user = userEvent.setup();
133
- const onSourceToggle = vi.fn();
134
- renderSummary(
135
- {source_snapshot: {format: 'yaml', content: 'name: deploy-web'}},
136
- {
137
- sourceAvailable: true,
138
- sourceOpen: false,
139
- sourcePanelId: 'workflow-source-panel',
140
- onSourceToggle,
141
- },
142
- );
143
-
144
- const sourceButton = await screen.findByRole('button', {name: 'View source'});
145
- await user.click(sourceButton);
146
-
147
- expect(sourceButton).toHaveAttribute('aria-controls', 'workflow-source-panel');
148
- expect(sourceButton).toHaveAttribute('aria-expanded', 'false');
149
- expect(onSourceToggle).toHaveBeenCalledTimes(1);
150
- });
151
-
152
133
  test('shows the selected attempt duration, not the top-level run duration', async () => {
153
134
  renderSummary({
154
135
  started_at: '2026-05-07T00:00:00.000Z',
@@ -193,7 +174,7 @@ describe('WorkflowRunSummary', () => {
193
174
  expect(duration).toHaveAttribute('aria-label', 'running 2m 14s');
194
175
  });
195
176
 
196
- test('omits source control when source is unavailable', async () => {
177
+ test('does not render a whole-run source control', async () => {
197
178
  renderSummary();
198
179
 
199
180
  await screen.findByRole('region', {name: 'deploy-web'});
@@ -12,7 +12,7 @@ import {RelativeTime} from '@shipfox/react-ui/relative-time';
12
12
  import {TimeTickerProvider} from '@shipfox/react-ui/time-ticker';
13
13
  import {Tooltip, TooltipContent, TooltipTrigger} from '@shipfox/react-ui/tooltip';
14
14
  import {Header, Text} from '@shipfox/react-ui/typography';
15
- import type {Ref} from 'react';
15
+ import {Link} from '@tanstack/react-router';
16
16
  import {useId} from 'react';
17
17
  import {WorkflowRunNumberLabel} from '#components/workflow-run-number-label.js';
18
18
  import {
@@ -22,6 +22,7 @@ import {
22
22
  type WorkflowRunDetail,
23
23
  type WorkflowRunRerunMode,
24
24
  } from '#core/workflow-run.js';
25
+ import {validateWorkflowRunsSearch, workflowRunListSearchParams} from '#routes/inputs.js';
25
26
  import {WorkflowRunDurationLabel} from '../workflow-run-duration-label.js';
26
27
  import {getWorkflowStatusVisual} from '../workflow-status/status-visuals.js';
27
28
  import {WorkflowRunAttemptSwitcher} from './workflow-run-attempt-switcher.js';
@@ -36,11 +37,6 @@ export interface WorkflowRunSummaryProps {
36
37
  workspaceSlug?: string | undefined;
37
38
  projectSlug?: string | undefined;
38
39
  run: WorkflowRunDetail;
39
- sourceAvailable?: boolean | undefined;
40
- sourceOpen?: boolean | undefined;
41
- sourcePanelId?: string | undefined;
42
- sourceButtonRef?: Ref<HTMLButtonElement> | undefined;
43
- onSourceToggle?: (() => void) | undefined;
44
40
  cancelling?: boolean | undefined;
45
41
  onCancel?: (() => void) | undefined;
46
42
  rerunPending?: boolean | undefined;
@@ -52,11 +48,6 @@ export function WorkflowRunSummary({
52
48
  workspaceSlug,
53
49
  projectSlug,
54
50
  run,
55
- sourceAvailable = false,
56
- sourceOpen = false,
57
- sourcePanelId,
58
- sourceButtonRef,
59
- onSourceToggle,
60
51
  cancelling = false,
61
52
  onCancel,
62
53
  rerunPending = false,
@@ -66,6 +57,7 @@ export function WorkflowRunSummary({
66
57
  const headingId = useId();
67
58
  const status = getWorkflowStatusVisual(run.runAttempt.status);
68
59
  const action = workflowRunActionForRun(run);
60
+ const hasAction = canRenderWorkflowRunAction(action, onCancel, onRerun);
69
61
  const attemptSwitcher =
70
62
  latestAttempt && latestAttempt > 1 && workspaceSlug && projectSlug
71
63
  ? {workspaceSlug, projectSlug, latestAttempt}
@@ -76,64 +68,77 @@ export function WorkflowRunSummary({
76
68
 
77
69
  return (
78
70
  <TimeTickerProvider intervalMs={1000} reducedMotionIntervalMs={10_000}>
79
- <section
80
- aria-labelledby={headingId}
81
- className="border-b border-border-neutral-base bg-background-subtle-base px-16 py-8"
82
- >
83
- <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-12 gap-y-4 overflow-hidden">
84
- <div className="col-start-1 row-start-1 flex min-w-0 items-center gap-8">
85
- <Badge variant={status.badge} size="xs">
86
- <span className="text-center" style={{width: `${STATUS_BADGE_LABEL_WIDTH_CH}ch`}}>
87
- {status.label}
88
- </span>
89
- </Badge>
90
-
91
- <Tooltip>
92
- <TooltipTrigger asChild>
93
- <Header id={headingId} variant="h3" className="min-w-0 truncate">
94
- <span
95
- ref={headingTextRef}
96
- title={isHeadingTruncated ? run.name : undefined}
97
- className="block min-w-0 truncate"
71
+ <section aria-labelledby={headingId} className="bg-background-neutral-background px-16 py-12">
72
+ <div className="grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-x-12 gap-y-8 overflow-hidden max-[480px]:grid-cols-1">
73
+ <nav
74
+ aria-label="Breadcrumb"
75
+ className="col-start-1 row-start-1 min-w-0 max-[480px]:col-start-auto max-[480px]:row-start-auto"
76
+ >
77
+ <ol className="flex min-w-0 items-center gap-8">
78
+ {workspaceSlug && projectSlug ? (
79
+ <li className="shrink-0">
80
+ <Link
81
+ to="/w/$workspaceSlug/p/$projectSlug/runs"
82
+ params={{workspaceSlug, projectSlug}}
83
+ search={
84
+ ((previous: Record<string, unknown>) =>
85
+ workflowRunListSearchParams(validateWorkflowRunsSearch(previous))) as never
86
+ }
87
+ className="rounded-4 text-xs font-medium text-foreground-neutral-subtle underline decoration-border-neutral-strong underline-offset-4 outline-none hover:text-foreground-neutral-base focus-visible:shadow-border-interactive-with-active"
98
88
  >
99
- {run.name}
100
- </span>
101
- </Header>
102
- </TooltipTrigger>
103
- {isHeadingTruncated ? (
104
- <TooltipContent>
105
- <Text as="span" size="xs" className="max-w-[360px] break-words">
106
- {run.name}
107
- </Text>
108
- </TooltipContent>
89
+ Runs
90
+ </Link>
91
+ </li>
109
92
  ) : null}
110
- </Tooltip>
111
- </div>
93
+ {workspaceSlug && projectSlug ? (
94
+ <li aria-hidden="true" className="shrink-0 text-xs text-foreground-neutral-subtle">
95
+ /
96
+ </li>
97
+ ) : null}
98
+ <li aria-current="page" className="flex min-w-0 items-center gap-8">
99
+ <Badge variant={status.badge} size="xs">
100
+ <span className="text-center" style={{width: `${STATUS_BADGE_LABEL_WIDTH_CH}ch`}}>
101
+ {status.label}
102
+ </span>
103
+ </Badge>
112
104
 
113
- <div className="col-start-2 row-start-1 flex min-w-max items-center gap-6 justify-self-end">
114
- <WorkflowRunActionSlot
115
- action={action}
116
- cancelling={cancelling}
117
- onCancel={onCancel}
118
- rerunPending={rerunPending}
119
- onRerun={onRerun}
120
- />
121
- {sourceAvailable ? (
122
- <Button
123
- ref={sourceButtonRef}
124
- type="button"
125
- variant="secondary"
126
- size="xs"
127
- aria-controls={sourcePanelId}
128
- aria-expanded={sourceOpen}
129
- onClick={onSourceToggle}
130
- >
131
- View source
132
- </Button>
133
- ) : null}
134
- </div>
105
+ <Tooltip>
106
+ <TooltipTrigger asChild>
107
+ <Header id={headingId} variant="h3" className="min-w-0 truncate">
108
+ <span
109
+ ref={headingTextRef}
110
+ title={isHeadingTruncated ? run.name : undefined}
111
+ className="block min-w-0 truncate"
112
+ >
113
+ {run.name}
114
+ </span>
115
+ </Header>
116
+ </TooltipTrigger>
117
+ {isHeadingTruncated ? (
118
+ <TooltipContent>
119
+ <Text as="span" size="xs" className="max-w-[360px] break-words">
120
+ {run.name}
121
+ </Text>
122
+ </TooltipContent>
123
+ ) : null}
124
+ </Tooltip>
125
+ </li>
126
+ </ol>
127
+ </nav>
135
128
 
136
- <div className="col-span-2 row-start-2 flex min-w-0 items-center gap-12 overflow-hidden text-foreground-neutral-muted">
129
+ {hasAction ? (
130
+ <div className="col-start-2 row-start-1 flex min-w-max items-center gap-6 justify-self-end max-[480px]:col-start-auto max-[480px]:row-start-auto max-[480px]:justify-self-start">
131
+ <WorkflowRunActionSlot
132
+ action={action}
133
+ cancelling={cancelling}
134
+ onCancel={onCancel}
135
+ rerunPending={rerunPending}
136
+ onRerun={onRerun}
137
+ />
138
+ </div>
139
+ ) : null}
140
+
141
+ <div className="col-span-2 row-start-2 flex min-w-0 items-center gap-12 overflow-hidden text-foreground-neutral-subtle max-[480px]:col-span-1 max-[480px]:row-start-auto">
137
142
  {run.number !== null ? (
138
143
  <>
139
144
  <WorkflowRunNumberLabel run={run} />
@@ -159,7 +164,7 @@ export function WorkflowRunSummary({
159
164
  <button
160
165
  type="button"
161
166
  aria-label={run.triggerLabel}
162
- className="inline-flex max-w-full min-w-0 items-center gap-4 rounded-6 border-0 bg-transparent p-0 text-left text-foreground-neutral-muted outline-none focus-visible:shadow-button-neutral-focus"
167
+ className="inline-flex max-w-full min-w-0 cursor-help items-center gap-4 rounded-6 border-0 bg-transparent p-0 text-left text-foreground-neutral-subtle outline-none focus-visible:shadow-button-neutral-focus"
163
168
  >
164
169
  <TriggerSourceIcon
165
170
  provider={run.triggerProvider}
@@ -187,7 +192,7 @@ export function WorkflowRunSummary({
187
192
  ) : null}
188
193
  <RelativeTime
189
194
  value={run.runAttempt.createdAt}
190
- className="shrink-0 whitespace-nowrap text-xs leading-20 text-foreground-neutral-muted"
195
+ className="shrink-0 whitespace-nowrap text-xs leading-20 text-foreground-neutral-subtle"
191
196
  />
192
197
 
193
198
  {displayDuration ? (
@@ -195,7 +200,7 @@ export function WorkflowRunSummary({
195
200
  <MetadataSeparator />
196
201
  <WorkflowRunDurationLabel
197
202
  duration={displayDuration}
198
- className="text-foreground-neutral-muted"
203
+ className="text-foreground-neutral-subtle"
199
204
  />
200
205
  </>
201
206
  ) : null}
@@ -290,6 +295,16 @@ function workflowRunActionForRun(run: WorkflowRunDetail): WorkflowRunAction {
290
295
  return 'rerun-menu';
291
296
  }
292
297
 
298
+ function canRenderWorkflowRunAction(
299
+ action: WorkflowRunAction,
300
+ onCancel: (() => void) | undefined,
301
+ onRerun: ((mode: WorkflowRunRerunMode) => void) | undefined,
302
+ ): boolean {
303
+ if (action === 'cancel') return onCancel !== undefined;
304
+ if (action === 'rerun-all' || action === 'rerun-menu') return onRerun !== undefined;
305
+ return false;
306
+ }
307
+
293
308
  function hasFailedOrCancelledJobs(run: WorkflowRunDetail): boolean {
294
309
  if (!workflowRunHasJobs(run)) return false;
295
310
 
@@ -300,6 +315,6 @@ function workflowRunHasJobs(run: WorkflowRunDetail): run is WorkflowRunDetail &
300
315
  return 'jobs' in run && Array.isArray(run.jobs);
301
316
  }
302
317
 
303
- function MetadataSeparator() {
318
+ export function MetadataSeparator() {
304
319
  return <span aria-hidden="true" className="h-12 w-px shrink-0 bg-border-neutral-base" />;
305
320
  }