(feat)workflows: add undo/redo

This commit is contained in:
ManishMadan2882 committed 2026-07-06 16:38:33 +05:30
1 parent 08f758b06b
commit 3356ca19a9
2 files changed
+281 -31

No files matched your search

+135 -31
View File
@@ -11,8 +11,10 @@ import {
Pencil,
Play,
Plus,
Redo2,
StickyNote,
Trash2,
Undo2,
X,
} from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -31,6 +33,7 @@ import ReactFlow, {
Node,
NodeChange,
NodeTypes,
Panel,
ReactFlowProvider,
useReactFlow,
} from 'reactflow';
@@ -66,6 +69,7 @@ import { Agent } from '../types';
import { ConditionCase, WorkflowNode } from '../types/workflow';
import MobileBlocker from './components/MobileBlocker';
import PromptTextArea from './components/PromptTextArea';
import { useUndoRedo, WorkflowSnapshot } from './hooks/useUndoRedo';
import {
AgentNode,
ConditionNode,
@@ -116,6 +120,13 @@ function validateJsonSchemaConfig(schema: unknown): string | null {
return null;
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
if (target.isContentEditable) return true;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
}
function createEmptyWorkflowAgent(): Agent {
return {
id: '',
@@ -390,6 +401,36 @@ function WorkflowBuilderInner() {
const [nodes, setNodes] = useState<Node[]>(initialNodes);
const [edges, setEdges] = useState<Edge[]>([]);
const handleHistoryRestore = useCallback(
(snapshot: WorkflowSnapshot) => {
setAgentJsonSchemaDrafts({});
setAgentJsonSchemaErrors({});
if (!selectedNode) return;
const restoredNode = snapshot.nodes.find((n) => n.id === selectedNode.id);
if (restoredNode) {
setSelectedNode(restoredNode);
} else {
setSelectedNode(null);
setShowNodeConfig(false);
}
},
[selectedNode],
);
const { takeSnapshot, undo, redo, clearHistory, canUndo, canRedo } =
useUndoRedo({
nodes,
edges,
setNodes,
setEdges,
onRestore: handleHistoryRestore,
});
const snapshotBeforeCanvasChange = useCallback(
() => takeSnapshot(),
[takeSnapshot],
);
const onNodesChange = useCallback(
(changes: NodeChange[]) =>
setNodes((nds) => applyNodeChanges(changes, nds)),
@@ -404,19 +445,21 @@ function WorkflowBuilderInner() {
const onConnect = useCallback(
(params: Connection) => {
const exists = edges.some(
(e) =>
e.source === params.source &&
e.sourceHandle === params.sourceHandle &&
e.target === params.target &&
e.targetHandle === params.targetHandle,
);
if (exists) return;
takeSnapshot();
const targetNode = nodes.find((n) => n.id === params.target);
const isEndNode = targetNode?.type === 'end';
setEdges((eds) => {
const exists = eds.some(
(e) =>
e.source === params.source &&
e.sourceHandle === params.sourceHandle &&
e.target === params.target &&
e.targetHandle === params.targetHandle,
);
if (exists) return eds;
const targetNode = nodes.find((n) => n.id === params.target);
const isEndNode = targetNode?.type === 'end';
const filtered = eds.filter(
(e) =>
!(
@@ -433,12 +476,16 @@ function WorkflowBuilderInner() {
return addEdge(params, filtered);
});
},
[nodes],
[nodes, edges, takeSnapshot],
);
const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => {
setEdges((eds) => eds.filter((e) => e.id !== edge.id));
}, []);
const onEdgeClick = useCallback(
(_event: React.MouseEvent, edge: Edge) => {
takeSnapshot();
setEdges((eds) => eds.filter((e) => e.id !== edge.id));
},
[takeSnapshot],
);
const handleNodeDragStart = useCallback(
(e: React.DragEvent, nodeType: string) => {
@@ -474,6 +521,8 @@ function WorkflowBuilderInner() {
const type = event.dataTransfer.getData('application/reactflow');
if (!type) return;
takeSnapshot();
const position = reactFlowInstance.screenToFlowPosition({
x: event.clientX,
y: event.clientY,
@@ -522,7 +571,7 @@ function WorkflowBuilderInner() {
setNodes((nds) => nds.concat(baseNode));
},
[reactFlowInstance, availableModels, defaultAgentModelId],
[reactFlowInstance, availableModels, defaultAgentModelId, takeSnapshot],
);
const handleNodeClick = useCallback(
@@ -535,6 +584,7 @@ function WorkflowBuilderInner() {
const handleDeleteNode = useCallback(() => {
if (!selectedNode || selectedNode.type === 'start') return;
takeSnapshot();
setNodes((nds) => nds.filter((n) => n.id !== selectedNode.id));
setEdges((eds) =>
eds.filter(
@@ -555,11 +605,15 @@ function WorkflowBuilderInner() {
});
setSelectedNode(null);
setShowNodeConfig(false);
}, [selectedNode]);
}, [selectedNode, takeSnapshot]);
const handleUpdateNodeData = useCallback(
(data: Record<string, unknown>) => {
(data: Record<string, unknown>, options?: { snapshot?: boolean }) => {
if (!selectedNode) return;
if (options?.snapshot !== false) {
// Group per node so a burst of keystrokes becomes one undo step
takeSnapshot(`node-data:${selectedNode.id}`);
}
setNodes((nds) =>
nds.map((n) =>
n.id === selectedNode.id ? { ...n, data: { ...n.data, ...data } } : n,
@@ -569,7 +623,7 @@ function WorkflowBuilderInner() {
prev ? { ...prev, data: { ...prev.data, ...data } } : null,
);
},
[selectedNode],
[selectedNode, takeSnapshot],
);
const handleAgentJsonSchemaChange = useCallback(
@@ -672,7 +726,22 @@ function WorkflowBuilderInner() {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Delete' && selectedNode) {
const isEditable = isEditableTarget(e.target);
if ((e.ctrlKey || e.metaKey) && !isEditable) {
const key = e.key.toLowerCase();
if (key === 'z') {
e.preventDefault();
if (e.shiftKey) redo();
else undo();
return;
}
if (key === 'y') {
e.preventDefault();
redo();
return;
}
}
if (e.key === 'Delete' && selectedNode && !isEditable) {
handleDeleteNode();
}
if (e.key === 'Escape') {
@@ -682,7 +751,7 @@ function WorkflowBuilderInner() {
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedNode, handleDeleteNode]);
}, [selectedNode, handleDeleteNode, undo, redo]);
const handlePanelBackdropClick = useCallback(() => {
setShowNodeConfig(false);
@@ -741,15 +810,18 @@ function WorkflowBuilderInner() {
if (!defaultAgentModelId) return;
if (selectedNode.data.config?.model_id) return;
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
model_id: defaultAgentModelId,
llm_name:
availableModels.find((model) => model.id === defaultAgentModelId)
?.provider || '',
handleUpdateNodeData(
{
config: {
...(selectedNode.data.config || {}),
model_id: defaultAgentModelId,
llm_name:
availableModels.find((model) => model.id === defaultAgentModelId)
?.provider || '',
},
},
});
{ snapshot: false },
);
}, [
selectedNode,
defaultAgentModelId,
@@ -865,6 +937,7 @@ function WorkflowBuilderInner() {
setAgentJsonSchemaErrors({});
setNodes(mappedNodes);
setEdges(mappedEdges);
clearHistory();
setSavedWorkflowSignature(
JSON.stringify(
createWorkflowPayload(
@@ -887,7 +960,7 @@ function WorkflowBuilderInner() {
}
};
loadWorkflow();
}, [workflowId, reactFlowInstance, token]);
}, [workflowId, reactFlowInstance, token, clearHistory]);
const validateWorkflow = useCallback((): string[] => {
const errors: string[] = [];
@@ -1742,12 +1815,43 @@ function WorkflowBuilderInner() {
onDrop={onDrop}
onDragOver={onDragOver}
onNodeClick={handleNodeClick}
onNodeDragStart={snapshotBeforeCanvasChange}
onSelectionDragStart={snapshotBeforeCanvasChange}
onNodesDelete={snapshotBeforeCanvasChange}
onEdgesDelete={snapshotBeforeCanvasChange}
nodeTypes={nodeTypes}
nodeDragThreshold={1}
deleteKeyCode={['Backspace', 'Delete']}
fitView
>
<Background />
<Controls />
<Panel position="top-left" className="flex gap-1.5">
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={undo}
disabled={!canUndo}
title="Undo (Ctrl+Z)"
aria-label="Undo"
className="bg-card"
>
<Undo2 size={16} />
</Button>
<Button
type="button"
variant="outline"
size="icon-sm"
onClick={redo}
disabled={!canRedo}
title="Redo (Ctrl+Shift+Z)"
aria-label="Redo"
className="bg-card"
>
<Redo2 size={16} />
</Button>
</Panel>
</ReactFlow>
{showNodeConfig && selectedNode && (
@@ -0,0 +1,146 @@
import { useCallback, useRef, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import type { Edge, Node } from 'reactflow';
export type WorkflowSnapshot = {
nodes: Node[];
edges: Edge[];
};
type UseUndoRedoOptions = {
nodes: Node[];
edges: Edge[];
setNodes: Dispatch<SetStateAction<Node[]>>;
setEdges: Dispatch<SetStateAction<Edge[]>>;
onRestore?: (snapshot: WorkflowSnapshot) => void;
maxHistorySize?: number;
};
const SNAPSHOT_GROUP_WINDOW_MS = 1000;
// Selection is not part of history: restored elements keep whatever
// selection the user has right now instead of the flags baked into the
// snapshot when it was taken.
function withCurrentSelection<T extends { id: string; selected?: boolean }>(
restored: T[],
current: T[],
): T[] {
const selectedIds = new Set(
current.filter((el) => el.selected).map((el) => el.id),
);
return restored.map((el) =>
Boolean(el.selected) === selectedIds.has(el.id)
? el
: { ...el, selected: selectedIds.has(el.id) },
);
}
export function useUndoRedo({
nodes,
edges,
setNodes,
setEdges,
onRestore,
maxHistorySize = 100,
}: UseUndoRedoOptions) {
const [past, setPast] = useState<WorkflowSnapshot[]>([]);
const [future, setFuture] = useState<WorkflowSnapshot[]>([]);
const stateRef = useRef<WorkflowSnapshot>({ nodes, edges });
stateRef.current = { nodes, edges };
const pastRef = useRef(past);
pastRef.current = past;
const futureRef = useRef(future);
futureRef.current = future;
const onRestoreRef = useRef(onRestore);
onRestoreRef.current = onRestore;
// Consecutive calls sharing a group key (e.g. keystrokes editing one
// node's config) collapse into a single history entry while each call
// stays inside the group window.
const lastGroupKeyRef = useRef<string | null>(null);
const lastGroupTimeRef = useRef(0);
// One user action can request several snapshots in the same event tick
// (deleting a node also deletes its edges); only the first one counts.
const tickGuardRef = useRef(false);
const takeSnapshot = useCallback(
(groupKey?: string) => {
const now = Date.now();
if (
groupKey &&
groupKey === lastGroupKeyRef.current &&
now - lastGroupTimeRef.current < SNAPSHOT_GROUP_WINDOW_MS
) {
lastGroupTimeRef.current = now;
return;
}
lastGroupKeyRef.current = groupKey ?? null;
lastGroupTimeRef.current = now;
if (tickGuardRef.current) return;
tickGuardRef.current = true;
queueMicrotask(() => {
tickGuardRef.current = false;
});
const snapshot: WorkflowSnapshot = {
nodes: stateRef.current.nodes,
edges: stateRef.current.edges,
};
setPast((prev) =>
prev.length >= maxHistorySize
? [...prev.slice(prev.length - maxHistorySize + 1), snapshot]
: [...prev, snapshot],
);
setFuture([]);
},
[maxHistorySize],
);
const undo = useCallback(() => {
const previous = pastRef.current[pastRef.current.length - 1];
if (!previous) return;
const current: WorkflowSnapshot = {
nodes: stateRef.current.nodes,
edges: stateRef.current.edges,
};
lastGroupKeyRef.current = null;
setPast(pastRef.current.slice(0, -1));
setFuture([...futureRef.current, current]);
setNodes(withCurrentSelection(previous.nodes, current.nodes));
setEdges(withCurrentSelection(previous.edges, current.edges));
onRestoreRef.current?.(previous);
}, [setNodes, setEdges]);
const redo = useCallback(() => {
const next = futureRef.current[futureRef.current.length - 1];
if (!next) return;
const current: WorkflowSnapshot = {
nodes: stateRef.current.nodes,
edges: stateRef.current.edges,
};
lastGroupKeyRef.current = null;
setFuture(futureRef.current.slice(0, -1));
setPast([...pastRef.current, current]);
setNodes(withCurrentSelection(next.nodes, current.nodes));
setEdges(withCurrentSelection(next.edges, current.edges));
onRestoreRef.current?.(next);
}, [setNodes, setEdges]);
const clearHistory = useCallback(() => {
lastGroupKeyRef.current = null;
setPast([]);
setFuture([]);
}, []);
return {
takeSnapshot,
undo,
redo,
clearHistory,
canUndo: past.length > 0,
canRedo: future.length > 0,
};
}