add pipeline job management with /jobs pages and per-step output viewer
- Pipeline jobs now persist to DB with progress tracking and cost accumulation - Jobs survive WebSocket disconnects with in-memory event buffer replay - New /jobs list page with search, status badges, and cost display - New /jobs/:id detail page with live WebSocket attachment and REST fallback - Two-column layout using WorkspaceLayout for resizable steps/output panels - Streaming messages tagged with stepIndex/iterationLabel for per-step output grouping - TaskRunnerModal links to job detail page once job is created - Dock entry added for Jobs page Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+91
-8
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { X, Play, Square, CircleCheck, CircleX, Copy, Check } from 'lucide-react';
|
||||
import { X, Play, Square, CircleCheck, CircleX, Copy, Check, Loader2, AlertCircle, ExternalLink } from 'lucide-react';
|
||||
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||
import { cardStyle } from '@/components/Card';
|
||||
@@ -512,7 +512,7 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
||||
// Auto-scroll
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [pipeline.messages, pipeline.streamingText, pipeline.currentStep]);
|
||||
}, [pipeline.messages, pipeline.streamingText, pipeline.currentStep, pipeline.parallelStep]);
|
||||
|
||||
// Ding on completion
|
||||
const prevPhaseRef = useRef(pipeline.phase);
|
||||
@@ -554,9 +554,25 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
||||
);
|
||||
}
|
||||
|
||||
const formatElapsed = (s: number) => {
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return m > 0 ? `${m}m ${sec}s` : `${sec}s`;
|
||||
};
|
||||
|
||||
const rc = pipeline.runningCost;
|
||||
const totalTokens = rc.inputTokens + rc.outputTokens;
|
||||
const ps = pipeline.parallelStep;
|
||||
|
||||
// Parallel progress counts
|
||||
const pDone = ps?.iterations.filter((it) => it.status === 'complete').length ?? 0;
|
||||
const pRunning = ps?.iterations.filter((it) => it.status === 'running').length ?? 0;
|
||||
const pError = ps?.iterations.filter((it) => it.status === 'error').length ?? 0;
|
||||
const pTotal = ps?.iterations.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Step progress header */}
|
||||
{/* Step progress header — sequential */}
|
||||
{pipeline.currentStep && (
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-duck-dark/3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
@@ -577,8 +593,65 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
{/* Step progress header — parallel */}
|
||||
{ps && (
|
||||
<div className="shrink-0 px-4 py-2 border-b border-duck-dark/10 bg-duck-dark/3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="font-medium text-duck-dark">{ps.taskName}</span>
|
||||
<span className="text-duck-dark/50 text-xs">
|
||||
{pDone}/{pTotal} done
|
||||
{pRunning > 0 && <span className="ml-1">· {pRunning} running</span>}
|
||||
{pError > 0 && <span className="ml-1 text-red-500">· {pError} failed</span>}
|
||||
</span>
|
||||
{pDone + pError < pTotal && (
|
||||
<span className="ml-auto inline-block h-2 w-2 rounded-full bg-amber-400 animate-pulse" />
|
||||
)}
|
||||
{pDone + pError === pTotal && pTotal > 0 && (
|
||||
<span className="ml-auto text-xs text-green-600">done</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Running stats */}
|
||||
{pipeline.phase === 'running' && (
|
||||
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/5 flex items-center gap-4 text-xs text-duck-dark/40 font-mono tabular-nums">
|
||||
<span>{formatElapsed(pipeline.elapsed)}</span>
|
||||
{totalTokens > 0 && <span>{totalTokens.toLocaleString()} tok</span>}
|
||||
{rc.totalUSD > 0 && <span>${rc.totalUSD.toFixed(3)}</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content area */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{/* Parallel iteration grid */}
|
||||
{ps && (
|
||||
<div className="px-4 py-3 space-y-1">
|
||||
{ps.iterations.map((it) => (
|
||||
<div key={it.label} className="flex items-center gap-2 py-1 px-2 rounded text-sm">
|
||||
{it.status === 'pending' && <span className="h-4 w-4 rounded-full border border-duck-dark/20 shrink-0" />}
|
||||
{it.status === 'running' && <Loader2 className="h-4 w-4 text-amber-500 animate-spin shrink-0" />}
|
||||
{it.status === 'complete' && <CircleCheck className="h-4 w-4 text-green-500 shrink-0" />}
|
||||
{it.status === 'error' && <AlertCircle className="h-4 w-4 text-red-500 shrink-0" />}
|
||||
<span className={`font-mono text-xs truncate ${it.status === 'running' ? 'text-duck-dark' : it.status === 'error' ? 'text-red-500' : 'text-duck-dark/60'}`}>
|
||||
{it.label}
|
||||
</span>
|
||||
{it.cost && (
|
||||
<span className="ml-auto text-xs text-duck-dark/30 font-mono tabular-nums shrink-0">
|
||||
${it.cost.totalUSD.toFixed(3)}
|
||||
</span>
|
||||
)}
|
||||
{it.error && (
|
||||
<span className="ml-auto text-xs text-red-400 truncate max-w-[200px]" title={it.error}>
|
||||
{it.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sequential messages */}
|
||||
{pipeline.messages.map((msg, i) => (
|
||||
<div key={i} className="px-4 py-1.5">
|
||||
<MessageBubble message={msg} onAnswer={() => {}} />
|
||||
@@ -614,8 +687,8 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
||||
</span>
|
||||
)}
|
||||
{pipeline.totalCost && (
|
||||
<span className="text-xs text-duck-dark/40">
|
||||
${pipeline.totalCost.totalUSD.toFixed(3)} · {pipeline.totalCost.inputTokens + pipeline.totalCost.outputTokens} tokens
|
||||
<span className="text-xs text-duck-dark/40 font-mono tabular-nums">
|
||||
{formatElapsed(pipeline.elapsed)} · {(pipeline.totalCost.inputTokens + pipeline.totalCost.outputTokens).toLocaleString()} tokens · ${pipeline.totalCost.totalUSD.toFixed(3)}
|
||||
</span>
|
||||
)}
|
||||
{pipeline.skippedItems.length > 0 && (
|
||||
@@ -623,6 +696,15 @@ const PipelineRunner = ({ taskDirName, context, cwd }: PipelineRunnerProps) => {
|
||||
{pipeline.skippedItems.length} skipped ({pipeline.skippedItems.map((s) => s.label).join(', ')})
|
||||
</span>
|
||||
)}
|
||||
{pipeline.jobId && (
|
||||
<a
|
||||
href={`/jobs/${pipeline.jobId}`}
|
||||
className="flex items-center gap-1.5 text-xs text-duck-teal hover:text-duck-teal/80 transition-colors mt-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
View in Jobs
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -656,8 +738,9 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryFull
|
||||
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName: entryName ?? '', entryType: entryType ?? 'file' };
|
||||
|
||||
// Context values for autofill
|
||||
// Build a ~/relative path for the agent (works inside bwrap sandbox)
|
||||
const entryRelPath = entryName && cwd.path ? `~/${cwd.path}/${entryName}` : entryName ? `~/${entryName}` : undefined;
|
||||
// Build absolute path the agent sees (sandboxed: /data/home/..., non-sandboxed: ~/...)
|
||||
const homePrefix = sandboxed ? '/data/home' : '~';
|
||||
const entryRelPath = entryName && cwd.path ? `${homePrefix}/${cwd.path}/${entryName}` : entryName ? `${homePrefix}/${entryName}` : undefined;
|
||||
const autofillContext: Record<string, string> = {};
|
||||
if (entryName) autofillContext.entry_name = entryName;
|
||||
if (entryRelPath) autofillContext.entry_path = entryRelPath;
|
||||
|
||||
+244
-101
@@ -6,6 +6,7 @@ type Phase = 'ready' | 'running' | 'done';
|
||||
type StepDef = {
|
||||
task: string;
|
||||
foreach?: string;
|
||||
concurrency?: number;
|
||||
};
|
||||
|
||||
type StepStatus = {
|
||||
@@ -15,31 +16,60 @@ type StepStatus = {
|
||||
cost?: { inputTokens: number; outputTokens: number; totalUSD: number };
|
||||
};
|
||||
|
||||
export type IterationStatus = {
|
||||
label: string;
|
||||
status: 'pending' | 'running' | 'complete' | 'error';
|
||||
error?: string;
|
||||
cost?: { inputTokens: number; outputTokens: number; totalUSD: number };
|
||||
};
|
||||
|
||||
type ParallelStep = {
|
||||
stepIndex: number;
|
||||
taskName: string;
|
||||
concurrency: number;
|
||||
iterations: IterationStatus[];
|
||||
};
|
||||
|
||||
type ServerMessage =
|
||||
| { type: 'pipeline:init'; steps: StepDef[] }
|
||||
| { type: 'step:start'; stepIndex: number; taskName: string; iteration?: { current: number; total: number; label: string } }
|
||||
| { type: 'step:complete'; stepIndex: number; cost?: { inputTokens: number; outputTokens: number; totalUSD: number } }
|
||||
| { type: 'step:skip'; stepIndex: number; label: string; reason: string }
|
||||
| { type: 'assistant:delta'; text: string }
|
||||
| { type: 'assistant:text'; text: string }
|
||||
| { type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown> }
|
||||
| { type: 'tool:result'; toolCallId: string; output: string; isError: boolean }
|
||||
| { type: 'pipeline:complete'; totalCost: { inputTokens: number; outputTokens: number; totalUSD: number } }
|
||||
| { type: 'error'; message: string }
|
||||
| { type: 'stopped' };
|
||||
| { 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?: { inputTokens: number; outputTokens: number; totalUSD: number } }
|
||||
| { 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?: { inputTokens: number; outputTokens: number; totalUSD: number } }
|
||||
| { jobId: string; type: 'iteration:error'; stepIndex: number; label: string; error: string }
|
||||
| { jobId: string; type: 'assistant:delta'; text: string; iterationLabel?: string }
|
||||
| { jobId: string; type: 'assistant:text'; text: string; iterationLabel?: string }
|
||||
| { jobId: string; type: 'tool:start'; toolCallId: string; toolName: string; toolInput: Record<string, unknown>; iterationLabel?: string }
|
||||
| { jobId: string; type: 'tool:result'; toolCallId: string; output: string; isError: boolean; iterationLabel?: string }
|
||||
| { jobId: string; type: 'pipeline:complete'; totalCost: { inputTokens: number; outputTokens: number; totalUSD: number } }
|
||||
| { jobId: string; type: 'error'; message: string }
|
||||
| { jobId: string; type: 'stopped' }
|
||||
| { type: 'job:created'; jobId: string }
|
||||
| { type: 'job:state'; jobId: string; status: string; progress: unknown; cost: unknown }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
export function usePipelineRunner() {
|
||||
const [phase, setPhase] = useState<Phase>('ready');
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [steps, setSteps] = useState<StepDef[]>([]);
|
||||
const [currentStep, setCurrentStep] = useState<StepStatus | null>(null);
|
||||
const [parallelStep, setParallelStep] = useState<ParallelStep | null>(null);
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [streamingText, setStreamingText] = useState('');
|
||||
const [totalCost, setTotalCost] = useState<{ inputTokens: number; outputTokens: number; totalUSD: number } | null>(null);
|
||||
const [runningCost, setRunningCost] = useState({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [skippedItems, setSkippedItems] = useState<Array<{ label: string; reason: string }>>([]);
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const streamBufferRef = useRef('');
|
||||
const startTimeRef = useRef<number>(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const jobIdRef = useRef<string | null>(null);
|
||||
const inParallelRef = useRef(false);
|
||||
|
||||
const flushStream = useCallback(() => {
|
||||
const text = streamBufferRef.current;
|
||||
@@ -50,6 +80,183 @@ export function usePipelineRunner() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const stopTimer = useCallback(() => {
|
||||
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
|
||||
}, []);
|
||||
|
||||
const addCost = useCallback((cost: { inputTokens: number; outputTokens: number; totalUSD: number }) => {
|
||||
setRunningCost((prev) => ({
|
||||
inputTokens: prev.inputTokens + cost.inputTokens,
|
||||
outputTokens: prev.outputTokens + cost.outputTokens,
|
||||
totalUSD: prev.totalUSD + cost.totalUSD,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleEvent = useCallback((msg: ServerMessage) => {
|
||||
// Filter events by jobId (ignore events from other jobs)
|
||||
if ('jobId' in msg && msg.jobId && jobIdRef.current && msg.jobId !== jobIdRef.current) return;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'job:created':
|
||||
jobIdRef.current = msg.jobId;
|
||||
setJobId(msg.jobId);
|
||||
break;
|
||||
|
||||
case 'job:state':
|
||||
// Reconnection to a completed/failed job
|
||||
if (msg.status === 'completed' || msg.status === 'failed' || msg.status === 'stopped' || msg.status === 'interrupted') {
|
||||
setPhase('done');
|
||||
if (msg.cost) setTotalCost(msg.cost as { inputTokens: number; outputTokens: number; totalUSD: number });
|
||||
if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true);
|
||||
stopTimer();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'pipeline:init':
|
||||
setSteps(msg.steps);
|
||||
break;
|
||||
|
||||
case 'step:start':
|
||||
flushStream();
|
||||
setMessages([]);
|
||||
setParallelStep(null);
|
||||
inParallelRef.current = false;
|
||||
setCurrentStep({
|
||||
taskName: msg.taskName,
|
||||
iteration: msg.iteration,
|
||||
status: 'running',
|
||||
});
|
||||
break;
|
||||
|
||||
case 'step:complete':
|
||||
flushStream();
|
||||
setCurrentStep((prev) => prev ? { ...prev, status: 'complete', cost: msg.cost } : null);
|
||||
if (msg.cost) addCost(msg.cost);
|
||||
break;
|
||||
|
||||
case 'step:skip':
|
||||
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
|
||||
break;
|
||||
|
||||
case 'step:parallel':
|
||||
flushStream();
|
||||
setMessages([]);
|
||||
setCurrentStep(null);
|
||||
inParallelRef.current = true;
|
||||
setParallelStep({
|
||||
stepIndex: msg.stepIndex,
|
||||
taskName: msg.taskName,
|
||||
concurrency: msg.concurrency,
|
||||
iterations: msg.iterations.map((label) => ({ label, status: 'pending' })),
|
||||
});
|
||||
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);
|
||||
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,
|
||||
),
|
||||
};
|
||||
});
|
||||
break;
|
||||
|
||||
case 'assistant:delta':
|
||||
// Skip messages from parallel sub-agents (shown in iteration grid instead)
|
||||
if (inParallelRef.current && msg.iterationLabel) break;
|
||||
streamBufferRef.current += msg.text;
|
||||
setStreamingText(streamBufferRef.current);
|
||||
break;
|
||||
|
||||
case 'assistant:text': {
|
||||
if (inParallelRef.current && msg.iterationLabel) break;
|
||||
const text = msg.text || streamBufferRef.current;
|
||||
if (text) {
|
||||
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]);
|
||||
}
|
||||
streamBufferRef.current = '';
|
||||
setStreamingText('');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool:start':
|
||||
if (inParallelRef.current && msg.iterationLabel) break;
|
||||
flushStream();
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'tool' as const,
|
||||
id: crypto.randomUUID(),
|
||||
toolCallId: msg.toolCallId,
|
||||
toolName: msg.toolName,
|
||||
toolInput: msg.toolInput,
|
||||
output: undefined,
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'tool:result':
|
||||
if (inParallelRef.current && msg.iterationLabel) break;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.role === 'tool' && 'toolCallId' in m && m.toolCallId === msg.toolCallId
|
||||
? { ...m, output: msg.output, isError: msg.isError }
|
||||
: m,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'pipeline:complete':
|
||||
flushStream();
|
||||
setTotalCost(msg.totalCost);
|
||||
setPhase('done');
|
||||
stopTimer();
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
flushStream();
|
||||
setMessages((prev) => [...prev, { role: 'error' as const, id: crypto.randomUUID(), text: msg.message }]);
|
||||
setHasError(true);
|
||||
setPhase('done');
|
||||
stopTimer();
|
||||
break;
|
||||
|
||||
case 'stopped':
|
||||
flushStream();
|
||||
setPhase('done');
|
||||
stopTimer();
|
||||
break;
|
||||
}
|
||||
}, [flushStream, stopTimer, addCost]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('BEARER_TOKEN');
|
||||
if (!token) return;
|
||||
@@ -59,98 +266,18 @@ export function usePipelineRunner() {
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.addEventListener('open', () => setIsConnected(true));
|
||||
ws.addEventListener('open', () => {
|
||||
setIsConnected(true);
|
||||
if (jobIdRef.current) {
|
||||
ws.send(JSON.stringify({ type: 'attach', jobId: jobIdRef.current }));
|
||||
}
|
||||
});
|
||||
ws.addEventListener('close', () => setIsConnected(false));
|
||||
|
||||
ws.addEventListener('message', (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data) as ServerMessage;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'pipeline:init':
|
||||
setSteps(msg.steps);
|
||||
break;
|
||||
|
||||
case 'step:start':
|
||||
// Flush any streaming text from the previous step
|
||||
flushStream();
|
||||
// Clear messages for the new step iteration
|
||||
setMessages([]);
|
||||
setCurrentStep({
|
||||
taskName: msg.taskName,
|
||||
iteration: msg.iteration,
|
||||
status: 'running',
|
||||
});
|
||||
break;
|
||||
|
||||
case 'step:complete':
|
||||
flushStream();
|
||||
setCurrentStep((prev) => prev ? { ...prev, status: 'complete', cost: msg.cost } : null);
|
||||
break;
|
||||
|
||||
case 'step:skip':
|
||||
setSkippedItems((prev) => [...prev, { label: msg.label, reason: msg.reason }]);
|
||||
break;
|
||||
|
||||
case 'assistant:delta':
|
||||
streamBufferRef.current += msg.text;
|
||||
setStreamingText(streamBufferRef.current);
|
||||
break;
|
||||
|
||||
case 'assistant:text': {
|
||||
const text = msg.text || streamBufferRef.current;
|
||||
if (text) {
|
||||
setMessages((prev) => [...prev, { role: 'assistant', id: crypto.randomUUID(), text }]);
|
||||
}
|
||||
streamBufferRef.current = '';
|
||||
setStreamingText('');
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tool:start':
|
||||
flushStream();
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'tool' as const,
|
||||
id: crypto.randomUUID(),
|
||||
toolCallId: msg.toolCallId,
|
||||
toolName: msg.toolName,
|
||||
toolInput: msg.toolInput,
|
||||
output: undefined,
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'tool:result':
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.role === 'tool' && 'toolCallId' in m && m.toolCallId === msg.toolCallId
|
||||
? { ...m, output: msg.output, isError: msg.isError }
|
||||
: m,
|
||||
),
|
||||
);
|
||||
break;
|
||||
|
||||
case 'pipeline:complete':
|
||||
flushStream();
|
||||
setTotalCost(msg.totalCost);
|
||||
setPhase('done');
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
flushStream();
|
||||
setMessages((prev) => [...prev, { role: 'error' as const, id: crypto.randomUUID(), text: msg.message }]);
|
||||
setHasError(true);
|
||||
setPhase('done');
|
||||
break;
|
||||
|
||||
case 'stopped':
|
||||
flushStream();
|
||||
setPhase('done');
|
||||
break;
|
||||
}
|
||||
handleEvent(msg);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -159,6 +286,7 @@ export function usePipelineRunner() {
|
||||
return () => {
|
||||
ws.close();
|
||||
wsRef.current = null;
|
||||
stopTimer();
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -169,17 +297,32 @@ export function usePipelineRunner() {
|
||||
setMessages([]);
|
||||
setStreamingText('');
|
||||
setTotalCost(null);
|
||||
setRunningCost({ inputTokens: 0, outputTokens: 0, totalUSD: 0 });
|
||||
setHasError(false);
|
||||
setSkippedItems([]);
|
||||
setCurrentStep(null);
|
||||
setParallelStep(null);
|
||||
setElapsed(0);
|
||||
setJobId(null);
|
||||
jobIdRef.current = null;
|
||||
streamBufferRef.current = '';
|
||||
|
||||
startTimeRef.current = Date.now();
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
timerRef.current = setInterval(() => {
|
||||
setElapsed(Math.floor((Date.now() - startTimeRef.current) / 1000));
|
||||
}, 1000);
|
||||
|
||||
wsRef.current.send(JSON.stringify({ type: 'run', taskDirName, inputs, cwd }));
|
||||
}, []);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
||||
wsRef.current.send(JSON.stringify({ type: 'stop' }));
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN || !jobIdRef.current) return;
|
||||
wsRef.current.send(JSON.stringify({ type: 'stop', jobId: jobIdRef.current }));
|
||||
}, []);
|
||||
|
||||
return { phase, isConnected, steps, currentStep, messages, streamingText, totalCost, hasError, skippedItems, run, stop };
|
||||
return {
|
||||
phase, isConnected, jobId, steps, currentStep, parallelStep, messages, streamingText,
|
||||
totalCost, runningCost, hasError, skippedItems, elapsed, run, stop,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user