FN-6524: add simple editor step reordering

Enable custom workflow authors to reorder simple editor steps from the mobile graph outline.

- Add move up/down controls for editable outline rows while keeping built-in workflows read-only.
- Swap sibling React Flow positions within columns or template parents so persisted order follows existing graph ordering.
- Cover reorder availability, behavior, styling, and documentation updates.

Files changed:
 docs/dashboard-guide.md                            |   2 +-
 .../app/components/MobileWorkflowGraphView.css     |  17 ++-
 .../app/components/MobileWorkflowGraphView.tsx     | 134 +++++++++++++++------
 .../app/components/WorkflowNodeEditor.tsx          |  20 ++-
 .../__tests__/MobileWorkflowGraphView.css.test.ts  |   8 +-
 .../__tests__/MobileWorkflowGraphView.test.tsx     | 123 ++++++++++++++++++-
 .../__tests__/workflow-mobile-graph.test.ts        |  75 +++++++++++-
 .../app/components/workflow-mobile-graph.ts        |  41 +++++++
 8 files changed, 372 insertions(+), 48 deletions(-)

Fusion-Task-Id: FN-6524

Fusion-Task-Lineage: 25ffa5aa-bdfb-4ee3-9365-07029f5bbbf6
This commit is contained in:
gsxdsm
2026-06-17 03:12:16 -07:00
parent 550715d10c
commit 7b3c628fba
8 changed files with 372 additions and 48 deletions

View File

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

View File

@@ -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);
}

View File

@@ -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 (
<div className="mobile-wf-node-group">
@@ -58,17 +78,43 @@ function NodeRow({
</span>
{row.editable ? <Pencil size={14} aria-hidden /> : null}
</button>
{hasChildren ? (
{hasChildren || showReorderControls ? (
<div className="mobile-wf-node-actions">
<button
type="button"
className="mobile-wf-node-expand"
aria-expanded={expanded}
aria-label={expanded ? t("common.collapse", "Collapse") : t("common.expand", "Expand")}
onClick={() => setExpanded((value) => !value)}
>
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
{showReorderControls ? (
<>
<button
type="button"
className="btn-icon mobile-wf-node-move"
data-testid={`mobile-wf-node-move-up-${row.id}`}
aria-label={t("workflowNodes.mobileMoveUp", "Move up")}
disabled={!canMoveUp}
onClick={() => onMoveNode?.(row.id, "up")}
>
<ArrowUp size={16} aria-hidden />
</button>
<button
type="button"
className="btn-icon mobile-wf-node-move"
data-testid={`mobile-wf-node-move-down-${row.id}`}
aria-label={t("workflowNodes.mobileMoveDown", "Move down")}
disabled={!canMoveDown}
onClick={() => onMoveNode?.(row.id, "down")}
>
<ArrowDown size={16} aria-hidden />
</button>
</>
) : null}
{hasChildren ? (
<button
type="button"
className="mobile-wf-node-expand"
aria-expanded={expanded}
aria-label={expanded ? t("common.collapse", "Collapse") : t("common.expand", "Expand")}
onClick={() => setExpanded((value) => !value)}
>
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
) : null}
</div>
) : null}
</div>
@@ -146,18 +192,25 @@ function NodeRow({
)}
{hasChildren && expanded ? (
<div className="mobile-wf-node-children">
{row.children.map((child) => (
<NodeRow
key={child.id}
row={child}
depth={depth + 1}
selectedNodeId={selectedNodeId}
selectedEdgeId={selectedEdgeId}
onSelectNode={onSelectNode}
onSelectEdge={onSelectEdge}
onCreateConnection={onCreateConnection}
/>
))}
{row.children.map((child, index) => {
const move = reorderAvailability(row.children, index);
return (
<NodeRow
key={child.id}
row={child}
depth={depth + 1}
selectedNodeId={selectedNodeId}
selectedEdgeId={selectedEdgeId}
onSelectNode={onSelectNode}
onSelectEdge={onSelectEdge}
onCreateConnection={onCreateConnection}
canReorder={canReorder}
onMoveNode={onMoveNode}
canMoveUp={move.up}
canMoveDown={move.down}
/>
);
})}
</div>
) : null}
</div>
@@ -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 (
<div className="mobile-wf-graph" data-testid="mobile-wf-graph">
{rows.map((row) => (
<NodeRow
key={row.id}
row={row}
depth={0}
selectedNodeId={selectedNodeId}
selectedEdgeId={selectedEdgeId}
onSelectNode={onSelectNode}
onSelectEdge={onSelectEdge}
onCreateConnection={onCreateConnection}
/>
))}
{rows.map((row, index) => {
const move = reorderAvailability(rows, index);
return (
<NodeRow
key={row.id}
row={row}
depth={0}
selectedNodeId={selectedNodeId}
selectedEdgeId={selectedEdgeId}
onSelectNode={onSelectNode}
onSelectEdge={onSelectEdge}
onCreateConnection={onCreateConnection}
canReorder={canReorder}
onMoveNode={onMoveNode}
canMoveUp={move.up}
canMoveDown={move.down}
/>
);
})}
</div>
);
}

View File

@@ -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}
/>
)}

View File

@@ -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*;/);

View File

@@ -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(
<MobileWorkflowGraphView
rows={rows}
selectedNodeId={null}
selectedEdgeId={null}
onSelectNode={() => {}}
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(
<MobileWorkflowGraphView
rows={rows}
selectedNodeId={null}
selectedEdgeId={null}
onSelectNode={() => {}}
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(
<MobileWorkflowGraphView
rows={rows}
selectedNodeId={null}
selectedEdgeId={null}
onSelectNode={() => {}}
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(
<MobileWorkflowGraphView
rows={rows}
selectedNodeId={null}
selectedEdgeId={null}
onSelectNode={() => {}}
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");
});
});

View File

@@ -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<WorkflowFlowNodeData>[]): 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(

View File

@@ -56,6 +56,47 @@ function compareNodePosition(
return Math.round(a.position.x) - Math.round(b.position.x);
}
function isEditableWorkflowNode(node: FlowNode<WorkflowFlowNodeData>): boolean {
return node.data.kind !== "start" && node.data.kind !== "end" && !isColumnBandNode(node.id);
}
function isSameReorderGroup(
target: FlowNode<WorkflowFlowNodeData>,
candidate: FlowNode<WorkflowFlowNodeData>,
): 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<WorkflowFlowNodeData>[],
nodeId: string,
direction: WorkflowNodeReorderDirection,
): FlowNode<WorkflowFlowNodeData>[] {
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<WorkflowFlowNodeData>[]) {
const names = new Map(columns.map((column) => [column.id, column.name || column.id]));
for (const node of nodes) {