import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { Check, ChevronDown, ChevronUp, Maximize2, Pencil, X } from "lucide-react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import type { AgentLogEntry, WorkflowStep, WorkflowStepResult } from "@fusion/core"; import { fetchWorkflowSteps } from "../api"; import { useAgentLogs } from "../hooks/useAgentLogs"; import type { Components } from "react-markdown"; // Markdown rendering components for workflow output const markdownComponents: Components = { pre: ({ children, ...props }) => (
      {children}
    
), table: ({ children, ...props }) => ( {children}
), }; interface WorkflowResultsTabProps { taskId: string; results: WorkflowStepResult[]; loading?: boolean; enabledWorkflowSteps?: string[]; canEdit?: boolean; projectId?: string; isTaskInProgress?: boolean; onWorkflowStepsChange?: (steps: string[]) => void; } interface WorkflowStepOption { id: string; name: string; description: string; phase: "pre-merge" | "post-merge"; icon?: ReactNode; } function getStatusLabel(status: WorkflowStepResult["status"]): string { switch (status) { case "passed": return "Passed"; case "failed": return "Failed"; case "skipped": return "Skipped"; case "pending": return "Running…"; default: return status; } } function formatDuration(startedAt?: string, completedAt?: string): string | null { if (!startedAt || !completedAt) return null; const start = new Date(startedAt).getTime(); const end = new Date(completedAt).getTime(); const durationMs = end - start; if (durationMs < 1000) return `${durationMs}ms`; const seconds = Math.round(durationMs / 1000); if (seconds < 60) return `${seconds}s`; const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; return `${minutes}m ${remainingSeconds}s`; } function formatTimestamp(iso?: string): string | null { if (!iso) return null; const date = new Date(iso); return date.toLocaleString(); } function getOutputPreview(output: string): string { const lines = output.split("\n"); if (lines.length <= 1) return output; return `${lines.length} lines`; } function phaseBadge(phase: "pre-merge" | "post-merge", id: string, prefix: string): ReactNode { const phaseClass = phase === "post-merge" ? "phase-badge--post-merge" : "phase-badge--pre-merge"; return ( {phase === "post-merge" ? "Post-merge" : "Pre-merge"} ); } /** * Renders live agent log output for a running (pending) workflow step. * Filters entries to show only those timestamped on or after the step's startedAt. */ function LiveAgentLogOutput({ entries, startedAt, stepId, }: { entries: AgentLogEntry[]; startedAt: string; stepId: string; }) { const containerRef = useRef(null); const startedAtMs = new Date(startedAt).getTime(); // Filter entries to only show those from this step's time window const stepEntries = entries.filter((entry) => { const entryMs = new Date(entry.timestamp).getTime(); return entryMs >= startedAtMs; }); // Auto-scroll to bottom as new entries arrive useEffect(() => { const container = containerRef.current; if (!container) return; container.scrollTop = container.scrollHeight; }, [stepEntries.length]); if (entries.length === 0) { return (
Waiting for agent output…
); } return (
{stepEntries.map((entry, i) => { if (entry.type === "tool") { return (
⚡ {entry.text} {entry.detail && — {entry.detail}}
); } if (entry.type === "tool_result") { return (
✓ {entry.text} {entry.detail && — {entry.detail}}
); } if (entry.type === "tool_error") { return (
✗ {entry.text} {entry.detail && — {entry.detail}}
); } if (entry.type === "thinking") { return (
{entry.text}
); } // Default: text entries return ( {entry.text} ); })}
); } export function WorkflowResultsTab({ taskId, results, loading, enabledWorkflowSteps, canEdit, projectId, isTaskInProgress, onWorkflowStepsChange, }: WorkflowResultsTabProps) { const [expandedOutputs, setExpandedOutputs] = useState>({}); const [renderModes, setRenderModes] = useState>({}); const [expandedViewStepId, setExpandedViewStepId] = useState(null); const [allWorkflowSteps, setAllWorkflowSteps] = useState([]); const [isEditing, setIsEditing] = useState(false); // Check if any result has pending status const hasPendingStep = results.some((r) => r.status === "pending"); // Subscribe to live agent logs when task is in progress and has a pending step const { entries: liveLogEntries } = useAgentLogs( taskId, !!isTaskInProgress && hasPendingStep, projectId, ); useEffect(() => { let cancelled = false; fetchWorkflowSteps(projectId) .then((steps) => { if (!cancelled) { setAllWorkflowSteps(steps.filter((step) => step.enabled)); } }) .catch(() => { if (!cancelled) { setAllWorkflowSteps([]); } }); return () => { cancelled = true; }; }, [projectId]); const selectedWorkflowSteps = enabledWorkflowSteps ?? []; const workflowStepOptions = useMemo(() => { const fetched = allWorkflowSteps.map((step) => ({ id: step.id, name: step.name, description: step.description, phase: (step.phase || "pre-merge") as "pre-merge" | "post-merge", })); return [ ...fetched, { id: "browser-verification", name: "Browser Verification", description: "Verify web application functionality using browser automation (agent-browser)", phase: "pre-merge", }, ]; }, [allWorkflowSteps]); const workflowStepLookup = useMemo(() => { return new Map(workflowStepOptions.map((step) => [step.id, step])); }, [workflowStepOptions]); const toggleOutput = (stepId: string) => { setExpandedOutputs((prev) => ({ ...prev, [stepId]: !prev[stepId] })); }; const toggleRenderMode = (stepId: string) => { setRenderModes((prev) => { const currentMode = prev[stepId] ?? "markdown"; return { ...prev, [stepId]: currentMode === "markdown" ? "plain" : "markdown" }; }); }; // Expanded view modal handlers const openExpandedView = (stepId: string) => { setExpandedViewStepId(stepId); }; const closeExpandedView = () => { setExpandedViewStepId(null); }; // Escape key handler for closing expanded view useEffect(() => { if (!expandedViewStepId) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { closeExpandedView(); } }; document.addEventListener("keydown", handleKeyDown); return () => { document.removeEventListener("keydown", handleKeyDown); }; }, [expandedViewStepId]); const toggleStep = useCallback((stepId: string, checked: boolean) => { if (!onWorkflowStepsChange) return; if (checked) { if (selectedWorkflowSteps.includes(stepId)) { onWorkflowStepsChange(selectedWorkflowSteps); return; } onWorkflowStepsChange([...selectedWorkflowSteps, stepId]); return; } onWorkflowStepsChange(selectedWorkflowSteps.filter((id) => id !== stepId)); }, [onWorkflowStepsChange, selectedWorkflowSteps]); const moveWorkflowStepUp = useCallback((index: number) => { if (!onWorkflowStepsChange || index <= 0) return; const updated = [...selectedWorkflowSteps]; [updated[index - 1], updated[index]] = [updated[index], updated[index - 1]]; onWorkflowStepsChange(updated); }, [onWorkflowStepsChange, selectedWorkflowSteps]); const moveWorkflowStepDown = useCallback((index: number) => { if (!onWorkflowStepsChange || index >= selectedWorkflowSteps.length - 1) return; const updated = [...selectedWorkflowSteps]; [updated[index], updated[index + 1]] = [updated[index + 1], updated[index]]; onWorkflowStepsChange(updated); }, [onWorkflowStepsChange, selectedWorkflowSteps]); const removeWorkflowStep = useCallback((stepId: string) => { if (!onWorkflowStepsChange) return; onWorkflowStepsChange(selectedWorkflowSteps.filter((id) => id !== stepId)); }, [onWorkflowStepsChange, selectedWorkflowSteps]); const hasResults = results.length > 0; const hasConfiguredSteps = selectedWorkflowSteps.length > 0; useEffect(() => { if (!canEdit) { setIsEditing(false); } }, [canEdit]); const configuredSteps = useMemo(() => { return selectedWorkflowSteps.map((stepId) => { const stepInfo = workflowStepLookup.get(stepId); return { id: stepId, name: stepInfo?.name || stepId, description: stepInfo?.description || "Step definition not found.", phase: stepInfo?.phase || "pre-merge", } as WorkflowStepOption; }); }, [selectedWorkflowSteps, workflowStepLookup]); const renderEditor = () => { if (!canEdit || !isEditing || loading) { return null; } return (
Select steps to run after task implementation completes
{workflowStepOptions.map((step) => ( ))}
{selectedWorkflowSteps.length > 1 && (
Execution order: {selectedWorkflowSteps.map((stepId, index) => { const stepInfo = workflowStepLookup.get(stepId); return (
{index + 1} {stepInfo?.name || stepId}
); })}
)}
); }; const renderResults = () => { if (loading) { return (
Loading workflow results…
); } if (!hasResults) { return (

No workflow steps configured for this task.

Pre-merge steps run after implementation, before merge. Post-merge steps run after merge succeeds.

); } const passed = results.filter((r) => r.status === "passed").length; const failed = results.filter((r) => r.status === "failed").length; const skipped = results.filter((r) => r.status === "skipped").length; const pending = results.filter((r) => r.status === "pending").length; const summaryParts: string[] = [`${results.length} step${results.length !== 1 ? "s" : ""}`]; if (passed > 0) summaryParts.push(`${passed} passed`); if (failed > 0) summaryParts.push(`${failed} failed`); if (skipped > 0) summaryParts.push(`${skipped} skipped`); if (pending > 0) summaryParts.push(`${pending} running`); return (
{summaryParts.join(" · ")}
{results.map((result, index) => { const phase = (result.phase || "pre-merge") as "pre-merge" | "post-merge"; const isExpanded = expandedOutputs[result.workflowStepId] ?? false; return (
{result.workflowStepName} {phaseBadge(phase, result.workflowStepId, "workflow-result-phase")}
{getStatusLabel(result.status)}
{result.startedAt && ( Started: {formatTimestamp(result.startedAt)} )} {result.completedAt && ( {formatDuration(result.startedAt, result.completedAt)} )}
{/* Show live agent logs for pending steps, static output for completed steps */} {result.status === "pending" && result.startedAt ? ( ) : result.output ? (
Output: {!isExpanded && ( {getOutputPreview(result.output)} )} {isExpanded && ( <> )}
{isExpanded && (
{(renderModes[result.workflowStepId] ?? "markdown") === "markdown" ? (
{result.output}
) : (
                          {result.output}
                        
)}
)}
) : null}
); })}
); }; const editButton = canEdit ? ( ) : null; const showConfiguredStepsState = !loading && !hasResults && hasConfiguredSteps; const showEditHeaderForResults = canEdit && hasResults; return (
{showConfiguredStepsState ? (

Configured Workflow Steps

{configuredSteps.length} step{configuredSteps.length === 1 ? "" : "s"}
{editButton}
{configuredSteps.map((step) => (
{step.name} {phaseBadge(step.phase, step.id, "workflow-configured-phase")}

{step.description}

))}

Pre-merge steps run after implementation, before merge. Post-merge steps run after merge succeeds.

{renderEditor()}
) : ( <> {showEditHeaderForResults && (

Workflow Steps

{editButton}
)} {renderResults()} {renderEditor()} )} {/* Expanded Output Modal */} {expandedViewStepId && (() => { const result = results.find((r) => r.workflowStepId === expandedViewStepId); if (!result) return null; const renderMode = renderModes[result.workflowStepId] ?? "markdown"; const phase = (result.phase || "pre-merge") as "pre-merge" | "post-merge"; return (
{ if (e.target === e.currentTarget) closeExpandedView(); }} data-testid="workflow-output-modal" >
{result.workflowStepName} {phaseBadge(phase, result.workflowStepId, "workflow-output-modal-phase")}
{renderMode === "markdown" ? (
{result.output}
) : (
{result.output}
)}
); })()}
); }