FN-6024: fix built-in workflow graph edges

Ensure built-in workflow editor graphs render connected, clickable edges above swimlane backgrounds.

- add explicit React Flow z-index layering so built-in workflow edges render above column bands while nodes stay interactive
- keep swimlane band backgrounds translucent and preserve failure/rework edge styling for read-only built-in workflows
- expand workflow editor tests to cover built-in/custom edge projection, handle rendering, and CSS edge visibility contracts
- document the built-in workflow editor edge behavior and add a patch changeset for @runfusion/fusion

Files changed:
 .changeset/FN-6024-built-in-workflow-edges.md      |   5 +
 docs/dashboard-guide.md                            |   1 +
 packages/dashboard/app/components/WorkflowNodeEditor.css          |   2 +-
 packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts       |  25 ++++ -
 packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx          | 106 ++++++++++++++++++++-
 packages/dashboard/app/components/workflow-flow-mapping.ts        |  15 ++-
 6 files changed, 145 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-6024

Fusion-Task-Lineage: 7151d59b-b591-4642-95ca-93cc35198d41
This commit is contained in:
gsxdsm
2026-06-08 12:04:51 -07:00
parent 7fd390a4d0
commit 40c0048f72
6 changed files with 145 additions and 9 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix built-in workflow editor graph edge visibility so read-only built-in workflows render connected, clickable React Flow edges for success, failure, and rework paths.

View File

@@ -107,6 +107,7 @@ Navigation:
Behavior:
- Opens a workflow node editor with a workflow list/sidebar, canvas, inspector, and settings/authoring panels
- Read-only built-in workflows are inspectable in the same canvas as custom workflows, including connected success, failure, and rework edges for their graph topology.
- The Settings panel is value-first for built-in workflows and groups workflow settings by Models, Review & Approval, Step Execution, and Advanced. Definitions remain available for custom workflow schema authoring.
- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, and Reviewer model lanes from **Project Models**; those controls write workflow setting values for the active default workflow.
- On desktop, the editor uses a multi-panel layout for editing the graph and adjacent workflow metadata

View File

@@ -802,7 +802,7 @@
.wf-column-band {
border: 1px dashed var(--border);
background: var(--bg-secondary);
background: color-mix(in srgb, var(--bg-secondary) 65%, transparent);
border-radius: var(--radius-md);
pointer-events: none;
}

View File

@@ -30,11 +30,30 @@ function extractMediaBlocks(css: string, query: string): string[] {
}
function findRule(blocks: string[], selector: RegExp): string {
const rule = blocks.map((block) => block.match(selector)?.[0] ?? "").find(Boolean) ?? "";
const globalSelector = new RegExp(selector.source, selector.flags.includes("g") ? selector.flags : `${selector.flags}g`);
const matches = blocks.flatMap((block) => [...block.matchAll(globalSelector)].map((match) => match[0]));
const rule = matches.at(-1) ?? "";
expect(rule).toBeTruthy();
return rule;
}
describe("WorkflowNodeEditor edge visibility CSS contract", () => {
it("keeps swimlane bands translucent so built-in workflow edges remain visible", () => {
const editorCss = readComponentCss("WorkflowNodeEditor.css");
const columnBandRule = findRule([editorCss], /\.wf-column-band\s*\{[^}]*\}/);
expect(columnBandRule).toMatch(/background\s*:\s*color-mix\(in srgb, var\(--bg-secondary\) 65%, transparent\)\s*;/);
expect(columnBandRule).toMatch(/pointer-events\s*:\s*none\s*;/);
const reworkRule = findRule([editorCss], /\.wf-edge-rework \.react-flow__edge-path\s*\{[^}]*\}/);
expect(reworkRule).toMatch(/stroke\s*:\s*var\(--accent, var\(--ws-info\)\)\s*;/);
expect(reworkRule).toMatch(/stroke-dasharray\s*:\s*5 4\s*;/);
const failureRule = findRule([editorCss], /\.react-flow__edge\.wf-edge-failure \.react-flow__edge-path\s*\{[^}]*\}/);
expect(failureRule).toMatch(/stroke\s*:\s*var\(--ws-error\)\s*;/);
expect(failureRule).toMatch(/stroke-dasharray\s*:\s*2 4\s*;/);
});
});
describe("WorkflowNodeEditor mobile CSS contract", () => {
it("FN-5992 preserves desktop editor min-width while adding full-screen mobile overrides", () => {
const baseCss = loadAllAppCssBaseOnly();
@@ -49,7 +68,7 @@ describe("WorkflowNodeEditor mobile CSS contract", () => {
expect(editorModalRule).toMatch(/border-radius\s*:\s*0\s*;/);
expect(editorModalRule).toMatch(/resize\s*:\s*none\s*;/);
const sidebarRule = findRule(mobileBlocks, /\.wf-editor-sidebar\s*\{[^}]*\}/);
const sidebarRule = findRule(mobileBlocks, /\.wf-editor-body--list-stage \.wf-editor-sidebar\s*\{[^}]*\}/);
expect(sidebarRule).toMatch(/width\s*:\s*100%\s*;/);
const inspectorRule = findRule(mobileBlocks, /\.wf-editor-inspector\s*\{[^}]*\}/);
@@ -60,7 +79,7 @@ describe("WorkflowNodeEditor mobile CSS contract", () => {
expect(settingsRule).toMatch(/min-width\s*:\s*0\s*;/);
const canvasWrapRule = findRule(mobileBlocks, /\.wf-editor-canvas-wrap\s*\{[^}]*\}/);
expect(canvasWrapRule).toMatch(/min-height\s*:\s*40vh\s*;/);
expect(canvasWrapRule).toMatch(/min-height\s*:\s*0\s*;/);
});
it("FN-5992 covers create dialog and AI panel mobile overlays", () => {

View File

@@ -2,8 +2,20 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, waitFor, cleanup, within } from "@testing-library/react";
import type { WorkflowDefinition, Settings } from "@fusion/core";
import type { Agent } from "../../api";
import { irToFlow, flowToIr, emptyWorkflowIr, emptyWorkflowLayout, foreachChildFlowId } from "../workflow-flow-mapping";
import { BUILTIN_CODING_WORKFLOW_IR, BUILTIN_STEPWISE_CODING_WORKFLOW_IR } from "@fusion/core";
import {
irToFlow,
flowToIr,
emptyWorkflowIr,
emptyWorkflowLayout,
foreachChildFlowId,
WF_EDGE_INTERACTION_WIDTH,
} from "../workflow-flow-mapping";
import {
BUILTIN_CODING_WORKFLOW_IR,
BUILTIN_PR_WORKFLOW_IR,
BUILTIN_STEPWISE_CODING_WORKFLOW_IR,
BUILTIN_WORKFLOWS,
} from "@fusion/core";
vi.mock("../../api", () => ({
fetchWorkflows: vi.fn(),
@@ -133,6 +145,36 @@ function builtinDef(): WorkflowDefinition {
};
}
function builtinPrDef(): WorkflowDefinition {
return {
id: "builtin:pr-workflow",
kind: "workflow",
name: "PR lifecycle (built-in)",
description: "Ships with Fusion",
ir: BUILTIN_PR_WORKFLOW_IR,
layout: {},
createdAt: "2026-06-03T00:00:00.000Z",
updatedAt: "2026-06-03T00:00:00.000Z",
};
}
function edgeRenderableAssertion(definition: WorkflowDefinition) {
const flow = irToFlow(definition);
const nodeIds = new Set(flow.nodes.map((node) => node.id));
expect(flow.edges.length, `${definition.id} should project edges`).toBeGreaterThan(0);
for (const edge of flow.edges) {
expect(nodeIds.has(edge.source), `${definition.id} edge ${edge.id} source ${edge.source}`).toBe(true);
expect(nodeIds.has(edge.target), `${definition.id} edge ${edge.id} target ${edge.target}`).toBe(true);
expect(edge.interactionWidth, `${definition.id} edge ${edge.id} interaction width`).toBe(
WF_EDGE_INTERACTION_WIDTH,
);
expect(edge.zIndex, `${definition.id} edge ${edge.id} z-index`).toBeGreaterThan(0);
expect(edge.sourceHandle, `${definition.id} edge ${edge.id} source handle`).toBeUndefined();
expect(edge.targetHandle, `${definition.id} edge ${edge.id} target handle`).toBeUndefined();
}
return flow;
}
function fragmentDef(): WorkflowDefinition {
return {
id: "WF-FRAG",
@@ -203,6 +245,37 @@ describe("workflow-flow-mapping", () => {
expect(ir.edges).toEqual([{ from: "start", to: "end", condition: "success" }]);
expect(emptyWorkflowLayout().start).toBeDefined();
});
it("projects every built-in workflow to connected, clickable React Flow edges", () => {
expect(BUILTIN_WORKFLOWS.map((workflow) => workflow.id).sort()).toEqual(
expect.arrayContaining(["builtin:coding", "builtin:stepwise-coding", "builtin:pr-workflow"]),
);
for (const workflow of BUILTIN_WORKFLOWS) {
edgeRenderableAssertion(workflow);
}
});
it("keeps built-in edge endpoints connected when layout is empty, undefined, or populated", () => {
const populated = BUILTIN_WORKFLOWS.find((workflow) => workflow.id === "builtin:pr-workflow");
expect(populated).toBeDefined();
edgeRenderableAssertion(populated!);
edgeRenderableAssertion({ ...populated!, layout: {} });
edgeRenderableAssertion({ ...populated!, layout: undefined });
});
it("projects custom v1 and v2 workflows to the same connected edge contract", () => {
edgeRenderableAssertion(def());
edgeRenderableAssertion(v2Def());
});
it("preserves duplicate and parallel built-in edges with valid endpoints and hit targets", () => {
const { edges } = edgeRenderableAssertion(builtinDef());
const failuresToEnd = edges.filter((edge) => edge.target === "end" && edge.data?.condition === "failure");
expect(failuresToEnd.map((edge) => edge.source).sort()).toEqual(["execute", "merge", "review"]);
expect(new Set(failuresToEnd.map((edge) => edge.id)).size).toBe(failuresToEnd.length);
expect(failuresToEnd.every((edge) => edge.interactionWidth === WF_EDGE_INTERACTION_WIDTH)).toBe(true);
});
});
describe("WorkflowNodeEditor", () => {
@@ -767,6 +840,7 @@ describe("WorkflowNodeEditor — built-in stepwise selection render path", () =>
beforeEach(() => {
vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
vi.mocked(fetchModels).mockResolvedValue({ models: [] });
});
afterEach(() => {
cleanup();
@@ -799,6 +873,31 @@ describe("WorkflowNodeEditor — built-in stepwise selection render path", () =>
expect(flowNodeIds).toContain(foreachChildFlowId("steps", "step-done"));
});
it("renders built-in and custom workflow nodes with handles matching projected edges", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([builtinPrDef(), v2Def()]);
const { container } = render(<WorkflowNodeEditor isOpen onClose={() => {}} addToast={() => {}} />);
await screen.findByTestId("wf-readonly-banner");
await waitFor(() => expect(screen.getAllByTestId("wf-node-hold").length).toBeGreaterThan(0));
const builtInFlow = irToFlow(builtinPrDef());
for (const edge of builtInFlow.edges.filter((candidate) => candidate.source === "pr-create")) {
expect(container.querySelector(`.react-flow__handle[data-nodeid="${edge.source}"][data-handlepos="right"]`)).toBeInTheDocument();
expect(container.querySelector(`.react-flow__handle[data-nodeid="${edge.target}"][data-handlepos="left"]`)).toBeInTheDocument();
}
expect(builtInFlow.edges.some((edge) => edge.label === "open")).toBe(true);
expect(builtInFlow.edges.some((edge) => edge.label === "failed")).toBe(true);
expect(builtInFlow.edges.some((edge) => edge.label === "failure")).toBe(true);
fireEvent.click(screen.getByText("Custom"));
await waitFor(() => expect(screen.queryByTestId("wf-readonly-banner")).not.toBeInTheDocument());
const customFlow = irToFlow(v2Def());
for (const edge of customFlow.edges) {
expect(container.querySelector(`.react-flow__handle[data-nodeid="${edge.source}"][data-handlepos="right"]`)).toBeInTheDocument();
expect(container.querySelector(`.react-flow__handle[data-nodeid="${edge.target}"][data-handlepos="left"]`)).toBeInTheDocument();
}
expect(customFlow.edges.every((edge) => edge.label === "success")).toBe(true);
});
it("irToFlow on the built-in stepwise IR yields a foreach group + rework-styled template edge (editor load path)", () => {
// Mirrors exactly what the editor's load effect feeds React Flow:
// const flow = irToFlow(activeWorkflow)
@@ -817,6 +916,7 @@ describe("WorkflowNodeEditor — built-in stepwise selection render path", () =>
// its rework styling so the editor shows the bounded loop-back.
const reworkEdges = edges.filter((e) => e.data?.kind === "rework");
expect(reworkEdges.length).toBeGreaterThan(0);
expect(group?.zIndex).toBeLessThan(reworkEdges[0]?.zIndex ?? 0);
expect(reworkEdges.every((e) => e.animated === true && e.className === "wf-edge-rework")).toBe(true);
expect(reworkEdges.some((e) => e.source === foreachChildFlowId("steps", "step-review"))).toBe(true);
});
@@ -864,7 +964,7 @@ describe("WorkflowNodeEditor — built-in stepwise selection render path", () =>
fireEvent.click(executeNode!);
expect((screen.getByLabelText("Prompt") as HTMLTextAreaElement).value).toContain(
"Fusion's standard implementation prompt",
"task execution agent",
);
});

View File

@@ -87,6 +87,10 @@ export const COLUMN_BAND_HEIGHT = 220;
export const COLUMN_BAND_WIDTH = 5000;
export const COLUMN_BAND_X = -40;
export const COLUMN_BAND_TOP = 0;
/** Layering: swimlane/template groups sit below routed edges; step cards sit above. */
const WF_BACKGROUND_GROUP_Z_INDEX = 0;
const WF_EDGE_Z_INDEX = 1;
const WF_STEP_NODE_Z_INDEX = 2;
/** React Flow node id for a column band group node. */
export const columnBandNodeId = (columnId: string): string => `__col__:${columnId}`;
export const isColumnBandNode = (id: string): boolean => id.startsWith("__col__:");
@@ -153,8 +157,8 @@ export function columnsToBandNodes(columns: WorkflowIrColumn[]): FlowNode<Workfl
draggable: false,
selectable: false,
deletable: false,
// Bands sit behind step nodes so steps remain clickable/draggable.
zIndex: -1,
// Bands sit behind routed edges and step nodes so built-in topology stays visible.
zIndex: WF_BACKGROUND_GROUP_Z_INDEX,
style: {
width: COLUMN_BAND_WIDTH,
height: COLUMN_BAND_HEIGHT,
@@ -212,6 +216,7 @@ function irEdgeToFlow(edge: WorkflowIrEdge, index: number, idScope = ""): FlowEd
className: edgeClassName(condition, isRework),
interactionWidth: WF_EDGE_INTERACTION_WIDTH,
markerEnd: undefined,
zIndex: WF_EDGE_Z_INDEX,
};
}
@@ -269,6 +274,7 @@ export function irToFlow(def: WorkflowDefinition): {
extent: "parent",
data: { kind: innerKind, label: nodeLabel(inner), config: { ...(inner.config ?? {}) } },
deletable: true,
zIndex: WF_STEP_NODE_Z_INDEX,
});
});
template.edges.forEach((edge, eIdx) => {
@@ -289,6 +295,7 @@ export function irToFlow(def: WorkflowDefinition): {
},
style: { width: FOREACH_GROUP_WIDTH, height: FOREACH_GROUP_HEIGHT },
deletable: true,
zIndex: WF_BACKGROUND_GROUP_Z_INDEX,
};
}
@@ -303,6 +310,7 @@ export function irToFlow(def: WorkflowDefinition): {
column,
},
deletable: node.kind !== "start" && node.kind !== "end",
zIndex: WF_STEP_NODE_Z_INDEX,
};
});
@@ -941,6 +949,7 @@ function irNodeToFlowNode(
position,
data: { kind, label: nodeLabel(node), config: { ...(node.config ?? {}) } },
deletable: node.kind !== "start" && node.kind !== "end",
zIndex: WF_STEP_NODE_Z_INDEX,
};
}
@@ -1018,6 +1027,7 @@ export function insertFragment(
extent: "parent",
data: { kind: innerKind, label: nodeLabel(inner), config: { ...(inner.config ?? {}) } },
deletable: true,
zIndex: WF_STEP_NODE_Z_INDEX,
});
});
template.edges.forEach((edge, eIdx) => {
@@ -1037,6 +1047,7 @@ export function insertFragment(
},
style: { width: FOREACH_GROUP_WIDTH, height: FOREACH_GROUP_HEIGHT },
deletable: true,
zIndex: WF_BACKGROUND_GROUP_Z_INDEX,
};
}
return irNodeToFlowNode(node, id, pos);