to the bottom when new content arrives,
// unless the user has deliberately scrolled up.
const scrollToBottom = useCallback(() => {
const el = preRef.current;
if (el && !userScrolledAwayRef.current) {
el.scrollTop = el.scrollHeight;
}
}, []);
// Detect whether the user has scrolled away from the bottom.
const handleScroll = useCallback(() => {
const el = preRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
userScrolledAwayRef.current = !atBottom;
}, []);
// ---- SSE streaming path (used when execution is running) ----
useEffect(() => {
if (!isRunning) return;
const token = localStorage.getItem("access_token");
if (!token) {
setLoadError("No authentication token available");
setIsLoadingContent(false);
return;
}
const url = `${OpenAPI.BASE}/api/v1/artifacts/${artifactId}/stream?token=${encodeURIComponent(token)}`;
const es = new EventSource(url);
eventSourceRef.current = es;
setIsStreaming(true);
setStreamDone(false);
es.addEventListener("waiting", (e: MessageEvent) => {
setIsWaiting(true);
setIsLoadingContent(false);
// If the message says "File found", the next event will be content
if (e.data?.includes("File found")) {
setIsWaiting(false);
}
});
es.addEventListener("content", (e: MessageEvent) => {
setContent(e.data);
setLoadError(null);
setIsLoadingContent(false);
setIsWaiting(false);
// Scroll after React renders the new content
requestAnimationFrame(scrollToBottom);
});
es.addEventListener("append", (e: MessageEvent) => {
setContent((prev) => (prev ?? "") + e.data);
setLoadError(null);
requestAnimationFrame(scrollToBottom);
});
es.addEventListener("done", () => {
setStreamDone(true);
setIsStreaming(false);
es.close();
});
es.addEventListener("error", (e: MessageEvent) => {
// SSE spec fires generic error events on connection close.
// Only show user-facing errors if the server sent an explicit event.
if (e.data) {
setLoadError(e.data);
}
});
es.onerror = () => {
// Connection dropped — EventSource will auto-reconnect, but if it
// reaches CLOSED state we fall back to the download endpoint.
if (es.readyState === EventSource.CLOSED) {
setIsStreaming(false);
// If we never got any content via SSE, fall back to download
setContent((prev) => {
if (prev === null) {
// Will be handled by the fetch fallback below
}
return prev;
});
}
};
return () => {
es.close();
eventSourceRef.current = null;
setIsStreaming(false);
};
}, [artifactId, isRunning, scrollToBottom]);
// ---- Fetch fallback (used when not running, or SSE never connected) ----
const fetchContent = useCallback(async () => {
const token = localStorage.getItem("access_token");
const url = `${OpenAPI.BASE}/api/v1/artifacts/${artifactId}/download`;
try {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
setLoadError(`HTTP ${response.status}: ${response.statusText}`);
setIsLoadingContent(false);
return;
}
const text = await response.text();
setContent(text);
setLoadError(null);
} catch (e) {
setLoadError(e instanceof Error ? e.message : "Unknown error");
} finally {
setIsLoadingContent(false);
}
}, [artifactId]);
// When NOT running (execution completed), use download endpoint once.
useEffect(() => {
if (isRunning) return;
fetchContent();
}, [isRunning, fetchContent]);
return (
{artifactName ?? "Text File"}
{isStreaming && !streamDone && (
Streaming
)}
{streamDone && (
Stream complete
)}
{isWaiting && (
Waiting for file…
)}
{isLoadingContent && !isWaiting && (
Loading content…
)}
{loadError && (
Error: {loadError}
)}
{!isLoadingContent && !loadError && content !== null && (
{content || (empty)}
)}
{isWaiting && content === null && !loadError && (
Waiting for the worker to write the file…
)}
);
}
// ============================================================================
// Progress Artifact Detail
// ============================================================================
interface ProgressDetailProps {
artifactId: number;
onClose: () => void;
}
function ProgressDetail({ artifactId, onClose }: ProgressDetailProps) {
const { data: artifactData, isLoading } = useArtifact(artifactId);
const artifact = artifactData?.data;
const progressEntries = useMemo(() => {
if (!artifact?.data || !Array.isArray(artifact.data)) return [];
return artifact.data as Array>;
}, [artifact]);
const latestEntry =
progressEntries.length > 0
? progressEntries[progressEntries.length - 1]
: null;
const latestPercent =
latestEntry && typeof latestEntry.percent === "number"
? latestEntry.percent
: null;
return (
{artifact?.name ?? "Progress"}
{isLoading && (
Loading progress…
)}
{!isLoading && latestPercent != null && (
{latestEntry?.message
? String(latestEntry.message)
: `${latestPercent}%`}
{latestPercent}%
)}
{!isLoading && progressEntries.length > 0 && (
#
%
Message
Time
{progressEntries.map((entry, idx) => (
{typeof entry.iteration === "number"
? entry.iteration
: idx + 1}
{typeof entry.percent === "number"
? `${entry.percent}%`
: "—"}
{entry.message ? String(entry.message) : "—"}
{entry.timestamp
? formatDistanceToNow(new Date(String(entry.timestamp)), {
addSuffix: true,
})
: "—"}
))}
)}
{!isLoading && progressEntries.length === 0 && (
No progress entries yet.
)}
);
}
// ============================================================================
// Main Panel
// ============================================================================
export default function ExecutionArtifactsPanel({
executionId,
isRunning = false,
defaultCollapsed = false,
}: ExecutionArtifactsPanelProps) {
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const [expandedProgressId, setExpandedProgressId] = useState(
null,
);
const [expandedTextFileId, setExpandedTextFileId] = useState(
null,
);
// Subscribe to real-time artifact notifications for this execution.
// WebSocket-driven cache invalidation replaces most of the polling need,
// but we keep polling as a fallback (staleTime/refetchInterval in the hook).
useArtifactStream({ executionId, enabled: isRunning });
const { data, isLoading, error } = useExecutionArtifacts(
executionId,
isRunning,
);
const artifacts: ArtifactSummary[] = useMemo(() => {
return data?.data ?? [];
}, [data]);
const summary = useMemo(() => {
const total = artifacts.length;
const files = artifacts.filter((a) =>
["file_text", "file_binary", "file_image", "file_datatable"].includes(
a.type,
),
).length;
const progress = artifacts.filter((a) => a.type === "progress").length;
const other = total - files - progress;
return { total, files, progress, other };
}, [artifacts]);
// Don't render anything if there are no artifacts and we're not loading
if (!isLoading && artifacts.length === 0 && !error) {
return null;
}
return (
{/* Header */}
{/* Content */}
{!isCollapsed && (
{isLoading && (
Loading artifacts…
)}
{error && (
Error loading artifacts:{" "}
{error instanceof Error ? error.message : "Unknown error"}
)}
{!isLoading && !error && artifacts.length > 0 && (
{/* Column headers */}
Type
Name
Ref
Size
Created
Actions
{/* Artifact rows */}
{artifacts.map((artifact) => {
const badge = getArtifactTypeBadge(artifact.type);
const isProgress = artifact.type === "progress";
const isTextFile = artifact.type === "file_text";
const isFile = [
"file_text",
"file_binary",
"file_image",
"file_datatable",
].includes(artifact.type);
const isProgressExpanded = expandedProgressId === artifact.id;
const isTextExpanded = expandedTextFileId === artifact.id;
return (
{
if (isProgress) {
setExpandedProgressId(
isProgressExpanded ? null : artifact.id,
);
setExpandedTextFileId(null);
} else if (isTextFile) {
setExpandedTextFileId(
isTextExpanded ? null : artifact.id,
);
setExpandedProgressId(null);
}
}}
>
{/* Type icon */}
{getArtifactTypeIcon(artifact.type)}
{/* Name */}
{artifact.name ?? artifact.ref}
{badge.label}
{/* Ref */}
{artifact.ref}
{/* Size */}
{formatBytes(artifact.size_bytes)}
{/* Created */}
{formatDistanceToNow(new Date(artifact.created), {
addSuffix: true,
})}
{/* Actions */}
e.stopPropagation()}
>
{isProgress && (
)}
{isTextFile && (
)}
{isFile && (
)}
{/* Expanded progress detail */}
{isProgress && isProgressExpanded && (
setExpandedProgressId(null)}
/>
)}
{/* Expanded text file preview */}
{isTextFile && isTextExpanded && (
setExpandedTextFileId(null)}
/>
)}
);
})}
)}
)}
);
}