Skip to content

Commit f78a6df

Browse files
authored
feat(frontend): add static style and beads in custom edge (#11364)
<!-- Clearly explain the need for these changes: --> This PR enhances the visual feedback in the flow editor by adding animated "beads" that travel along edges during execution. This provides users with clear, real-time visualization of data flow and execution progress through the graph, making it easier to understand which connections are active and track execution state. https://github.com/user-attachments/assets/df4a4650-8192-403f-a200-15f6af95e384 ### Changes 🏗️ <!-- Concisely describe all of the changes made in this pull request: --> - **Added new edge data types and structure:** - Added `CustomEdgeData` type with `isStatic`, `beadUp`, `beadDown`, and `beadData` properties - Created `CustomEdge` type extending XYEdge with custom data - **Implemented bead animation components:** - Added `JSBeads.tsx` - JavaScript-based animation component with real-time updates - Added `SVGBeads.tsx` - SVG-based animation component (for future consideration) - Added helper functions for path calculations and bead positioning - **Updated edge rendering:** - Modified `CustomEdge` component to display beads during execution - Added static edge styling with dashed lines (`stroke-dasharray: 6`) - Improved visual hierarchy with different stroke styles for selected/unselected states - **Refactored edge management:** - Converted `edgeStore` from using `Connection` type to `CustomEdge` type - Added `updateEdgeBeads` and `resetEdgeBeads` methods for bead state management - Updated `copyPasteStore` to work with new edge structure - **Added support for static outputs:** - Added `staticOutput` property to `CustomNodeData` - Static edges show continuous bead animation while regular edges show one-time animation ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Create a flow with multiple blocks and verify beads animate along edges during execution - [x] Test that beads increment when execution starts (`beadUp`) and decrement when completed (`beadDown`) - [x] Verify static edges display with dashed lines and continuous animation - [x] Confirm copy/paste operations preserve edge data and bead states - [x] Test edge animations performance with complex graphs (10+ nodes) - [x] Verify bead animations complete properly before disappearing - [x] Test that multiple beads can animate on the same edge for concurrent executions - [x] Verify edge selection/deletion still works with new visualization - [x] Test that bead state resets properly when starting new executions
1 parent 4d43570 commit f78a6df

File tree

15 files changed

+562
-148
lines changed

15 files changed

+562
-148
lines changed

autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const Flow = () => {
2020
useShallow((state) => state.onNodesChange),
2121
);
2222
const nodeTypes = useMemo(() => ({ custom: CustomNode }), []);
23+
const edgeTypes = useMemo(() => ({ custom: CustomEdge }), []);
2324
const { edges, onConnect, onEdgesChange } = useCustomEdge();
2425

2526
// We use this hook to load the graph and convert them into custom nodes and edges.
@@ -51,10 +52,10 @@ export const Flow = () => {
5152
nodes={nodes}
5253
onNodesChange={onNodesChange}
5354
nodeTypes={nodeTypes}
55+
edgeTypes={edgeTypes}
5456
edges={edges}
5557
onConnect={onConnect}
5658
onEdgesChange={onEdgesChange}
57-
edgeTypes={{ custom: CustomEdge }}
5859
maxZoom={2}
5960
minZoom={0.1}
6061
onDragOver={onDragOver}

autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export const useFlow = () => {
3232
const setGraphSchemas = useGraphStore(
3333
useShallow((state) => state.setGraphSchemas),
3434
);
35+
const updateEdgeBeads = useEdgeStore(
36+
useShallow((state) => state.updateEdgeBeads),
37+
);
3538
const { screenToFlowPosition } = useReactFlow();
3639
const addBlock = useNodeStore(useShallow((state) => state.addBlock));
3740
const setBlockMenuOpen = useControlPanelStore(
@@ -109,7 +112,7 @@ export const useFlow = () => {
109112

110113
// adding links
111114
if (graph?.links) {
112-
useEdgeStore.getState().setConnections([]);
115+
useEdgeStore.getState().setEdges([]);
113116
addLinks(graph.links);
114117
}
115118

@@ -130,23 +133,25 @@ export const useFlow = () => {
130133
});
131134
}
132135

133-
// update node execution results in nodes
136+
// update node execution results in nodes, also update edge beads
134137
if (
135138
executionDetails &&
136139
"node_executions" in executionDetails &&
137140
executionDetails.node_executions
138141
) {
139142
executionDetails.node_executions.forEach((nodeExecution) => {
140143
updateNodeExecutionResult(nodeExecution.node_id, nodeExecution);
144+
updateEdgeBeads(nodeExecution.node_id, nodeExecution);
141145
});
142146
}
143147
}, [customNodes, addNodes, graph?.links, executionDetails, updateNodeStatus]);
144148

145149
useEffect(() => {
146150
return () => {
147151
useNodeStore.getState().setNodes([]);
148-
useEdgeStore.getState().setConnections([]);
152+
useEdgeStore.getState().setEdges([]);
149153
useGraphStore.getState().reset();
154+
useEdgeStore.getState().resetEdgeBeads();
150155
setIsGraphRunning(false);
151156
};
152157
}, []);

autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlowRealtime.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useShallow } from "zustand/react/shallow";
99
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
1010
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
1111
import { useGraphStore } from "../../../stores/graphStore";
12+
import { useEdgeStore } from "../../../stores/edgeStore";
1213

1314
export const useFlowRealtime = () => {
1415
const api = useBackendAPI();
@@ -21,6 +22,12 @@ export const useFlowRealtime = () => {
2122
const setIsGraphRunning = useGraphStore(
2223
useShallow((state) => state.setIsGraphRunning),
2324
);
25+
const updateEdgeBeads = useEdgeStore(
26+
useShallow((state) => state.updateEdgeBeads),
27+
);
28+
const resetEdgeBeads = useEdgeStore(
29+
useShallow((state) => state.resetEdgeBeads),
30+
);
2431

2532
const [{ flowExecutionID, flowID }] = useQueryStates({
2633
flowExecutionID: parseAsString,
@@ -34,12 +41,12 @@ export const useFlowRealtime = () => {
3441
if (data.graph_exec_id != flowExecutionID) {
3542
return;
3643
}
37-
// TODO: Update the states of nodes
3844
updateNodeExecutionResult(
3945
data.node_id,
4046
data as unknown as NodeExecutionResult,
4147
);
4248
updateStatus(data.node_id, data.status);
49+
updateEdgeBeads(data.node_id, data as unknown as NodeExecutionResult);
4350
},
4451
);
4552

@@ -82,8 +89,9 @@ export const useFlowRealtime = () => {
8289
deregisterNodeExecutionEvent();
8390
deregisterGraphExecutionSubscription();
8491
deregisterGraphExecutionStatusEvent();
92+
resetEdgeBeads();
8593
};
86-
}, [api, flowExecutionID]);
94+
}, [api, flowExecutionID, resetEdgeBeads]);
8795

8896
return {};
8997
};

autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/edges/CustomEdge.tsx

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,30 @@
11
import { Button } from "@/components/atoms/Button/Button";
22
import {
33
BaseEdge,
4+
Edge as XYEdge,
45
EdgeLabelRenderer,
56
EdgeProps,
67
getBezierPath,
78
} from "@xyflow/react";
8-
99
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
1010
import { XIcon } from "@phosphor-icons/react";
11+
import { cn } from "@/lib/utils";
12+
import { NodeExecutionResult } from "@/lib/autogpt-server-api";
13+
import { JSBeads } from "./components/JSBeads";
14+
15+
export type CustomEdgeData = {
16+
isStatic?: boolean;
17+
beadUp?: number;
18+
beadDown?: number;
19+
beadData?: Map<string, NodeExecutionResult["status"]>;
20+
};
21+
22+
export type CustomEdge = XYEdge<CustomEdgeData, "custom">;
1123
import { memo } from "react";
1224

1325
const CustomEdge = ({
1426
id,
27+
data,
1528
sourceX,
1629
sourceY,
1730
targetX,
@@ -20,8 +33,8 @@ const CustomEdge = ({
2033
targetPosition,
2134
markerEnd,
2235
selected,
23-
}: EdgeProps) => {
24-
const removeConnection = useEdgeStore((state) => state.removeConnection);
36+
}: EdgeProps<CustomEdge>) => {
37+
const removeConnection = useEdgeStore((state) => state.removeEdge);
2538
const [edgePath, labelX, labelY] = getBezierPath({
2639
sourceX,
2740
sourceY,
@@ -31,14 +44,27 @@ const CustomEdge = ({
3144
targetPosition,
3245
});
3346

47+
const isStatic = data?.isStatic ?? false;
48+
const beadUp = data?.beadUp ?? 0;
49+
const beadDown = data?.beadDown ?? 0;
50+
3451
return (
3552
<>
3653
<BaseEdge
3754
path={edgePath}
3855
markerEnd={markerEnd}
39-
className={
40-
selected ? "[stroke:#555]" : "[stroke:#555]80 hover:[stroke:#555]"
41-
}
56+
className={cn(
57+
isStatic && "!stroke-[1.5px] [stroke-dasharray:6]",
58+
selected
59+
? "stroke-zinc-800"
60+
: "stroke-zinc-500/50 hover:stroke-zinc-500",
61+
)}
62+
/>
63+
<JSBeads
64+
beadUp={beadUp}
65+
beadDown={beadDown}
66+
edgePath={edgePath}
67+
beadsKey={`beads-${id}-${sourceX}-${sourceY}-${targetX}-${targetY}`}
4268
/>
4369
<EdgeLabelRenderer>
4470
<Button
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
// This component uses JS animation [It's replica of legacy builder]
2+
// Problem - It lags at real time updates, because of state change
3+
4+
import { useCallback, useEffect, useRef, useState } from "react";
5+
import {
6+
getLengthOfPathInPixels,
7+
getPointAtT,
8+
getTForDistance,
9+
setTargetPositions,
10+
} from "../helpers";
11+
12+
const BEAD_DIAMETER = 10;
13+
const ANIMATION_DURATION = 500;
14+
15+
interface Bead {
16+
t: number;
17+
targetT: number;
18+
startTime: number;
19+
}
20+
21+
interface BeadsProps {
22+
beadUp: number;
23+
beadDown: number;
24+
edgePath: string;
25+
beadsKey: string;
26+
isStatic?: boolean;
27+
}
28+
29+
export const JSBeads = ({
30+
beadUp,
31+
beadDown,
32+
edgePath,
33+
beadsKey,
34+
}: BeadsProps) => {
35+
const [beads, setBeads] = useState<{
36+
beads: Bead[];
37+
created: number;
38+
destroyed: number;
39+
}>({ beads: [], created: 0, destroyed: 0 });
40+
41+
const beadsRef = useRef(beads);
42+
const totalLength = getLengthOfPathInPixels(edgePath);
43+
const animationFrameRef = useRef<number | null>(null);
44+
const lastFrameTimeRef = useRef<number>(0);
45+
46+
const pathRef = useRef<SVGPathElement | null>(null);
47+
48+
const getPointAtTWrapper = (t: number) => {
49+
return getPointAtT(t, edgePath, pathRef);
50+
};
51+
52+
const getTForDistanceWrapper = (distanceFromEnd: number) => {
53+
return getTForDistance(distanceFromEnd, totalLength);
54+
};
55+
56+
const setTargetPositionsWrapper = useCallback(
57+
(beads: Bead[]) => {
58+
return setTargetPositions(beads, BEAD_DIAMETER, getTForDistanceWrapper);
59+
},
60+
[getTForDistanceWrapper],
61+
);
62+
63+
beadsRef.current = beads;
64+
65+
useEffect(() => {
66+
pathRef.current = null;
67+
}, [edgePath]);
68+
69+
useEffect(() => {
70+
if (
71+
beadUp === 0 &&
72+
beadDown === 0 &&
73+
(beads.created > 0 || beads.destroyed > 0)
74+
) {
75+
setBeads({ beads: [], created: 0, destroyed: 0 });
76+
return;
77+
}
78+
79+
// Adding beads
80+
if (beadUp > beads.created) {
81+
setBeads(({ beads, created, destroyed }) => {
82+
const newBeads = [];
83+
for (let i = 0; i < beadUp - created; i++) {
84+
newBeads.push({ t: 0, targetT: 0, startTime: Date.now() });
85+
}
86+
87+
const b = setTargetPositionsWrapper([...beads, ...newBeads]);
88+
return { beads: b, created: beadUp, destroyed };
89+
});
90+
}
91+
92+
const animate = (currentTime: number) => {
93+
const beads = beadsRef.current;
94+
95+
if (
96+
(beadUp === beads.created && beads.created === beads.destroyed) ||
97+
beads.beads.every((bead) => bead.t >= bead.targetT)
98+
) {
99+
animationFrameRef.current = null;
100+
return;
101+
}
102+
103+
const deltaTime = lastFrameTimeRef.current
104+
? currentTime - lastFrameTimeRef.current
105+
: 16;
106+
lastFrameTimeRef.current = currentTime;
107+
108+
setBeads(({ beads, created, destroyed }) => {
109+
let destroyedCount = 0;
110+
111+
const newBeads = beads
112+
.map((bead) => {
113+
const progressIncrement = deltaTime / ANIMATION_DURATION;
114+
const t = Math.min(
115+
bead.t + bead.targetT * progressIncrement,
116+
bead.targetT,
117+
);
118+
119+
return { ...bead, t };
120+
})
121+
.filter((bead, index) => {
122+
const removeCount = beadDown - destroyed;
123+
if (bead.t >= bead.targetT && index < removeCount) {
124+
destroyedCount++;
125+
return false;
126+
}
127+
return true;
128+
});
129+
130+
return {
131+
beads: setTargetPositionsWrapper(newBeads),
132+
created,
133+
destroyed: destroyed + destroyedCount,
134+
};
135+
});
136+
137+
animationFrameRef.current = requestAnimationFrame(animate);
138+
};
139+
140+
lastFrameTimeRef.current = 0;
141+
animationFrameRef.current = requestAnimationFrame(animate);
142+
143+
return () => {
144+
if (animationFrameRef.current !== null) {
145+
cancelAnimationFrame(animationFrameRef.current);
146+
animationFrameRef.current = null;
147+
}
148+
};
149+
}, [beadUp, beadDown, setTargetPositionsWrapper]);
150+
151+
return (
152+
<>
153+
{beads.beads.map((bead, index) => {
154+
const pos = getPointAtTWrapper(bead.t);
155+
return (
156+
<circle
157+
key={`${beadsKey}-${index}`}
158+
cx={pos.x}
159+
cy={pos.y}
160+
r={BEAD_DIAMETER / 2}
161+
fill="#8d8d95"
162+
/>
163+
);
164+
})}
165+
</>
166+
);
167+
};

0 commit comments

Comments
 (0)