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:
@@ -203,6 +203,7 @@ export function AppModals({
|
||||
onTaskUpdated={modalManager.updateDetailTask}
|
||||
addToast={addToast}
|
||||
prAuthAvailable={settings.prAuthAvailable}
|
||||
onOpenWorkflowEditor={() => modalManager.openWorkflowEditor()}
|
||||
initialTab={modalManager.detailTaskInitialTab}
|
||||
/>
|
||||
</ModalErrorBoundary>
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user