From fba61c72ed64bc5dc527392ffbd667e8e278cea2 Mon Sep 17 00:00:00 2001 From: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com> Date: Wed, 31 Dec 2025 15:16:12 +0530 Subject: [PATCH 1/2] feat(frontend): fix duplicate publish button and improve BuilderActionButton styling (#11669) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes duplicate "Publish to Marketplace" buttons in the builder by adding a `showTrigger` prop to control modal trigger visibility. Screenshot 2025-12-23 at 8 18 58โ€ฏAM ### Changes ๐Ÿ—๏ธ **BuilderActionButton.tsx** - Removed borders on hover and active states for a cleaner visual appearance - Added `hover:border-none` and `active:border-none` to maintain consistent styling during interactions **PublishToMarketplace.tsx** - Pass `showTrigger={false}` to `PublishAgentModal` to hide the default trigger button - This prevents duplicate buttons when a custom trigger is already rendered **PublishAgentModal.tsx** - Added `showTrigger` prop (defaults to `true`) to conditionally render the modal trigger - Allows parent components to control whether the built-in trigger button should be displayed - Maintains backward compatibility with existing usage ### 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] Verify only one "Publish to Marketplace" button appears in the builder - [x] Confirm button hover/active states display correctly without border artifacts - [x] Verify modal can still be triggered programmatically without the trigger button --- .../BuilderActions/components/BuilderActionButton.tsx | 4 ++-- .../PublishToMarketplace/PublishToMarketplace.tsx | 1 + .../contextual/PublishAgentModal/PublishAgentModal.tsx | 9 ++++++--- .../contextual/PublishAgentModal/usePublishAgentModal.ts | 1 + 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/BuilderActionButton.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/BuilderActionButton.tsx index f8b3f1051e..549b432a38 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/BuilderActionButton.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/BuilderActionButton.tsx @@ -19,8 +19,8 @@ export const BuilderActionButton = ({ "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)]", + "hover:border-none hover:shadow-[inset_0_1px_0_0_rgba(255,255,255,0.5),0_1px_2px_0_rgba(0,0,0,0.2)]", + "active:border-none 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, diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/PublishToMarketplace/PublishToMarketplace.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/PublishToMarketplace/PublishToMarketplace.tsx index 1e6545dfbd..500b8f0b47 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/PublishToMarketplace/PublishToMarketplace.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/BuilderActions/components/PublishToMarketplace/PublishToMarketplace.tsx @@ -30,6 +30,7 @@ export const PublishToMarketplace = ({ flowID }: { flowID: string | null }) => { targetState={publishState} onStateChange={handleStateChange} preSelectedAgentId={flowID || undefined} + showTrigger={false} /> ); diff --git a/autogpt_platform/frontend/src/components/contextual/PublishAgentModal/PublishAgentModal.tsx b/autogpt_platform/frontend/src/components/contextual/PublishAgentModal/PublishAgentModal.tsx index dd91094f9c..da3324f600 100644 --- a/autogpt_platform/frontend/src/components/contextual/PublishAgentModal/PublishAgentModal.tsx +++ b/autogpt_platform/frontend/src/components/contextual/PublishAgentModal/PublishAgentModal.tsx @@ -20,6 +20,7 @@ export function PublishAgentModal({ onStateChange, preSelectedAgentId, preSelectedAgentVersion, + showTrigger = true, }: Props) { const { // State @@ -121,9 +122,11 @@ export function PublishAgentModal({ }, }} > - - {trigger || } - + {showTrigger && ( + + {trigger || } + + )}
{renderContent()}
diff --git a/autogpt_platform/frontend/src/components/contextual/PublishAgentModal/usePublishAgentModal.ts b/autogpt_platform/frontend/src/components/contextual/PublishAgentModal/usePublishAgentModal.ts index f83698d8e7..0f8a819c6e 100644 --- a/autogpt_platform/frontend/src/components/contextual/PublishAgentModal/usePublishAgentModal.ts +++ b/autogpt_platform/frontend/src/components/contextual/PublishAgentModal/usePublishAgentModal.ts @@ -30,6 +30,7 @@ export interface Props { onStateChange?: (state: PublishState) => void; preSelectedAgentId?: string; preSelectedAgentVersion?: number; + showTrigger?: boolean; } export function usePublishAgentModal({ From 290d0d9a9beaccb74221540bd32363bf73743172 Mon Sep 17 00:00:00 2001 From: Abhimanyu Yadav <122007096+Abhi1992002@users.noreply.github.com> Date: Wed, 31 Dec 2025 20:19:53 +0530 Subject: [PATCH 2/2] feat(frontend): add auto-save Draft Recovery feature with IndexedDB persistence (#11658) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Implements an auto-save draft recovery system that persists unsaved flow builder state across browser sessions, tab closures, and refreshes. When users return to a flow with unsaved changes, they can choose to restore or discard the draft via an intuitive recovery popup. https://github.com/user-attachments/assets/0f77173b-7834-48d2-b7aa-73c6cd2eaff6 ## Changes ๐Ÿ—๏ธ ### Core Features - **Draft Recovery Popup** (`DraftRecoveryPopup.tsx`) - Displays amber-themed notification with unsaved changes metadata - Shows node count, edge count, and relative time since last save - Provides restore and discard actions with tooltips - Auto-dismisses on click outside or ESC key - **Auto-Save System** (`useDraftManager.ts`) - Automatically saves draft state every 15 seconds - Saves on browser tab close/refresh via `beforeunload` - Tracks nodes, edges, graph schemas, node counter, and flow version - Smart dirty checking - only saves when actual changes detected - Cleans up expired drafts (24-hour TTL) - **IndexedDB Persistence** (`db.ts`, `draft-service.ts`) - Uses Dexie library for reliable client-side storage - Handles both existing flows (by flowID) and new flows (via temp session IDs) - Compares draft state with current state to determine if recovery needed - Automatically clears drafts after successful save ### Integration Changes - **Flow Editor** (`Flow.tsx`) - Integrated `DraftRecoveryPopup` component - Passes `isInitialLoadComplete` state for proper timing - **useFlow Hook** (`useFlow.ts`) - Added `isInitialLoadComplete` state to track when flow is ready - Ensures draft check happens after initial graph load - Resets state on flow/version changes - **useCopyPaste Hook** (`useCopyPaste.ts`) - Refactored to manage keyboard event listeners internally - Simplified integration by removing external event handler setup - **useSaveGraph Hook** (`useSaveGraph.ts`) - Clears draft after successful save (both create and update) - Removes temp flow ID from session storage on first save ### Dependencies - Added `dexie@4.2.1` - Modern IndexedDB wrapper for reliable client-side storage ## Technical Details **Auto-Save Flow:** 1. User makes changes to nodes/edges 2. Change triggers 15-second debounced save 3. Draft saved to IndexedDB with timestamp 4. On save, current state compared with last saved state 5. Only saves if meaningful changes detected **Recovery Flow:** 1. User loads flow/refreshes page 2. After initial load completes, check for existing draft 3. Compare draft with current state 4. If different and non-empty, show recovery popup 5. User chooses to restore or discard 6. Draft cleared after either action **Session Management:** - Existing flows: Use actual flowID for draft key ### Test Plan ๐Ÿงช - [x] Create a new flow with 3+ blocks and connections, wait 15+ seconds, then refresh the page - verify recovery popup appears with correct counts and restoring works - [x] Create a flow with blocks, refresh, then click "Discard" button on recovery popup - verify popup disappears and draft is deleted - [x] Add blocks to a flow, save successfully - verify draft is cleared from IndexedDB (check DevTools > Application > IndexedDB) - [x] Make changes to an existing flow, refresh page - verify recovery popup shows and restoring preserves all changes correctly - [x] Verify empty flows (0 nodes) don't trigger recovery popup or save drafts --- autogpt_platform/frontend/package.json | 1 + autogpt_platform/frontend/pnpm-lock.yaml | 8 + .../DraftRecoveryPopup.tsx | 118 +++++++ .../useDraftRecoveryPopup.tsx | 61 ++++ .../build/components/FlowEditor/Flow/Flow.tsx | 28 +- .../FlowEditor/Flow/useCopyPaste.ts | 13 +- .../FlowEditor/Flow/useDraftManager.ts | 300 ++++++++++++++++++ .../components/FlowEditor/Flow/useFlow.ts | 15 + .../(platform)/build/hooks/useSaveGraph.ts | 22 +- autogpt_platform/frontend/src/lib/dexie/db.ts | 46 +++ .../frontend/src/lib/dexie/draft-utils.ts | 33 ++ .../services/builder-draft/draft-service.ts | 118 +++++++ 12 files changed, 745 insertions(+), 18 deletions(-) create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/DraftRecoveryPopup.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/useDraftRecoveryPopup.tsx create mode 100644 autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useDraftManager.ts create mode 100644 autogpt_platform/frontend/src/lib/dexie/db.ts create mode 100644 autogpt_platform/frontend/src/lib/dexie/draft-utils.ts create mode 100644 autogpt_platform/frontend/src/services/builder-draft/draft-service.ts diff --git a/autogpt_platform/frontend/package.json b/autogpt_platform/frontend/package.json index 4cbd867cd8..1708ac9053 100644 --- a/autogpt_platform/frontend/package.json +++ b/autogpt_platform/frontend/package.json @@ -69,6 +69,7 @@ "cmdk": "1.1.1", "cookie": "1.0.2", "date-fns": "4.1.0", + "dexie": "4.2.1", "dotenv": "17.2.3", "elliptic": "6.6.1", "embla-carousel-react": "8.6.0", diff --git a/autogpt_platform/frontend/pnpm-lock.yaml b/autogpt_platform/frontend/pnpm-lock.yaml index 7d39b68468..355ffff129 100644 --- a/autogpt_platform/frontend/pnpm-lock.yaml +++ b/autogpt_platform/frontend/pnpm-lock.yaml @@ -131,6 +131,9 @@ importers: date-fns: specifier: 4.1.0 version: 4.1.0 + dexie: + specifier: 4.2.1 + version: 4.2.1 dotenv: specifier: 17.2.3 version: 17.2.3 @@ -4428,6 +4431,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dexie@4.2.1: + resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} @@ -12323,6 +12329,8 @@ snapshots: dependencies: dequal: 2.0.3 + dexie@4.2.1: {} + didyoumean@1.2.2: {} diffie-hellman@5.0.3: diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/DraftRecoveryPopup.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/DraftRecoveryPopup.tsx new file mode 100644 index 0000000000..520addd50f --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/DraftRecoveryPopup.tsx @@ -0,0 +1,118 @@ +"use client"; + +import { Button } from "@/components/atoms/Button/Button"; +import { ClockCounterClockwiseIcon, XIcon } from "@phosphor-icons/react"; +import { cn } from "@/lib/utils"; +import { formatTimeAgo } from "@/lib/utils/time"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/atoms/Tooltip/BaseTooltip"; +import { useDraftRecoveryPopup } from "./useDraftRecoveryPopup"; +import { Text } from "@/components/atoms/Text/Text"; +import { AnimatePresence, motion } from "framer-motion"; + +interface DraftRecoveryPopupProps { + isInitialLoadComplete: boolean; +} + +export function DraftRecoveryPopup({ + isInitialLoadComplete, +}: DraftRecoveryPopupProps) { + const { isOpen, popupRef, nodeCount, edgeCount, savedAt, onLoad, onDiscard } = + useDraftRecoveryPopup(isInitialLoadComplete); + + return ( + + {isOpen && ( + +
+
+ +
+ +
+ + Unsaved changes found + + + {nodeCount} block{nodeCount !== 1 ? "s" : ""}, {edgeCount}{" "} + connection + {edgeCount !== 1 ? "s" : ""} โ€ข{" "} + {formatTimeAgo(new Date(savedAt).toISOString())} + +
+ +
+ + + + + Restore changes + + + + + + Discard changes + +
+
+
+ )} +
+ ); +} diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/useDraftRecoveryPopup.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/useDraftRecoveryPopup.tsx new file mode 100644 index 0000000000..0914b04952 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/DraftRecoveryDialog/useDraftRecoveryPopup.tsx @@ -0,0 +1,61 @@ +import { useEffect, useRef } from "react"; +import { useDraftManager } from "../FlowEditor/Flow/useDraftManager"; + +export const useDraftRecoveryPopup = (isInitialLoadComplete: boolean) => { + const popupRef = useRef(null); + + const { + isRecoveryOpen: isOpen, + savedAt, + nodeCount, + edgeCount, + loadDraft: onLoad, + discardDraft: onDiscard, + } = useDraftManager(isInitialLoadComplete); + + useEffect(() => { + if (!isOpen) return; + + const handleClickOutside = (event: MouseEvent) => { + if ( + popupRef.current && + !popupRef.current.contains(event.target as Node) + ) { + onDiscard(); + } + }; + + const timeoutId = setTimeout(() => { + document.addEventListener("mousedown", handleClickOutside); + }, 100); + + return () => { + clearTimeout(timeoutId); + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isOpen, onDiscard]); + + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onDiscard(); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("keydown", handleKeyDown); + }; + }, [isOpen, onDiscard]); + return { + popupRef, + isOpen, + nodeCount, + edgeCount, + savedAt, + onLoad, + onDiscard, + }; +}; diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx index c9cf5296c6..4c6796d746 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/Flow.tsx @@ -4,7 +4,7 @@ import CustomEdge from "../edges/CustomEdge"; import { useFlow } from "./useFlow"; import { useShallow } from "zustand/react/shallow"; import { useNodeStore } from "../../../stores/nodeStore"; -import { useMemo, useEffect, useCallback } from "react"; +import { useMemo, useCallback } from "react"; import { CustomNode } from "../nodes/CustomNode/CustomNode"; import { useCustomEdge } from "../edges/useCustomEdge"; import { useFlowRealtime } from "./useFlowRealtime"; @@ -21,6 +21,7 @@ import { okData } from "@/app/api/helpers"; import { TriggerAgentBanner } from "./components/TriggerAgentBanner"; import { resolveCollisions } from "./helpers/resolve-collision"; import { FloatingSafeModeToggle } from "../../FloatingSafeModeToogle"; +import { DraftRecoveryPopup } from "../../DraftRecoveryDialog/DraftRecoveryPopup"; export const Flow = () => { const [{ flowID, flowExecutionID }] = useQueryStates({ @@ -60,26 +61,22 @@ export const Flow = () => { }, [setNodes, nodes]); const { edges, onConnect, onEdgesChange } = useCustomEdge(); - // We use this hook to load the graph and convert them into custom nodes and edges. - const { onDragOver, onDrop, isFlowContentLoading, isLocked, setIsLocked } = - useFlow(); + // for loading purpose + const { + onDragOver, + onDrop, + isFlowContentLoading, + isInitialLoadComplete, + isLocked, + setIsLocked, + } = useFlow(); // This hook is used for websocket realtime updates. useFlowRealtime(); // Copy/paste functionality - const handleCopyPaste = useCopyPaste(); + useCopyPaste(); - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - handleCopyPaste(event); - }; - - window.addEventListener("keydown", handleKeyDown); - return () => { - window.removeEventListener("keydown", handleKeyDown); - }; - }, [handleCopyPaste]); const isGraphRunning = useGraphStore( useShallow((state) => state.isGraphRunning), ); @@ -115,6 +112,7 @@ export const Flow = () => { className="right-2 top-32 p-2" /> )} + {/* TODO: Need to update it in future - also do not send executionId as prop - rather use useQueryState inside the component */} diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useCopyPaste.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useCopyPaste.ts index 7a8213da22..c6c54006d4 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useCopyPaste.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useCopyPaste.ts @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { useCallback, useEffect } from "react"; import { useReactFlow } from "@xyflow/react"; import { v4 as uuidv4 } from "uuid"; import { useNodeStore } from "../../../stores/nodeStore"; @@ -151,5 +151,16 @@ export function useCopyPaste() { [getViewport, toast], ); + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + handleCopyPaste(event); + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [handleCopyPaste]); + return handleCopyPaste; } diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useDraftManager.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useDraftManager.ts new file mode 100644 index 0000000000..f6d03923bd --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useDraftManager.ts @@ -0,0 +1,300 @@ +import { useState, useCallback, useEffect, useRef } from "react"; +import { parseAsString, parseAsInteger, useQueryStates } from "nuqs"; +import { + draftService, + getTempFlowId, + getOrCreateTempFlowId, + DraftData, +} from "@/services/builder-draft/draft-service"; +import { BuilderDraft } from "@/lib/dexie/db"; +import { cleanNodes, cleanEdges } from "@/lib/dexie/draft-utils"; +import { useNodeStore } from "../../../stores/nodeStore"; +import { useEdgeStore } from "../../../stores/edgeStore"; +import { useGraphStore } from "../../../stores/graphStore"; +import { useHistoryStore } from "../../../stores/historyStore"; +import isEqual from "lodash/isEqual"; + +const AUTO_SAVE_INTERVAL_MS = 15000; // 15 seconds + +interface DraftRecoveryState { + isOpen: boolean; + draft: BuilderDraft | null; +} + +/** + * Consolidated hook for draft persistence and recovery + * - Auto-saves builder state every 15 seconds + * - Saves on beforeunload event + * - Checks for and manages unsaved drafts on load + */ +export function useDraftManager(isInitialLoadComplete: boolean) { + const [state, setState] = useState({ + isOpen: false, + draft: null, + }); + + const [{ flowID, flowVersion }] = useQueryStates({ + flowID: parseAsString, + flowVersion: parseAsInteger, + }); + + const lastSavedStateRef = useRef(null); + const saveTimeoutRef = useRef(null); + const isDirtyRef = useRef(false); + const hasCheckedForDraft = useRef(false); + + const getEffectiveFlowId = useCallback((): string => { + return flowID || getOrCreateTempFlowId(); + }, [flowID]); + + const getCurrentState = useCallback((): DraftData => { + const nodes = useNodeStore.getState().nodes; + const edges = useEdgeStore.getState().edges; + const nodeCounter = useNodeStore.getState().nodeCounter; + const graphStore = useGraphStore.getState(); + + return { + nodes, + edges, + graphSchemas: { + input: graphStore.inputSchema, + credentials: graphStore.credentialsInputSchema, + output: graphStore.outputSchema, + }, + nodeCounter, + flowVersion: flowVersion ?? undefined, + }; + }, [flowVersion]); + + const cleanStateForComparison = useCallback((stateData: DraftData) => { + return { + nodes: cleanNodes(stateData.nodes), + edges: cleanEdges(stateData.edges), + }; + }, []); + + const hasChanges = useCallback((): boolean => { + const currentState = getCurrentState(); + + if (!lastSavedStateRef.current) { + return currentState.nodes.length > 0; + } + + const currentClean = cleanStateForComparison(currentState); + const lastClean = cleanStateForComparison(lastSavedStateRef.current); + + return !isEqual(currentClean, lastClean); + }, [getCurrentState, cleanStateForComparison]); + + const saveDraft = useCallback(async () => { + const effectiveFlowId = getEffectiveFlowId(); + const currentState = getCurrentState(); + + if (currentState.nodes.length === 0 && currentState.edges.length === 0) { + return; + } + + if (!hasChanges()) { + return; + } + + try { + await draftService.saveDraft(effectiveFlowId, currentState); + lastSavedStateRef.current = currentState; + isDirtyRef.current = false; + } catch (error) { + console.error("[DraftPersistence] Failed to save draft:", error); + } + }, [getEffectiveFlowId, getCurrentState, hasChanges]); + + const scheduleSave = useCallback(() => { + isDirtyRef.current = true; + + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + + saveTimeoutRef.current = setTimeout(() => { + saveDraft(); + }, AUTO_SAVE_INTERVAL_MS); + }, [saveDraft]); + + useEffect(() => { + const unsubscribeNodes = useNodeStore.subscribe((storeState, prevState) => { + if (storeState.nodes !== prevState.nodes) { + scheduleSave(); + } + }); + + const unsubscribeEdges = useEdgeStore.subscribe((storeState, prevState) => { + if (storeState.edges !== prevState.edges) { + scheduleSave(); + } + }); + + return () => { + unsubscribeNodes(); + unsubscribeEdges(); + }; + }, [scheduleSave]); + + useEffect(() => { + const handleBeforeUnload = () => { + if (isDirtyRef.current) { + const effectiveFlowId = getEffectiveFlowId(); + const currentState = getCurrentState(); + + if ( + currentState.nodes.length === 0 && + currentState.edges.length === 0 + ) { + return; + } + + draftService.saveDraft(effectiveFlowId, currentState).catch(() => { + // Ignore errors on unload + }); + } + }; + + window.addEventListener("beforeunload", handleBeforeUnload); + return () => { + window.removeEventListener("beforeunload", handleBeforeUnload); + }; + }, [getEffectiveFlowId, getCurrentState]); + + useEffect(() => { + return () => { + if (saveTimeoutRef.current) { + clearTimeout(saveTimeoutRef.current); + } + if (isDirtyRef.current) { + saveDraft(); + } + }; + }, [saveDraft]); + + useEffect(() => { + draftService.cleanupExpired().catch((error) => { + console.error( + "[DraftPersistence] Failed to cleanup expired drafts:", + error, + ); + }); + }, []); + + const checkForDraft = useCallback(async () => { + const effectiveFlowId = flowID || getTempFlowId(); + + if (!effectiveFlowId) { + return; + } + + try { + const draft = await draftService.loadDraft(effectiveFlowId); + + if (!draft) { + return; + } + + const currentNodes = useNodeStore.getState().nodes; + const currentEdges = useEdgeStore.getState().edges; + + const isDifferent = draftService.isDraftDifferent( + draft, + currentNodes, + currentEdges, + ); + + if (isDifferent && (draft.nodes.length > 0 || draft.edges.length > 0)) { + setState({ + isOpen: true, + draft, + }); + } else { + await draftService.deleteDraft(effectiveFlowId); + } + } catch (error) { + console.error("[DraftRecovery] Failed to check for draft:", error); + } + }, [flowID]); + + useEffect(() => { + if (isInitialLoadComplete && !hasCheckedForDraft.current) { + hasCheckedForDraft.current = true; + checkForDraft(); + } + }, [isInitialLoadComplete, checkForDraft]); + + useEffect(() => { + hasCheckedForDraft.current = false; + setState({ + isOpen: false, + draft: null, + }); + }, [flowID]); + + const loadDraft = useCallback(async () => { + if (!state.draft) return; + + const { draft } = state; + + try { + useNodeStore.getState().setNodes(draft.nodes); + useEdgeStore.getState().setEdges(draft.edges); + + // Restore nodeCounter to prevent ID conflicts when adding new nodes + if (draft.nodeCounter !== undefined) { + useNodeStore.setState({ nodeCounter: draft.nodeCounter }); + } + + if (draft.graphSchemas) { + useGraphStore + .getState() + .setGraphSchemas( + draft.graphSchemas.input as Record | null, + draft.graphSchemas.credentials as Record | null, + draft.graphSchemas.output as Record | null, + ); + } + + setTimeout(() => { + useHistoryStore.getState().initializeHistory(); + }, 100); + + await draftService.deleteDraft(draft.id); + + setState({ + isOpen: false, + draft: null, + }); + } catch (error) { + console.error("[DraftRecovery] Failed to load draft:", error); + } + }, [state.draft]); + + const discardDraft = useCallback(async () => { + if (!state.draft) { + setState({ isOpen: false, draft: null }); + return; + } + + try { + await draftService.deleteDraft(state.draft.id); + } catch (error) { + console.error("[DraftRecovery] Failed to discard draft:", error); + } + + setState({ isOpen: false, draft: null }); + }, [state.draft]); + + return { + // Recovery popup props + isRecoveryOpen: state.isOpen, + savedAt: state.draft?.savedAt ?? 0, + nodeCount: state.draft?.nodes.length ?? 0, + edgeCount: state.draft?.edges.length ?? 0, + loadDraft, + discardDraft, + }; +} diff --git a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts index be76c4ec2b..7514611f08 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/components/FlowEditor/Flow/useFlow.ts @@ -21,6 +21,7 @@ import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecut export const useFlow = () => { const [isLocked, setIsLocked] = useState(false); const [hasAutoFramed, setHasAutoFramed] = useState(false); + const [isInitialLoadComplete, setIsInitialLoadComplete] = useState(false); const addNodes = useNodeStore(useShallow((state) => state.addNodes)); const addLinks = useEdgeStore(useShallow((state) => state.addLinks)); const updateNodeStatus = useNodeStore( @@ -174,11 +175,23 @@ export const useFlow = () => { if (customNodes.length > 0 && graph?.links) { const timer = setTimeout(() => { useHistoryStore.getState().initializeHistory(); + // Mark initial load as complete after history is initialized + setIsInitialLoadComplete(true); }, 100); return () => clearTimeout(timer); } }, [customNodes, graph?.links]); + // Also mark as complete for new flows (no flowID) after a short delay + useEffect(() => { + if (!flowID && !isGraphLoading && !isBlocksLoading) { + const timer = setTimeout(() => { + setIsInitialLoadComplete(true); + }, 200); + return () => clearTimeout(timer); + } + }, [flowID, isGraphLoading, isBlocksLoading]); + useEffect(() => { return () => { useNodeStore.getState().setNodes([]); @@ -217,6 +230,7 @@ export const useFlow = () => { useEffect(() => { setHasAutoFramed(false); + setIsInitialLoadComplete(false); }, [flowID, flowVersion]); // Drag and drop block from block menu @@ -253,6 +267,7 @@ export const useFlow = () => { return { isFlowContentLoading: isGraphLoading || isBlocksLoading, + isInitialLoadComplete, onDragOver, onDrop, isLocked, diff --git a/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSaveGraph.ts b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSaveGraph.ts index d0b488f26c..505303cc1e 100644 --- a/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSaveGraph.ts +++ b/autogpt_platform/frontend/src/app/(platform)/build/hooks/useSaveGraph.ts @@ -15,6 +15,11 @@ import { useEdgeStore } from "../stores/edgeStore"; import { graphsEquivalent } from "../components/NewControlPanel/NewSaveControl/helpers"; import { useGraphStore } from "../stores/graphStore"; import { useShallow } from "zustand/react/shallow"; +import { + draftService, + clearTempFlowId, + getTempFlowId, +} from "@/services/builder-draft/draft-service"; export type SaveGraphOptions = { showToast?: boolean; @@ -52,12 +57,19 @@ export const useSaveGraph = ({ const { mutateAsync: createNewGraph, isPending: isCreating } = usePostV1CreateNewGraph({ mutation: { - onSuccess: (response) => { + onSuccess: async (response) => { const data = response.data as GraphModel; setQueryStates({ flowID: data.id, flowVersion: data.version, }); + + const tempFlowId = getTempFlowId(); + if (tempFlowId) { + await draftService.deleteDraft(tempFlowId); + clearTempFlowId(); + } + onSuccess?.(data); if (showToast) { toast({ @@ -82,12 +94,18 @@ export const useSaveGraph = ({ const { mutateAsync: updateGraph, isPending: isUpdating } = usePutV1UpdateGraphVersion({ mutation: { - onSuccess: (response) => { + onSuccess: async (response) => { const data = response.data as GraphModel; setQueryStates({ flowID: data.id, flowVersion: data.version, }); + + // Clear the draft for this flow after successful save + if (data.id) { + await draftService.deleteDraft(data.id); + } + onSuccess?.(data); if (showToast) { toast({ diff --git a/autogpt_platform/frontend/src/lib/dexie/db.ts b/autogpt_platform/frontend/src/lib/dexie/db.ts new file mode 100644 index 0000000000..05e749ca4b --- /dev/null +++ b/autogpt_platform/frontend/src/lib/dexie/db.ts @@ -0,0 +1,46 @@ +import Dexie, { type EntityTable } from "dexie"; +import type { CustomNode } from "@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode"; +import type { CustomEdge } from "@/app/(platform)/build/components/FlowEditor/edges/CustomEdge"; + +// 24 hrs expiry +export const DRAFT_EXPIRY_MS = 24 * 60 * 60 * 1000; + +export interface BuilderDraft { + id: string; + nodes: CustomNode[]; + edges: CustomEdge[]; + graphSchemas: { + input: Record | null; + credentials: Record | null; + output: Record | null; + }; + nodeCounter: number; + savedAt: number; + flowVersion?: number; +} + +class BuilderDatabase extends Dexie { + drafts!: EntityTable; + + constructor() { + super("AutoGPTBuilderDB"); + + this.version(1).stores({ + drafts: "id, savedAt", + }); + } +} + +// Singleton database instance +export const db = new BuilderDatabase(); + +export async function cleanupExpiredDrafts(): Promise { + const expiryThreshold = Date.now() - DRAFT_EXPIRY_MS; + + const deletedCount = await db.drafts + .where("savedAt") + .below(expiryThreshold) + .delete(); + + return deletedCount; +} diff --git a/autogpt_platform/frontend/src/lib/dexie/draft-utils.ts b/autogpt_platform/frontend/src/lib/dexie/draft-utils.ts new file mode 100644 index 0000000000..185ebf92b4 --- /dev/null +++ b/autogpt_platform/frontend/src/lib/dexie/draft-utils.ts @@ -0,0 +1,33 @@ +import type { CustomNode } from "@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode"; +import type { CustomEdge } from "@/app/(platform)/build/components/FlowEditor/edges/CustomEdge"; + +export function cleanNode(node: CustomNode) { + return { + id: node.id, + position: node.position, + data: { + hardcodedValues: node.data.hardcodedValues, + title: node.data.title, + block_id: node.data.block_id, + metadata: node.data.metadata, + }, + }; +} + +export function cleanEdge(edge: CustomEdge) { + return { + id: edge.id, + source: edge.source, + target: edge.target, + sourceHandle: edge.sourceHandle, + targetHandle: edge.targetHandle, + }; +} + +export function cleanNodes(nodes: CustomNode[]) { + return nodes.map(cleanNode); +} + +export function cleanEdges(edges: CustomEdge[]) { + return edges.map(cleanEdge); +} diff --git a/autogpt_platform/frontend/src/services/builder-draft/draft-service.ts b/autogpt_platform/frontend/src/services/builder-draft/draft-service.ts new file mode 100644 index 0000000000..6d35d23bf4 --- /dev/null +++ b/autogpt_platform/frontend/src/services/builder-draft/draft-service.ts @@ -0,0 +1,118 @@ +import { + db, + BuilderDraft, + DRAFT_EXPIRY_MS, + cleanupExpiredDrafts, +} from "../../lib/dexie/db"; +import type { CustomNode } from "@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/CustomNode"; +import type { CustomEdge } from "@/app/(platform)/build/components/FlowEditor/edges/CustomEdge"; +import { cleanNodes, cleanEdges } from "../../lib/dexie/draft-utils"; +import isEqual from "lodash/isEqual"; +import { environment } from "@/services/environment"; + +const SESSION_TEMP_ID_KEY = "builder_temp_flow_id"; + +export function getOrCreateTempFlowId(): string { + if (environment.isServerSide()) { + return `temp_${crypto.randomUUID()}`; + } + + let tempId = sessionStorage.getItem(SESSION_TEMP_ID_KEY); + if (!tempId) { + tempId = `temp_${crypto.randomUUID()}`; + sessionStorage.setItem(SESSION_TEMP_ID_KEY, tempId); + } + return tempId; +} + +export function clearTempFlowId(): void { + if (environment.isClientSide()) { + sessionStorage.removeItem(SESSION_TEMP_ID_KEY); + } +} + +export function getTempFlowId(): string | null { + if (environment.isServerSide()) { + return null; + } + return sessionStorage.getItem(SESSION_TEMP_ID_KEY); +} + +export interface DraftData { + nodes: CustomNode[]; + edges: CustomEdge[]; + graphSchemas: { + input: Record | null; + credentials: Record | null; + output: Record | null; + }; + nodeCounter: number; + flowVersion?: number; +} + +export const draftService = { + async saveDraft(flowId: string, data: DraftData): Promise { + const draft: BuilderDraft = { + id: flowId, + nodes: data.nodes, + edges: data.edges, + graphSchemas: data.graphSchemas, + nodeCounter: data.nodeCounter, + savedAt: Date.now(), + flowVersion: data.flowVersion, + }; + + await db.drafts.put(draft); + }, + + async loadDraft(flowId: string): Promise { + const draft = await db.drafts.get(flowId); + + if (!draft) { + return null; + } + const age = Date.now() - draft.savedAt; + if (age > DRAFT_EXPIRY_MS) { + await this.deleteDraft(flowId); + return null; + } + + return draft; + }, + + async deleteDraft(flowId: string): Promise { + await db.drafts.delete(flowId); + }, + + async hasDraft(flowId: string): Promise { + const draft = await db.drafts.get(flowId); + if (!draft) return false; + + // Check expiry + const age = Date.now() - draft.savedAt; + if (age > DRAFT_EXPIRY_MS) { + await this.deleteDraft(flowId); + return false; + } + + return true; + }, + + isDraftDifferent( + draft: BuilderDraft, + currentNodes: CustomNode[], + currentEdges: CustomEdge[], + ): boolean { + const draftNodesClean = cleanNodes(draft.nodes); + const currentNodesClean = cleanNodes(currentNodes); + const draftEdgesClean = cleanEdges(draft.edges); + const currentEdgesClean = cleanEdges(currentEdges); + + const nodesDifferent = !isEqual(draftNodesClean, currentNodesClean); + const edgesDifferent = !isEqual(draftEdgesClean, currentEdgesClean); + + return nodesDifferent || edgesDifferent; + }, + + cleanupExpired: cleanupExpiredDrafts, +};