every plugin route renders a workspace, and it is not a rule you can forget

an exclusionary rule, made structural. a plugin does not render a screen: it
contributes panels and says how they are arranged, and the shell renders
WorkspaceView around them.

    web/panels.ts   appRegistryMetas — at least one panel
    web/layout.ts   defaultLayout — how they are arranged

both required the moment web/ exists, and missing either is refused at discovery
by name and with the reason. tested:

    probeplug: has a web/ directory but is missing web/layout.ts.
    Every plugin route renders a Workspace: contribute panels and a layout,
    not a screen.

there is deliberately no way to export a component. one that could would be free
to render a bare div, a full-page form, or its own navigation, and the platform
would become a shell hosting strangers' layouts rather than one application.
non-compliance is not so much refused as unrepresentable — there is nowhere to
put a screen.

the shell registers <prefix> and <prefix>/:section, exactly as the core screens
do, so a plugin's sections stay addressable and cmd-clickable, and panels read
useParams independently rather than passing state between themselves.
appTypes.allowed is pinned to that plugin's own keys, so a persisted layout
naming something else falls back instead of rendering another plugin's panel
inside this screen.

the example plugin is rebuilt to model it — two panels, a layout, one of them
calling its own /api/example/ping through useClient — because the reference
implementation is what everyone copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 23:47:48 +00:00
co-authored by Claude Opus 5
parent 2e3c935da6
commit 543e88a9a6
38 changed files with 1026 additions and 440 deletions
@@ -1,8 +1,16 @@
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,
ArrowLeft,
CheckCircle2,
AlertCircle,
Loader2,
StopCircle,
AlertTriangle,
Clock,
Square,
ChevronRight,
Wrench,
} from 'lucide-react';
import { useClient } from 'hooks/useClient';
import { Card } from '@/components/Card';
@@ -53,21 +61,58 @@ type JobData = {
// 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 };
| {
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: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: '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: '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' }
@@ -86,7 +131,7 @@ const formatElapsed = (seconds: number) => {
const formatCost = (cost: number) => `$${cost.toFixed(4)}`;
const formatTokens = (n: number) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
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) =>
@@ -128,15 +173,12 @@ const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
<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"
/>
<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' : ''}`} />
{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">
@@ -149,7 +191,9 @@ const ToolCallEntry = ({ entry }: ToolCallEntryProps) => {
{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'}`}>
<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>
@@ -194,9 +238,18 @@ const DEFAULT_LAYOUT: LayoutNode = {
const StepsPanel = () => {
const ctx = useJobPanel();
const {
displaySteps, isLive, isRunning, completedSteps, activeStepIndex,
progressStepIndex, jobStatus, selectedKey, selectOutput, displayParallel,
skippedItems, outputMap,
displaySteps,
isLive,
isRunning,
completedSteps,
activeStepIndex,
progressStepIndex,
jobStatus,
selectedKey,
selectOutput,
displayParallel,
skippedItems,
outputMap,
} = ctx;
return (
@@ -261,16 +314,16 @@ const StepsPanel = () => {
<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>
<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 className="pl-9 pr-3 py-1.5 text-[10px] text-duck-dark/40">{skippedItems.length} skipped</div>
)}
</div>
)}
@@ -294,7 +347,9 @@ const OutputPanel = () => {
<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>
<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">
@@ -316,9 +371,7 @@ const OutputPanel = () => {
</div>
);
}
return (
<ToolCallEntry key={entry.id} entry={entry} />
);
return <ToolCallEntry key={entry.id} entry={entry} />;
})}
{selectedStreaming && (
<div className="text-duck-dark/60 whitespace-pre-wrap break-words leading-relaxed">
@@ -365,7 +418,10 @@ export const PipelineJobDetail = () => {
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopTimer = useCallback(() => {
if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; }
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
const addCost = useCallback((cost: Cost) => {
@@ -390,9 +446,10 @@ export const PipelineJobDetail = () => {
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,
));
next.set(
key,
arr.map((e) => (e.type === 'tool' && e.toolCallId === toolCallId ? { ...e, output, isError } : e)),
);
return next;
});
}, []);
@@ -461,181 +518,205 @@ export const PipelineJobDetail = () => {
};
}, [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') {
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');
if (msg.cost) setTotalCost(msg.cost as Cost);
if (msg.status === 'failed' || msg.status === 'interrupted') setHasError(true);
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(() => {});
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;
break;
case 'pipeline:init':
setIsLive(true);
setSteps(msg.steps);
break;
case 'error':
setHasError(true);
setLiveStatus('done');
stopTimer();
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 'stopped':
setLiveStatus('done');
stopTimer();
break;
}
},
[id, stopTimer, addCost, appendOutput, updateToolOutput],
);
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 });
}
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; });
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,
setStreamingMap((prev) => {
const n = new Map(prev);
n.delete(key);
return n;
});
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]);
},
[appendOutput],
);
const handleStop = useCallback(() => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN && id) {
@@ -648,10 +729,13 @@ export const PipelineJobDetail = () => {
setSelectedKey(key);
}, []);
const panelComponents: PanelComponents = useMemo(() => ({
steps: StepsPanel,
output: OutputPanel,
}), []);
const panelComponents: PanelComponents = useMemo(
() => ({
steps: StepsPanel,
output: OutputPanel,
}),
[],
);
const displayStatus = job ? (isLive && liveStatus === 'running' ? 'running' : job.status) : 'pending';
const isRunning = displayStatus === 'running';
@@ -659,37 +743,65 @@ export const PipelineJobDetail = () => {
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 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,
]);
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>
);
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>
<Link to="/jobs" className="text-duck-teal text-xs hover:underline">
Back to jobs
</Link>
</div>
);
}
@@ -699,7 +811,9 @@ export const PipelineJobDetail = () => {
? elapsed
: job.startedAt && job.completedAt
? Math.floor((new Date(job.completedAt).getTime() - new Date(job.startedAt).getTime()) / 1000)
: elapsed > 0 ? elapsed : null;
: elapsed > 0
? elapsed
: null;
return (
<div className="flex h-full flex-col p-3 md:p-6 gap-4">
@@ -758,7 +872,9 @@ export const PipelineJobDetail = () => {
<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>
<span className="text-sm text-red-700 dark:text-red-300">
{job.error ?? 'An error occurred during execution'}
</span>
</div>
</Card>
)}
@@ -772,4 +888,3 @@ export const PipelineJobDetail = () => {
</div>
);
};