feat(workflow): auto-layout steps on AI workflow creation via shared tidy-up (#21756)

## Context

The workflow builder has a "Tidy up" action that auto-positions steps
using a
Dagre layout. However, this lived entirely in the frontend and depended
on node
dimensions measured by React Flow after rendering in the browser.

As a result, workflows (and steps) created through AI Chat / MCP tools
were never
laid out: `create_complete_workflow` accepted optional `stepPositions`
that the
LLM had to invent, and `create_workflow_version_step` stored an optional
position
verbatim. In practice this produced overlapping / poorly positioned
steps.

## What this does

Extracts the tidy-up layout into a pure, frontend-free util in
`twenty-shared` and
reuses it from both the frontend tidy-up and the server, so
AI/MCP-created
workflows are auto-laid out at creation time.

### twenty-shared
- New `computeWorkflowLayout({ nodes, edges, options? })` — a pure Dagre
layout over
a minimal `{ id, width, height }` / `{ source, target }` graph,
returning
top-left-anchored positions (matching React Flow). Ignores edges
pointing to
  unknown nodes.
- New constants: `WORKFLOW_LAYOUT_DEFAULT_OPTIONS`
(ranksep/nodesep/rankdir) and
`WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS` (estimated node size for
server-side
  layout, where measured sizes are unavailable).
- Added `@dagrejs/dagre` dependency.

### twenty-front
- `getOrganizedDiagram` now delegates to `computeWorkflowLayout`,
passing real
  measured node sizes. No behavior change for users.

### twenty-server
- New `WorkflowVersionWorkspaceService.autoLayoutWorkflowVersion(...)`
builds the
graph topology via the existing `buildWorkflowGraph` (covers if-else
branches and
iterator loops), feeds estimated node sizes into
`computeWorkflowLayout`, and
  persists through the existing `updateWorkflowVersionPositions`.
- `create_complete_workflow`: removed `stepPositions` from the tool
schema; the
  server always auto-lays out after creation/edges.
- `create_workflow_version_step`: re-tidies the whole version after each
added step
(wired at the tool level so the builder UI is unaffected) and dropped
the now
  redundant `position` field.

## Notes
- Server-side layout uses estimated node sizes, so it is "good enough";
opening the
workflow and running the existing FE tidy-up refines it with real
measured sizes.
- Auto-layout is wired in the MCP tools, not in the shared creation
service, so
  manual step creation in the builder UI is unchanged.

## Test plan
- [x] `twenty-shared` unit tests for `computeWorkflowLayout` (linear
chain, if-else
  spread, dangling-edge safety)
- [x] `twenty-shared` builds; `twenty-server` and `twenty-front`
typecheck
- [x] Lint/format clean on changed files
- [ ] Create a workflow via AI Chat / MCP and confirm steps are laid out
without
  overlap
- [x] Add a step via MCP and confirm the version is re-tidied
- [ ] Frontend "Tidy up" still behaves as before

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21756?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Etienne
2026-06-18 16:10:04 +02:00
committed by GitHub
parent f1d4d6aeaf
commit c6309fd92b
12 changed files with 275 additions and 62 deletions
@@ -1,36 +1,30 @@
import { type WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram';
import Dagre from '@dagrejs/dagre';
import { computeWorkflowLayout } from 'twenty-shared/workflow';
export const getOrganizedDiagram = (
diagram: WorkflowDiagram,
): WorkflowDiagram => {
const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
graph.setGraph({
ranksep: 80, // Vertical distance between 2 nodes
nodesep: 200, // Horizontal distance between 2 nodes
rankdir: 'TB',
});
diagram.edges.forEach((edge) => graph.setEdge(edge.source, edge.target));
diagram.nodes.forEach((node) =>
graph.setNode(node.id, {
const positions = computeWorkflowLayout({
nodes: diagram.nodes.map((node) => ({
id: node.id,
width: node.measured?.width ?? 0,
height: node.measured?.height ?? 0,
}),
);
})),
edges: diagram.edges.map((edge) => ({
source: edge.source,
target: edge.target,
})),
});
Dagre.layout(graph);
const positionByNodeId = new Map(
positions.map((position) => [position.id, position.position]),
);
return {
nodes: diagram.nodes.map((node) => {
const position = graph.node(node.id);
const position = positionByNodeId.get(node.id);
// We are shifting the dagre node position (anchor=center center) to the top left
// so it matches the React Flow node anchor point (top left).
const x = position.x - position.width / 2;
const y = position.y - position.height / 2;
return { ...node, position: { x, y } };
return position ? { ...node, position } : node;
}),
edges: diagram.edges,
};