diff --git a/packages/dashboard/app/components/WorkflowNodeEditor.tsx b/packages/dashboard/app/components/WorkflowNodeEditor.tsx
index 262386195e..6979b4baa1 100644
--- a/packages/dashboard/app/components/WorkflowNodeEditor.tsx
+++ b/packages/dashboard/app/components/WorkflowNodeEditor.tsx
@@ -14,7 +14,7 @@ import {
type Edge as FlowEdge,
} from "@xyflow/react";
import { useTranslation } from "react-i18next";
-import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2 } from "lucide-react";
+import { X, Plus, Trash2, Save, MessageSquare, Terminal, Shield, GitMerge, Loader2, HelpCircle, PauseCircle, Split, Merge, Repeat, ClipboardCheck, ListChecks, Code2, LayoutGrid } from "lucide-react";
import type { WorkflowDefinition, WorkflowIrColumn, TraitViolation } from "@fusion/core";
import { getErrorMessage } from "@fusion/core";
import {
@@ -61,6 +61,7 @@ import {
FOREACH_CHILD_X,
FOREACH_CHILD_Y,
} from "./workflow-flow-mapping";
+import { autoLayout, applyAutoLayout } from "./workflow-auto-layout";
import { fetchTraits, fetchStepParsers, type TraitCatalogEntry } from "../api";
import { WorkflowColumnPanel } from "./WorkflowColumnPanel";
import { WorkflowFieldsPanel } from "./WorkflowFieldsPanel";
@@ -542,6 +543,13 @@ function InnerEditor({
[setNodes, t],
);
+ // Auto-layout: one-click left-to-right tidy (U5, R8). Recomputes positions
+ // only; bands and foreach template children are left in place. Marks the
+ // editor dirty automatically via the layout serialization in isDirty.
+ const handleAutoLayout = useCallback(() => {
+ setNodes((ns) => applyAutoLayout(ns, autoLayout(ns, edges, columns)));
+ }, [setNodes, edges, columns]);
+
const updateSelectedData = useCallback(
(
patch:
@@ -1175,6 +1183,13 @@ function InnerEditor({
))}
+
diff --git a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx
index 82bac6536f..3b2f8f8a40 100644
--- a/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx
+++ b/packages/dashboard/app/components/__tests__/WorkflowNodeEditor.test.tsx
@@ -329,6 +329,47 @@ describe("WorkflowNodeEditor — U3 deletion", () => {
});
});
+describe("WorkflowNodeEditor — U5 auto-layout", () => {
+ beforeEach(() => {
+ vi.mocked(fetchTraits).mockResolvedValue(TRAIT_CATALOG);
+ vi.mocked(fetchStepParsers).mockResolvedValue(["step-headings", "json-steps"]);
+ vi.mocked(fetchModels).mockResolvedValue({ models: [] });
+ });
+ afterEach(() => cleanup());
+
+ it("shows the Auto-layout button for an editable workflow", async () => {
+ vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
+ render( {}} addToast={() => {}} />);
+ await screen.findByTestId("wf-node-start");
+ expect(screen.getByTestId("wf-auto-layout")).toBeInTheDocument();
+ });
+
+ it("does not show the Auto-layout button for a built-in workflow", async () => {
+ vi.mocked(fetchWorkflows).mockResolvedValue([builtinDef()]);
+ render( {}} addToast={() => {}} />);
+ await screen.findByTestId("wf-readonly-banner");
+ expect(screen.queryByTestId("wf-auto-layout")).not.toBeInTheDocument();
+ });
+
+ it("repositions nodes on click (a node's transform changes)", async () => {
+ vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
+ const { container } = render(
+ {}} addToast={() => {}} />,
+ );
+ await screen.findByTestId("wf-node-start");
+ // 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"));
+ await waitFor(() => {
+ const after = wrapperFor("step")?.style.transform ?? "";
+ expect(after).not.toBe("");
+ expect(after).not.toBe(before);
+ });
+ });
+});
+
// ── U8: step-inversion authoring (foreach/step-review/parse-steps/code) ──────
/** A custom v2 workflow with a foreach (one step-execute child + a step-review)
@@ -415,7 +456,9 @@ describe("WorkflowNodeEditor — U8 step-inversion authoring", () => {
// Adding a foreach renders a group node with an empty inspector hint absent
// (it has a child) and an inspector for the foreach.
fireEvent.click(screen.getByText("For-each step").closest("button")!);
- await waitFor(() => expect(screen.getByTestId("wf-node-foreach")).toBeInTheDocument());
+ // 3s timeout: React Flow group-node mount can exceed the 1s default under
+ // cold-transform shard load (observed intermittently in CI-like runs).
+ await waitFor(() => expect(screen.getByTestId("wf-node-foreach")).toBeInTheDocument(), { timeout: 3000 });
// The foreach inspector shows the Mode select (KTD-3).
expect(screen.getByText("Mode")).toBeInTheDocument();
// No empty-state hint because the palette seeded a step-execute child.
@@ -854,7 +897,11 @@ describe("WorkflowNodeEditor — U4 create dialog / delete / inline rename / dir
it("cancels an inline rename on Escape (value reverts)", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([v2Def()]);
render( {}} addToast={() => {}} />);
- fireEvent.click(await screen.findByTestId("wf-workflow-name"));
+ // Wait for the editor to fully stabilize (column panel rendered) before
+ // interacting — clicking mid-load races the initial render cycle.
+ await screen.findByText("Save");
+ await waitFor(() => expect(screen.getAllByLabelText(/Column name/i).length).toBeGreaterThan(0));
+ fireEvent.click(screen.getByTestId("wf-workflow-name"));
const input = (await screen.findByTestId("wf-workflow-name-input")) as HTMLInputElement;
fireEvent.change(input, { target: { value: "Throwaway" } });
fireEvent.keyDown(input, { key: "Escape" });
diff --git a/packages/dashboard/app/components/__tests__/workflow-auto-layout.test.ts b/packages/dashboard/app/components/__tests__/workflow-auto-layout.test.ts
new file mode 100644
index 0000000000..919ae38bfd
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/workflow-auto-layout.test.ts
@@ -0,0 +1,240 @@
+import { describe, it, expect } from "vitest";
+import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react";
+import type { WorkflowIrColumn } from "@fusion/core";
+import type { WorkflowFlowNodeData } from "../nodes/WorkflowNodeTypes";
+import { autoLayout, applyAutoLayout } from "../workflow-auto-layout";
+import {
+ strictColumnForY,
+ bandTop,
+ columnBandNodeId,
+ COLUMN_BAND_HEIGHT,
+} from "../workflow-flow-mapping";
+
+type N = FlowNode;
+
+function node(
+ id: string,
+ kind: WorkflowFlowNodeData["kind"],
+ x: number,
+ y: number,
+ extra: Partial & { column?: string } = {},
+): N {
+ const { column, ...rest } = extra;
+ return {
+ id,
+ type: kind,
+ position: { x, y },
+ data: { kind, label: id, ...(column ? { column } : {}) },
+ ...rest,
+ } as N;
+}
+
+function edge(source: string, target: string, kind?: "rework"): FlowEdge {
+ return {
+ id: `e-${source}-${target}`,
+ source,
+ target,
+ data: { condition: "success", kind },
+ };
+}
+
+const COLUMNS_3: WorkflowIrColumn[] = [
+ { id: "triage", name: "Triage", traits: [] },
+ { id: "in-progress", name: "In progress", traits: [] },
+ { id: "done", name: "Done", traits: [] },
+];
+
+/** Mid-band y for a column index (a stable starting placement). */
+function midBand(index: number): number {
+ return bandTop(index) + COLUMN_BAND_HEIGHT / 2;
+}
+
+describe("autoLayout — v2 (column-preserving)", () => {
+ it("linear chain: strictly increasing x and every node keeps its column", () => {
+ const nodes: N[] = [
+ node("start", "start", 999, midBand(0), { column: "triage" }),
+ node("a", "prompt", 50, midBand(1), { column: "in-progress" }),
+ node("b", "prompt", 10, midBand(1), { column: "in-progress" }),
+ node("end", "end", 0, midBand(2), { column: "done" }),
+ ];
+ const edges = [edge("start", "a"), edge("a", "b"), edge("b", "end")];
+ const pos = autoLayout(nodes, edges, COLUMNS_3);
+
+ const xs = ["start", "a", "b", "end"].map((id) => pos.get(id)!.x);
+ for (let i = 1; i < xs.length; i++) {
+ expect(xs[i]).toBeGreaterThan(xs[i - 1]);
+ }
+
+ // Invariant: column unchanged for every node.
+ for (const n of nodes) {
+ const original = n.data.column!;
+ const newY = pos.get(n.id)!.y;
+ expect(strictColumnForY(newY, COLUMNS_3)).toBe(original);
+ }
+ });
+
+ it("branching graph: two branch targets get distinct positions", () => {
+ const nodes: N[] = [
+ node("start", "start", 0, midBand(0), { column: "triage" }),
+ node("ok", "prompt", 0, midBand(1), { column: "in-progress" }),
+ node("fail", "prompt", 0, midBand(1), { column: "in-progress" }),
+ ];
+ const edges = [edge("start", "ok"), edge("start", "fail")];
+ const pos = autoLayout(nodes, edges, COLUMNS_3);
+
+ const a = pos.get("ok")!;
+ const b = pos.get("fail")!;
+ expect(a.x === b.x && a.y === b.y).toBe(false);
+ // Same layer + same band → stacked vertically.
+ expect(a.x).toBe(b.x);
+ expect(a.y).not.toBe(b.y);
+ expect(strictColumnForY(a.y, COLUMNS_3)).toBe("in-progress");
+ expect(strictColumnForY(b.y, COLUMNS_3)).toBe("in-progress");
+ });
+
+ it("dense band: more same-layer/same-band nodes than fit 220px stay in-band, staggered x, no collisions", () => {
+ const count = 12;
+ const nodes: N[] = [node("start", "start", 0, midBand(0), { column: "triage" })];
+ const edges: FlowEdge[] = [];
+ for (let i = 0; i < count; i++) {
+ nodes.push(node(`n${i}`, "prompt", 0, midBand(1), { column: "in-progress" }));
+ edges.push(edge("start", `n${i}`));
+ }
+ const pos = autoLayout(nodes, edges, COLUMNS_3);
+
+ const seen = new Set();
+ for (let i = 0; i < count; i++) {
+ const p = pos.get(`n${i}`)!;
+ // All stay in their band.
+ expect(strictColumnForY(p.y, COLUMNS_3)).toBe("in-progress");
+ // No two nodes share a position.
+ const key = `${p.x},${p.y}`;
+ expect(seen.has(key)).toBe(false);
+ seen.add(key);
+ }
+ // Overflow forced horizontal staggering (more than one distinct x).
+ const distinctX = new Set([...seen].map((k) => k.split(",")[0]));
+ expect(distinctX.size).toBeGreaterThan(1);
+ });
+
+ it("derives column from y when data.column is absent", () => {
+ const nodes: N[] = [
+ node("start", "start", 0, midBand(0)),
+ node("a", "prompt", 0, midBand(2)),
+ ];
+ const pos = autoLayout(nodes, [edge("start", "a")], COLUMNS_3);
+ expect(strictColumnForY(pos.get("start")!.y, COLUMNS_3)).toBe("triage");
+ expect(strictColumnForY(pos.get("a")!.y, COLUMNS_3)).toBe("done");
+ });
+});
+
+describe("autoLayout — v1 (free placement)", () => {
+ it("produces layered positions, no NaN, deterministic across two calls", () => {
+ const nodes: N[] = [
+ node("start", "start", 0, 0),
+ node("a", "prompt", 0, 0),
+ node("b", "prompt", 0, 0),
+ node("end", "end", 0, 0),
+ ];
+ const edges = [edge("start", "a"), edge("start", "b"), edge("a", "end"), edge("b", "end")];
+ const p1 = autoLayout(nodes, edges, []);
+ const p2 = autoLayout(nodes, edges, []);
+
+ for (const n of nodes) {
+ const p = p1.get(n.id)!;
+ expect(Number.isFinite(p.x)).toBe(true);
+ expect(Number.isFinite(p.y)).toBe(true);
+ }
+ // start before branches before end.
+ expect(p1.get("start")!.x).toBeLessThan(p1.get("a")!.x);
+ expect(p1.get("a")!.x).toBeLessThan(p1.get("end")!.x);
+ // Deterministic.
+ for (const n of nodes) {
+ expect(p1.get(n.id)).toEqual(p2.get(n.id));
+ }
+ });
+});
+
+describe("autoLayout — foreach / unreachable / cycles", () => {
+ it("repositions a foreach group but leaves its parentId children untouched", () => {
+ const childPos = { x: 30, y: 56 };
+ const nodes: N[] = [
+ node("start", "start", 0, midBand(0), { column: "triage" }),
+ node("grp", "foreach", 999, midBand(1), { column: "in-progress" }),
+ {
+ id: "grp::c1",
+ type: "prompt",
+ position: { ...childPos },
+ parentId: "grp",
+ extent: "parent",
+ data: { kind: "prompt", label: "c1" },
+ } as N,
+ ];
+ const edges = [edge("start", "grp")];
+ const pos = autoLayout(nodes, edges, COLUMNS_3);
+
+ // Group moved.
+ expect(pos.has("grp")).toBe(true);
+ expect(pos.get("grp")!.x).not.toBe(999);
+ // Child not in the position map → untouched.
+ expect(pos.has("grp::c1")).toBe(false);
+
+ const applied = applyAutoLayout(nodes, pos);
+ const child = applied.find((n) => n.id === "grp::c1")!;
+ expect(child.position).toEqual(childPos);
+ });
+
+ it("gives an unreachable node a finite position in a trailing layer", () => {
+ const nodes: N[] = [
+ node("start", "start", 0, midBand(0), { column: "triage" }),
+ node("a", "prompt", 0, midBand(1), { column: "in-progress" }),
+ node("orphan", "prompt", 0, midBand(2), { column: "done" }),
+ ];
+ const edges = [edge("start", "a")];
+ const pos = autoLayout(nodes, edges, COLUMNS_3);
+
+ const o = pos.get("orphan")!;
+ expect(Number.isFinite(o.x)).toBe(true);
+ expect(Number.isFinite(o.y)).toBe(true);
+ // Trailing layer is past the reachable nodes.
+ expect(o.x).toBeGreaterThan(pos.get("a")!.x);
+ expect(strictColumnForY(o.y, COLUMNS_3)).toBe("done");
+ });
+
+ it("terminates and stays sane with a rework cycle edge present", () => {
+ const nodes: N[] = [
+ node("start", "start", 0, midBand(0), { column: "triage" }),
+ node("a", "prompt", 0, midBand(1), { column: "in-progress" }),
+ node("b", "prompt", 0, midBand(1), { column: "in-progress" }),
+ node("end", "end", 0, midBand(2), { column: "done" }),
+ ];
+ const edges = [
+ edge("start", "a"),
+ edge("a", "b"),
+ edge("b", "end"),
+ edge("b", "a", "rework"), // rework loop — ignored for layering
+ ];
+ const pos = autoLayout(nodes, edges, COLUMNS_3);
+
+ // Layering ignores rework: a strictly before b.
+ expect(pos.get("a")!.x).toBeLessThan(pos.get("b")!.x);
+ for (const n of nodes) {
+ expect(strictColumnForY(pos.get(n.id)!.y, COLUMNS_3)).toBe(n.data.column);
+ }
+ });
+
+ it("ignores column band group nodes", () => {
+ const nodes: N[] = [
+ {
+ id: columnBandNodeId("triage"),
+ type: "group",
+ position: { x: -40, y: bandTop(0) },
+ data: { kind: "start", label: "Triage" },
+ } as N,
+ node("start", "start", 0, midBand(0), { column: "triage" }),
+ ];
+ const pos = autoLayout(nodes, [], COLUMNS_3);
+ expect(pos.has(columnBandNodeId("triage"))).toBe(false);
+ expect(pos.has("start")).toBe(true);
+ });
+});
diff --git a/packages/dashboard/app/components/workflow-auto-layout.ts b/packages/dashboard/app/components/workflow-auto-layout.ts
new file mode 100644
index 0000000000..e2c042be2b
--- /dev/null
+++ b/packages/dashboard/app/components/workflow-auto-layout.ts
@@ -0,0 +1,215 @@
+import type { Node as FlowNode, Edge as FlowEdge } from "@xyflow/react";
+import type { WorkflowIrColumn } from "@fusion/core";
+import type { WorkflowFlowNodeData } from "./nodes/WorkflowNodeTypes";
+import {
+ WF_CARD_MAX_WIDTH,
+ WF_CARD_HEIGHT,
+ COLUMN_BAND_HEIGHT,
+ bandTop,
+ columnForY,
+ isColumnBandNode,
+} from "./workflow-flow-mapping";
+
+// ── One-click auto-layout (U5, R8) ───────────────────────────────────────────
+//
+// Pure left-to-right "tidy" that NEVER re-columns or unplaces a node. Layering
+// derives x from graph topology (longest-path from the start node, ignoring
+// rework edges and tolerating cycles); y is constrained per-node to the band of
+// the column the node already belongs to (v2) or assigned by within-layer index
+// (v1). When same-layer/same-band nodes exceed a band's vertical capacity,
+// overflow staggers horizontally (extra x offset) rather than escaping the band
+// — preserving the test-enforced invariant strictColumnForY(newY) === original
+// column for every node. Foreach group nodes move as units; their parentId
+// template children are positioned parent-relative and are left untouched.
+
+/** Horizontal gap between layer columns (added to the card max-width to derive
+ * the per-layer x spacing). Exported so U5's tests and any future tuning share
+ * the single source of truth rather than duplicating the number. */
+export const WF_AUTO_LAYOUT_GAP_X = 80;
+
+/** Vertical gap between stacked same-layer/same-band cards. */
+export const WF_AUTO_LAYOUT_GAP_Y = 24;
+
+/** Padding inside a band before the first card / after the last card row. */
+export const WF_AUTO_LAYOUT_BAND_PADDING = 16;
+
+/** Per-layer horizontal spacing: a full card-width plus the gap. */
+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;
+
+/** Row height for a stacked card (card + vertical gap). */
+const ROW_HEIGHT = WF_CARD_HEIGHT + WF_AUTO_LAYOUT_GAP_Y;
+
+type LayoutNode = FlowNode;
+
+/** A node is layoutable by auto-layout when it is a top-level step node: not a
+ * column band group and not a foreach template child (parentId set). Foreach
+ * GROUP nodes ARE layoutable (they move as a unit). */
+function isLayoutable(node: LayoutNode): boolean {
+ if (isColumnBandNode(node.id)) return false;
+ if (node.parentId) return false;
+ return true;
+}
+
+/**
+ * Assign each layoutable node a layer index via longest-path layering from the
+ * start node. Rework edges (data.kind === "rework") are ignored for layering.
+ * Cycle-safe: a per-node depth cap plus a visited guard bounds the relaxation so
+ * non-rework cycles (should not occur, but be defensive) cannot loop forever.
+ * Nodes unreachable from start land in a trailing layer (max + 1).
+ */
+function layerNodes(nodeIds: string[], edges: FlowEdge[]): Map {
+ const idSet = new Set(nodeIds);
+ // Adjacency over non-rework edges whose endpoints are both layoutable.
+ const adj = new Map();
+ const indegree = new Map();
+ for (const id of nodeIds) indegree.set(id, 0);
+ for (const e of edges) {
+ if ((e.data?.kind as string | undefined) === "rework") continue;
+ if (!idSet.has(e.source) || !idSet.has(e.target)) continue;
+ if (e.source === e.target) continue;
+ (adj.get(e.source) ?? adj.set(e.source, []).get(e.source)!).push(e.target);
+ indegree.set(e.target, (indegree.get(e.target) ?? 0) + 1);
+ }
+
+ // Roots: explicit start node(s), plus any node with no incoming layering edge
+ // (so isolated graphs without a "start" still get laid out).
+ const startIds = nodeIds.filter((id) => id === "start");
+ const roots = startIds.length
+ ? startIds
+ : nodeIds.filter((id) => (indegree.get(id) ?? 0) === 0);
+
+ const layer = new Map();
+ // BFS longest-path relaxation. The cap bounds work in the presence of any
+ // accidental non-rework cycle: a node can be relaxed at most nodeIds.length
+ // times before its layer would exceed the maximum possible acyclic depth.
+ const cap = nodeIds.length + 1;
+ const queue: string[] = [];
+ for (const r of roots) {
+ layer.set(r, 0);
+ queue.push(r);
+ }
+ let guard = nodeIds.length * nodeIds.length + nodeIds.length + 1;
+ while (queue.length && guard-- > 0) {
+ const cur = queue.shift()!;
+ const curLayer = layer.get(cur) ?? 0;
+ for (const next of adj.get(cur) ?? []) {
+ const candidate = curLayer + 1;
+ const existing = layer.get(next);
+ if ((existing === undefined || candidate > existing) && candidate <= cap) {
+ layer.set(next, candidate);
+ queue.push(next);
+ }
+ }
+ }
+
+ // Unreachable nodes → a trailing layer after the deepest reached layer.
+ let maxLayer = 0;
+ for (const v of layer.values()) if (v > maxLayer) maxLayer = v;
+ const trailing = layer.size ? maxLayer + 1 : 0;
+ for (const id of nodeIds) {
+ if (!layer.has(id)) layer.set(id, trailing);
+ }
+ return layer;
+}
+
+/** Result: nodeId → new absolute position. Only positions change. */
+export type AutoLayoutPositions = Map;
+
+/**
+ * Compute new positions for the layoutable nodes. Returns a Map keyed by node
+ * id; nodes not in the map (band groups, foreach children) keep their current
+ * positions. The caller applies the map via setNodes (positions only).
+ *
+ * @param nodes current flow nodes (bands + steps + foreach children)
+ * @param edges current flow edges
+ * @param columns the authored v2 columns (empty array ⇒ v1 free placement)
+ */
+export function autoLayout(
+ nodes: LayoutNode[],
+ edges: FlowEdge[],
+ columns: WorkflowIrColumn[],
+): AutoLayoutPositions {
+ const layoutables = nodes.filter(isLayoutable);
+ const ids = layoutables.map((n) => n.id);
+ const layer = layerNodes(ids, edges);
+
+ // Stable sort within a layer: current y, then id. Deterministic across calls.
+ const byId = new Map(layoutables.map((n) => [n.id, n]));
+ const positions: AutoLayoutPositions = new Map();
+
+ // Group node ids by layer.
+ const layers = new Map();
+ for (const id of ids) {
+ const l = layer.get(id) ?? 0;
+ (layers.get(l) ?? layers.set(l, []).get(l)!).push(id);
+ }
+
+ const v2 = columns.length > 0;
+
+ for (const [layerIndex, layerIds] of layers) {
+ const sorted = [...layerIds].sort((a, b) => {
+ const na = byId.get(a)!;
+ const nb = byId.get(b)!;
+ if (na.position.y !== nb.position.y) return na.position.y - nb.position.y;
+ return a < b ? -1 : a > b ? 1 : 0;
+ });
+
+ const layerX = ORIGIN_X + layerIndex * WF_AUTO_LAYOUT_SPACING;
+
+ if (!v2) {
+ // v1: free placement — within-layer index × row height.
+ sorted.forEach((id, withinIdx) => {
+ positions.set(id, { x: layerX, y: ORIGIN_Y + withinIdx * ROW_HEIGHT });
+ });
+ continue;
+ }
+
+ // v2: each node KEEPS its column. Track the next free row per column so
+ // same-layer/same-band nodes stack downward; overflow staggers x.
+ // Per (column, stagger-bucket) we track how many rows are filled.
+ const rowsPerColumn = new Map();
+ for (const id of sorted) {
+ const node = byId.get(id)!;
+ const colId = node.data.column ?? columnForY(node.position.y, columns);
+ const colIndex = colId ? columns.findIndex((c) => c.id === colId) : -1;
+ const safeColIndex = colIndex >= 0 ? colIndex : 0;
+
+ const top = bandTop(safeColIndex);
+ const firstY = top + WF_AUTO_LAYOUT_BAND_PADDING;
+ // Last y at which a card still fits fully inside the band.
+ const maxY = top + COLUMN_BAND_HEIGHT - WF_CARD_HEIGHT - WF_AUTO_LAYOUT_BAND_PADDING;
+ const capacity = Math.max(1, Math.floor((maxY - firstY) / ROW_HEIGHT) + 1);
+
+ const used = rowsPerColumn.get(colId ?? `__idx${safeColIndex}`) ?? 0;
+ const rowInBucket = used % capacity;
+ const staggerBucket = Math.floor(used / capacity);
+
+ const y = firstY + rowInBucket * ROW_HEIGHT;
+ // Stagger x by half-spacing per overflow bucket so wrapped rows don't
+ // collide with the un-staggered column while staying in the same band.
+ const x = layerX + staggerBucket * (WF_AUTO_LAYOUT_SPACING / 2);
+
+ positions.set(id, { x, y });
+ rowsPerColumn.set(colId ?? `__idx${safeColIndex}`, used + 1);
+ }
+ }
+
+ return positions;
+}
+
+/** Apply auto-layout positions to a node list, returning a new array. Only
+ * positions of mapped nodes change; everything else is preserved by reference
+ * shape. Convenience for setNodes in the editor. */
+export function applyAutoLayout(
+ nodes: LayoutNode[],
+ positions: AutoLayoutPositions,
+): LayoutNode[] {
+ return nodes.map((n) => {
+ const pos = positions.get(n.id);
+ return pos ? { ...n, position: pos } : n;
+ });
+}
diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json
index 5b2e136c9b..5be1155102 100644
--- a/packages/i18n/locales/en/app.json
+++ b/packages/i18n/locales/en/app.json
@@ -6757,6 +6757,7 @@
},
"workflowNodes": {
"advisory": "Advisory",
+ "autoLayout": "Auto-layout",
"codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
"codeSource": "Source (TypeScript)",
"codeTimeout": "Timeout (ms)",
diff --git a/packages/i18n/locales/es/app.json b/packages/i18n/locales/es/app.json
index bcfde8ea3b..b5cf2e4fe2 100644
--- a/packages/i18n/locales/es/app.json
+++ b/packages/i18n/locales/es/app.json
@@ -6757,6 +6757,7 @@
},
"workflowNodes": {
"advisory": "",
+ "autoLayout": "",
"codeNote": "Ejecuta TypeScript en un entorno aislado. La sintaxis se valida al guardar.",
"codeSource": "Origen (TypeScript)",
"codeTimeout": "Tiempo de espera (ms)",
diff --git a/packages/i18n/locales/fr/app.json b/packages/i18n/locales/fr/app.json
index d72504423a..bf378d4d82 100644
--- a/packages/i18n/locales/fr/app.json
+++ b/packages/i18n/locales/fr/app.json
@@ -6757,6 +6757,7 @@
},
"workflowNodes": {
"advisory": "",
+ "autoLayout": "",
"codeNote": "Exécute du TypeScript en bac à sable. La syntaxe est validée à l’enregistrement.",
"codeSource": "Source (TypeScript)",
"codeTimeout": "Délai d’expiration (ms)",
diff --git a/packages/i18n/locales/ko/app.json b/packages/i18n/locales/ko/app.json
index b166392597..48ac35e960 100644
--- a/packages/i18n/locales/ko/app.json
+++ b/packages/i18n/locales/ko/app.json
@@ -6757,6 +6757,7 @@
},
"workflowNodes": {
"advisory": "",
+ "autoLayout": "",
"codeNote": "샌드박스에서 TypeScript를 실행합니다. 구문은 저장 시 검증됩니다.",
"codeSource": "소스(TypeScript)",
"codeTimeout": "제한 시간(ms)",
diff --git a/packages/i18n/locales/zh-CN/app.json b/packages/i18n/locales/zh-CN/app.json
index 1ef8108e63..4254c20a8d 100644
--- a/packages/i18n/locales/zh-CN/app.json
+++ b/packages/i18n/locales/zh-CN/app.json
@@ -6757,6 +6757,7 @@
},
"workflowNodes": {
"advisory": "",
+ "autoLayout": "",
"codeNote": "在沙箱中运行 TypeScript。语法在保存时校验。",
"codeSource": "源代码(TypeScript)",
"codeTimeout": "超时(毫秒)",
diff --git a/packages/i18n/locales/zh-TW/app.json b/packages/i18n/locales/zh-TW/app.json
index 15e7a874f5..39eea53ea1 100644
--- a/packages/i18n/locales/zh-TW/app.json
+++ b/packages/i18n/locales/zh-TW/app.json
@@ -6757,6 +6757,7 @@
},
"workflowNodes": {
"advisory": "",
+ "autoLayout": "",
"codeNote": "在沙箱中執行 TypeScript。語法會在儲存時驗證。",
"codeSource": "原始碼(TypeScript)",
"codeTimeout": "逾時(毫秒)",
diff --git a/packages/i18n/src/resources.d.ts b/packages/i18n/src/resources.d.ts
index a42a447cae..011e72c528 100644
--- a/packages/i18n/src/resources.d.ts
+++ b/packages/i18n/src/resources.d.ts
@@ -6759,6 +6759,7 @@ export default interface Resources {
},
"workflowNodes": {
"advisory": "Advisory",
+ "autoLayout": "Auto-layout",
"codeNote": "Runs sandboxed TypeScript. Syntax is validated at save.",
"codeSource": "Source (TypeScript)",
"codeTimeout": "Timeout (ms)",