Chat took the whole page down at the end of every turn with `TypeError: crypto.randomUUID is not a function`. `crypto.randomUUID()` is SECURE-CONTEXT ONLY — over plain http on anything that is not localhost it is not defined at all. Officer is reached at http://officer-dev:9000, which is neither, so all eighteen call sites in the frontend were throwing. The stack shows why it was fatal rather than merely broken: it was called inside a `useState` initialiser, so the throw happened during render and unmounted the tree. The assistant message that has no id yet is created at the end of a turn, which is exactly when it fired. No TLS needed. `crypto.getRandomValues()` carries no such restriction — it is on `Crypto`, not `SubtleCrypto`, and works in an insecure context. helpers/random-id uses randomUUID when it exists and otherwise assembles a v4 from the same CSPRNG: same 122 bits, same version and variant bits. Verified both paths produce a UUID matching the v4 pattern, including with randomUUID deleted. `crypto.subtle` is not used anywhere in the frontend, so randomUUID was the whole of the problem. Audio recording is a different matter — getUserMedia genuinely requires a secure context and cannot be polyfilled. Nine files, eighteen call sites. The vendored hls.mjs is left alone. Two of my own mistakes on the way, both caught by parsing rather than by reading: the rewrite added an import of the helper TO the helper, and inserted another one inside a multi-line import block — the same trap as the officerdb move earlier tonight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
776 lines
29 KiB
TypeScript
776 lines
29 KiB
TypeScript
import { useState, useEffect, useRef, useCallback, useMemo, createContext, useContext } from 'react';
|
|
import { useParams, 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';
|
|
import { randomId } from 'helpers/random-id';
|
|
|
|
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 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: randomId(), 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: randomId(),
|
|
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: randomId(), 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">
|
|
{/* The other two job detail panes already do this; this was the last imperative back in Jobs. */}
|
|
<Link to="/jobs" aria-label="Back to jobs" className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer">
|
|
<ArrowLeft className="h-4 w-4 text-duck-dark/60" />
|
|
</Link>
|
|
<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>
|
|
);
|
|
};
|
|
|