mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-04-08 03:00:28 -04:00
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:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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: [] }),
|
||||
}));
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user