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:
@@ -18,6 +18,15 @@ export { WORKFLOW_TRIGGER_METADATA_WORKSPACE_MEMBER_ID_KEY } from './constants/W
|
||||
export { WORKFLOW_TRIGGER_METADATA_WORKSPACE_MEMBER_ID_LABEL } from './constants/WorkflowTriggerMetadataWorkspaceMemberIdLabel';
|
||||
export { WORKFLOW_TRIGGER_PAYLOAD_KEY } from './constants/WorkflowTriggerPayloadKey';
|
||||
export { WORKFLOW_TRIGGER_PAYLOAD_LABEL } from './constants/WorkflowTriggerPayloadLabel';
|
||||
export { WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS } from './layout/constants/WorkflowDiagramDefaultNodeDimensions';
|
||||
export { WORKFLOW_LAYOUT_DEFAULT_OPTIONS } from './layout/constants/WorkflowLayoutDefaultOptions';
|
||||
export type {
|
||||
WorkflowLayoutNode,
|
||||
WorkflowLayoutEdge,
|
||||
WorkflowLayoutPosition,
|
||||
WorkflowLayoutOptions,
|
||||
} from './layout/utils/compute-workflow-layout.util';
|
||||
export { computeWorkflowLayout } from './layout/utils/compute-workflow-layout.util';
|
||||
export { workflowAiAgentActionSchema } from './schemas/ai-agent-action-schema';
|
||||
export { workflowAiAgentActionSettingsSchema } from './schemas/ai-agent-action-settings-schema';
|
||||
export { baseTriggerSchema } from './schemas/base-trigger-schema';
|
||||
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export const WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS = {
|
||||
width: 240,
|
||||
height: 52,
|
||||
} as const;
|
||||
@@ -0,0 +1,5 @@
|
||||
export const WORKFLOW_LAYOUT_DEFAULT_OPTIONS = {
|
||||
ranksep: 80, // Vertical distance between 2 nodes
|
||||
nodesep: 200, // Horizontal distance between 2 nodes
|
||||
rankdir: 'TB',
|
||||
} as const;
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
import { WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS } from '@/workflow/layout/constants/WorkflowDiagramDefaultNodeDimensions';
|
||||
import {
|
||||
computeWorkflowLayout,
|
||||
type WorkflowLayoutEdge,
|
||||
type WorkflowLayoutNode,
|
||||
} from '@/workflow/layout/utils/compute-workflow-layout.util';
|
||||
|
||||
const buildNode = (id: string): WorkflowLayoutNode => ({
|
||||
id,
|
||||
...WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS,
|
||||
});
|
||||
|
||||
const doNodesOverlap = (
|
||||
a: { x: number; y: number },
|
||||
b: { x: number; y: number },
|
||||
): boolean => {
|
||||
const { width, height } = WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS;
|
||||
|
||||
return Math.abs(a.x - b.x) < width && Math.abs(a.y - b.y) < height;
|
||||
};
|
||||
|
||||
describe('computeWorkflowLayout', () => {
|
||||
it('should return a position for every node', () => {
|
||||
const nodes = [buildNode('trigger'), buildNode('a'), buildNode('b')];
|
||||
const edges: WorkflowLayoutEdge[] = [
|
||||
{ source: 'trigger', target: 'a' },
|
||||
{ source: 'a', target: 'b' },
|
||||
];
|
||||
|
||||
const positions = computeWorkflowLayout({ nodes, edges });
|
||||
|
||||
expect(positions).toHaveLength(3);
|
||||
expect(positions.map((position) => position.id).sort()).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'trigger',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should stack a linear chain vertically without overlap (rankdir TB)', () => {
|
||||
const nodes = [buildNode('trigger'), buildNode('a'), buildNode('b')];
|
||||
const edges: WorkflowLayoutEdge[] = [
|
||||
{ source: 'trigger', target: 'a' },
|
||||
{ source: 'a', target: 'b' },
|
||||
];
|
||||
|
||||
const positionById = new Map(
|
||||
computeWorkflowLayout({ nodes, edges }).map((position) => [
|
||||
position.id,
|
||||
position.position,
|
||||
]),
|
||||
);
|
||||
|
||||
const trigger = positionById.get('trigger')!;
|
||||
const stepA = positionById.get('a')!;
|
||||
const stepB = positionById.get('b')!;
|
||||
|
||||
expect(stepA.y).toBeGreaterThan(trigger.y);
|
||||
expect(stepB.y).toBeGreaterThan(stepA.y);
|
||||
});
|
||||
|
||||
it('should spread if-else branches horizontally without overlap', () => {
|
||||
const nodes = [
|
||||
buildNode('trigger'),
|
||||
buildNode('ifElse'),
|
||||
buildNode('branchA'),
|
||||
buildNode('branchB'),
|
||||
];
|
||||
const edges: WorkflowLayoutEdge[] = [
|
||||
{ source: 'trigger', target: 'ifElse' },
|
||||
{ source: 'ifElse', target: 'branchA' },
|
||||
{ source: 'ifElse', target: 'branchB' },
|
||||
];
|
||||
|
||||
const positionById = new Map(
|
||||
computeWorkflowLayout({ nodes, edges }).map((position) => [
|
||||
position.id,
|
||||
position.position,
|
||||
]),
|
||||
);
|
||||
|
||||
const branchA = positionById.get('branchA')!;
|
||||
const branchB = positionById.get('branchB')!;
|
||||
|
||||
expect(branchA.x).not.toEqual(branchB.x);
|
||||
expect(doNodesOverlap(branchA, branchB)).toBe(false);
|
||||
});
|
||||
|
||||
it('should ignore edges pointing to unknown nodes', () => {
|
||||
const nodes = [buildNode('trigger'), buildNode('a')];
|
||||
const edges: WorkflowLayoutEdge[] = [
|
||||
{ source: 'trigger', target: 'a' },
|
||||
{ source: 'a', target: 'does-not-exist' },
|
||||
];
|
||||
|
||||
expect(() => computeWorkflowLayout({ nodes, edges })).not.toThrow();
|
||||
expect(computeWorkflowLayout({ nodes, edges })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import Dagre from '@dagrejs/dagre';
|
||||
|
||||
import { WORKFLOW_LAYOUT_DEFAULT_OPTIONS } from '@/workflow/layout/constants/WorkflowLayoutDefaultOptions';
|
||||
|
||||
export type WorkflowLayoutNode = {
|
||||
id: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
export type WorkflowLayoutEdge = {
|
||||
source: string;
|
||||
target: string;
|
||||
};
|
||||
|
||||
export type WorkflowLayoutPosition = {
|
||||
id: string;
|
||||
position: { x: number; y: number };
|
||||
};
|
||||
|
||||
export type WorkflowLayoutOptions = {
|
||||
ranksep: number;
|
||||
nodesep: number;
|
||||
rankdir: string;
|
||||
};
|
||||
|
||||
export const computeWorkflowLayout = ({
|
||||
nodes,
|
||||
edges,
|
||||
options = WORKFLOW_LAYOUT_DEFAULT_OPTIONS,
|
||||
}: {
|
||||
nodes: WorkflowLayoutNode[];
|
||||
edges: WorkflowLayoutEdge[];
|
||||
options?: WorkflowLayoutOptions;
|
||||
}): WorkflowLayoutPosition[] => {
|
||||
const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
graph.setGraph({
|
||||
ranksep: options.ranksep,
|
||||
nodesep: options.nodesep,
|
||||
rankdir: options.rankdir,
|
||||
});
|
||||
|
||||
const nodeIds = new Set(nodes.map((node) => node.id));
|
||||
|
||||
nodes.forEach((node) =>
|
||||
graph.setNode(node.id, {
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
}),
|
||||
);
|
||||
|
||||
edges.forEach((edge) => {
|
||||
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
|
||||
graph.setEdge(edge.source, edge.target);
|
||||
}
|
||||
});
|
||||
|
||||
Dagre.layout(graph);
|
||||
|
||||
return nodes.map((node) => {
|
||||
const layoutedNode = graph.node(node.id);
|
||||
|
||||
const x = layoutedNode.x - layoutedNode.width / 2;
|
||||
const y = layoutedNode.y - layoutedNode.height / 2;
|
||||
|
||||
return { id: node.id, position: { x, y } };
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user