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
This commit is contained in:
Abhimanyu Yadav
2025-11-19 19:22:18 +05:30
committed by GitHub
parent 901bb31e14
commit 746dbbac84
30 changed files with 675 additions and 307 deletions

View File

@@ -1,13 +1,20 @@
import { parseAsString, useQueryStates } from "nuqs";
import { AgentOutputs } from "./components/AgentOutputs/AgentOutputs";
import { RunGraph } from "./components/RunGraph/RunGraph";
import { ScheduleGraph } from "./components/ScheduleGraph/ScheduleGraph";
import { memo } from "react";
export const BuilderActions = () => {
export const BuilderActions = memo(() => {
const [{ flowID }] = useQueryStates({
flowID: parseAsString,
});
return (
<div className="absolute bottom-4 left-[50%] z-[100] flex -translate-x-1/2 items-center gap-4">
<AgentOutputs />
<RunGraph />
<ScheduleGraph />
<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">
<AgentOutputs flowID={flowID} />
<RunGraph flowID={flowID} />
<ScheduleGraph flowID={flowID} />
</div>
);
};
});
BuilderActions.displayName = "BuilderActions";

View File

@@ -1,26 +1,22 @@
import { Button } from "@/components/atoms/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { LogOutIcon } from "lucide-react";
import { BuilderActionButton } from "../BuilderActionButton";
import { BookOpenIcon } from "@phosphor-icons/react";
export const AgentOutputs = () => {
export const AgentOutputs = ({ flowID }: { flowID: string | null }) => {
return (
<>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{/* Todo: Implement Agent Outputs */}
<Button
variant="primary"
size="large"
className={"relative min-w-0 border-none text-lg"}
>
<LogOutIcon className="size-6" />
</Button>
<BuilderActionButton disabled={!flowID}>
<BookOpenIcon className="size-6" />
</BuilderActionButton>
</TooltipTrigger>
<TooltipContent>
<p>Agent Outputs</p>

View File

@@ -0,0 +1,37 @@
import { Button } from "@/components/atoms/Button/Button";
import { ButtonProps } from "@/components/atoms/Button/helpers";
import { cn } from "@/lib/utils";
import { CircleNotchIcon } from "@phosphor-icons/react";
export const BuilderActionButton = ({
children,
className,
isLoading,
...props
}: ButtonProps & { isLoading?: boolean }) => {
return (
<Button
variant="icon"
size={"small"}
className={cn(
"relative h-12 w-12 min-w-0 text-lg",
"bg-gradient-to-br from-zinc-50 to-zinc-200",
"border border-zinc-200",
"shadow-[inset_0_3px_0_0_rgba(255,255,255,0.5),0_2px_4px_0_rgba(0,0,0,0.2)]",
"dark:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.1),0_2px_4px_0_rgba(0,0,0,0.4)]",
"hover:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.5),0_1px_2px_0_rgba(0,0,0,0.2)]",
"active:shadow-[inset_0_2px_4px_0_rgba(0,0,0,0.2)]",
"transition-all duration-150",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
{!isLoading ? (
children
) : (
<CircleNotchIcon className="size-6 animate-spin" />
)}
</Button>
);
};

View File

@@ -1,9 +1,7 @@
import { Button } from "@/components/atoms/Button/Button";
import { PlayIcon } from "lucide-react";
import { useRunGraph } from "./useRunGraph";
import { useGraphStore } from "@/app/(platform)/build/stores/graphStore";
import { useShallow } from "zustand/react/shallow";
import { StopIcon } from "@phosphor-icons/react";
import { PlayIcon, StopIcon } from "@phosphor-icons/react";
import { cn } from "@/lib/utils";
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
import {
@@ -11,14 +9,16 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { BuilderActionButton } from "../BuilderActionButton";
export const RunGraph = () => {
export const RunGraph = ({ flowID }: { flowID: string | null }) => {
const {
handleRunGraph,
handleStopGraph,
isSaving,
openRunInputDialog,
setOpenRunInputDialog,
isExecutingGraph,
isSaving,
} = useRunGraph();
const isGraphRunning = useGraphStore(
useShallow((state) => state.isGraphRunning),
@@ -28,20 +28,21 @@ export const RunGraph = () => {
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="primary"
size="large"
<BuilderActionButton
className={cn(
"relative min-w-0 border-none bg-gradient-to-r from-purple-500 to-pink-500 text-lg",
isGraphRunning &&
"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)]",
)}
onClick={isGraphRunning ? handleStopGraph : handleRunGraph}
disabled={!flowID || isExecutingGraph}
isLoading={isExecutingGraph || isSaving}
>
{!isGraphRunning && !isSaving ? (
<PlayIcon className="size-6" />
{!isGraphRunning ? (
<PlayIcon className="size-6 drop-shadow-sm" />
) : (
<StopIcon className="size-6" />
<StopIcon className="size-6 drop-shadow-sm" />
)}
</Button>
</BuilderActionButton>
</TooltipTrigger>
<TooltipContent>
{isGraphRunning ? "Stop agent" : "Run agent"}

View File

@@ -31,25 +31,26 @@ export const useRunGraph = () => {
flowExecutionID: parseAsString,
});
const { mutateAsync: executeGraph } = usePostV1ExecuteGraphAgent({
mutation: {
onSuccess: (response) => {
const { id } = response.data as GraphExecutionMeta;
setQueryStates({
flowExecutionID: id,
});
},
onError: (error) => {
setIsGraphRunning(false);
const { mutateAsync: executeGraph, isPending: isExecutingGraph } =
usePostV1ExecuteGraphAgent({
mutation: {
onSuccess: (response) => {
const { id } = response.data as GraphExecutionMeta;
setQueryStates({
flowExecutionID: id,
});
},
onError: (error) => {
setIsGraphRunning(false);
toast({
title: (error.detail as string) ?? "An unexpected error occurred.",
description: "An unexpected error occurred.",
variant: "destructive",
});
toast({
title: (error.detail as string) ?? "An unexpected error occurred.",
description: "An unexpected error occurred.",
variant: "destructive",
});
},
},
},
});
});
const { mutateAsync: stopGraph } = usePostV1StopGraphExecution({
mutation: {
@@ -72,7 +73,6 @@ export const useRunGraph = () => {
if (hasInputs() || hasCredentials()) {
setOpenRunInputDialog(true);
} else {
setIsGraphRunning(true);
await executeGraph({
graphId: flowID ?? "",
graphVersion: flowVersion || null,
@@ -95,6 +95,7 @@ export const useRunGraph = () => {
handleRunGraph,
handleStopGraph,
isSaving,
isExecutingGraph,
openRunInputDialog,
setOpenRunInputDialog,
};

View File

@@ -43,7 +43,6 @@ export const useRunInputDialog = ({
setQueryStates({
flowExecutionID: id,
});
setIsGraphRunning(false);
},
onError: (error) => {
setIsGraphRunning(false);
@@ -81,7 +80,6 @@ export const useRunInputDialog = ({
const handleManualRun = () => {
setIsOpen(false);
setIsGraphRunning(true);
executeGraph({
graphId: flowID ?? "",
graphVersion: flowVersion || null,

View File

@@ -1,4 +1,3 @@
import { Button } from "@/components/atoms/Button/Button";
import { ClockIcon } from "@phosphor-icons/react";
import { RunInputDialog } from "../RunInputDialog/RunInputDialog";
import { useScheduleGraph } from "./useScheduleGraph";
@@ -9,8 +8,9 @@ import {
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { CronSchedulerDialog } from "../CronSchedulerDialog/CronSchedulerDialog";
import { BuilderActionButton } from "../BuilderActionButton";
export const ScheduleGraph = () => {
export const ScheduleGraph = ({ flowID }: { flowID: string | null }) => {
const {
openScheduleInputDialog,
setOpenScheduleInputDialog,
@@ -23,14 +23,12 @@ export const ScheduleGraph = () => {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="primary"
size="large"
className={"relative min-w-0 border-none text-lg"}
<BuilderActionButton
onClick={handleScheduleGraph}
disabled={!flowID}
>
<ClockIcon className="size-6" />
</Button>
</BuilderActionButton>
</TooltipTrigger>
<TooltipContent>
<p>Schedule Graph</p>

View File

@@ -1,4 +1,4 @@
import { ReactFlow, Background, Controls } from "@xyflow/react";
import { ReactFlow, Background } from "@xyflow/react";
import NewControlPanel from "../../NewControlPanel/NewControlPanel";
import CustomEdge from "../edges/CustomEdge";
import { useFlow } from "./useFlow";
@@ -13,6 +13,7 @@ import { BuilderActions } from "../../BuilderActions/BuilderActions";
import { RunningBackground } from "./components/RunningBackground";
import { useGraphStore } from "../../../stores/graphStore";
import { useCopyPaste } from "./useCopyPaste";
import { CustomControls } from "./components/CustomControl";
export const Flow = () => {
const nodes = useNodeStore(useShallow((state) => state.nodes));
@@ -24,7 +25,8 @@ export const Flow = () => {
const { edges, onConnect, onEdgesChange } = useCustomEdge();
// We use this hook to load the graph and convert them into custom nodes and edges.
const { onDragOver, onDrop } = useFlow();
const { onDragOver, onDrop, isFlowContentLoading, isLocked, setIsLocked } =
useFlow();
// This hook is used for websocket realtime updates.
useFlowRealtime();
@@ -42,8 +44,6 @@ export const Flow = () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [handleCopyPaste]);
const { isFlowContentLoading } = useFlow();
const { isGraphRunning } = useGraphStore();
return (
<div className="flex h-full w-full dark:bg-slate-900">
@@ -60,12 +60,15 @@ export const Flow = () => {
minZoom={0.1}
onDragOver={onDragOver}
onDrop={onDrop}
nodesDraggable={!isLocked}
nodesConnectable={!isLocked}
elementsSelectable={!isLocked}
>
<Background />
<Controls />
<CustomControls setIsLocked={setIsLocked} isLocked={isLocked} />
<NewControlPanel />
<BuilderActions />
{isFlowContentLoading && <GraphLoadingBox />}
{<GraphLoadingBox flowContentLoading={isFlowContentLoading} />}
{isGraphRunning && <RunningBackground />}
</ReactFlow>
</div>

View File

@@ -0,0 +1,80 @@
import { useReactFlow } from "@xyflow/react";
import { Button } from "@/components/atoms/Button/Button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import {
FrameCornersIcon,
MinusIcon,
PlusIcon,
} from "@phosphor-icons/react/dist/ssr";
import { LockIcon, LockOpenIcon } from "lucide-react";
import { memo } from "react";
export const CustomControls = memo(
({
setIsLocked,
isLocked,
}: {
isLocked: boolean;
setIsLocked: (isLocked: boolean) => void;
}) => {
const { zoomIn, zoomOut, fitView } = useReactFlow();
const controls = [
{
icon: <PlusIcon className="size-4" />,
label: "Zoom In",
onClick: () => zoomIn(),
className: "h-10 w-10 border-none",
},
{
icon: <MinusIcon className="size-4" />,
label: "Zoom Out",
onClick: () => zoomOut(),
className: "h-10 w-10 border-none",
},
{
icon: <FrameCornersIcon className="size-4" />,
label: "Fit View",
onClick: () => fitView({ padding: 0.2, duration: 800, maxZoom: 1 }),
className: "h-10 w-10 border-none",
},
{
icon: !isLocked ? (
<LockOpenIcon className="size-4" />
) : (
<LockIcon className="size-4" />
),
label: "Toggle Lock",
onClick: () => setIsLocked(!isLocked),
className: `h-10 w-10 border-none ${isLocked ? "bg-zinc-100" : "bg-white"}`,
},
];
return (
<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">
{controls.map((control, index) => (
<Tooltip key={index} delayDuration={300}>
<TooltipTrigger asChild>
<Button
variant="icon"
size={"small"}
onClick={control.onClick}
className={control.className}
>
{control.icon}
<span className="sr-only">{control.label}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">{control.label}</TooltipContent>
</Tooltip>
))}
</div>
);
},
);
CustomControls.displayName = "CustomControls";

View File

@@ -1,6 +1,28 @@
import {
getPostV1CreateNewGraphMutationOptions,
getPutV1UpdateGraphVersionMutationOptions,
} from "@/app/api/__generated__/endpoints/graphs/graphs";
import { Text } from "@/components/atoms/Text/Text";
import { useIsMutating } from "@tanstack/react-query";
export const GraphLoadingBox = ({
flowContentLoading,
}: {
flowContentLoading: boolean;
}) => {
const isCreating = useIsMutating({
mutationKey: getPostV1CreateNewGraphMutationOptions().mutationKey,
});
const isUpdating = useIsMutating({
mutationKey: getPutV1UpdateGraphVersionMutationOptions().mutationKey,
});
const isSaving = !!(isCreating || isUpdating);
if (!flowContentLoading && !isSaving) {
return null;
}
export const GraphLoadingBox = () => {
return (
<div className="absolute left-[50%] top-[50%] z-[99] -translate-x-1/2 -translate-y-1/2">
<div className="flex flex-col items-center gap-4 rounded-xlarge border border-gray-200 bg-white p-8 shadow-lg dark:border-gray-700 dark:bg-slate-800">
@@ -8,8 +30,15 @@ export const GraphLoadingBox = () => {
<div className="absolute inset-0 animate-spin rounded-full border-4 border-violet-200 border-t-violet-500 dark:border-gray-700 dark:border-t-blue-400"></div>
</div>
<div className="flex flex-col items-center gap-2">
<Text variant="h4">Loading Flow</Text>
<Text variant="small">Please wait while we load your graph...</Text>
{isSaving && <Text variant="h4">Saving Graph</Text>}
{flowContentLoading && <Text variant="h4">Loading Flow</Text>}
{isSaving && (
<Text variant="small">Please wait while we save your graph...</Text>
)}
{flowContentLoading && (
<Text variant="small">Please wait while we load your graph...</Text>
)}
</div>
</div>
</div>

View File

@@ -2,154 +2,53 @@ export const RunningBackground = () => {
return (
<div className="absolute inset-0 h-full w-full">
<style jsx>{`
@keyframes rotateGradient {
0% {
border-image: linear-gradient(
to right,
#bc82f3 17%,
#f5b9ea 24%,
#8d99ff 35%,
#aa6eee 58%,
#ff6778 70%,
#ffba71 81%,
#c686ff 92%
)
1;
}
14.28% {
border-image: linear-gradient(
to right,
#c686ff 17%,
#bc82f3 24%,
#f5b9ea 35%,
#8d99ff 58%,
#aa6eee 70%,
#ff6778 81%,
#ffba71 92%
)
1;
}
28.56% {
border-image: linear-gradient(
to right,
#ffba71 17%,
#c686ff 24%,
#bc82f3 35%,
#f5b9ea 58%,
#8d99ff 70%,
#aa6eee 81%,
#ff6778 92%
)
1;
}
42.84% {
border-image: linear-gradient(
to right,
#ff6778 17%,
#ffba71 24%,
#c686ff 35%,
#bc82f3 58%,
#f5b9ea 70%,
#8d99ff 81%,
#aa6eee 92%
)
1;
}
57.12% {
border-image: linear-gradient(
to right,
#aa6eee 17%,
#ff6778 24%,
#ffba71 35%,
#c686ff 58%,
#bc82f3 70%,
#f5b9ea 81%,
#8d99ff 92%
)
1;
}
71.4% {
border-image: linear-gradient(
to right,
#8d99ff 17%,
#aa6eee 24%,
#ff6778 35%,
#ffba71 58%,
#c686ff 70%,
#bc82f3 81%,
#f5b9ea 92%
)
1;
}
85.68% {
border-image: linear-gradient(
to right,
#f5b9ea 17%,
#8d99ff 24%,
#aa6eee 35%,
#ff6778 58%,
#ffba71 70%,
#c686ff 81%,
#bc82f3 92%
)
1;
}
@keyframes pulse {
0%,
100% {
border-image: linear-gradient(
to right,
#bc82f3 17%,
#f5b9ea 24%,
#8d99ff 35%,
#aa6eee 58%,
#ff6778 70%,
#ffba71 81%,
#c686ff 92%
)
1;
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.animate-gradient {
animation: rotateGradient 8s linear infinite;
.animate-pulse-border {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
`}</style>
<div
className="animate-gradient absolute inset-0 bg-transparent blur-xl"
className="animate-pulse-border absolute inset-0 bg-transparent blur-xl"
style={{
borderWidth: "15px",
borderStyle: "solid",
borderColor: "transparent",
borderImage:
"linear-gradient(to right, #BC82F3 17%, #F5B9EA 24%, #8D99FF 35%, #AA6EEE 58%, #FF6778 70%, #FFBA71 81%, #C686FF 92%) 1",
borderImage: "linear-gradient(to right, #BC82F3, #BC82F3) 1",
}}
></div>
<div
className="animate-gradient absolute inset-0 bg-transparent blur-lg"
className="animate-pulse-border absolute inset-0 bg-transparent blur-lg"
style={{
borderWidth: "10px",
borderStyle: "solid",
borderColor: "transparent",
borderImage:
"linear-gradient(to right, #BC82F3 17%, #F5B9EA 24%, #8D99FF 35%, #AA6EEE 58%, #FF6778 70%, #FFBA71 81%, #C686FF 92%) 1",
borderImage: "linear-gradient(to right, #BC82F3, #BC82F3) 1",
}}
></div>
<div
className="animate-gradient absolute inset-0 bg-transparent blur-md"
className="animate-pulse-border absolute inset-0 bg-transparent blur-md"
style={{
borderWidth: "6px",
borderStyle: "solid",
borderColor: "transparent",
borderImage:
"linear-gradient(to right, #BC82F3 17%, #F5B9EA 24%, #8D99FF 35%, #AA6EEE 58%, #FF6778 70%, #FFBA71 81%, #C686FF 92%) 1",
borderImage: "linear-gradient(to right, #BC82F3, #BC82F3) 1",
}}
></div>
<div
className="animate-gradient absolute inset-0 bg-transparent blur-sm"
className="animate-pulse-border absolute inset-0 bg-transparent blur-sm"
style={{
borderWidth: "6px",
borderStyle: "solid",
borderColor: "transparent",
borderImage:
"linear-gradient(to right, #BC82F3 17%, #F5B9EA 24%, #8D99FF 35%, #AA6EEE 58%, #FF6778 70%, #FFBA71 81%, #C686FF 92%) 1",
borderImage: "linear-gradient(to right, #BC82F3, #BC82F3) 1",
}}
></div>
</div>

View File

@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useGetV2GetSpecificBlocks } from "@/app/api/__generated__/endpoints/default/default";
import {
useGetV1GetExecutionDetails,
@@ -18,6 +18,7 @@ import { useReactFlow } from "@xyflow/react";
import { useControlPanelStore } from "../../../stores/controlPanelStore";
export const useFlow = () => {
const [isLocked, setIsLocked] = useState(false);
const addNodes = useNodeStore(useShallow((state) => state.addNodes));
const addLinks = useEdgeStore(useShallow((state) => state.addLinks));
const updateNodeStatus = useNodeStore(
@@ -69,7 +70,9 @@ export const useFlow = () => {
);
const nodes = graph?.nodes;
const blockIds = nodes?.map((node) => node.block_id);
const blockIds = nodes
? Array.from(new Set(nodes.map((node) => node.block_id)))
: undefined;
const { data: blocks, isLoading: isBlocksLoading } =
useGetV2GetSpecificBlocks(
@@ -95,34 +98,43 @@ export const useFlow = () => {
});
}, [nodes, blocks]);
// load graph schemas
useEffect(() => {
// load graph schemas
if (graph) {
setGraphSchemas(
graph.input_schema as Record<string, any> | null,
graph.credentials_input_schema as Record<string, any> | null,
);
}
}, [graph]);
// adding nodes
// adding nodes
useEffect(() => {
if (customNodes.length > 0) {
useNodeStore.getState().setNodes([]);
addNodes(customNodes);
}
}, [customNodes, addNodes]);
// adding links
// adding links
useEffect(() => {
if (graph?.links) {
useEdgeStore.getState().setEdges([]);
addLinks(graph.links);
}
}, [graph?.links, addLinks]);
// update graph running status
// update graph running status
useEffect(() => {
const isRunning =
executionDetails?.status === AgentExecutionStatus.RUNNING ||
executionDetails?.status === AgentExecutionStatus.QUEUED;
setIsGraphRunning(isRunning);
// update node execution status in nodes
setIsGraphRunning(isRunning);
}, [executionDetails?.status]);
// update node execution status in nodes
useEffect(() => {
if (
executionDetails &&
"node_executions" in executionDetails &&
@@ -132,8 +144,10 @@ export const useFlow = () => {
updateNodeStatus(nodeExecution.node_id, nodeExecution.status);
});
}
}, [executionDetails, updateNodeStatus]);
// update node execution results in nodes, also update edge beads
// update node execution results in nodes, also update edge beads
useEffect(() => {
if (
executionDetails &&
"node_executions" in executionDetails &&
@@ -144,7 +158,7 @@ export const useFlow = () => {
updateEdgeBeads(nodeExecution.node_id, nodeExecution);
});
}
}, [customNodes, addNodes, graph?.links, executionDetails, updateNodeStatus]);
}, [executionDetails, updateNodeExecutionResult, updateEdgeBeads]);
useEffect(() => {
return () => {
@@ -162,30 +176,37 @@ export const useFlow = () => {
event.dataTransfer.dropEffect = "copy";
}, []);
const onDrop = async (event: React.DragEvent) => {
event.preventDefault();
const blockDataString = event.dataTransfer.getData("application/reactflow");
if (!blockDataString) return;
const onDrop = useCallback(
async (event: React.DragEvent) => {
event.preventDefault();
const blockDataString = event.dataTransfer.getData(
"application/reactflow",
);
if (!blockDataString) return;
try {
const blockData = JSON.parse(blockDataString) as BlockInfo;
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
addBlock(blockData, position);
try {
const blockData = JSON.parse(blockDataString) as BlockInfo;
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
addBlock(blockData, {}, position);
await new Promise((resolve) => setTimeout(resolve, 200));
setBlockMenuOpen(true);
} catch (error) {
console.error("Failed to drop block:", error);
setBlockMenuOpen(true);
}
};
await new Promise((resolve) => setTimeout(resolve, 200));
setBlockMenuOpen(true);
} catch (error) {
console.error("Failed to drop block:", error);
setBlockMenuOpen(true);
}
},
[screenToFlowPosition, addBlock, setBlockMenuOpen],
);
return {
isFlowContentLoading: isGraphLoading || isBlocksLoading,
onDragOver,
onDrop,
isLocked,
setIsLocked,
};
};

View File

@@ -2,6 +2,7 @@ import { cn } from "@/lib/utils";
import { nodeStyleBasedOnStatus } from "../helpers";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { useShallow } from "zustand/react/shallow";
export const NodeContainer = ({
children,
@@ -12,7 +13,9 @@ export const NodeContainer = ({
nodeId: string;
selected: boolean;
}) => {
const status = useNodeStore((state) => state.getNodeStatus(nodeId));
const status = useNodeStore(
useShallow((state) => state.getNodeStatus(nodeId)),
);
return (
<div
className={cn(

View File

@@ -3,6 +3,7 @@ import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecut
import { Badge } from "@/components/__legacy__/ui/badge";
import { LoadingSpinner } from "@/components/__legacy__/ui/loading";
import { cn } from "@/lib/utils";
import { useShallow } from "zustand/react/shallow";
const statusStyles: Record<AgentExecutionStatus, string> = {
INCOMPLETE: "text-slate-700 border-slate-400",
@@ -14,7 +15,9 @@ const statusStyles: Record<AgentExecutionStatus, string> = {
};
export const NodeExecutionBadge = ({ nodeId }: { nodeId: string }) => {
const status = useNodeStore((state) => state.getNodeStatus(nodeId));
const status = useNodeStore(
useShallow((state) => state.getNodeStatus(nodeId)),
);
if (!status) return null;
return (
<div className="flex items-center justify-end rounded-b-xl py-2 pr-4">

View File

@@ -7,6 +7,8 @@ import { PlusIcon } from "@phosphor-icons/react";
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
import { useControlPanelStore } from "../../../stores/controlPanelStore";
import { blockDragPreviewStyle } from "./style";
import { useReactFlow } from "@xyflow/react";
import { useNodeStore } from "../../../stores/nodeStore";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
title?: string;
description?: string;
@@ -29,6 +31,23 @@ export const Block: BlockComponent = ({
const setBlockMenuOpen = useControlPanelStore(
(state) => state.setBlockMenuOpen,
);
const { setViewport } = useReactFlow();
const { addBlock } = useNodeStore();
const handleClick = () => {
const customNode = addBlock(blockData);
setTimeout(() => {
setViewport(
{
x: -customNode.position.x * 0.8 + window.innerWidth / 2,
y: -customNode.position.y * 0.8 + (window.innerHeight - 400) / 2,
zoom: 0.8,
},
{ duration: 500 },
);
}, 50);
};
const handleDragStart = (e: React.DragEvent<HTMLButtonElement>) => {
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("application/reactflow", JSON.stringify(blockData));
@@ -55,6 +74,7 @@ export const Block: BlockComponent = ({
className,
)}
onDragStart={handleDragStart}
onClick={handleClick}
{...rest}
>
<div className="flex flex-1 flex-col items-start gap-0.5">

View File

@@ -1,7 +1,6 @@
import React from "react";
import { Block } from "../Block";
import { blockMenuContainerStyle } from "../style";
import { useNodeStore } from "../../../../stores/nodeStore";
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
interface BlocksListProps {
@@ -13,7 +12,6 @@ export const BlocksList: React.FC<BlocksListProps> = ({
blocks,
loading = false,
}) => {
const { addBlock } = useNodeStore();
if (loading) {
return (
<div className={blockMenuContainerStyle}>
@@ -28,7 +26,6 @@ export const BlocksList: React.FC<BlocksListProps> = ({
key={block.id}
title={block.name}
description={block.description}
onClick={() => addBlock(block)}
blockData={block}
/>
));

View File

@@ -11,7 +11,6 @@ import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
import { blockMenuContainerStyle } from "../style";
import { cn } from "@/lib/utils";
import { NoSearchResult } from "../NoSearchResult";
import { useNodeStore } from "../../../../stores/nodeStore";
export const BlockMenuSearch = () => {
const {
@@ -22,7 +21,6 @@ export const BlockMenuSearch = () => {
searchLoading,
} = useBlockMenuSearch();
const { searchQuery } = useBlockMenuStore();
const addBlock = useNodeStore((state) => state.addBlock);
if (searchLoading) {
return (
@@ -75,7 +73,6 @@ export const BlockMenuSearch = () => {
title={data.name}
highlightedText={searchQuery}
description={data.description}
onClick={() => addBlock(data)}
blockData={data}
/>
);

View File

@@ -5,7 +5,6 @@ import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { useIntegrationBlocks } from "./useIntegrationBlocks";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { InfiniteScroll } from "@/components/contextual/InfiniteScroll/InfiniteScroll";
import { useNodeStore } from "../../../../stores/nodeStore";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
export const IntegrationBlocks = () => {
@@ -21,7 +20,6 @@ export const IntegrationBlocks = () => {
error,
refetch,
} = useIntegrationBlocks();
const addBlock = useNodeStore((state) => state.addBlock);
if (blocksLoading) {
return (
@@ -93,8 +91,8 @@ export const IntegrationBlocks = () => {
key={block.id}
title={block.name}
description={block.description}
blockData={block}
icon_url={`/integrations/${integration}.png`}
onClick={() => addBlock(block)}
/>
))}
</div>

View File

@@ -5,12 +5,18 @@ import Image from "next/image";
import React, { ButtonHTMLAttributes } from "react";
import { highlightText } from "./helpers";
import { Button } from "@/components/atoms/Button/Button";
import { useControlPanelStore } from "../../../stores/controlPanelStore";
import { useReactFlow } from "@xyflow/react";
import { useNodeStore } from "../../../stores/nodeStore";
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
import { blockDragPreviewStyle } from "./style";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
title?: string;
description?: string;
icon_url?: string;
highlightedText?: string;
blockData: BlockInfo;
}
interface IntegrationBlockComponent extends React.FC<Props> {
@@ -23,16 +29,57 @@ export const IntegrationBlock: IntegrationBlockComponent = ({
description,
className,
highlightedText,
blockData,
...rest
}) => {
const setBlockMenuOpen = useControlPanelStore(
(state) => state.setBlockMenuOpen,
);
const { setViewport } = useReactFlow();
const { addBlock } = useNodeStore();
const handleClick = () => {
const customNode = addBlock(blockData);
setTimeout(() => {
setViewport(
{
x: -customNode.position.x * 0.8 + window.innerWidth / 2,
y: -customNode.position.y * 0.8 + (window.innerHeight - 400) / 2,
zoom: 0.8,
},
{ duration: 500 },
);
}, 50);
};
const handleDragStart = (e: React.DragEvent<HTMLButtonElement>) => {
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("application/reactflow", JSON.stringify(blockData));
setBlockMenuOpen(false);
// preview when user drags it
const dragPreview = document.createElement("div");
dragPreview.style.cssText = blockDragPreviewStyle;
dragPreview.textContent = beautifyString(title || "");
document.body.appendChild(dragPreview);
e.dataTransfer.setDragImage(dragPreview, 0, 0);
setTimeout(() => document.body.removeChild(dragPreview), 0);
};
return (
<Button
draggable={true}
variant={"ghost"}
className={cn(
"group flex h-16 w-full min-w-[7.5rem] items-center justify-start gap-3 whitespace-normal rounded-[0.75rem] bg-zinc-50 px-[0.875rem] py-[0.625rem] text-start shadow-none",
"hover:cursor-default hover:bg-zinc-100 focus:ring-0 active:bg-zinc-100 active:ring-1 active:ring-zinc-300 disabled:cursor-not-allowed",
className,
)}
onDragStart={handleDragStart}
onClick={handleClick}
{...rest}
>
<div className="relative h-[2.625rem] w-[2.625rem] rounded-[0.5rem] bg-white">

View File

@@ -16,6 +16,9 @@ export const MyAgentsContent = () => {
error,
status,
refetch,
handleAddBlock,
isGettingAgentDetails,
selectedAgentId,
} = useMyAgentsContent();
if (agentLoading) {
@@ -59,7 +62,9 @@ export const MyAgentsContent = () => {
title={agent.name}
edited_time={agent.updated_at}
version={agent.graph_version}
isLoading={isGettingAgentDetails && selectedAgentId === agent.id}
image_url={agent.image_url}
onClick={() => handleAddBlock(agent)}
/>
))}
</InfiniteScroll>

View File

@@ -1,7 +1,22 @@
import { useGetV2ListLibraryAgentsInfinite } from "@/app/api/__generated__/endpoints/library/library";
import {
getV2GetLibraryAgent,
useGetV2ListLibraryAgentsInfinite,
} from "@/app/api/__generated__/endpoints/library/library";
import { LibraryAgentResponse } from "@/app/api/__generated__/models/libraryAgentResponse";
import { useState } from "react";
import { convertLibraryAgentIntoCustomNode } from "../helpers";
import { useNodeStore } from "@/app/(platform)/build/stores/nodeStore";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useShallow } from "zustand/react/shallow";
import { useReactFlow } from "@xyflow/react";
export const useMyAgentsContent = () => {
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [isGettingAgentDetails, setIsGettingAgentDetails] = useState(false);
const addBlock = useNodeStore(useShallow((state) => state.addBlock));
const { setViewport } = useReactFlow();
// This endpoints is not giving info about inputSchema and outputSchema
// Will create new endpoint for this
const {
data: agents,
fetchNextPage,
@@ -38,6 +53,43 @@ export const useMyAgentsContent = () => {
const status = agents?.pages[0]?.status;
const handleAddBlock = async (agent: LibraryAgent) => {
setSelectedAgentId(agent.id);
setIsGettingAgentDetails(true);
try {
const response = await getV2GetLibraryAgent(agent.id);
if (!response.data) {
console.error("Failed to get agent details", selectedAgentId, agent.id);
return;
}
const { input_schema, output_schema } = response.data as LibraryAgent;
const { block, hardcodedValues } = convertLibraryAgentIntoCustomNode(
agent,
input_schema,
output_schema,
);
const customNode = addBlock(block, hardcodedValues);
setTimeout(() => {
setViewport(
{
x: -customNode.position.x * 0.8 + window.innerWidth / 2,
y: -customNode.position.y * 0.8 + (window.innerHeight - 400) / 2,
zoom: 0.8,
},
{ duration: 500 },
);
}, 50);
} catch (error) {
console.error("Error adding block:", error);
} finally {
setSelectedAgentId(null);
setIsGettingAgentDetails(false);
}
};
return {
allAgents,
agentLoading,
@@ -48,5 +100,8 @@ export const useMyAgentsContent = () => {
refetch,
error,
status,
handleAddBlock,
isGettingAgentDetails,
selectedAgentId,
};
};

View File

@@ -4,14 +4,12 @@ import { Block } from "../Block";
import { useSuggestionContent } from "./useSuggestionContent";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { blockMenuContainerStyle } from "../style";
import { useNodeStore } from "../../../../stores/nodeStore";
import { useBlockMenuStore } from "../../../../stores/blockMenuStore";
import { DefaultStateType } from "../types";
export const SuggestionContent = () => {
const { setIntegration, setDefaultState } = useBlockMenuStore();
const { data, isLoading, isError, error, refetch } = useSuggestionContent();
const addBlock = useNodeStore((state) => state.addBlock);
if (isError) {
return (
@@ -76,7 +74,6 @@ export const SuggestionContent = () => {
key={`block-${index}`}
title={block.name}
description={block.description}
onClick={() => addBlock(block)}
blockData={block}
/>
))

View File

@@ -1,13 +1,15 @@
import { Button } from "@/components/__legacy__/ui/button";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { cn } from "@/lib/utils";
import { Plus } from "lucide-react";
import Image from "next/image";
import React, { ButtonHTMLAttributes } from "react";
import { highlightText } from "./helpers";
import { formatTimeAgo } from "@/lib/utils/time";
import { CircleNotchIcon } from "@phosphor-icons/react";
import { PlusIcon } from "@phosphor-icons/react/dist/ssr";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
isLoading?: boolean;
title?: string;
edited_time?: Date;
version?: number;
@@ -20,6 +22,7 @@ interface UGCAgentBlockComponent extends React.FC<Props> {
}
export const UGCAgentBlock: UGCAgentBlockComponent = ({
isLoading,
title,
image_url,
edited_time = new Date(),
@@ -85,7 +88,11 @@ export const UGCAgentBlock: UGCAgentBlockComponent = ({
"flex h-7 w-7 items-center justify-center rounded-[0.5rem] bg-zinc-700 group-disabled:bg-zinc-400",
)}
>
<Plus className="h-5 w-5 text-zinc-50" strokeWidth={2} />
{isLoading ? (
<CircleNotchIcon className="h-5 w-5 animate-spin text-zinc-50" />
) : (
<PlusIcon className="h-5 w-5 text-zinc-50" strokeWidth={2} />
)}
</div>
</Button>
);

View File

@@ -1,3 +1,10 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { BlockUIType } from "../../types";
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
import { BlockCategory } from "../../helper";
import { RJSFSchema } from "@rjsf/utils";
import { SpecialBlockID } from "@/lib/autogpt-server-api";
export const highlightText = (
text: string | undefined,
highlight: string | undefined,
@@ -20,3 +27,37 @@ export const highlightText = (
),
);
};
export const convertLibraryAgentIntoCustomNode = (
agent: LibraryAgent,
inputSchema: RJSFSchema = {} as RJSFSchema,
outputSchema: RJSFSchema = {} as RJSFSchema,
) => {
const block: BlockInfo = {
id: SpecialBlockID.AGENT,
name: agent.name,
description:
`Ver.${agent.graph_version}` +
(agent.description ? ` | ${agent.description}` : ""),
categories: [{ category: BlockCategory.AGENT, description: "" }],
inputSchema: inputSchema,
outputSchema: outputSchema,
staticOutput: false,
uiType: BlockUIType.AGENT,
costs: [],
contributors: [],
};
const hardcodedValues: Record<string, any> = {
graph_id: agent.graph_id,
graph_version: agent.graph_version,
input_schema: inputSchema,
output_schema: outputSchema,
agent_name: agent.name,
};
return {
block,
hardcodedValues,
};
};

View File

@@ -1,14 +1,12 @@
// import { Separator } from "@/components/__legacy__/ui/separator";
import { cn } from "@/lib/utils";
import React, { useMemo } from "react";
import React, { memo } from "react";
import { BlockMenu } from "./NewBlockMenu/BlockMenu/BlockMenu";
import { useNewControlPanel } from "./useNewControlPanel";
// import { NewSaveControl } from "../SaveControl/NewSaveControl";
import { GraphExecutionID } from "@/lib/autogpt-server-api";
// import { ControlPanelButton } from "../ControlPanelButton";
import { ArrowUUpLeftIcon, ArrowUUpRightIcon } from "@phosphor-icons/react";
// import { GraphSearchMenu } from "../GraphMenu/GraphMenu";
import { history } from "@/app/(platform)/build/components/legacy-builder/history";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { Separator } from "@/components/__legacy__/ui/separator";
import { NewSaveControl } from "./NewSaveControl/NewSaveControl";
@@ -31,56 +29,39 @@ export type NewControlPanelProps = {
onNodeSelect?: (nodeId: string) => void;
onNodeHover?: (nodeId: string) => void;
};
export const NewControlPanel = ({
flowExecutionID: _flowExecutionID,
visualizeBeads: _visualizeBeads,
pinSavePopover: _pinSavePopover,
pinBlocksPopover: _pinBlocksPopover,
nodes: _nodes,
onNodeSelect: _onNodeSelect,
onNodeHover: _onNodeHover,
}: NewControlPanelProps) => {
const _isGraphSearchEnabled = useGetFlag(Flag.GRAPH_SEARCH);
export const NewControlPanel = memo(
({
flowExecutionID: _flowExecutionID,
visualizeBeads: _visualizeBeads,
pinSavePopover: _pinSavePopover,
pinBlocksPopover: _pinBlocksPopover,
nodes: _nodes,
onNodeSelect: _onNodeSelect,
onNodeHover: _onNodeHover,
}: NewControlPanelProps) => {
const _isGraphSearchEnabled = useGetFlag(Flag.GRAPH_SEARCH);
const {
// agentDescription,
// setAgentDescription,
// saveAgent,
// agentName,
// setAgentName,
// savedAgent,
// isSaving,
// isRunning,
// isStopping,
} = useNewControlPanel({});
const {
// agentDescription,
// setAgentDescription,
// saveAgent,
// agentName,
// setAgentName,
// savedAgent,
// isSaving,
// isRunning,
// isStopping,
} = useNewControlPanel({});
const _controls: Control[] = useMemo(
() => [
{
label: "Undo",
icon: <ArrowUUpLeftIcon size={20} weight="bold" />,
onClick: history.undo,
disabled: !history.canUndo(),
},
{
label: "Redo",
icon: <ArrowUUpRightIcon size={20} weight="bold" />,
onClick: history.redo,
disabled: !history.canRedo(),
},
],
[],
);
return (
<section
className={cn(
"absolute left-4 top-10 z-10 w-[4.25rem] overflow-hidden rounded-[1rem] border-none bg-white p-0 shadow-[0_1px_5px_0_rgba(0,0,0,0.1)]",
)}
>
<div className="flex flex-col items-center justify-center rounded-[1rem] p-0">
<BlockMenu />
{/* <Separator className="text-[#E1E1E1]" />
return (
<section
className={cn(
"absolute left-4 top-10 z-10 w-[4.25rem] overflow-hidden rounded-[1rem] border-none bg-white p-0 shadow-[0_1px_5px_0_rgba(0,0,0,0.1)]",
)}
>
<div className="flex flex-col items-center justify-center rounded-[1rem] p-0">
<BlockMenu />
{/* <Separator className="text-[#E1E1E1]" />
{isGraphSearchEnabled && (
<>
<GraphSearchMenu
@@ -105,13 +86,16 @@ export const NewControlPanel = ({
{control.icon}
</ControlPanelButton>
))} */}
<Separator className="text-[#E1E1E1]" />
<NewSaveControl />
<Separator className="text-[#E1E1E1]" />
<UndoRedoButtons />
</div>
</section>
);
};
<Separator className="text-[#E1E1E1]" />
<NewSaveControl />
<Separator className="text-[#E1E1E1]" />
<UndoRedoButtons />
</div>
</section>
);
},
);
export default NewControlPanel;
NewControlPanel.displayName = "NewControlPanel";

View File

@@ -42,9 +42,10 @@ export const graphsEquivalent = (
name: current.name,
description: current.description,
nodes: sortNodes(current.nodes ?? []).map(({ id: _, ...rest }) => rest),
links: sortLinks(current.links ?? []).map(
({ source_id: _, sink_id: __, ...rest }) => rest,
),
links: sortLinks(current.links ?? []).map((v) => ({
sink_name: v.sink_name,
source_name: v.source_name,
})),
};
return deepEquals(_saved, _current);

View File

@@ -8,6 +8,7 @@ import { NodeModel } from "@/app/api/__generated__/models/nodeModel";
import { NodeModelMetadata } from "@/app/api/__generated__/models/nodeModelMetadata";
import { Link } from "@/app/api/__generated__/models/link";
import { CustomEdge } from "./FlowEditor/edges/CustomEdge";
import { XYPosition } from "@xyflow/react";
export const convertBlockInfoIntoCustomNodeData = (
block: BlockInfo,
@@ -115,3 +116,107 @@ export const isCostFilterMatch = (
)
: costFilter === inputValues;
};
// ----- Position related helpers -----
export interface NodeDimensions {
x: number;
y: number;
width: number;
height: number;
}
function rectanglesOverlap(
rect1: NodeDimensions,
rect2: NodeDimensions,
): boolean {
const x1 = rect1.x,
y1 = rect1.y,
w1 = rect1.width,
h1 = rect1.height;
const x2 = rect2.x,
y2 = rect2.y,
w2 = rect2.width,
h2 = rect2.height;
return !(x1 + w1 <= x2 || x1 >= x2 + w2 || y1 + h1 <= y2 || y1 >= y2 + h2);
}
export function findFreePosition(
existingNodes: Array<{
position: XYPosition;
measured?: { width: number; height: number };
}>,
newNodeWidth: number = 500,
margin: number = 60,
): XYPosition {
if (existingNodes.length === 0) {
return { x: 100, y: 100 }; // Default starting position
}
// Start from the most recently added node
for (let i = existingNodes.length - 1; i >= 0; i--) {
const lastNode = existingNodes[i];
const lastNodeWidth = lastNode.measured?.width ?? 500;
const lastNodeHeight = lastNode.measured?.height ?? 400;
// Try right
const candidate = {
x: lastNode.position.x + lastNodeWidth + margin,
y: lastNode.position.y,
width: newNodeWidth,
height: 400, // Estimated height
};
if (
!existingNodes.some((n) =>
rectanglesOverlap(candidate, {
x: n.position.x,
y: n.position.y,
width: n.measured?.width ?? 500,
height: n.measured?.height ?? 400,
}),
)
) {
return { x: candidate.x, y: candidate.y };
}
// Try left
candidate.x = lastNode.position.x - newNodeWidth - margin;
if (
!existingNodes.some((n) =>
rectanglesOverlap(candidate, {
x: n.position.x,
y: n.position.y,
width: n.measured?.width ?? 500,
height: n.measured?.height ?? 400,
}),
)
) {
return { x: candidate.x, y: candidate.y };
}
// Try below
candidate.x = lastNode.position.x;
candidate.y = lastNode.position.y + lastNodeHeight + margin;
if (
!existingNodes.some((n) =>
rectanglesOverlap(candidate, {
x: n.position.x,
y: n.position.y,
width: n.measured?.width ?? 500,
height: n.measured?.height ?? 400,
}),
)
) {
return { x: candidate.x, y: candidate.y };
}
}
// Fallback: place it far to the right
const lastNode = existingNodes[existingNodes.length - 1];
return {
x: lastNode.position.x + 600,
y: lastNode.position.y,
};
}

View File

@@ -2,10 +2,8 @@
import { useCallback } from "react";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { useQueryClient } from "@tanstack/react-query";
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import {
getGetV1GetSpecificGraphQueryKey,
useGetV1GetSpecificGraph,
usePostV1CreateNewGraph,
usePutV1UpdateGraphVersion,
@@ -15,6 +13,8 @@ import { Graph } from "@/app/api/__generated__/models/graph";
import { useNodeStore } from "../stores/nodeStore";
import { useEdgeStore } from "../stores/edgeStore";
import { graphsEquivalent } from "../components/NewControlPanel/NewSaveControl/helpers";
import { useGraphStore } from "../stores/graphStore";
import { useShallow } from "zustand/react/shallow";
export type SaveGraphOptions = {
showToast?: boolean;
@@ -28,13 +28,16 @@ export const useSaveGraph = ({
onError,
}: SaveGraphOptions) => {
const { toast } = useToast();
const queryClient = useQueryClient();
const [{ flowID, flowVersion }, setQueryStates] = useQueryStates({
flowID: parseAsString,
flowVersion: parseAsInteger,
});
const setGraphSchemas = useGraphStore(
useShallow((state) => state.setGraphSchemas),
);
const { data: graph } = useGetV1GetSpecificGraph(
flowID ?? "",
flowVersion !== null ? { version: flowVersion } : {},
@@ -55,9 +58,6 @@ export const useSaveGraph = ({
flowID: data.id,
flowVersion: data.version,
});
queryClient.refetchQueries({
queryKey: getGetV1GetSpecificGraphQueryKey(data.id),
});
onSuccess?.(data);
if (showToast) {
toast({
@@ -88,9 +88,6 @@ export const useSaveGraph = ({
flowID: data.id,
flowVersion: data.version,
});
queryClient.refetchQueries({
queryKey: getGetV1GetSpecificGraphQueryKey(data.id),
});
onSuccess?.(data);
if (showToast) {
toast({
@@ -140,7 +137,12 @@ export const useSaveGraph = ({
return;
}
await updateGraph({ graphId: graph.id, data: data });
const response = await updateGraph({ graphId: graph.id, data: data });
const graphData = response.data as GraphModel;
setGraphSchemas(
graphData.input_schema,
graphData.credentials_input_schema,
);
} else {
const data: Graph = {
name: values?.name || `New Agent ${new Date().toISOString()}`,
@@ -149,7 +151,12 @@ export const useSaveGraph = ({
links: graphLinks,
};
await createNewGraph({ data: { graph: data } });
const response = await createNewGraph({ data: { graph: data } });
const graphData = response.data as GraphModel;
setGraphSchemas(
graphData.input_schema,
graphData.credentials_input_schema,
);
}
},
[graph, toast, createNewGraph, updateGraph],

View File

@@ -2,7 +2,10 @@ import { create } from "zustand";
import { NodeChange, XYPosition, applyNodeChanges } from "@xyflow/react";
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
import { convertBlockInfoIntoCustomNodeData } from "../components/helper";
import {
convertBlockInfoIntoCustomNodeData,
findFreePosition,
} from "../components/helper";
import { Node } from "@/app/api/__generated__/models/node";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
@@ -17,7 +20,11 @@ type NodeStore = {
setNodes: (nodes: CustomNode[]) => void;
onNodesChange: (changes: NodeChange<CustomNode>[]) => void;
addNode: (node: CustomNode) => void;
addBlock: (block: BlockInfo, position?: XYPosition) => void;
addBlock: (
block: BlockInfo,
hardcodedValues?: Record<string, any>,
position?: XYPosition,
) => CustomNode;
incrementNodeCounter: () => void;
updateNodeData: (nodeId: string, data: Partial<CustomNode["data"]>) => void;
toggleAdvanced: (nodeId: string) => void;
@@ -36,7 +43,6 @@ type NodeStore = {
result: NodeExecutionResult,
) => void;
getNodeExecutionResult: (nodeId: string) => NodeExecutionResult | undefined;
getNodeBlockUIType: (nodeId: string) => BlockUIType;
};
@@ -74,19 +80,42 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
nodes: [...state.nodes, node],
}));
},
addBlock: (block: BlockInfo, position?: XYPosition) => {
const customNodeData = convertBlockInfoIntoCustomNodeData(block);
addBlock: (
block: BlockInfo,
hardcodedValues?: Record<string, any>,
position?: XYPosition,
) => {
const customNodeData = convertBlockInfoIntoCustomNodeData(
block,
hardcodedValues,
);
get().incrementNodeCounter();
const nodeNumber = get().nodeCounter;
const nodePosition =
position ||
findFreePosition(
get().nodes.map((node) => ({
position: node.position,
measured: {
width: node.data.uiType === BlockUIType.NOTE ? 300 : 500,
height: 400,
},
})),
block.uiType === BlockUIType.NOTE ? 300 : 400,
30,
);
const customNode: CustomNode = {
id: nodeNumber.toString(),
data: customNodeData,
type: "custom",
position: position || ({ x: 0, y: 0 } as XYPosition),
position: nodePosition,
};
set((state) => ({
nodes: [...state.nodes, customNode],
}));
return customNode;
},
updateNodeData: (nodeId, data) => {
set((state) => ({

View File

@@ -150,7 +150,9 @@ const FieldTemplate: React.FC<FieldTemplateProps> = ({
</label>
)}
{(isAnyOf || !isConnected) && (
<div className={cn(size === "small" ? "pl-2" : "")}>{children}</div>
<div className={cn(size === "small" ? "max-w-[340px] pl-2" : "")}>
{children}
</div>
)}{" "}
</div>
);