@ryanfw/prompt-orchestration-pipeline 0.5.0 → 0.7.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 (67) hide show
  1. package/README.md +1 -2
  2. package/package.json +1 -2
  3. package/src/api/validators/json.js +39 -0
  4. package/src/components/DAGGrid.jsx +392 -303
  5. package/src/components/JobCard.jsx +14 -12
  6. package/src/components/JobDetail.jsx +54 -51
  7. package/src/components/JobTable.jsx +72 -23
  8. package/src/components/Layout.jsx +145 -42
  9. package/src/components/LiveText.jsx +47 -0
  10. package/src/components/PageSubheader.jsx +75 -0
  11. package/src/components/TaskDetailSidebar.jsx +216 -0
  12. package/src/components/TimerText.jsx +82 -0
  13. package/src/components/UploadSeed.jsx +0 -70
  14. package/src/components/ui/Logo.jsx +16 -0
  15. package/src/components/ui/RestartJobModal.jsx +140 -0
  16. package/src/components/ui/toast.jsx +138 -0
  17. package/src/config/models.js +322 -0
  18. package/src/config/statuses.js +119 -0
  19. package/src/core/config.js +4 -34
  20. package/src/core/file-io.js +13 -28
  21. package/src/core/module-loader.js +54 -40
  22. package/src/core/pipeline-runner.js +65 -26
  23. package/src/core/status-writer.js +213 -58
  24. package/src/core/symlink-bridge.js +57 -0
  25. package/src/core/symlink-utils.js +94 -0
  26. package/src/core/task-runner.js +321 -437
  27. package/src/llm/index.js +258 -86
  28. package/src/pages/Code.jsx +351 -0
  29. package/src/pages/PipelineDetail.jsx +124 -15
  30. package/src/pages/PromptPipelineDashboard.jsx +20 -88
  31. package/src/providers/anthropic.js +83 -69
  32. package/src/providers/base.js +52 -0
  33. package/src/providers/deepseek.js +20 -21
  34. package/src/providers/gemini.js +226 -0
  35. package/src/providers/openai.js +36 -106
  36. package/src/providers/zhipu.js +136 -0
  37. package/src/ui/client/adapters/job-adapter.js +42 -28
  38. package/src/ui/client/api.js +134 -0
  39. package/src/ui/client/hooks/useJobDetailWithUpdates.js +65 -179
  40. package/src/ui/client/index.css +15 -0
  41. package/src/ui/client/index.html +2 -1
  42. package/src/ui/client/main.jsx +19 -14
  43. package/src/ui/client/time-store.js +161 -0
  44. package/src/ui/config-bridge.js +15 -24
  45. package/src/ui/config-bridge.node.js +15 -24
  46. package/src/ui/dist/assets/{index-CxcrauYR.js → index-DqkbzXZ1.js} +2132 -1086
  47. package/src/ui/dist/assets/style-DBF9NQGk.css +62 -0
  48. package/src/ui/dist/index.html +4 -3
  49. package/src/ui/job-reader.js +0 -108
  50. package/src/ui/public/favicon.svg +12 -0
  51. package/src/ui/server.js +252 -0
  52. package/src/ui/sse-enhancer.js +0 -1
  53. package/src/ui/transformers/list-transformer.js +32 -12
  54. package/src/ui/transformers/status-transformer.js +29 -42
  55. package/src/utils/dag.js +8 -4
  56. package/src/utils/duration.js +13 -19
  57. package/src/utils/formatters.js +27 -0
  58. package/src/utils/geometry-equality.js +83 -0
  59. package/src/utils/pipelines.js +5 -1
  60. package/src/utils/time-utils.js +40 -0
  61. package/src/utils/token-cost-calculator.js +294 -0
  62. package/src/utils/ui.jsx +18 -20
  63. package/src/components/ui/select.jsx +0 -27
  64. package/src/lib/utils.js +0 -6
  65. package/src/ui/client/hooks/useTicker.js +0 -26
  66. package/src/ui/config-bridge.browser.js +0 -149
  67. package/src/ui/dist/assets/style-D6K_oQ12.css +0 -62
@@ -0,0 +1,351 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { Box, Heading, Table, Code, Text } from "@radix-ui/themes";
3
+ import Layout from "../components/Layout.jsx";
4
+ import PageSubheader from "../components/PageSubheader.jsx";
5
+ import { Button } from "../components/ui/button.jsx";
6
+
7
+ const ioFunctions = [
8
+ {
9
+ name: "writeArtifact",
10
+ description: "Write an artifact file",
11
+ params:
12
+ 'name: string, content: string, options?: { mode?: "replace"|"append"=replace }',
13
+ returns: "Promise<string>",
14
+ notes: "Writes to {workDir}/files/artifacts; updates tasks-status.json",
15
+ },
16
+ {
17
+ name: "writeLog",
18
+ description: "Write a log file",
19
+ params:
20
+ 'name: string, content: string, options?: { mode?: "append"|"replace"=append }',
21
+ returns: "Promise<string>",
22
+ notes:
23
+ "Writes to {workDir}/files/logs; default append; updates tasks-status.json",
24
+ },
25
+ {
26
+ name: "writeTmp",
27
+ description: "Write a temporary file",
28
+ params:
29
+ 'name: string, content: string, options?: { mode?: "replace"|"append"=replace }',
30
+ returns: "Promise<string>",
31
+ notes: "Writes to {workDir}/files/tmp; updates tasks-status.json",
32
+ },
33
+ {
34
+ name: "readArtifact",
35
+ description: "Read an artifact file",
36
+ params: "name: string",
37
+ returns: "Promise<string>",
38
+ notes: "Reads from {workDir}/files/artifacts",
39
+ },
40
+ {
41
+ name: "readLog",
42
+ description: "Read a log file",
43
+ params: "name: string",
44
+ returns: "Promise<string>",
45
+ notes: "Reads from {workDir}/files/logs",
46
+ },
47
+ {
48
+ name: "readTmp",
49
+ description: "Read a temporary file",
50
+ params: "name: string",
51
+ returns: "Promise<string>",
52
+ notes: "Reads from {workDir}/files/tmp",
53
+ },
54
+ {
55
+ name: "getTaskDir",
56
+ description: "Get the task directory path",
57
+ params: "",
58
+ returns: "string",
59
+ notes: "Returns {workDir}/tasks/{taskName}",
60
+ },
61
+ {
62
+ name: "getCurrentStage",
63
+ description: "Get the current stage name",
64
+ params: "",
65
+ returns: "string",
66
+ notes: "Calls injected getStage()",
67
+ },
68
+ ];
69
+
70
+ const sampleSeed = {
71
+ name: "some-name",
72
+ pipeline: "content-generation",
73
+ data: {
74
+ type: "some-type",
75
+ contentType: "blog-post",
76
+ targetAudience: "software-developers",
77
+ tone: "professional-yet-accessible",
78
+ length: "1500-2000 words",
79
+ outputFormat: "blog-post",
80
+ },
81
+ };
82
+
83
+ export default function CodePage() {
84
+ const [llmFunctions, setLlmFunctions] = useState(null);
85
+
86
+ useEffect(() => {
87
+ fetch("/api/llm/functions")
88
+ .then((res) => res.json())
89
+ .then(setLlmFunctions)
90
+ .catch(console.error);
91
+ }, []);
92
+
93
+ const breadcrumbs = [{ label: "Home", href: "/" }, { label: "Code" }];
94
+
95
+ const handleCopySeed = () => {
96
+ navigator.clipboard.writeText(JSON.stringify(sampleSeed, null, 2));
97
+ };
98
+
99
+ return (
100
+ <Layout>
101
+ <PageSubheader breadcrumbs={breadcrumbs} />
102
+ <Box>
103
+ {/* Seed File Example Section */}
104
+ <Box mb="8">
105
+ <Heading size="6" mb="4">
106
+ Seed File Example
107
+ </Heading>
108
+ <Text as="p" mb="3" size="2">
109
+ A seed file is a JSON object used to start a new pipeline job. It
110
+ defines the job name, the pipeline to run, and any contextual data
111
+ the pipeline requires to begin.
112
+ </Text>
113
+ <Text as="p" mb="3" size="2" weight="bold">
114
+ Required fields:
115
+ </Text>
116
+ <ul className="list-disc list-inside mb-4 space-y-1">
117
+ <li className="text-sm text-gray-700">
118
+ <Text as="span" weight="bold">
119
+ name
120
+ </Text>{" "}
121
+ (string): Human-friendly title; non-empty, printable only, ≤120
122
+ chars; must be unique.
123
+ </li>
124
+ <li className="text-sm text-gray-700">
125
+ <Text as="span" weight="bold">
126
+ pipeline
127
+ </Text>{" "}
128
+ (string): Pipeline slug defined in your registry (e.g.,
129
+ content-generation).
130
+ </li>
131
+ <li className="text-sm text-gray-700">
132
+ <Text as="span" weight="bold">
133
+ data
134
+ </Text>{" "}
135
+ (object): Required but flexible; include any arbitrary keys your
136
+ pipeline tasks expect.
137
+ </li>
138
+ </ul>
139
+ <Box mb="3">
140
+ <Button
141
+ size="1"
142
+ onClick={handleCopySeed}
143
+ data-testid="copy-seed-example"
144
+ >
145
+ Copy
146
+ </Button>
147
+ </Box>
148
+ <pre className="text-xs bg-gray-50 p-3 rounded overflow-auto max-h-60 border border-gray-200">
149
+ {JSON.stringify(sampleSeed, null, 2)}
150
+ </pre>
151
+ </Box>
152
+
153
+ <Heading size="6" mb="4">
154
+ Pipeline Task IO API
155
+ </Heading>
156
+ <Box overflowX="auto">
157
+ <Table.Root>
158
+ <Table.Header>
159
+ <Table.Row>
160
+ <Table.ColumnHeaderCell>Function</Table.ColumnHeaderCell>
161
+ <Table.ColumnHeaderCell>Parameters</Table.ColumnHeaderCell>
162
+ <Table.ColumnHeaderCell>Returns</Table.ColumnHeaderCell>
163
+ <Table.ColumnHeaderCell>Notes</Table.ColumnHeaderCell>
164
+ </Table.Row>
165
+ </Table.Header>
166
+ <Table.Body>
167
+ {ioFunctions.map((fn) => (
168
+ <Table.Row key={fn.name}>
169
+ <Table.RowHeaderCell>
170
+ <Code size="3">io.{fn.name}</Code>
171
+ </Table.RowHeaderCell>
172
+ <Table.Cell>
173
+ <Code size="3">{fn.params || "—"}</Code>
174
+ </Table.Cell>
175
+ <Table.Cell>
176
+ <Code size="3">{fn.returns}</Code>
177
+ </Table.Cell>
178
+ <Table.Cell>
179
+ {fn.description}
180
+ <br />
181
+ {fn.notes}
182
+ </Table.Cell>
183
+ </Table.Row>
184
+ ))}
185
+ </Table.Body>
186
+ </Table.Root>
187
+ </Box>
188
+
189
+ <Heading size="6" mt="8" mb="4">
190
+ Pipeline Task LLM API
191
+ </Heading>
192
+ <Box mb="4">
193
+ <Heading size="4" mb="2">
194
+ Arguments
195
+ </Heading>
196
+ <Code size="3" mb="4">
197
+ {`{
198
+ messages: Array<{role: "system"|"user"|"assistant", content: string }>,
199
+ temperature?: number,
200
+ maxTokens?: number,
201
+ responseFormat?: "json" | { type: "json_object" | { type: "json_schema", name: string, json_schema: object } },
202
+ stop?: string | string[],
203
+ topP?: number,
204
+ frequencyPenalty?: number,
205
+ presencePenalty?: number,
206
+ seed?: number,
207
+ provider?: string,
208
+ model?: string,
209
+ metadata?: object,
210
+ maxRetries?: number
211
+ }`}
212
+ </Code>
213
+ <Heading size="4" mb="2">
214
+ Returns
215
+ </Heading>
216
+ <Code size="3">{`Promise<{ content: any, usage?: object, raw?: any }>`}</Code>
217
+ </Box>
218
+
219
+ {llmFunctions && (
220
+ <Box overflowX="auto">
221
+ <Table.Root>
222
+ <Table.Header>
223
+ <Table.Row>
224
+ <Table.ColumnHeaderCell>Function</Table.ColumnHeaderCell>
225
+ <Table.ColumnHeaderCell>Model</Table.ColumnHeaderCell>
226
+ </Table.Row>
227
+ </Table.Header>
228
+ <Table.Body>
229
+ {Object.entries(llmFunctions).map(([provider, functions]) =>
230
+ functions.map((fn) => (
231
+ <Table.Row key={fn.fullPath}>
232
+ <Table.RowHeaderCell>
233
+ <Code size="3">{fn.fullPath}</Code>
234
+ </Table.RowHeaderCell>
235
+ <Table.Cell>
236
+ <Code size="3">{fn.model}</Code>
237
+ </Table.Cell>
238
+ </Table.Row>
239
+ ))
240
+ )}
241
+ </Table.Body>
242
+ </Table.Root>
243
+ </Box>
244
+ )}
245
+
246
+ <Heading size="6" mt="8" mb="4">
247
+ Validation API
248
+ </Heading>
249
+ <Text as="p" mb="3" size="2">
250
+ Schema validation helper available to task stages via validators.
251
+ </Text>
252
+ <Box mb="4">
253
+ <Heading size="4" mb="2">
254
+ Function Signature
255
+ </Heading>
256
+ <Code size="3">
257
+ {`validateWithSchema(schema: object, data: object | string): { valid: true } | { valid: false, errors: AjvError[] }`}
258
+ </Code>
259
+ <Heading size="4" mb="2" mt="4">
260
+ Behavior
261
+ </Heading>
262
+ <ul className="list-disc list-inside mb-4 space-y-1">
263
+ <li className="text-sm text-gray-700">
264
+ <Text as="span">
265
+ Parses string data to JSON; on parse failure returns{" "}
266
+ {`{ valid:false, errors:[{ keyword:"type", message:"must be a valid JSON object (string parsing failed)"} ]`}
267
+ </Text>
268
+ </li>
269
+ <li className="text-sm text-gray-700">
270
+ <Text as="span">
271
+ Uses Ajv({`{ allErrors: true, strict: false }`}); compiles
272
+ provided schema
273
+ </Text>
274
+ </li>
275
+ <li className="text-sm text-gray-700">
276
+ <Text as="span">Returns AJV errors array when invalid</Text>
277
+ </li>
278
+ </ul>
279
+ <Heading size="4" mb="2">
280
+ Source
281
+ </Heading>
282
+ <Code size="3">src/api/validators/json.js</Code>
283
+ <Heading size="4" mb="2" mt="4">
284
+ Usage Example
285
+ </Heading>
286
+ <Code size="3">{`export const validateStructure = async ({
287
+ io,
288
+ flags,
289
+ validators: { validateWithSchema },
290
+ }) => {
291
+ const content = await io.readArtifact("research-output.json");
292
+ const result = validateWithSchema(researchJsonSchema, content);
293
+
294
+ if (!result.valid) {
295
+ console.warn("[Research:validateStructure] Validation failed", result.errors);
296
+ return { output: {}, flags: { ...flags, validationFailed: true } };
297
+ }
298
+ return { output: {}, flags };
299
+ };`}</Code>
300
+ </Box>
301
+
302
+ <Heading size="6" mt="8" mb="4">
303
+ Environment Configuration
304
+ </Heading>
305
+ <Box mb="4">
306
+ <Heading size="4" mb="2">
307
+ Example .env Configuration
308
+ </Heading>
309
+ <Box overflowX="auto">
310
+ <Table.Root>
311
+ <Table.Header>
312
+ <Table.Row>
313
+ <Table.ColumnHeaderCell>
314
+ Environment Variable
315
+ </Table.ColumnHeaderCell>
316
+ </Table.Row>
317
+ </Table.Header>
318
+ <Table.Body>
319
+ <Table.Row>
320
+ <Table.RowHeaderCell>
321
+ <Code size="3">OPENAI_API_KEY=</Code>
322
+ </Table.RowHeaderCell>
323
+ </Table.Row>
324
+ <Table.Row>
325
+ <Table.RowHeaderCell>
326
+ <Code size="3">DEEPSEEK_API_KEY=</Code>
327
+ </Table.RowHeaderCell>
328
+ </Table.Row>
329
+ <Table.Row>
330
+ <Table.RowHeaderCell>
331
+ <Code size="3">GEMINI_API_KEY=</Code>
332
+ </Table.RowHeaderCell>
333
+ </Table.Row>
334
+ <Table.Row>
335
+ <Table.RowHeaderCell>
336
+ <Code size="3">ANTHROPIC_API_KEY=</Code>
337
+ </Table.RowHeaderCell>
338
+ </Table.Row>
339
+ <Table.Row>
340
+ <Table.RowHeaderCell>
341
+ <Code size="3">ZHIPU_API_KEY=</Code>
342
+ </Table.RowHeaderCell>
343
+ </Table.Row>
344
+ </Table.Body>
345
+ </Table.Root>
346
+ </Box>
347
+ </Box>
348
+ </Box>
349
+ </Layout>
350
+ );
351
+ }
@@ -1,10 +1,13 @@
1
1
  import React from "react";
2
- import { useParams } from "react-router-dom";
2
+ import { data, useParams } from "react-router-dom";
3
3
  import { Box, Flex, Text } from "@radix-ui/themes";
4
+ import * as Tooltip from "@radix-ui/react-tooltip";
4
5
  import JobDetail from "../components/JobDetail.jsx";
5
6
  import { useJobDetailWithUpdates } from "../ui/client/hooks/useJobDetailWithUpdates.js";
6
7
  import Layout from "../components/Layout.jsx";
8
+ import PageSubheader from "../components/PageSubheader.jsx";
7
9
  import { statusBadge } from "../utils/ui.jsx";
10
+ import { formatCurrency4, formatTokensCompact } from "../utils/formatters.js";
8
11
 
9
12
  export default function PipelineDetail() {
10
13
  const { jobId } = useParams();
@@ -12,7 +15,10 @@ export default function PipelineDetail() {
12
15
  // Handle missing job ID (undefined/null)
13
16
  if (jobId === undefined || jobId === null) {
14
17
  return (
15
- <Layout title="Pipeline Details" showBackButton={true}>
18
+ <Layout
19
+ pageTitle="Pipeline Details"
20
+ breadcrumbs={[{ label: "Home", href: "/" }]}
21
+ >
16
22
  <Flex align="center" justify="center" className="min-h-64">
17
23
  <Box className="text-center">
18
24
  <Text size="5" weight="medium" color="red" className="mb-2">
@@ -24,11 +30,31 @@ export default function PipelineDetail() {
24
30
  );
25
31
  }
26
32
 
27
- const { data: job, loading, error } = useJobDetailWithUpdates(jobId);
33
+ const {
34
+ data: job,
35
+ loading,
36
+ error,
37
+ isRefreshing,
38
+ isHydrated,
39
+ } = useJobDetailWithUpdates(jobId);
28
40
 
29
- if (loading) {
41
+ // Only show loading screen on initial load, not during refresh
42
+ const showLoadingScreen = loading && !isHydrated;
43
+
44
+ if (showLoadingScreen) {
30
45
  return (
31
- <Layout title="Pipeline Details" showBackButton={true}>
46
+ <Layout
47
+ pageTitle="Pipeline Details"
48
+ breadcrumbs={[
49
+ { label: "Home", href: "/" },
50
+ {
51
+ label:
52
+ job && job?.pipelineLabel
53
+ ? job.pipelineLabel
54
+ : "Pipeline Details",
55
+ },
56
+ ]}
57
+ >
32
58
  <Flex align="center" justify="center" className="min-h-64">
33
59
  <Box className="text-center">
34
60
  <Text size="5" weight="medium" className="mb-2">
@@ -42,7 +68,18 @@ export default function PipelineDetail() {
42
68
 
43
69
  if (error) {
44
70
  return (
45
- <Layout title="Pipeline Details" showBackButton={true}>
71
+ <Layout
72
+ pageTitle="Pipeline Details"
73
+ breadcrumbs={[
74
+ { label: "Home", href: "/" },
75
+ {
76
+ label:
77
+ job && job?.pipelineLabel
78
+ ? job.pipelineLabel
79
+ : "Pipeline Details",
80
+ },
81
+ ]}
82
+ >
46
83
  <Flex align="center" justify="center" className="min-h-64">
47
84
  <Box className="text-center">
48
85
  <Text size="5" weight="medium" color="red" className="mb-2">
@@ -58,8 +95,15 @@ export default function PipelineDetail() {
58
95
  }
59
96
 
60
97
  if (!job) {
98
+ const pipelineDisplay = "Pipeline Details";
61
99
  return (
62
- <Layout title="Pipeline Details" showBackButton={true}>
100
+ <Layout
101
+ pageTitle="Pipeline Details"
102
+ breadcrumbs={[
103
+ { label: "Home", href: "/" },
104
+ { label: job.pipelineLabel || "Pipeline Details" },
105
+ ]}
106
+ >
63
107
  <Flex align="center" justify="center" className="min-h-64">
64
108
  <Box className="text-center">
65
109
  <Text size="5" weight="medium" className="mb-2">
@@ -89,22 +133,87 @@ export default function PipelineDetail() {
89
133
  return { tasks: pipelineTasks };
90
134
  })();
91
135
 
92
- // Header actions: job ID and status badge
93
- const headerActions = (
94
- <Flex align="center" gap="3" className="shrink-0">
136
+ const pageTitle = job.name || "Pipeline Details";
137
+
138
+ const breadcrumbs = [
139
+ { label: "Home", href: "/" },
140
+ {
141
+ label: job && job?.pipelineLabel ? job.pipelineLabel : "Pipeline Details",
142
+ },
143
+ ...(job.name ? [{ label: job.name }] : []),
144
+ ];
145
+
146
+ // Derive cost data from job object with safe fallbacks
147
+ const totalCost = job?.totalCost || job?.costs?.summary?.totalCost || 0;
148
+ const totalTokens = job?.totalTokens || job?.costs?.summary?.totalTokens || 0;
149
+ const totalInputTokens = job?.costs?.summary?.totalInputTokens || 0;
150
+ const totalOutputTokens = job?.costs?.summary?.totalOutputTokens || 0;
151
+
152
+ // Create cost indicator with tooltip when token data is available
153
+ const costIndicator = (
154
+ <Text size="2" color="gray">
155
+ Cost: {totalCost > 0 ? formatCurrency4(totalCost) : "—"}
156
+ </Text>
157
+ );
158
+
159
+ const costIndicatorWithTooltip =
160
+ totalCost > 0 && totalTokens > 0 ? (
161
+ <Tooltip.Provider delayDuration={100}>
162
+ <Tooltip.Root>
163
+ <Tooltip.Trigger asChild>
164
+ <Box
165
+ className="cursor-help border-b border-dotted border-gray-400 hover:border-gray-600 transition-colors"
166
+ aria-label={`Total cost: ${formatCurrency4(totalCost)}, ${formatTokensCompact(totalTokens)}`}
167
+ >
168
+ {costIndicator}
169
+ </Box>
170
+ </Tooltip.Trigger>
171
+ <Tooltip.Portal>
172
+ <Tooltip.Content
173
+ className="bg-gray-900 text-white px-2 py-1 rounded text-xs max-w-xs"
174
+ sideOffset={5}
175
+ >
176
+ <div className="space-y-1">
177
+ <div className="font-semibold">
178
+ {formatTokensCompact(totalTokens)}
179
+ </div>
180
+ {totalInputTokens > 0 && totalOutputTokens > 0 && (
181
+ <div className="text-gray-300">
182
+ Input: {formatTokensCompact(totalInputTokens)} • Output:{" "}
183
+ {formatTokensCompact(totalOutputTokens)}
184
+ </div>
185
+ )}
186
+ </div>
187
+ <Tooltip.Arrow className="fill-gray-900" />
188
+ </Tooltip.Content>
189
+ </Tooltip.Portal>
190
+ </Tooltip.Root>
191
+ </Tooltip.Provider>
192
+ ) : (
193
+ costIndicator
194
+ );
195
+
196
+ // Right side content for PageSubheader: job ID, cost indicator, and status badge
197
+ const subheaderRightContent = (
198
+ <Flex align="center" gap="3" className="shrink-0 flex-wrap">
95
199
  <Text size="2" color="gray">
96
200
  ID: {job.id || jobId}
97
201
  </Text>
202
+ {costIndicatorWithTooltip}
98
203
  {statusBadge(job.status)}
99
204
  </Flex>
100
205
  );
101
206
 
102
207
  return (
103
- <Layout
104
- title={job.name || "Pipeline Details"}
105
- showBackButton={true}
106
- actions={headerActions}
107
- >
208
+ <Layout pageTitle={pageTitle} breadcrumbs={breadcrumbs}>
209
+ <PageSubheader breadcrumbs={breadcrumbs} maxWidth="max-w-7xl">
210
+ {subheaderRightContent}
211
+ {isRefreshing && (
212
+ <Text size="2" color="blue" className="ml-3 animate-pulse">
213
+ Refreshing...
214
+ </Text>
215
+ )}
216
+ </PageSubheader>
108
217
  <JobDetail job={job} pipeline={pipeline} />
109
218
  </Layout>
110
219
  );