Skip to content

Commit 746dbba

Browse files
authored
refactor(frontend): improve new builder performance and UX with position handling and store optimizations (#11397)
This PR introduces several performance and user experience improvements to the new builder, focusing on node positioning, state management optimizations, and visual enhancements. The new builder had several issues that impacted developer experience and runtime performance: - Inefficient store subscriptions causing unnecessary re-renders - No intelligent node positioning when adding blocks via clicking - useEffect dependencies causing potential stale closures - Width constraints missing on form fields affecting layout consistency ### Changes 🏗️ #### Performance Optimizations - **Store subscription optimization**: Added `useShallow` from zustand to prevent unnecessary re-renders in [NodeContainer](file:///app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer.tsx) and [NodeExecutionBadge](file:///app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeExecutionBadge.tsx) - **useEffect cleanup**: Split combined useEffects in [useFlow](file:///app/(platform)/build/hooks/useFlow.ts) for clearer dependencies and better performance - **Memoization**: Added `memo` to [NewControlPanel](file:///app/(platform)/build/components/NewControlPanel/NewControlPanel.tsx) to prevent unnecessary re-renders - **Callback optimization**: Wrapped `onDrop` handler in `useCallback` to prevent recreation on every render #### UX Improvements - **Smart node positioning**: Implemented `findFreePosition` algorithm in [helper.ts](file:///app/(platform)/build/components/helper.ts) that: - Automatically finds non-overlapping positions for new nodes - Tries right, left, then below existing nodes - Falls back to far-right position if no space available - **Click-to-add blocks**: Added click handlers to blocks that: - Add the block at an intelligent position - Automatically pan viewport to center the new node with smooth animation - **Visual feedback**: Added loading state with spinner icon for agent blocks during fetch - **Form field width**: Added `max-w-[340px]` constraint to prevent overflow in [FieldTemplate](file:///components/renderers/input-renderer/templates/FieldTemplate.tsx) ### 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 from scratch and execute an agent with at least 3 blocks - [x] Test adding blocks via drag-and-drop ensures no overlapping - [x] Test adding blocks via click positions them intelligently - [x] Test viewport animation when adding blocks via click - [x] Import an agent from file upload, and confirm it executes correctly - [x] Test loading spinner appears when adding agents from "My Agents" - [x] Verify performance improvements by checking React DevTools for reduced re-renders
1 parent 901bb31 commit 746dbba

File tree

30 files changed

+676
-308
lines changed

30 files changed

+676
-308
lines changed
Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1+
import { parseAsString, useQueryStates } from "nuqs";
12
import { AgentOutputs } from "./components/AgentOutputs/AgentOutputs";
23
import { RunGraph } from "./components/RunGraph/RunGraph";
34
import { ScheduleGraph } from "./components/ScheduleGraph/ScheduleGraph";
5+
import { memo } from "react";
46

5-
export const BuilderActions = () => {
7+
export const BuilderActions = memo(() => {
8+
const [{ flowID }] = useQueryStates({
9+
flowID: parseAsString,
10+
});
611
return (
7-
<div className="absolute bottom-4 left-[50%] z-[100] flex -translate-x-1/2 items-center gap-4">
8-
<AgentOutputs />
9-
<RunGraph />
10-
<ScheduleGraph />
12+
<div className="absolute bottom-4 left-[50%] z-[100] flex -translate-x-1/2 items-center gap-4 rounded-full bg-white p-2 px-4 shadow-lg">
13+
<AgentOutputs flowID={flowID} />
14+
<RunGraph flowID={flowID} />
15+
<ScheduleGraph flowID={flowID} />
1116
</div>
1217
);
13-
};
18+
});
19+
20+
BuilderActions.displayName = "BuilderActions";

autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/AgentOutputs/AgentOutputs.tsx

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,22 @@
1-
import { Button } from "@/components/atoms/Button/Button";
21
import {
32
Tooltip,
43
TooltipContent,
54
TooltipProvider,
65
TooltipTrigger,
76
} from "@/components/atoms/Tooltip/BaseTooltip";
8-
import { LogOutIcon } from "lucide-react";
7+
import { BuilderActionButton } from "../BuilderActionButton";
8+
import { BookOpenIcon } from "@phosphor-icons/react";
99

10-
export const AgentOutputs = () => {
10+
export const AgentOutputs = ({ flowID }: { flowID: string | null }) => {
1111
return (
1212
<>
1313
<TooltipProvider>
1414
<Tooltip>
1515
<TooltipTrigger asChild>
1616
{/* Todo: Implement Agent Outputs */}
17-
<Button
18-
variant="primary"
19-
size="large"
20-
className={"relative min-w-0 border-none text-lg"}
21-
>
22-
<LogOutIcon className="size-6" />
23-
</Button>
17+
<BuilderActionButton disabled={!flowID}>
18+
<BookOpenIcon className="size-6" />
19+
</BuilderActionButton>
2420
</TooltipTrigger>
2521
<TooltipContent>
2622
<p>Agent Outputs</p>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Button } from "@/components/atoms/Button/Button";
2+
import { ButtonProps } from "@/components/atoms/Button/helpers";
3+
import { cn } from "@/lib/utils";
4+
import { CircleNotchIcon } from "@phosphor-icons/react";
5+
6+
export const BuilderActionButton = ({
7+
children,
8+
className,
9+
isLoading,
10+
...props
11+
}: ButtonProps & { isLoading?: boolean }) => {
12+
return (
13+
<Button
14+
variant="icon"
15+
size={"small"}
16+
className={cn(
17+
"relative h-12 w-12 min-w-0 text-lg",
18+
"bg-gradient-to-br from-zinc-50 to-zinc-200",
19+
"border border-zinc-200",
20+
"shadow-[inset_0_3px_0_0_rgba(255,255,255,0.5),0_2px_4px_0_rgba(0,0,0,0.2)]",
21+
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.1),0_2px_4px_0_rgba(0,0,0,0.4)]",
22+
"hover:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.5),0_1px_2px_0_rgba(0,0,0,0.2)]",
23+
"active:shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.2)]",
24+
"transition-all duration-150",
25+
"disabled:cursor-not-allowed disabled:opacity-50",
26+
className,
27+
)}
28+
{...props}
29+
>
30+
{!isLoading ? (
31+
children
32+
) : (
33+
<CircleNotchIcon className="size-6 animate-spin" />
34+
)}
35+
</Button>
36+
);
37+
};

autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/RunGraph.tsx

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,24 @@
1-
import { Button } from "@/components/atoms/Button/Button";
2-
import { PlayIcon } from "lucide-react";
31
import { useRunGraph } from "./useRunGraph";
42
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
53
import { useShallow } from "zustand/react/shallow";
6-
import { StopIcon } from "@phosphor-icons/react";
4+
import { PlayIcon, StopIcon } from "@phosphor-icons/react";
75
import { cn } from "@/lib/utils";
86
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
97
import {
108
Tooltip,
119
TooltipContent,
1210
TooltipTrigger,
1311
} from "@/components/atoms/Tooltip/BaseTooltip";
12+
import { BuilderActionButton } from "../BuilderActionButton";
1413

15-
export const RunGraph = () => {
14+
export const RunGraph = ({ flowID }: { flowID: string | null }) => {
1615
const {
1716
handleRunGraph,
1817
handleStopGraph,
19-
isSaving,
2018
openRunInputDialog,
2119
setOpenRunInputDialog,
20+
isExecutingGraph,
21+
isSaving,
2222
} = useRunGraph();
2323
const isGraphRunning = useGraphStore(
2424
useShallow((state) => state.isGraphRunning),
@@ -28,20 +28,21 @@ export const RunGraph = () => {
2828
<>
2929
<Tooltip>
3030
<TooltipTrigger asChild>
31-
<Button
32-
variant="primary"
33-
size="large"
31+
<BuilderActionButton
3432
className={cn(
35-
"relative min-w-0 border-none bg-gradient-to-r from-purple-500 to-pink-500 text-lg",
33+
isGraphRunning &&
34+
"border-red-500 bg-gradient-to-br from-red-400 to-red-500 shadow-[inset_0_2px_0_0_rgba(255,255,255,0.5),0_2px_4px_0_rgba(0,0,0,0.2)]",
3635
)}
3736
onClick={isGraphRunning ? handleStopGraph : handleRunGraph}
37+
disabled={!flowID || isExecutingGraph}
38+
isLoading={isExecutingGraph || isSaving}
3839
>
39-
{!isGraphRunning && !isSaving ? (
40-
<PlayIcon className="size-6" />
40+
{!isGraphRunning ? (
41+
<PlayIcon className="size-6 drop-shadow-sm" />
4142
) : (
42-
<StopIcon className="size-6" />
43+
<StopIcon className="size-6 drop-shadow-sm" />
4344
)}
44-
</Button>
45+
</BuilderActionButton>
4546
</TooltipTrigger>
4647
<TooltipContent>
4748
{isGraphRunning ? "Stop agent" : "Run agent"}

autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunGraph/useRunGraph.ts

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,25 +31,26 @@ export const useRunGraph = () => {
3131
flowExecutionID: parseAsString,
3232
});
3333

34-
const { mutateAsync: executeGraph } = usePostV1ExecuteGraphAgent({
35-
mutation: {
36-
onSuccess: (response) => {
37-
const { id } = response.data as GraphExecutionMeta;
38-
setQueryStates({
39-
flowExecutionID: id,
40-
});
41-
},
42-
onError: (error) => {
43-
setIsGraphRunning(false);
34+
const { mutateAsync: executeGraph, isPending: isExecutingGraph } =
35+
usePostV1ExecuteGraphAgent({
36+
mutation: {
37+
onSuccess: (response) => {
38+
const { id } = response.data as GraphExecutionMeta;
39+
setQueryStates({
40+
flowExecutionID: id,
41+
});
42+
},
43+
onError: (error) => {
44+
setIsGraphRunning(false);
4445

45-
toast({
46-
title: (error.detail as string) ?? "An unexpected error occurred.",
47-
description: "An unexpected error occurred.",
48-
variant: "destructive",
49-
});
46+
toast({
47+
title: (error.detail as string) ?? "An unexpected error occurred.",
48+
description: "An unexpected error occurred.",
49+
variant: "destructive",
50+
});
51+
},
5052
},
51-
},
52-
});
53+
});
5354

5455
const { mutateAsync: stopGraph } = usePostV1StopGraphExecution({
5556
mutation: {
@@ -72,7 +73,6 @@ export const useRunGraph = () => {
7273
if (hasInputs() || hasCredentials()) {
7374
setOpenRunInputDialog(true);
7475
} else {
75-
setIsGraphRunning(true);
7676
await executeGraph({
7777
graphId: flowID ?? "",
7878
graphVersion: flowVersion || null,
@@ -95,6 +95,7 @@ export const useRunGraph = () => {
9595
handleRunGraph,
9696
handleStopGraph,
9797
isSaving,
98+
isExecutingGraph,
9899
openRunInputDialog,
99100
setOpenRunInputDialog,
100101
};

autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/RunInputDialog/useRunInputDialog.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@ export const useRunInputDialog = ({
4343
setQueryStates({
4444
flowExecutionID: id,
4545
});
46-
setIsGraphRunning(false);
4746
},
4847
onError: (error) => {
4948
setIsGraphRunning(false);
@@ -81,7 +80,6 @@ export const useRunInputDialog = ({
8180

8281
const handleManualRun = () => {
8382
setIsOpen(false);
84-
setIsGraphRunning(true);
8583
executeGraph({
8684
graphId: flowID ?? "",
8785
graphVersion: flowVersion || null,

autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/ScheduleGraph/ScheduleGraph.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { Button } from "@/components/atoms/Button/Button";
21
import { ClockIcon } from "@phosphor-icons/react";
32
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
43
import { useScheduleGraph } from "./useScheduleGraph";
@@ -9,8 +8,9 @@ import {
98
TooltipTrigger,
109
} from "@/components/atoms/Tooltip/BaseTooltip";
1110
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";
11+
import { BuilderActionButton } from "../BuilderActionButton";
1212

13-
export const ScheduleGraph = () => {
13+
export const ScheduleGraph = ({ flowID }: { flowID: string | null }) => {
1414
const {
1515
openScheduleInputDialog,
1616
setOpenScheduleInputDialog,
@@ -23,14 +23,12 @@ export const ScheduleGraph = () => {
2323
<TooltipProvider>
2424
<Tooltip>
2525
<TooltipTrigger asChild>
26-
<Button
27-
variant="primary"
28-
size="large"
29-
className={"relative min-w-0 border-none text-lg"}
26+
<BuilderActionButton
3027
onClick={handleScheduleGraph}
28+
disabled={!flowID}
3129
>
3230
<ClockIcon className="size-6" />
33-
</Button>
31+
</BuilderActionButton>
3432
</TooltipTrigger>
3533
<TooltipContent>
3634
<p>Schedule Graph</p>

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

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ReactFlow, Background, Controls } from "@xyflow/react";
1+
import { ReactFlow, Background } from "@xyflow/react";
22
import NewControlPanel from "../../NewControlPanel/NewControlPanel";
33
import CustomEdge from "../edges/CustomEdge";
44
import { useFlow } from "./useFlow";
@@ -13,6 +13,7 @@ import { BuilderActions } from "../../BuilderActions/BuilderActions";
1313
import { RunningBackground } from "./components/RunningBackground";
1414
import { useGraphStore } from "../../../stores/graphStore";
1515
import { useCopyPaste } from "./useCopyPaste";
16+
import { CustomControls } from "./components/CustomControl";
1617

1718
export const Flow = () => {
1819
const nodes = useNodeStore(useShallow((state) => state.nodes));
@@ -24,7 +25,8 @@ export const Flow = () => {
2425
const { edges, onConnect, onEdgesChange } = useCustomEdge();
2526

2627
// We use this hook to load the graph and convert them into custom nodes and edges.
27-
const { onDragOver, onDrop } = useFlow();
28+
const { onDragOver, onDrop, isFlowContentLoading, isLocked, setIsLocked } =
29+
useFlow();
2830

2931
// This hook is used for websocket realtime updates.
3032
useFlowRealtime();
@@ -42,8 +44,6 @@ export const Flow = () => {
4244
window.removeEventListener("keydown", handleKeyDown);
4345
};
4446
}, [handleCopyPaste]);
45-
46-
const { isFlowContentLoading } = useFlow();
4747
const { isGraphRunning } = useGraphStore();
4848
return (
4949
<div className="flex h-full w-full dark:bg-slate-900">
@@ -60,12 +60,15 @@ export const Flow = () => {
6060
minZoom={0.1}
6161
onDragOver={onDragOver}
6262
onDrop={onDrop}
63+
nodesDraggable={!isLocked}
64+
nodesConnectable={!isLocked}
65+
elementsSelectable={!isLocked}
6366
>
6467
<Background />
65-
<Controls />
68+
<CustomControls setIsLocked={setIsLocked} isLocked={isLocked} />
6669
<NewControlPanel />
6770
<BuilderActions />
68-
{isFlowContentLoading && <GraphLoadingBox />}
71+
{<GraphLoadingBox flowContentLoading={isFlowContentLoading} />}
6972
{isGraphRunning && <RunningBackground />}
7073
</ReactFlow>
7174
</div>
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { useReactFlow } from "@xyflow/react";
2+
import { Button } from "@/components/atoms/Button/Button";
3+
import {
4+
Tooltip,
5+
TooltipContent,
6+
TooltipTrigger,
7+
} from "@/components/atoms/Tooltip/BaseTooltip";
8+
import {
9+
FrameCornersIcon,
10+
MinusIcon,
11+
PlusIcon,
12+
} from "@phosphor-icons/react/dist/ssr";
13+
import { LockIcon, LockOpenIcon } from "lucide-react";
14+
import { memo } from "react";
15+
16+
export const CustomControls = memo(
17+
({
18+
setIsLocked,
19+
isLocked,
20+
}: {
21+
isLocked: boolean;
22+
setIsLocked: (isLocked: boolean) => void;
23+
}) => {
24+
const { zoomIn, zoomOut, fitView } = useReactFlow();
25+
26+
const controls = [
27+
{
28+
icon: <PlusIcon className="size-4" />,
29+
label: "Zoom In",
30+
onClick: () => zoomIn(),
31+
className: "h-10 w-10 border-none",
32+
},
33+
{
34+
icon: <MinusIcon className="size-4" />,
35+
label: "Zoom Out",
36+
onClick: () => zoomOut(),
37+
className: "h-10 w-10 border-none",
38+
},
39+
{
40+
icon: <FrameCornersIcon className="size-4" />,
41+
label: "Fit View",
42+
onClick: () => fitView({ padding: 0.2, duration: 800, maxZoom: 1 }),
43+
className: "h-10 w-10 border-none",
44+
},
45+
{
46+
icon: !isLocked ? (
47+
<LockOpenIcon className="size-4" />
48+
) : (
49+
<LockIcon className="size-4" />
50+
),
51+
label: "Toggle Lock",
52+
onClick: () => setIsLocked(!isLocked),
53+
className: `h-10 w-10 border-none ${isLocked ? "bg-zinc-100" : "bg-white"}`,
54+
},
55+
];
56+
57+
return (
58+
<div className="absolute bottom-4 left-4 z-10 flex flex-col items-center gap-2 rounded-full bg-white px-1 py-2 shadow-lg">
59+
{controls.map((control, index) => (
60+
<Tooltip key={index} delayDuration={300}>
61+
<TooltipTrigger asChild>
62+
<Button
63+
variant="icon"
64+
size={"small"}
65+
onClick={control.onClick}
66+
className={control.className}
67+
>
68+
{control.icon}
69+
<span className="sr-only">{control.label}</span>
70+
</Button>
71+
</TooltipTrigger>
72+
<TooltipContent side="right">{control.label}</TooltipContent>
73+
</Tooltip>
74+
))}
75+
</div>
76+
);
77+
},
78+
);
79+
80+
CustomControls.displayName = "CustomControls";

0 commit comments

Comments
 (0)