@ryanfw/prompt-orchestration-pipeline 0.6.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 +13 -11
- package/src/components/JobDetail.jsx +41 -71
- package/src/components/JobTable.jsx +32 -22
- package/src/components/Layout.jsx +0 -21
- package/src/components/LiveText.jsx +47 -0
- package/src/components/TaskDetailSidebar.jsx +216 -0
- package/src/components/TimerText.jsx +82 -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 +2 -164
- package/src/core/file-io.js +1 -1
- package/src/core/module-loader.js +54 -40
- package/src/core/pipeline-runner.js +52 -20
- package/src/core/status-writer.js +147 -3
- package/src/core/symlink-bridge.js +57 -0
- package/src/core/symlink-utils.js +94 -0
- package/src/core/task-runner.js +267 -443
- package/src/llm/index.js +167 -52
- package/src/pages/Code.jsx +57 -3
- package/src/pages/PipelineDetail.jsx +92 -22
- package/src/pages/PromptPipelineDashboard.jsx +15 -36
- package/src/providers/anthropic.js +83 -69
- package/src/providers/base.js +52 -0
- package/src/providers/deepseek.js +17 -34
- 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 +16 -26
- package/src/ui/client/api.js +134 -0
- package/src/ui/client/hooks/useJobDetailWithUpdates.js +65 -178
- package/src/ui/client/index.css +9 -0
- package/src/ui/client/index.html +1 -0
- package/src/ui/client/main.jsx +18 -15
- 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-WgJUlSmE.js → index-DqkbzXZ1.js} +1408 -771
- package/src/ui/dist/assets/style-DBF9NQGk.css +62 -0
- package/src/ui/dist/index.html +3 -2
- package/src/ui/public/favicon.svg +12 -0
- package/src/ui/server.js +231 -33
- package/src/ui/transformers/status-transformer.js +18 -31
- 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 +4 -7
- package/src/utils/ui.jsx +14 -16
- 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-x0V-5m8e.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,15 +15,13 @@ 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
21
|
const jobTitle = job.title;
|
|
25
22
|
|
|
23
|
+
const { startMs, endMs } = currentTask ? taskToTimerProps(currentTask) : {};
|
|
24
|
+
|
|
26
25
|
return (
|
|
27
26
|
<Card
|
|
28
27
|
role="button"
|
|
@@ -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,40 +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
|
-
|
|
10
|
-
function formatCurrency4(x) {
|
|
11
|
-
if (typeof x !== "number" || x === 0) return "$0.0000";
|
|
12
|
-
const formatted = x.toFixed(4);
|
|
13
|
-
// Trim trailing zeros and unnecessary decimal point
|
|
14
|
-
return `$${formatted.replace(/\.?0+$/, "")}`;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function formatTokensCompact(n) {
|
|
18
|
-
if (typeof n !== "number" || n === 0) return "0 tok";
|
|
19
|
-
|
|
20
|
-
if (n >= 1000000) {
|
|
21
|
-
return `${(n / 1000000).toFixed(1).replace(/\.0$/, "")}M tokens`;
|
|
22
|
-
} else if (n >= 1000) {
|
|
23
|
-
return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k tokens`;
|
|
24
|
-
}
|
|
25
|
-
return `${n} tokens`;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export default function JobDetail({ job, pipeline, onClose, onResume }) {
|
|
29
|
-
const now = useTicker(1000);
|
|
30
|
-
const [resumeFrom, setResumeFrom] = useState(
|
|
31
|
-
pipeline?.tasks?.[0]
|
|
32
|
-
? typeof pipeline.tasks[0] === "string"
|
|
33
|
-
? pipeline.tasks[0]
|
|
34
|
-
: (pipeline.tasks[0].id ?? pipeline.tasks[0].name ?? "")
|
|
35
|
-
: ""
|
|
36
|
-
);
|
|
37
|
-
|
|
7
|
+
export default function JobDetail({ job, pipeline }) {
|
|
38
8
|
// job.tasks is expected to be an object keyed by task name; normalize from array if needed
|
|
39
9
|
const taskById = React.useMemo(() => {
|
|
40
10
|
const tasks = job?.tasks;
|
|
@@ -58,16 +28,6 @@ export default function JobDetail({ job, pipeline, onClose, onResume }) {
|
|
|
58
28
|
return result;
|
|
59
29
|
}, [job?.tasks]);
|
|
60
30
|
|
|
61
|
-
useEffect(() => {
|
|
62
|
-
setResumeFrom(
|
|
63
|
-
pipeline?.tasks?.[0]
|
|
64
|
-
? typeof pipeline.tasks[0] === "string"
|
|
65
|
-
? pipeline.tasks[0]
|
|
66
|
-
: (pipeline.tasks[0].id ?? pipeline.tasks[0].name ?? "")
|
|
67
|
-
: ""
|
|
68
|
-
);
|
|
69
|
-
}, [job.id, pipeline?.tasks?.length]);
|
|
70
|
-
|
|
71
31
|
// Compute pipeline tasks from pipeline or derive from job tasks
|
|
72
32
|
const computedPipeline = React.useMemo(() => {
|
|
73
33
|
let result;
|
|
@@ -91,21 +51,11 @@ export default function JobDetail({ job, pipeline, onClose, onResume }) {
|
|
|
91
51
|
return result;
|
|
92
52
|
}, [pipeline, job?.tasks]);
|
|
93
53
|
|
|
94
|
-
//
|
|
95
|
-
const
|
|
54
|
+
// Memoized helper to preserve DAG item identity and minimize churn
|
|
55
|
+
const stableDagItems = React.useMemo(() => {
|
|
96
56
|
const rawDagItems = computeDagItems(job, computedPipeline);
|
|
97
57
|
|
|
98
|
-
|
|
99
|
-
if (process.env.NODE_ENV !== "test") {
|
|
100
|
-
console.debug("[JobDetail] computed DAG item", {
|
|
101
|
-
id: item.id,
|
|
102
|
-
status: item.status,
|
|
103
|
-
stage: item.stage,
|
|
104
|
-
jobHasTasks: !!job?.tasks,
|
|
105
|
-
taskKeys: job?.tasks ? Object.keys(job.tasks) : null,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
58
|
+
return rawDagItems.map((item, index) => {
|
|
109
59
|
const task = taskById[item.id];
|
|
110
60
|
|
|
111
61
|
const taskConfig = task?.config || {};
|
|
@@ -118,18 +68,9 @@ export default function JobDetail({ job, pipeline, onClose, onResume }) {
|
|
|
118
68
|
if (taskConfig?.temperature != null) {
|
|
119
69
|
subtitleParts.push(`temp ${taskConfig.temperature}`);
|
|
120
70
|
}
|
|
121
|
-
if (task?.attempts != null) {
|
|
122
|
-
subtitleParts.push(`${task.attempts} attempts`);
|
|
123
|
-
}
|
|
124
71
|
if (task?.refinementAttempts != null) {
|
|
125
72
|
subtitleParts.push(`${task.refinementAttempts} refinements`);
|
|
126
73
|
}
|
|
127
|
-
if (task?.startedAt) {
|
|
128
|
-
const durationMs = taskDisplayDurationMs(task, now);
|
|
129
|
-
if (durationMs > 0) {
|
|
130
|
-
subtitleParts.push(fmtDuration(durationMs));
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
74
|
|
|
134
75
|
// Prefer taskBreakdown totals for consistency with backend
|
|
135
76
|
const taskBreakdown = job?.costs?.taskBreakdown?.[item.id]?.summary || {};
|
|
@@ -147,7 +88,7 @@ export default function JobDetail({ job, pipeline, onClose, onResume }) {
|
|
|
147
88
|
? errorMsg
|
|
148
89
|
: null;
|
|
149
90
|
|
|
150
|
-
|
|
91
|
+
return {
|
|
151
92
|
...item,
|
|
152
93
|
title:
|
|
153
94
|
typeof item.id === "string"
|
|
@@ -155,13 +96,42 @@ export default function JobDetail({ job, pipeline, onClose, onResume }) {
|
|
|
155
96
|
: item.id?.name || item.id?.id || `Task ${item.id}`,
|
|
156
97
|
subtitle: subtitleParts.length > 0 ? subtitleParts.join(" · ") : null,
|
|
157
98
|
body,
|
|
99
|
+
startedAt: task?.startedAt,
|
|
100
|
+
endedAt: task?.endedAt,
|
|
158
101
|
};
|
|
102
|
+
});
|
|
103
|
+
}, [job, computedPipeline, taskById]);
|
|
104
|
+
|
|
105
|
+
// Previous dagItems reference for identity comparison
|
|
106
|
+
const prevDagItemsRef = React.useRef([]);
|
|
159
107
|
|
|
160
|
-
|
|
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;
|
|
161
130
|
});
|
|
162
131
|
|
|
163
|
-
|
|
164
|
-
|
|
132
|
+
prevDagItemsRef.current = newItems;
|
|
133
|
+
return newItems;
|
|
134
|
+
}, [stableDagItems]);
|
|
165
135
|
|
|
166
136
|
const activeIndex = React.useMemo(() => {
|
|
167
137
|
const index = computeActiveIndex(dagItems);
|
|
@@ -177,10 +147,10 @@ export default function JobDetail({ job, pipeline, onClose, onResume }) {
|
|
|
177
147
|
[job]
|
|
178
148
|
);
|
|
179
149
|
|
|
180
|
-
console.log("dagItems", dagItems);
|
|
181
|
-
|
|
182
150
|
return (
|
|
183
151
|
<div className="flex h-full flex-col">
|
|
152
|
+
{/* Job Header */}
|
|
153
|
+
|
|
184
154
|
<DAGGrid
|
|
185
155
|
items={dagItems}
|
|
186
156
|
activeIndex={activeIndex}
|
|
@@ -1,11 +1,13 @@
|
|
|
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
12
|
// Local helpers for formatting costs and tokens
|
|
11
13
|
function formatCurrency4(x) {
|
|
@@ -26,13 +28,7 @@ function formatTokensCompact(n) {
|
|
|
26
28
|
return `${n} tok`;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
|
-
export default function JobTable({
|
|
30
|
-
jobs,
|
|
31
|
-
pipeline,
|
|
32
|
-
onOpenJob,
|
|
33
|
-
overallElapsed,
|
|
34
|
-
now,
|
|
35
|
-
}) {
|
|
31
|
+
export default function JobTable({ jobs, pipeline, onOpenJob }) {
|
|
36
32
|
if (jobs.length === 0) {
|
|
37
33
|
return (
|
|
38
34
|
<Box className="p-6">
|
|
@@ -72,9 +68,6 @@ export default function JobTable({
|
|
|
72
68
|
)
|
|
73
69
|
: job.tasks || {};
|
|
74
70
|
const currentTask = job.current ? taskById[job.current] : undefined;
|
|
75
|
-
const currentElapsedMs = currentTask
|
|
76
|
-
? taskDisplayDurationMs(currentTask, now)
|
|
77
|
-
: 0;
|
|
78
71
|
const totalCompleted = countCompleted(job);
|
|
79
72
|
const totalTasks =
|
|
80
73
|
pipeline?.tasks?.length ??
|
|
@@ -84,7 +77,6 @@ export default function JobTable({
|
|
|
84
77
|
const progress = Number.isFinite(job.progress)
|
|
85
78
|
? Math.round(job.progress)
|
|
86
79
|
: 0;
|
|
87
|
-
const duration = overallElapsed(job);
|
|
88
80
|
const currentTaskName = currentTask
|
|
89
81
|
? (currentTask.name ?? currentTask.id ?? job.current)
|
|
90
82
|
: undefined;
|
|
@@ -157,7 +149,7 @@ export default function JobTable({
|
|
|
157
149
|
<Text size="2" className="text-slate-700">
|
|
158
150
|
{currentTaskName
|
|
159
151
|
? currentTaskName
|
|
160
|
-
: job.status === "
|
|
152
|
+
: job.status === "done"
|
|
161
153
|
? "—"
|
|
162
154
|
: (job.current ?? "—")}
|
|
163
155
|
</Text>
|
|
@@ -169,12 +161,26 @@ export default function JobTable({
|
|
|
169
161
|
currentTask?.temperature != null
|
|
170
162
|
? `temp ${currentTaskConfig?.temperature ?? currentTask?.temperature}`
|
|
171
163
|
: null,
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
+
})(),
|
|
175
176
|
]
|
|
176
177
|
.filter(Boolean)
|
|
177
|
-
.
|
|
178
|
+
.map((item, index) => (
|
|
179
|
+
<React.Fragment key={index}>
|
|
180
|
+
{typeof item === "string" ? item : item}
|
|
181
|
+
{index < 2 && " · "}
|
|
182
|
+
</React.Fragment>
|
|
183
|
+
))}
|
|
178
184
|
</Text>
|
|
179
185
|
)}
|
|
180
186
|
</Flex>
|
|
@@ -215,9 +221,13 @@ export default function JobTable({
|
|
|
215
221
|
<Table.Cell>
|
|
216
222
|
<Flex align="center" gap="1">
|
|
217
223
|
<TimerReset className="h-3 w-3 text-slate-500" />
|
|
218
|
-
<
|
|
219
|
-
{
|
|
220
|
-
|
|
224
|
+
<LiveText
|
|
225
|
+
cadenceMs={10000}
|
|
226
|
+
compute={(now) =>
|
|
227
|
+
fmtDuration(jobCumulativeDurationMs(job, now))
|
|
228
|
+
}
|
|
229
|
+
className="text-slate-700"
|
|
230
|
+
/>
|
|
221
231
|
</Flex>
|
|
222
232
|
</Table.Cell>
|
|
223
233
|
|
|
@@ -19,7 +19,6 @@ export default function Layout({
|
|
|
19
19
|
pageTitle,
|
|
20
20
|
breadcrumbs,
|
|
21
21
|
actions,
|
|
22
|
-
showBackButton = false,
|
|
23
22
|
backTo = "/",
|
|
24
23
|
maxWidth = "max-w-7xl",
|
|
25
24
|
}) {
|
|
@@ -110,26 +109,6 @@ export default function Layout({
|
|
|
110
109
|
>
|
|
111
110
|
{/* Left side: Navigation and title */}
|
|
112
111
|
<Flex align="center" className="min-w-0 flex-1">
|
|
113
|
-
{/* Back button (conditional) */}
|
|
114
|
-
{showBackButton && (
|
|
115
|
-
<Tooltip.Root delayDuration={200}>
|
|
116
|
-
<Tooltip.Trigger asChild>
|
|
117
|
-
<Button
|
|
118
|
-
variant="ghost"
|
|
119
|
-
size="sm"
|
|
120
|
-
onClick={handleBack}
|
|
121
|
-
className="shrink-0"
|
|
122
|
-
aria-label="Go back"
|
|
123
|
-
>
|
|
124
|
-
<ArrowLeft className="h-4 w-4" />
|
|
125
|
-
</Button>
|
|
126
|
-
</Tooltip.Trigger>
|
|
127
|
-
<Tooltip.Content side="bottom" sideOffset={5}>
|
|
128
|
-
<Text size="2">Go back</Text>
|
|
129
|
-
</Tooltip.Content>
|
|
130
|
-
</Tooltip.Root>
|
|
131
|
-
)}
|
|
132
|
-
|
|
133
112
|
{/* Logo */}
|
|
134
113
|
<Box
|
|
135
114
|
asChild
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Callout } from "@radix-ui/themes";
|
|
3
|
+
import { TaskFilePane } from "./TaskFilePane.jsx";
|
|
4
|
+
import { TaskState } from "../config/statuses.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* TaskDetailSidebar component for displaying task details in a slide-over panel
|
|
8
|
+
* @param {Object} props - Component props
|
|
9
|
+
* @param {boolean} props.open - Whether the sidebar is open
|
|
10
|
+
* @param {string} props.title - Preformatted step name for the header
|
|
11
|
+
* @param {string} props.status - TaskState for styling
|
|
12
|
+
* @param {string} props.jobId - Job ID for file operations
|
|
13
|
+
* @param {string} props.taskId - Task ID for file operations
|
|
14
|
+
* @param {string|null} props.taskBody - Task body for error callout when status is FAILED
|
|
15
|
+
* @param {Function} props.filesByTypeForItem - Selector returning { artifacts, logs, tmp }
|
|
16
|
+
* @param {Object} props.task - Original task item, passed for filesByTypeForItem
|
|
17
|
+
* @param {Function} props.onClose - Close handler
|
|
18
|
+
*/
|
|
19
|
+
export function TaskDetailSidebar({
|
|
20
|
+
open,
|
|
21
|
+
title,
|
|
22
|
+
status,
|
|
23
|
+
jobId,
|
|
24
|
+
taskId,
|
|
25
|
+
taskBody,
|
|
26
|
+
filesByTypeForItem = () => ({ artifacts: [], logs: [], tmp: [] }),
|
|
27
|
+
task,
|
|
28
|
+
onClose,
|
|
29
|
+
taskIndex, // Add taskIndex for ID compatibility
|
|
30
|
+
}) {
|
|
31
|
+
// Internal state
|
|
32
|
+
const [filePaneType, setFilePaneType] = useState("artifacts");
|
|
33
|
+
const [filePaneOpen, setFilePaneOpen] = useState(false);
|
|
34
|
+
const [filePaneFilename, setFilePaneFilename] = useState(null);
|
|
35
|
+
const closeButtonRef = useRef(null);
|
|
36
|
+
|
|
37
|
+
// Get CSS classes for card header based on status (mirrored from DAGGrid)
|
|
38
|
+
const getHeaderClasses = (status) => {
|
|
39
|
+
switch (status) {
|
|
40
|
+
case TaskState.DONE:
|
|
41
|
+
return "bg-green-50 border-green-200 text-green-700";
|
|
42
|
+
case TaskState.RUNNING:
|
|
43
|
+
return "bg-amber-50 border-amber-200 text-amber-700";
|
|
44
|
+
case TaskState.FAILED:
|
|
45
|
+
return "bg-pink-50 border-pink-200 text-pink-700";
|
|
46
|
+
default:
|
|
47
|
+
return "bg-gray-100 border-gray-200 text-gray-700";
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// Focus close button when sidebar opens
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (open && closeButtonRef.current) {
|
|
54
|
+
closeButtonRef.current.focus();
|
|
55
|
+
}
|
|
56
|
+
}, [open]);
|
|
57
|
+
|
|
58
|
+
// Reset internal state when open changes
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (open) {
|
|
61
|
+
setFilePaneType("artifacts");
|
|
62
|
+
setFilePaneOpen(false);
|
|
63
|
+
setFilePaneFilename(null);
|
|
64
|
+
}
|
|
65
|
+
}, [open]);
|
|
66
|
+
|
|
67
|
+
// Reset file pane when type changes
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
setFilePaneFilename(null);
|
|
70
|
+
setFilePaneOpen(false);
|
|
71
|
+
}, [filePaneType]);
|
|
72
|
+
|
|
73
|
+
// Handle file click
|
|
74
|
+
const handleFileClick = (filename) => {
|
|
75
|
+
setFilePaneFilename(filename);
|
|
76
|
+
setFilePaneOpen(true);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Handle TaskFilePane close
|
|
80
|
+
const handleFilePaneClose = () => {
|
|
81
|
+
setFilePaneOpen(false);
|
|
82
|
+
setFilePaneFilename(null);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (!open) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Get files for the current task
|
|
90
|
+
const filesForStep = filesByTypeForItem(task);
|
|
91
|
+
const filesForTab = filesForStep[filePaneType] ?? [];
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<aside
|
|
95
|
+
role="dialog"
|
|
96
|
+
aria-modal="true"
|
|
97
|
+
aria-labelledby={`slide-over-title-${taskIndex}`}
|
|
98
|
+
aria-hidden={false}
|
|
99
|
+
className={`fixed inset-y-0 right-0 z-[2000] w-full max-w-4xl bg-white border-l border-gray-200 transform transition-transform duration-300 ease-out translate-x-0`}
|
|
100
|
+
>
|
|
101
|
+
{/* Header */}
|
|
102
|
+
<div
|
|
103
|
+
className={`px-6 py-4 border-b flex items-center justify-between ${getHeaderClasses(status)}`}
|
|
104
|
+
>
|
|
105
|
+
<div
|
|
106
|
+
id={`slide-over-title-${taskIndex}`}
|
|
107
|
+
className="text-lg font-semibold truncate"
|
|
108
|
+
>
|
|
109
|
+
{title}
|
|
110
|
+
</div>
|
|
111
|
+
<button
|
|
112
|
+
ref={closeButtonRef}
|
|
113
|
+
type="button"
|
|
114
|
+
aria-label="Close details"
|
|
115
|
+
onClick={onClose}
|
|
116
|
+
className="rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50 px-3 py-1.5 text-base"
|
|
117
|
+
>
|
|
118
|
+
×
|
|
119
|
+
</button>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div className="p-6 space-y-8 overflow-y-auto h-full">
|
|
123
|
+
{/* Error Callout - shown when task has error status and body */}
|
|
124
|
+
{status === TaskState.FAILED && taskBody && (
|
|
125
|
+
<section aria-label="Error">
|
|
126
|
+
<Callout.Root role="alert" aria-live="assertive">
|
|
127
|
+
<Callout.Text className="whitespace-pre-wrap break-words">
|
|
128
|
+
{taskBody}
|
|
129
|
+
</Callout.Text>
|
|
130
|
+
</Callout.Root>
|
|
131
|
+
</section>
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{/* File Display Area with Type Tabs */}
|
|
135
|
+
<section className="mt-6">
|
|
136
|
+
<div className="flex items-center justify-between mb-4">
|
|
137
|
+
<h3 className="text-base font-semibold text-gray-900">Files</h3>
|
|
138
|
+
<div className="flex items-center space-x-2">
|
|
139
|
+
<div className="flex rounded-lg border border-gray-200 bg-gray-50 p-1">
|
|
140
|
+
<button
|
|
141
|
+
onClick={() => setFilePaneType("artifacts")}
|
|
142
|
+
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
|
143
|
+
filePaneType === "artifacts"
|
|
144
|
+
? "bg-white text-gray-900 shadow-sm"
|
|
145
|
+
: "text-gray-600 hover:text-gray-900"
|
|
146
|
+
}`}
|
|
147
|
+
>
|
|
148
|
+
Artifacts
|
|
149
|
+
</button>
|
|
150
|
+
<button
|
|
151
|
+
onClick={() => setFilePaneType("logs")}
|
|
152
|
+
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
|
153
|
+
filePaneType === "logs"
|
|
154
|
+
? "bg-white text-gray-900 shadow-sm"
|
|
155
|
+
: "text-gray-600 hover:text-gray-900"
|
|
156
|
+
}`}
|
|
157
|
+
>
|
|
158
|
+
Logs
|
|
159
|
+
</button>
|
|
160
|
+
<button
|
|
161
|
+
onClick={() => setFilePaneType("tmp")}
|
|
162
|
+
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
|
163
|
+
filePaneType === "tmp"
|
|
164
|
+
? "bg-white text-gray-900 shadow-sm"
|
|
165
|
+
: "text-gray-600 hover:text-gray-900"
|
|
166
|
+
}`}
|
|
167
|
+
>
|
|
168
|
+
Temp
|
|
169
|
+
</button>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</section>
|
|
174
|
+
|
|
175
|
+
{/* File List */}
|
|
176
|
+
<div className="space-y-2">
|
|
177
|
+
<div className="text-sm text-gray-600">
|
|
178
|
+
{filePaneType.charAt(0).toUpperCase() + filePaneType.slice(1)} files
|
|
179
|
+
for {taskId}
|
|
180
|
+
</div>
|
|
181
|
+
<div className="space-y-1">
|
|
182
|
+
{filesForTab.length === 0 ? (
|
|
183
|
+
<div className="text-sm text-gray-500 italic py-4 text-center">
|
|
184
|
+
No {filePaneType} files available for this task
|
|
185
|
+
</div>
|
|
186
|
+
) : (
|
|
187
|
+
filesForTab.map((name) => (
|
|
188
|
+
<div
|
|
189
|
+
key={`${filePaneType}-${name}`}
|
|
190
|
+
className="flex items-center justify-between p-2 rounded border border-gray-200 hover:border-gray-300 hover:bg-gray-50 cursor-pointer transition-colors"
|
|
191
|
+
onClick={() => handleFileClick(name)}
|
|
192
|
+
>
|
|
193
|
+
<div className="flex items-center space-x-2">
|
|
194
|
+
<span className="text-sm text-gray-700">{name}</span>
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
))
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
|
|
202
|
+
{/* TaskFilePane Modal */}
|
|
203
|
+
<TaskFilePane
|
|
204
|
+
isOpen={filePaneOpen}
|
|
205
|
+
jobId={jobId}
|
|
206
|
+
taskId={taskId}
|
|
207
|
+
type={filePaneType}
|
|
208
|
+
filename={filePaneFilename}
|
|
209
|
+
onClose={handleFilePaneClose}
|
|
210
|
+
/>
|
|
211
|
+
</div>
|
|
212
|
+
</aside>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export default React.memo(TaskDetailSidebar);
|