From 40c0048f72e72fe05abb5472189049a166f40f6f Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 8 Jun 2026 12:04:51 -0700 Subject: [PATCH] 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 --- .changeset/FN-6024-built-in-workflow-edges.md | 5 + docs/dashboard-guide.md | 1 + .../app/components/WorkflowNodeEditor.css | 2 +- .../__tests__/WorkflowNodeEditor.css.test.ts | 25 ++++- .../__tests__/WorkflowNodeEditor.test.tsx | 106 +++++++++++++++++- .../app/components/workflow-flow-mapping.ts | 15 ++- 6 files changed, 145 insertions(+), 9 deletions(-) create mode 100644 .changeset/FN-6024-built-in-workflow-edges.md diff --git a/.changeset/FN-6024-built-in-workflow-edges.md b/.changeset/FN-6024-built-in-workflow-edges.md new file mode 100644 index 0000000000..bc62ea28b3 --- /dev/null +++ b/.changeset/FN-6024-built-in-workflow-edges.md @@ -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. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index c8f3a156e2..954c63209e 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -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 diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.css b/packages/dashboard/app/components/WorkflowNodeEditor.css index e6753b5b43..545a364220 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.css +++ b/packages/dashboard/app/components/WorkflowNodeEditor.css @@ -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; } diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts index bbd6bbf7ac..9e3babacbe 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.css.test.ts @@ -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", () => { diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index 652a754055..bcb685d145 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -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( {}} 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", ); }); diff --git a/packages/dashboard/app/components/workflow-flow-mapping.ts b/packages/dashboard/app/components/workflow-flow-mapping.ts index 63f1b18222..0ae907cfc9 100644 --- a/packages/dashboard/app/components/workflow-flow-mapping.ts +++ b/packages/dashboard/app/components/workflow-flow-mapping.ts @@ -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 { @@ -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);