Skip to content

Commit e4a3764

Browse files
ersinkocclaude
andcommitted
🔧 fix(ui): dynamic node sizing in auto-arrange layout
Replace fixed 220x100 dimensions with per-type size estimation. Each node type now gets dimensions based on its visual weight: - LLM: 260x120-170 (varies with prompt/temperature/JSON badge) - Code: 260x100-140 (varies with code line count) - Switch: 240x110+ (grows with case count) - Condition: 240x130 (true/false split zones) - Tool: 230x95-125 (varies with args/description) - Parallel: width grows with branch count - StickyNote: shrinks to text content - Compact nodes (Merge, Delay): smaller footprint Gaps increased: 80px horizontal, 100px vertical for breathing room. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c5cee73 commit e4a3764

1 file changed

Lines changed: 128 additions & 7 deletions

File tree

packages/ui/src/components/workflows/auto-arrange.ts

Lines changed: 128 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,137 @@
11
import dagre from '@dagrejs/dagre';
22
import type { Node, Edge } from '@xyflow/react';
33

4-
const NODE_WIDTH = 220;
5-
const NODE_HEIGHT = 100;
6-
const HORIZONTAL_GAP = 60;
7-
const VERTICAL_GAP = 80;
4+
const HORIZONTAL_GAP = 80;
5+
const VERTICAL_GAP = 100;
86
const GRID_SIZE = 16;
97

108
/** Snap a value to the nearest grid increment. */
119
function snapToGrid(value: number): number {
1210
return Math.round(value / GRID_SIZE) * GRID_SIZE;
1311
}
1412

13+
/**
14+
* Estimate node dimensions based on type and data content.
15+
* Nodes with more inline info (LLM, Code, HTTP) are taller.
16+
*/
17+
function getNodeSize(node: Node): { width: number; height: number } {
18+
const d = (node.data ?? {}) as Record<string, unknown>;
19+
20+
switch (node.type) {
21+
// Large nodes — gradient header + multi-line content
22+
case 'llmNode': {
23+
let h = 120; // header + chips
24+
if (d.systemPrompt) h += 16;
25+
if (d.userMessage) h += 16;
26+
if (d.temperature != null) h += 18;
27+
if (d.responseFormat === 'json') h += 20;
28+
return { width: 260, height: h };
29+
}
30+
31+
case 'httpRequestNode': {
32+
let h = 110;
33+
if (d.url) h += 16;
34+
if (d.auth) h += 16;
35+
return { width: 250, height: h };
36+
}
37+
38+
case 'codeNode': {
39+
const code = (d.code as string) ?? '';
40+
const lines = Math.min(code.split('\n').length, 3);
41+
return { width: 260, height: 100 + lines * 14 };
42+
}
43+
44+
// Medium nodes — header + expression/content
45+
case 'conditionNode':
46+
return { width: 240, height: 130 }; // split true/false zones
47+
48+
case 'switchNode': {
49+
const cases = (d.cases as unknown[]) ?? [];
50+
return { width: 240, height: 110 + Math.min(cases.length, 5) * 20 };
51+
}
52+
53+
case 'forEachNode':
54+
return { width: 230, height: 130 }; // each/done zones
55+
56+
case 'triggerNode':
57+
return { width: 230, height: 110 };
58+
59+
case 'subWorkflowNode': {
60+
let h = 110;
61+
const mapping = d.inputMapping as Record<string, unknown> | undefined;
62+
if (mapping) h += Math.min(Object.keys(mapping).length, 3) * 16;
63+
return { width: 240, height: h };
64+
}
65+
66+
case 'schemaValidatorNode': {
67+
let h = 110;
68+
const schema = d.schema as Record<string, unknown> | undefined;
69+
const props = schema?.properties as Record<string, unknown> | undefined;
70+
if (props) h += Math.min(Object.keys(props).length, 3) * 14;
71+
return { width: 240, height: h };
72+
}
73+
74+
// Compact nodes — header + one line of info
75+
case 'toolNode': {
76+
let h = 95;
77+
const args = d.toolArgs as Record<string, unknown> | undefined;
78+
if (args && Object.keys(args).length > 0) h += 16;
79+
if (d.description) h += 14;
80+
return { width: 230, height: h };
81+
}
82+
83+
case 'filterNode':
84+
case 'mapNode':
85+
return { width: 220, height: 110 };
86+
87+
case 'aggregateNode':
88+
return { width: 220, height: 110 };
89+
90+
case 'dataStoreNode':
91+
return { width: 220, height: 110 };
92+
93+
case 'delayNode':
94+
return { width: 200, height: 110 };
95+
96+
case 'notificationNode':
97+
return { width: 220, height: d.message ? 110 : 90 };
98+
99+
case 'approvalNode':
100+
return { width: 230, height: 120 };
101+
102+
case 'errorHandlerNode':
103+
return { width: 220, height: 100 };
104+
105+
case 'webhookResponseNode':
106+
return { width: 220, height: 100 };
107+
108+
// Structural nodes — minimal
109+
case 'parallelNode': {
110+
const count = (d.branchCount as number) ?? 2;
111+
return { width: 200 + count * 20, height: 100 };
112+
}
113+
114+
case 'mergeNode':
115+
return { width: 200, height: 90 };
116+
117+
case 'stickyNoteNode': {
118+
const text = (d.text as string) ?? '';
119+
const lines = Math.min(text.split('\n').length, 5);
120+
return { width: 180, height: 60 + lines * 16 };
121+
}
122+
123+
default:
124+
return { width: 220, height: 100 };
125+
}
126+
}
127+
15128
/**
16129
* Compute an automatic top-to-bottom DAG layout for the given nodes and edges
17130
* using the dagre graph layout algorithm.
18131
*
132+
* Node sizes are estimated per-type so the layout adapts to the visual weight
133+
* of each node (LLM nodes are taller than Merge nodes, etc.).
134+
*
19135
* Returns a new array of nodes with updated positions — inputs are never mutated.
20136
*/
21137
export function autoArrangeNodes(nodes: Node[], edges: Edge[]): Node[] {
@@ -31,8 +147,12 @@ export function autoArrangeNodes(nodes: Node[], edges: Edge[]): Node[] {
31147
marginy: 40,
32148
});
33149

150+
// Register each node with its estimated dimensions
151+
const sizeCache = new Map<string, { width: number; height: number }>();
34152
for (const node of nodes) {
35-
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
153+
const size = getNodeSize(node);
154+
sizeCache.set(node.id, size);
155+
g.setNode(node.id, size);
36156
}
37157

38158
for (const edge of edges) {
@@ -43,12 +163,13 @@ export function autoArrangeNodes(nodes: Node[], edges: Edge[]): Node[] {
43163

44164
return nodes.map((node) => {
45165
const pos = g.node(node.id);
166+
const size = sizeCache.get(node.id)!;
46167
// dagre returns center coordinates — convert to top-left for ReactFlow
47168
return {
48169
...node,
49170
position: {
50-
x: snapToGrid(pos.x - NODE_WIDTH / 2),
51-
y: snapToGrid(pos.y - NODE_HEIGHT / 2),
171+
x: snapToGrid(pos.x - size.width / 2),
172+
y: snapToGrid(pos.y - size.height / 2),
52173
},
53174
};
54175
});

0 commit comments

Comments
 (0)