775 lines
29 KiB
TypeScript
775 lines
29 KiB
TypeScript
import { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from 'react';
|
|
import { useParams, useNavigate, Link } from 'react-router';
|
|
import {
|
|
ArrowLeft, CheckCircle2, AlertCircle, Loader2, StopCircle, AlertTriangle, Clock, Square,
|
|
ChevronRight, Wrench,
|
|
} from 'lucide-react';
|
|
import { useClient } from 'hooks/useClient';
|
|
import { Card } from '@/components/Card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { WorkspaceLayout } from 'officerdev';
|
|
import type { LayoutNode, PanelComponents } from 'officerdev';
|
|
|
|
type Cost = { inputTokens: number; outputTokens: number; totalUSD: number };
|
|
|
|
type StepDef = { task: string; foreach?: string; concurrency?: number };
|
|
|
|
type IterationStatus = {
|
|
label: string;
|
|
status: 'pending' | 'running' | 'complete' | 'error';
|
|
error?: string;
|
|
cost?: Cost;
|
|
};
|
|
|
|
type ParallelStep = {
|
|
stepIndex: number;
|
|
taskName: string;
|
|
concurrency: number;
|
|
iterations: IterationStatus[];
|
|
};
|
|
|
|
type JobStatus = 'pending' | 'running' | 'completed' | 'failed' | 'stopped' | 'interrupted';
|
|
|
|
type ProgressData = {
|
|
steps?: StepDef[];
|
|
currentStepIndex?: number;
|
|
parallel?: { taskName: string; concurrency: number; iterations: Array<{ label: string; status: string }> };
|
|
};
|
|
|
|
type JobData = {
|
|
id: string;
|
|
taskDirName: string;
|
|
taskName: string;
|
|
status: JobStatus;
|
|
totalCost: Cost | null;
|
|
progress: ProgressData | null;
|
|
error: string | null;
|
|
createdAt: string;
|
|
startedAt: string | null;
|
|
completedAt: string | null;
|
|
};
|
|
|
|
// Output entries for the right panel
|
|
type OutputEntry =
|
|
| { id: string; type: 'text'; text: string }
|
|
| { id: string; type: 'tool'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; output?: string; isError?: boolean };
|
|
|
|
type ServerMessage =
|
|
| { jobId: string; type: 'pipeline:init'; steps: StepDef[] }
|
|
| { jobId: string; type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
|
|
| { jobId: string; type: 'step:complete'; stepIndex: number; cost?: Cost }
|
|
| { jobId: string; type: 'step:skip'; stepIndex: number; label: string; reason: string }
|
|
| { jobId: string; type: 'step:parallel'; stepIndex: number; taskName: string; iterations: string[]; concurrency: number }
|
|
| { jobId: string; type: 'iteration:start'; stepIndex: number; label: string }
|
|
| { jobId: string; type: 'iteration:complete'; stepIndex: number; label: string; cost?: Cost }
|
|
| { jobId: string; type: 'iteration:error'; stepIndex: number; label: string; error: string }
|
|
| { jobId: string; type: 'assistant:delta'; text: string; stepIndex: number; iterationLabel?: string }
|
|
| { jobId: string; type: 'assistant:text'; text: string; stepIndex: number; iterationLabel?: string }
|
|
| { jobId: string; type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; stepIndex: number; iterationLabel?: string }
|
|
| { jobId: string; type: 'tool:result'; toolCallId: string; output: string; isError: boolean; stepIndex: number; iterationLabel?: string }
|
|
| { jobId: string; type: 'pipeline:complete'; totalCost: Cost }
|
|
| { jobId: string; type: 'error'; message: string }
|
|
| { jobId: string; type: 'stopped' }
|
|
| { type: 'job:state'; jobId: string; status: string; progress: unknown; cost: unknown };
|
|
|
|
const formatDate = (iso: string) => {
|
|
const d = new Date(iso);
|
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
};
|
|
|
|
const formatElapsed = (seconds: number) => {
|
|
const m = Math.floor(seconds / 60);
|
|
const s = seconds % 60;
|
|
return m > 0 ? `${m}m ${s}s` : `${s}s`;
|
|
};
|
|
|
|
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
|
|
|
|
const formatTokens = (n: number) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
|
|
|
|
/** Build a unique key for grouping output by step/iteration */
|
|
const outputKey = (stepIndex: number, iterationLabel?: string) =>
|
|
iterationLabel ? `${stepIndex}:${iterationLabel}` : `${stepIndex}`;
|
|
|
|
const StatusIcon = ({ status, className }: { status: string; className?: string }) => {
|
|
const base = className ?? 'h-4 w-4 shrink-0';
|
|
switch (status) {
|
|
case 'completed':
|
|
case 'complete':
|
|
return <CheckCircle2 className={`${base} text-green-500`} />;
|
|
case 'failed':
|
|
case 'error':
|
|
return <AlertCircle className={`${base} text-red-500`} />;
|
|
case 'running':
|
|
return <Loader2 className={`${base} text-blue-500 animate-spin`} />;
|
|
case 'stopped':
|
|
return <StopCircle className={`${base} text-amber-500`} />;
|
|
case 'interrupted':
|
|
return <AlertTriangle className={`${base} text-amber-500`} />;
|
|
default:
|
|
return <Clock className={`${base} text-duck-dark/30`} />;
|
|
}
|
|
};
|
|
|
|
type ToolCallEntryProps = {
|
|
entry: Extract<OutputEntry, { type: 'tool' }>;
|
|
};
|
|
|
|
const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
return (
|
|
<div className="border border-duck-dark/10 rounded-md overflow-hidden">
|
|
<button
|
|
onClick={() => setExpanded(!expanded)}
|
|
className="w-full flex items-center gap-2 px-2.5 py-1.5 bg-duck-dark/[0.03] hover:bg-duck-dark/[0.06] transition-colors cursor-pointer"
|
|
>
|
|
<Wrench className="h-3 w-3 text-duck-dark/40 shrink-0" />
|
|
<span className="text-duck-dark/60 font-medium">{entry.toolName}</span>
|
|
{entry.output !== undefined && (
|
|
<StatusIcon
|
|
status={entry.isError ? 'error' : 'complete'}
|
|
className="h-3 w-3 shrink-0 ml-auto"
|
|
/>
|
|
)}
|
|
{entry.output === undefined && (
|
|
<Loader2 className="h-3 w-3 text-blue-500 animate-spin shrink-0 ml-auto" />
|
|
)}
|
|
<ChevronRight className={`h-3 w-3 text-duck-dark/30 shrink-0 transition-transform ${expanded ? 'rotate-90' : ''}`} />
|
|
</button>
|
|
{expanded && (
|
|
<div className="p-2.5 space-y-2 border-t border-duck-dark/10">
|
|
<div>
|
|
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Input</div>
|
|
<pre className="text-duck-dark/60 whitespace-pre-wrap break-words text-[11px] max-h-40 overflow-y-auto">
|
|
{JSON.stringify(entry.toolInput, null, 2)}
|
|
</pre>
|
|
</div>
|
|
{entry.output !== undefined && (
|
|
<div>
|
|
<div className="text-[10px] text-duck-dark/40 uppercase mb-1">Output</div>
|
|
<pre className={`whitespace-pre-wrap break-words text-[11px] max-h-60 overflow-y-auto ${entry.isError ? 'text-red-500' : 'text-duck-dark/60'}`}>
|
|
{entry.output}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Context to share state between workspace panels
|
|
type JobPanelContext = {
|
|
displaySteps: StepDef[];
|
|
isLive: boolean;
|
|
isRunning: boolean;
|
|
completedSteps: Set<number>;
|
|
activeStepIndex: number;
|
|
progressStepIndex: number;
|
|
jobStatus: JobStatus;
|
|
selectedKey: string | null;
|
|
selectOutput: (key: string) => void;
|
|
displayParallel: ParallelStep | null;
|
|
skippedItems: Array<{ label: string; reason: string }>;
|
|
outputMap: Map<string, OutputEntry[]>;
|
|
streamingMap: Map<string, string>;
|
|
outputPanelRef: React.RefObject<HTMLDivElement | null>;
|
|
};
|
|
|
|
const JobPanelCtx = createContext<JobPanelContext | null>(null);
|
|
const useJobPanel = () => useContext(JobPanelCtx)!;
|
|
|
|
const DEFAULT_LAYOUT: LayoutNode = {
|
|
type: 'group',
|
|
id: 'root',
|
|
direction: 'horizontal',
|
|
children: [
|
|
{ node: { type: 'panel', id: 'steps', appType: null }, size: 30 },
|
|
{ node: { type: 'panel', id: 'output', appType: null }, size: 70 },
|
|
],
|
|
};
|
|
|
|
const StepsPanel = () => {
|
|
const ctx = useJobPanel();
|
|
const {
|
|
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
|
|
progressStepIndex, jobStatus, selectedKey, selectOutput, displayParallel,
|
|
skippedItems, outputMap,
|
|
} = ctx;
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden">
|
|
<div className="px-3 py-2 border-b border-duck-dark/10">
|
|
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Steps</h2>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto">
|
|
{displaySteps.map((step, i) => {
|
|
let status: string = 'pending';
|
|
|
|
if (isLive || isRunning) {
|
|
if (completedSteps.has(i)) {
|
|
status = 'complete';
|
|
} else if (activeStepIndex === i) {
|
|
status = 'running';
|
|
} else if (i < activeStepIndex) {
|
|
status = 'complete';
|
|
}
|
|
} else {
|
|
if (jobStatus === 'completed') {
|
|
status = 'complete';
|
|
} else if (progressStepIndex >= 0 && i < progressStepIndex) {
|
|
status = 'complete';
|
|
} else if (i === progressStepIndex) {
|
|
status = jobStatus === 'failed' || jobStatus === 'interrupted' ? 'error' : 'complete';
|
|
}
|
|
}
|
|
|
|
const stepKey = outputKey(i);
|
|
const isSelected = selectedKey === stepKey && !displayParallel?.stepIndex;
|
|
const hasOutput = outputMap.has(stepKey);
|
|
|
|
return (
|
|
<div key={i}>
|
|
<button
|
|
onClick={() => selectOutput(stepKey)}
|
|
className={`w-full text-left flex items-center gap-2 px-3 py-2 border-b border-duck-dark/5 hover:bg-duck-dark/5 transition-colors cursor-pointer ${isSelected ? 'bg-duck-teal/10' : ''}`}
|
|
>
|
|
<div className="w-4 h-4 rounded-full flex items-center justify-center text-[9px] font-medium bg-duck-dark/10 text-duck-dark/50 shrink-0">
|
|
{i + 1}
|
|
</div>
|
|
<StatusIcon status={status} className="h-3 w-3 shrink-0" />
|
|
<span className="text-xs text-duck-dark flex-1 truncate">{step.task}</span>
|
|
{hasOutput && <ChevronRight className="h-3 w-3 text-duck-dark/20 shrink-0" />}
|
|
</button>
|
|
|
|
{/* Parallel iterations nested under their step */}
|
|
{displayParallel && displayParallel.stepIndex === i && (
|
|
<div className="bg-duck-dark/[0.02]">
|
|
{displayParallel.iterations.map((it) => {
|
|
const itKey = outputKey(i, it.label);
|
|
const itSelected = selectedKey === itKey;
|
|
const itHasOutput = outputMap.has(itKey);
|
|
|
|
return (
|
|
<button
|
|
key={it.label}
|
|
onClick={() => selectOutput(itKey)}
|
|
className={`w-full text-left flex items-center gap-2 pl-9 pr-3 py-1.5 border-b border-duck-dark/5 hover:bg-duck-dark/5 transition-colors cursor-pointer ${itSelected ? 'bg-duck-teal/10' : ''}`}
|
|
>
|
|
<StatusIcon status={it.status} className="h-3 w-3 shrink-0" />
|
|
<span className="text-xs text-duck-dark/80 flex-1 truncate">{it.label}</span>
|
|
{it.cost && (
|
|
<span className="text-[10px] text-duck-dark/40 font-mono tabular-nums">{formatCost(it.cost.totalUSD)}</span>
|
|
)}
|
|
{itHasOutput && <ChevronRight className="h-3 w-3 text-duck-dark/20 shrink-0" />}
|
|
</button>
|
|
);
|
|
})}
|
|
{skippedItems.length > 0 && (
|
|
<div className="pl-9 pr-3 py-1.5 text-[10px] text-duck-dark/40">
|
|
{skippedItems.length} skipped
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const OutputPanel = () => {
|
|
const ctx = useJobPanel();
|
|
const { selectedKey, outputMap, streamingMap, outputPanelRef } = ctx;
|
|
|
|
const selectedOutputs = selectedKey ? (outputMap.get(selectedKey) ?? []) : [];
|
|
const selectedStreaming = selectedKey ? (streamingMap.get(selectedKey) ?? '') : '';
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden">
|
|
<div className="px-3 py-2 border-b border-duck-dark/10 flex items-center gap-2">
|
|
<h2 className="text-xs font-medium text-duck-dark/60 uppercase tracking-wider">Output</h2>
|
|
{selectedKey && (
|
|
<span className="text-xs text-duck-dark/40 truncate">{selectedKey.includes(':') ? selectedKey.split(':')[1] : `step ${Number(selectedKey) + 1}`}</span>
|
|
)}
|
|
</div>
|
|
<div ref={outputPanelRef} className="flex-1 overflow-y-auto p-3 space-y-2 font-mono text-xs">
|
|
{!selectedKey && (
|
|
<div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans">
|
|
Select a step or sub-task to view output
|
|
</div>
|
|
)}
|
|
{selectedKey && selectedOutputs.length === 0 && !selectedStreaming && (
|
|
<div className="flex items-center justify-center h-full text-duck-dark/30 text-sm font-sans">
|
|
No output yet
|
|
</div>
|
|
)}
|
|
{selectedOutputs.map((entry) => {
|
|
if (entry.type === 'text') {
|
|
return (
|
|
<div key={entry.id} className="text-duck-dark/80 whitespace-pre-wrap break-words leading-relaxed">
|
|
{entry.text}
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<ToolCallEntry key={entry.id} entry={entry} />
|
|
);
|
|
})}
|
|
{selectedStreaming && (
|
|
<div className="text-duck-dark/60 whitespace-pre-wrap break-words leading-relaxed">
|
|
{selectedStreaming}
|
|
<span className="animate-pulse">|</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const PipelineJobDetail = () => {
|
|
const { id } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const client = useClient();
|
|
|
|
const [job, setJob] = useState<JobData | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isLive, setIsLive] = useState(false);
|
|
|
|
// Live state — track by step index
|
|
const [steps, setSteps] = useState<StepDef[]>([]);
|
|
const [activeStepIndex, setActiveStepIndex] = useState<number>(-1);
|
|
const [completedSteps, setCompletedSteps] = useState<Set<number>>(new Set());
|
|
const [parallelStep, setParallelStep] = useState<ParallelStep | null>(null);
|
|
const [runningCost, setRunningCost] = useState<Cost>({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
|
|
const [totalCost, setTotalCost] = useState<Cost | null>(null);
|
|
const [hasError, setHasError] = useState(false);
|
|
const [skippedItems, setSkippedItems] = useState<Array<{ label: string; reason: string }>>([]);
|
|
const [elapsed, setElapsed] = useState(0);
|
|
const [liveStatus, setLiveStatus] = useState<'running' | 'done'>('running');
|
|
|
|
// Per-step/iteration output tracking
|
|
const [outputMap, setOutputMap] = useState<Map<string, OutputEntry[]>>(new Map());
|
|
const [streamingMap, setStreamingMap] = useState<Map<string, string>>(new Map());
|
|
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
|
const streamBuffers = useRef<Map<string, string>>(new Map());
|
|
const outputPanelRef = useRef<HTMLDivElement | null>(null);
|
|
const autoFollowRef = useRef(true);
|
|
|
|
const [layout, setLayout] = useState<LayoutNode>(DEFAULT_LAYOUT);
|
|
const wsRef = useRef<WebSocket | null>(null);
|
|
const startTimeRef = useRef(Date.now());
|
|
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
|
|
const stopTimer = useCallback(() => {
|
|
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
|
|
}, []);
|
|
|
|
const addCost = useCallback((cost: Cost) => {
|
|
setRunningCost((prev) => ({
|
|
inputTokens: prev.inputTokens + cost.inputTokens,
|
|
outputTokens: prev.outputTokens + cost.outputTokens,
|
|
totalUSD: prev.totalUSD + cost.totalUSD,
|
|
}));
|
|
}, []);
|
|
|
|
const appendOutput = useCallback((key: string, entry: OutputEntry) => {
|
|
setOutputMap((prev) => {
|
|
const next = new Map(prev);
|
|
const arr = next.get(key) ?? [];
|
|
next.set(key, [...arr, entry]);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const updateToolOutput = useCallback((key: string, toolCallId: string, output: string, isError: boolean) => {
|
|
setOutputMap((prev) => {
|
|
const arr = prev.get(key);
|
|
if (!arr) return prev;
|
|
const next = new Map(prev);
|
|
next.set(key, arr.map((e) =>
|
|
e.type === 'tool' && e.toolCallId === toolCallId ? { ...e, output, isError } : e,
|
|
));
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// Auto-scroll output panel
|
|
useEffect(() => {
|
|
if (outputPanelRef.current) {
|
|
outputPanelRef.current.scrollTop = outputPanelRef.current.scrollHeight;
|
|
}
|
|
}, [outputMap, streamingMap, selectedKey]);
|
|
|
|
// Fetch job from REST API
|
|
useEffect(() => {
|
|
if (!id) return;
|
|
client
|
|
.get<JobData>(`/pipeline-jobs/${id}`)
|
|
.then((data) => {
|
|
setJob(data);
|
|
setIsLoading(false);
|
|
})
|
|
.catch(() => {
|
|
setIsLoading(false);
|
|
});
|
|
}, [id]);
|
|
|
|
// Connect WebSocket for live attachment
|
|
useEffect(() => {
|
|
if (!id || !job) return;
|
|
if (job.status !== 'running' && job.status !== 'pending') return;
|
|
|
|
const token = localStorage.getItem('BEARER_TOKEN');
|
|
if (!token) return;
|
|
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const url = `${protocol}//${window.location.host}/api/tasks/pipeline/ws?token=${token}`;
|
|
const ws = new WebSocket(url);
|
|
wsRef.current = ws;
|
|
|
|
startTimeRef.current = job.startedAt ? new Date(job.startedAt).getTime() : Date.now();
|
|
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
|
|
timerRef.current = setInterval(() => {
|
|
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
|
|
}, 1000);
|
|
|
|
ws.addEventListener('open', () => {
|
|
ws.send(JSON.stringify({ type: 'attach', jobId: id }));
|
|
});
|
|
|
|
ws.addEventListener('message', (ev) => {
|
|
try {
|
|
const msg = JSON.parse(ev.data) as ServerMessage;
|
|
handleEvent(msg);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
});
|
|
|
|
ws.addEventListener('close', () => {
|
|
setIsLive(false);
|
|
});
|
|
|
|
return () => {
|
|
ws.close();
|
|
wsRef.current = null;
|
|
stopTimer();
|
|
};
|
|
}, [id, job?.status]);
|
|
|
|
const handleEvent = useCallback((msg: ServerMessage) => {
|
|
switch (msg.type) {
|
|
case 'job:state':
|
|
if (msg.status === 'completed' || msg.status === 'failed' || msg.status === 'stopped' || msg.status === 'interrupted') {
|
|
setLiveStatus('done');
|
|
if (msg.cost) setTotalCost(msg.cost as Cost);
|
|
if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true);
|
|
stopTimer();
|
|
if (id) {
|
|
client.get<JobData>(`/pipeline-jobs/${id}`).then(setJob).catch(() => {});
|
|
}
|
|
}
|
|
if (msg.progress) {
|
|
const p = msg.progress as ProgressData;
|
|
if (p?.steps) setSteps(p.steps);
|
|
}
|
|
break;
|
|
|
|
case 'pipeline:init':
|
|
setIsLive(true);
|
|
setSteps(msg.steps);
|
|
break;
|
|
|
|
case 'step:start': {
|
|
setParallelStep(null);
|
|
setActiveStepIndex(msg.stepIndex);
|
|
const key = msg.iteration
|
|
? outputKey(msg.stepIndex, msg.iteration.label)
|
|
: outputKey(msg.stepIndex);
|
|
if (autoFollowRef.current) setSelectedKey(key);
|
|
break;
|
|
}
|
|
|
|
case 'step:complete':
|
|
setCompletedSteps((prev) => new Set(prev).add(msg.stepIndex));
|
|
setParallelStep(null);
|
|
setActiveStepIndex(-1);
|
|
if (msg.cost) addCost(msg.cost);
|
|
// Flush any remaining stream buffer for this step
|
|
flushStreamBuffer(outputKey(msg.stepIndex));
|
|
break;
|
|
|
|
case 'step:skip':
|
|
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
|
|
break;
|
|
|
|
case 'step:parallel':
|
|
setActiveStepIndex(msg.stepIndex);
|
|
setParallelStep({
|
|
stepIndex: msg.stepIndex,
|
|
taskName: msg.taskName,
|
|
concurrency: msg.concurrency,
|
|
iterations: msg.iterations.map((label) => ({ label, status: 'pending' })),
|
|
});
|
|
// Auto-select first iteration
|
|
if (autoFollowRef.current && msg.iterations.length > 0) {
|
|
setSelectedKey(outputKey(msg.stepIndex, msg.iterations[0]));
|
|
}
|
|
break;
|
|
|
|
case 'iteration:start':
|
|
setParallelStep((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
iterations: prev.iterations.map((it) =>
|
|
it.label === msg.label ? { ...it, status: 'running' } : it,
|
|
),
|
|
};
|
|
});
|
|
break;
|
|
|
|
case 'iteration:complete':
|
|
setParallelStep((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
iterations: prev.iterations.map((it) =>
|
|
it.label === msg.label ? { ...it, status: 'complete', cost: msg.cost } : it,
|
|
),
|
|
};
|
|
});
|
|
if (msg.cost) addCost(msg.cost);
|
|
flushStreamBuffer(outputKey(msg.stepIndex, msg.label));
|
|
break;
|
|
|
|
case 'iteration:error':
|
|
setParallelStep((prev) => {
|
|
if (!prev) return prev;
|
|
return {
|
|
...prev,
|
|
iterations: prev.iterations.map((it) =>
|
|
it.label === msg.label ? { ...it, status: 'error', error: msg.error } : it,
|
|
),
|
|
};
|
|
});
|
|
flushStreamBuffer(outputKey(msg.stepIndex, msg.label));
|
|
break;
|
|
|
|
case 'assistant:delta': {
|
|
const key = outputKey(msg.stepIndex, msg.iterationLabel);
|
|
const buf = streamBuffers.current;
|
|
buf.set(key, (buf.get(key) ?? '') + msg.text);
|
|
setStreamingMap((prev) => new Map(prev).set(key, buf.get(key)!));
|
|
break;
|
|
}
|
|
|
|
case 'assistant:text': {
|
|
const key = outputKey(msg.stepIndex, msg.iterationLabel);
|
|
const text = msg.text || streamBuffers.current.get(key) || '';
|
|
if (text) {
|
|
appendOutput(key, { id: crypto.randomUUID(), type: 'text', text });
|
|
}
|
|
streamBuffers.current.delete(key);
|
|
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
|
|
break;
|
|
}
|
|
|
|
case 'tool:start': {
|
|
const key = outputKey(msg.stepIndex, msg.iterationLabel);
|
|
// Flush any streaming text before the tool call
|
|
flushStreamBuffer(key);
|
|
appendOutput(key, {
|
|
id: crypto.randomUUID(),
|
|
type: 'tool',
|
|
toolCallId: msg.toolCallId,
|
|
toolName: msg.toolName,
|
|
toolInput: msg.toolInput,
|
|
});
|
|
break;
|
|
}
|
|
|
|
case 'tool:result': {
|
|
const key = outputKey(msg.stepIndex, msg.iterationLabel);
|
|
updateToolOutput(key, msg.toolCallId, msg.output, msg.isError);
|
|
break;
|
|
}
|
|
|
|
case 'pipeline:complete':
|
|
setTotalCost(msg.totalCost);
|
|
setLiveStatus('done');
|
|
setCompletedSteps((prev) => {
|
|
const next = new Set(prev);
|
|
setSteps((s) => { s.forEach((_, i) => next.add(i)); return s; });
|
|
return next;
|
|
});
|
|
setActiveStepIndex(-1);
|
|
setParallelStep(null);
|
|
stopTimer();
|
|
if (id) {
|
|
client.get<JobData>(`/pipeline-jobs/${id}`).then(setJob).catch(() => {});
|
|
}
|
|
break;
|
|
|
|
case 'error':
|
|
setHasError(true);
|
|
setLiveStatus('done');
|
|
stopTimer();
|
|
break;
|
|
|
|
case 'stopped':
|
|
setLiveStatus('done');
|
|
stopTimer();
|
|
break;
|
|
}
|
|
}, [id, stopTimer, addCost, appendOutput, updateToolOutput]);
|
|
|
|
const flushStreamBuffer = useCallback((key: string) => {
|
|
const text = streamBuffers.current.get(key);
|
|
if (text) {
|
|
appendOutput(key, { id: crypto.randomUUID(), type: 'text', text });
|
|
streamBuffers.current.delete(key);
|
|
setStreamingMap((prev) => { const n = new Map(prev); n.delete(key); return n; });
|
|
}
|
|
}, [appendOutput]);
|
|
|
|
const handleStop = useCallback(() => {
|
|
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && id) {
|
|
wsRef.current.send(JSON.stringify({ type: 'stop', jobId: id }));
|
|
}
|
|
}, [id]);
|
|
|
|
const selectOutput = useCallback((key: string) => {
|
|
autoFollowRef.current = false;
|
|
setSelectedKey(key);
|
|
}, []);
|
|
|
|
const panelComponents: PanelComponents = useMemo(() => ({
|
|
steps: StepsPanel,
|
|
output: OutputPanel,
|
|
}), []);
|
|
|
|
const displayStatus = job ? (isLive && liveStatus === 'running' ? 'running' : job.status) : 'pending';
|
|
const isRunning = displayStatus === 'running';
|
|
const displaySteps = steps.length > 0 ? steps : (job?.progress?.steps ?? []);
|
|
const jobDone = !isRunning && !isLive;
|
|
const progressStepIndex = job?.progress?.currentStepIndex ?? -1;
|
|
|
|
const displayParallel: ParallelStep | null = parallelStep ?? (jobDone && job?.progress?.parallel ? {
|
|
stepIndex: progressStepIndex,
|
|
taskName: job.progress.parallel.taskName,
|
|
concurrency: job.progress.parallel.concurrency,
|
|
iterations: job.progress.parallel.iterations.map((it) => ({
|
|
label: it.label,
|
|
status: it.status as IterationStatus['status'],
|
|
})),
|
|
} : null);
|
|
|
|
const panelCtx = useMemo<JobPanelContext>(() => ({
|
|
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
|
|
progressStepIndex, jobStatus: job?.status ?? 'pending', selectedKey, selectOutput,
|
|
displayParallel, skippedItems, outputMap, streamingMap, outputPanelRef,
|
|
}), [
|
|
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
|
|
progressStepIndex, job?.status, selectedKey, selectOutput,
|
|
displayParallel, skippedItems, outputMap, streamingMap,
|
|
]);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center text-duck-dark/30 text-sm">Loading...</div>
|
|
);
|
|
}
|
|
|
|
if (!job) {
|
|
return (
|
|
<div className="flex h-full flex-col items-center justify-center gap-3 text-duck-dark/30 text-sm">
|
|
<span>Job not found</span>
|
|
<Link to="/jobs" className="text-duck-teal text-xs hover:underline">Back to jobs</Link>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const displayCost = totalCost ?? (runningCost.totalUSD > 0 ? runningCost : null) ?? job.totalCost;
|
|
const displayElapsed = isRunning
|
|
? elapsed
|
|
: job.startedAt && job.completedAt
|
|
? Math.floor((new Date(job.completedAt).getTime() - new Date(job.startedAt).getTime()) / 1000)
|
|
: elapsed > 0 ? elapsed : null;
|
|
|
|
return (
|
|
<div className="flex h-full flex-col p-3 md:p-6 gap-4">
|
|
{/* Header + stats bar */}
|
|
<Card className="px-4 py-3 shrink-0">
|
|
<div className="flex items-center gap-3">
|
|
<button onClick={() => navigate('/jobs')} className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer">
|
|
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
|
</button>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<StatusIcon status={displayStatus} className="h-5 w-5" />
|
|
<h1 className="text-lg font-semibold text-duck-dark truncate">{job.taskName}</h1>
|
|
</div>
|
|
<div className="text-xs text-duck-dark/50 mt-0.5 ml-7">
|
|
{formatDate(job.createdAt)}
|
|
{job.completedAt && ` — ${formatDate(job.completedAt)}`}
|
|
</div>
|
|
</div>
|
|
{isRunning && (
|
|
<Button variant="outline" size="sm" onClick={handleStop} className="gap-1.5">
|
|
<Square className="h-3 w-3" />
|
|
Stop
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{(displayCost || displayElapsed !== null) && (
|
|
<div className="flex items-center gap-6 text-xs mt-3 pt-3 border-t border-duck-dark/10 ml-9">
|
|
{displayElapsed !== null && (
|
|
<div className="flex items-center gap-1.5 text-duck-dark/60">
|
|
<Clock className="h-3.5 w-3.5" />
|
|
<span className="font-mono tabular-nums">{formatElapsed(displayElapsed)}</span>
|
|
</div>
|
|
)}
|
|
{displayCost && (
|
|
<>
|
|
<div className="flex items-center gap-1.5 text-duck-dark/60">
|
|
<span className="text-duck-dark/40">Tokens:</span>
|
|
<span className="font-mono tabular-nums">
|
|
{formatTokens(displayCost.inputTokens)} in / {formatTokens(displayCost.outputTokens)} out
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 text-duck-dark/60">
|
|
<span className="text-duck-dark/40">Cost:</span>
|
|
<span className="font-mono tabular-nums">{formatCost(displayCost.totalUSD)}</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Error message */}
|
|
{(hasError || job.error) && (
|
|
<Card className="px-4 py-3 shrink-0 border-red-200 dark:border-red-800/50 bg-red-50/50 dark:bg-red-950/20">
|
|
<div className="flex items-start gap-2">
|
|
<AlertCircle className="h-4 w-4 text-red-500 shrink-0 mt-0.5" />
|
|
<span className="text-sm text-red-700 dark:text-red-300">{job.error ?? 'An error occurred during execution'}</span>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Two-column: steps list + output viewer */}
|
|
<div className="flex-1 min-h-0">
|
|
<JobPanelCtx.Provider value={panelCtx}>
|
|
<WorkspaceLayout layout={layout} onLayoutChange={setLayout} components={panelComponents} noHeader />
|
|
</JobPanelCtx.Provider>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|