44 addEdge ,
55 useNodesState ,
66 useEdgesState ,
7+ useReactFlow ,
8+ ReactFlowProvider ,
79 Controls ,
810 MiniMap ,
911 Background ,
@@ -13,6 +15,7 @@ import {
1315 type Node ,
1416} from '@xyflow/react' ;
1517import '@xyflow/react/dist/style.css' ;
18+ import dagre from '@dagrejs/dagre' ;
1619
1720import { nodeTypes } from './nodes' ;
1821import { NodeConfigPanel } from './NodeConfigPanel' ;
@@ -93,22 +96,61 @@ function fromReactFlowEdges(edges: Edge[]): Omit<WorkflowEdge, 'workflowId'>[] {
9396 } ) ) ;
9497}
9598
99+ const NODE_WIDTH = 200 ;
100+ const NODE_HEIGHT = 80 ;
101+
102+ function layoutGraph ( nodes : Node [ ] , edges : Edge [ ] ) : Node [ ] {
103+ const g = new dagre . graphlib . Graph ( ) . setDefaultEdgeLabel ( ( ) => ( { } ) ) ;
104+ g . setGraph ( { rankdir : 'TB' , nodesep : 60 , ranksep : 100 , marginx : 40 , marginy : 40 } ) ;
105+
106+ for ( const node of nodes ) {
107+ g . setNode ( node . id , { width : NODE_WIDTH , height : NODE_HEIGHT } ) ;
108+ }
109+ for ( const edge of edges ) {
110+ g . setEdge ( edge . source , edge . target ) ;
111+ }
112+
113+ dagre . layout ( g ) ;
114+
115+ return nodes . map ( node => {
116+ const pos = g . node ( node . id ) ;
117+ return { ...node , position : { x : pos . x - NODE_WIDTH / 2 , y : pos . y - NODE_HEIGHT / 2 } } ;
118+ } ) ;
119+ }
120+
96121let nodeIdCounter = 0 ;
97122
98- export function WorkflowCanvas ( { agentId, workflowId } : Props ) {
123+ function WorkflowCanvasInner ( { agentId, workflowId } : Props ) {
99124 const [ nodes , setNodes , onNodesChange ] = useNodesState < Node > ( [ ] ) ;
100125 const [ edges , setEdges , onEdgesChange ] = useEdgesState < Edge > ( [ ] ) ;
101126 const [ selectedNode , setSelectedNode ] = useState < Node | null > ( null ) ;
102127 const [ saving , setSaving ] = useState ( false ) ;
103128 const [ dirty , setDirty ] = useState ( false ) ;
104129 const loaded = useRef ( false ) ;
130+ const { fitView } = useReactFlow ( ) ;
131+
132+ const applyLayout = useCallback ( ( ) => {
133+ setNodes ( nds => {
134+ const laid = layoutGraph ( nds , edges ) ;
135+ setTimeout ( ( ) => fitView ( { padding : 0.2 , duration : 300 } ) , 50 ) ;
136+ return laid ;
137+ } ) ;
138+ setDirty ( true ) ;
139+ } , [ edges , fitView ] ) ;
105140
106141 // Load graph
107142 useEffect ( ( ) => {
108143 fetchWorkflowGraph ( agentId , workflowId ) . then ( graph => {
109144 if ( graph . nodes . length > 0 ) {
110- setNodes ( graph . nodes . map ( toReactFlowNode ) ) ;
111- setEdges ( graph . edges . map ( toReactFlowEdge ) ) ;
145+ const rfNodes = graph . nodes . map ( toReactFlowNode ) ;
146+ const rfEdges = graph . edges . map ( toReactFlowEdge ) ;
147+ const allAtOrigin = rfNodes . every ( n => n . position . x === 0 && n . position . y === 0 ) ;
148+ if ( allAtOrigin ) {
149+ setNodes ( layoutGraph ( rfNodes , rfEdges ) ) ;
150+ } else {
151+ setNodes ( rfNodes ) ;
152+ }
153+ setEdges ( rfEdges ) ;
112154 } else {
113155 // Empty graph — seed with trigger + end
114156 const triggerId = crypto . randomUUID ( ) ;
@@ -227,6 +269,12 @@ export function WorkflowCanvas({ agentId, workflowId }: Props) {
227269 { nodes . length } nodes · { edges . length } edges
228270 { dirty && < span className = "text-warning ml-2" > • unsaved</ span > }
229271 </ span >
272+ < button
273+ className = { buttonGhost + ' !text-[10px]' }
274+ onClick = { applyLayout }
275+ >
276+ Auto Layout
277+ </ button >
230278 < button
231279 className = { buttonGhost + ' !text-[10px]' }
232280 onClick = { handleSave }
@@ -291,3 +339,11 @@ export function WorkflowCanvas({ agentId, workflowId }: Props) {
291339 </ div >
292340 ) ;
293341}
342+
343+ export function WorkflowCanvas ( props : Props ) {
344+ return (
345+ < ReactFlowProvider >
346+ < WorkflowCanvasInner { ...props } />
347+ </ ReactFlowProvider >
348+ ) ;
349+ }
0 commit comments