diff --git a/.changeset/fn-7055-workflow-tab-graph-step-details.md b/.changeset/fn-7055-workflow-tab-graph-step-details.md new file mode 100644 index 0000000000..b9c23c89a7 --- /dev/null +++ b/.changeset/fn-7055-workflow-tab-graph-step-details.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix task-detail Workflow tabs so inherited workflow graphs and step details populate. +category: fix +dev: Resets stale task workflow selection/results on task switches and aliases optional step template IDs. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 6518f72658..733b119828 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -966,6 +966,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou - From this section you can explicitly enable/disable tracking and manage a per-task repo override (`owner/repo`). Clearing the override saves `null` and falls back to project/global defaults. - In `in-review`, pull-request controls/status (including stall badges) are in a dedicated **Pull Request** tab instead of the Definition tab. - Task Detail and list split-pane PR affordances follow the live project auto-merge setting: when auto-merge is off, manual **Create PR** / merge actions are shown; when it is on, the tab shows the automatic auto-merge hint unless a per-task override changes the effective behavior. +- The **Workflow** tab resolves the effective workflow for both explicitly selected and default-inherited tasks. Its overview, expandable graph preview, configured step details, and live step results refresh when switching tasks or projects without showing stale rows from the previous task. - The **Create Pull Request** modal now offers in-app remediation for every blocking preflight check. If `branchOnRemote` is false, use **Push branch to remote** and Fusion will publish `fusion/` to `origin` and refresh preflight. If `conflictsWithBase` is true, use **Resolve conflicts with AI** and Fusion will use an AI coding agent to resolve merge markers on the task branch, commit and push real merge changes, or report success without an empty commit when the selected base is already merged; preflight then refreshes so normal PR creation can continue once all checks pass. - The modal shell renders immediately: preflight checks and PR options load independently of AI-generated title/body metadata, so slow AI suggestions no longer block base-branch selection, diagnostics, or manual PR authoring. - AI title/body generation is bounded to 60 seconds and is canceled if the dialog request disconnects; on timeout/cancel, Fusion falls back to deterministic task-based PR title/body content instead of leaving the spinner stuck forever. diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 33b1f44322..59a63d4ce8 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -951,6 +951,11 @@ export function TaskDetailContent({ useEffect(() => { if (activeTab !== "workflow") return; let cancelled = false; + /* + FNXC:TaskWorkflowDetails 2026-06-26-01:43: + A mounted task-detail Workflow tab can switch from one task to another while the previous result list is visible. Clear results before the new fetch so live step/stage details never flash stale rows from another task while cancellation protects the in-flight request. + */ + setWorkflowResults([]); setWorkflowResultsLoading(true); fetchWorkflowResults(task.id, projectId) .then((results) => { diff --git a/packages/dashboard/app/components/WorkflowResultsTab.tsx b/packages/dashboard/app/components/WorkflowResultsTab.tsx index e2f297a8a6..4ec54f0a32 100644 --- a/packages/dashboard/app/components/WorkflowResultsTab.tsx +++ b/packages/dashboard/app/components/WorkflowResultsTab.tsx @@ -350,12 +350,17 @@ export function WorkflowResultsTab({ // Load the task's current workflow selection (if any). useEffect(() => { let cancelled = false; + /* + FNXC:TaskWorkflowDetails 2026-06-26-01:31: + Task-detail hosts can keep WorkflowResultsTab mounted while switching tasks. Clear the previous explicit selection before the new task selection fetch resolves (or fails) so default-inherited tasks use boardWorkflowFallbackId for the summary, graph fetch, and configured step details instead of a stale custom workflow from the prior task. + */ + setSelectedWorkflowId(null); fetchTaskWorkflow(taskId, projectId) .then((res) => { if (!cancelled) setSelectedWorkflowId(res.workflowId); }) .catch(() => { - /* selection is optional; ignore load failures */ + if (!cancelled) setSelectedWorkflowId(null); }); return () => { cancelled = true; @@ -418,7 +423,14 @@ export function WorkflowResultsTab({ const graphCacheKey = effectiveWorkflowId ? `${projectId ?? ""}::${effectiveWorkflowId}` : null; useEffect(() => { - if (!graphExpanded || !effectiveWorkflowId || !graphCacheKey || workflowGraphCache[graphCacheKey]) return; + if (!graphExpanded || !effectiveWorkflowId || !graphCacheKey) { + setWorkflowGraphLoading(false); + return; + } + if (workflowGraphCache[graphCacheKey]) { + setWorkflowGraphLoading(false); + return; + } let cancelled = false; setWorkflowGraphLoading(true); fetchWorkflow(effectiveWorkflowId, projectId) @@ -513,8 +525,21 @@ export function WorkflowResultsTab({ }, [allWorkflowSteps, optionalWorkflowSteps]); const workflowStepLookup = useMemo(() => { - return new Map(workflowStepOptions.map((step) => [step.id, step])); - }, [workflowStepOptions]); + const lookup = new Map(); + for (const step of workflowStepOptions) { + lookup.set(step.id, step); + } + /* + FNXC:TaskWorkflowDetails 2026-06-26-01:37: + Some persisted tasks store optional-group template ids (for example `browser-verification`) while the global step resolver returns the materialized workflow-step id plus `templateId`. Alias both ids to the same definition so configured step/stage details populate instead of showing the missing-definition fallback. + */ + for (const step of allWorkflowSteps) { + if (!step.templateId) continue; + const option = lookup.get(step.id); + if (option) lookup.set(step.templateId, option); + } + return lookup; + }, [allWorkflowSteps, workflowStepOptions]); const toggleOutput = (stepId: string) => { setExpandedOutputs((prev) => ({ ...prev, [stepId]: !prev[stepId] })); @@ -1060,7 +1085,7 @@ export function WorkflowResultsTab({
{t("app:workflow.loadingGraph", "Loading workflow graph…")}
- ) : graphFlow ? ( + ) : graphFlow && graphFlow.nodes.length > 0 ? (
{ expect(screen.queryByText("Initial Check")).not.toBeInTheDocument(); }); + it("clears stale workflow results immediately when switching tasks while the tab stays mounted", async () => { + const { fetchWorkflowResults } = await import("../../api"); + const mockFetch = vi.mocked(fetchWorkflowResults); + let resolveNextResults: (results: import("@fusion/core").WorkflowStepResult[]) => void = () => {}; + const nextResultsPromise = new Promise((resolve) => { + resolveNextResults = resolve; + }); + mockFetch + .mockResolvedValueOnce([ + { workflowStepId: "WS-INITIAL", workflowStepName: "Initial Check", status: "passed", output: "Initial output" }, + ] as import("@fusion/core").WorkflowStepResult[]) + .mockReturnValueOnce(nextResultsPromise); + + const { rerender } = render( + , + ); + + expect(await screen.findByText("Initial Check", {}, { timeout: 15_000 })).toBeTruthy(); + + rerender( + , + ); + + await waitFor(() => expect(mockFetch).toHaveBeenCalledWith("FN-200", undefined)); + await waitFor(() => expect(screen.queryByText("Initial Check")).not.toBeInTheDocument()); + expect(screen.getByTestId("workflow-results-loading")).toBeTruthy(); + + await act(async () => { + resolveNextResults([ + { workflowStepId: "WS-NEXT", workflowStepName: "Next Task Check", status: "passed", output: "Next output" }, + ] as import("@fusion/core").WorkflowStepResult[]); + await nextResultsPromise; + }); + + expect(await screen.findByText("Next Task Check")).toBeTruthy(); + expect(screen.queryByText("Initial Check")).not.toBeInTheDocument(); + }); + it("renders configured workflow steps state when results are empty", async () => { const { fetchWorkflowResults } = await import("../../api"); const mockFetch = vi.mocked(fetchWorkflowResults); diff --git a/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx index 324afc5df2..857d79cee3 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx @@ -318,6 +318,47 @@ describe("WorkflowResultsTab", () => { expect(await screen.findByTestId("workflow-graph-preview")).toBeInTheDocument(); }); + it("recomputes inherited workflow details after switching from an explicit task while selection fetch fails", async () => { + mockedFetchTaskWorkflow + .mockResolvedValueOnce({ workflowId: "WF-001" }) + .mockRejectedValueOnce(new Error("task workflow unavailable")); + + const { rerender } = render( + , + ); + + await waitFor(() => expect(screen.getByTestId("workflow-state-summary-name")).toHaveTextContent("Custom Delivery Workflow")); + + rerender( + , + ); + + await waitFor(() => expect(mockedFetchTaskWorkflow).toHaveBeenCalledWith("FN-002", "project-switch")); + await waitFor(() => expect(screen.getByTestId("workflow-state-summary-name")).toHaveTextContent("Built-in Coding Workflow")); + await waitFor(() => expect(screen.getByTestId("workflow-configured-step-browser-verification")).toHaveTextContent("Browser Verification")); + expect(screen.getByTestId("workflow-configured-step-browser-verification")).not.toHaveTextContent("Step definition not found."); + + fireEvent.click(screen.getByTestId("workflow-graph-toggle")); + + await waitFor(() => expect(mockedFetchWorkflow).toHaveBeenCalledWith("builtin:coding", "project-switch")); + expect(mockedFetchWorkflow).not.toHaveBeenCalledWith("WF-001", "project-switch"); + expect(await screen.findByTestId("workflow-graph-preview")).toBeInTheDocument(); + }); + it("returns to the effective default workflow when an explicit selection is cleared", async () => { mockedSelectTaskWorkflow.mockResolvedValueOnce({ workflowId: null, enabledWorkflowSteps: [] }); @@ -362,6 +403,37 @@ describe("WorkflowResultsTab", () => { expect(screen.queryByTestId("workflow-graph-preview")).not.toBeInTheDocument(); }); + it("shows graph unavailable when a fetched workflow has no mappable nodes", async () => { + mockedFetchTaskWorkflow.mockResolvedValueOnce({ workflowId: "WF-EMPTY" }); + mockedFetchWorkflows.mockResolvedValue([{ id: "WF-EMPTY", name: "Empty Workflow", ir: { version: 1, nodes: [], edges: [] } } as WorkflowDefinition]); + mockedFetchWorkflow.mockResolvedValueOnce({ id: "WF-EMPTY", name: "Empty Workflow", ir: { version: 1, nodes: [], edges: [] } } as WorkflowDefinition); + + render(); + + await waitFor(() => expect(screen.getByTestId("workflow-state-summary-name")).toHaveTextContent("Empty Workflow")); + fireEvent.click(screen.getByTestId("workflow-graph-toggle")); + + await waitFor(() => expect(mockedFetchWorkflow).toHaveBeenCalledWith("WF-EMPTY", "project-empty")); + expect(await screen.findByTestId("workflow-graph-unavailable")).toHaveTextContent("Workflow graph unavailable"); + expect(screen.queryByTestId("workflow-graph-preview")).not.toBeInTheDocument(); + expect(screen.queryByTestId("workflow-graph-loading")).not.toBeInTheDocument(); + }); + + it("keys graph cache by project and effective workflow id", async () => { + mockedFetchTaskWorkflow.mockResolvedValue({ workflowId: null }); + const { rerender } = render(); + + await waitFor(() => expect(screen.getByTestId("workflow-state-summary-name")).toHaveTextContent("Built-in Coding Workflow")); + fireEvent.click(screen.getByTestId("workflow-graph-toggle")); + await waitFor(() => expect(mockedFetchWorkflow).toHaveBeenCalledWith("builtin:coding", "project-a")); + expect(await screen.findByTestId("workflow-graph-preview")).toBeInTheDocument(); + + rerender(); + + await waitFor(() => expect(mockedFetchWorkflow).toHaveBeenCalledWith("builtin:coding", "project-b")); + expect(await screen.findByTestId("workflow-graph-preview")).toBeInTheDocument(); + }); + it("shows no workflow assigned and avoids graph fetch when board workflows provide no usable effective id", async () => { mockedFetchWorkflows.mockResolvedValueOnce([]); mockedFetchBoardWorkflows.mockResolvedValueOnce({