From d382d51c75945aa7f3f5fb42950452f00bd9497c Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 21:00:52 -0700 Subject: [PATCH] feat(dashboard): one-click auto-layout respecting column bands --- .../app/components/WorkflowNodeEditor.tsx | 17 +- .../__tests__/WorkflowNodeEditor.test.tsx | 51 +++- .../__tests__/workflow-auto-layout.test.ts | 240 ++++++++++++++++++ .../app/components/workflow-auto-layout.ts | 215 ++++++++++++++++ packages/i18n/locales/en/app.json | 1 + packages/i18n/locales/es/app.json | 1 + packages/i18n/locales/fr/app.json | 1 + packages/i18n/locales/ko/app.json | 1 + packages/i18n/locales/zh-CN/app.json | 1 + packages/i18n/locales/zh-TW/app.json | 1 + packages/i18n/src/resources.d.ts | 1 + 11 files changed, 527 insertions(+), 3 deletions(-) create mode 100644 packages/dashboard/app/components/__tests__/workflow-auto-layout.test.ts create mode 100644 packages/dashboard/app/components/workflow-auto-layout.ts 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)",