@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.
Files changed (76) hide show
  1. package/README.md +415 -24
  2. package/package.json +45 -8
  3. package/src/api/files.js +48 -0
  4. package/src/api/index.js +149 -53
  5. package/src/api/validators/seed.js +141 -0
  6. package/src/cli/index.js +456 -29
  7. package/src/cli/run-orchestrator.js +39 -0
  8. package/src/cli/update-pipeline-json.js +47 -0
  9. package/src/components/DAGGrid.jsx +649 -0
  10. package/src/components/JobCard.jsx +96 -0
  11. package/src/components/JobDetail.jsx +159 -0
  12. package/src/components/JobTable.jsx +202 -0
  13. package/src/components/Layout.jsx +134 -0
  14. package/src/components/TaskFilePane.jsx +570 -0
  15. package/src/components/UploadSeed.jsx +239 -0
  16. package/src/components/ui/badge.jsx +20 -0
  17. package/src/components/ui/button.jsx +43 -0
  18. package/src/components/ui/card.jsx +20 -0
  19. package/src/components/ui/focus-styles.css +60 -0
  20. package/src/components/ui/progress.jsx +26 -0
  21. package/src/components/ui/select.jsx +27 -0
  22. package/src/components/ui/separator.jsx +6 -0
  23. package/src/config/paths.js +99 -0
  24. package/src/core/config.js +270 -9
  25. package/src/core/file-io.js +202 -0
  26. package/src/core/module-loader.js +157 -0
  27. package/src/core/orchestrator.js +275 -294
  28. package/src/core/pipeline-runner.js +95 -41
  29. package/src/core/progress.js +66 -0
  30. package/src/core/status-writer.js +331 -0
  31. package/src/core/task-runner.js +719 -73
  32. package/src/core/validation.js +120 -1
  33. package/src/lib/utils.js +6 -0
  34. package/src/llm/README.md +139 -30
  35. package/src/llm/index.js +222 -72
  36. package/src/pages/PipelineDetail.jsx +111 -0
  37. package/src/pages/PromptPipelineDashboard.jsx +223 -0
  38. package/src/providers/deepseek.js +3 -15
  39. package/src/ui/client/adapters/job-adapter.js +258 -0
  40. package/src/ui/client/bootstrap.js +120 -0
  41. package/src/ui/client/hooks/useJobDetailWithUpdates.js +619 -0
  42. package/src/ui/client/hooks/useJobList.js +50 -0
  43. package/src/ui/client/hooks/useJobListWithUpdates.js +335 -0
  44. package/src/ui/client/hooks/useTicker.js +26 -0
  45. package/src/ui/client/index.css +31 -0
  46. package/src/ui/client/index.html +18 -0
  47. package/src/ui/client/main.jsx +38 -0
  48. package/src/ui/config-bridge.browser.js +149 -0
  49. package/src/ui/config-bridge.js +149 -0
  50. package/src/ui/config-bridge.node.js +310 -0
  51. package/src/ui/dist/assets/index-BDABnI-4.js +33399 -0
  52. package/src/ui/dist/assets/style-Ks8LY8gB.css +28496 -0
  53. package/src/ui/dist/index.html +19 -0
  54. package/src/ui/endpoints/job-endpoints.js +300 -0
  55. package/src/ui/file-reader.js +216 -0
  56. package/src/ui/job-change-detector.js +83 -0
  57. package/src/ui/job-index.js +231 -0
  58. package/src/ui/job-reader.js +274 -0
  59. package/src/ui/job-scanner.js +188 -0
  60. package/src/ui/public/app.js +3 -1
  61. package/src/ui/server.js +1636 -59
  62. package/src/ui/sse-enhancer.js +149 -0
  63. package/src/ui/sse.js +204 -0
  64. package/src/ui/state-snapshot.js +252 -0
  65. package/src/ui/transformers/list-transformer.js +347 -0
  66. package/src/ui/transformers/status-transformer.js +307 -0
  67. package/src/ui/watcher.js +61 -7
  68. package/src/utils/dag.js +101 -0
  69. package/src/utils/duration.js +126 -0
  70. package/src/utils/id-generator.js +30 -0
  71. package/src/utils/jobs.js +7 -0
  72. package/src/utils/pipelines.js +44 -0
  73. package/src/utils/task-files.js +271 -0
  74. package/src/utils/ui.jsx +76 -0
  75. package/src/ui/public/index.html +0 -53
  76. package/src/ui/public/style.css +0 -341
@@ -0,0 +1,649 @@
1
+ import React, {
2
+ useLayoutEffect,
3
+ useMemo,
4
+ useRef,
5
+ useState,
6
+ createRef,
7
+ } from "react";
8
+ import { Callout } from "@radix-ui/themes";
9
+ import { TaskFilePane } from "./TaskFilePane.jsx";
10
+ import { createEmptyTaskFiles } from "../utils/task-files.js";
11
+
12
+ // Helpers: capitalize fallback step ids (upperFirst only; do not alter provided titles)
13
+ function upperFirst(s) {
14
+ return typeof s === "string" && s.length > 0
15
+ ? s.charAt(0).toUpperCase() + s.slice(1)
16
+ : s;
17
+ }
18
+
19
+ // Format stage token into human-readable label
20
+ function formatStageLabel(s) {
21
+ if (typeof s !== "string" || s.length === 0) return s;
22
+
23
+ // Replace underscores and hyphens with spaces first
24
+ let processed = s.replace(/[_-]/g, " ");
25
+
26
+ // Add space before capital letters that follow lowercase letters
27
+ processed = processed.replace(/([a-z])([A-Z])/g, "$1 $2");
28
+
29
+ // Split into words and clean up
30
+ const words = processed.trim().split(/\s+/).filter(Boolean);
31
+
32
+ if (words.length === 0) return s;
33
+
34
+ // Handle consecutive capitals by treating them as a single word
35
+ const normalizedWords = words.map((word) => {
36
+ // If word is all caps or mostly caps, treat it as an acronym
37
+ if (word.length > 1 && word === word.toUpperCase()) {
38
+ return word;
39
+ }
40
+ return word;
41
+ });
42
+
43
+ // Lower-case all words except first (which gets upperFirst)
44
+ const [first, ...rest] = normalizedWords;
45
+ return (
46
+ upperFirst(first.toLowerCase()) +
47
+ " " +
48
+ rest.map((w) => w.toLowerCase()).join(" ")
49
+ );
50
+ }
51
+
52
+ function formatStepName(item, idx) {
53
+ const raw = item.title ?? item.id ?? `Step ${idx + 1}`;
54
+ // If item has a title, assume it’s curated and leave unchanged; otherwise capitalize fallback
55
+ return upperFirst(item.title ? item.title : raw);
56
+ }
57
+
58
+ /**
59
+ * DAGGrid component for visualizing pipeline tasks with connectors and slide-over details
60
+ * @param {Object} props
61
+ * @param {Array} props.items - Array of DAG items with id, status, and optional title/subtitle
62
+ * @param {number} props.cols - Number of columns for grid layout (default: 3)
63
+ * @param {string} props.cardClass - Additional CSS classes for cards
64
+ * @param {number} props.activeIndex - Index of the active item
65
+ * @param {string} props.jobId - Job ID for file operations
66
+ * @param {Function} props.filesByTypeForItem - Selector returning { artifacts, logs, tmp }
67
+ */
68
+ // Instrumentation helper for DAGGrid
69
+ const createDAGGridLogger = (jobId) => {
70
+ const prefix = `[DAGGrid:${jobId || "unknown"}]`;
71
+ return {
72
+ log: (message, data = null) => {
73
+ console.log(`${prefix} ${message}`, data ? data : "");
74
+ },
75
+ warn: (message, data = null) => {
76
+ console.warn(`${prefix} ${message}`, data ? data : "");
77
+ },
78
+ error: (message, data = null) => {
79
+ console.error(`${prefix} ${message}`, data ? data : "");
80
+ },
81
+ group: (label) => console.group(`${prefix} ${label}`),
82
+ groupEnd: () => console.groupEnd(),
83
+ table: (data, title) => {
84
+ console.log(`${prefix} ${title}:`);
85
+ console.table(data);
86
+ },
87
+ };
88
+ };
89
+
90
+ function DAGGrid({
91
+ items,
92
+ cols = 3,
93
+ cardClass = "",
94
+ activeIndex = 0,
95
+ jobId,
96
+ filesByTypeForItem = () => createEmptyTaskFiles(),
97
+ }) {
98
+ const logger = React.useMemo(() => createDAGGridLogger(jobId), [jobId]);
99
+
100
+ const overlayRef = useRef(null);
101
+ const gridRef = useRef(null);
102
+ const nodeRefs = useRef([]);
103
+ const [lines, setLines] = useState([]);
104
+ const [effectiveCols, setEffectiveCols] = useState(cols);
105
+ const [openIdx, setOpenIdx] = useState(null);
106
+ const [selectedFile, setSelectedFile] = useState(null);
107
+ const [filePaneOpen, setFilePaneOpen] = useState(false);
108
+ const [filePaneType, setFilePaneType] = useState("artifacts");
109
+ const [filePaneFilename, setFilePaneFilename] = useState(null);
110
+
111
+ // Log component props and state changes
112
+ React.useEffect(() => {
113
+ logger.group("Component Render");
114
+ logger.log("Props received:", {
115
+ itemCount: items?.length,
116
+ cols,
117
+ activeIndex,
118
+ jobId,
119
+ });
120
+ logger.log("Items data:", items);
121
+ logger.groupEnd();
122
+ }, [items, cols, activeIndex, jobId, logger]);
123
+
124
+ // Create refs for each node
125
+ nodeRefs.current = useMemo(
126
+ () => items.map((_, i) => nodeRefs.current[i] ?? createRef()),
127
+ [items.length]
128
+ );
129
+
130
+ // Responsive: force single-column on narrow screens
131
+ useLayoutEffect(() => {
132
+ // Skip in test environment
133
+ if (process.env.NODE_ENV === "test") {
134
+ setEffectiveCols(cols);
135
+ return;
136
+ }
137
+
138
+ const mq = window.matchMedia("(min-width: 1024px)");
139
+ const apply = () => setEffectiveCols(mq.matches ? cols : 1);
140
+ apply();
141
+
142
+ const handleChange = () => apply();
143
+ mq.addEventListener
144
+ ? mq.addEventListener("change", handleChange)
145
+ : mq.addListener(handleChange);
146
+
147
+ return () => {
148
+ mq.removeEventListener
149
+ ? mq.removeEventListener("change", handleChange)
150
+ : mq.removeListener(handleChange);
151
+ };
152
+ }, [cols]);
153
+
154
+ // Calculate visual order for snake-like layout
155
+ const visualOrder = useMemo(() => {
156
+ if (effectiveCols === 1) {
157
+ return Array.from({ length: items.length }, (_, i) => i);
158
+ }
159
+
160
+ const order = [];
161
+ const rows = Math.ceil(items.length / effectiveCols);
162
+
163
+ for (let r = 0; r < rows; r++) {
164
+ const start = r * effectiveCols;
165
+ const end = Math.min(start + effectiveCols, items.length);
166
+ const slice = Array.from({ length: end - start }, (_, k) => start + k);
167
+ const rowLen = slice.length;
168
+ const pad = Math.max(0, effectiveCols - rowLen);
169
+
170
+ if (r % 2 === 1) {
171
+ // Reverse order for odd rows (snake pattern)
172
+ const reversed = slice.reverse();
173
+ order.push(...Array(pad).fill(-1), ...reversed);
174
+ } else {
175
+ order.push(...slice);
176
+ }
177
+ }
178
+
179
+ return order;
180
+ }, [items.length, effectiveCols]);
181
+
182
+ // Calculate connector lines between cards
183
+ useLayoutEffect(() => {
184
+ // Skip entirely in test environment to prevent hanging
185
+ if (process.env.NODE_ENV === "test") {
186
+ return;
187
+ }
188
+
189
+ // Skip if no window or no items
190
+ if (
191
+ typeof window === "undefined" ||
192
+ !overlayRef.current ||
193
+ items.length === 0
194
+ ) {
195
+ return;
196
+ }
197
+
198
+ let isComputing = false;
199
+ const compute = () => {
200
+ if (isComputing) return; // Prevent infinite loops
201
+ isComputing = true;
202
+
203
+ try {
204
+ if (!overlayRef.current) return;
205
+
206
+ const overlayBox = overlayRef.current.getBoundingClientRect();
207
+ const boxes = nodeRefs.current.map((r) => {
208
+ const el = r.current;
209
+ if (!el) return null;
210
+
211
+ const b = el.getBoundingClientRect();
212
+ const headerEl = el.querySelector('[data-role="card-header"]');
213
+ const hr = headerEl ? headerEl.getBoundingClientRect() : null;
214
+ const headerMidY = hr
215
+ ? hr.top - overlayBox.top + hr.height / 2
216
+ : b.top - overlayBox.top + Math.min(24, b.height / 6);
217
+
218
+ return {
219
+ left: b.left - overlayBox.left,
220
+ top: b.top - overlayBox.top,
221
+ width: b.width,
222
+ height: b.height,
223
+ right: b.right - overlayBox.left,
224
+ bottom: b.bottom - overlayBox.top,
225
+ cx: b.left - overlayBox.left + b.width / 2,
226
+ cy: b.top - overlayBox.top + b.height / 2,
227
+ headerMidY,
228
+ };
229
+ });
230
+
231
+ const newLines = [];
232
+ for (let i = 0; i < items.length - 1; i++) {
233
+ const a = boxes[i];
234
+ const b = boxes[i + 1];
235
+ if (!a || !b) continue;
236
+
237
+ const rowA = Math.floor(i / effectiveCols);
238
+ const rowB = Math.floor((i + 1) / effectiveCols);
239
+ const sameRow = rowA === rowB;
240
+
241
+ if (sameRow) {
242
+ // Horizontal connection
243
+ const leftToRight = rowA % 2 === 0;
244
+ if (leftToRight) {
245
+ const start = { x: a.right, y: a.headerMidY };
246
+ const end = { x: b.left, y: b.headerMidY };
247
+ const midX = (start.x + end.x) / 2;
248
+ newLines.push({
249
+ d: `M ${start.x} ${start.y} L ${midX} ${start.y} L ${midX} ${end.y} L ${end.x} ${end.y}`,
250
+ });
251
+ } else {
252
+ const start = { x: a.left, y: a.headerMidY };
253
+ const end = { x: b.right, y: b.headerMidY };
254
+ const midX = (start.x + end.x) / 2;
255
+ newLines.push({
256
+ d: `M ${start.x} ${start.y} L ${midX} ${start.y} L ${midX} ${end.y} L ${end.x} ${end.y}`,
257
+ });
258
+ }
259
+ } else {
260
+ // Vertical connection
261
+ const start = { x: a.cx, y: a.bottom };
262
+ const end = { x: b.cx, y: b.top };
263
+ const midY = (start.y + end.y) / 2;
264
+ newLines.push({
265
+ d: `M ${start.x} ${start.y} L ${start.x} ${midY} L ${end.x} ${midY} L ${end.x} ${end.y}`,
266
+ });
267
+ }
268
+ }
269
+
270
+ setLines(newLines);
271
+ } finally {
272
+ isComputing = false;
273
+ }
274
+ };
275
+
276
+ // Initial compute
277
+ compute();
278
+
279
+ // Set up observers only if ResizeObserver is available and not in test
280
+ let ro = null;
281
+ if (
282
+ typeof ResizeObserver !== "undefined" &&
283
+ process.env.NODE_ENV !== "test"
284
+ ) {
285
+ ro = new ResizeObserver(compute);
286
+ if (gridRef.current) ro.observe(gridRef.current);
287
+ nodeRefs.current.forEach((r) => r.current && ro.observe(r.current));
288
+ }
289
+
290
+ const handleResize = () => compute();
291
+ const handleScroll = () => compute();
292
+
293
+ window.addEventListener("resize", handleResize);
294
+ window.addEventListener("scroll", handleScroll, true);
295
+
296
+ return () => {
297
+ if (ro) ro.disconnect();
298
+ window.removeEventListener("resize", handleResize);
299
+ window.removeEventListener("scroll", handleScroll, true);
300
+ };
301
+ }, [items, effectiveCols, visualOrder]);
302
+
303
+ // Get status for a given item index with fallback to activeIndex
304
+ const getStatus = (index) => {
305
+ const item = items[index];
306
+ const s = item?.status;
307
+ if (s === "failed") return "failed";
308
+ if (s === "done") return "done";
309
+ if (s === "running") return "running";
310
+ if (typeof activeIndex === "number") {
311
+ if (index < activeIndex) return "done";
312
+ if (index === activeIndex) return "running";
313
+ return "pending";
314
+ }
315
+ return "pending";
316
+ };
317
+
318
+ // Get CSS classes for card header based on status
319
+ const getHeaderClasses = (status) => {
320
+ switch (status) {
321
+ case "done":
322
+ return "bg-green-50 border-green-200 text-green-700";
323
+ case "running":
324
+ return "bg-amber-50 border-amber-200 text-amber-700";
325
+ case "failed":
326
+ return "bg-pink-50 border-pink-200 text-pink-700";
327
+ default:
328
+ return "bg-gray-100 border-gray-200 text-gray-700";
329
+ }
330
+ };
331
+
332
+ // Handle Escape key to close slide-over
333
+ React.useEffect(() => {
334
+ const handleKeyDown = (e) => {
335
+ if (e.key === "Escape" && openIdx !== null) {
336
+ setOpenIdx(null);
337
+ setSelectedFile(null);
338
+ }
339
+ };
340
+
341
+ if (openIdx !== null) {
342
+ document.addEventListener("keydown", handleKeyDown);
343
+ return () => document.removeEventListener("keydown", handleKeyDown);
344
+ }
345
+ }, [openIdx]);
346
+
347
+ // Focus management for slide-over
348
+ const closeButtonRef = useRef(null);
349
+ React.useEffect(() => {
350
+ if (openIdx !== null && closeButtonRef.current) {
351
+ closeButtonRef.current.focus();
352
+ }
353
+ }, [openIdx]);
354
+
355
+ React.useEffect(() => {
356
+ setFilePaneFilename(null);
357
+ setFilePaneOpen(false);
358
+ }, [filePaneType]);
359
+
360
+ React.useEffect(() => {
361
+ if (openIdx === null) {
362
+ setFilePaneFilename(null);
363
+ setFilePaneOpen(false);
364
+ return;
365
+ }
366
+ setFilePaneType("artifacts");
367
+ setFilePaneFilename(null);
368
+ setFilePaneOpen(false);
369
+ }, [openIdx]);
370
+
371
+ return (
372
+ <div className="relative w-full" role="list">
373
+ {/* SVG overlay for connector lines */}
374
+ <svg
375
+ ref={overlayRef}
376
+ className="absolute inset-0 w-full h-full pointer-events-none z-10"
377
+ aria-hidden="true"
378
+ >
379
+ <defs>
380
+ <marker
381
+ id="arrow"
382
+ viewBox="0 0 10 10"
383
+ refX="10"
384
+ refY="5"
385
+ markerWidth="8"
386
+ markerHeight="8"
387
+ orient="auto"
388
+ markerUnits="userSpaceOnUse"
389
+ >
390
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="#9ca3af" />
391
+ </marker>
392
+ </defs>
393
+ {lines.map((line, idx) => (
394
+ <g key={idx}>
395
+ <path
396
+ d={line.d}
397
+ fill="none"
398
+ stroke="currentColor"
399
+ strokeWidth="3"
400
+ strokeLinecap="round"
401
+ className="text-gray-300"
402
+ strokeLinejoin="round"
403
+ markerEnd="url(#arrow)"
404
+ />
405
+ </g>
406
+ ))}
407
+ </svg>
408
+
409
+ {/* Grid of task cards */}
410
+ <div
411
+ ref={gridRef}
412
+ className="grid grid-cols-1 lg:grid-cols-3 gap-16 relative z-0"
413
+ >
414
+ {visualOrder.map((idx, mapIndex) => {
415
+ if (idx === -1) {
416
+ return (
417
+ <div
418
+ key={`ghost-${mapIndex}`}
419
+ className="invisible"
420
+ aria-hidden="true"
421
+ />
422
+ );
423
+ }
424
+
425
+ const item = items[idx];
426
+ const status = getStatus(idx);
427
+ const isActive = idx === activeIndex;
428
+
429
+ return (
430
+ <div
431
+ key={item.id ?? idx}
432
+ ref={nodeRefs.current[idx]}
433
+ role="listitem"
434
+ aria-current={isActive ? "step" : undefined}
435
+ tabIndex={0}
436
+ onClick={() => {
437
+ setOpenIdx(idx);
438
+ setSelectedFile(null);
439
+ }}
440
+ onKeyDown={(e) => {
441
+ if (e.key === "Enter" || e.key === " ") {
442
+ e.preventDefault();
443
+ setOpenIdx(idx);
444
+ setSelectedFile(null);
445
+ }
446
+ }}
447
+ className={`cursor-pointer rounded-lg border border-gray-400 bg-white overflow-hidden flex flex-col transition outline outline-2 outline-transparent hover:outline-gray-400/70 focus-visible:outline-blue-500/60 ${cardClass}`}
448
+ >
449
+ <div
450
+ data-role="card-header"
451
+ className={`rounded-t-lg px-4 py-2 border-b flex items-center justify-between gap-3 ${getHeaderClasses(status)}`}
452
+ >
453
+ <div className="font-medium truncate">
454
+ {formatStepName(item, idx)}
455
+ </div>
456
+ <div className="flex items-center gap-2">
457
+ {status === "running" ? (
458
+ <>
459
+ <div className="relative h-4 w-4" aria-label="Running">
460
+ <span className="sr-only">Running</span>
461
+ <span className="absolute inset-0 rounded-full border-2 border-amber-200" />
462
+ <span className="absolute inset-0 rounded-full border-2 border-transparent border-t-amber-600 animate-spin" />
463
+ </div>
464
+ {item.stage && (
465
+ <span
466
+ className="text-[11px] font-medium opacity-80 truncate"
467
+ title={item.stage}
468
+ >
469
+ {formatStageLabel(item.stage)}
470
+ </span>
471
+ )}
472
+ </>
473
+ ) : (
474
+ <span className="text-[11px] uppercase tracking-wide opacity-80">
475
+ {status}
476
+ {status === "failed" && item.stage && (
477
+ <span
478
+ className="text-[11px] font-medium opacity-80 truncate ml-2"
479
+ title={item.stage}
480
+ >
481
+ ({formatStageLabel(item.stage)})
482
+ </span>
483
+ )}
484
+ </span>
485
+ )}
486
+ </div>
487
+ </div>
488
+ <div className="p-4">
489
+ {item.subtitle && (
490
+ <div className="text-sm text-gray-600">{item.subtitle}</div>
491
+ )}
492
+ {item.body && (
493
+ <div className="mt-2 text-sm text-gray-700">{item.body}</div>
494
+ )}
495
+ </div>
496
+ </div>
497
+ );
498
+ })}
499
+ </div>
500
+
501
+ {/* Slide-over panel for task details */}
502
+ <aside
503
+ role="dialog"
504
+ aria-modal="true"
505
+ aria-labelledby={`slide-over-title-${openIdx}`}
506
+ aria-hidden={openIdx === null}
507
+ 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 ${openIdx !== null ? "translate-x-0" : "translate-x-full"}`}
508
+ >
509
+ {openIdx !== null && (
510
+ <>
511
+ <div
512
+ className={`px-6 py-4 border-b flex items-center justify-between ${getHeaderClasses(getStatus(openIdx))}`}
513
+ >
514
+ <div
515
+ id={`slide-over-title-${openIdx}`}
516
+ className="text-lg font-semibold truncate"
517
+ >
518
+ {formatStepName(items[openIdx], openIdx)}
519
+ </div>
520
+ <button
521
+ ref={closeButtonRef}
522
+ type="button"
523
+ aria-label="Close details"
524
+ onClick={() => {
525
+ setOpenIdx(null);
526
+ setSelectedFile(null);
527
+ }}
528
+ className="rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50 px-3 py-1.5 text-base"
529
+ >
530
+ ×
531
+ </button>
532
+ </div>
533
+ <div className="p-6 space-y-8 overflow-y-auto h-full">
534
+ {/* Error Callout - shown when task has error status and body */}
535
+ {items[openIdx]?.status === "failed" && items[openIdx]?.body && (
536
+ <section aria-label="Error">
537
+ <Callout.Root role="alert" aria-live="assertive">
538
+ <Callout.Text className="whitespace-pre-wrap break-words">
539
+ {items[openIdx].body}
540
+ </Callout.Text>
541
+ </Callout.Root>
542
+ </section>
543
+ )}
544
+
545
+ {/* File Display Area with Type Tabs */}
546
+ <section className="mt-6">
547
+ <div className="flex items-center justify-between mb-4">
548
+ <h3 className="text-base font-semibold text-gray-900">
549
+ Files
550
+ </h3>
551
+ <div className="flex items-center space-x-2">
552
+ <div className="flex rounded-lg border border-gray-200 bg-gray-50 p-1">
553
+ <button
554
+ onClick={() => setFilePaneType("artifacts")}
555
+ className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
556
+ filePaneType === "artifacts"
557
+ ? "bg-white text-gray-900 shadow-sm"
558
+ : "text-gray-600 hover:text-gray-900"
559
+ }`}
560
+ >
561
+ Artifacts
562
+ </button>
563
+ <button
564
+ onClick={() => setFilePaneType("logs")}
565
+ className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
566
+ filePaneType === "logs"
567
+ ? "bg-white text-gray-900 shadow-sm"
568
+ : "text-gray-600 hover:text-gray-900"
569
+ }`}
570
+ >
571
+ Logs
572
+ </button>
573
+ <button
574
+ onClick={() => setFilePaneType("tmp")}
575
+ className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
576
+ filePaneType === "tmp"
577
+ ? "bg-white text-gray-900 shadow-sm"
578
+ : "text-gray-600 hover:text-gray-900"
579
+ }`}
580
+ >
581
+ Temp
582
+ </button>
583
+ </div>
584
+ </div>
585
+ </div>
586
+ </section>
587
+
588
+ {/* File List */}
589
+ <div className="space-y-2">
590
+ <div className="text-sm text-gray-600">
591
+ {filePaneType.charAt(0).toUpperCase() + filePaneType.slice(1)}{" "}
592
+ files for {items[openIdx]?.id || `Task ${openIdx + 1}`}
593
+ </div>
594
+ <div className="space-y-1">
595
+ {(() => {
596
+ const filesForStep = filesByTypeForItem(items[openIdx]);
597
+ const filesForTab = filesForStep[filePaneType] ?? [];
598
+
599
+ if (filesForTab.length === 0) {
600
+ return (
601
+ <div className="text-sm text-gray-500 italic py-4 text-center">
602
+ No {filePaneType} files available for this task
603
+ </div>
604
+ );
605
+ }
606
+
607
+ return filesForTab.map((name) => {
608
+ return (
609
+ <div
610
+ key={`${filePaneType}-${name}`}
611
+ 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"
612
+ onClick={() => {
613
+ setFilePaneFilename(name);
614
+ setFilePaneOpen(true);
615
+ }}
616
+ >
617
+ <div className="flex items-center space-x-2">
618
+ <span className="text-sm text-gray-700">
619
+ {name}
620
+ </span>
621
+ </div>
622
+ </div>
623
+ );
624
+ });
625
+ })()}
626
+ </div>
627
+ </div>
628
+
629
+ {/* TaskFilePane Modal */}
630
+ <TaskFilePane
631
+ isOpen={filePaneOpen}
632
+ jobId={jobId}
633
+ taskId={items[openIdx]?.id || `task-${openIdx}`}
634
+ type={filePaneType}
635
+ filename={filePaneFilename}
636
+ onClose={() => {
637
+ setFilePaneOpen(false);
638
+ setFilePaneFilename(null);
639
+ }}
640
+ />
641
+ </div>
642
+ </>
643
+ )}
644
+ </aside>
645
+ </div>
646
+ );
647
+ }
648
+
649
+ export default DAGGrid;