import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { formatDistanceToNow } from "date-fns"; import { ChevronDown, ChevronRight, FileText, FileImage, File, BarChart3, Link as LinkIcon, Table2, Package, Loader2, Download, Eye, X, Radio, } from "lucide-react"; import { useExecutionArtifacts, useArtifact, type ArtifactSummary, type ArtifactType, } from "@/hooks/useArtifacts"; import { useArtifactStream } from "@/hooks/useArtifactStream"; import { OpenAPI } from "@/api/core/OpenAPI"; interface ExecutionArtifactsPanelProps { executionId: number; /** Whether the execution is still running (enables polling) */ isRunning?: boolean; defaultCollapsed?: boolean; } function getArtifactTypeIcon(type: ArtifactType) { switch (type) { case "file_text": return ; case "file_image": return ; case "file_binary": return ; case "file_datatable": return ; case "progress": return ; case "url": return ; case "other": default: return ; } } function getArtifactTypeBadge(type: ArtifactType): { label: string; classes: string; } { switch (type) { case "file_text": return { label: "Text File", classes: "bg-blue-100 text-blue-800" }; case "file_image": return { label: "Image", classes: "bg-purple-100 text-purple-800" }; case "file_binary": return { label: "Binary", classes: "bg-gray-100 text-gray-800" }; case "file_datatable": return { label: "Data Table", classes: "bg-green-100 text-green-800" }; case "progress": return { label: "Progress", classes: "bg-amber-100 text-amber-800" }; case "url": return { label: "URL", classes: "bg-cyan-100 text-cyan-800" }; case "other": default: return { label: "Other", classes: "bg-gray-100 text-gray-700" }; } } function formatBytes(bytes: number | null): string { if (bytes == null || bytes === 0) return "—"; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } /** Download the latest version of an artifact using a fetch with auth token. */ async function downloadArtifact(artifactId: number, artifactRef: string) { const token = localStorage.getItem("access_token"); const url = `${OpenAPI.BASE}/api/v1/artifacts/${artifactId}/download`; const response = await fetch(url, { headers: { Authorization: `Bearer ${token}`, }, }); if (!response.ok) { console.error(`Download failed: ${response.status} ${response.statusText}`); return; } // Extract filename from Content-Disposition header or fall back to ref const disposition = response.headers.get("Content-Disposition"); let filename = artifactRef.replace(/\./g, "_") + ".bin"; if (disposition) { const match = disposition.match(/filename="?([^"]+)"?/); if (match) filename = match[1]; } const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = blobUrl; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(blobUrl); } // ============================================================================ // Text File Artifact Detail // ============================================================================ interface TextFileDetailProps { artifactId: number; artifactName: string | null; isRunning?: boolean; onClose: () => void; } function TextFileDetail({ artifactId, artifactName, isRunning = false, onClose, }: TextFileDetailProps) { const [content, setContent] = useState(null); const [loadError, setLoadError] = useState(null); const [isLoadingContent, setIsLoadingContent] = useState(true); const [isStreaming, setIsStreaming] = useState(false); const [isWaiting, setIsWaiting] = useState(false); const [streamDone, setStreamDone] = useState(false); const preRef = useRef(null); const eventSourceRef = useRef(null); // Track whether the user has scrolled away from the bottom so we can // auto-scroll only when they're already at the end. const userScrolledAwayRef = useRef(false); // Auto-scroll the
 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 && (
{progressEntries.map((entry, idx) => ( ))}
# % Message Time
{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)} />
)}
); })}
)}
)}
); }