@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.
- package/README.md +1 -2
- package/package.json +1 -2
- package/src/api/validators/json.js +39 -0
- package/src/components/DAGGrid.jsx +392 -303
- package/src/components/JobCard.jsx +14 -12
- package/src/components/JobDetail.jsx +54 -51
- package/src/components/JobTable.jsx +72 -23
- package/src/components/Layout.jsx +145 -42
- package/src/components/LiveText.jsx +47 -0
- package/src/components/PageSubheader.jsx +75 -0
- package/src/components/TaskDetailSidebar.jsx +216 -0
- package/src/components/TimerText.jsx +82 -0
- package/src/components/UploadSeed.jsx +0 -70
- package/src/components/ui/Logo.jsx +16 -0
- package/src/components/ui/RestartJobModal.jsx +140 -0
- package/src/components/ui/toast.jsx +138 -0
- package/src/config/models.js +322 -0
- package/src/config/statuses.js +119 -0
- package/src/core/config.js +4 -34
- package/src/core/file-io.js +13 -28
- package/src/core/module-loader.js +54 -40
- package/src/core/pipeline-runner.js +65 -26
- package/src/core/status-writer.js +213 -58
- package/src/core/symlink-bridge.js +57 -0
- package/src/core/symlink-utils.js +94 -0
- package/src/core/task-runner.js +321 -437
- package/src/llm/index.js +258 -86
- package/src/pages/Code.jsx +351 -0
- package/src/pages/PipelineDetail.jsx +124 -15
- package/src/pages/PromptPipelineDashboard.jsx +20 -88
- package/src/providers/anthropic.js +83 -69
- package/src/providers/base.js +52 -0
- package/src/providers/deepseek.js +20 -21
- package/src/providers/gemini.js +226 -0
- package/src/providers/openai.js +36 -106
- package/src/providers/zhipu.js +136 -0
- package/src/ui/client/adapters/job-adapter.js +42 -28
- package/src/ui/client/api.js +134 -0
- package/src/ui/client/hooks/useJobDetailWithUpdates.js +65 -179
- package/src/ui/client/index.css +15 -0
- package/src/ui/client/index.html +2 -1
- package/src/ui/client/main.jsx +19 -14
- package/src/ui/client/time-store.js +161 -0
- package/src/ui/config-bridge.js +15 -24
- package/src/ui/config-bridge.node.js +15 -24
- package/src/ui/dist/assets/{index-CxcrauYR.js → index-DqkbzXZ1.js} +2132 -1086
- package/src/ui/dist/assets/style-DBF9NQGk.css +62 -0
- package/src/ui/dist/index.html +4 -3
- package/src/ui/job-reader.js +0 -108
- package/src/ui/public/favicon.svg +12 -0
- package/src/ui/server.js +252 -0
- package/src/ui/sse-enhancer.js +0 -1
- package/src/ui/transformers/list-transformer.js +32 -12
- package/src/ui/transformers/status-transformer.js +29 -42
- package/src/utils/dag.js +8 -4
- package/src/utils/duration.js +13 -19
- package/src/utils/formatters.js +27 -0
- package/src/utils/geometry-equality.js +83 -0
- package/src/utils/pipelines.js +5 -1
- package/src/utils/time-utils.js +40 -0
- package/src/utils/token-cost-calculator.js +294 -0
- package/src/utils/ui.jsx +18 -20
- package/src/components/ui/select.jsx +0 -27
- package/src/lib/utils.js +0 -6
- package/src/ui/client/hooks/useTicker.js +0 -26
- package/src/ui/config-bridge.browser.js +0 -149
- 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 {
|
|
5
|
-
import { fmtDuration
|
|
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
|
|
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
|
|
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
|
-
{
|
|
72
|
-
<
|
|
73
|
-
{
|
|
74
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
76
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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 =
|
|
86
|
+
const body =
|
|
87
|
+
(item.status === "failed" || item.status === "error") && errorMsg
|
|
88
|
+
? errorMsg
|
|
89
|
+
: null;
|
|
118
90
|
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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 {
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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.
|
|
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 === "
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
.
|
|
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
|
|
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
|
-
{
|
|
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
|
|
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"
|
|
60
|
-
{/*
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
87
|
-
|
|
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
|
-
<
|
|
108
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
+
}
|