FN-6023: enrich workflow tab with overview, graph, and model settings

Add richer workflow context and controls to the task detail workflow tab.

- show workflow overview cards for selected workflow, execution phase, aggregate result, and step progress
- add a collapsible workflow graph preview plus edit-workflow affordance wired through the task detail modal
- expose effective model settings and thinking level, update docs, and cover the new UI in WorkflowResultsTab tests

Files changed:
 docs/dashboard-guide.md                            |   2 +-
 packages/dashboard/app/components/AppModals.tsx    |   1 +
 .../dashboard/app/components/TaskDetailModal.tsx   |   5 +
 .../app/components/WorkflowResultsTab.css          | 113 +++++++++
 .../app/components/WorkflowResultsTab.tsx          | 281 ++++++++++++++++++++-
 .../__tests__/WorkflowResultsTab.test.tsx          | 209 ++++++++++++++-
 6 files changed, 592 insertions(+), 19 deletions(-)

Fusion-Task-Id: FN-6023

Fusion-Task-Lineage: 7d4b46d2-a3ad-4eb2-96ed-b6454943036c
This commit is contained in:
gsxdsm
2026-06-08 09:59:55 -07:00
parent 60eb2ec995
commit a1411b1e0b
6 changed files with 592 additions and 19 deletions

View File

@@ -203,6 +203,7 @@ export function AppModals({
onTaskUpdated={modalManager.updateDetailTask}
addToast={addToast}
prAuthAvailable={settings.prAuthAvailable}
onOpenWorkflowEditor={() => modalManager.openWorkflowEditor()}
initialTab={modalManager.detailTaskInitialTab}
/>
</ModalErrorBoundary>

View File

@@ -368,6 +368,7 @@ export interface TaskDetailModalProps {
onTaskUpdated?: (task: Task) => void;
addToast: (message: string, type?: ToastType) => void;
prAuthAvailable?: boolean;
onOpenWorkflowEditor?: () => void;
/** Open the modal with this tab active instead of "definition" */
initialTab?: TabId;
/** Mobile-only header affordance mode. */
@@ -548,6 +549,7 @@ export function TaskDetailContent({
onTaskUpdated,
addToast,
prAuthAvailable,
onOpenWorkflowEditor,
initialTab = "definition",
mobileHeaderMode = "close",
embedded = false,
@@ -3038,6 +3040,7 @@ export function TaskDetailContent({
<div className="detail-section">
<WorkflowResultsTab
taskId={task.id}
task={task}
results={workflowResults}
loading={workflowResultsLoading}
enabledWorkflowSteps={workflowEnabledSteps}
@@ -3055,6 +3058,8 @@ export function TaskDetailContent({
onWorkflowReconciled={handleWorkflowReconciled}
taskStatus={task.status}
taskPausedReason={task.pausedReason}
settings={settings}
onEditWorkflow={onOpenWorkflowEditor}
/>
</div>
) : activeTab === "model" ? (

View File

@@ -135,6 +135,105 @@
gap: var(--space-md);
}
.workflow-state-summary,
.workflow-disclosure,
.workflow-management {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.workflow-state-summary__header,
.workflow-management__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
flex-wrap: wrap;
}
.workflow-state-summary__header h4,
.workflow-management__header h4 {
margin: 0;
color: var(--text);
}
.workflow-state-summary__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-md);
}
.workflow-state-summary__item {
display: flex;
flex-direction: column;
gap: var(--space-xs);
min-width: 0;
}
.workflow-state-summary__label {
color: var(--text-muted);
font-size: calc(var(--space-sm) + var(--space-xs));
}
.workflow-state-summary__value,
.workflow-model-value {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
min-width: 0;
color: var(--text);
font-weight: 600;
overflow-wrap: anywhere;
}
.workflow-disclosure__toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
width: 100%;
padding: 0;
border: 0;
background: transparent;
color: var(--text);
font: inherit;
cursor: pointer;
text-align: left;
}
.workflow-disclosure__title {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
font-weight: 600;
}
.workflow-disclosure__content {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.workflow-disclosure__empty {
margin: 0;
color: var(--text-muted);
}
.workflow-graph-preview {
height: calc(var(--space-xl) * 8);
min-height: 0;
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
}
.workflow-graph-preview .react-flow__renderer,
.workflow-graph-preview .react-flow__container,
.workflow-graph-preview .react-flow__viewport {
background: var(--surface);
}
.workflow-results-edit-header {
display: flex;
align-items: center;
@@ -685,6 +784,20 @@
}
@media (max-width: 768px) {
.workflow-state-summary__grid {
grid-template-columns: minmax(0, 1fr);
}
.workflow-management__header,
.workflow-state-summary__header {
align-items: flex-start;
}
.workflow-management__header .btn {
width: 100%;
justify-content: center;
}
/* Workflow results edit toggle: touch target on mobile */
.workflow-results-edit-toggle {
min-width: calc(var(--space-lg) * 2 + var(--space-xs));

View File

@@ -1,14 +1,19 @@
import "@xyflow/react/dist/style.css";
import "./WorkflowResultsTab.css";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronDown, ChevronUp, Maximize2, Pencil, X } from "lucide-react";
import { Check, ChevronDown, ChevronRight, 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 { getErrorMessage } from "@fusion/core";
import { fetchWorkflowSteps, fetchTaskWorkflow, selectTaskWorkflow, submitTaskWorkflowInput, approveTaskWorkflowCli } from "../api";
import { ReactFlow, ReactFlowProvider } from "@xyflow/react";
import type { AgentLogEntry, Settings, Task, TaskDetail, WorkflowDefinition, WorkflowStep, WorkflowStepResult } from "@fusion/core";
import { getErrorMessage, resolveTaskExecutionModel, resolveTaskPlanningModel, resolveTaskValidatorModel } from "@fusion/core";
import { approveTaskWorkflowCli, fetchWorkflow, fetchWorkflows, fetchWorkflowSteps, fetchTaskWorkflow, selectTaskWorkflow, submitTaskWorkflowInput } from "../api";
import { WorkflowSelector } from "./WorkflowSelector";
import { useAgentLogs } from "../hooks/useAgentLogs";
import { ProviderIcon } from "./ProviderIcon";
import { irToFlow } from "./workflow-flow-mapping";
import { workflowNodeTypes } from "./nodes/WorkflowNodeTypes";
import type { Components } from "react-markdown";
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
@@ -37,6 +42,7 @@ const markdownComponents: Components = {
interface WorkflowResultsTabProps {
taskId: string;
task?: Task | TaskDetail;
results: WorkflowStepResult[];
loading?: boolean;
enabledWorkflowSteps?: string[];
@@ -46,6 +52,8 @@ interface WorkflowResultsTabProps {
onWorkflowStepsChange?: (steps: string[]) => void;
taskStatus?: string;
taskPausedReason?: string;
settings?: Settings;
onEditWorkflow?: () => void;
/** U5 (R20): called after a workflow switch re-homed the card to a new column
* (reconciliation present and not preserved) so the board can refresh before
* the SSE catch-up arrives. */
@@ -132,6 +140,77 @@ function phaseBadge(phase: "pre-merge" | "post-merge", id: string, prefix: strin
);
}
function getWorkflowName(
selectedWorkflowId: string | null,
workflows: WorkflowDefinition[],
t: ReturnType<typeof useTranslation>["t"],
): string {
if (!selectedWorkflowId) return t("workflow.defaultWorkflow", "Default");
const match = workflows.find((workflow) => workflow.id === selectedWorkflowId);
return match?.name || t("workflow.customWorkflowFallback", "Custom workflow");
}
function getAggregateWorkflowResult(
results: WorkflowStepResult[],
t: ReturnType<typeof useTranslation>["t"],
): { label: string; badgeClass: string; testId: string } {
if (results.some((result) => result.status === "failed")) {
return { label: t("workflow.statusFailed", "Failed"), badgeClass: "workflow-result-badge--failed", testId: "failed" };
}
if (results.some((result) => result.status === "advisory_failure")) {
return { label: t("workflow.statusAdvisory", "Advisory"), badgeClass: "workflow-result-badge--advisory_failure", testId: "advisory" };
}
if (results.some((result) => result.status === "pending")) {
return { label: t("workflow.aggregateInProgress", "In progress"), badgeClass: "workflow-result-badge--pending", testId: "pending" };
}
if (results.length === 0) {
return { label: t("workflow.aggregateNoResults", "No results"), badgeClass: "workflow-result-badge--skipped", testId: "no-results" };
}
return { label: t("workflow.aggregateAllPassed", "All passed"), badgeClass: "workflow-result-badge--passed", testId: "passed" };
}
function getExecutionPhase(
task: Task | TaskDetail | undefined,
taskStatus: string | undefined,
taskPausedReason: string | undefined,
results: WorkflowStepResult[],
t: ReturnType<typeof useTranslation>["t"],
): { label: string; badgeClass: string; testId: string } {
if (taskStatus === "awaiting-user-input") {
return { label: t("workflow.executionAwaitingInput", "Awaiting input"), badgeClass: "workflow-result-badge--pending", testId: "awaiting-input" };
}
if (taskStatus === "awaiting-cli-approval") {
return { label: t("workflow.executionAwaitingCliApproval", "Awaiting CLI approval"), badgeClass: "workflow-result-badge--pending", testId: "awaiting-cli-approval" };
}
if (taskStatus === "paused" || taskPausedReason) {
return { label: t("workflow.executionPaused", "Paused"), badgeClass: "workflow-result-badge--pending", testId: "paused" };
}
const pendingResult = results.find((result) => result.status === "pending");
if (pendingResult) {
const isPostMerge = (pendingResult.phase || "pre-merge") === "post-merge";
return {
label: isPostMerge
? t("workflow.executionPostMerge", "Post-merge steps running")
: t("workflow.executionPreMerge", "Pre-merge steps running"),
badgeClass: "workflow-result-badge--pending",
testId: isPostMerge ? "post-merge" : "pre-merge",
};
}
const hasTerminalResults = results.length > 0 && results.every((result) => ["passed", "failed", "advisory_failure", "skipped"].includes(result.status));
if (hasTerminalResults || taskStatus === "done" || task?.column === "done" || task?.column === "in-review") {
return { label: t("workflow.executionCompleted", "Completed"), badgeClass: "workflow-result-badge--passed", testId: "completed" };
}
return { label: t("workflow.executionNotStarted", "Not started"), badgeClass: "workflow-result-badge--pending", testId: "not-started" };
}
function formatModelValue(selection: { provider?: string; modelId?: string } | undefined): string {
if (!selection?.provider || !selection.modelId) return "Default";
return `${selection.provider}/${selection.modelId}`;
}
/**
* 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.
@@ -222,6 +301,7 @@ function LiveAgentLogOutput({
export function WorkflowResultsTab({
taskId,
task,
results,
loading,
enabledWorkflowSteps,
@@ -231,6 +311,8 @@ export function WorkflowResultsTab({
onWorkflowStepsChange,
taskStatus,
taskPausedReason,
settings,
onEditWorkflow,
onWorkflowReconciled,
}: WorkflowResultsTabProps) {
const { t } = useTranslation("app");
@@ -241,9 +323,14 @@ export function WorkflowResultsTab({
const [submitted, setSubmitted] = useState(false);
const [expandedViewStepId, setExpandedViewStepId] = useState<string | null>(null);
const [allWorkflowSteps, setAllWorkflowSteps] = useState<WorkflowStep[]>([]);
const [workflowDefinitions, setWorkflowDefinitions] = useState<WorkflowDefinition[]>([]);
const [isEditing, setIsEditing] = useState(false);
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
const [resumeError, setResumeError] = useState<string | null>(null);
const [graphExpanded, setGraphExpanded] = useState(false);
const [workflowGraphCache, setWorkflowGraphCache] = useState<Record<string, WorkflowDefinition>>({});
const [workflowGraphLoading, setWorkflowGraphLoading] = useState(false);
const [modelSettingsExpanded, setModelSettingsExpanded] = useState(false);
// Reset the paused-action UI whenever the blocked node/task changes, so a new
// awaiting-user-input / awaiting-cli-approval pause starts with fresh controls
@@ -284,6 +371,41 @@ export function WorkflowResultsTab({
[taskId, projectId, onWorkflowStepsChange, onWorkflowReconciled],
);
useEffect(() => {
let cancelled = false;
fetchWorkflows(projectId)
.then((definitions) => {
if (!cancelled) setWorkflowDefinitions(definitions);
})
.catch(() => {
if (!cancelled) setWorkflowDefinitions([]);
});
return () => {
cancelled = true;
};
}, [projectId]);
useEffect(() => {
if (!graphExpanded || !selectedWorkflowId || workflowGraphCache[selectedWorkflowId]) return;
let cancelled = false;
setWorkflowGraphLoading(true);
fetchWorkflow(selectedWorkflowId, projectId)
.then((definition) => {
if (!cancelled) {
setWorkflowGraphCache((prev) => ({ ...prev, [selectedWorkflowId]: definition }));
}
})
.catch(() => {
/* graph preview is optional; leave empty state */
})
.finally(() => {
if (!cancelled) setWorkflowGraphLoading(false);
});
return () => {
cancelled = true;
};
}, [graphExpanded, selectedWorkflowId, projectId, workflowGraphCache]);
// Check if any result has pending status
const hasPendingStep = results.some((r) => r.status === "pending");
@@ -419,6 +541,16 @@ export function WorkflowResultsTab({
});
}, [selectedWorkflowSteps, workflowStepLookup, t]);
const workflowName = useMemo(() => getWorkflowName(selectedWorkflowId, workflowDefinitions, t), [selectedWorkflowId, workflowDefinitions, t]);
const executionPhase = useMemo(() => getExecutionPhase(task, taskStatus, taskPausedReason, results, t), [task, taskStatus, taskPausedReason, results, t]);
const aggregateResult = useMemo(() => getAggregateWorkflowResult(results, t), [results, t]);
const completedStepCount = useMemo(() => results.filter((result) => ["passed", "skipped", "failed", "advisory_failure"].includes(result.status)).length, [results]);
const graphWorkflow = selectedWorkflowId ? workflowGraphCache[selectedWorkflowId] : undefined;
const graphFlow = useMemo(() => (graphWorkflow ? irToFlow(graphWorkflow) : null), [graphWorkflow]);
const effectiveExecutor = useMemo(() => (task ? resolveTaskExecutionModel(task, settings) : undefined), [task, settings]);
const effectiveValidator = useMemo(() => (task ? resolveTaskValidatorModel(task, settings) : undefined), [task, settings]);
const effectivePlanning = useMemo(() => (task ? resolveTaskPlanningModel(task, settings) : undefined), [task, settings]);
const renderEditor = () => {
if (!canEdit || !isEditing || loading) {
return null;
@@ -803,16 +935,139 @@ export function WorkflowResultsTab({
)}
</div>
)}
{canEdit && onWorkflowStepsChange && (
<div className="workflow-selector-row">
<WorkflowSelector
value={selectedWorkflowId}
onChange={handleWorkflowSelect}
projectId={projectId}
label="Custom workflow"
/>
<section className="card workflow-state-summary" data-testid="workflow-state-summary">
<div className="workflow-state-summary__header">
<h4>{t("workflow.overview", "Workflow overview")}</h4>
</div>
)}
<div className="workflow-state-summary__grid">
<div className="workflow-state-summary__item" data-testid="workflow-state-summary-name">
<span className="workflow-state-summary__label">{t("workflow.workflowName", "Workflow")}</span>
<span className="workflow-state-summary__value">{workflowName}</span>
</div>
<div className="workflow-state-summary__item" data-testid="workflow-state-summary-phase">
<span className="workflow-state-summary__label">{t("workflow.executionPhase", "Execution phase")}</span>
<span className={`workflow-result-badge ${executionPhase.badgeClass}`} data-testid={`workflow-phase-badge-${executionPhase.testId}`}>
{executionPhase.label}
</span>
</div>
<div className="workflow-state-summary__item" data-testid="workflow-state-summary-aggregate">
<span className="workflow-state-summary__label">{t("workflow.aggregateResult", "Aggregate result")}</span>
<span className={`workflow-result-badge ${aggregateResult.badgeClass}`} data-testid={`workflow-aggregate-badge-${aggregateResult.testId}`}>
{aggregateResult.label}
</span>
</div>
<div className="workflow-state-summary__item" data-testid="workflow-state-summary-count">
<span className="workflow-state-summary__label">{t("workflow.stepProgress", "Step count")}</span>
<span className="workflow-state-summary__value">{t("workflow.stepProgressValue", "{{completed}} of {{total}} steps completed", { completed: completedStepCount, total: results.length })}</span>
</div>
</div>
</section>
<section className="card workflow-disclosure" data-testid="workflow-graph-section">
<button
type="button"
className="workflow-disclosure__toggle"
onClick={() => setGraphExpanded((prev) => !prev)}
data-testid="workflow-graph-toggle"
>
<span className="workflow-disclosure__title">
{graphExpanded ? <ChevronDown aria-hidden /> : <ChevronRight aria-hidden />}
{t("workflow.graph", "Workflow graph")}
</span>
</button>
{graphExpanded && (
<div className="workflow-disclosure__content">
{!selectedWorkflowId ? (
<p className="workflow-disclosure__empty" data-testid="workflow-graph-empty">
{t("workflow.noWorkflowAssigned", "No workflow assigned")}
</p>
) : workflowGraphLoading && !graphWorkflow ? (
<div className="workflow-results-loading" data-testid="workflow-graph-loading">
<div className="workflow-results-spinner" />
<span>{t("workflow.loadingGraph", "Loading workflow graph…")}</span>
</div>
) : graphFlow ? (
<div className="workflow-graph-preview" data-testid="workflow-graph-preview">
<ReactFlowProvider>
<ReactFlow
nodes={graphFlow.nodes}
edges={graphFlow.edges}
nodeTypes={workflowNodeTypes}
fitView
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={false}
zoomOnScroll={false}
panOnDrag={false}
preventScrolling={false}
attributionPosition="bottom-left"
/>
</ReactFlowProvider>
</div>
) : (
<p className="workflow-disclosure__empty" data-testid="workflow-graph-unavailable">
{t("workflow.graphUnavailable", "Workflow graph unavailable")}
</p>
)}
</div>
)}
</section>
<section className="card workflow-management" data-testid="workflow-management-section">
<div className="workflow-management__header">
<h4>{t("workflow.workflowName", "Workflow")}</h4>
{canEdit && selectedWorkflowId && onEditWorkflow && (
<button
type="button"
className="btn btn-sm"
onClick={onEditWorkflow}
data-testid="workflow-edit-button"
>
<Pencil aria-hidden />
{t("workflow.editWorkflow", "Edit workflow")}
</button>
)}
</div>
<WorkflowSelector
value={selectedWorkflowId}
onChange={handleWorkflowSelect}
projectId={projectId}
label="Custom workflow"
disabled={!canEdit}
/>
</section>
<section className="card workflow-disclosure" data-testid="workflow-model-settings-section">
<button
type="button"
className="workflow-disclosure__toggle"
onClick={() => setModelSettingsExpanded((prev) => !prev)}
data-testid="workflow-model-settings-toggle"
>
<span className="workflow-disclosure__title">
{modelSettingsExpanded ? <ChevronDown aria-hidden /> : <ChevronRight aria-hidden />}
{t("workflow.modelSettings", "Model settings")}
</span>
</button>
{modelSettingsExpanded && (
<div className="workflow-disclosure__content workflow-state-summary__grid" data-testid="workflow-model-settings-content">
{[
{ key: "executor", label: t("models.targetLabels.executor", "Executor"), value: formatModelValue(effectiveExecutor), provider: effectiveExecutor?.provider },
{ key: "reviewer", label: t("models.targetLabels.validator", "Reviewer"), value: formatModelValue(effectiveValidator), provider: effectiveValidator?.provider },
{ key: "planning", label: t("models.targetLabels.planning", "Planning"), value: formatModelValue(effectivePlanning), provider: effectivePlanning?.provider },
{ key: "thinking", label: t("workflow.thinkingLevel", "Thinking level"), value: task?.thinkingLevel || "Default" },
].map((item) => (
<div className="workflow-state-summary__item" key={item.key} data-testid={`workflow-model-setting-${item.key}`}>
<span className="workflow-state-summary__label">{item.label}</span>
<span className="workflow-state-summary__value workflow-model-value">
{item.provider ? <ProviderIcon provider={item.provider} size="sm" /> : null}
{item.value}
</span>
</div>
))}
</div>
)}
</section>
{showConfiguredStepsState ? (
<div className="workflow-configured-steps" data-testid="workflow-configured-steps">
<div className="workflow-configured-header" data-testid="workflow-configured-header">

View File

@@ -1,25 +1,37 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import { WorkflowResultsTab } from "../WorkflowResultsTab";
import { fetchWorkflowSteps } from "../../api";
import { fetchWorkflow, fetchWorkflows, fetchWorkflowSteps } from "../../api";
import { useAgentLogs } from "../../hooks/useAgentLogs";
import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture";
import type { AgentLogEntry, WorkflowStep, WorkflowStepResult } from "@fusion/core";
import type { AgentLogEntry, Settings, Task, WorkflowDefinition, WorkflowStep, WorkflowStepResult } from "@fusion/core";
vi.mock("../../api", () => ({
fetchWorkflowSteps: vi.fn(),
fetchTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null }),
selectTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null, enabledWorkflowSteps: [] }),
fetchTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: "WF-001" }),
selectTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: "WF-001", enabledWorkflowSteps: [] }),
fetchWorkflows: vi.fn().mockResolvedValue([]),
fetchWorkflow: vi.fn(),
submitTaskWorkflowInput: vi.fn().mockResolvedValue({ ok: true }),
approveTaskWorkflowCli: vi.fn().mockResolvedValue({ approved: "ok" }),
}));
vi.mock("@xyflow/react", () => ({
ReactFlow: ({ nodes = [], edges = [] }: { nodes?: unknown[]; edges?: unknown[] }) => (
<div data-testid="react-flow-mock">nodes:{nodes.length};edges:{edges.length}</div>
),
ReactFlowProvider: ({ children }: { children: unknown }) => <>{children}</>,
Handle: () => <span data-testid="react-flow-handle" />,
Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" },
}));
vi.mock("../../hooks/useAgentLogs", () => ({
useAgentLogs: vi.fn(),
}));
const mockedFetchWorkflowSteps = vi.mocked(fetchWorkflowSteps);
const mockedFetchWorkflow = vi.mocked(fetchWorkflow);
const mockedFetchWorkflows = vi.mocked(fetchWorkflows);
const mockedUseAgentLogs = vi.mocked(useAgentLogs);
describe("WorkflowResultsTab", () => {
@@ -60,9 +72,67 @@ describe("WorkflowResultsTab", () => {
},
];
const selectedWorkflow: WorkflowDefinition = {
id: "WF-001",
name: "Custom Delivery Workflow",
description: "Custom workflow",
ir: {
version: 1,
nodes: [
{ id: "start", kind: "start", config: {} },
{ id: "prompt-1", kind: "prompt", config: { name: "Run checks" } },
{ id: "end", kind: "end", config: {} },
],
edges: [
{ from: "start", to: "prompt-1" },
{ from: "prompt-1", to: "end" },
],
},
} as WorkflowDefinition;
const baseTask: Task = {
id: "FN-001",
title: "Task",
status: "todo",
column: "todo",
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-01T00:00:00Z",
modelProvider: "openai",
modelId: "gpt-4.1",
validatorModelProvider: "anthropic",
validatorModelId: "claude-3-7-sonnet",
planningModelProvider: "google",
planningModelId: "gemini-2.5-pro",
thinkingLevel: "high",
dependencies: [],
outputBranch: null,
prompt: "",
baseBranch: null,
assignee: null,
labels: [],
priority: "normal",
autoMerge: false,
autoMergeMode: "squash",
paused: false,
userPaused: false,
} as Task;
const mockSettings: Settings = {
modelProvider: "openai",
model: "gpt-4.1-mini",
validatorModelProvider: "anthropic",
validatorModel: "claude-3-5-haiku",
planningModelProvider: "google",
planningModel: "gemini-2.5-flash",
} as Settings;
beforeEach(() => {
mockedFetchWorkflowSteps.mockReset();
mockedFetchWorkflowSteps.mockResolvedValue(mockWorkflowSteps);
mockedFetchWorkflow.mockReset();
mockedFetchWorkflow.mockResolvedValue(selectedWorkflow);
mockedFetchWorkflows.mockReset();
mockedFetchWorkflows.mockResolvedValue([selectedWorkflow]);
mockedUseAgentLogs.mockReset();
mockedUseAgentLogs.mockReturnValue({
entries: [],
@@ -115,7 +185,7 @@ describe("WorkflowResultsTab", () => {
];
it("renders list of workflow step results", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} task={baseTask} settings={mockSettings} />);
expect(screen.getByTestId("workflow-results-list")).toBeInTheDocument();
expect(screen.getByText("QA Check")).toBeInTheDocument();
@@ -124,6 +194,128 @@ describe("WorkflowResultsTab", () => {
expect(screen.getByText("Performance Check")).toBeInTheDocument();
});
it("renders workflow state summary with workflow name and aggregate result", async () => {
render(<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} />);
await waitFor(() => expect(screen.getByTestId("workflow-state-summary-name")).toHaveTextContent("Custom Delivery Workflow"));
expect(screen.getByTestId("workflow-aggregate-badge-failed")).toHaveTextContent("Failed");
expect(screen.getByTestId("workflow-state-summary-count")).toHaveTextContent("3 of 4 steps completed");
});
it.each([
{ name: "not started", task: { ...baseTask, status: "todo", column: "todo" } as Task, results: [] as WorkflowStepResult[], testId: "workflow-phase-badge-not-started", text: "Not started" },
{ name: "in progress", task: { ...baseTask, status: "in-progress", column: "in-progress" } as Task, results: [{ workflowStepId: "WS-004", workflowStepName: "Performance Check", phase: "pre-merge", status: "pending" }] as WorkflowStepResult[], testId: "workflow-phase-badge-pre-merge", text: "Pre-merge steps running" },
{ name: "paused", task: { ...baseTask, status: "paused", column: "in-progress" } as Task, results: [] as WorkflowStepResult[], testId: "workflow-phase-badge-paused", text: "Paused" },
{ name: "completed", task: { ...baseTask, status: "done", column: "done" } as Task, results: [{ workflowStepId: "WS-001", workflowStepName: "QA Check", phase: "pre-merge", status: "passed" }] as WorkflowStepResult[], testId: "workflow-phase-badge-completed", text: "Completed" },
])("shows correct workflow phase for $name", async ({ task, results, testId, text }) => {
render(<WorkflowResultsTab taskId="FN-001" task={task} settings={mockSettings} results={results} taskStatus={task.status} />);
await waitFor(() => expect(screen.getByTestId(testId)).toHaveTextContent(text));
});
it("uses failed aggregate priority over advisory", async () => {
render(
<WorkflowResultsTab
taskId="FN-001"
task={baseTask}
settings={mockSettings}
results={[
{ workflowStepId: "WS-001", workflowStepName: "QA Check", phase: "pre-merge", status: "advisory_failure" },
{ workflowStepId: "WS-002", workflowStepName: "Security Audit", phase: "pre-merge", status: "failed" },
]}
/>,
);
await waitFor(() => expect(screen.getByTestId("workflow-aggregate-badge-failed")).toBeInTheDocument());
expect(screen.queryByTestId("workflow-aggregate-badge-advisory")).not.toBeInTheDocument();
});
it("keeps the graph collapsed by default and lazily fetches on expand", async () => {
render(<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} />);
expect(screen.queryByTestId("react-flow-mock")).not.toBeInTheDocument();
expect(mockedFetchWorkflow).not.toHaveBeenCalled();
fireEvent.click(screen.getByTestId("workflow-graph-toggle"));
await waitFor(() => expect(mockedFetchWorkflow).toHaveBeenCalledWith("WF-001", undefined));
expect(await screen.findByTestId("react-flow-mock")).toBeInTheDocument();
});
it("shows no workflow assigned when none is selected", async () => {
mockedFetchWorkflows.mockResolvedValueOnce([]);
const { fetchTaskWorkflow } = await import("../../api");
vi.mocked(fetchTaskWorkflow).mockResolvedValueOnce({ workflowId: null });
render(<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} />);
fireEvent.click(screen.getByTestId("workflow-graph-toggle"));
expect(await screen.findByTestId("workflow-graph-empty")).toHaveTextContent("No workflow assigned");
});
it("shows edit workflow affordance only when editable and workflow selected", async () => {
const onEditWorkflow = vi.fn();
const { rerender } = render(
<WorkflowResultsTab
taskId="FN-001"
task={baseTask}
settings={mockSettings}
results={mockResults}
canEdit={false}
onWorkflowStepsChange={vi.fn()}
onEditWorkflow={onEditWorkflow}
/>,
);
expect(screen.queryByTestId("workflow-edit-button")).not.toBeInTheDocument();
rerender(
<WorkflowResultsTab
taskId="FN-001"
task={baseTask}
settings={mockSettings}
results={mockResults}
canEdit
onWorkflowStepsChange={vi.fn()}
onEditWorkflow={onEditWorkflow}
/>,
);
const button = await screen.findByTestId("workflow-edit-button");
fireEvent.click(button);
expect(onEditWorkflow).toHaveBeenCalledTimes(1);
});
it("shows effective model settings and default fallbacks", async () => {
const { rerender } = render(
<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} />,
);
await screen.findByTestId("workflow-state-summary-name");
fireEvent.click(screen.getByTestId("workflow-model-settings-toggle"));
await waitFor(() => expect(screen.getByTestId("workflow-model-setting-executor")).toHaveTextContent("openai/gpt-4.1"));
expect(screen.getByTestId("workflow-model-setting-reviewer")).toHaveTextContent("anthropic/claude-3-7-sonnet");
expect(screen.getByTestId("workflow-model-setting-planning")).toHaveTextContent("google/gemini-2.5-pro");
expect(screen.getByTestId("workflow-model-setting-thinking")).toHaveTextContent("high");
rerender(
<WorkflowResultsTab
taskId="FN-001"
task={{ ...baseTask, modelProvider: null, modelId: null, validatorModelProvider: null, validatorModelId: null, planningModelProvider: null, planningModelId: null, thinkingLevel: null } as Task}
settings={undefined}
results={mockResults}
/>,
);
await screen.findByTestId("workflow-state-summary-name");
if (!screen.queryByTestId("workflow-model-setting-executor")) {
fireEvent.click(screen.getByTestId("workflow-model-settings-toggle"));
}
await waitFor(() => expect(screen.getByTestId("workflow-model-setting-executor")).toHaveTextContent("Default"));
expect(screen.getByTestId("workflow-model-setting-reviewer")).toHaveTextContent("Default");
expect(screen.getByTestId("workflow-model-setting-planning")).toHaveTextContent("Default");
expect(screen.getByTestId("workflow-model-setting-thinking")).toHaveTextContent("Default");
});
it("renders correct status badges for each result", () => {
render(<WorkflowResultsTab taskId="FN-001" results={mockResults} />);
@@ -996,6 +1188,13 @@ describe("WorkflowResultsTab", () => {
expect(css).toMatch(/@media[^{]*\(max-width: 768px\)[^{]*\{[\s\S]*?\.workflow-configured-header \.workflow-results-edit-toggle\s*\{[^}]*width:\s*100%;[^}]*justify-content:\s*center;/);
});
it("stacks workflow summary cards on mobile", () => {
const css = loadAllAppCss();
expect(css).toMatch(/\.workflow-state-summary__grid\s*\{[^}]*grid-template-columns:\s*repeat\(2, minmax\(0, 1fr\)\);/);
expect(css).toMatch(/@media[^{]*\(max-width: 768px\)[^{]*\{[\s\S]*?\.workflow-state-summary__grid\s*\{[^}]*grid-template-columns:\s*minmax\(0, 1fr\);/);
});
it("applies fullscreen modal dimensions on mobile", () => {
const css = loadAllAppCss();