fix(frontend): improve history tracking and error handling in flow editor (#11784)

### Changes 🏗️

- Improved error handling in `useRunInputDialog.ts` to properly handle
cases where node errors are empty or undefined
- Fixed node collision resolution in Flow component by using the current
state from the store instead of stale props
- Enhanced edge management to track history when edges are removed
- Fixed potential null reference in NodeHeader when accessing
hardcodedValues
- Improved history management in edgeStore by saving state before
modifications
- Significantly enhanced historyStore with better undo/redo
functionality:
  - Added checks to prevent redundant state changes
  - Fixed comparison of current vs. previous states
  - Improved tracking of drag operations
- Fixed nodeStore to properly track node position changes and maintain
history
- Removed console.log statement from FormRenderer

### 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] Verified undo/redo functionality works correctly after
adding/removing nodes
  - [x] Confirmed error handling works when validation fails
  - [x] Tested node dragging and collision resolution
  - [x] Verified edge creation and deletion properly updates history
This commit is contained in:
Abhimanyu Yadav
2026-01-16 18:43:19 +05:30
committed by GitHub
parent 0116e0686e
commit 11d5ef2f43
8 changed files with 161 additions and 63 deletions

View File

@@ -48,17 +48,29 @@ export const useRunInputDialog = ({
},
onError: (error) => {
if (error instanceof ApiError && error.isGraphValidationError()) {
const errorData = error.response?.detail;
Object.entries(errorData.node_errors).forEach(
([nodeId, nodeErrors]) => {
useNodeStore
.getState()
.updateNodeErrors(
nodeId,
nodeErrors as { [key: string]: string },
);
},
);
const errorData = error.response?.detail || {
node_errors: {},
message: undefined,
};
const nodeErrors = errorData.node_errors || {};
if (Object.keys(nodeErrors).length > 0) {
Object.entries(nodeErrors).forEach(
([nodeId, nodeErrorsForNode]) => {
useNodeStore
.getState()
.updateNodeErrors(
nodeId,
nodeErrorsForNode as { [key: string]: string },
);
},
);
} else {
useNodeStore.getState().nodes.forEach((node) => {
useNodeStore.getState().updateNodeErrors(node.id, {});
});
}
toast({
title: errorData?.message || "Graph validation failed",
description:
@@ -67,7 +79,7 @@ export const useRunInputDialog = ({
});
setIsOpen(false);
const firstBackendId = Object.keys(errorData.node_errors)[0];
const firstBackendId = Object.keys(nodeErrors)[0];
if (firstBackendId) {
const firstErrorNode = useNodeStore

View File

@@ -55,14 +55,16 @@ export const Flow = () => {
const edgeTypes = useMemo(() => ({ custom: CustomEdge }), []);
const onNodeDragStop = useCallback(() => {
const currentNodes = useNodeStore.getState().nodes;
setNodes(
resolveCollisions(nodes, {
resolveCollisions(currentNodes, {
maxIterations: Infinity,
overlapThreshold: 0.5,
margin: 15,
}),
);
}, [setNodes, nodes]);
}, [setNodes]);
const { edges, onConnect, onEdgesChange } = useCustomEdge();
// for loading purpose

View File

@@ -6,6 +6,7 @@ import {
import { useEdgeStore } from "@/app/(platform)/build/stores/edgeStore";
import { useCallback } from "react";
import { useNodeStore } from "../../../stores/nodeStore";
import { useHistoryStore } from "../../../stores/historyStore";
import { CustomEdge } from "./CustomEdge";
export const useCustomEdge = () => {
@@ -51,7 +52,20 @@ export const useCustomEdge = () => {
const onEdgesChange = useCallback(
(changes: EdgeChange<CustomEdge>[]) => {
const hasRemoval = changes.some((change) => change.type === "remove");
const prevState = hasRemoval
? {
nodes: useNodeStore.getState().nodes,
edges: edges,
}
: null;
setEdges(applyEdgeChanges(changes, edges));
if (prevState) {
useHistoryStore.getState().pushState(prevState);
}
},
[edges, setEdges],
);

View File

@@ -22,7 +22,7 @@ export const NodeHeader = ({ data, nodeId }: Props) => {
const updateNodeData = useNodeStore((state) => state.updateNodeData);
const title =
(data.metadata?.customized_name as string) ||
data.hardcodedValues.agent_name ||
data.hardcodedValues?.agent_name ||
data.title;
const [isEditingTitle, setIsEditingTitle] = useState(false);

View File

@@ -5,6 +5,8 @@ import { customEdgeToLink, linkToCustomEdge } from "../components/helper";
import { MarkerType } from "@xyflow/react";
import { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
import { cleanUpHandleId } from "@/components/renderers/InputRenderer/helpers";
import { useHistoryStore } from "./historyStore";
import { useNodeStore } from "./nodeStore";
type EdgeStore = {
edges: CustomEdge[];
@@ -53,25 +55,36 @@ export const useEdgeStore = create<EdgeStore>((set, get) => ({
id,
};
set((state) => {
const exists = state.edges.some(
(e) =>
e.source === newEdge.source &&
e.target === newEdge.target &&
e.sourceHandle === newEdge.sourceHandle &&
e.targetHandle === newEdge.targetHandle,
);
if (exists) return state;
return { edges: [...state.edges, newEdge] };
});
const exists = get().edges.some(
(e) =>
e.source === newEdge.source &&
e.target === newEdge.target &&
e.sourceHandle === newEdge.sourceHandle &&
e.targetHandle === newEdge.targetHandle,
);
if (exists) return newEdge;
const prevState = {
nodes: useNodeStore.getState().nodes,
edges: get().edges,
};
set((state) => ({ edges: [...state.edges, newEdge] }));
useHistoryStore.getState().pushState(prevState);
return newEdge;
},
removeEdge: (edgeId) =>
removeEdge: (edgeId) => {
const prevState = {
nodes: useNodeStore.getState().nodes,
edges: get().edges,
};
set((state) => ({
edges: state.edges.filter((e) => e.id !== edgeId),
})),
}));
useHistoryStore.getState().pushState(prevState);
},
upsertMany: (edges) =>
set((state) => {

View File

@@ -37,6 +37,15 @@ export const useHistoryStore = create<HistoryStore>((set, get) => ({
return;
}
const actualCurrentState = {
nodes: useNodeStore.getState().nodes,
edges: useEdgeStore.getState().edges,
};
if (isEqual(state, actualCurrentState)) {
return;
}
set((prev) => ({
past: [...prev.past.slice(-MAX_HISTORY + 1), state],
future: [],
@@ -55,18 +64,25 @@ export const useHistoryStore = create<HistoryStore>((set, get) => ({
undo: () => {
const { past, future } = get();
if (past.length <= 1) return;
if (past.length === 0) return;
const currentState = past[past.length - 1];
const actualCurrentState = {
nodes: useNodeStore.getState().nodes,
edges: useEdgeStore.getState().edges,
};
const previousState = past[past.length - 2];
const previousState = past[past.length - 1];
if (isEqual(actualCurrentState, previousState)) {
return;
}
useNodeStore.getState().setNodes(previousState.nodes);
useEdgeStore.getState().setEdges(previousState.edges);
set({
past: past.slice(0, -1),
future: [currentState, ...future],
past: past.length > 1 ? past.slice(0, -1) : past,
future: [actualCurrentState, ...future],
});
},
@@ -74,18 +90,36 @@ export const useHistoryStore = create<HistoryStore>((set, get) => ({
const { past, future } = get();
if (future.length === 0) return;
const actualCurrentState = {
nodes: useNodeStore.getState().nodes,
edges: useEdgeStore.getState().edges,
};
const nextState = future[0];
useNodeStore.getState().setNodes(nextState.nodes);
useEdgeStore.getState().setEdges(nextState.edges);
const lastPast = past[past.length - 1];
const shouldPushToPast =
!lastPast || !isEqual(actualCurrentState, lastPast);
set({
past: [...past, nextState],
past: shouldPushToPast ? [...past, actualCurrentState] : past,
future: future.slice(1),
});
},
canUndo: () => get().past.length > 1,
canUndo: () => {
const { past } = get();
if (past.length === 0) return false;
const actualCurrentState = {
nodes: useNodeStore.getState().nodes,
edges: useEdgeStore.getState().edges,
};
return !isEqual(actualCurrentState, past[past.length - 1]);
},
canRedo: () => get().future.length > 0,
clear: () => set({ past: [{ nodes: [], edges: [] }], future: [] }),

View File

@@ -1,6 +1,7 @@
import { create } from "zustand";
import { NodeChange, XYPosition, applyNodeChanges } from "@xyflow/react";
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
import { CustomEdge } from "../components/FlowEditor/edges/CustomEdge";
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
import {
convertBlockInfoIntoCustomNodeData,
@@ -44,6 +45,8 @@ const MINIMUM_MOVE_BEFORE_LOG = 50;
// Track initial positions when drag starts (outside store to avoid re-renders)
const dragStartPositions: Record<string, XYPosition> = {};
let dragStartState: { nodes: CustomNode[]; edges: CustomEdge[] } | null = null;
type NodeStore = {
nodes: CustomNode[];
nodeCounter: number;
@@ -124,14 +127,20 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
nodeCounter: state.nodeCounter + 1,
})),
onNodesChange: (changes) => {
const prevState = {
nodes: get().nodes,
edges: useEdgeStore.getState().edges,
};
// Track initial positions when drag starts
changes.forEach((change) => {
if (change.type === "position" && change.dragging === true) {
if (!dragStartState) {
const currentNodes = get().nodes;
const currentEdges = useEdgeStore.getState().edges;
dragStartState = {
nodes: currentNodes.map((n) => ({
...n,
position: { ...n.position },
data: { ...n.data },
})),
edges: currentEdges.map((e) => ({ ...e })),
};
}
if (!dragStartPositions[change.id]) {
const node = get().nodes.find((n) => n.id === change.id);
if (node) {
@@ -141,12 +150,17 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
}
});
// Check if we should track this change in history
let shouldTrack = changes.some(
(change) => change.type === "remove" || change.type === "add",
);
let shouldTrack = changes.some((change) => change.type === "remove");
let stateToTrack: { nodes: CustomNode[]; edges: CustomEdge[] } | null =
null;
if (shouldTrack) {
stateToTrack = {
nodes: get().nodes,
edges: useEdgeStore.getState().edges,
};
}
// For position changes, only track if movement exceeds threshold
if (!shouldTrack) {
changes.forEach((change) => {
if (change.type === "position" && change.dragging === false) {
@@ -158,20 +172,23 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
);
if (distanceMoved > MINIMUM_MOVE_BEFORE_LOG) {
shouldTrack = true;
stateToTrack = dragStartState;
}
}
// Clean up tracked position after drag ends
delete dragStartPositions[change.id];
}
});
if (Object.keys(dragStartPositions).length === 0) {
dragStartState = null;
}
}
set((state) => ({
nodes: applyNodeChanges(changes, state.nodes),
}));
if (shouldTrack) {
useHistoryStore.getState().pushState(prevState);
if (shouldTrack && stateToTrack) {
useHistoryStore.getState().pushState(stateToTrack);
}
},
@@ -185,6 +202,11 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
hardcodedValues?: Record<string, any>,
position?: XYPosition,
) => {
const prevState = {
nodes: get().nodes,
edges: useEdgeStore.getState().edges,
};
const customNodeData = convertBlockInfoIntoCustomNodeData(
block,
hardcodedValues,
@@ -218,21 +240,24 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
set((state) => ({
nodes: [...state.nodes, customNode],
}));
useHistoryStore.getState().pushState(prevState);
return customNode;
},
updateNodeData: (nodeId, data) => {
const prevState = {
nodes: get().nodes,
edges: useEdgeStore.getState().edges,
};
set((state) => ({
nodes: state.nodes.map((n) =>
n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n,
),
}));
const newState = {
nodes: get().nodes,
edges: useEdgeStore.getState().edges,
};
useHistoryStore.getState().pushState(newState);
useHistoryStore.getState().pushState(prevState);
},
toggleAdvanced: (nodeId: string) =>
set((state) => ({
@@ -391,6 +416,11 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
},
setCredentialsOptional: (nodeId: string, optional: boolean) => {
const prevState = {
nodes: get().nodes,
edges: useEdgeStore.getState().edges,
};
set((state) => ({
nodes: state.nodes.map((n) =>
n.id === nodeId
@@ -408,12 +438,7 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
),
}));
const newState = {
nodes: get().nodes,
edges: useEdgeStore.getState().edges,
};
useHistoryStore.getState().pushState(newState);
useHistoryStore.getState().pushState(prevState);
},
// Sub-agent resolution mode state

View File

@@ -30,8 +30,6 @@ export const FormRenderer = ({
return generateUiSchemaForCustomFields(preprocessedSchema, uiSchema);
}, [preprocessedSchema, uiSchema]);
console.log("preprocessedSchema", preprocessedSchema);
return (
<div className={"mb-6 mt-4"} data-tutorial-id="input-handles">
<Form