- Stop injecting a hardcoded Browser Verification option in WorkflowResultsTab and rely only on fetched workflow step definitions - Normalize workflow step checkbox test IDs to be id-based for all options, including template-backed Browser Verification steps - Extend WorkflowResultsTab coverage to assert Browser Verification renders exactly once when provided by API data - Increase dashboard Vitest timeout defaults and update the slow route test timeout to reduce flaky failures under concurrent workspace runs
706 lines
25 KiB
TypeScript
706 lines
25 KiB
TypeScript
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";
|
|
|
|
// Markdown rendering components for workflow output
|
|
const markdownComponents: Components = {
|
|
pre: ({ children, className, ...props }) => (
|
|
<pre
|
|
{...props}
|
|
className={["workflow-markdown-pre", className].filter(Boolean).join(" ")}
|
|
>
|
|
{children}
|
|
</pre>
|
|
),
|
|
table: ({ children, className, ...props }) => (
|
|
<table
|
|
{...props}
|
|
className={["workflow-markdown-table", className].filter(Boolean).join(" ")}
|
|
>
|
|
{children}
|
|
</table>
|
|
),
|
|
};
|
|
|
|
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 (
|
|
<span
|
|
className={`phase-badge ${phaseClass}`}
|
|
data-testid={`${prefix}-${id}`}
|
|
>
|
|
{phase === "post-merge" ? "Post-merge" : "Pre-merge"}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<HTMLDivElement>(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 (
|
|
<div className="workflow-live-log" data-testid={`workflow-live-log-${stepId}`}>
|
|
<div className="workflow-live-log-empty">Waiting for agent output…</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="workflow-live-log"
|
|
data-testid={`workflow-live-log-${stepId}`}
|
|
>
|
|
{stepEntries.map((entry, i) => {
|
|
if (entry.type === "tool") {
|
|
return (
|
|
<div key={i} className="workflow-live-log-tool">
|
|
⚡ {entry.text}
|
|
{entry.detail && <span className="workflow-live-log-detail"> — {entry.detail}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
if (entry.type === "tool_result") {
|
|
return (
|
|
<div key={i} className="workflow-live-log-tool-result">
|
|
✓ {entry.text}
|
|
{entry.detail && <span className="workflow-live-log-detail"> — {entry.detail}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
if (entry.type === "tool_error") {
|
|
return (
|
|
<div key={i} className="workflow-live-log-tool-error">
|
|
✗ {entry.text}
|
|
{entry.detail && <span className="workflow-live-log-detail"> — {entry.detail}</span>}
|
|
</div>
|
|
);
|
|
}
|
|
if (entry.type === "thinking") {
|
|
return (
|
|
<div key={i} className="workflow-live-log-thinking">
|
|
{entry.text}
|
|
</div>
|
|
);
|
|
}
|
|
// Default: text entries
|
|
return (
|
|
<span key={i} className="workflow-live-log-text">
|
|
{entry.text}
|
|
</span>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function WorkflowResultsTab({
|
|
taskId,
|
|
results,
|
|
loading,
|
|
enabledWorkflowSteps,
|
|
canEdit,
|
|
projectId,
|
|
isTaskInProgress,
|
|
onWorkflowStepsChange,
|
|
}: WorkflowResultsTabProps) {
|
|
const [expandedOutputs, setExpandedOutputs] = useState<Record<string, boolean>>({});
|
|
const [renderModes, setRenderModes] = useState<Record<string, "markdown" | "plain">>({});
|
|
const [expandedViewStepId, setExpandedViewStepId] = useState<string | null>(null);
|
|
const [allWorkflowSteps, setAllWorkflowSteps] = useState<WorkflowStep[]>([]);
|
|
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<WorkflowStepOption[]>(() => {
|
|
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 (
|
|
<div className="workflow-results-editor" data-testid="workflow-steps-editor">
|
|
<div className="workflow-steps-section">
|
|
<small className="workflow-steps-description">
|
|
Select steps to run after task implementation completes
|
|
</small>
|
|
<div className="workflow-steps-list">
|
|
{workflowStepOptions.map((step) => (
|
|
<label
|
|
key={step.id}
|
|
className="checkbox-label workflow-step-item"
|
|
data-testid={`workflow-step-checkbox-${step.id}`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedWorkflowSteps.includes(step.id)}
|
|
onChange={(event) => toggleStep(step.id, event.target.checked)}
|
|
/>
|
|
<div>
|
|
<span className="workflow-step-name">
|
|
{step.name}
|
|
{phaseBadge(step.phase, step.id, "workflow-step-phase")}
|
|
</span>
|
|
<div className="workflow-step-description">
|
|
{step.description}
|
|
</div>
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{selectedWorkflowSteps.length > 1 && (
|
|
<div className="workflow-step-order" data-testid="workflow-step-order">
|
|
<small className="workflow-step-order-label">Execution order:</small>
|
|
{selectedWorkflowSteps.map((stepId, index) => {
|
|
const stepInfo = workflowStepLookup.get(stepId);
|
|
return (
|
|
<div key={stepId} className="workflow-step-order-item" data-testid={`workflow-step-order-item-${stepId}`}>
|
|
<span className="workflow-step-order-number">{index + 1}</span>
|
|
<span className="workflow-step-order-name">{stepInfo?.name || stepId}</span>
|
|
<div className="workflow-step-order-actions">
|
|
<button
|
|
type="button"
|
|
className="btn btn-icon btn-sm"
|
|
onClick={() => moveWorkflowStepUp(index)}
|
|
disabled={index === 0}
|
|
data-testid={`workflow-step-move-up-${stepId}`}
|
|
title="Move up"
|
|
>
|
|
<ChevronUp size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-icon btn-sm"
|
|
onClick={() => moveWorkflowStepDown(index)}
|
|
disabled={index === selectedWorkflowSteps.length - 1}
|
|
data-testid={`workflow-step-move-down-${stepId}`}
|
|
title="Move down"
|
|
>
|
|
<ChevronDown size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="btn btn-icon btn-sm"
|
|
onClick={() => removeWorkflowStep(stepId)}
|
|
data-testid={`workflow-step-remove-${stepId}`}
|
|
title="Remove"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const renderResults = () => {
|
|
if (loading) {
|
|
return (
|
|
<div className="workflow-results-loading" data-testid="workflow-results-loading">
|
|
<div className="workflow-results-spinner" />
|
|
<span>Loading workflow results…</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!hasResults) {
|
|
return (
|
|
<div className="workflow-results-empty" data-testid="workflow-results-empty">
|
|
<p>No workflow steps configured for this task.</p>
|
|
<p className="workflow-results-empty-hint">
|
|
Pre-merge steps run after implementation, before merge. Post-merge steps run after merge succeeds.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div className="workflow-results-list" data-testid="workflow-results-list">
|
|
<div className="workflow-results-summary-bar" data-testid="workflow-results-summary">
|
|
{summaryParts.join(" · ")}
|
|
</div>
|
|
{results.map((result, index) => {
|
|
const phase = (result.phase || "pre-merge") as "pre-merge" | "post-merge";
|
|
const isExpanded = expandedOutputs[result.workflowStepId] ?? false;
|
|
return (
|
|
<div
|
|
key={`${result.workflowStepId}-${index}`}
|
|
className={`workflow-result-item workflow-result-item--${result.status}`}
|
|
data-testid={`workflow-result-item-${result.workflowStepId}`}
|
|
>
|
|
<div className="workflow-result-header">
|
|
<div className="workflow-result-name">
|
|
{result.workflowStepName}
|
|
{phaseBadge(phase, result.workflowStepId, "workflow-result-phase")}
|
|
</div>
|
|
<span
|
|
className={`workflow-result-badge workflow-result-badge--${result.status}`}
|
|
data-testid={`workflow-result-badge-${result.workflowStepId}`}
|
|
>
|
|
{getStatusLabel(result.status)}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="workflow-result-meta">
|
|
{result.startedAt && (
|
|
<span className="workflow-result-timestamp">Started: {formatTimestamp(result.startedAt)}</span>
|
|
)}
|
|
{result.completedAt && (
|
|
<span className="workflow-result-duration">{formatDuration(result.startedAt, result.completedAt)}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Show live agent logs for pending steps, static output for completed steps */}
|
|
{result.status === "pending" && result.startedAt ? (
|
|
<LiveAgentLogOutput
|
|
entries={liveLogEntries}
|
|
startedAt={result.startedAt}
|
|
stepId={result.workflowStepId}
|
|
/>
|
|
) : result.output ? (
|
|
<div className="workflow-result-output-section">
|
|
<div className="workflow-result-output-header">
|
|
<span className="workflow-result-output-label">Output:</span>
|
|
<button
|
|
className="workflow-result-toggle"
|
|
onClick={() => toggleOutput(result.workflowStepId)}
|
|
data-testid={`workflow-result-toggle-${result.workflowStepId}`}
|
|
>
|
|
{isExpanded ? "Hide output" : "Show output"}
|
|
</button>
|
|
{!isExpanded && (
|
|
<span
|
|
className="workflow-result-output-preview"
|
|
data-testid={`workflow-result-preview-${result.workflowStepId}`}
|
|
>
|
|
{getOutputPreview(result.output)}
|
|
</span>
|
|
)}
|
|
{isExpanded && (
|
|
<>
|
|
<button
|
|
className="workflow-result-mode-toggle"
|
|
onClick={() => toggleRenderMode(result.workflowStepId)}
|
|
data-testid={`workflow-result-mode-toggle-${result.workflowStepId}`}
|
|
title={(renderModes[result.workflowStepId] ?? "markdown") === "markdown" ? "Switch to plain text" : "Switch to markdown"}
|
|
>
|
|
{(renderModes[result.workflowStepId] ?? "markdown") === "markdown" ? "Markdown" : "Plain"}
|
|
</button>
|
|
<button
|
|
className="workflow-result-expand-toggle"
|
|
onClick={() => openExpandedView(result.workflowStepId)}
|
|
data-testid={`workflow-result-expand-${result.workflowStepId}`}
|
|
title="Expand output"
|
|
>
|
|
<Maximize2 size={12} />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
{isExpanded && (
|
|
<div
|
|
className={`workflow-result-output${(renderModes[result.workflowStepId] ?? "markdown") === "markdown" ? " workflow-result-output--markdown" : ""}`}
|
|
data-testid={`workflow-result-output-${result.workflowStepId}`}
|
|
>
|
|
{(renderModes[result.workflowStepId] ?? "markdown") === "markdown" ? (
|
|
<div className="markdown-body">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
|
{result.output}
|
|
</ReactMarkdown>
|
|
</div>
|
|
) : (
|
|
<pre className="workflow-result-output-text">
|
|
{result.output}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const editButton = canEdit ? (
|
|
<button
|
|
type="button"
|
|
className="modal-edit-btn workflow-results-edit-toggle"
|
|
onClick={() => setIsEditing((prev) => !prev)}
|
|
data-testid="workflow-steps-edit-toggle"
|
|
aria-label={isEditing ? "Done editing workflow steps" : "Edit workflow steps"}
|
|
title={isEditing ? "Done" : "Edit"}
|
|
>
|
|
{isEditing ? (
|
|
<>
|
|
<Check size={14} />
|
|
Done
|
|
</>
|
|
) : (
|
|
<>
|
|
<Pencil size={14} />
|
|
Edit
|
|
</>
|
|
)}
|
|
</button>
|
|
) : null;
|
|
|
|
const showConfiguredStepsState = !loading && !hasResults && hasConfiguredSteps;
|
|
const showEditHeaderForResults = canEdit && hasResults;
|
|
|
|
return (
|
|
<div className="workflow-results-tab" data-task-id={taskId}>
|
|
{showConfiguredStepsState ? (
|
|
<div className="workflow-configured-steps" data-testid="workflow-configured-steps">
|
|
<div className="workflow-configured-header" data-testid="workflow-configured-header">
|
|
<div className="workflow-configured-title-row">
|
|
<h4>Configured Workflow Steps</h4>
|
|
<span className="workflow-configured-count" data-testid="workflow-configured-count">
|
|
{configuredSteps.length} step{configuredSteps.length === 1 ? "" : "s"}
|
|
</span>
|
|
</div>
|
|
{editButton}
|
|
</div>
|
|
|
|
<div className="workflow-configured-list" data-testid="workflow-configured-list">
|
|
{configuredSteps.map((step) => (
|
|
<div
|
|
key={step.id}
|
|
className="workflow-configured-item"
|
|
data-testid={`workflow-configured-step-${step.id}`}
|
|
>
|
|
<div className="workflow-configured-name">
|
|
{step.name}
|
|
{phaseBadge(step.phase, step.id, "workflow-configured-phase")}
|
|
</div>
|
|
<p className="workflow-configured-description">{step.description}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<p className="workflow-results-empty-hint">
|
|
Pre-merge steps run after implementation, before merge. Post-merge steps run after merge succeeds.
|
|
</p>
|
|
|
|
{renderEditor()}
|
|
</div>
|
|
) : (
|
|
<>
|
|
{showEditHeaderForResults && (
|
|
<div className="workflow-results-edit-header" data-testid="workflow-results-edit-header">
|
|
<h4>Workflow Steps</h4>
|
|
{editButton}
|
|
</div>
|
|
)}
|
|
{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 (
|
|
<div
|
|
className="workflow-output-modal-overlay"
|
|
onClick={(e) => {
|
|
if (e.target === e.currentTarget) closeExpandedView();
|
|
}}
|
|
data-testid="workflow-output-modal"
|
|
>
|
|
<div className="workflow-output-modal" role="dialog" aria-modal="true">
|
|
<div className="workflow-output-modal-header">
|
|
<div className="workflow-output-modal-title">
|
|
<span className="workflow-output-modal-name">{result.workflowStepName}</span>
|
|
{phaseBadge(phase, result.workflowStepId, "workflow-output-modal-phase")}
|
|
</div>
|
|
<div className="workflow-output-modal-controls">
|
|
<button
|
|
className="workflow-result-mode-toggle"
|
|
onClick={() => toggleRenderMode(result.workflowStepId)}
|
|
data-testid="workflow-output-modal-mode-toggle"
|
|
title={renderMode === "markdown" ? "Switch to plain text" : "Switch to markdown"}
|
|
>
|
|
{renderMode === "markdown" ? "Markdown" : "Plain"}
|
|
</button>
|
|
<button
|
|
className="workflow-output-modal-close"
|
|
onClick={closeExpandedView}
|
|
data-testid="workflow-output-modal-close"
|
|
aria-label="Close"
|
|
>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="workflow-output-modal-body">
|
|
<div
|
|
className={`workflow-result-output workflow-result-output--expanded${renderMode === "markdown" ? " workflow-result-output--markdown" : ""}`}
|
|
data-testid="workflow-output-modal-content"
|
|
>
|
|
{renderMode === "markdown" ? (
|
|
<div className="markdown-body">
|
|
<ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
|
|
{result.output}
|
|
</ReactMarkdown>
|
|
</div>
|
|
) : (
|
|
<pre className="workflow-result-output-text">{result.output}</pre>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
);
|
|
}
|