feat(frontend): add undo/redo functionality with keyboard shortcuts to flow builder (#11307)

This PR introduces comprehensive undo/redo functionality to the flow
builder, allowing users to revert and restore changes to their
workflows. The implementation includes keyboard shortcuts (Ctrl/Cmd+Z
for undo, Ctrl/Cmd+Y for redo) and visual controls in the UI.



https://github.com/user-attachments/assets/514253a6-4e86-4ac5-96b4-992180fb3b00



### What's New 🚀
- **Undo/Redo State Management**: Implemented a dedicated Zustand store
(`historyStore`) that tracks up to 50 historical states of nodes and
connections
- **Keyboard Shortcuts**: Added cross-platform keyboard shortcuts:
  - `Ctrl/Cmd + Z` for undo
  - `Ctrl/Cmd + Y` for redo
- **UI Controls**: Added dedicated undo/redo buttons to the control
panel with:
  - Visual feedback when actions are available/disabled
  - Tooltips for better user guidance
  - Proper accessibility attributes
- **Automatic History Tracking**: Integrated history tracking into node
operations (add, remove, position changes, data updates)

### Technical Details 🔧

#### Architecture
- **History Store** (`historyStore.ts`): Manages past and future states
using a stack-based approach
  - Stores snapshots of nodes and connections
  - Implements state deduplication to prevent duplicate history entries
  - Limits history to 50 states to manage memory usage

- **Integration Points**:
- `nodeStore.ts`: Modified to push state changes to history on relevant
operations
- `Flow.tsx`: Added the new `useFlowRealtime` hook for real-time updates
- `NewControlPanel.tsx`: Integrated the new `UndoRedoButtons` component

#### UI Improvements
- **Enhanced Control Panel Button**: Updated to support different HTML
elements (button/div) with proper role attributes for accessibility
- **Block Menu Tooltips**: Added tooltips to improve user guidance
- **Responsive UI**: Adjusted tooltip delays for better responsiveness
(100ms delay)

### Testing 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 new flow with multiple nodes and verify undo/redo works
for node additions
  - [x] Move nodes and verify position changes can be undone/redone
  - [x] Delete nodes and verify deletions can be undone
- [x] Test keyboard shortcuts (Ctrl/Cmd+Z and Ctrl/Cmd+Y) on different
platforms
- [x] Verify undo/redo buttons are disabled when no history is available
- [x] Test with complex flows (10+ nodes) to ensure performance remains
good
This commit is contained in:
Abhimanyu Yadav
2025-11-04 10:47:29 +05:30
committed by GitHub
parent a78b08f5e7
commit b1a2d21892
8 changed files with 210 additions and 26 deletions

View File

@@ -23,6 +23,8 @@ export const Flow = () => {
// We use this hook to load the graph and convert them into custom nodes and edges.
useFlow();
// This hook is used for websocket realtime updates.
useFlowRealtime();
const { isFlowContentLoading } = useFlow();

View File

@@ -1,35 +1,38 @@
// BLOCK MENU TODO: We need a disable state in this, currently it's not in design.
import { cn } from "@/lib/utils";
import React from "react";
interface Props extends React.HTMLAttributes<HTMLDivElement> {
interface Props extends React.HTMLAttributes<HTMLElement> {
selected?: boolean;
children?: React.ReactNode; // For icon purpose
children?: React.ReactNode;
disabled?: boolean;
as?: "div" | "button";
}
export const ControlPanelButton: React.FC<Props> = ({
selected = false,
children,
disabled,
as = "div",
className,
...rest
}) => {
const Component = as;
return (
// Using div instead of button, because it's only for design purposes. We are using this to give design to PopoverTrigger.
<div
role="button"
// Why div - because on some places we are only using this for design purposes.
<Component
role={as === "div" ? "button" : undefined}
disabled={as === "button" ? disabled : undefined}
className={cn(
"flex h-[4.25rem] w-[4.25rem] items-center justify-center whitespace-normal bg-white p-[1.38rem] text-zinc-800 shadow-none hover:cursor-pointer hover:bg-zinc-100 hover:text-zinc-950 focus:ring-0",
selected &&
"bg-violet-50 text-violet-700 hover:cursor-default hover:bg-violet-50 hover:text-violet-700 active:bg-violet-50 active:text-violet-700",
disabled && "cursor-not-allowed",
disabled && "cursor-not-allowed opacity-50 hover:cursor-not-allowed",
className,
)}
{...rest}
>
{children}
</div>
</Component>
);
};

View File

@@ -8,23 +8,32 @@ import { BlockMenuContent } from "../BlockMenuContent/BlockMenuContent";
import { ControlPanelButton } from "../../ControlPanelButton";
import { LegoIcon } from "@phosphor-icons/react";
import { useControlPanelStore } from "@/app/(platform)/build/stores/controlPanelStore";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
export const BlockMenu = () => {
const { blockMenuOpen, setBlockMenuOpen } = useControlPanelStore();
return (
// pinBlocksPopover ? true : open
<Popover onOpenChange={setBlockMenuOpen}>
<PopoverTrigger className="hover:cursor-pointer">
<ControlPanelButton
data-id="blocks-control-popover-trigger"
data-testid="blocks-control-blocks-button"
selected={blockMenuOpen}
className="rounded-none"
>
{/* Need to find phosphor icon alternative for this lucide icon */}
<LegoIcon className="h-6 w-6" />
</ControlPanelButton>
</PopoverTrigger>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<PopoverTrigger className="hover:cursor-pointer">
<ControlPanelButton
data-id="blocks-control-popover-trigger"
data-testid="blocks-control-blocks-button"
selected={blockMenuOpen}
className="rounded-none"
>
<LegoIcon className="h-6 w-6" />
</ControlPanelButton>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent side="right">Blocks</TooltipContent>
</Tooltip>
<PopoverContent
side="right"

View File

@@ -13,6 +13,7 @@ import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { Separator } from "@/components/__legacy__/ui/separator";
import { NewSaveControl } from "./NewSaveControl/NewSaveControl";
import { CustomNode } from "../FlowEditor/nodes/CustomNode/CustomNode";
import { UndoRedoButtons } from "./UndoRedoButtons";
export type Control = {
icon: React.ReactNode;
@@ -106,6 +107,8 @@ export const NewControlPanel = ({
))} */}
<Separator className="text-[#E1E1E1]" />
<NewSaveControl />
<Separator className="text-[#E1E1E1]" />
<UndoRedoButtons />
</div>
</section>
);

View File

@@ -25,7 +25,7 @@ export const NewSaveControl = () => {
const { saveControlOpen, setSaveControlOpen } = useControlPanelStore();
return (
<Popover onOpenChange={setSaveControlOpen}>
<Tooltip delayDuration={500}>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<ControlPanelButton
@@ -34,7 +34,6 @@ export const NewSaveControl = () => {
selected={saveControlOpen}
className="rounded-none"
>
{/* Need to find phosphor icon alternative for this lucide icon */}
<FloppyDiskIcon className="h-6 w-6" />
</ControlPanelButton>
</PopoverTrigger>

View File

@@ -0,0 +1,62 @@
import { Separator } from "@/components/__legacy__/ui/separator";
import { ControlPanelButton } from "./ControlPanelButton";
import { ArrowUUpLeftIcon, ArrowUUpRightIcon } from "@phosphor-icons/react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { useHistoryStore } from "../../stores/historyStore";
import { useEffect } from "react";
export const UndoRedoButtons = () => {
const { undo, redo, canUndo, canRedo } = useHistoryStore();
// Keyboard shortcuts for undo and redo
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const isMac = /Mac/i.test(navigator.userAgent);
const isCtrlOrCmd = isMac ? event.metaKey : event.ctrlKey;
if (isCtrlOrCmd && event.key === "z" && !event.shiftKey) {
event.preventDefault();
if (canUndo()) {
undo();
}
} else if (isCtrlOrCmd && event.key === "y") {
event.preventDefault();
if (canRedo()) {
redo();
}
}
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [undo, redo, canUndo, canRedo]);
return (
<>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<ControlPanelButton as="button" disabled={!canUndo()} onClick={undo}>
<ArrowUUpLeftIcon className="h-6 w-6" />
</ControlPanelButton>
</TooltipTrigger>
<TooltipContent side="right">Undo</TooltipContent>
</Tooltip>
<Separator className="text-[#E1E1E1]" />
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<ControlPanelButton as="button" disabled={!canRedo()} onClick={redo}>
<ArrowUUpRightIcon className="h-6 w-6" />
</ControlPanelButton>
</TooltipTrigger>
<TooltipContent side="right">Redo</TooltipContent>
</Tooltip>
</>
);
};

View File

@@ -0,0 +1,80 @@
import { create } from "zustand";
import isEqual from "lodash/isEqual";
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
import { Connection, useEdgeStore } from "./edgeStore";
import { useNodeStore } from "./nodeStore";
type HistoryState = {
nodes: CustomNode[];
connections: Connection[];
};
type HistoryStore = {
past: HistoryState[];
future: HistoryState[];
undo: () => void;
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
pushState: (state: HistoryState) => void;
clear: () => void;
};
const MAX_HISTORY = 50;
export const useHistoryStore = create<HistoryStore>((set, get) => ({
past: [{ nodes: [], connections: [] }],
future: [],
pushState: (state: HistoryState) => {
const { past } = get();
const lastState = past[past.length - 1];
if (lastState && isEqual(lastState, state)) {
return;
}
set((prev) => ({
past: [...prev.past.slice(-MAX_HISTORY + 1), state],
future: [],
}));
},
undo: () => {
const { past, future } = get();
if (past.length <= 1) return;
const currentState = past[past.length - 1];
const previousState = past[past.length - 2];
useNodeStore.getState().setNodes(previousState.nodes);
useEdgeStore.getState().setConnections(previousState.connections);
set({
past: past.slice(0, -1),
future: [currentState, ...future],
});
},
redo: () => {
const { past, future } = get();
if (future.length === 0) return;
const nextState = future[0];
useNodeStore.getState().setNodes(nextState.nodes);
useEdgeStore.getState().setConnections(nextState.connections);
set({
past: [...past, nextState],
future: future.slice(1),
});
},
canUndo: () => get().past.length > 1,
canRedo: () => get().future.length > 0,
clear: () => set({ past: [{ nodes: [], connections: [] }], future: [] }),
}));

View File

@@ -6,6 +6,8 @@ import { convertBlockInfoIntoCustomNodeData } 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";
import { useHistoryStore } from "./historyStore";
import { useEdgeStore } from "./edgeStore";
type NodeStore = {
nodes: CustomNode[];
@@ -44,10 +46,26 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
set((state) => ({
nodeCounter: state.nodeCounter + 1,
})),
onNodesChange: (changes) =>
onNodesChange: (changes) => {
const prevState = {
nodes: get().nodes,
connections: useEdgeStore.getState().connections,
};
const shouldTrack = changes.some(
(change) =>
change.type === "remove" ||
change.type === "add" ||
(change.type === "position" && change.dragging === false),
);
set((state) => ({
nodes: applyNodeChanges(changes, state.nodes),
})),
}));
if (shouldTrack) {
useHistoryStore.getState().pushState(prevState);
}
},
addNode: (node) =>
set((state) => ({
nodes: [...state.nodes, node],
@@ -66,12 +84,20 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
nodes: [...state.nodes, customNode],
}));
},
updateNodeData: (nodeId, data) =>
updateNodeData: (nodeId, data) => {
set((state) => ({
nodes: state.nodes.map((n) =>
n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n,
),
})),
}));
const newState = {
nodes: get().nodes,
connections: useEdgeStore.getState().connections,
};
useHistoryStore.getState().pushState(newState);
},
toggleAdvanced: (nodeId: string) =>
set((state) => ({
nodeAdvancedStates: {