@ryanfw/prompt-orchestration-pipeline 0.0.1 → 0.3.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 (76) hide show
  1. package/README.md +415 -24
  2. package/package.json +45 -8
  3. package/src/api/files.js +48 -0
  4. package/src/api/index.js +149 -53
  5. package/src/api/validators/seed.js +141 -0
  6. package/src/cli/index.js +456 -29
  7. package/src/cli/run-orchestrator.js +39 -0
  8. package/src/cli/update-pipeline-json.js +47 -0
  9. package/src/components/DAGGrid.jsx +649 -0
  10. package/src/components/JobCard.jsx +96 -0
  11. package/src/components/JobDetail.jsx +159 -0
  12. package/src/components/JobTable.jsx +202 -0
  13. package/src/components/Layout.jsx +134 -0
  14. package/src/components/TaskFilePane.jsx +570 -0
  15. package/src/components/UploadSeed.jsx +239 -0
  16. package/src/components/ui/badge.jsx +20 -0
  17. package/src/components/ui/button.jsx +43 -0
  18. package/src/components/ui/card.jsx +20 -0
  19. package/src/components/ui/focus-styles.css +60 -0
  20. package/src/components/ui/progress.jsx +26 -0
  21. package/src/components/ui/select.jsx +27 -0
  22. package/src/components/ui/separator.jsx +6 -0
  23. package/src/config/paths.js +99 -0
  24. package/src/core/config.js +270 -9
  25. package/src/core/file-io.js +202 -0
  26. package/src/core/module-loader.js +157 -0
  27. package/src/core/orchestrator.js +275 -294
  28. package/src/core/pipeline-runner.js +95 -41
  29. package/src/core/progress.js +66 -0
  30. package/src/core/status-writer.js +331 -0
  31. package/src/core/task-runner.js +719 -73
  32. package/src/core/validation.js +120 -1
  33. package/src/lib/utils.js +6 -0
  34. package/src/llm/README.md +139 -30
  35. package/src/llm/index.js +222 -72
  36. package/src/pages/PipelineDetail.jsx +111 -0
  37. package/src/pages/PromptPipelineDashboard.jsx +223 -0
  38. package/src/providers/deepseek.js +3 -15
  39. package/src/ui/client/adapters/job-adapter.js +258 -0
  40. package/src/ui/client/bootstrap.js +120 -0
  41. package/src/ui/client/hooks/useJobDetailWithUpdates.js +619 -0
  42. package/src/ui/client/hooks/useJobList.js +50 -0
  43. package/src/ui/client/hooks/useJobListWithUpdates.js +335 -0
  44. package/src/ui/client/hooks/useTicker.js +26 -0
  45. package/src/ui/client/index.css +31 -0
  46. package/src/ui/client/index.html +18 -0
  47. package/src/ui/client/main.jsx +38 -0
  48. package/src/ui/config-bridge.browser.js +149 -0
  49. package/src/ui/config-bridge.js +149 -0
  50. package/src/ui/config-bridge.node.js +310 -0
  51. package/src/ui/dist/assets/index-BDABnI-4.js +33399 -0
  52. package/src/ui/dist/assets/style-Ks8LY8gB.css +28496 -0
  53. package/src/ui/dist/index.html +19 -0
  54. package/src/ui/endpoints/job-endpoints.js +300 -0
  55. package/src/ui/file-reader.js +216 -0
  56. package/src/ui/job-change-detector.js +83 -0
  57. package/src/ui/job-index.js +231 -0
  58. package/src/ui/job-reader.js +274 -0
  59. package/src/ui/job-scanner.js +188 -0
  60. package/src/ui/public/app.js +3 -1
  61. package/src/ui/server.js +1636 -59
  62. package/src/ui/sse-enhancer.js +149 -0
  63. package/src/ui/sse.js +204 -0
  64. package/src/ui/state-snapshot.js +252 -0
  65. package/src/ui/transformers/list-transformer.js +347 -0
  66. package/src/ui/transformers/status-transformer.js +307 -0
  67. package/src/ui/watcher.js +61 -7
  68. package/src/utils/dag.js +101 -0
  69. package/src/utils/duration.js +126 -0
  70. package/src/utils/id-generator.js +30 -0
  71. package/src/utils/jobs.js +7 -0
  72. package/src/utils/pipelines.js +44 -0
  73. package/src/utils/task-files.js +271 -0
  74. package/src/utils/ui.jsx +76 -0
  75. package/src/ui/public/index.html +0 -53
  76. package/src/ui/public/style.css +0 -341
@@ -0,0 +1,96 @@
1
+ import React from "react";
2
+ import { Card, CardHeader, CardTitle, CardContent } from "./ui/card";
3
+ import { Progress } from "./ui/progress";
4
+ import { Clock, TimerReset, ChevronRight } from "lucide-react";
5
+ import { fmtDuration, taskDisplayDurationMs } from "../utils/duration";
6
+ import { countCompleted } from "../utils/jobs";
7
+ import { progressClasses, statusBadge } from "../utils/ui";
8
+ import { useTicker } from "../ui/client/hooks/useTicker";
9
+
10
+ export default function JobCard({
11
+ job,
12
+ pipeline,
13
+ onClick,
14
+ progressPct,
15
+ overallElapsedMs,
16
+ }) {
17
+ const now = useTicker(60000);
18
+ const currentTask = job.current ? job.tasks[job.current] : undefined;
19
+ const currentElapsedMs = currentTask
20
+ ? taskDisplayDurationMs(currentTask, now)
21
+ : 0;
22
+ const totalCompleted = countCompleted(job);
23
+ const hasValidId = Boolean(job.id);
24
+ const jobTitle = job.title || job.name; // Fallback for backward compatibility
25
+
26
+ return (
27
+ <Card
28
+ role="button"
29
+ tabIndex={hasValidId ? 0 : -1}
30
+ aria-label={
31
+ hasValidId
32
+ ? `Open ${jobTitle}`
33
+ : `${jobTitle} - No valid job ID, cannot open details`
34
+ }
35
+ onClick={() => hasValidId && onClick()}
36
+ onKeyDown={(e) =>
37
+ hasValidId && (e.key === "Enter" || e.key === " ") && onClick()
38
+ }
39
+ className={`group transition-colors rounded-xl border border-slate-200 ${
40
+ hasValidId
41
+ ? "cursor-pointer hover:bg-slate-100/40 hover:shadow-sm focus-visible:ring-2"
42
+ : "cursor-not-allowed opacity-60"
43
+ }`}
44
+ title={
45
+ hasValidId
46
+ ? undefined
47
+ : "This job cannot be opened because it lacks a valid ID"
48
+ }
49
+ >
50
+ <CardHeader className="pb-3">
51
+ <div className="flex items-start justify-between gap-2">
52
+ <div>
53
+ <div className="text-xs text-slate-500">{job.jobId}</div>
54
+ <CardTitle className="text-lg font-semibold">{jobTitle}</CardTitle>
55
+ </div>
56
+ <div className="flex items-center gap-2">
57
+ {statusBadge(job.status)}
58
+ <ChevronRight className="h-4 w-4 opacity-50 group-hover:translate-x-0.5 transition-transform" />
59
+ </div>
60
+ </div>
61
+ </CardHeader>
62
+ <CardContent className="pt-0">
63
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm">
64
+ <div className="font-semibold">
65
+ {currentTask
66
+ ? currentTask.name
67
+ : job.status === "completed"
68
+ ? "—"
69
+ : (job.current ?? "—")}
70
+ </div>
71
+ {currentTask && currentElapsedMs > 0 && (
72
+ <div className="text-slate-500">
73
+ {fmtDuration(currentElapsedMs)}
74
+ </div>
75
+ )}
76
+ </div>
77
+
78
+ <div className="mt-3">
79
+ <Progress
80
+ className={`h-2 ${progressClasses(job.status)}`}
81
+ value={progressPct}
82
+ aria-label={`Progress ${progressPct}%`}
83
+ />
84
+ <div className="mt-2 flex flex-wrap items-center justify-between text-sm text-slate-500">
85
+ <div>
86
+ {totalCompleted} of {pipeline.tasks.length} tasks
87
+ </div>
88
+ <div className="flex items-center gap-1 text-right">
89
+ <TimerReset className="h-4 w-4" /> {fmtDuration(overallElapsedMs)}
90
+ </div>
91
+ </div>
92
+ </div>
93
+ </CardContent>
94
+ </Card>
95
+ );
96
+ }
@@ -0,0 +1,159 @@
1
+ import React, { useEffect, useState } from "react";
2
+ import { fmtDuration } from "../utils/duration.js";
3
+ import { taskDisplayDurationMs } from "../utils/duration.js";
4
+ import { useTicker } from "../ui/client/hooks/useTicker.js";
5
+ import DAGGrid from "./DAGGrid.jsx";
6
+ import { computeDagItems, computeActiveIndex } from "../utils/dag.js";
7
+ import { getTaskFilesForTask } from "../utils/task-files.js";
8
+
9
+ export default function JobDetail({ job, pipeline, onClose, onResume }) {
10
+ const now = useTicker(1000);
11
+ const [resumeFrom, setResumeFrom] = useState(
12
+ pipeline?.tasks?.[0]
13
+ ? typeof pipeline.tasks[0] === "string"
14
+ ? pipeline.tasks[0]
15
+ : (pipeline.tasks[0].id ?? pipeline.tasks[0].name ?? "")
16
+ : ""
17
+ );
18
+
19
+ useEffect(() => {
20
+ setResumeFrom(
21
+ pipeline?.tasks?.[0]
22
+ ? typeof pipeline.tasks[0] === "string"
23
+ ? pipeline.tasks[0]
24
+ : (pipeline.tasks[0].id ?? pipeline.tasks[0].name ?? "")
25
+ : ""
26
+ );
27
+ }, [job.id, pipeline?.tasks?.length]);
28
+
29
+ // job.tasks is expected to be an object keyed by task name; normalize from array if needed
30
+ const taskById = React.useMemo(() => {
31
+ const tasks = job?.tasks;
32
+
33
+ let result;
34
+ if (!tasks) {
35
+ result = {};
36
+ } else if (Array.isArray(tasks)) {
37
+ const map = {};
38
+ for (const t of tasks) {
39
+ const key = t?.name;
40
+ if (key) {
41
+ map[key] = t;
42
+ }
43
+ }
44
+ result = map;
45
+ } else {
46
+ result = tasks;
47
+ }
48
+
49
+ return result;
50
+ }, [job?.tasks]);
51
+
52
+ // Compute pipeline tasks from pipeline or derive from job tasks
53
+ const computedPipeline = React.useMemo(() => {
54
+ let result;
55
+ if (pipeline?.tasks) {
56
+ result = pipeline;
57
+ } else {
58
+ // Derive pipeline tasks from job tasks object keys
59
+ const jobTasks = job?.tasks;
60
+
61
+ if (!jobTasks) {
62
+ result = { tasks: [] };
63
+ } else {
64
+ const taskKeys = Array.isArray(jobTasks)
65
+ ? jobTasks.map((t) => t?.name).filter(Boolean)
66
+ : Object.keys(jobTasks);
67
+
68
+ result = { tasks: taskKeys };
69
+ }
70
+ }
71
+
72
+ return result;
73
+ }, [pipeline, job?.tasks]);
74
+
75
+ // Compute DAG items and active index for visualization
76
+ const dagItems = React.useMemo(() => {
77
+ const rawDagItems = computeDagItems(job, computedPipeline);
78
+
79
+ const processedItems = rawDagItems.map((item, index) => {
80
+ if (process.env.NODE_ENV !== "test") {
81
+ console.debug("[JobDetail] computed DAG item", {
82
+ id: item.id,
83
+ status: item.status,
84
+ stage: item.stage,
85
+ jobHasTasks: !!job?.tasks,
86
+ taskKeys: job?.tasks ? Object.keys(job.tasks) : null,
87
+ });
88
+ }
89
+
90
+ const task = taskById[item.id];
91
+
92
+ const taskConfig = task?.config || {};
93
+
94
+ // Build subtitle with useful metadata when available (Tufte-inspired inline tokens)
95
+ const subtitleParts = [];
96
+ if (taskConfig?.model) {
97
+ subtitleParts.push(taskConfig.model);
98
+ }
99
+ if (taskConfig?.temperature != null) {
100
+ subtitleParts.push(`temp ${taskConfig.temperature}`);
101
+ }
102
+ if (task?.attempts != null) {
103
+ subtitleParts.push(`${task.attempts} attempts`);
104
+ }
105
+ if (task?.refinementAttempts != null) {
106
+ subtitleParts.push(`${task.refinementAttempts} refinements`);
107
+ }
108
+ if (task?.startedAt) {
109
+ const durationMs = taskDisplayDurationMs(task, now);
110
+ if (durationMs > 0) {
111
+ subtitleParts.push(fmtDuration(durationMs));
112
+ }
113
+ }
114
+
115
+ // Include error message in body when task status is error
116
+ const errorMsg = task?.error?.message;
117
+ const body = item.status === "failed" && errorMsg ? errorMsg : null;
118
+
119
+ const resultItem = {
120
+ ...item,
121
+ title:
122
+ typeof item.id === "string"
123
+ ? item.id
124
+ : item.id?.name || item.id?.id || `Task ${item.id}`,
125
+ subtitle: subtitleParts.length > 0 ? subtitleParts.join(" · ") : null,
126
+ body,
127
+ };
128
+
129
+ return resultItem;
130
+ });
131
+
132
+ return processedItems;
133
+ }, [job, computedPipeline, taskById, now]);
134
+
135
+ const activeIndex = React.useMemo(() => {
136
+ const index = computeActiveIndex(dagItems);
137
+
138
+ return index;
139
+ }, [dagItems]);
140
+
141
+ const filesByTypeForItem = React.useCallback(
142
+ (item) => {
143
+ if (!item) return { artifacts: [], logs: [], tmp: [] };
144
+ return getTaskFilesForTask(job, item.id ?? item.name ?? item);
145
+ },
146
+ [job]
147
+ );
148
+
149
+ return (
150
+ <div className="flex h-full flex-col">
151
+ <DAGGrid
152
+ items={dagItems}
153
+ activeIndex={activeIndex}
154
+ jobId={job.id}
155
+ filesByTypeForItem={filesByTypeForItem}
156
+ />
157
+ </div>
158
+ );
159
+ }
@@ -0,0 +1,202 @@
1
+ import React from "react";
2
+ import { Box, Flex, Table, Text, Button } from "@radix-ui/themes";
3
+ import { Progress } from "./ui/progress";
4
+ import { Clock, TimerReset, ChevronRight } from "lucide-react";
5
+ import { fmtDuration } from "../utils/duration.js";
6
+ import { taskDisplayDurationMs } from "../utils/duration.js";
7
+ import { countCompleted } from "../utils/jobs";
8
+ import { progressClasses, statusBadge } from "../utils/ui";
9
+
10
+ export default function JobTable({
11
+ jobs,
12
+ pipeline,
13
+ onOpenJob,
14
+ overallElapsed,
15
+ now,
16
+ }) {
17
+ if (jobs.length === 0) {
18
+ return (
19
+ <Box className="p-6">
20
+ <Text size="2" className="text-slate-600">
21
+ No jobs to show here yet.
22
+ </Text>
23
+ </Box>
24
+ );
25
+ }
26
+
27
+ return (
28
+ <Box>
29
+ <Table.Root radius="none">
30
+ <Table.Header>
31
+ <Table.Row>
32
+ <Table.ColumnHeaderCell>Job Name</Table.ColumnHeaderCell>
33
+ <Table.ColumnHeaderCell>Pipeline</Table.ColumnHeaderCell>
34
+ <Table.ColumnHeaderCell>Status</Table.ColumnHeaderCell>
35
+ <Table.ColumnHeaderCell>Current Task</Table.ColumnHeaderCell>
36
+ <Table.ColumnHeaderCell>Progress</Table.ColumnHeaderCell>
37
+ <Table.ColumnHeaderCell>Tasks</Table.ColumnHeaderCell>
38
+ <Table.ColumnHeaderCell>Duration</Table.ColumnHeaderCell>
39
+ <Table.ColumnHeaderCell className="w-12"></Table.ColumnHeaderCell>
40
+ </Table.Row>
41
+ </Table.Header>
42
+
43
+ <Table.Body>
44
+ {jobs.map((job) => {
45
+ const jobTitle = job.title || job.name; // Fallback for backward compatibility
46
+ const taskById = Array.isArray(job.tasks)
47
+ ? Object.fromEntries(
48
+ (job.tasks || []).map((t) => {
49
+ if (typeof t === "string") return [t, { id: t, name: t }];
50
+ return [t.id ?? t.name, t];
51
+ })
52
+ )
53
+ : job.tasks || {};
54
+ const currentTask = job.current ? taskById[job.current] : undefined;
55
+ const currentElapsedMs = currentTask
56
+ ? taskDisplayDurationMs(currentTask, now)
57
+ : 0;
58
+ const totalCompleted = countCompleted(job);
59
+ const totalTasks =
60
+ pipeline?.tasks?.length ??
61
+ (Array.isArray(job.tasks)
62
+ ? job.tasks.length
63
+ : Object.keys(job.tasks || {}).length);
64
+ const progress = Number.isFinite(job.progress)
65
+ ? Math.round(job.progress)
66
+ : 0;
67
+ const duration = overallElapsed(job);
68
+ const currentTaskName = currentTask
69
+ ? (currentTask.name ?? currentTask.id ?? job.current)
70
+ : undefined;
71
+ const currentTaskConfig =
72
+ (job.current &&
73
+ (currentTask?.config || pipeline?.taskConfig?.[job.current])) ||
74
+ {};
75
+
76
+ const hasValidId = Boolean(job.id);
77
+ return (
78
+ <Table.Row
79
+ key={job.id}
80
+ className={`group transition-colors ${
81
+ hasValidId
82
+ ? "cursor-pointer hover:bg-slate-50/50"
83
+ : "cursor-not-allowed opacity-60"
84
+ }`}
85
+ onClick={() => hasValidId && onOpenJob(job)}
86
+ onKeyDown={(e) =>
87
+ hasValidId &&
88
+ (e.key === "Enter" || e.key === " ") &&
89
+ onOpenJob(job)
90
+ }
91
+ tabIndex={hasValidId ? 0 : -1}
92
+ aria-label={
93
+ hasValidId
94
+ ? `Open ${jobTitle}`
95
+ : `${jobTitle} - No valid job ID, cannot open details`
96
+ }
97
+ title={
98
+ hasValidId
99
+ ? undefined
100
+ : "This job cannot be opened because it lacks a valid ID"
101
+ }
102
+ >
103
+ <Table.Cell>
104
+ <Flex direction="column" gap="1">
105
+ <Text size="2" weight="medium" className="text-slate-900">
106
+ {jobTitle}
107
+ </Text>
108
+ <Text size="1" className="text-slate-500">
109
+ {job.id}
110
+ </Text>
111
+ </Flex>
112
+ </Table.Cell>
113
+
114
+ <Table.Cell>
115
+ <Flex direction="column" gap="1">
116
+ <Text size="2" className="text-slate-900">
117
+ {job.pipelineLabel || job.pipeline || "—"}
118
+ </Text>
119
+ {job.pipelineLabel && job.pipeline && (
120
+ <Text size="1" className="text-slate-500">
121
+ {job.pipeline}
122
+ </Text>
123
+ )}
124
+ </Flex>
125
+ </Table.Cell>
126
+
127
+ <Table.Cell>{statusBadge(job.status)}</Table.Cell>
128
+
129
+ <Table.Cell>
130
+ <Flex direction="column" gap="1">
131
+ <Text size="2" className="text-slate-700">
132
+ {currentTaskName
133
+ ? currentTaskName
134
+ : job.status === "completed"
135
+ ? "—"
136
+ : (job.current ?? "—")}
137
+ </Text>
138
+ {currentTask && (
139
+ <Text size="1" className="text-slate-500">
140
+ {[
141
+ currentTaskConfig?.model || currentTask?.model,
142
+ currentTaskConfig?.temperature != null ||
143
+ currentTask?.temperature != null
144
+ ? `temp ${currentTaskConfig?.temperature ?? currentTask?.temperature}`
145
+ : null,
146
+ currentElapsedMs > 0
147
+ ? fmtDuration(currentElapsedMs)
148
+ : null,
149
+ ]
150
+ .filter(Boolean)
151
+ .join(" · ")}
152
+ </Text>
153
+ )}
154
+ </Flex>
155
+ </Table.Cell>
156
+
157
+ <Table.Cell>
158
+ <Flex direction="column" gap="2" className="w-32">
159
+ <Progress
160
+ className={`h-2 ${progressClasses(job.status)}`}
161
+ value={progress}
162
+ aria-label={`Progress ${progress}%`}
163
+ />
164
+ <Text size="1" className="text-slate-500">
165
+ {progress}%
166
+ </Text>
167
+ </Flex>
168
+ </Table.Cell>
169
+
170
+ <Table.Cell>
171
+ <Text size="2" className="text-slate-700">
172
+ {totalCompleted} of {totalTasks}
173
+ </Text>
174
+ </Table.Cell>
175
+
176
+ <Table.Cell>
177
+ <Flex align="center" gap="1">
178
+ <TimerReset className="h-3 w-3 text-slate-500" />
179
+ <Text size="2" className="text-slate-700">
180
+ {fmtDuration(duration)}
181
+ </Text>
182
+ </Flex>
183
+ </Table.Cell>
184
+
185
+ <Table.Cell>
186
+ <Button
187
+ variant="ghost"
188
+ size="1"
189
+ className="opacity-0 group-hover:opacity-100 transition-opacity text-slate-500 hover:text-slate-700"
190
+ aria-label={`View details for ${jobTitle}`}
191
+ >
192
+ <ChevronRight className="h-4 w-4" />
193
+ </Button>
194
+ </Table.Cell>
195
+ </Table.Row>
196
+ );
197
+ })}
198
+ </Table.Body>
199
+ </Table.Root>
200
+ </Box>
201
+ );
202
+ }
@@ -0,0 +1,134 @@
1
+ import React from "react";
2
+ import { useNavigate, useLocation } from "react-router-dom";
3
+ import * as Tooltip from "@radix-ui/react-tooltip";
4
+ import { Box, Flex, Text, Heading, Link as RadixLink } from "@radix-ui/themes";
5
+ import { Button } from "./ui/button.jsx";
6
+ import { ArrowLeft, Home } from "lucide-react";
7
+ import "./ui/focus-styles.css";
8
+
9
+ /**
10
+ * Shared Layout component that provides consistent header, navigation,
11
+ * and container structure across all pages.
12
+ */
13
+ export default function Layout({
14
+ children,
15
+ title,
16
+ actions,
17
+ showBackButton = false,
18
+ backTo = "/",
19
+ maxWidth = "max-w-7xl",
20
+ }) {
21
+ const navigate = useNavigate();
22
+ const location = useLocation();
23
+
24
+ // Determine active navigation based on current path
25
+ const isActivePath = (path) => {
26
+ if (path === "/" && location.pathname === "/") return true;
27
+ if (path !== "/" && location.pathname.startsWith(path)) return true;
28
+ return false;
29
+ };
30
+
31
+ const handleBack = () => {
32
+ navigate(backTo);
33
+ };
34
+
35
+ return (
36
+ <Tooltip.Provider delayDuration={200}>
37
+ <Box className="min-h-screen bg-gray-1">
38
+ {/* Skip to main content link for accessibility */}
39
+ <Box
40
+ as="a"
41
+ href="#main-content"
42
+ className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 z-50 bg-blue-600 text-white px-3 py-2 rounded-md text-sm font-medium focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
43
+ >
44
+ Skip to main content
45
+ </Box>
46
+
47
+ {/* Header */}
48
+ <Box
49
+ role="banner"
50
+ className="sticky top-0 z-20 border-b border-gray-300 bg-gray-1/80 backdrop-blur supports-[backdrop-filter]:bg-gray-1/60"
51
+ >
52
+ <Flex
53
+ align="center"
54
+ justify="between"
55
+ className={`mx-auto w-full ${maxWidth} px-4 sm:px-6 lg:px-8 py-4`}
56
+ gap="4"
57
+ >
58
+ {/* Left side: Navigation and title */}
59
+ <Flex align="center" gap="3" className="min-w-0 flex-1">
60
+ {/* Back button (conditional) */}
61
+ {showBackButton && (
62
+ <Tooltip.Root delayDuration={200}>
63
+ <Tooltip.Trigger asChild>
64
+ <Button
65
+ variant="ghost"
66
+ size="sm"
67
+ onClick={handleBack}
68
+ className="shrink-0"
69
+ aria-label="Go back"
70
+ >
71
+ <ArrowLeft className="h-4 w-4" />
72
+ </Button>
73
+ </Tooltip.Trigger>
74
+ <Tooltip.Content side="bottom" sideOffset={5}>
75
+ <Text size="2">Go back</Text>
76
+ </Tooltip.Content>
77
+ </Tooltip.Root>
78
+ )}
79
+
80
+ {/* App title */}
81
+ <Heading
82
+ size="5"
83
+ weight="medium"
84
+ className="text-gray-12 truncate"
85
+ >
86
+ {title || "Prompt Pipeline"}
87
+ </Heading>
88
+ </Flex>
89
+
90
+ {/* Center: Navigation */}
91
+ <nav
92
+ role="navigation"
93
+ aria-label="Main navigation"
94
+ className="hidden md:flex"
95
+ >
96
+ <Flex align="center" gap="6">
97
+ <RadixLink
98
+ href="/"
99
+ className={`text-sm font-medium transition-colors hover:text-blue-600 ${
100
+ isActivePath("/")
101
+ ? "text-blue-600"
102
+ : "text-gray-11 hover:text-gray-12"
103
+ }`}
104
+ aria-current={isActivePath("/") ? "page" : undefined}
105
+ >
106
+ <Flex align="center" gap="2">
107
+ <Home className="h-4 w-4" />
108
+ Dashboard
109
+ </Flex>
110
+ </RadixLink>
111
+ </Flex>
112
+ </nav>
113
+
114
+ {/* Right side: Actions */}
115
+ {actions && (
116
+ <Flex align="center" gap="3" className="shrink-0">
117
+ {actions}
118
+ </Flex>
119
+ )}
120
+ </Flex>
121
+ </Box>
122
+
123
+ {/* Main content */}
124
+ <main
125
+ id="main-content"
126
+ role="main"
127
+ className={`mx-auto w-full ${maxWidth} px-4 sm:px-6 lg:px-8 py-6`}
128
+ >
129
+ {children}
130
+ </main>
131
+ </Box>
132
+ </Tooltip.Provider>
133
+ );
134
+ }