From 76cbe02690bfb213ccdf95ff4c38cf8f8e7a6cbc Mon Sep 17 00:00:00 2001 From: Celal Zamanoglu <95054566+celalzamanoglu@users.noreply.github.com> Date: Wed, 28 Jan 2026 17:04:43 +0300 Subject: [PATCH] Add Upload SOP option to workflow editor + button - frontend (#4566) --- .../workflows/editor/WorkflowAddMenu.tsx | 84 ++++++++++------ .../workflows/editor/WorkflowAdderBusy.tsx | 61 +++++++++++- .../editor/edges/EdgeWithAddButton.tsx | 80 +++++++++++++++- .../nodes/NodeAdderNode/NodeAdderNode.tsx | 80 +++++++++++++++- .../workflows/hooks/useSopToBlocksMutation.ts | 95 +++++++++++++++++++ 5 files changed, 364 insertions(+), 36 deletions(-) create mode 100644 skyvern-frontend/src/routes/workflows/hooks/useSopToBlocksMutation.ts diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowAddMenu.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowAddMenu.tsx index 529213c1..2199575e 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowAddMenu.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowAddMenu.tsx @@ -1,7 +1,7 @@ -import { SquareIcon, PlusIcon } from "@radix-ui/react-icons"; -import { ReactNode } from "react"; +import { SquareIcon, PlusIcon, UploadIcon } from "@radix-ui/react-icons"; +import { ReactNode, useMemo } from "react"; -import { RadialMenu } from "@/components/RadialMenu"; +import { RadialMenu, RadialMenuItem } from "@/components/RadialMenu"; import { useDebugStore } from "@/store/useDebugStore"; import { useRecordingStore } from "@/store/useRecordingStore"; import { useSettingsStore } from "@/store/SettingsStore"; @@ -13,9 +13,11 @@ type WorkflowAddMenuProps = { radius?: string; rotateText?: boolean; startAt?: number; + isUploadingSOP?: boolean; // -- onAdd: () => void; onRecord: () => void; + onUploadSOP: () => void; }; function WorkflowAddMenu({ @@ -25,43 +27,71 @@ function WorkflowAddMenu({ radius = "80px", rotateText = true, startAt = 90, + isUploadingSOP = false, // -- onAdd, onRecord, + onUploadSOP, }: WorkflowAddMenuProps) { const debugStore = useDebugStore(); const recordingStore = useRecordingStore(); const settingsStore = useSettingsStore(); - if (!debugStore.isDebugMode || !settingsStore.isUsingABrowser) { + const items = useMemo(() => { + const menuItems: Array = [ + { + id: "1", + icon: , + text: "Add Block", + onClick: () => { + onAdd(); + }, + }, + ]; + + // Only show Record Browser when browser is ON + if (settingsStore.isUsingABrowser) { + menuItems.push({ + id: "2", + icon: , + enabled: !recordingStore.isRecording, + text: "Record Browser", + onClick: () => { + onRecord(); + }, + }); + } + + // Always show Upload SOP option + menuItems.push({ + id: "3", + icon: , + text: "Upload SOP", + enabled: !isUploadingSOP, + onClick: () => { + onUploadSOP(); + }, + }); + + return menuItems; + }, [ + buttonSize, + onAdd, + onRecord, + onUploadSOP, + recordingStore.isRecording, + settingsStore.isUsingABrowser, + isUploadingSOP, + ]); + + // Show menu in debug mode regardless of browser state + if (!debugStore.isDebugMode) { return <>{children}; } return ( , - text: "Add Block", - onClick: () => { - onAdd(); - }, - }, - { - id: "2", - icon: , - enabled: !recordingStore.isRecording && settingsStore.isUsingABrowser, - text: "Record Browser", - onClick: () => { - if (!settingsStore.isUsingABrowser) { - return; - } - - onRecord(); - }, - }, - ]} + items={items} buttonSize={buttonSize} radius={radius} startAt={startAt} diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowAdderBusy.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowAdderBusy.tsx index f925eca5..a7451b17 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowAdderBusy.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowAdderBusy.tsx @@ -1,5 +1,14 @@ import { ReactNode, useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Tooltip, TooltipContent, @@ -11,7 +20,7 @@ import { cn } from "@/util/utils"; import "./WorkflowAdderBusy.css"; -type Operation = "recording" | "processing"; +type Operation = "recording" | "processing" | "uploading"; type Size = "small" | "large"; @@ -29,8 +38,14 @@ type Props = { * Color for the cover and ellipses. Defaults to "red". */ color?: string; - // -- + /** + * Callback for when the operation completes (recording/processing). + */ onComplete: () => void; + /** + * Callback for when the user cancels an upload operation. + */ + onCancel?: () => void; }; function WorkflowAdderBusy({ @@ -39,10 +54,12 @@ function WorkflowAdderBusy({ size, color = "red", onComplete, + onCancel, }: Props) { const recordingStore = useRecordingStore(); const [isHovered, setIsHovered] = useState(false); const [shouldBump, setShouldBump] = useState(false); + const [showCancelDialog, setShowCancelDialog] = useState(false); const bumpTimeoutRef = useRef(null); const prevCountRef = useRef(0); const eventCount = recordingStore.exposedEventCount; @@ -73,11 +90,21 @@ function WorkflowAdderBusy({ const handleClick = (e: React.MouseEvent) => { e.stopPropagation(); e.preventDefault(); - onComplete(); + + if (operation === "uploading" && onCancel) { + setShowCancelDialog(true); + } else { + onComplete(); + } return false; }; + const handleConfirmCancel = () => { + setShowCancelDialog(false); + onCancel?.(); + }; + return (
@@ -157,7 +184,11 @@ function WorkflowAdderBusy({

- {operation === "recording" ? "Finish Recording" : "Processing..."} + {operation === "recording" + ? "Finish Recording" + : operation === "uploading" + ? "Converting SOP... (click to cancel)" + : "Processing..."}

@@ -186,6 +217,28 @@ function WorkflowAdderBusy({ )}
+ + + + Cancel SOP Conversion? + + The SOP is currently being converted to workflow blocks. Are you + sure you want to cancel this operation? + + + + + + + +
); } diff --git a/skyvern-frontend/src/routes/workflows/editor/edges/EdgeWithAddButton.tsx b/skyvern-frontend/src/routes/workflows/editor/edges/EdgeWithAddButton.tsx index 63c1dc57..fe923888 100644 --- a/skyvern-frontend/src/routes/workflows/editor/edges/EdgeWithAddButton.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/edges/EdgeWithAddButton.tsx @@ -6,6 +6,7 @@ import { getBezierPath, useNodes, } from "@xyflow/react"; +import { useRef } from "react"; import { Button } from "@/components/ui/button"; import { @@ -13,11 +14,13 @@ import { useWorkflowPanelStore, } from "@/store/WorkflowPanelStore"; import { useProcessRecordingMutation } from "@/routes/browserSessions/hooks/useProcessRecordingMutation"; +import { useSopToBlocksMutation } from "@/routes/workflows/hooks/useSopToBlocksMutation"; import { useDebugStore } from "@/store/useDebugStore"; import { useRecordedBlocksStore } from "@/store/RecordedBlocksStore"; import { useRecordingStore } from "@/store/useRecordingStore"; import { useSettingsStore } from "@/store/SettingsStore"; import { cn } from "@/util/utils"; +import { toast } from "@/components/ui/use-toast"; import { REACT_FLOW_EDGE_Z_INDEX } from "../constants"; import type { NodeBaseData } from "../nodes/types"; @@ -55,6 +58,11 @@ function EdgeWithAddButton({ const setWorkflowPanelState = useWorkflowPanelStore( (state) => state.setWorkflowPanelState, ); + // SOP upload + const fileInputRef = useRef(null); + + const sourceNode = nodes.find((node) => node.id === source); + const processRecordingMutation = useProcessRecordingMutation({ browserSessionId: settingsStore.browserSessionId, onSuccess: (result) => { @@ -67,9 +75,22 @@ function EdgeWithAddButton({ }, }); - const isProcessing = processRecordingMutation.isPending; + const sopToBlocksMutation = useSopToBlocksMutation({ + onSuccess: (result) => { + // Reuse existing block insertion pattern + setRecordedBlocks(result, { + previous: source, + next: target, + parent: sourceNode?.parentId, + connectingEdgeType: "edgeWithAddButton", + }); + }, + }); - const sourceNode = nodes.find((node) => node.id === source); + // Derive upload state directly from mutation to avoid race conditions + const isUploadingSOP = sopToBlocksMutation.isPending; + + const isProcessing = processRecordingMutation.isPending; const isBusy = (isProcessing || recordingStore.isRecording) && @@ -170,6 +191,28 @@ function EdgeWithAddButton({ processRecordingMutation.mutate(); }; + const onUploadSOP = () => { + fileInputRef.current?.click(); + }; + + const handleSOPFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) { + return; + } + if (!file.name.toLowerCase().endsWith(".pdf")) { + toast({ + variant: "destructive", + title: "Invalid file type", + description: "Please select a PDF file", + }); + e.target.value = ""; + return; + } + sopToBlocksMutation.mutate(file); + e.target.value = ""; + }; + const adder = (