import "./WorkflowResultsTab.css"; 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"; import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify"; // Markdown rendering components for workflow output const markdownComponents: Components = { p: ({ children, ...props }) =>

{linkifyReactChildren(children)}

, li: ({ children, ...props }) =>
  • {linkifyReactChildren(children)}
  • , code: ({ children, ...props }) => {linkifyReactChildren(children)}, pre: ({ children, className, ...props }) => (
          {linkifyReactChildren(children)}
        
    ), table: ({ children, className, ...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 "advisory_failure": return "Advisory failure"; 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 (stepEntries.length === 0) { return (
    Waiting for agent output…
    ); } return (
    {stepEntries.map((entry, i) => { if (entry.type === "tool") { return (
    ⚡ {linkifyFilePaths(entry.text)} {entry.detail && — {linkifyFilePaths(entry.detail)}}
    ); } if (entry.type === "tool_result") { return (
    ✓ {linkifyFilePaths(entry.text)} {entry.detail && — {linkifyFilePaths(entry.detail)}}
    ); } if (entry.type === "tool_error") { return (
    ✗ {linkifyFilePaths(entry.text)} {entry.detail && — {linkifyFilePaths(entry.detail)}}
    ); } if (entry.type === "thinking") { return (
    {linkifyFilePaths(entry.text)}
    ); } // Default: text entries return ( {linkifyFilePaths(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(() => { return allWorkflowSteps.map((step) => ({ id: step.id, name: step.name, description: step.description, phase: (step.phase || "pre-merge") as "pre-merge" | "post-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 advisoryFailures = results.filter((r) => r.status === "advisory_failure"); 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 (advisoryFailures.length > 0) summaryParts.push(`${advisoryFailures.length} advisory`); if (skipped > 0) summaryParts.push(`${skipped} skipped`); if (pending > 0) summaryParts.push(`${pending} running`); return (
    {summaryParts.join(" · ")}
    {advisoryFailures.length > 0 && (

    Polish notes

    Advisory workflow steps flagged non-blocking improvements:

      {advisoryFailures.map((result, index) => (
    • {result.workflowStepName}: {result.output || "Needs follow-up review."}
    • ))}
    )} {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")}
    {result.verdict && ( {result.verdict} )} {getStatusLabel(result.status)}
    {result.notes && result.status !== "pending" && (
    Notes: {result.notes}
    )}
    {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}
    ) : (
                              {linkifyFilePaths(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}
    ) : (
    {linkifyFilePaths(result.output ?? "")}
    )}
    ); })()}
    ); }