diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx index 585397669b..bb19eba2ba 100644 --- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx +++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx @@ -9,6 +9,7 @@ import { MiniMap, useNodesState, useEdgesState, + useReactFlow, type Connection, type Node as FlowNode, type Edge as FlowEdge, @@ -750,6 +751,8 @@ function InnerEditor({ // localStorage unavailable (private mode / SSR): non-fatal. } }, [settingsCollapsed]); + // React Flow instance for programmatic viewport control (auto-layout on load). + const { setViewport } = useReactFlow(); // Wrapper around so keyboard deletion can return focus to the // canvas container (R6) instead of leaving it on a now-removed node. const canvasRef = useRef(null); @@ -1070,11 +1073,15 @@ function InnerEditor({ return; } const flow = irToFlow(activeWorkflow); - setNodes(flow.nodes); - setEdges(flow.edges); const loadedColumns = columnsOf(activeWorkflow); const loadedFields = fieldsOf(activeWorkflow); const loadedSettings = settingsOf(activeWorkflow); + // Auto-layout on load: compute tidy positions and apply them before the + // first render so nodes are visible in the top-left viewport. + const layoutPositions = autoLayout(flow.nodes, flow.edges, loadedColumns); + const laidOutNodes = applyAutoLayout(flow.nodes, layoutPositions); + setNodes(laidOutNodes); + setEdges(flow.edges); setColumns(loadedColumns); setFields(loadedFields); setSettings(loadedSettings); @@ -1087,7 +1094,7 @@ function InnerEditor({ loadedSnapshotRef.current = serializeGraph( activeWorkflow.name, activeWorkflow.description ?? "", - flow.nodes, + laidOutNodes, flow.edges, loadedColumns, loadedFields, @@ -1096,6 +1103,8 @@ function InnerEditor({ setSelectedNodeId(null); setSelectedEdgeId(null); setValidationError(null); + // Position viewport at top-left so the laid-out nodes are visible. + setViewport({ x: 0, y: 0, zoom: 1 }, { duration: 0 }); // Honor a pending AI interpreter-only flag exactly once for the workflow it // just activated; otherwise the banner clears on load (U10/R11). if (pendingInterpreterOnlyRef.current) { @@ -1104,7 +1113,7 @@ function InnerEditor({ } else { setInterpreterOnly(false); } - }, [activeWorkflow, setNodes, setEdges]); + }, [activeWorkflow, setNodes, setEdges, setViewport]); // `?panel=settings` deep link (U6/U9 redirect stubs): once the active workflow // has loaded, scroll the settings panel into view. Runs once per editor open. diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx index f08ca87739..110402c6b4 100644 --- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx +++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx @@ -427,7 +427,7 @@ describe("WorkflowNodeEditor — U5 auto-layout", () => { expect(screen.queryByTestId("wf-auto-layout")).not.toBeInTheDocument(); }); - it("repositions nodes on click (a node's transform changes)", async () => { + it("runs auto-layout on load (nodes are positioned at layout positions)", async () => { vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]); const { container } = render( {}} addToast={() => {}} />, @@ -436,14 +436,25 @@ describe("WorkflowNodeEditor — U5 auto-layout", () => { // React Flow positions step nodes via a translate transform on their wrapper. const wrapperFor = (id: string) => container.querySelector(`.react-flow__node[data-id="${id}"]`); - const before = wrapperFor("step")?.style.transform ?? ""; - fireEvent.click(screen.getByTestId("wf-auto-layout")); + // After load, the step node should have been auto-laid-out (positioned). await waitFor(() => { - const after = wrapperFor("step")?.style.transform ?? ""; - expect(after).not.toBe(""); - expect(after).not.toBe(before); + const transform = wrapperFor("step")?.style.transform ?? ""; + expect(transform).not.toBe(""); }); }); + + it("clicking auto-layout still works after initial load", async () => { + vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]); + render( + {}} addToast={() => {}} />, + ); + await screen.findByTestId("wf-node-start"); + // The auto-layout button should still be present and clickable. + const btn = screen.getByTestId("wf-auto-layout"); + expect(btn).toBeInTheDocument(); + // Clicking it should not throw. + fireEvent.click(btn); + }); }); // ── U8: step-inversion authoring (foreach/step-review/parse-steps/code) ────── diff --git a/packages/dashboard/app/components/workflow-auto-layout.ts b/packages/dashboard/app/components/workflow-auto-layout.ts index 5de7389036..b2c200188b 100644 --- a/packages/dashboard/app/components/workflow-auto-layout.ts +++ b/packages/dashboard/app/components/workflow-auto-layout.ts @@ -37,8 +37,8 @@ export const WF_AUTO_LAYOUT_BAND_PADDING = 16; export const WF_AUTO_LAYOUT_SPACING = WF_CARD_MAX_WIDTH + WF_AUTO_LAYOUT_GAP_X; /** Left/top origin for the laid-out graph. */ -const ORIGIN_X = 40; -const ORIGIN_Y = 40; +const ORIGIN_X = 0; +const ORIGIN_Y = 0; /** Row height for a stacked card (card + vertical gap). */ const ROW_HEIGHT = WF_CARD_HEIGHT + WF_AUTO_LAYOUT_GAP_Y;