From 4da7b6d4dd37bc36dc015fde42e8f011cffffd60 Mon Sep 17 00:00:00 2001 From: Jonathan Dobson Date: Mon, 3 Nov 2025 10:01:24 -0500 Subject: [PATCH] Coalesce the Validation Block and the new Human Interaction Block (#3882) --- .../routes/workflows/editor/FlowRenderer.tsx | 34 ++++- .../HumanInteractionNode.tsx | 11 ++ .../nodes/ValidationNode/ValidationNode.tsx | 11 ++ .../editor/nodes/components/MicroDropdown.tsx | 132 ++++++++++++++++++ .../editor/nodes/components/NodeHeader.tsx | 44 +++++- .../panels/WorkflowNodeLibraryPanel.tsx | 26 ++-- .../editor/reactFlowOverrideStyles.css | 16 +++ .../hooks/useTransmuteNodeCallback.ts | 17 +++ .../src/store/BlockActionContext.ts | 2 + 9 files changed, 280 insertions(+), 13 deletions(-) create mode 100644 skyvern-frontend/src/routes/workflows/editor/nodes/components/MicroDropdown.tsx create mode 100644 skyvern-frontend/src/routes/workflows/hooks/useTransmuteNodeCallback.ts diff --git a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx index c1c64450..cd1744f9 100644 --- a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx @@ -60,7 +60,12 @@ import { BITWARDEN_MASTER_PASSWORD_AWS_SECRET_KEY, } from "./constants"; import { edgeTypes } from "./edges"; -import { AppNode, isWorkflowBlockNode, nodeTypes } from "./nodes"; +import { + AppNode, + isWorkflowBlockNode, + nodeTypes, + WorkflowBlockNode, +} from "./nodes"; import { ParametersState, parameterIsSkyvernCredential, @@ -71,6 +76,7 @@ import { import "./reactFlowOverrideStyles.css"; import { convertEchoParameters, + createNode, descendants, getAdditionalParametersForEmailBlock, getOrderedChildrenBlocks, @@ -484,6 +490,30 @@ function FlowRenderer({ doLayout(newNodesWithUpdatedParameters, newEdges); } + function transmuteNode(id: string, nodeType: string) { + const nodeToTransmute = nodes.find((node) => node.id === id); + + if (!nodeToTransmute || !isWorkflowBlockNode(nodeToTransmute)) { + return; + } + + const newNode = createNode( + { id: nodeToTransmute.id, parentId: nodeToTransmute.parentId }, + nodeType as NonNullable, + nodeToTransmute.data.label, + ); + + const newNodes = nodes.map((node) => { + if (node.id === id) { + return newNode; + } + return node; + }); + + workflowChangesStore.setHasChanges(true); + doLayout(newNodes, edges); + } + function toggleScript({ id, label, @@ -646,6 +676,8 @@ function FlowRenderer({ */ deleteNodeCallback: (id: string) => setTimeout(() => deleteNode(id), 0), + transmuteNodeCallback: (id: string, nodeName: string) => + setTimeout(() => transmuteNode(id, nodeName), 0), toggleScriptForNodeCallback: toggleScript, }} > diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/HumanInteractionNode/HumanInteractionNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/HumanInteractionNode/HumanInteractionNode.tsx index 2f6bfa61..c3c36af8 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/HumanInteractionNode/HumanInteractionNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/HumanInteractionNode/HumanInteractionNode.tsx @@ -77,6 +77,17 @@ function HumanInteractionNode({ nodeId={id} totpIdentifier={null} totpUrl={null} + transmutations={{ + blockTitle: "Validation", + self: "human", + others: [ + { + label: "agent", + reason: "Convert to automated agent validation", + nodeName: "validation", + }, + ], + }} type={type} />
) { nodeId={id} totpIdentifier={null} totpUrl={null} + transmutations={{ + blockTitle: "Validation", + self: "agent", + others: [ + { + label: "human", + reason: "Convert to human validation", + nodeName: "human_interaction", + }, + ], + }} type={type} />
diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/components/MicroDropdown.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/components/MicroDropdown.tsx new file mode 100644 index 00000000..99799fa3 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/components/MicroDropdown.tsx @@ -0,0 +1,132 @@ +import { ChevronDownIcon, ChevronUpIcon } from "@radix-ui/react-icons"; +import { useEffect, useRef, useState } from "react"; + +import { cn } from "@/util/utils"; + +interface Props { + selections: string[]; + selected: string; + // -- + onChange: (selection: string) => void; +} + +function MicroDropdown({ selections, selected, onChange }: Props) { + const dropdownRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [openUpwards, setOpenUpwards] = useState(false); + + function handleOnChange(selection: string) { + setIsOpen(false); + onChange(selection); + } + + useEffect(() => { + if (!isOpen) { + return; + } + + function handleClickOutside(event: MouseEvent) { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + } + + document.addEventListener("mousedown", handleClickOutside, true); + return () => { + document.removeEventListener("mousedown", handleClickOutside, true); + }; + }, [isOpen]); + + useEffect(() => { + if (!isOpen) { + return; + } + + function handleEscKey(event: KeyboardEvent) { + if (event.key === "Escape") { + setIsOpen(false); + } + } + + document.addEventListener("keydown", handleEscKey); + return () => { + document.removeEventListener("keydown", handleEscKey); + }; + }, [isOpen]); + + return ( +
+
+
{ + if (!isOpen && dropdownRef.current) { + const rect = dropdownRef.current.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const componentMiddle = rect.top + rect.height / 2; + setOpenUpwards(componentMiddle > viewportHeight * 0.5); + } + setIsOpen(!isOpen); + }} + > + [{selected}] + {isOpen ? ( + + ) : ( + + )} + {isOpen && ( +
e.stopPropagation()} + > +
+ {selections.map((s, index) => ( +
+
{ + if (s === selected) { + return; + } + + handleOnChange(s); + }} + > + {s === selected ? `[${s}]` : s} +
+
+ ))} +
+
+ )} +
+
+
+ ); +} + +export { MicroDropdown }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx index 2c8b8866..da0d2706 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/components/NodeHeader.tsx @@ -15,6 +15,7 @@ import { useAutoplayStore } from "@/store/useAutoplayStore"; import { useNodeLabelChangeHandler } from "@/routes/workflows/hooks/useLabelChangeHandler"; import { useDeleteNodeCallback } from "@/routes/workflows/hooks/useDeleteNodeCallback"; +import { useTransmuteNodeCallback } from "@/routes/workflows/hooks/useTransmuteNodeCallback"; import { useToggleScriptForNodeCallback } from "@/routes/workflows/hooks/useToggleScriptForNodeCallback"; import { useDebugSessionQuery } from "@/routes/workflows/hooks/useDebugSessionQuery"; import { useWorkflowQuery } from "@/routes/workflows/hooks/useWorkflowQuery"; @@ -45,6 +46,17 @@ import { EditableNodeTitle } from "../components/EditableNodeTitle"; import { NodeActionMenu } from "../NodeActionMenu"; import { WorkflowBlockIcon } from "../WorkflowBlockIcon"; import { workflowBlockTitle } from "../types"; +import { MicroDropdown } from "./MicroDropdown"; + +interface Transmutations { + blockTitle: string; + self: string; + others: { + label: string; + reason: string; + nodeName: string; + }[]; +} interface Props { blockLabel: string; // today, this + wpid act as the identity of a block @@ -53,6 +65,7 @@ interface Props { nodeId: string; totpIdentifier: string | null; totpUrl: string | null; + transmutations?: Transmutations; type: WorkflowBlockType; } @@ -144,6 +157,7 @@ function NodeHeader({ nodeId, totpIdentifier, totpUrl, + transmutations, type, }: Props) { const log = useLogging(); @@ -162,6 +176,7 @@ function NodeHeader({ }); const blockTitle = workflowBlockTitle[type]; const deleteNodeCallback = useDeleteNodeCallback(); + const transmuteNodeCallback = useTransmuteNodeCallback(); const toggleScriptForNodeCallback = useToggleScriptForNodeCallback(); const credentialGetter = useCredentialGetter(); const navigate = useNavigate(); @@ -510,7 +525,34 @@ function NodeHeader({ titleClassName="text-base" inputClassName="text-base" /> - {blockTitle} + + {transmutations && transmutations.others.length ? ( +
+ + {transmutations.blockTitle} + + t.label), + ]} + selected={transmutations.self} + onChange={(label) => { + const transmutation = transmutations.others.find( + (t) => t.label === label, + ); + + if (!transmutation) { + return; + } + + transmuteNodeCallback(nodeId, transmutation.nodeName); + }} + /> +
+ ) : ( + {blockTitle} + )}
diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx index cdad3d2d..ded8ab0a 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx @@ -87,17 +87,21 @@ const nodeLibraryItems: Array<{ title: "Validation Block", description: "Validate completion criteria", }, - { - nodeType: "human_interaction", - icon: ( - - ), - title: "Human Interaction Block", - description: "Validate via human interaction", - }, + /** + * The Human Interaction block can be had via a transmutation of the + * Validation block. + */ + // { + // nodeType: "human_interaction", + // icon: ( + // + // ), + // title: "Human Interaction Block", + // description: "Validate via human interaction", + // }, // { // nodeType: "task", // icon: ( diff --git a/skyvern-frontend/src/routes/workflows/editor/reactFlowOverrideStyles.css b/skyvern-frontend/src/routes/workflows/editor/reactFlowOverrideStyles.css index fc320e63..7783f139 100644 --- a/skyvern-frontend/src/routes/workflows/editor/reactFlowOverrideStyles.css +++ b/skyvern-frontend/src/routes/workflows/editor/reactFlowOverrideStyles.css @@ -14,6 +14,22 @@ @apply bg-slate-elevation3; } +/* Smooth transitions for node position and size changes */ +.react-flow__node { + transition: transform 0.3s ease-in-out; +} + +/* Smooth transitions for edge position updates */ +.react-flow__edge path, +.react-flow__edge-path { + transition: d 0.3s ease-in-out; +} + +.react-flow__edge text, +.react-flow__edge-text { + transition: transform 0.3s ease-in-out; +} + .react-flow__handle-top { top: 3px; } diff --git a/skyvern-frontend/src/routes/workflows/hooks/useTransmuteNodeCallback.ts b/skyvern-frontend/src/routes/workflows/hooks/useTransmuteNodeCallback.ts new file mode 100644 index 00000000..208e356c --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useTransmuteNodeCallback.ts @@ -0,0 +1,17 @@ +import { BlockActionContext } from "@/store/BlockActionContext"; +import { useContext } from "react"; + +function useTransmuteNodeCallback() { + const transmuteNodeCallback = + useContext(BlockActionContext)?.transmuteNodeCallback; + + if (!transmuteNodeCallback) { + throw new Error( + "useTransmuteNodeCallback must be used within a BlockActionContextProvider", + ); + } + + return transmuteNodeCallback; +} + +export { useTransmuteNodeCallback }; diff --git a/skyvern-frontend/src/store/BlockActionContext.ts b/skyvern-frontend/src/store/BlockActionContext.ts index 3a699192..f1877466 100644 --- a/skyvern-frontend/src/store/BlockActionContext.ts +++ b/skyvern-frontend/src/store/BlockActionContext.ts @@ -1,6 +1,7 @@ import { createContext } from "react"; type DeleteNodeCallback = (id: string) => void; +type TransmuteNodeCallback = (id: string, nodeName: string) => void; type ToggleScriptForNodeCallback = (opts: { id?: string; label?: string; @@ -10,6 +11,7 @@ type ToggleScriptForNodeCallback = (opts: { const BlockActionContext = createContext< | { deleteNodeCallback: DeleteNodeCallback; + transmuteNodeCallback: TransmuteNodeCallback; toggleScriptForNodeCallback?: ToggleScriptForNodeCallback; } | undefined