@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.
- package/README.md +415 -24
- package/package.json +45 -8
- package/src/api/files.js +48 -0
- package/src/api/index.js +149 -53
- package/src/api/validators/seed.js +141 -0
- package/src/cli/index.js +456 -29
- package/src/cli/run-orchestrator.js +39 -0
- package/src/cli/update-pipeline-json.js +47 -0
- package/src/components/DAGGrid.jsx +649 -0
- package/src/components/JobCard.jsx +96 -0
- package/src/components/JobDetail.jsx +159 -0
- package/src/components/JobTable.jsx +202 -0
- package/src/components/Layout.jsx +134 -0
- package/src/components/TaskFilePane.jsx +570 -0
- package/src/components/UploadSeed.jsx +239 -0
- package/src/components/ui/badge.jsx +20 -0
- package/src/components/ui/button.jsx +43 -0
- package/src/components/ui/card.jsx +20 -0
- package/src/components/ui/focus-styles.css +60 -0
- package/src/components/ui/progress.jsx +26 -0
- package/src/components/ui/select.jsx +27 -0
- package/src/components/ui/separator.jsx +6 -0
- package/src/config/paths.js +99 -0
- package/src/core/config.js +270 -9
- package/src/core/file-io.js +202 -0
- package/src/core/module-loader.js +157 -0
- package/src/core/orchestrator.js +275 -294
- package/src/core/pipeline-runner.js +95 -41
- package/src/core/progress.js +66 -0
- package/src/core/status-writer.js +331 -0
- package/src/core/task-runner.js +719 -73
- package/src/core/validation.js +120 -1
- package/src/lib/utils.js +6 -0
- package/src/llm/README.md +139 -30
- package/src/llm/index.js +222 -72
- package/src/pages/PipelineDetail.jsx +111 -0
- package/src/pages/PromptPipelineDashboard.jsx +223 -0
- package/src/providers/deepseek.js +3 -15
- package/src/ui/client/adapters/job-adapter.js +258 -0
- package/src/ui/client/bootstrap.js +120 -0
- package/src/ui/client/hooks/useJobDetailWithUpdates.js +619 -0
- package/src/ui/client/hooks/useJobList.js +50 -0
- package/src/ui/client/hooks/useJobListWithUpdates.js +335 -0
- package/src/ui/client/hooks/useTicker.js +26 -0
- package/src/ui/client/index.css +31 -0
- package/src/ui/client/index.html +18 -0
- package/src/ui/client/main.jsx +38 -0
- package/src/ui/config-bridge.browser.js +149 -0
- package/src/ui/config-bridge.js +149 -0
- package/src/ui/config-bridge.node.js +310 -0
- package/src/ui/dist/assets/index-BDABnI-4.js +33399 -0
- package/src/ui/dist/assets/style-Ks8LY8gB.css +28496 -0
- package/src/ui/dist/index.html +19 -0
- package/src/ui/endpoints/job-endpoints.js +300 -0
- package/src/ui/file-reader.js +216 -0
- package/src/ui/job-change-detector.js +83 -0
- package/src/ui/job-index.js +231 -0
- package/src/ui/job-reader.js +274 -0
- package/src/ui/job-scanner.js +188 -0
- package/src/ui/public/app.js +3 -1
- package/src/ui/server.js +1636 -59
- package/src/ui/sse-enhancer.js +149 -0
- package/src/ui/sse.js +204 -0
- package/src/ui/state-snapshot.js +252 -0
- package/src/ui/transformers/list-transformer.js +347 -0
- package/src/ui/transformers/status-transformer.js +307 -0
- package/src/ui/watcher.js +61 -7
- package/src/utils/dag.js +101 -0
- package/src/utils/duration.js +126 -0
- package/src/utils/id-generator.js +30 -0
- package/src/utils/jobs.js +7 -0
- package/src/utils/pipelines.js +44 -0
- package/src/utils/task-files.js +271 -0
- package/src/utils/ui.jsx +76 -0
- package/src/ui/public/index.html +0 -53
- 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
|
+
}
|