FN-7055: fix task workflow detail rendering

Fix task-detail workflow rendering so inherited workflow data and live results refresh reliably.

- Clear stale workflow selections and result rows while switching between tasks.
- Load inherited/default workflow graphs and avoid empty graph previews during loading.
- Alias optional template IDs to materialized workflow steps so configured details populate.
- Cover task switching, inherited workflows, and optional step detail rendering with dashboard tests.

Files changed:
 .changeset/fn-7055-workflow-tab-graph-step-details.md     |  7 +++
 docs/dashboard-guide.md                                    |  1 +
 packages/dashboard/app/components/TaskDetailModal.tsx      |  5 ++
 packages/dashboard/app/components/WorkflowResultsTab.tsx   | 35 +++++++++--
 packages/dashboard/app/components/__tests__/TaskDetailModal.models-progress-workflow.test.tsx | 56 +++++++++++++++++
 packages/dashboard/app/components/__tests__/WorkflowResultsTab.test.tsx | 72 ++++++++++++++++++++++
 6 files changed, 171 insertions(+), 5 deletions(-)

Fusion-Task-Id: FN-7055

Fusion-Task-Lineage: a28dd138-3c2f-43bc-81f9-fb47799f5251
This commit is contained in:
gsxdsm
2026-06-26 02:04:53 -07:00
parent 59fc94bc0f
commit afa33b76d3
6 changed files with 171 additions and 5 deletions

View File

@@ -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.

View File

@@ -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/<task-id-lower>` 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.

View File

@@ -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) => {

View File

@@ -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<string, WorkflowStepOption>();
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({
<div className="workflow-results-spinner" />
<span>{t("app:workflow.loadingGraph", "Loading workflow graph…")}</span>
</div>
) : graphFlow ? (
) : graphFlow && graphFlow.nodes.length > 0 ? (
<div className="workflow-graph-preview" data-testid="workflow-graph-preview">
<ReactFlowProvider>
<ReactFlow

View File

@@ -1120,6 +1120,62 @@ describe("TaskDetailModal", () => {
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<import("@fusion/core").WorkflowStepResult[]>((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(
<TaskDetailModal
initialTab="workflow"
task={makeTask({ id: "FN-099", enabledWorkflowSteps: ["WS-INITIAL"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
expect(await screen.findByText("Initial Check", {}, { timeout: 15_000 })).toBeTruthy();
rerender(
<TaskDetailModal
initialTab="workflow"
task={makeTask({ id: "FN-200", enabledWorkflowSteps: ["WS-NEXT"] })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
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);

View File

@@ -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(
<WorkflowResultsTab
taskId="FN-001"
task={{ ...baseTask, id: "FN-001" }}
settings={mockSettings}
results={[]}
enabledWorkflowSteps={["browser-verification"]}
projectId="project-switch"
/>,
);
await waitFor(() => expect(screen.getByTestId("workflow-state-summary-name")).toHaveTextContent("Custom Delivery Workflow"));
rerender(
<WorkflowResultsTab
taskId="FN-002"
task={{ ...baseTask, id: "FN-002" }}
settings={mockSettings}
results={[]}
enabledWorkflowSteps={["browser-verification"]}
projectId="project-switch"
/>,
);
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(<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} projectId="project-empty" />);
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(<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} projectId="project-a" />);
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(<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} projectId="project-b" />);
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({