@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.
Files changed (61) 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 +13 -11
  6. package/src/components/JobDetail.jsx +41 -71
  7. package/src/components/JobTable.jsx +32 -22
  8. package/src/components/Layout.jsx +0 -21
  9. package/src/components/LiveText.jsx +47 -0
  10. package/src/components/TaskDetailSidebar.jsx +216 -0
  11. package/src/components/TimerText.jsx +82 -0
  12. package/src/components/ui/RestartJobModal.jsx +140 -0
  13. package/src/components/ui/toast.jsx +138 -0
  14. package/src/config/models.js +322 -0
  15. package/src/config/statuses.js +119 -0
  16. package/src/core/config.js +2 -164
  17. package/src/core/file-io.js +1 -1
  18. package/src/core/module-loader.js +54 -40
  19. package/src/core/pipeline-runner.js +52 -20
  20. package/src/core/status-writer.js +147 -3
  21. package/src/core/symlink-bridge.js +57 -0
  22. package/src/core/symlink-utils.js +94 -0
  23. package/src/core/task-runner.js +267 -443
  24. package/src/llm/index.js +167 -52
  25. package/src/pages/Code.jsx +57 -3
  26. package/src/pages/PipelineDetail.jsx +92 -22
  27. package/src/pages/PromptPipelineDashboard.jsx +15 -36
  28. package/src/providers/anthropic.js +83 -69
  29. package/src/providers/base.js +52 -0
  30. package/src/providers/deepseek.js +17 -34
  31. package/src/providers/gemini.js +226 -0
  32. package/src/providers/openai.js +36 -106
  33. package/src/providers/zhipu.js +136 -0
  34. package/src/ui/client/adapters/job-adapter.js +16 -26
  35. package/src/ui/client/api.js +134 -0
  36. package/src/ui/client/hooks/useJobDetailWithUpdates.js +65 -178
  37. package/src/ui/client/index.css +9 -0
  38. package/src/ui/client/index.html +1 -0
  39. package/src/ui/client/main.jsx +18 -15
  40. package/src/ui/client/time-store.js +161 -0
  41. package/src/ui/config-bridge.js +15 -24
  42. package/src/ui/config-bridge.node.js +15 -24
  43. package/src/ui/dist/assets/{index-WgJUlSmE.js → index-DqkbzXZ1.js} +1408 -771
  44. package/src/ui/dist/assets/style-DBF9NQGk.css +62 -0
  45. package/src/ui/dist/index.html +3 -2
  46. package/src/ui/public/favicon.svg +12 -0
  47. package/src/ui/server.js +231 -33
  48. package/src/ui/transformers/status-transformer.js +18 -31
  49. package/src/utils/dag.js +8 -4
  50. package/src/utils/duration.js +13 -19
  51. package/src/utils/formatters.js +27 -0
  52. package/src/utils/geometry-equality.js +83 -0
  53. package/src/utils/pipelines.js +5 -1
  54. package/src/utils/time-utils.js +40 -0
  55. package/src/utils/token-cost-calculator.js +4 -7
  56. package/src/utils/ui.jsx +14 -16
  57. package/src/components/ui/select.jsx +0 -27
  58. package/src/lib/utils.js +0 -6
  59. package/src/ui/client/hooks/useTicker.js +0 -26
  60. package/src/ui/config-bridge.browser.js +0 -149
  61. 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 { 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,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
- {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,40 +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
- // Local helpers for formatting costs and tokens
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
- // Compute DAG items and active index for visualization
95
- const dagItems = React.useMemo(() => {
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
- const processedItems = rawDagItems.map((item, index) => {
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
- const resultItem = {
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
- 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;
161
130
  });
162
131
 
163
- return processedItems;
164
- }, [job, computedPipeline, taskById, now]);
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 { 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
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 === "completed"
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
- currentElapsedMs > 0
173
- ? fmtDuration(currentElapsedMs)
174
- : 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
+ })(),
175
176
  ]
176
177
  .filter(Boolean)
177
- .join(" · ")}
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
- <Text size="2" className="text-slate-700">
219
- {fmtDuration(duration)}
220
- </Text>
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);