@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
@@ -1,11 +1,12 @@
1
1
  import React from "react";
2
2
  import { Card, CardHeader, CardTitle, CardContent } from "./ui/card";
3
3
  import { Progress } from "./ui/progress";
4
- import { Clock, TimerReset, ChevronRight } from "lucide-react";
5
- import { fmtDuration, taskDisplayDurationMs } from "../utils/duration";
4
+ import { TimerReset, ChevronRight } from "lucide-react";
5
+ import { fmtDuration } from "../utils/duration";
6
6
  import { countCompleted } from "../utils/jobs";
7
7
  import { progressClasses, statusBadge } from "../utils/ui";
8
- import { useTicker } from "../ui/client/hooks/useTicker";
8
+ import TimerText from "./TimerText";
9
+ import { taskToTimerProps } from "../utils/time-utils.js";
9
10
 
10
11
  export default function JobCard({
11
12
  job,
@@ -14,14 +15,12 @@ export default function JobCard({
14
15
  progressPct,
15
16
  overallElapsedMs,
16
17
  }) {
17
- const now = useTicker(60000);
18
18
  const currentTask = job.current ? job.tasks[job.current] : undefined;
19
- const currentElapsedMs = currentTask
20
- ? taskDisplayDurationMs(currentTask, now)
21
- : 0;
22
19
  const totalCompleted = countCompleted(job);
23
20
  const hasValidId = Boolean(job.id);
24
- const jobTitle = job.title || job.name; // Fallback for backward compatibility
21
+ const jobTitle = job.title;
22
+
23
+ const { startMs, endMs } = currentTask ? taskToTimerProps(currentTask) : {};
25
24
 
26
25
  return (
27
26
  <Card
@@ -68,10 +67,13 @@ export default function JobCard({
68
67
  ? "—"
69
68
  : (job.current ?? "—")}
70
69
  </div>
71
- {currentTask && currentElapsedMs > 0 && (
72
- <div className="text-slate-500">
73
- {fmtDuration(currentElapsedMs)}
74
- </div>
70
+ {startMs && (
71
+ <TimerText
72
+ startMs={startMs}
73
+ endMs={endMs}
74
+ granularity="second"
75
+ className="text-slate-500"
76
+ />
75
77
  )}
76
78
  </div>
77
79
 
@@ -1,31 +1,10 @@
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";
1
+ import React from "react";
5
2
  import DAGGrid from "./DAGGrid.jsx";
6
3
  import { computeDagItems, computeActiveIndex } from "../utils/dag.js";
7
4
  import { getTaskFilesForTask } from "../utils/task-files.js";
5
+ import { formatCurrency4, formatTokensCompact } from "../utils/formatters.js";
8
6
 
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
-
7
+ export default function JobDetail({ job, pipeline }) {
29
8
  // job.tasks is expected to be an object keyed by task name; normalize from array if needed
30
9
  const taskById = React.useMemo(() => {
31
10
  const tasks = job?.tasks;
@@ -72,21 +51,11 @@ export default function JobDetail({ job, pipeline, onClose, onResume }) {
72
51
  return result;
73
52
  }, [pipeline, job?.tasks]);
74
53
 
75
- // Compute DAG items and active index for visualization
76
- const dagItems = React.useMemo(() => {
54
+ // Memoized helper to preserve DAG item identity and minimize churn
55
+ const stableDagItems = React.useMemo(() => {
77
56
  const rawDagItems = computeDagItems(job, computedPipeline);
78
57
 
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
-
58
+ return rawDagItems.map((item, index) => {
90
59
  const task = taskById[item.id];
91
60
 
92
61
  const taskConfig = task?.config || {};
@@ -99,24 +68,27 @@ export default function JobDetail({ job, pipeline, onClose, onResume }) {
99
68
  if (taskConfig?.temperature != null) {
100
69
  subtitleParts.push(`temp ${taskConfig.temperature}`);
101
70
  }
102
- if (task?.attempts != null) {
103
- subtitleParts.push(`${task.attempts} attempts`);
104
- }
105
71
  if (task?.refinementAttempts != null) {
106
72
  subtitleParts.push(`${task.refinementAttempts} refinements`);
107
73
  }
108
- if (task?.startedAt) {
109
- const durationMs = taskDisplayDurationMs(task, now);
110
- if (durationMs > 0) {
111
- subtitleParts.push(fmtDuration(durationMs));
112
- }
74
+
75
+ // Prefer taskBreakdown totals for consistency with backend
76
+ const taskBreakdown = job?.costs?.taskBreakdown?.[item.id]?.summary || {};
77
+ if (taskBreakdown.totalTokens > 0) {
78
+ subtitleParts.push(formatTokensCompact(taskBreakdown.totalTokens));
79
+ }
80
+ if (taskBreakdown.totalCost > 0) {
81
+ subtitleParts.push(formatCurrency4(taskBreakdown.totalCost));
113
82
  }
114
83
 
115
- // Include error message in body when task status is error
84
+ // Include error message in body when task status is error or failed
116
85
  const errorMsg = task?.error?.message;
117
- const body = item.status === "failed" && errorMsg ? errorMsg : null;
86
+ const body =
87
+ (item.status === "failed" || item.status === "error") && errorMsg
88
+ ? errorMsg
89
+ : null;
118
90
 
119
- const resultItem = {
91
+ return {
120
92
  ...item,
121
93
  title:
122
94
  typeof item.id === "string"
@@ -124,13 +96,42 @@ export default function JobDetail({ job, pipeline, onClose, onResume }) {
124
96
  : item.id?.name || item.id?.id || `Task ${item.id}`,
125
97
  subtitle: subtitleParts.length > 0 ? subtitleParts.join(" · ") : null,
126
98
  body,
99
+ startedAt: task?.startedAt,
100
+ endedAt: task?.endedAt,
127
101
  };
102
+ });
103
+ }, [job, computedPipeline, taskById]);
104
+
105
+ // Previous dagItems reference for identity comparison
106
+ const prevDagItemsRef = React.useRef([]);
128
107
 
129
- return resultItem;
108
+ // Compute DAG items with identity preservation
109
+ const dagItems = React.useMemo(() => {
110
+ const prevItems = prevDagItemsRef.current;
111
+
112
+ // Create new array but reuse objects when possible to maintain identity
113
+ const newItems = stableDagItems.map((item, index) => {
114
+ const prevItem = prevItems[index];
115
+
116
+ // Reuse previous object if all relevant properties are unchanged
117
+ if (
118
+ prevItem &&
119
+ prevItem.id === item.id &&
120
+ prevItem.status === item.status &&
121
+ prevItem.stage === item.stage &&
122
+ prevItem.title === item.title &&
123
+ prevItem.subtitle === item.subtitle &&
124
+ prevItem.body === item.body
125
+ ) {
126
+ return prevItem;
127
+ }
128
+
129
+ return item;
130
130
  });
131
131
 
132
- return processedItems;
133
- }, [job, computedPipeline, taskById, now]);
132
+ prevDagItemsRef.current = newItems;
133
+ return newItems;
134
+ }, [stableDagItems]);
134
135
 
135
136
  const activeIndex = React.useMemo(() => {
136
137
  const index = computeActiveIndex(dagItems);
@@ -148,6 +149,8 @@ export default function JobDetail({ job, pipeline, onClose, onResume }) {
148
149
 
149
150
  return (
150
151
  <div className="flex h-full flex-col">
152
+ {/* Job Header */}
153
+
151
154
  <DAGGrid
152
155
  items={dagItems}
153
156
  activeIndex={activeIndex}
@@ -1,19 +1,34 @@
1
1
  import React from "react";
2
2
  import { Box, Flex, Table, Text, Button } from "@radix-ui/themes";
3
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";
4
+ import { TimerReset, ChevronRight } from "lucide-react";
5
+ import { fmtDuration, jobCumulativeDurationMs } from "../utils/duration.js";
7
6
  import { countCompleted } from "../utils/jobs";
8
7
  import { progressClasses, statusBadge } from "../utils/ui";
8
+ import TimerText from "./TimerText.jsx";
9
+ import LiveText from "./LiveText.jsx";
10
+ import { taskToTimerProps } from "../utils/time-utils.js";
9
11
 
10
- export default function JobTable({
11
- jobs,
12
- pipeline,
13
- onOpenJob,
14
- overallElapsed,
15
- now,
16
- }) {
12
+ // Local helpers for formatting costs and tokens
13
+ function formatCurrency4(x) {
14
+ if (typeof x !== "number" || x === 0) return "$0.0000";
15
+ const formatted = x.toFixed(4);
16
+ // Trim trailing zeros and unnecessary decimal point
17
+ return `$${formatted.replace(/\.?0+$/, "")}`;
18
+ }
19
+
20
+ function formatTokensCompact(n) {
21
+ if (typeof n !== "number" || n === 0) return "0 tok";
22
+
23
+ if (n >= 1000000) {
24
+ return `${(n / 1000000).toFixed(1).replace(/\.0$/, "")}M tok`;
25
+ } else if (n >= 1000) {
26
+ return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k tok`;
27
+ }
28
+ return `${n} tok`;
29
+ }
30
+
31
+ export default function JobTable({ jobs, pipeline, onOpenJob }) {
17
32
  if (jobs.length === 0) {
18
33
  return (
19
34
  <Box className="p-6">
@@ -35,6 +50,7 @@ export default function JobTable({
35
50
  <Table.ColumnHeaderCell>Current Task</Table.ColumnHeaderCell>
36
51
  <Table.ColumnHeaderCell>Progress</Table.ColumnHeaderCell>
37
52
  <Table.ColumnHeaderCell>Tasks</Table.ColumnHeaderCell>
53
+ <Table.ColumnHeaderCell>Cost</Table.ColumnHeaderCell>
38
54
  <Table.ColumnHeaderCell>Duration</Table.ColumnHeaderCell>
39
55
  <Table.ColumnHeaderCell className="w-12"></Table.ColumnHeaderCell>
40
56
  </Table.Row>
@@ -42,7 +58,7 @@ export default function JobTable({
42
58
 
43
59
  <Table.Body>
44
60
  {jobs.map((job) => {
45
- const jobTitle = job.title || job.name; // Fallback for backward compatibility
61
+ const jobTitle = job.name;
46
62
  const taskById = Array.isArray(job.tasks)
47
63
  ? Object.fromEntries(
48
64
  (job.tasks || []).map((t) => {
@@ -52,9 +68,6 @@ export default function JobTable({
52
68
  )
53
69
  : job.tasks || {};
54
70
  const currentTask = job.current ? taskById[job.current] : undefined;
55
- const currentElapsedMs = currentTask
56
- ? taskDisplayDurationMs(currentTask, now)
57
- : 0;
58
71
  const totalCompleted = countCompleted(job);
59
72
  const totalTasks =
60
73
  pipeline?.tasks?.length ??
@@ -64,7 +77,6 @@ export default function JobTable({
64
77
  const progress = Number.isFinite(job.progress)
65
78
  ? Math.round(job.progress)
66
79
  : 0;
67
- const duration = overallElapsed(job);
68
80
  const currentTaskName = currentTask
69
81
  ? (currentTask.name ?? currentTask.id ?? job.current)
70
82
  : undefined;
@@ -73,6 +85,12 @@ export default function JobTable({
73
85
  (currentTask?.config || pipeline?.taskConfig?.[job.current])) ||
74
86
  {};
75
87
 
88
+ // Cost and token data
89
+ const costsSummary = job.costsSummary || {};
90
+ const totalCost = job.totalCost || costsSummary.totalCost || 0;
91
+ const totalTokens =
92
+ job.totalTokens || costsSummary.totalTokens || 0;
93
+
76
94
  const hasValidId = Boolean(job.id);
77
95
  return (
78
96
  <Table.Row
@@ -131,7 +149,7 @@ export default function JobTable({
131
149
  <Text size="2" className="text-slate-700">
132
150
  {currentTaskName
133
151
  ? currentTaskName
134
- : job.status === "completed"
152
+ : job.status === "done"
135
153
  ? "—"
136
154
  : (job.current ?? "—")}
137
155
  </Text>
@@ -143,12 +161,26 @@ export default function JobTable({
143
161
  currentTask?.temperature != null
144
162
  ? `temp ${currentTaskConfig?.temperature ?? currentTask?.temperature}`
145
163
  : null,
146
- currentElapsedMs > 0
147
- ? fmtDuration(currentElapsedMs)
148
- : null,
164
+ (() => {
165
+ const { startMs, endMs } =
166
+ taskToTimerProps(currentTask);
167
+ return startMs ? (
168
+ <TimerText
169
+ startMs={startMs}
170
+ endMs={endMs}
171
+ granularity="second"
172
+ className="text-slate-500"
173
+ />
174
+ ) : null;
175
+ })(),
149
176
  ]
150
177
  .filter(Boolean)
151
- .join(" · ")}
178
+ .map((item, index) => (
179
+ <React.Fragment key={index}>
180
+ {typeof item === "string" ? item : item}
181
+ {index < 2 && " · "}
182
+ </React.Fragment>
183
+ ))}
152
184
  </Text>
153
185
  )}
154
186
  </Flex>
@@ -174,11 +206,28 @@ export default function JobTable({
174
206
  </Table.Cell>
175
207
 
176
208
  <Table.Cell>
177
- <Flex align="center" gap="1">
178
- <TimerReset className="h-3 w-3 text-slate-500" />
209
+ <Flex direction="column" gap="1">
179
210
  <Text size="2" className="text-slate-700">
180
- {fmtDuration(duration)}
211
+ {totalCost > 0 ? formatCurrency4(totalCost) : "—"}
181
212
  </Text>
213
+ {totalTokens > 0 && (
214
+ <Text size="1" className="text-slate-500">
215
+ {formatTokensCompact(totalTokens)}
216
+ </Text>
217
+ )}
218
+ </Flex>
219
+ </Table.Cell>
220
+
221
+ <Table.Cell>
222
+ <Flex align="center" gap="1">
223
+ <TimerReset className="h-3 w-3 text-slate-500" />
224
+ <LiveText
225
+ cadenceMs={10000}
226
+ compute={(now) =>
227
+ fmtDuration(jobCumulativeDurationMs(job, now))
228
+ }
229
+ className="text-slate-700"
230
+ />
182
231
  </Flex>
183
232
  </Table.Cell>
184
233
 
@@ -1,9 +1,12 @@
1
- import React from "react";
2
- import { useNavigate, useLocation } from "react-router-dom";
1
+ import React, { useState, useRef, useEffect } from "react";
2
+ import { useNavigate, useLocation, Link } from "react-router-dom";
3
3
  import * as Tooltip from "@radix-ui/react-tooltip";
4
4
  import { Box, Flex, Text, Heading, Link as RadixLink } from "@radix-ui/themes";
5
5
  import { Button } from "./ui/button.jsx";
6
- import { ArrowLeft, Home } from "lucide-react";
6
+ import Logo from "./ui/Logo.jsx";
7
+ import PageSubheader from "./PageSubheader.jsx";
8
+ import UploadSeed from "./UploadSeed.jsx";
9
+ import { ArrowLeft, Code2, Upload } from "lucide-react";
7
10
  import "./ui/focus-styles.css";
8
11
 
9
12
  /**
@@ -13,13 +16,18 @@ import "./ui/focus-styles.css";
13
16
  export default function Layout({
14
17
  children,
15
18
  title,
19
+ pageTitle,
20
+ breadcrumbs,
16
21
  actions,
17
- showBackButton = false,
18
22
  backTo = "/",
19
23
  maxWidth = "max-w-7xl",
20
24
  }) {
21
25
  const navigate = useNavigate();
22
26
  const location = useLocation();
27
+ const [isUploadOpen, setIsUploadOpen] = useState(false);
28
+ const [seedUploadSuccess, setSeedUploadSuccess] = useState(null);
29
+ const [seedUploadTimer, setSeedUploadTimer] = useState(null);
30
+ const uploadPanelRef = useRef(null);
23
31
 
24
32
  // Determine active navigation based on current path
25
33
  const isActivePath = (path) => {
@@ -32,6 +40,50 @@ export default function Layout({
32
40
  navigate(backTo);
33
41
  };
34
42
 
43
+ const toggleUploadPanel = () => {
44
+ setIsUploadOpen(!isUploadOpen);
45
+ };
46
+
47
+ // Handle seed upload success
48
+ const handleSeedUploadSuccess = ({ jobName }) => {
49
+ // Clear any existing timer
50
+ if (seedUploadTimer) {
51
+ clearTimeout(seedUploadTimer);
52
+ }
53
+
54
+ // Set success message
55
+ setSeedUploadSuccess(jobName);
56
+
57
+ // Auto-clear after exactly 5000 ms
58
+ const timer = setTimeout(() => {
59
+ setSeedUploadSuccess(null);
60
+ setSeedUploadTimer(null);
61
+ }, 5000);
62
+
63
+ setSeedUploadTimer(timer);
64
+ };
65
+
66
+ // Cleanup timer on unmount
67
+ useEffect(() => {
68
+ return () => {
69
+ if (seedUploadTimer) {
70
+ clearTimeout(seedUploadTimer);
71
+ }
72
+ };
73
+ }, [seedUploadTimer]);
74
+
75
+ // Focus upload panel when opened
76
+ useEffect(() => {
77
+ if (isUploadOpen && uploadPanelRef.current) {
78
+ const uploadArea = uploadPanelRef.current.querySelector(
79
+ '[data-testid="upload-area"]'
80
+ );
81
+ if (uploadArea) {
82
+ uploadArea.focus();
83
+ }
84
+ }
85
+ }, [isUploadOpen]);
86
+
35
87
  return (
36
88
  <Tooltip.Provider delayDuration={200}>
37
89
  <Box className="min-h-screen bg-gray-1">
@@ -56,35 +108,40 @@ export default function Layout({
56
108
  gap="4"
57
109
  >
58
110
  {/* 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
- )}
111
+ <Flex align="center" className="min-w-0 flex-1">
112
+ {/* Logo */}
113
+ <Box
114
+ asChild
115
+ className="shrink-0"
116
+ style={{ width: "80px", height: "60px" }}
117
+ >
118
+ <Link
119
+ to="/"
120
+ aria-label="Go to homepage"
121
+ className="rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500"
122
+ >
123
+ <Logo />
124
+ </Link>
125
+ </Box>
79
126
 
80
- {/* App title */}
81
- <Heading
82
- size="5"
83
- weight="medium"
84
- className="text-gray-12 truncate"
127
+ {/* App title - clickable to navigate to dashboard */}
128
+ <Box
129
+ asChild
130
+ className="shrink-0 cursor-pointer hover:bg-gray-3 rounded p-1 -m-1 transition-colors"
131
+ onClick={() => navigate("/")}
85
132
  >
86
- {title || "Prompt Pipeline"}
87
- </Heading>
133
+ <Heading
134
+ size="6"
135
+ weight="medium"
136
+ className="text-gray-12 truncate"
137
+ >
138
+ <>
139
+ Prompt
140
+ <br />
141
+ Pipeline
142
+ </>
143
+ </Heading>
144
+ </Box>
88
145
  </Flex>
89
146
 
90
147
  {/* Center: Navigation */}
@@ -95,36 +152,82 @@ export default function Layout({
95
152
  >
96
153
  <Flex align="center" gap="6">
97
154
  <RadixLink
98
- href="/"
155
+ href="/code"
99
156
  className={`text-sm font-medium transition-colors hover:text-blue-600 ${
100
- isActivePath("/")
157
+ isActivePath("/code")
101
158
  ? "text-blue-600"
102
159
  : "text-gray-11 hover:text-gray-12"
103
160
  }`}
104
- aria-current={isActivePath("/") ? "page" : undefined}
161
+ aria-current={isActivePath("/code") ? "page" : undefined}
105
162
  >
106
163
  <Flex align="center" gap="2">
107
- <Home className="h-4 w-4" />
108
- Dashboard
164
+ <Code2 className="h-4 w-4" />
165
+ Help
109
166
  </Flex>
110
167
  </RadixLink>
111
168
  </Flex>
112
169
  </nav>
113
170
 
114
171
  {/* Right side: Actions */}
115
- {actions && (
116
- <Flex align="center" gap="3" className="shrink-0">
117
- {actions}
118
- </Flex>
119
- )}
172
+ <Flex align="center" gap="3" className="shrink-0">
173
+ {actions}
174
+ <Tooltip.Root delayDuration={200}>
175
+ <Tooltip.Trigger asChild>
176
+ <Button
177
+ size="sm"
178
+ variant="default"
179
+ onClick={toggleUploadPanel}
180
+ aria-controls="layout-upload-panel"
181
+ aria-expanded={isUploadOpen}
182
+ >
183
+ <Upload className="h-4 w-4" />
184
+ <Text size="2" className="ml-2">
185
+ Upload Seed
186
+ </Text>
187
+ </Button>
188
+ </Tooltip.Trigger>
189
+ <Tooltip.Content side="bottom" sideOffset={5}>
190
+ <Text size="2">Upload seed file</Text>
191
+ </Tooltip.Content>
192
+ </Tooltip.Root>
193
+ </Flex>
120
194
  </Flex>
121
195
  </Box>
122
196
 
197
+ {/* Upload Panel */}
198
+ {isUploadOpen && (
199
+ <Box
200
+ id="layout-upload-panel"
201
+ ref={uploadPanelRef}
202
+ role="region"
203
+ aria-label="Upload seed file"
204
+ className="bg-blue-50"
205
+ >
206
+ <Flex
207
+ direction="column"
208
+ gap="3"
209
+ className={`mx-auto w-full ${maxWidth} px-4 sm:px-6 lg:px-8 py-4`}
210
+ >
211
+ {/* Success Message */}
212
+ {seedUploadSuccess && (
213
+ <Box className="rounded-md bg-green-50 p-3 border border-green-200">
214
+ <Text size="2" className="text-green-800">
215
+ Job <strong>{seedUploadSuccess}</strong> created
216
+ successfully
217
+ </Text>
218
+ </Box>
219
+ )}
220
+
221
+ <UploadSeed onUploadSuccess={handleSeedUploadSuccess} />
222
+ </Flex>
223
+ </Box>
224
+ )}
225
+
123
226
  {/* Main content */}
124
227
  <main
125
228
  id="main-content"
126
229
  role="main"
127
- className={`mx-auto w-full ${maxWidth} px-4 sm:px-6 lg:px-8 py-6`}
230
+ className={`mx-auto w-full ${maxWidth} px-4 sm:px-6 lg:px-8`}
128
231
  >
129
232
  {children}
130
233
  </main>
@@ -0,0 +1,47 @@
1
+ import React, { useSyncExternalStore, useId, useState, useEffect } from "react";
2
+ import {
3
+ subscribe,
4
+ getSnapshot,
5
+ getServerSnapshot,
6
+ addCadenceHint,
7
+ removeCadenceHint,
8
+ } from "../ui/client/time-store.js";
9
+
10
+ /**
11
+ * LiveText component for displaying computed text that updates on a cadence
12
+ *
13
+ * @param {Object} props
14
+ * @param {Function} props.compute - Function that takes nowMs and returns string to display
15
+ * @param {number} props.cadenceMs - Update cadence in milliseconds (default 10000)
16
+ * @param {string} props.className - CSS className for styling
17
+ */
18
+ export default function LiveText({ compute, cadenceMs = 10000, className }) {
19
+ const id = useId();
20
+
21
+ // Get current time from the global time store
22
+ const now = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
23
+
24
+ // Local state for the computed text to avoid re-renders of parent
25
+ const [displayText, setDisplayText] = useState(() => {
26
+ // Initial text for SSR safety
27
+ return compute(Date.now());
28
+ });
29
+
30
+ // Register cadence hint and handle subscription
31
+ useEffect(() => {
32
+ // Register cadence hint
33
+ addCadenceHint(id, cadenceMs);
34
+
35
+ // Cleanup function
36
+ return () => {
37
+ removeCadenceHint(id);
38
+ };
39
+ }, [id, cadenceMs]);
40
+
41
+ // Update display text when time changes
42
+ useEffect(() => {
43
+ setDisplayText(compute(now));
44
+ }, [now, compute]);
45
+
46
+ return <span className={className}>{displayText}</span>;
47
+ }