diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c8b2fdb0a5..c8f3a156e2 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -33,7 +33,7 @@ Use deep links to open a specific task directly from notifications, chat, or ext File paths in dashboard text are automatically rendered as inline links. Clicking a linked path opens the Files browser modal at that path (including line/column targets when available) so you can inspect the file and use editor actions where supported. Current surfaces include: -- Task detail modal content (description markdown, **Review** tab, and **Workflow Results** tab output) +- Task detail modal content (description markdown, **Review** tab, and **Workflow Results** tab output plus workflow overview/graph/model settings) - Chat view messages/tool output - Agent log viewer - Activity log modal diff --git a/packages/dashboard/app/components/AppModals.tsx b/packages/dashboard/app/components/AppModals.tsx index 91077eca78..ca2370ccf0 100644 --- a/packages/dashboard/app/components/AppModals.tsx +++ b/packages/dashboard/app/components/AppModals.tsx @@ -203,6 +203,7 @@ export function AppModals({ onTaskUpdated={modalManager.updateDetailTask} addToast={addToast} prAuthAvailable={settings.prAuthAvailable} + onOpenWorkflowEditor={() => modalManager.openWorkflowEditor()} initialTab={modalManager.detailTaskInitialTab} /> diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index fa762392ae..7fe4580d38 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -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({
) : activeTab === "model" ? ( diff --git a/packages/dashboard/app/components/WorkflowResultsTab.css b/packages/dashboard/app/components/WorkflowResultsTab.css index ffa0953148..e75725c40e 100644 --- a/packages/dashboard/app/components/WorkflowResultsTab.css +++ b/packages/dashboard/app/components/WorkflowResultsTab.css @@ -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)); diff --git a/packages/dashboard/app/components/WorkflowResultsTab.tsx b/packages/dashboard/app/components/WorkflowResultsTab.tsx index 6ebe34bff8..f3dfac9c3e 100644 --- a/packages/dashboard/app/components/WorkflowResultsTab.tsx +++ b/packages/dashboard/app/components/WorkflowResultsTab.tsx @@ -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["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["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["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(null); const [allWorkflowSteps, setAllWorkflowSteps] = useState([]); + const [workflowDefinitions, setWorkflowDefinitions] = useState([]); const [isEditing, setIsEditing] = useState(false); const [selectedWorkflowId, setSelectedWorkflowId] = useState(null); const [resumeError, setResumeError] = useState(null); + const [graphExpanded, setGraphExpanded] = useState(false); + const [workflowGraphCache, setWorkflowGraphCache] = useState>({}); + 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({ )} )} - {canEdit && onWorkflowStepsChange && ( -
- +
+
+

{t("workflow.overview", "Workflow overview")}

- )} +
+
+ {t("workflow.workflowName", "Workflow")} + {workflowName} +
+
+ {t("workflow.executionPhase", "Execution phase")} + + {executionPhase.label} + +
+
+ {t("workflow.aggregateResult", "Aggregate result")} + + {aggregateResult.label} + +
+
+ {t("workflow.stepProgress", "Step count")} + {t("workflow.stepProgressValue", "{{completed}} of {{total}} steps completed", { completed: completedStepCount, total: results.length })} +
+
+
+ +
+ + {graphExpanded && ( +
+ {!selectedWorkflowId ? ( +

+ {t("workflow.noWorkflowAssigned", "No workflow assigned")} +

+ ) : workflowGraphLoading && !graphWorkflow ? ( +
+
+ {t("workflow.loadingGraph", "Loading workflow graph…")} +
+ ) : graphFlow ? ( +
+ + + +
+ ) : ( +

+ {t("workflow.graphUnavailable", "Workflow graph unavailable")} +

+ )} +
+ )} +
+ +
+
+

{t("workflow.workflowName", "Workflow")}

+ {canEdit && selectedWorkflowId && onEditWorkflow && ( + + )} +
+ +
+ +
+ + {modelSettingsExpanded && ( +
+ {[ + { 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) => ( +
+ {item.label} + + {item.provider ? : null} + {item.value} + +
+ ))} +
+ )} +
{showConfiguredStepsState ? (
diff --git a/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx index 886d89b0ce..0829d52a72 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx @@ -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[] }) => ( +
nodes:{nodes.length};edges:{edges.length}
+ ), + ReactFlowProvider: ({ children }: { children: unknown }) => <>{children}, + 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(); + render(); 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(); + + 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(); + await waitFor(() => expect(screen.getByTestId(testId)).toHaveTextContent(text)); + }); + + it("uses failed aggregate priority over advisory", async () => { + render( + , + ); + + 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(); + + 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(); + 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( + , + ); + + expect(screen.queryByTestId("workflow-edit-button")).not.toBeInTheDocument(); + + rerender( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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(); @@ -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();