@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
@@ -4,10 +4,23 @@ import React, {
4
4
  useRef,
5
5
  useState,
6
6
  createRef,
7
+ memo,
7
8
  } from "react";
8
- import { Callout } from "@radix-ui/themes";
9
- import { TaskFilePane } from "./TaskFilePane.jsx";
9
+ import { areGeometriesEqual } from "../utils/geometry-equality.js";
10
+ import { TaskDetailSidebar } from "./TaskDetailSidebar.jsx";
11
+ import { RestartJobModal } from "./ui/RestartJobModal.jsx";
12
+ import { Button } from "./ui/button.jsx";
13
+ import { restartJob } from "../ui/client/api.js";
10
14
  import { createEmptyTaskFiles } from "../utils/task-files.js";
15
+ import { TaskState } from "../config/statuses.js";
16
+ import TimerText from "./TimerText.jsx";
17
+ import { taskToTimerProps } from "../utils/time-utils.js";
18
+
19
+ // Utility to check for reduced motion preference
20
+ const prefersReducedMotion = () => {
21
+ if (typeof window === "undefined") return false;
22
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
23
+ };
11
24
 
12
25
  // Helpers: capitalize fallback step ids (upperFirst only; do not alter provided titles)
13
26
  function upperFirst(s) {
@@ -51,41 +64,156 @@ function formatStageLabel(s) {
51
64
 
52
65
  function formatStepName(item, idx) {
53
66
  const raw = item.title ?? item.id ?? `Step ${idx + 1}`;
54
- // If item has a title, assume its curated and leave unchanged; otherwise capitalize fallback
67
+ // If item has a title, assume it's curated and leave unchanged; otherwise capitalize fallback
55
68
  return upperFirst(item.title ? item.title : raw);
56
69
  }
57
70
 
71
+ // Get CSS classes for card header based on status
72
+ const getHeaderClasses = (status) => {
73
+ switch (status) {
74
+ case TaskState.DONE:
75
+ return "bg-green-50 border-green-200 text-green-700";
76
+ case TaskState.RUNNING:
77
+ return "bg-amber-50 border-amber-200 text-amber-700";
78
+ case TaskState.FAILED:
79
+ return "bg-pink-50 border-pink-200 text-pink-700";
80
+ default:
81
+ return "bg-gray-100 border-gray-200 text-gray-700";
82
+ }
83
+ };
84
+
85
+ // Check if Restart button should be shown for a given status
86
+ const canShowRestart = (status) => {
87
+ return status === TaskState.FAILED || status === TaskState.DONE;
88
+ };
89
+
90
+ // Memoized card component to prevent unnecessary re-renders
91
+ const TaskCard = memo(function TaskCard({
92
+ item,
93
+ idx,
94
+ nodeRef,
95
+ status,
96
+ isActive,
97
+ canRestart,
98
+ isSubmitting,
99
+ getRestartDisabledReason,
100
+ onClick,
101
+ onKeyDown,
102
+ handleRestartClick,
103
+ }) {
104
+ const { startMs, endMs } = taskToTimerProps(item);
105
+ const reducedMotion = prefersReducedMotion();
106
+
107
+ return (
108
+ <div
109
+ ref={nodeRef}
110
+ role="listitem"
111
+ aria-current={isActive ? "step" : undefined}
112
+ tabIndex={0}
113
+ onClick={onClick}
114
+ onKeyDown={onKeyDown}
115
+ className={`cursor-pointer rounded-lg border border-gray-400 ${status === TaskState.PENDING ? "bg-gray-50" : "bg-white"} overflow-hidden flex flex-col ${reducedMotion ? "" : "transition-all duration-200 ease-in-out"} outline outline-2 outline-transparent hover:outline-gray-400/70 focus-visible:outline-blue-500/60`}
116
+ >
117
+ <div
118
+ data-role="card-header"
119
+ className={`rounded-t-lg px-4 py-2 border-b flex items-center justify-between gap-3 ${reducedMotion ? "" : "transition-opacity duration-300 ease-in-out"} ${getHeaderClasses(status)}`}
120
+ >
121
+ <div className="font-medium truncate">{formatStepName(item, idx)}</div>
122
+ <div className="flex items-center gap-2">
123
+ {status === TaskState.RUNNING ? (
124
+ <>
125
+ <div className="relative h-4 w-4" aria-label="Active">
126
+ <span className="sr-only">Active</span>
127
+ <span className="absolute inset-0 rounded-full border-2 border-amber-200" />
128
+ <span
129
+ className={`absolute inset-0 rounded-full border-2 border-transparent border-t-amber-600 ${reducedMotion ? "" : "animate-spin"}`}
130
+ />
131
+ </div>
132
+ {item.stage && (
133
+ <span
134
+ className="text-[11px] font-medium opacity-80 truncate uppercase tracking-wide"
135
+ title={item.stage}
136
+ >
137
+ {formatStageLabel(item.stage)}
138
+ </span>
139
+ )}
140
+ {startMs && (
141
+ <TimerText
142
+ startMs={startMs}
143
+ granularity="second"
144
+ className="text-[11px] opacity-80"
145
+ />
146
+ )}
147
+ </>
148
+ ) : (
149
+ <>
150
+ <span
151
+ className={`text-[11px] uppercase tracking-wide opacity-80${reducedMotion ? "" : " transition-opacity duration-200"}`}
152
+ >
153
+ {status}
154
+ {status === TaskState.FAILED && item.stage && (
155
+ <span
156
+ className="text-[11px] font-medium opacity-80 truncate ml-2 uppercase tracking-wide"
157
+ title={item.stage}
158
+ >
159
+ ({formatStageLabel(item.stage)})
160
+ </span>
161
+ )}
162
+ </span>
163
+ {status === TaskState.DONE && startMs && (
164
+ <TimerText
165
+ startMs={startMs}
166
+ endMs={endMs || item.finishedAt}
167
+ granularity="minute"
168
+ className="text-[11px] opacity-80"
169
+ />
170
+ )}
171
+ </>
172
+ )}
173
+ </div>
174
+ </div>
175
+ <div className="p-4">
176
+ {item.subtitle && (
177
+ <div className="text-sm text-gray-600">{item.subtitle}</div>
178
+ )}
179
+ {item.body && (
180
+ <div className="mt-2 text-sm text-gray-700">{item.body}</div>
181
+ )}
182
+
183
+ {/* Restart button */}
184
+ {canShowRestart(status) && (
185
+ <div className="mt-3 pt-3 border-t border-gray-100">
186
+ <Button
187
+ variant="outline"
188
+ size="sm"
189
+ onClick={(e) => handleRestartClick(e, item.id)}
190
+ disabled={!canRestart || isSubmitting}
191
+ className="text-xs cursor-pointer disabled:cursor-not-allowed"
192
+ title={
193
+ !canRestart
194
+ ? getRestartDisabledReason()
195
+ : `Restart job from ${item.id}`
196
+ }
197
+ >
198
+ Restart
199
+ </Button>
200
+ </div>
201
+ )}
202
+ </div>
203
+ </div>
204
+ );
205
+ });
206
+
58
207
  /**
59
208
  * DAGGrid component for visualizing pipeline tasks with connectors and slide-over details
60
209
  * @param {Object} props
61
210
  * @param {Array} props.items - Array of DAG items with id, status, and optional title/subtitle
62
211
  * @param {number} props.cols - Number of columns for grid layout (default: 3)
63
212
  * @param {string} props.cardClass - Additional CSS classes for cards
64
- * @param {number} props.activeIndex - Index of the active item
213
+ * @param {number} props.activeIndex - Index of active item
65
214
  * @param {string} props.jobId - Job ID for file operations
66
215
  * @param {Function} props.filesByTypeForItem - Selector returning { artifacts, logs, tmp }
67
216
  */
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
217
 
90
218
  function DAGGrid({
91
219
  items,
@@ -95,31 +223,23 @@ function DAGGrid({
95
223
  jobId,
96
224
  filesByTypeForItem = () => createEmptyTaskFiles(),
97
225
  }) {
98
- const logger = React.useMemo(() => createDAGGridLogger(jobId), [jobId]);
99
-
100
226
  const overlayRef = useRef(null);
101
227
  const gridRef = useRef(null);
102
228
  const nodeRefs = useRef([]);
103
229
  const [lines, setLines] = useState([]);
104
230
  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);
231
+ const [openIdx, setOpenIdx] = useState(-1);
110
232
 
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]);
233
+ // Restart modal state
234
+ const [restartModalOpen, setRestartModalOpen] = useState(false);
235
+ const [restartTaskId, setRestartTaskId] = useState(null);
236
+ const [isSubmitting, setIsSubmitting] = useState(false);
237
+ const [alertMessage, setAlertMessage] = useState(null);
238
+ const [alertType, setAlertType] = useState("info"); // info, success, error, warning
239
+
240
+ // Previous geometry snapshot for throttling connector recomputation
241
+ const prevGeometryRef = useRef(null);
242
+ const rafRef = useRef(null);
123
243
 
124
244
  // Create refs for each node
125
245
  nodeRefs.current = useMemo(
@@ -165,11 +285,12 @@ function DAGGrid({
165
285
  const end = Math.min(start + effectiveCols, items.length);
166
286
  const slice = Array.from({ length: end - start }, (_, k) => start + k);
167
287
  const rowLen = slice.length;
168
- const pad = Math.max(0, effectiveCols - rowLen);
169
288
 
170
- if (r % 2 === 1) {
171
- // Reverse order for odd rows (snake pattern)
289
+ const isReversedRow = r % 2 === 1; // odd rows RTL
290
+ if (isReversedRow) {
291
+ // Reverse order for even rows (snake pattern)
172
292
  const reversed = slice.reverse();
293
+ const pad = effectiveCols - rowLen;
173
294
  order.push(...Array(pad).fill(-1), ...reversed);
174
295
  } else {
175
296
  order.push(...slice);
@@ -179,7 +300,7 @@ function DAGGrid({
179
300
  return order;
180
301
  }, [items.length, effectiveCols]);
181
302
 
182
- // Calculate connector lines between cards
303
+ // Calculate connector lines between cards with throttling
183
304
  useLayoutEffect(() => {
184
305
  // Skip entirely in test environment to prevent hanging
185
306
  if (process.env.NODE_ENV === "test") {
@@ -195,12 +316,14 @@ function DAGGrid({
195
316
  return;
196
317
  }
197
318
 
198
- let isComputing = false;
319
+ // Throttled compute function using requestAnimationFrame
199
320
  const compute = () => {
200
- if (isComputing) return; // Prevent infinite loops
201
- isComputing = true;
321
+ // Cancel any pending RAF
322
+ if (rafRef.current) {
323
+ cancelAnimationFrame(rafRef.current);
324
+ }
202
325
 
203
- try {
326
+ rafRef.current = requestAnimationFrame(() => {
204
327
  if (!overlayRef.current) return;
205
328
 
206
329
  const overlayBox = overlayRef.current.getBoundingClientRect();
@@ -228,6 +351,23 @@ function DAGGrid({
228
351
  };
229
352
  });
230
353
 
354
+ // Check if geometry changed significantly
355
+ const currentGeometry = {
356
+ overlayBox,
357
+ boxes: boxes.filter(Boolean),
358
+ effectiveCols,
359
+ itemsLength: items.length,
360
+ };
361
+
362
+ const geometryChanged =
363
+ !prevGeometryRef.current ||
364
+ !areGeometriesEqual(prevGeometryRef.current, currentGeometry);
365
+
366
+ if (!geometryChanged) {
367
+ rafRef.current = null;
368
+ return;
369
+ }
370
+
231
371
  const newLines = [];
232
372
  for (let i = 0; i < items.length - 1; i++) {
233
373
  const a = boxes[i];
@@ -267,10 +407,10 @@ function DAGGrid({
267
407
  }
268
408
  }
269
409
 
410
+ prevGeometryRef.current = currentGeometry;
270
411
  setLines(newLines);
271
- } finally {
272
- isComputing = false;
273
- }
412
+ rafRef.current = null;
413
+ });
274
414
  };
275
415
 
276
416
  // Initial compute
@@ -297,6 +437,9 @@ function DAGGrid({
297
437
  if (ro) ro.disconnect();
298
438
  window.removeEventListener("resize", handleResize);
299
439
  window.removeEventListener("scroll", handleScroll, true);
440
+ if (rafRef.current) {
441
+ cancelAnimationFrame(rafRef.current);
442
+ }
300
443
  };
301
444
  }, [items, effectiveCols, visualOrder]);
302
445
 
@@ -304,72 +447,181 @@ function DAGGrid({
304
447
  const getStatus = (index) => {
305
448
  const item = items[index];
306
449
  const s = item?.status;
307
- if (s === "failed") return "failed";
308
- if (s === "done") return "done";
309
- if (s === "running") return "running";
450
+ if (s === TaskState.FAILED) return TaskState.FAILED;
451
+ if (s === TaskState.DONE) return TaskState.DONE;
452
+ if (s === TaskState.RUNNING) return TaskState.RUNNING;
310
453
  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";
454
+ if (index < activeIndex) return TaskState.DONE;
455
+ if (index === activeIndex) return TaskState.RUNNING;
456
+ return TaskState.PENDING;
329
457
  }
458
+ return TaskState.PENDING;
330
459
  };
331
460
 
332
461
  // Handle Escape key to close slide-over
333
462
  React.useEffect(() => {
334
463
  const handleKeyDown = (e) => {
335
- if (e.key === "Escape" && openIdx !== null) {
336
- setOpenIdx(null);
337
- setSelectedFile(null);
464
+ if (e.key === "Escape" && openIdx !== -1) {
465
+ setOpenIdx(-1);
338
466
  }
339
467
  };
340
468
 
341
- if (openIdx !== null) {
469
+ if (openIdx !== -1) {
342
470
  document.addEventListener("keydown", handleKeyDown);
343
471
  return () => document.removeEventListener("keydown", handleKeyDown);
344
472
  }
345
473
  }, [openIdx]);
346
474
 
347
- // Focus management for slide-over
348
- const closeButtonRef = useRef(null);
349
- React.useEffect(() => {
350
- if (openIdx !== null && closeButtonRef.current) {
351
- closeButtonRef.current.focus();
475
+ // Restart functionality
476
+ const handleRestartClick = (e, taskId) => {
477
+ e.stopPropagation(); // Prevent card click
478
+ setRestartTaskId(taskId);
479
+ setRestartModalOpen(true);
480
+ };
481
+
482
+ const handleRestartConfirm = async () => {
483
+ if (!jobId || isSubmitting) return;
484
+
485
+ setIsSubmitting(true);
486
+ setAlertMessage(null);
487
+
488
+ try {
489
+ const restartOptions = {};
490
+ if (restartTaskId) {
491
+ restartOptions.fromTask = restartTaskId;
492
+ }
493
+
494
+ await restartJob(jobId, restartOptions);
495
+
496
+ const successMessage = restartTaskId
497
+ ? `Restart requested from ${restartTaskId}. The job will start from that task in the background.`
498
+ : "Restart requested. The job will reset to pending and start in the background.";
499
+ setAlertMessage(successMessage);
500
+ setAlertType("success");
501
+ setRestartModalOpen(false);
502
+ setRestartTaskId(null);
503
+ } catch (error) {
504
+ let message = "Failed to start restart. Try again.";
505
+ let type = "error";
506
+
507
+ switch (error.code) {
508
+ case "job_running":
509
+ message = "Job is currently running; restart is unavailable.";
510
+ type = "warning";
511
+ break;
512
+ case "unsupported_lifecycle":
513
+ message = "Job must be in current lifecycle to restart.";
514
+ type = "warning";
515
+ break;
516
+ case "job_not_found":
517
+ message = "Job not found.";
518
+ type = "error";
519
+ break;
520
+ case "spawn_failed":
521
+ message = "Failed to start restart. Try again.";
522
+ type = "error";
523
+ break;
524
+ default:
525
+ message = error.message || "An unexpected error occurred.";
526
+ type = "error";
527
+ }
528
+
529
+ setAlertMessage(message);
530
+ setAlertType(type);
531
+ } finally {
532
+ setIsSubmitting(false);
352
533
  }
353
- }, [openIdx]);
534
+ };
354
535
 
355
- React.useEffect(() => {
356
- setFilePaneFilename(null);
357
- setFilePaneOpen(false);
358
- }, [filePaneType]);
536
+ const handleRestartCancel = () => {
537
+ setRestartModalOpen(false);
538
+ setRestartTaskId(null);
539
+ };
359
540
 
541
+ // Clear alert after 5 seconds
360
542
  React.useEffect(() => {
361
- if (openIdx === null) {
362
- setFilePaneFilename(null);
363
- setFilePaneOpen(false);
364
- return;
543
+ if (alertMessage) {
544
+ const timer = setTimeout(() => {
545
+ setAlertMessage(null);
546
+ }, 5000);
547
+ return () => clearTimeout(timer);
365
548
  }
366
- setFilePaneType("artifacts");
367
- setFilePaneFilename(null);
368
- setFilePaneOpen(false);
369
- }, [openIdx]);
549
+ }, [alertMessage]);
550
+
551
+ // Check if restart should be enabled (job lifecycle = current and not running)
552
+ const isRestartEnabled = React.useCallback(() => {
553
+ // Check if any item indicates that job is running (job-level state)
554
+ const isJobRunning = items.some(
555
+ (item) => item?.state === TaskState.RUNNING
556
+ );
557
+
558
+ // Check if any task has explicit running status (not derived from activeIndex)
559
+ const hasRunningTask = items.some(
560
+ (item) => item?.status === TaskState.RUNNING
561
+ );
562
+
563
+ const jobLifecycle = items[0]?.lifecycle || "current";
564
+
565
+ return jobLifecycle === "current" && !isJobRunning && !hasRunningTask;
566
+ }, [items]);
567
+
568
+ // Get disabled reason for tooltip
569
+ const getRestartDisabledReason = React.useCallback(() => {
570
+ // Check if any item indicates that job is running (job-level state)
571
+ const isJobRunning = items.some(
572
+ (item) => item?.state === TaskState.RUNNING
573
+ );
574
+
575
+ // Check if any task has explicit running status (not derived from activeIndex)
576
+ const hasRunningTask = items.some(
577
+ (item) => item?.status === TaskState.RUNNING
578
+ );
579
+
580
+ const jobLifecycle = items[0]?.lifecycle || "current";
581
+
582
+ if (isJobRunning || hasRunningTask) return "Job is currently running";
583
+ if (jobLifecycle !== "current") return "Job must be in current lifecycle";
584
+ return "";
585
+ }, [items]);
370
586
 
371
587
  return (
372
588
  <div className="relative w-full" role="list">
589
+ {/* Alert notification */}
590
+ {alertMessage && (
591
+ <div
592
+ className={`fixed top-4 right-4 z-[3000] max-w-sm p-4 rounded-lg shadow-lg border ${
593
+ alertType === "success"
594
+ ? "bg-green-50 border-green-200 text-green-800"
595
+ : alertType === "error"
596
+ ? "bg-red-50 border-red-200 text-red-800"
597
+ : alertType === "warning"
598
+ ? "bg-yellow-50 border-yellow-200 text-yellow-800"
599
+ : "bg-blue-50 border-blue-200 text-blue-800"
600
+ }`}
601
+ role="alert"
602
+ aria-live="polite"
603
+ >
604
+ <div className="flex items-start">
605
+ <div className="flex-1">
606
+ <p className="text-sm font-medium">{alertMessage}</p>
607
+ </div>
608
+ <button
609
+ onClick={() => setAlertMessage(null)}
610
+ className="ml-3 flex-shrink-0 inline-flex text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
611
+ aria-label="Dismiss notification"
612
+ >
613
+ <svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
614
+ <path
615
+ fillRule="evenodd"
616
+ d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
617
+ clipRule="evenodd"
618
+ />
619
+ </svg>
620
+ </button>
621
+ </div>
622
+ </div>
623
+ )}
624
+
373
625
  {/* SVG overlay for connector lines */}
374
626
  <svg
375
627
  ref={overlayRef}
@@ -386,8 +638,9 @@ function DAGGrid({
386
638
  markerHeight="8"
387
639
  orient="auto"
388
640
  markerUnits="userSpaceOnUse"
641
+ className="text-gray-400"
389
642
  >
390
- <path d="M 0 0 L 10 5 L 0 10 z" fill="#9ca3af" />
643
+ <path d="M 0 0 L 10 5 L 0 10 z" />
391
644
  </marker>
392
645
  </defs>
393
646
  {lines.map((line, idx) => (
@@ -396,9 +649,9 @@ function DAGGrid({
396
649
  d={line.d}
397
650
  fill="none"
398
651
  stroke="currentColor"
399
- strokeWidth="3"
400
- strokeLinecap="round"
401
- className="text-gray-300"
652
+ strokeWidth="1"
653
+ strokeLinecap="square"
654
+ className="text-gray-400"
402
655
  strokeLinejoin="round"
403
656
  markerEnd="url(#arrow)"
404
657
  />
@@ -425,223 +678,59 @@ function DAGGrid({
425
678
  const item = items[idx];
426
679
  const status = getStatus(idx);
427
680
  const isActive = idx === activeIndex;
681
+ const canRestart = isRestartEnabled();
428
682
 
429
683
  return (
430
- <div
684
+ <TaskCard
431
685
  key={item.id ?? idx}
432
- ref={nodeRefs.current[idx]}
433
- role="listitem"
434
- aria-current={isActive ? "step" : undefined}
435
- tabIndex={0}
686
+ idx={idx}
687
+ nodeRef={nodeRefs.current[idx]}
688
+ status={status}
689
+ isActive={isActive}
690
+ canRestart={canRestart}
691
+ isSubmitting={isSubmitting}
692
+ getRestartDisabledReason={getRestartDisabledReason}
436
693
  onClick={() => {
437
694
  setOpenIdx(idx);
438
- setSelectedFile(null);
439
695
  }}
440
696
  onKeyDown={(e) => {
441
697
  if (e.key === "Enter" || e.key === " ") {
442
698
  e.preventDefault();
443
699
  setOpenIdx(idx);
444
- setSelectedFile(null);
445
700
  }
446
701
  }}
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>
702
+ handleRestartClick={handleRestartClick}
703
+ item={item}
704
+ />
497
705
  );
498
706
  })}
499
707
  </div>
500
708
 
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>
709
+ {/* TaskDetailSidebar */}
710
+ {openIdx !== -1 && (
711
+ <TaskDetailSidebar
712
+ open={openIdx !== -1}
713
+ title={formatStepName(items[openIdx], openIdx)}
714
+ status={getStatus(openIdx)}
715
+ jobId={jobId}
716
+ taskId={items[openIdx]?.id || `task-${openIdx}`}
717
+ taskBody={items[openIdx]?.body || null}
718
+ filesByTypeForItem={filesByTypeForItem}
719
+ task={items[openIdx]}
720
+ taskIndex={openIdx}
721
+ onClose={() => setOpenIdx(-1)}
722
+ />
723
+ )}
724
+
725
+ {/* Restart Job Modal */}
726
+ <RestartJobModal
727
+ open={restartModalOpen}
728
+ onClose={handleRestartCancel}
729
+ onConfirm={handleRestartConfirm}
730
+ jobId={jobId}
731
+ taskId={restartTaskId}
732
+ isSubmitting={isSubmitting}
733
+ />
645
734
  </div>
646
735
  );
647
736
  }