feat(FN-1100): enable inline workflow step editing in task workflow tab
- Add editable workflow step controls in WorkflowResultsTab, including selection, ordering, removal, and phase badges - Load available enabled workflow steps (plus browser verification) for edit mode and preserve existing results visibility while editing - Wire TaskDetailModal to manage optimistic workflow step updates, persist enabledWorkflowSteps, and surface success/error toasts - Expand WorkflowResultsTab tests to cover edit toggle behavior, API loading, selection changes, reordering/removal, and mixed results+edit rendering
This commit is contained in:
@@ -260,6 +260,7 @@ export function TaskDetailModal({
|
||||
// Workflow results state
|
||||
const [workflowResults, setWorkflowResults] = useState<WorkflowStepResult[]>([]);
|
||||
const [workflowResultsLoading, setWorkflowResultsLoading] = useState(false);
|
||||
const [workflowEnabledSteps, setWorkflowEnabledSteps] = useState<string[]>(task.enabledWorkflowSteps || []);
|
||||
|
||||
// Reset edit state when task changes
|
||||
useEffect(() => {
|
||||
@@ -268,6 +269,10 @@ export function TaskDetailModal({
|
||||
setIsEditing(false);
|
||||
}, [task.id, task.title, task.description]);
|
||||
|
||||
useEffect(() => {
|
||||
setWorkflowEnabledSteps(task.enabledWorkflowSteps || []);
|
||||
}, [task.id, task.enabledWorkflowSteps]);
|
||||
|
||||
// Load merged settings for effective model resolution
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -648,6 +653,20 @@ export function TaskDetailModal({
|
||||
}
|
||||
}, [task.id, addToast]);
|
||||
|
||||
const handleWorkflowStepsChange = useCallback(async (enabledWorkflowSteps: string[]) => {
|
||||
const previousSteps = workflowEnabledSteps;
|
||||
setWorkflowEnabledSteps(enabledWorkflowSteps);
|
||||
|
||||
try {
|
||||
const updatedTask = await updateTask(task.id, { enabledWorkflowSteps }, projectId);
|
||||
addToast("Workflow steps updated", "success");
|
||||
onTaskUpdated?.(updatedTask);
|
||||
} catch (err: any) {
|
||||
setWorkflowEnabledSteps(previousSteps);
|
||||
addToast(`Failed to update workflow steps: ${err.message}`, "error");
|
||||
}
|
||||
}, [task.id, projectId, workflowEnabledSteps, onTaskUpdated, addToast]);
|
||||
|
||||
const handleAddDep = useCallback(async (depId: string) => {
|
||||
const newDeps = [...dependencies, depId];
|
||||
setDependencies(newDeps);
|
||||
@@ -902,7 +921,10 @@ export function TaskDetailModal({
|
||||
taskId={task.id}
|
||||
results={workflowResults}
|
||||
loading={workflowResultsLoading}
|
||||
enabledWorkflowSteps={task.enabledWorkflowSteps}
|
||||
enabledWorkflowSteps={workflowEnabledSteps}
|
||||
canEdit={canEdit}
|
||||
projectId={projectId}
|
||||
onWorkflowStepsChange={handleWorkflowStepsChange}
|
||||
/>
|
||||
</div>
|
||||
) : activeTab === "model" ? (
|
||||
|
||||
@@ -1,9 +1,46 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { WorkflowResultsTab } from "./WorkflowResultsTab";
|
||||
import type { WorkflowStepResult } from "@fusion/core";
|
||||
import { fetchWorkflowSteps } from "../api";
|
||||
import type { WorkflowStep, WorkflowStepResult } from "@fusion/core";
|
||||
|
||||
vi.mock("../api", () => ({
|
||||
fetchWorkflowSteps: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockedFetchWorkflowSteps = vi.mocked(fetchWorkflowSteps);
|
||||
|
||||
describe("WorkflowResultsTab", () => {
|
||||
const mockWorkflowSteps: WorkflowStep[] = [
|
||||
{
|
||||
id: "WS-101",
|
||||
name: "QA Check",
|
||||
description: "Run test suite",
|
||||
mode: "prompt",
|
||||
phase: "pre-merge",
|
||||
prompt: "Run QA checks",
|
||||
enabled: true,
|
||||
createdAt: "2026-04-01T00:00:00Z",
|
||||
updatedAt: "2026-04-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "WS-102",
|
||||
name: "Docs Review",
|
||||
description: "Review docs",
|
||||
mode: "prompt",
|
||||
phase: "post-merge",
|
||||
prompt: "Review docs",
|
||||
enabled: true,
|
||||
createdAt: "2026-04-01T00:00:00Z",
|
||||
updatedAt: "2026-04-01T00:00:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
mockedFetchWorkflowSteps.mockReset();
|
||||
mockedFetchWorkflowSteps.mockResolvedValue(mockWorkflowSteps);
|
||||
});
|
||||
|
||||
const mockResults: WorkflowStepResult[] = [
|
||||
{
|
||||
workflowStepId: "WS-001",
|
||||
@@ -324,4 +361,145 @@ describe("WorkflowResultsTab", () => {
|
||||
expect(screen.getByTestId("workflow-result-output-WS-002")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow step editing", () => {
|
||||
it("shows edit button when canEdit is true", () => {
|
||||
render(<WorkflowResultsTab taskId="FN-001" results={[]} canEdit />);
|
||||
|
||||
expect(screen.getByTestId("workflow-steps-edit-toggle")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show edit button when canEdit is false or undefined", () => {
|
||||
const { rerender } = render(<WorkflowResultsTab taskId="FN-001" results={[]} canEdit={false} />);
|
||||
expect(screen.queryByTestId("workflow-steps-edit-toggle")).not.toBeInTheDocument();
|
||||
|
||||
rerender(<WorkflowResultsTab taskId="FN-001" results={[]} />);
|
||||
expect(screen.queryByTestId("workflow-steps-edit-toggle")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows and hides workflow step checkboxes when edit is toggled", async () => {
|
||||
render(<WorkflowResultsTab taskId="FN-001" results={[]} canEdit />);
|
||||
|
||||
expect(screen.queryByTestId("workflow-steps-editor")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("workflow-steps-edit-toggle"));
|
||||
expect(screen.getByTestId("workflow-steps-editor")).toBeInTheDocument();
|
||||
await screen.findByTestId("workflow-step-checkbox-WS-101");
|
||||
expect(screen.getByTestId("browser-verification-checkbox")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId("workflow-steps-edit-toggle"));
|
||||
expect(screen.queryByTestId("workflow-steps-editor")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onWorkflowStepsChange when checking and unchecking steps", async () => {
|
||||
const onWorkflowStepsChange = vi.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<WorkflowResultsTab
|
||||
taskId="FN-001"
|
||||
results={[]}
|
||||
canEdit
|
||||
enabledWorkflowSteps={[]}
|
||||
onWorkflowStepsChange={onWorkflowStepsChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("workflow-steps-edit-toggle"));
|
||||
const stepCheckbox = (await screen.findByTestId("workflow-step-checkbox-WS-101")).querySelector("input") as HTMLInputElement;
|
||||
fireEvent.click(stepCheckbox);
|
||||
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-101"]);
|
||||
|
||||
onWorkflowStepsChange.mockClear();
|
||||
rerender(
|
||||
<WorkflowResultsTab
|
||||
taskId="FN-001"
|
||||
results={[]}
|
||||
canEdit
|
||||
enabledWorkflowSteps={["WS-101"]}
|
||||
onWorkflowStepsChange={onWorkflowStepsChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const selectedCheckbox = (await screen.findByTestId("workflow-step-checkbox-WS-101")).querySelector("input") as HTMLInputElement;
|
||||
expect(selectedCheckbox.checked).toBe(true);
|
||||
fireEvent.click(selectedCheckbox);
|
||||
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("reorders selected workflow steps with move buttons", async () => {
|
||||
const onWorkflowStepsChange = vi.fn();
|
||||
|
||||
render(
|
||||
<WorkflowResultsTab
|
||||
taskId="FN-001"
|
||||
results={[]}
|
||||
canEdit
|
||||
enabledWorkflowSteps={["WS-101", "WS-102"]}
|
||||
onWorkflowStepsChange={onWorkflowStepsChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("workflow-steps-edit-toggle"));
|
||||
await screen.findByTestId("workflow-step-order");
|
||||
|
||||
fireEvent.click(screen.getByTestId("workflow-step-move-down-WS-101"));
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-102", "WS-101"]);
|
||||
});
|
||||
|
||||
it("removes a selected workflow step from execution order", async () => {
|
||||
const onWorkflowStepsChange = vi.fn();
|
||||
|
||||
render(
|
||||
<WorkflowResultsTab
|
||||
taskId="FN-001"
|
||||
results={[]}
|
||||
canEdit
|
||||
enabledWorkflowSteps={["WS-101", "WS-102"]}
|
||||
onWorkflowStepsChange={onWorkflowStepsChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("workflow-steps-edit-toggle"));
|
||||
await screen.findByTestId("workflow-step-order");
|
||||
|
||||
fireEvent.click(screen.getByTestId("workflow-step-remove-WS-101"));
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-102"]);
|
||||
});
|
||||
|
||||
it("shows both results and edit UI when editing with existing results", async () => {
|
||||
render(
|
||||
<WorkflowResultsTab
|
||||
taskId="FN-001"
|
||||
results={mockResults}
|
||||
canEdit
|
||||
enabledWorkflowSteps={["WS-101"]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("workflow-results-list")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByTestId("workflow-steps-edit-toggle"));
|
||||
|
||||
expect(screen.getByTestId("workflow-results-list")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("workflow-steps-editor")).toBeInTheDocument();
|
||||
await screen.findByTestId("workflow-step-checkbox-WS-101");
|
||||
});
|
||||
|
||||
it("fetches workflow step definitions when canEdit and projectId are provided", async () => {
|
||||
render(
|
||||
<WorkflowResultsTab
|
||||
taskId="FN-001"
|
||||
results={[]}
|
||||
canEdit
|
||||
projectId="proj-123"
|
||||
enabledWorkflowSteps={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedFetchWorkflowSteps).toHaveBeenCalledWith("proj-123");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
import { useState } from "react";
|
||||
import type { WorkflowStepResult } from "@fusion/core";
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { Check, ChevronDown, ChevronUp, Pencil, X } from "lucide-react";
|
||||
import type { WorkflowStep, WorkflowStepResult } from "@fusion/core";
|
||||
import { fetchWorkflowSteps } from "../api";
|
||||
|
||||
interface WorkflowResultsTabProps {
|
||||
taskId: string;
|
||||
results: WorkflowStepResult[];
|
||||
loading?: boolean;
|
||||
enabledWorkflowSteps?: string[];
|
||||
canEdit?: boolean;
|
||||
projectId?: string;
|
||||
onWorkflowStepsChange?: (steps: string[]) => void;
|
||||
}
|
||||
|
||||
interface WorkflowStepOption {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
phase: "pre-merge" | "post-merge";
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
function getStatusColor(status: WorkflowStepResult["status"]): string {
|
||||
@@ -63,141 +76,344 @@ function getOutputPreview(output: string): string {
|
||||
return `${lines.length} lines`;
|
||||
}
|
||||
|
||||
export function WorkflowResultsTab({ taskId, results, loading, enabledWorkflowSteps }: WorkflowResultsTabProps) {
|
||||
function phaseBadge(phase: "pre-merge" | "post-merge", id: string, prefix: string): ReactNode {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "6px",
|
||||
fontSize: "11px",
|
||||
padding: "1px 6px",
|
||||
borderRadius: "4px",
|
||||
background: phase === "post-merge" ? "rgba(139, 92, 246, 0.15)" : "rgba(59, 130, 246, 0.15)",
|
||||
color: phase === "post-merge" ? "#8b5cf6" : "#3b82f6",
|
||||
}}
|
||||
data-testid={`${prefix}-${id}`}
|
||||
>
|
||||
{phase === "post-merge" ? "Post-merge" : "Pre-merge"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkflowResultsTab({
|
||||
taskId,
|
||||
results,
|
||||
loading,
|
||||
enabledWorkflowSteps,
|
||||
canEdit,
|
||||
projectId,
|
||||
onWorkflowStepsChange,
|
||||
}: WorkflowResultsTabProps) {
|
||||
const [expandedOutputs, setExpandedOutputs] = useState<Record<string, boolean>>({});
|
||||
const [allWorkflowSteps, setAllWorkflowSteps] = useState<WorkflowStep[]>([]);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canEdit) {
|
||||
setAllWorkflowSteps([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
fetchWorkflowSteps(projectId)
|
||||
.then((steps) => {
|
||||
if (!cancelled) {
|
||||
setAllWorkflowSteps(steps.filter((step) => step.enabled));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setAllWorkflowSteps([]);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canEdit, projectId]);
|
||||
|
||||
const selectedWorkflowSteps = enabledWorkflowSteps ?? [];
|
||||
|
||||
const workflowStepOptions = useMemo<WorkflowStepOption[]>(() => {
|
||||
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] }));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
const toggleWorkflowStep = 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 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) {
|
||||
const hasConfiguredSteps = selectedWorkflowSteps.length > 0;
|
||||
return (
|
||||
<div className="workflow-results-empty" data-testid="workflow-results-empty">
|
||||
<p>
|
||||
{hasConfiguredSteps
|
||||
? "Workflow steps configured but haven't run yet."
|
||||
: "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-loading" data-testid="workflow-results-loading">
|
||||
<div className="workflow-results-spinner" />
|
||||
<span>Loading workflow results…</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (results.length === 0) {
|
||||
const hasConfiguredSteps = (enabledWorkflowSteps?.length ?? 0) > 0;
|
||||
return (
|
||||
<div className="workflow-results-empty" data-testid="workflow-results-empty">
|
||||
<p>
|
||||
{hasConfiguredSteps
|
||||
? "Workflow steps configured but haven't run yet."
|
||||
: "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>
|
||||
);
|
||||
}
|
||||
|
||||
// Compute summary counts
|
||||
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";
|
||||
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}
|
||||
<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-phase-badge workflow-result-phase-badge--${phase}`}
|
||||
data-testid={`workflow-result-phase-${result.workflowStepId}`}
|
||||
className={`workflow-result-badge workflow-result-badge--${result.status}`}
|
||||
style={{
|
||||
marginLeft: "8px",
|
||||
fontSize: "11px",
|
||||
padding: "1px 6px",
|
||||
borderRadius: "4px",
|
||||
background: phase === "post-merge"
|
||||
? "rgba(139, 92, 246, 0.15)"
|
||||
: "rgba(59, 130, 246, 0.15)",
|
||||
color: phase === "post-merge"
|
||||
? "#8b5cf6"
|
||||
: "#3b82f6",
|
||||
backgroundColor: getStatusColor(result.status),
|
||||
color: result.status === "skipped" ? "var(--text-muted)" : "#fff",
|
||||
}}
|
||||
data-testid={`workflow-result-badge-${result.workflowStepId}`}
|
||||
>
|
||||
{phase === "post-merge" ? "Post-merge" : "Pre-merge"}
|
||||
{getStatusLabel(result.status)}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={`workflow-result-badge workflow-result-badge--${result.status}`}
|
||||
style={{
|
||||
backgroundColor: getStatusColor(result.status),
|
||||
color: result.status === "skipped" ? "var(--text-muted)" : "#fff",
|
||||
}}
|
||||
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>
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<pre
|
||||
className="workflow-result-output"
|
||||
data-testid={`workflow-result-output-${result.workflowStepId}`}
|
||||
>
|
||||
{result.output}
|
||||
</pre>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<pre
|
||||
className="workflow-result-output"
|
||||
data-testid={`workflow-result-output-${result.workflowStepId}`}
|
||||
>
|
||||
{result.output}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const showEditUI = !!canEdit && isEditing;
|
||||
|
||||
return (
|
||||
<div className="workflow-results-tab" data-task-id={taskId}>
|
||||
{canEdit && (
|
||||
<div className="workflow-results-edit-header" data-testid="workflow-results-edit-header">
|
||||
<h4>Workflow Steps</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="modal-edit-btn"
|
||||
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} /> : <Pencil size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!showEditUI || hasResults || loading) && renderResults()}
|
||||
|
||||
{showEditUI && !loading && (
|
||||
<div className="workflow-results-editor" data-testid="workflow-steps-editor">
|
||||
<small style={{ marginBottom: "8px", display: "block" }}>
|
||||
Select steps to run after task implementation completes
|
||||
</small>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
{workflowStepOptions.map((step) => (
|
||||
<label
|
||||
key={step.id}
|
||||
className="checkbox-label"
|
||||
style={{ display: "flex", alignItems: "flex-start", gap: "8px" }}
|
||||
data-testid={step.id === "browser-verification"
|
||||
? "browser-verification-checkbox"
|
||||
: `workflow-step-checkbox-${step.id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedWorkflowSteps.includes(step.id)}
|
||||
onChange={(event) => toggleWorkflowStep(step.id, event.target.checked)}
|
||||
style={{ marginTop: "2px" }}
|
||||
/>
|
||||
<div>
|
||||
<span style={{ fontWeight: 500, fontSize: "13px" }}>
|
||||
{step.name}
|
||||
{phaseBadge(step.phase, step.id, "workflow-step-phase")}
|
||||
</span>
|
||||
<div style={{ fontSize: "12px", color: "var(--text-secondary)", marginTop: "2px" }}>
|
||||
{step.description}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19217,6 +19217,32 @@ html .column.drag-over * {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.workflow-results-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md, 16px);
|
||||
}
|
||||
|
||||
.workflow-results-edit-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm, 8px);
|
||||
}
|
||||
|
||||
.workflow-results-edit-header h4 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: var(--text-primary, #e6edf3);
|
||||
}
|
||||
|
||||
.workflow-results-editor {
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
background: var(--surface-elevated, #161b22);
|
||||
padding: var(--space-md, 16px);
|
||||
}
|
||||
|
||||
.workflow-results-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Reference in New Issue
Block a user