diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index 7d0a42eb7a..50d52d23cf 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -128,7 +128,7 @@ Behavior:
- The main Settings modal also exposes the default workflow's Plan/Triage, Executor, and Reviewer model lanes from **Project Models**; the modal's primary **Save** action writes those dropdown values as workflow setting values for the active default workflow.
- On desktop, the editor uses a multi-panel canvas layout for editing the graph and adjacent workflow metadata. The **Show simple editor** toggle switches that same workflow into the graph-outline editor with dedicated **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions** tabs.
- On viewports `<=768px`, the editor switches to a full-screen mobile sheet. Global workflow entry points open to the workflow list with no workflow preselected and prompt users to select a workflow to edit; the board workflow toolbar edit button opens directly to the selected workflow editor when that selected workflow is available.
-- Simple/mobile editing uses a graph outline instead of making the canvas the primary control. The outline shows nodes, branch/rework edges, column placement, and foreach/loop template children as tappable rows and chips that open the same node and edge detail editors as desktop.
+- Simple/mobile editing uses a graph outline instead of making the canvas the primary control. The outline shows nodes, branch/rework edges, column placement, and foreach/loop template children as tappable rows and chips that open the same node and edge detail editors as desktop. For custom workflows, editable outline rows also expose **Move up** and **Move down** controls that reorder steps within their current column or template parent; built-in workflows remain read-only and hide those controls.
- Simple/mobile authoring exposes dedicated destinations for **Graph**, **Add**, **Settings**, **Fields**, **Columns**, and **Actions**. Add includes the node palette plus fragments, built-in step templates, and plugin step templates; Actions includes save, AI edit, auto-layout, export, and delete for custom workflows, plus export and duplicate for built-ins. Settings keeps the Definitions/Values tab split.
- The create-workflow dialog and workflow AI authoring popover follow the same mobile full-screen/sheet pattern so they are not clipped by the editor canvas on narrow screens
diff --git a/packages/dashboard/app/components/MobileWorkflowGraphView.css b/packages/dashboard/app/components/MobileWorkflowGraphView.css
index 6b635cc6d1..31c4854f46 100644
--- a/packages/dashboard/app/components/MobileWorkflowGraphView.css
+++ b/packages/dashboard/app/components/MobileWorkflowGraphView.css
@@ -105,7 +105,12 @@
font-size: 0.78rem;
}
+/*
+FNXC:WorkflowSimpleEditor 2026-06-17-03:02:
+Simple-editor step order is editable on touch and compact desktop surfaces, so move controls use the same minimum touch target and focus/active treatment as existing outline buttons without creating hidden shells for read-only or structural rows.
+*/
.mobile-wf-node-expand,
+.mobile-wf-node-move,
.mobile-wf-connect-button {
display: inline-flex;
align-items: center;
@@ -122,21 +127,30 @@
box-shadow var(--transition-fast);
}
-.mobile-wf-node-expand {
+.mobile-wf-node-expand,
+.mobile-wf-node-move {
width: var(--wf-editor-touch-target);
}
+.mobile-wf-node-move:disabled {
+ cursor: not-allowed;
+ opacity: var(--opacity-disabled, 0.5);
+ transform: none;
+}
+
.mobile-wf-connect-button {
gap: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
}
.mobile-wf-node-expand:hover,
+.mobile-wf-node-move:not(:disabled):hover,
.mobile-wf-connect-button:hover {
background: var(--bg-tertiary);
}
.mobile-wf-node-expand:focus-visible,
+.mobile-wf-node-move:focus-visible,
.mobile-wf-connect-button:focus-visible,
.mobile-wf-connect-select:focus-visible {
outline: none;
@@ -144,6 +158,7 @@
}
.mobile-wf-node-expand:active,
+.mobile-wf-node-move:not(:disabled):active,
.mobile-wf-connect-button:active {
transform: scale(0.97);
}
diff --git a/packages/dashboard/app/components/MobileWorkflowGraphView.tsx b/packages/dashboard/app/components/MobileWorkflowGraphView.tsx
index 64a8f61640..808e8394d0 100644
--- a/packages/dashboard/app/components/MobileWorkflowGraphView.tsx
+++ b/packages/dashboard/app/components/MobileWorkflowGraphView.tsx
@@ -1,7 +1,7 @@
-import { ChevronDown, ChevronRight, GitBranch, Pencil } from "lucide-react";
+import { ArrowDown, ArrowUp, ChevronDown, ChevronRight, GitBranch, Pencil } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
-import type { MobileWorkflowNodeSummary } from "./workflow-mobile-graph";
+import type { MobileWorkflowNodeSummary, WorkflowNodeReorderDirection } from "./workflow-mobile-graph";
import "./MobileWorkflowGraphView.css";
interface MobileWorkflowGraphViewProps {
@@ -11,6 +11,17 @@ interface MobileWorkflowGraphViewProps {
onSelectNode: (id: string) => void;
onSelectEdge: (id: string) => void;
onCreateConnection?: (source: string, target: string) => void;
+ canReorder?: boolean;
+ onMoveNode?: (id: string, direction: WorkflowNodeReorderDirection) => void;
+}
+
+function reorderAvailability(rows: MobileWorkflowNodeSummary[], index: number) {
+ const row = rows[index];
+ if (!row?.editable) return { up: false, down: false };
+ return {
+ up: rows[index - 1]?.editable === true,
+ down: rows[index + 1]?.editable === true,
+ };
}
function NodeRow({
@@ -21,6 +32,10 @@ function NodeRow({
onSelectNode,
onSelectEdge,
onCreateConnection,
+ canReorder,
+ onMoveNode,
+ canMoveUp,
+ canMoveDown,
}: {
row: MobileWorkflowNodeSummary;
depth: number;
@@ -29,6 +44,10 @@ function NodeRow({
onSelectNode: (id: string) => void;
onSelectEdge: (id: string) => void;
onCreateConnection?: (source: string, target: string) => void;
+ canReorder?: boolean;
+ onMoveNode?: (id: string, direction: WorkflowNodeReorderDirection) => void;
+ canMoveUp: boolean;
+ canMoveDown: boolean;
}) {
const { t } = useTranslation("app");
const hasChildren = row.children.length > 0;
@@ -37,6 +56,7 @@ function NodeRow({
const selected = selectedNodeId === row.id;
const connectionTargets = row.connectionTargets ?? [];
const canCreateConnection = !!onCreateConnection && row.editable && connectionTargets.length > 0;
+ const showReorderControls = !!canReorder && !!onMoveNode && row.editable;
return (
@@ -58,17 +78,43 @@ function NodeRow({
{row.editable ?
: null}
- {hasChildren ? (
+ {hasChildren || showReorderControls ? (
-
+ {showReorderControls ? (
+ <>
+
+
+ >
+ ) : null}
+ {hasChildren ? (
+
+ ) : null}
) : null}
@@ -146,18 +192,25 @@ function NodeRow({
)}
{hasChildren && expanded ? (
- {row.children.map((child) => (
-
- ))}
+ {row.children.map((child, index) => {
+ const move = reorderAvailability(row.children, index);
+ return (
+
+ );
+ })}
) : null}
@@ -171,6 +224,8 @@ export function MobileWorkflowGraphView({
onSelectNode,
onSelectEdge,
onCreateConnection,
+ canReorder,
+ onMoveNode,
}: MobileWorkflowGraphViewProps) {
const { t } = useTranslation("app");
if (rows.length === 0) {
@@ -183,18 +238,25 @@ export function MobileWorkflowGraphView({
return (
- {rows.map((row) => (
-
- ))}
+ {rows.map((row, index) => {
+ const move = reorderAvailability(rows, index);
+ return (
+
+ );
+ })}
);
}
diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx
index baa6566634..a47d4a3e12 100644
--- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx
+++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx
@@ -85,7 +85,12 @@ import { WorkflowSettingsPanel } from "./WorkflowSettingsPanel";
import type { WorkflowFieldDefinition, WorkflowSettingDefinition } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { MobileWorkflowGraphView } from "./MobileWorkflowGraphView";
-import { buildMobileWorkflowGraph, type MobileWorkflowConnectionTarget } from "./workflow-mobile-graph";
+import {
+ buildMobileWorkflowGraph,
+ reorderWorkflowNode,
+ type MobileWorkflowConnectionTarget,
+ type WorkflowNodeReorderDirection,
+} from "./workflow-mobile-graph";
type ExecutorKind = "model" | "agent" | "skill" | "cli" | "cli-agent";
type MobileWorkflowPanel = "graph" | "add" | "settings" | "fields" | "columns" | "actions";
@@ -1278,6 +1283,17 @@ function InnerEditor({
[createConnectionEdge],
);
+ /**
+ * FNXC:WorkflowSimpleEditor 2026-06-17-03:08:
+ * Custom-workflow simple editors need read-only-safe reordering without a canvas drag gesture. Swap sibling node positions through the shared mobile graph helper so built-ins stay gated, selection remains untouched, and the existing IR save path persists the new position-derived order.
+ */
+ const onMoveSimpleNode = useCallback(
+ (nodeId: string, direction: WorkflowNodeReorderDirection) => {
+ setNodes((ns) => reorderWorkflowNode(ns, nodeId, direction));
+ },
+ [setNodes],
+ );
+
// Dragging a step node into a column band sets node.column (position-based
// hit testing against the ordered bands — see workflow-flow-mapping).
const onNodeDragStop = useCallback(
@@ -2575,6 +2591,8 @@ function InnerEditor({
setSelectedNodeId(null);
}}
onCreateConnection={isBuiltin ? undefined : onCreateSimpleConnection}
+ canReorder={!isBuiltin}
+ onMoveNode={onMoveSimpleNode}
/>
)}
diff --git a/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.css.test.ts b/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.css.test.ts
index ddb8d7850e..b1463b2628 100644
--- a/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.css.test.ts
+++ b/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.css.test.ts
@@ -47,12 +47,14 @@ describe("MobileWorkflowGraphView CSS contract", () => {
const nodeMainActiveRule = findRule([graphCss], /\.mobile-wf-node-main:active\s*\{[^}]*\}/);
expect(nodeMainActiveRule).toMatch(/transform\s*:\s*scale\(0\.97\)\s*;/);
- const nodeExpandHoverRule = findRule([graphCss], /\.mobile-wf-node-expand:hover\s*\{[^}]*\}/);
+ const nodeExpandHoverRule = findRule([graphCss], /\.mobile-wf-node-expand:hover,\s*\.mobile-wf-node-move:not\(:disabled\):hover,\s*\.mobile-wf-connect-button:hover\s*\{[^}]*\}/);
expect(nodeExpandHoverRule).toMatch(/background\s*:\s*var\(--bg-tertiary\)\s*;/);
- const nodeExpandFocusRule = findRule([graphCss], /\.mobile-wf-node-expand:focus-visible\s*\{[^}]*\}/);
+ const nodeExpandFocusRule = findRule([graphCss], /\.mobile-wf-node-expand:focus-visible,\s*\.mobile-wf-node-move:focus-visible,\s*\.mobile-wf-connect-button:focus-visible,\s*\.mobile-wf-connect-select:focus-visible\s*\{[^}]*\}/);
expect(nodeExpandFocusRule).toMatch(/box-shadow\s*:\s*var\(--focus-ring-strong\)\s*;/);
- const nodeExpandActiveRule = findRule([graphCss], /\.mobile-wf-node-expand:active\s*\{[^}]*\}/);
+ const nodeExpandActiveRule = findRule([graphCss], /\.mobile-wf-node-expand:active,\s*\.mobile-wf-node-move:not\(:disabled\):active,\s*\.mobile-wf-connect-button:active\s*\{[^}]*\}/);
expect(nodeExpandActiveRule).toMatch(/transform\s*:\s*scale\(0\.97\)\s*;/);
+ const nodeMoveSizeRule = findRule([graphCss], /\.mobile-wf-node-expand,\s*\.mobile-wf-node-move\s*\{[^}]*\}/);
+ expect(nodeMoveSizeRule).toMatch(/width\s*:\s*var\(--wf-editor-touch-target\)\s*;/);
const edgeChipHoverRule = findRule([graphCss], /\.mobile-wf-edge-chip:hover\s*\{[^}]*\}/);
expect(edgeChipHoverRule).toMatch(/background\s*:\s*var\(--bg-secondary\)\s*;/);
diff --git a/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.test.tsx b/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.test.tsx
index 5fa88f4c87..bd44e301d1 100644
--- a/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.test.tsx
+++ b/packages/dashboard/app/components/__tests__/MobileWorkflowGraphView.test.tsx
@@ -13,6 +13,15 @@ const rows: MobileWorkflowNodeSummary[] = [
outgoing: [{ id: "e1", source: "start", target: "prompt", targetLabel: "Prompt", label: "success" }],
children: [],
},
+ {
+ id: "prompt",
+ label: "Prompt",
+ kind: "prompt",
+ summary: "Draft prompt",
+ editable: true,
+ outgoing: [],
+ children: [],
+ },
{
id: "loop",
label: "Review loop",
@@ -22,18 +31,38 @@ const rows: MobileWorkflowNodeSummary[] = [
outgoing: [],
children: [
{
- id: "loop::child",
- label: "Loop step",
+ id: "loop::child-a",
+ label: "Loop step A",
kind: "prompt",
summary: "Not configured",
editable: true,
parentId: "loop",
- templateLocalId: "child",
+ templateLocalId: "child-a",
+ outgoing: [],
+ children: [],
+ },
+ {
+ id: "loop::child-b",
+ label: "Loop step B",
+ kind: "script",
+ summary: "Not configured",
+ editable: true,
+ parentId: "loop",
+ templateLocalId: "child-b",
outgoing: [],
children: [],
},
],
},
+ {
+ id: "end",
+ label: "End",
+ kind: "end",
+ summary: "",
+ editable: false,
+ outgoing: [],
+ children: [],
+ },
];
describe("MobileWorkflowGraphView", () => {
@@ -68,8 +97,92 @@ describe("MobileWorkflowGraphView", () => {
/>,
);
- expect(screen.getByTestId("mobile-wf-node-loop::child")).toBeInTheDocument();
+ expect(screen.getByTestId("mobile-wf-node-loop::child-a")).toBeInTheDocument();
fireEvent.click(within(screen.getByTestId("mobile-wf-node-loop")).getByRole("button", { name: /collapse/i }));
- expect(screen.queryByTestId("mobile-wf-node-loop::child")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("mobile-wf-node-loop::child-a")).not.toBeInTheDocument();
+ });
+
+ it("exposes move controls for editable sibling rows and calls the move callback", () => {
+ const onMoveNode = vi.fn();
+ render(
+ {}}
+ onSelectEdge={() => {}}
+ canReorder
+ onMoveNode={onMoveNode}
+ />,
+ );
+
+ expect(screen.queryByTestId("mobile-wf-node-move-up-start")).not.toBeInTheDocument();
+ expect(screen.getByTestId("mobile-wf-node-move-up-prompt")).toBeDisabled();
+ fireEvent.click(screen.getByTestId("mobile-wf-node-move-down-prompt"));
+ expect(onMoveNode).toHaveBeenCalledWith("prompt", "down");
+
+ fireEvent.click(screen.getByTestId("mobile-wf-node-move-up-loop"));
+ expect(onMoveNode).toHaveBeenCalledWith("loop", "up");
+ expect(screen.getByTestId("mobile-wf-node-move-down-loop")).toBeDisabled();
+ expect(screen.queryByTestId("mobile-wf-node-move-down-end")).not.toBeInTheDocument();
+ });
+
+ it("hides move controls for read-only built-ins without empty action shells", () => {
+ render(
+ {}}
+ onSelectEdge={() => {}}
+ canReorder={false}
+ onMoveNode={() => {}}
+ />,
+ );
+
+ expect(screen.queryByTestId(/mobile-wf-node-move-/)).not.toBeInTheDocument();
+ expect(within(screen.getByTestId("mobile-wf-node-prompt")).queryByRole("button", { name: /move/i })).not.toBeInTheDocument();
+ });
+
+ it("renders template-child move controls with child-level boundaries", () => {
+ const onMoveNode = vi.fn();
+ render(
+ {}}
+ onSelectEdge={() => {}}
+ canReorder
+ onMoveNode={onMoveNode}
+ />,
+ );
+
+ expect(screen.getByTestId("mobile-wf-node-move-up-loop::child-a")).toBeDisabled();
+ fireEvent.click(screen.getByTestId("mobile-wf-node-move-down-loop::child-a"));
+ expect(onMoveNode).toHaveBeenCalledWith("loop::child-a", "down");
+ fireEvent.click(screen.getByTestId("mobile-wf-node-move-up-loop::child-b"));
+ expect(onMoveNode).toHaveBeenCalledWith("loop::child-b", "up");
+ expect(screen.getByTestId("mobile-wf-node-move-down-loop::child-b")).toBeDisabled();
+ });
+
+ it("proves the simple editor reorder symptom is gone through callback controls", () => {
+ const onMoveNode = vi.fn();
+ render(
+ {}}
+ onSelectEdge={() => {}}
+ canReorder
+ onMoveNode={onMoveNode}
+ />,
+ );
+
+ const movePromptDown = within(screen.getByTestId("mobile-wf-node-prompt")).getByRole("button", { name: "Move down" });
+ fireEvent.click(movePromptDown);
+
+ expect(onMoveNode).toHaveBeenCalledWith("prompt", "down");
});
});
diff --git a/packages/dashboard/app/components/__tests__/workflow-mobile-graph.test.ts b/packages/dashboard/app/components/__tests__/workflow-mobile-graph.test.ts
index 31e859d21a..037f015736 100644
--- a/packages/dashboard/app/components/__tests__/workflow-mobile-graph.test.ts
+++ b/packages/dashboard/app/components/__tests__/workflow-mobile-graph.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { Edge as FlowEdge, Node as FlowNode } from "@xyflow/react";
-import { buildMobileWorkflowGraph } from "../workflow-mobile-graph";
+import { buildMobileWorkflowGraph, reorderWorkflowNode } from "../workflow-mobile-graph";
import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
import { columnBandNodeId, foreachChildFlowId } from "../workflow-flow-mapping";
@@ -30,6 +30,79 @@ function edge(id: string, source: string, target: string, condition = "success")
};
}
+function rowOrder(nodes: FlowNode[]): string[] {
+ return buildMobileWorkflowGraph(nodes, []).map((row) => row.id);
+}
+
+describe("reorderWorkflowNode", () => {
+ it("swaps adjacent editable top-level siblings in the same column and re-derives the new order", () => {
+ const nodes = [
+ node("a", "prompt", 0, 0, { data: { kind: "prompt", label: "A", column: "todo" } }),
+ node("b", "script", 0, 80, { data: { kind: "script", label: "B", column: "todo" } }),
+ node("c", "gate", 0, 160, { data: { kind: "gate", label: "C", column: "todo" } }),
+ ];
+
+ const reordered = reorderWorkflowNode(nodes, "b", "up");
+
+ expect(rowOrder(reordered)).toEqual(["b", "a", "c"]);
+ expect(reordered.find((n) => n.id === "b")?.position).toEqual({ x: 0, y: 0 });
+ expect(reordered.find((n) => n.id === "a")?.position).toEqual({ x: 0, y: 80 });
+ });
+
+ it("does not move past same-group boundaries", () => {
+ const nodes = [
+ node("a", "prompt", 0, 0, { data: { kind: "prompt", label: "A", column: "todo" } }),
+ node("b", "script", 0, 80, { data: { kind: "script", label: "B", column: "todo" } }),
+ ];
+
+ expect(reorderWorkflowNode(nodes, "a", "up")).toBe(nodes);
+ expect(reorderWorkflowNode(nodes, "b", "down")).toBe(nodes);
+ });
+
+ it("does not move top-level nodes across column groups", () => {
+ const nodes = [
+ node("todo-a", "prompt", 0, 0, { data: { kind: "prompt", label: "A", column: "todo" } }),
+ node("doing-a", "script", 0, 80, { data: { kind: "script", label: "B", column: "doing" } }),
+ ];
+
+ expect(reorderWorkflowNode(nodes, "todo-a", "down")).toBe(nodes);
+ expect(rowOrder(reorderWorkflowNode(nodes, "doing-a", "up"))).toEqual(["todo-a", "doing-a"]);
+ });
+
+ it("reorders template children only within the same parent", () => {
+ const first = foreachChildFlowId("each", "first");
+ const second = foreachChildFlowId("each", "second");
+ const other = foreachChildFlowId("other", "first");
+ const nodes = [
+ node("each", "foreach", 0, 0, { data: { kind: "foreach", label: "Each" } }),
+ node(first, "prompt", 20, 60, { parentId: "each", data: { kind: "prompt", label: "First" } }),
+ node(second, "script", 20, 120, { parentId: "each", data: { kind: "script", label: "Second" } }),
+ node("other", "loop", 0, 200, { data: { kind: "loop", label: "Other" } }),
+ node(other, "prompt", 20, 60, { parentId: "other", data: { kind: "prompt", label: "Other child" } }),
+ ];
+
+ const rows = buildMobileWorkflowGraph(reorderWorkflowNode(nodes, second, "up"), []);
+
+ expect(rows.find((row) => row.id === "each")?.children.map((child) => child.id)).toEqual([second, first]);
+ expect(rows.find((row) => row.id === "other")?.children.map((child) => child.id)).toEqual([other]);
+ });
+
+ it("refuses to reorder non-editable nodes or swap with a non-editable neighbor", () => {
+ const nodes = [
+ node("start", "start", 0, 0, { data: { kind: "start", label: "Start", column: "todo" } }),
+ node("step", "prompt", 0, 80, { data: { kind: "prompt", label: "Step", column: "todo" } }),
+ node(columnBandNodeId("todo"), "start", -40, 0, {
+ type: "group",
+ data: { kind: "start", label: "Todo", column: "todo" },
+ }),
+ ];
+
+ expect(reorderWorkflowNode(nodes, "start", "down")).toBe(nodes);
+ expect(reorderWorkflowNode(nodes, "step", "up")).toBe(nodes);
+ expect(reorderWorkflowNode(nodes, columnBandNodeId("todo"), "down")).toBe(nodes);
+ });
+});
+
describe("buildMobileWorkflowGraph", () => {
it("returns ordered linear rows with outgoing edge destinations", () => {
const rows = buildMobileWorkflowGraph(
diff --git a/packages/dashboard/app/components/workflow-mobile-graph.ts b/packages/dashboard/app/components/workflow-mobile-graph.ts
index 2a34d8a97e..66174c242e 100644
--- a/packages/dashboard/app/components/workflow-mobile-graph.ts
+++ b/packages/dashboard/app/components/workflow-mobile-graph.ts
@@ -56,6 +56,47 @@ function compareNodePosition(
return Math.round(a.position.x) - Math.round(b.position.x);
}
+function isEditableWorkflowNode(node: FlowNode): boolean {
+ return node.data.kind !== "start" && node.data.kind !== "end" && !isColumnBandNode(node.id);
+}
+
+function isSameReorderGroup(
+ target: FlowNode,
+ candidate: FlowNode,
+): boolean {
+ if (isColumnBandNode(candidate.id)) return false;
+ if (target.parentId || candidate.parentId) return target.parentId === candidate.parentId;
+ return target.data.column === candidate.data.column;
+}
+
+export type WorkflowNodeReorderDirection = "up" | "down";
+
+/**
+ * FNXC:WorkflowSimpleEditor 2026-06-17-02:55:
+ * Simple-editor order is derived from React Flow positions through compareNodePosition, so move controls must swap sibling positions instead of inventing a second ordering field. Keep moves inside the same column group for top-level nodes and inside the same parent group for template children so the re-derived outline and persisted IR stay consistent with canvas placement.
+ */
+export function reorderWorkflowNode(
+ nodes: FlowNode[],
+ nodeId: string,
+ direction: WorkflowNodeReorderDirection,
+): FlowNode[] {
+ const target = nodes.find((node) => node.id === nodeId);
+ if (!target || !isEditableWorkflowNode(target)) return nodes;
+
+ const siblings = nodes
+ .filter((node) => isSameReorderGroup(target, node))
+ .sort(compareNodePosition);
+ const targetIndex = siblings.findIndex((node) => node.id === nodeId);
+ const neighbor = siblings[targetIndex + (direction === "up" ? -1 : 1)];
+ if (!neighbor || !isEditableWorkflowNode(neighbor)) return nodes;
+
+ return nodes.map((node) => {
+ if (node.id === target.id) return { ...node, position: { ...neighbor.position } };
+ if (node.id === neighbor.id) return { ...node, position: { ...target.position } };
+ return node;
+ });
+}
+
function buildColumnNameMap(columns: WorkflowIrColumn[], nodes: FlowNode[]) {
const names = new Map(columns.map((column) => [column.id, column.name || column.id]));
for (const node of nodes) {