diff --git a/skyvern-frontend/src/components/icons/GitBranchIcon.tsx b/skyvern-frontend/src/components/icons/GitBranchIcon.tsx new file mode 100644 index 00000000..180788b3 --- /dev/null +++ b/skyvern-frontend/src/components/icons/GitBranchIcon.tsx @@ -0,0 +1,26 @@ +type Props = { + className?: string; +}; + +function GitBranchIcon({ className }: Props) { + return ( + + + + ); +} + +export { GitBranchIcon }; diff --git a/skyvern-frontend/src/routes/workflows/debugger/Debugger.tsx b/skyvern-frontend/src/routes/workflows/debugger/Debugger.tsx index e3fb415c..602844a8 100644 --- a/skyvern-frontend/src/routes/workflows/debugger/Debugger.tsx +++ b/skyvern-frontend/src/routes/workflows/debugger/Debugger.tsx @@ -4,7 +4,10 @@ import { ReactFlowProvider } from "@xyflow/react"; import { useWorkflowQuery } from "../hooks/useWorkflowQuery"; import { WorkflowSettings } from "../types/workflowTypes"; -import { getElements } from "@/routes/workflows/editor/workflowEditorUtils"; +import { + getElements, + upgradeWorkflowBlocksV1toV2, +} from "@/routes/workflows/editor/workflowEditorUtils"; import { getInitialParameters } from "@/routes/workflows/editor/utils"; import { Workspace } from "@/routes/workflows/editor/Workspace"; import { useDebugSessionBlockOutputsQuery } from "../hooks/useDebugSessionBlockOutputsQuery"; @@ -52,6 +55,13 @@ function Debugger() { return null; } + // Auto-upgrade v1 workflows to v2 by assigning sequential next_block_label values + const workflowVersion = workflow.workflow_definition.version ?? 1; + const blocksToRender = + workflowVersion < 2 + ? upgradeWorkflowBlocksV1toV2(workflow.workflow_definition.blocks) + : workflow.workflow_definition.blocks; + const settings: WorkflowSettings = { persistBrowserSession: workflow.persist_browser_session, proxyLocation: workflow.proxy_location, @@ -68,11 +78,7 @@ function Debugger() { sequentialKey: workflow.sequential_key ?? null, }; - const elements = getElements( - workflow.workflow_definition.blocks, - settings, - true, - ); + const elements = getElements(blocksToRender, settings, true); return (
diff --git a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx index 4900ea5e..cf5ba0b6 100644 --- a/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/FlowRenderer.tsx @@ -309,9 +309,14 @@ function FlowRenderer({ const [shouldConstrainPan, setShouldConstrainPan] = useState(false); const flowIsConstrained = debugStore.isDebugMode; + // Track if this is the initial load to prevent false "unsaved changes" detection + const isInitialLoadRef = useRef(true); + useEffect(() => { if (nodesInitialized) { setShouldConstrainPan(true); + // Mark initial load as complete after nodes are initialized + isInitialLoadRef.current = false; } }, [nodesInitialized]); @@ -850,7 +855,10 @@ function FlowRenderer({ if (dimensionChanges.length > 0) { doLayout(tempNodes, edges); } + + // Only track changes after initial load is complete if ( + !isInitialLoadRef.current && changes.some((change) => { return ( change.type === "add" || diff --git a/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx b/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx index fb8ebc9a..5db8b618 100644 --- a/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/WorkflowEditor.tsx @@ -2,7 +2,10 @@ import { ReactFlowProvider } from "@xyflow/react"; import { useParams } from "react-router-dom"; import { useEffect } from "react"; import { useWorkflowQuery } from "../hooks/useWorkflowQuery"; -import { getElements } from "./workflowEditorUtils"; +import { + getElements, + upgradeWorkflowBlocksV1toV2, +} from "./workflowEditorUtils"; import { LogoMinimized } from "@/components/LogoMinimized"; import { WorkflowSettings } from "../types/workflowTypes"; import { useGlobalWorkflowsQuery } from "../hooks/useGlobalWorkflowsQuery"; @@ -53,6 +56,13 @@ function WorkflowEditor() { globalWorkflow.workflow_permanent_id === workflowPermanentId, ); + // Auto-upgrade v1 workflows to v2 by assigning sequential next_block_label values + const workflowVersion = workflow.workflow_definition.version ?? 1; + const blocksToRender = + workflowVersion < 2 + ? upgradeWorkflowBlocksV1toV2(workflow.workflow_definition.blocks) + : workflow.workflow_definition.blocks; + const settings: WorkflowSettings = { persistBrowserSession: workflow.persist_browser_session, proxyLocation: workflow.proxy_location, @@ -69,11 +79,7 @@ function WorkflowEditor() { sequentialKey: workflow.sequential_key ?? null, }; - const elements = getElements( - workflow.workflow_definition.blocks, - settings, - !isGlobalWorkflow, - ); + const elements = getElements(blocksToRender, settings, !isGlobalWorkflow); return (
diff --git a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx index 327e7c46..f586524e 100644 --- a/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/Workspace.tsx @@ -17,7 +17,12 @@ import { ReloadIcon, } from "@radix-ui/react-icons"; import { useParams, useSearchParams } from "react-router-dom"; -import { useEdgesState, useNodesState, Edge } from "@xyflow/react"; +import { + useEdgesState, + useNodesState, + useReactFlow, + Edge, +} from "@xyflow/react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { getClient } from "@/api/AxiosClient"; @@ -57,7 +62,10 @@ import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; import { DebuggerRun } from "@/routes/workflows/debugger/DebuggerRun"; import { DebuggerRunMinimal } from "@/routes/workflows/debugger/DebuggerRunMinimal"; import { useWorkflowRunQuery } from "@/routes/workflows/hooks/useWorkflowRunQuery"; -import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore"; +import { + BranchContext, + useWorkflowPanelStore, +} from "@/store/WorkflowPanelStore"; import { useWorkflowHasChangesStore, useWorkflowSave, @@ -68,6 +76,7 @@ import { cn } from "@/util/utils"; import { FlowRenderer, type FlowRendererProps } from "./FlowRenderer"; import { AppNode, isWorkflowBlockNode, WorkflowBlockNode } from "./nodes"; +import { ConditionalNodeData } from "./nodes/ConditionalNode/types"; import { WorkflowNodeLibraryPanel } from "./panels/WorkflowNodeLibraryPanel"; import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel"; import { WorkflowCacheKeyValuesPanel } from "./panels/WorkflowCacheKeyValuesPanel"; @@ -105,6 +114,7 @@ export type AddNodeProps = { next: string | null; parent?: string; connectingEdgeType: string; + branch?: BranchContext; }; interface Dom { @@ -214,6 +224,7 @@ function Workspace({ useWorkflowPanelStore(); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); + const { getNodes, getEdges } = useReactFlow(); const saveWorkflow = useWorkflowSave({ status: "published" }); const { data: workflowRun } = useWorkflowRunQuery(); @@ -259,6 +270,22 @@ function Workspace({ splitLeft: useRef(null), }; + // Track all used labels globally (including those in saved branch states) + // Initialize with labels from initial nodes + const usedLabelsRef = useRef>( + new Set( + initialNodes.filter(isWorkflowBlockNode).map((node) => node.data.label), + ), + ); + + // Sync usedLabelsRef with current nodes to handle any external changes + useEffect(() => { + const currentLabels = nodes + .filter(isWorkflowBlockNode) + .map((node) => node.data.label); + usedLabelsRef.current = new Set(currentLabels); + }, [nodes]); + const handleOnSave = async () => { const errors = getWorkflowErrors(nodes); if (errors.length > 0) { @@ -628,11 +655,38 @@ function Workspace({ }; }, [dom.splitLeft]); - function doLayout(nodes: Array, edges: Array) { - const layoutedElements = layout(nodes, edges); - setNodes(layoutedElements.nodes); - setEdges(layoutedElements.edges); - } + const doLayout = useCallback( + (nodes: Array, edges: Array) => { + const layoutedElements = layout(nodes, edges); + setNodes(layoutedElements.nodes); + setEdges(layoutedElements.edges); + }, + [setNodes, setEdges], + ); + + // Listen for conditional branch changes to trigger re-layout + useEffect(() => { + const handleBranchChange = () => { + // Use a small delay to ensure visibility updates have propagated + setTimeout(() => { + // Get the latest nodes and edges (including visibility changes) + const currentNodes = getNodes() as Array; + const currentEdges = getEdges(); + + const layoutedElements = layout(currentNodes, currentEdges); + setNodes(layoutedElements.nodes); + setEdges(layoutedElements.edges); + }, 10); // Small delay to ensure visibility updates complete + }; + + window.addEventListener("conditional-branch-changed", handleBranchChange); + return () => { + window.removeEventListener( + "conditional-branch-changed", + handleBranchChange, + ); + }; + }, [getNodes, getEdges, setNodes, setEdges]); function addNode({ nodeType, @@ -640,21 +694,35 @@ function Workspace({ next, parent, connectingEdgeType, + branch, }: AddNodeProps) { const newNodes: Array = []; const newEdges: Array = []; const id = nanoid(); - const existingLabels = nodes - .filter(isWorkflowBlockNode) - .map((node) => node.data.label); + // Use global label tracking instead of just current nodes + const existingLabels = Array.from(usedLabelsRef.current); + const newLabel = generateNodeLabel(existingLabels); + const computedParentId = parent ?? branch?.conditionalNodeId; const node = createNode( - { id, parentId: parent }, + { id, parentId: computedParentId }, nodeType, - generateNodeLabel(existingLabels), + newLabel, ); + // Track the new label + usedLabelsRef.current.add(newLabel); + + if (branch && "data" in node) { + node.data = { + ...node.data, + conditionalBranchId: branch.branchId, + conditionalLabel: branch.conditionalLabel, + conditionalNodeId: branch.conditionalNodeId, + conditionalMergeLabel: branch.mergeLabel ?? null, + }; + } newNodes.push(node); if (previous) { - const newEdge = { + const newEdge: Edge = { id: nanoid(), type: "edgeWithAddButton", source: previous, @@ -662,11 +730,17 @@ function Workspace({ style: { strokeWidth: 2, }, + data: branch + ? { + conditionalNodeId: branch.conditionalNodeId, + conditionalBranchId: branch.branchId, + } + : undefined, }; newEdges.push(newEdge); } if (next) { - const newEdge = { + const newEdge: Edge = { id: nanoid(), type: connectingEdgeType, source: id, @@ -674,6 +748,12 @@ function Workspace({ style: { strokeWidth: 2, }, + data: branch + ? { + conditionalNodeId: branch.conditionalNodeId, + conditionalBranchId: branch.branchId, + } + : undefined, }; newEdges.push(newEdge); } @@ -698,8 +778,59 @@ function Workspace({ newEdges.push(defaultEdge(startNodeId, adderNodeId)); } + if (nodeType === "conditional" && "data" in node) { + // Conditional blocks need StartNode and NodeAdderNode as children + const startNodeId = nanoid(); + const adderNodeId = nanoid(); + newNodes.push( + startNode( + startNodeId, + { + withWorkflowSettings: false, + editable: true, + label: "__start_block__", + showCode: false, + parentNodeType: "conditional", + }, + id, + ), + ); + newNodes.push(nodeAdderNode(adderNodeId, id)); + + // Create an edge for each branch (initially all branches have START → NodeAdder) + const conditionalData = node.data as ConditionalNodeData; + conditionalData.branches.forEach((branch) => { + const edge: Edge = { + id: nanoid(), + type: "default", + source: startNodeId, + target: adderNodeId, + style: { strokeWidth: 2 }, + data: { + conditionalNodeId: id, + conditionalBranchId: branch.id, + }, + }; + newEdges.push(edge); + }); + } + const editedEdges = previous - ? edges.filter((edge) => edge.source !== previous) + ? edges.filter((edge) => { + // Don't remove edges from the previous node + if (edge.source !== previous) { + return true; + } + // If we're in a branch, only remove the edge for this branch + if (branch) { + const edgeData = edge.data as + | { conditionalBranchId?: string } + | undefined; + return edgeData?.conditionalBranchId !== branch.branchId; + } + // Otherwise remove all edges from previous + return false; + }) : edges; const previousNode = nodes.find((node) => node.id === previous); diff --git a/skyvern-frontend/src/routes/workflows/editor/edges/EdgeWithAddButton.tsx b/skyvern-frontend/src/routes/workflows/editor/edges/EdgeWithAddButton.tsx index ec10930e..63c1dc57 100644 --- a/skyvern-frontend/src/routes/workflows/editor/edges/EdgeWithAddButton.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/edges/EdgeWithAddButton.tsx @@ -8,15 +8,19 @@ import { } from "@xyflow/react"; import { Button } from "@/components/ui/button"; +import { + BranchContext, + useWorkflowPanelStore, +} from "@/store/WorkflowPanelStore"; import { useProcessRecordingMutation } from "@/routes/browserSessions/hooks/useProcessRecordingMutation"; import { useDebugStore } from "@/store/useDebugStore"; import { useRecordedBlocksStore } from "@/store/RecordedBlocksStore"; import { useRecordingStore } from "@/store/useRecordingStore"; import { useSettingsStore } from "@/store/SettingsStore"; -import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore"; import { cn } from "@/util/utils"; import { REACT_FLOW_EDGE_Z_INDEX } from "../constants"; +import type { NodeBaseData } from "../nodes/types"; import { WorkflowAddMenu } from "../WorkflowAddMenu"; import { WorkflowAdderBusy } from "../WorkflowAdderBusy"; @@ -78,14 +82,65 @@ function EdgeWithAddButton({ const isDisabled = !isBusy && recordingStore.isRecording; - const updateWorkflowPanelState = (active: boolean) => { + const deriveBranchContext = (): BranchContext | undefined => { + if ( + sourceNode && + "data" in sourceNode && + (sourceNode.data as NodeBaseData).conditionalBranchId && + (sourceNode.data as NodeBaseData).conditionalNodeId + ) { + const sourceData = sourceNode.data as NodeBaseData; + return { + conditionalNodeId: sourceData.conditionalNodeId!, + conditionalLabel: sourceData.conditionalLabel ?? sourceData.label, + branchId: sourceData.conditionalBranchId!, + mergeLabel: sourceData.conditionalMergeLabel ?? null, + }; + } + + // If source node doesn't have branch context, check if it's inside a conditional block + // (e.g., StartNode or NodeAdderNode inside a conditional) + if (sourceNode?.parentId) { + const parentNode = nodes.find((n) => n.id === sourceNode.parentId); + if (parentNode?.type === "conditional" && "data" in parentNode) { + const conditionalData = parentNode.data as { + activeBranchId: string | null; + branches: Array<{ id: string }>; + label: string; + mergeLabel: string | null; + }; + const activeBranchId = conditionalData.activeBranchId; + const activeBranch = conditionalData.branches?.find( + (b) => b.id === activeBranchId, + ); + + if (activeBranch) { + return { + conditionalNodeId: parentNode.id, + conditionalLabel: conditionalData.label, + branchId: activeBranch.id, + mergeLabel: conditionalData.mergeLabel ?? null, + }; + } + } + } + + return undefined; + }; + + const updateWorkflowPanelState = ( + active: boolean, + branchContext?: BranchContext, + ) => { setWorkflowPanelState({ active, content: "nodeLibrary", data: { previous: source, next: target, - parent: sourceNode?.parentId, + parent: branchContext?.conditionalNodeId ?? sourceNode?.parentId, + connectingEdgeType: "edgeWithAddButton", + branchContext, }, }); }; @@ -94,8 +149,8 @@ function EdgeWithAddButton({ if (isDisabled) { return; } - - updateWorkflowPanelState(true); + const branchContext = deriveBranchContext(); + updateWorkflowPanelState(true, branchContext); }; const onRecord = () => { diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ConditionalNode/ConditionalNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/ConditionalNode/ConditionalNode.tsx new file mode 100644 index 00000000..b3be4b9c --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ConditionalNode/ConditionalNode.tsx @@ -0,0 +1,702 @@ +import { useEffect, useMemo } from "react"; +import { + Handle, + NodeProps, + Position, + useNodes, + useReactFlow, +} from "@xyflow/react"; +import { + PlusIcon, + ChevronDownIcon, + DotsVerticalIcon, +} from "@radix-ui/react-icons"; +import type { Node } from "@xyflow/react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { cn } from "@/util/utils"; +import { useUpdate } from "../../useUpdate"; +import { NodeHeader } from "../components/NodeHeader"; +import { AppNode, isWorkflowBlockNode } from ".."; +import { + getLoopNodeWidth, + updateNodeAndDescendantsVisibility, +} from "../../workflowEditorUtils"; +import type { ConditionalNode } from "./types"; +import { + ConditionalNodeData, + createBranchCondition, + defaultBranchCriteria, +} from "./types"; +import type { BranchCondition } from "../../../types/workflowTypes"; +import { HelpTooltip } from "@/components/HelpTooltip"; + +function ConditionalNodeComponent({ id, data }: NodeProps) { + const nodes = useNodes(); + const { setNodes, setEdges } = useReactFlow(); + const node = nodes.find((n) => n.id === id); + + const update = useUpdate({ + id, + editable: data.editable, + }); + + const children = useMemo(() => { + return nodes.filter((node) => node.parentId === id && !node.hidden); + }, [nodes, id]); + + const furthestDownChild: Node | null = useMemo(() => { + return children.reduce( + (acc, child) => { + if (!acc) { + return child; + } + if ( + child.position.y + (child.measured?.height ?? 0) > + acc.position.y + (acc.measured?.height ?? 0) + ) { + return child; + } + return acc; + }, + null as Node | null, + ); + }, [children]); + + const childrenHeightExtent = useMemo(() => { + return ( + (furthestDownChild?.measured?.height ?? 0) + + (furthestDownChild?.position.y ?? 0) + + 24 + ); + }, [furthestDownChild]); + + const conditionalNodeWidth = useMemo(() => { + return node ? getLoopNodeWidth(node, nodes) : 450; + }, [node, nodes]); + + const orderedBranches = useMemo(() => { + const defaultBranch = data.branches.find((branch) => branch.is_default); + const nonDefault = data.branches.filter((branch) => !branch.is_default); + return defaultBranch ? [...nonDefault, defaultBranch] : nonDefault; + }, [data.branches]); + + const activeBranch = + orderedBranches.find((branch) => branch.id === data.activeBranchId) ?? + orderedBranches[0] ?? + null; + + useEffect(() => { + if (!data.branches.some((branch) => branch.is_default)) { + update({ + branches: [ + ...data.branches, + createBranchCondition({ is_default: true }), + ], + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data.branches]); + + useEffect(() => { + if (!data.activeBranchId && orderedBranches.length > 0) { + update({ + activeBranchId: orderedBranches[0]?.id ?? null, + }); + } + }, [data.activeBranchId, orderedBranches, update]); + + // Toggle visibility of branch nodes/edges when activeBranchId changes + useEffect(() => { + if (!data.activeBranchId) { + return; + } + + const activeBranchId = data.activeBranchId; + let updatedNodesSnapshot: Array = []; + + // Toggle node visibility with cascading to descendants + setNodes((currentNodes) => { + let updatedNodes = currentNodes as Array; + + // First pass: Update direct children of this conditional + updatedNodes = updatedNodes.map((n) => { + // Only affect workflow block nodes that belong to this conditional + if (!isWorkflowBlockNode(n)) { + return n; + } + if (n.data.conditionalNodeId !== id) { + return n; + } + if (!n.data.conditionalBranchId) { + return n; + } + + // Hide nodes that don't match the active branch + const shouldHide = n.data.conditionalBranchId !== activeBranchId; + return { ...n, hidden: shouldHide }; + }); + + // Second pass: Cascade visibility to all descendants of affected nodes + const affectedNodeIds = updatedNodes + .filter(isWorkflowBlockNode) + .filter((n) => n.data.conditionalNodeId === id) + .map((n) => n.id); + + affectedNodeIds.forEach((nodeId) => { + const node = updatedNodes.find((n) => n.id === nodeId); + if (node) { + updatedNodes = updateNodeAndDescendantsVisibility( + updatedNodes, + nodeId, + node.hidden ?? false, + ); + } + }); + + updatedNodesSnapshot = updatedNodes; + return updatedNodes; + }); + + // Toggle edge visibility using callback (needs updated nodes) + setEdges((currentEdges) => { + return currentEdges.map((edge) => { + const edgeData = edge.data as + | { + conditionalNodeId?: string; + conditionalBranchId?: string; + } + | undefined; + + // Only affect edges that belong to this conditional and have branch metadata + if ( + edgeData?.conditionalNodeId === id && + edgeData?.conditionalBranchId + ) { + const shouldHide = edgeData.conditionalBranchId !== activeBranchId; + return { ...edge, hidden: shouldHide }; + } + + // Also hide edges connected to hidden nodes (cascading) + const sourceNode = updatedNodesSnapshot.find( + (n: AppNode) => n.id === edge.source, + ); + const targetNode = updatedNodesSnapshot.find( + (n: AppNode) => n.id === edge.target, + ); + if (sourceNode?.hidden || targetNode?.hidden) { + return { ...edge, hidden: true }; + } + + return edge; + }); + }); + + // Trigger layout recalculation after visibility changes + setTimeout(() => { + window.dispatchEvent(new CustomEvent("conditional-branch-changed")); + }, 0); + }, [data.activeBranchId, id, setNodes, setEdges]); + + const handleAddCondition = () => { + if (!data.editable) { + return; + } + const defaultBranch = data.branches.find((branch) => branch.is_default); + const otherBranches = data.branches.filter((branch) => !branch.is_default); + const newBranch = createBranchCondition(); + const updatedBranches = defaultBranch + ? [...otherBranches, newBranch, defaultBranch] + : [...otherBranches, newBranch]; + + // Find the START and NodeAdder nodes inside this conditional + const startNode = nodes.find( + (n) => n.type === "start" && n.parentId === id, + ); + const adderNode = nodes.find( + (n) => n.type === "nodeAdder" && n.parentId === id, + ); + + // Create a START → NodeAdder edge for the new branch + if (startNode && adderNode) { + setEdges((currentEdges) => [ + ...currentEdges, + { + id: `${id}-${newBranch.id}-start-adder`, + type: "default", + source: startNode.id, + target: adderNode.id, + style: { strokeWidth: 2 }, + data: { + conditionalNodeId: id, + conditionalBranchId: newBranch.id, + }, + hidden: false, // This branch will be active + }, + ]); + } + + update({ + branches: updatedBranches, + activeBranchId: newBranch.id, + }); + }; + + const handleSelectBranch = (branchId: string) => { + if (!data.editable) { + return; + } + update({ activeBranchId: branchId }); + }; + + const handleRemoveBranch = (branchId: string) => { + if (!data.editable) { + return; + } + + // Don't allow removing if it's the last non-default branch + const nonDefaultBranches = data.branches.filter((b) => !b.is_default); + if (nonDefaultBranches.length <= 1) { + return; // Need at least one non-default branch + } + + // Remove nodes that belong to this branch + setNodes((currentNodes) => { + return (currentNodes as Array).filter((n) => { + if (isWorkflowBlockNode(n) && n.data.conditionalBranchId === branchId) { + return false; + } + return true; + }); + }); + + // Remove edges that belong to this branch + setEdges((currentEdges) => { + return currentEdges.filter((edge) => { + const edgeData = edge.data as + | { conditionalBranchId?: string } + | undefined; + return edgeData?.conditionalBranchId !== branchId; + }); + }); + + // Remove the branch from the branches array + const updatedBranches = data.branches.filter((b) => b.id !== branchId); + + // If the deleted branch was active, switch to the first branch + const newActiveBranchId = + data.activeBranchId === branchId + ? updatedBranches[0]?.id ?? null + : data.activeBranchId; + + update({ + branches: updatedBranches, + activeBranchId: newActiveBranchId, + }); + }; + + const handleMoveBranchUp = (branchId: string) => { + if (!data.editable) { + return; + } + + const nonDefaultBranches = data.branches.filter((b) => !b.is_default); + const currentIndex = nonDefaultBranches.findIndex((b) => b.id === branchId); + if (currentIndex <= 0) { + return; // Already at the top or not found + } + + // Swap within the non-default array + const newNonDefaultBranches = [...nonDefaultBranches]; + [ + newNonDefaultBranches[currentIndex], + newNonDefaultBranches[currentIndex - 1], + ] = [ + newNonDefaultBranches[currentIndex - 1]!, + newNonDefaultBranches[currentIndex]!, + ]; + + // Reconstruct the array with default branch at the end + const defaultBranch = data.branches.find((b) => b.is_default); + const reorderedBranches = defaultBranch + ? [...newNonDefaultBranches, defaultBranch] + : newNonDefaultBranches; + + update({ branches: reorderedBranches }); + }; + + const handleMoveBranchDown = (branchId: string) => { + if (!data.editable) { + return; + } + + const nonDefaultBranches = data.branches.filter((b) => !b.is_default); + const currentIndex = nonDefaultBranches.findIndex((b) => b.id === branchId); + + if (currentIndex < 0 || currentIndex >= nonDefaultBranches.length - 1) { + return; // Already at the bottom, not found, or is default branch + } + + // Swap with the branch below + const newNonDefaultBranches = [...nonDefaultBranches]; + [ + newNonDefaultBranches[currentIndex], + newNonDefaultBranches[currentIndex + 1], + ] = [ + newNonDefaultBranches[currentIndex + 1]!, + newNonDefaultBranches[currentIndex]!, + ]; + + // Reconstruct with default branch at the end + const defaultBranch = data.branches.find((b) => b.is_default); + const reorderedBranches = defaultBranch + ? [...newNonDefaultBranches, defaultBranch] + : newNonDefaultBranches; + + update({ branches: reorderedBranches }); + }; + + const handleExpressionChange = (expression: string) => { + if (!activeBranch || activeBranch.is_default) { + return; + } + update({ + branches: data.branches.map((branch) => { + if (branch.id !== activeBranch.id) { + return branch; + } + return { + ...branch, + criteria: { + ...(branch.criteria ?? { ...defaultBranchCriteria }), + expression, + }, + }; + }), + }); + }; + + // Convert number to Excel-style letter (A, B, C... Z, AA, AB, AC...) + const getExcelStyleLetter = (index: number): string => { + let result = ""; + let num = index; + + while (num >= 0) { + result = String.fromCharCode(65 + (num % 26)) + result; + num = Math.floor(num / 26) - 1; + } + + return result; + }; + + // Generate condition label: A • If, B • Else, C • Else If, etc. + const getConditionLabel = (branch: BranchCondition, index: number) => { + const letter = getExcelStyleLetter(index); + + if (branch.is_default) { + return `${letter} • Else`; + } + + if (index === 0) { + return `${letter} • If`; + } + + return `${letter} • Else If`; + }; + + if (!node) { + // If the node has been removed or is not yet available, bail out gracefully. + return null; + } + + return ( +
+ +
+
+
+ +
+
+
+ {(() => { + const MAX_VISIBLE_TABS = 3; + const totalBranches = orderedBranches.length; + const nonDefaultBranches = data.branches.filter( + (b) => !b.is_default, + ); + + // Determine which branches to show in the 3 visible slots + let visibleBranches: Array; + let overflowBranches: Array = []; + + if (totalBranches <= MAX_VISIBLE_TABS) { + // Show all branches if 3 or fewer + visibleBranches = orderedBranches; + } else { + // Show first 2 + dynamic 3rd slot + const first2 = orderedBranches.slice(0, 2); + const activeBranchIndex = orderedBranches.findIndex( + (b) => b.id === activeBranch?.id, + ); + + if (activeBranchIndex >= 2) { + // Active branch is 3rd or beyond, show it in 3rd slot + visibleBranches = [ + ...first2, + orderedBranches[activeBranchIndex]!, + ]; + // Overflow = all branches except the 3 visible ones + overflowBranches = orderedBranches.filter( + (_, i) => i >= 2 && i !== activeBranchIndex, + ); + } else { + // Active branch is in first 2, show 3rd branch normally + visibleBranches = orderedBranches.slice( + 0, + MAX_VISIBLE_TABS, + ); + overflowBranches = + orderedBranches.slice(MAX_VISIBLE_TABS); + } + } + + return ( + <> + {visibleBranches.map((branch) => { + const index = orderedBranches.findIndex( + (b) => b.id === branch.id, + ); + const canDelete = + data.editable && + !branch.is_default && + nonDefaultBranches.length > 1; + + const canReorder = !branch.is_default; + const branchIndexInNonDefault = + nonDefaultBranches.findIndex( + (b) => b.id === branch.id, + ); + const canMoveUp = branchIndexInNonDefault > 0; + const canMoveDown = + branchIndexInNonDefault >= 0 && + branchIndexInNonDefault < + nonDefaultBranches.length - 1; + + const showMenu = canReorder || canDelete; + + return ( +
+ + {showMenu && ( + + + + + + {canReorder && ( + <> + { + e.stopPropagation(); + handleMoveBranchUp(branch.id); + }} + className="cursor-pointer" + > + Move Up + + { + e.stopPropagation(); + handleMoveBranchDown(branch.id); + }} + className="cursor-pointer" + > + Move Down + + + )} + {canReorder && canDelete && ( + + )} + {canDelete && ( + { + e.stopPropagation(); + handleRemoveBranch(branch.id); + }} + className="cursor-pointer text-red-400 focus:text-red-400" + > + Remove + + )} + + + )} +
+ ); + })} + + {overflowBranches.length > 0 && ( + + + + + + {overflowBranches.map((branch) => { + const index = orderedBranches.findIndex( + (b) => b.id === branch.id, + ); + return ( + + handleSelectBranch(branch.id) + } + className="cursor-pointer" + > + {getConditionLabel(branch, index)} + + ); + })} + + + )} + + {/* Add new condition button */} + + + ); + })()} +
+
+ {activeBranch && ( +
+
+ + {!activeBranch.is_default && ( + 100 }} +Natural language: y is greater than 100`} + /> + )} +
+ { + handleExpressionChange(event.target.value); + }} + placeholder="Enter condition to evaluate (Jinja or natural language)" + /> +
+ )} +
+
+
+
+ +
+ ); +} + +export { ConditionalNodeComponent as ConditionalNode }; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/ConditionalNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/ConditionalNode/types.ts new file mode 100644 index 00000000..bdddd78e --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/ConditionalNode/types.ts @@ -0,0 +1,81 @@ +import type { Node } from "@xyflow/react"; +import { nanoid } from "nanoid"; + +import { + BranchCondition, + BranchCriteria, + BranchCriteriaTypes, +} from "@/routes/workflows/types/workflowTypes"; +import { NodeBaseData } from "../types"; + +export type ConditionalNodeData = NodeBaseData & { + branches: Array; + activeBranchId: string | null; + mergeLabel: string | null; +}; + +export type ConditionalNode = Node; + +export const defaultBranchCriteria: BranchCriteria = { + criteria_type: BranchCriteriaTypes.Jinja2Template, + expression: "", + description: null, +}; + +export function createBranchCondition( + overrides: Partial = {}, +): BranchCondition { + return { + id: overrides.id ?? nanoid(), + criteria: + overrides.is_default ?? false + ? null + : overrides.criteria + ? { + ...overrides.criteria, + } + : { ...defaultBranchCriteria }, + next_block_label: overrides.next_block_label ?? null, + description: overrides.description ?? null, + is_default: overrides.is_default ?? false, + }; +} + +const initialBranches: Array = [ + createBranchCondition(), + createBranchCondition({ is_default: true }), +]; + +export const conditionalNodeDefaultData: ConditionalNodeData = { + debuggable: true, + editable: true, + label: "", + continueOnFailure: false, + model: null, + showCode: false, + branches: initialBranches, + activeBranchId: initialBranches[0]!.id, + mergeLabel: null, +}; + +export function isConditionalNode(node: Node): node is ConditionalNode { + return node.type === "conditional"; +} + +export function cloneBranchConditions( + branches: Array, +): Array { + return branches.map((branch) => + createBranchCondition({ + id: branch.id, + criteria: branch.criteria, + next_block_label: branch.next_block_label, + description: branch.description, + is_default: branch.is_default, + }), + ); +} + +export function createDefaultBranchConditions(): Array { + return [createBranchCondition(), createBranchCondition({ is_default: true })]; +} diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx index 8b76ed83..65b2f97c 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/NodeAdderNode/NodeAdderNode.tsx @@ -1,12 +1,16 @@ import { PlusIcon } from "@radix-ui/react-icons"; -import { Handle, NodeProps, Position, useEdges } from "@xyflow/react"; +import { Handle, NodeProps, Position, useEdges, useNodes } from "@xyflow/react"; import { useProcessRecordingMutation } from "@/routes/browserSessions/hooks/useProcessRecordingMutation"; import { useDebugStore } from "@/store/useDebugStore"; +import { + BranchContext, + useWorkflowPanelStore, +} from "@/store/WorkflowPanelStore"; +import type { NodeBaseData } from "../types"; import { useRecordedBlocksStore } from "@/store/RecordedBlocksStore"; import { useRecordingStore } from "@/store/useRecordingStore"; import { useSettingsStore } from "@/store/SettingsStore"; -import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore"; import { cn } from "@/util/utils"; import type { NodeAdderNode } from "./types"; @@ -15,6 +19,7 @@ import { WorkflowAdderBusy } from "../../WorkflowAdderBusy"; function NodeAdderNode({ id, parentId }: NodeProps) { const edges = useEdges(); + const nodes = useNodes(); const debugStore = useDebugStore(); const recordingStore = useRecordingStore(); const settingsStore = useSettingsStore(); @@ -26,13 +31,89 @@ function NodeAdderNode({ id, parentId }: NodeProps) { (state) => state.setRecordedBlocks, ); - const previous = edges.find((edge) => edge.target === id)?.source ?? null; + const deriveBranchContext = (previousNodeId: string | undefined) => { + const previousNode = nodes.find((node) => node.id === previousNodeId); + if ( + previousNode && + "data" in previousNode && + (previousNode.data as NodeBaseData).conditionalBranchId && + (previousNode.data as NodeBaseData).conditionalNodeId + ) { + const prevData = previousNode.data as NodeBaseData; + return { + conditionalNodeId: prevData.conditionalNodeId!, + conditionalLabel: prevData.conditionalLabel ?? prevData.label, + branchId: prevData.conditionalBranchId!, + mergeLabel: prevData.conditionalMergeLabel ?? null, + } satisfies BranchContext; + } + + // If previous node doesn't have branch context, check if this NodeAdderNode is inside a conditional block + if (parentId) { + const parentNode = nodes.find((n) => n.id === parentId); + if (parentNode?.type === "conditional" && "data" in parentNode) { + const conditionalData = parentNode.data as { + activeBranchId: string | null; + branches: Array<{ id: string }>; + label: string; + mergeLabel: string | null; + }; + const activeBranchId = conditionalData.activeBranchId; + const activeBranch = conditionalData.branches?.find( + (b) => b.id === activeBranchId, + ); + + if (activeBranch) { + return { + conditionalNodeId: parentNode.id, + conditionalLabel: conditionalData.label, + branchId: activeBranch.id, + mergeLabel: conditionalData.mergeLabel ?? null, + } satisfies BranchContext; + } + } + } + + return undefined; + }; + + // Find the edge that targets this NodeAdder + // If inside a conditional, find the edge for the active branch + const previous = (() => { + const incomingEdges = edges.filter((edge) => edge.target === id); + + // If inside a conditional, filter by active branch + if (parentId) { + const parentNode = nodes.find((n) => n.id === parentId); + if (parentNode?.type === "conditional" && "data" in parentNode) { + const conditionalData = parentNode.data as { + activeBranchId: string | null; + }; + const activeBranchId = conditionalData.activeBranchId; + + // Find edge for active branch + const branchEdge = incomingEdges.find((edge) => { + const edgeData = edge.data as + | { conditionalBranchId?: string } + | undefined; + return edgeData?.conditionalBranchId === activeBranchId; + }); + + if (branchEdge) { + return branchEdge.source; + } + } + } + + // Otherwise return the first edge + return incomingEdges[0]?.source; + })(); const processRecordingMutation = useProcessRecordingMutation({ browserSessionId: settingsStore.browserSessionId, onSuccess: (result) => { setRecordedBlocks(result, { - previous, + previous: previous ?? null, next: id, parent: parentId, connectingEdgeType: "default", @@ -53,17 +134,19 @@ function NodeAdderNode({ id, parentId }: NodeProps) { const isDisabled = !isBusy && recordingStore.isRecording; - const updateWorkflowPanelState = (active: boolean) => { - const previous = edges.find((edge) => edge.target === id)?.source; - + const updateWorkflowPanelState = ( + active: boolean, + branchContext?: BranchContext, + ) => { setWorkflowPanelState({ active, content: "nodeLibrary", data: { previous: previous ?? null, next: id, - parent: parentId, + parent: branchContext?.conditionalNodeId ?? parentId, connectingEdgeType: "default", + branchContext, }, }); }; @@ -72,8 +155,8 @@ function NodeAdderNode({ id, parentId }: NodeProps) { if (isDisabled) { return; } - - updateWorkflowPanelState(true); + const branchContext = deriveBranchContext(previous); + updateWorkflowPanelState(true, branchContext); }; const onRecord = () => { diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/StartNode.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/StartNode.tsx index e81cdeed..c42227ca 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/StartNode.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/StartNode.tsx @@ -52,7 +52,7 @@ interface StartSettings { extraHttpHeaders: string | Record | null; } -function StartNode({ id, data }: NodeProps) { +function StartNode({ id, data, parentId }: NodeProps) { const workflowSettingsStore = useWorkflowSettingsStore(); const reactFlowInstance = useReactFlow(); const [facing, setFacing] = useState<"front" | "back">("front"); @@ -63,6 +63,10 @@ function StartNode({ id, data }: NodeProps) { const toggleScriptForNodeCallback = useToggleScriptForNodeCallback(); const isRecording = recordingStore.isRecording; + const parentNode = parentId ? reactFlowInstance.getNode(parentId) : null; + const isInsideConditional = parentNode?.type === "conditional"; + const isInsideLoop = parentNode?.type === "loop"; + const makeStartSettings = (data: StartNode["data"]): StartSettings => { return { webhookCallbackUrl: data.withWorkflowSettings @@ -410,18 +414,25 @@ function StartNode({ id, data }: NodeProps) { id="a" className="opacity-0" /> -
+
Start -
- 💡 -
- Use{" "} - - {{ current_value }} - {" "} - to get the current loop value for a given iteration. + {isInsideLoop && ( +
+ 💡 +
+ Use{" "} + + {{ current_value }} + {" "} + to get the current loop value for a given iteration. +
-
+ )} + {isInsideConditional && ( +
+ Start adding blocks to be executed for the selected condition +
+ )}
); diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts index 5ad698cd..a29fda40 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/StartNode/types.ts @@ -26,6 +26,7 @@ export type OtherStartNodeData = { editable: boolean; label: "__start_block__"; showCode: boolean; + parentNodeType?: "loop" | "conditional"; }; export type StartNodeData = WorkflowStartNodeData | OtherStartNodeData; diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/WorkflowBlockIcon.tsx b/skyvern-frontend/src/routes/workflows/editor/nodes/WorkflowBlockIcon.tsx index cf338882..f7001d37 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/WorkflowBlockIcon.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/WorkflowBlockIcon.tsx @@ -17,6 +17,7 @@ import { UploadIcon, } from "@radix-ui/react-icons"; import { ExtractIcon } from "@/components/icons/ExtractIcon"; +import { GitBranchIcon } from "@/components/icons/GitBranchIcon"; import { RobotIcon } from "@/components/icons/RobotIcon"; type Props = { @@ -32,6 +33,9 @@ function WorkflowBlockIcon({ workflowBlockType, className }: Props) { case "code": { return ; } + case "conditional": { + return ; + } case "download_to_s3": { return ; } diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts index 719fb615..8b8afca0 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/index.ts @@ -1,6 +1,8 @@ import { memo } from "react"; import { CodeBlockNode as CodeBlockNodeComponent } from "./CodeBlockNode/CodeBlockNode"; import { CodeBlockNode } from "./CodeBlockNode/types"; +import { ConditionalNode as ConditionalNodeComponent } from "./ConditionalNode/ConditionalNode"; +import type { ConditionalNode } from "./ConditionalNode/types"; import { LoopNode as LoopNodeComponent } from "./LoopNode/LoopNode"; import type { LoopNode } from "./LoopNode/types"; import { SendEmailNode as SendEmailNodeComponent } from "./SendEmailNode/SendEmailNode"; @@ -50,6 +52,7 @@ export type UtilityNode = StartNode | NodeAdderNode; export type WorkflowBlockNode = | LoopNode + | ConditionalNode | TaskNode | TextPromptNode | SendEmailNode @@ -83,6 +86,7 @@ export type AppNode = UtilityNode | WorkflowBlockNode; export const nodeTypes = { loop: memo(LoopNodeComponent), + conditional: memo(ConditionalNodeComponent), task: memo(TaskNodeComponent), textPrompt: memo(TextPromptNodeComponent), sendEmail: memo(SendEmailNodeComponent), diff --git a/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts b/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts index 6ff3e663..cc70db4c 100644 --- a/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts +++ b/skyvern-frontend/src/routes/workflows/editor/nodes/types.ts @@ -10,6 +10,14 @@ export type NodeBaseData = { model: WorkflowModel | null; showCode?: boolean; comparisonColor?: string; + /** + * Optional metadata used for conditional branches. + * These values are only set on nodes that live within a conditional block. + */ + conditionalBranchId?: string | null; + conditionalLabel?: string | null; + conditionalNodeId?: string | null; + conditionalMergeLabel?: string | null; }; export const errorMappingExampleValue = { @@ -38,6 +46,7 @@ export const workflowBlockTitle: { } = { action: "Browser Action", code: "Code", + conditional: "Conditional", download_to_s3: "Download", extraction: "Extraction", file_download: "File Download", diff --git a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx index f0fdaaa7..a18da5ba 100644 --- a/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx +++ b/skyvern-frontend/src/routes/workflows/editor/panels/WorkflowNodeLibraryPanel.tsx @@ -11,6 +11,14 @@ import { WorkflowBlockNode } from "../nodes"; import { WorkflowBlockIcon } from "../nodes/WorkflowBlockIcon"; import { AddNodeProps } from "../Workspace"; import { Input } from "@/components/ui/input"; +import { useNodes } from "@xyflow/react"; +import { AppNode } from "../nodes"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; const enableCodeBlock = import.meta.env.VITE_ENABLE_CODE_BLOCK?.toLowerCase() === "true"; @@ -135,6 +143,17 @@ const nodeLibraryItems: Array<{ title: "Text Prompt Block", description: "Process text with LLM", }, + { + nodeType: "conditional", + icon: ( + + ), + title: "Conditional Block", + description: "Branch execution based on conditions", + }, { nodeType: "sendEmail", icon: ( @@ -268,6 +287,7 @@ function WorkflowNodeLibraryPanel({ onNodeClick, first, }: Props) { + const nodes = useNodes() as Array; const workflowPanelData = useWorkflowPanelStore( (state) => state.workflowPanelState.data, ); @@ -280,6 +300,46 @@ function WorkflowNodeLibraryPanel({ const [search, setSearch] = useState(""); const inputRef = useRef(null); + // Determine parent context to check if certain blocks should be disabled + const parentNode = workflowPanelData?.parent + ? nodes.find((n) => n.id === workflowPanelData.parent) + : null; + const parentType = parentNode?.type; + + // Check if a node type should be disabled based on parent context + const isBlockDisabled = ( + nodeType: NonNullable, + ): { disabled: boolean; reason: string } => { + // Disable conditional inside conditional + if (nodeType === "conditional" && parentType === "conditional") { + return { + disabled: true, + reason: + "We're working on supporting nested conditionals. Soon you'll be able to use this feature!", + }; + } + + // Disable conditional inside loop + if (nodeType === "conditional" && parentType === "loop") { + return { + disabled: true, + reason: + "We're working on supporting conditionals inside loops. Soon you'll be able to use this feature!", + }; + } + + // Disable loop inside conditional + if (nodeType === "loop" && parentType === "conditional") { + return { + disabled: true, + reason: + "We're working on supporting loops inside conditionals. Soon you'll be able to use this feature!", + }; + } + + return { disabled: false, reason: "" }; + }; + useEffect(() => { // Focus the input when the panel becomes active if (workflowPanelActive && inputRef.current) { @@ -374,39 +434,64 @@ function WorkflowNodeLibraryPanel({
{filteredItems.length > 0 ? ( - filteredItems.map((item) => ( -
{ - onNodeClick({ - nodeType: item.nodeType, - next: workflowPanelData?.next ?? null, - parent: workflowPanelData?.parent, - previous: workflowPanelData?.previous ?? null, - connectingEdgeType: - workflowPanelData?.connectingEdgeType ?? - "edgeWithAddButton", - }); - closeWorkflowPanel(); - }} - > -
-
- {item.icon} -
-
- - {item.title} - - - {item.description} - + filteredItems.map((item) => { + const { disabled, reason } = isBlockDisabled(item.nodeType); + const itemContent = ( +
{ + if (disabled) return; + onNodeClick({ + nodeType: item.nodeType, + next: workflowPanelData?.next ?? null, + parent: workflowPanelData?.parent, + previous: workflowPanelData?.previous ?? null, + connectingEdgeType: + workflowPanelData?.connectingEdgeType ?? + "edgeWithAddButton", + branch: workflowPanelData?.branchContext, + }); + closeWorkflowPanel(); + }} + > +
+
+ {item.icon} +
+
+ + {item.title} + + + {item.description} + +
+
- -
- )) + ); + + // Wrap with tooltip if disabled + if (disabled) { + return ( + + + {itemContent} + +

{reason}

+
+
+
+ ); + } + + return itemContent; + }) ) : (
No results found diff --git a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts index 259ff1b1..236bb13e 100644 --- a/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts +++ b/skyvern-frontend/src/routes/workflows/editor/workflowEditorUtils.ts @@ -17,11 +17,14 @@ import { type WorkflowApiResponse, type WorkflowBlock, type WorkflowSettings, + type ConditionalBlock, + type ForLoopBlock, } from "../types/workflowTypes"; import { ActionBlockYAML, BlockYAML, CodeBlockYAML, + ConditionalBlockYAML, DownloadToS3BlockYAML, FileUrlParserBlockYAML, ForLoopBlockYAML, @@ -64,6 +67,12 @@ import { isFileParserNode, fileParserNodeDefaultData, } from "./nodes/FileParserNode/types"; +import { + cloneBranchConditions, + conditionalNodeDefaultData, + createDefaultBranchConditions, + ConditionalNode, +} from "./nodes/ConditionalNode/types"; import { isLoopNode, LoopNode, @@ -116,6 +125,11 @@ import { httpRequestNodeDefaultData } from "./nodes/HttpRequestNode/types"; export const NEW_NODE_LABEL_PREFIX = "block_"; +type ConditionalEdgeData = { + conditionalNodeId?: string; + conditionalBranchId?: string; +}; + function layoutUtil( nodes: Array, edges: Array, @@ -154,6 +168,57 @@ export function descendants(nodes: Array, id: string): Array { return children.concat(...children.map((c) => descendants(nodes, c.id))); } +/** + * Updates visibility for a node and all its descendants recursively. + * For nested conditionals, respects their active branch settings. + */ +export function updateNodeAndDescendantsVisibility( + nodes: Array, + nodeId: string, + shouldHide: boolean, +): Array { + const nodeDescendants = descendants(nodes, nodeId); + const descendantIds = new Set([nodeId, ...nodeDescendants.map((n) => n.id)]); + + return nodes.map((node) => { + if (!descendantIds.has(node.id)) { + return node; + } + + // If we're hiding, hide everything + if (shouldHide) { + return { ...node, hidden: true }; + } + + // If we're showing, need to respect nested conditional logic + if (node.id === nodeId) { + return { ...node, hidden: false }; + } + + // For descendants, check if they're in a nested conditional + if (isWorkflowBlockNode(node) && node.data.conditionalNodeId) { + // This node is inside a conditional - find that conditional + const conditionalNode = nodes.find( + (n) => n.id === node.data.conditionalNodeId, + ); + + if (conditionalNode && isWorkflowBlockNode(conditionalNode)) { + const conditionalData = conditionalNode.data as { + activeBranchId?: string | null; + }; + const activeBranchId = conditionalData.activeBranchId; + + // Show only if this node belongs to the active branch + const shouldShow = node.data.conditionalBranchId === activeBranchId; + return { ...node, hidden: !shouldShow }; + } + } + + // Otherwise, show the node + return { ...node, hidden: false }; + }); +} + export function getLoopNodeWidth(node: AppNode, nodes: Array): number { const maxNesting = maxNestingLevel(nodes); const nestingLevel = getNestingLevel(node, nodes); @@ -178,33 +243,118 @@ function layout( nodes: Array, edges: Array, ): { nodes: Array; edges: Array } { - const loopNodes = nodes.filter((node) => node.type === "loop"); + const loopNodes = nodes.filter( + (node) => node.type === "loop" && !node.hidden, + ); const loopNodeChildren: Array> = loopNodes.map(() => []); loopNodes.forEach((node, index) => { - const childNodes = nodes.filter((n) => n.parentId === node.id); - const childEdges = edges.filter((edge) => - childNodes.some( - (node) => node.id === edge.source || node.id === edge.target, - ), + const childNodes = nodes.filter((n) => n.parentId === node.id && !n.hidden); + const childNodeIds = new Set(childNodes.map((child) => child.id)); + const childEdges = edges.filter( + (edge) => + !edge.hidden && + childNodeIds.has(edge.source) && + childNodeIds.has(edge.target), ); const maxChildWidth = Math.max( ...childNodes.map((node) => node.measured?.width ?? 0), ); const loopNodeWidth = getLoopNodeWidth(node, nodes); - const layouted = layoutUtil(childNodes, childEdges, { + // Reset child positions to (0,0) before layout to avoid stale positions + const childNodesWithResetPositions = childNodes.map((n) => ({ + ...n, + position: { x: 0, y: 0 }, + })); + const layouted = layoutUtil(childNodesWithResetPositions, childEdges, { marginx: (loopNodeWidth - maxChildWidth) / 2, marginy: 225, }); loopNodeChildren[index] = layouted.nodes; }); - const topLevelNodes = nodes.filter((node) => !node.parentId); + const conditionalNodes = nodes.filter( + (node) => node.type === "conditional" && !node.hidden, + ); + const conditionalNodeChildren: Array> = conditionalNodes.map( + () => [], + ); - const topLevelNodesLayout = layoutUtil(topLevelNodes, edges); + conditionalNodes.forEach((node, index) => { + const childNodes = nodes.filter((n) => n.parentId === node.id && !n.hidden); + const childNodeIds = new Set(childNodes.map((child) => child.id)); + const childEdges = edges.filter( + (edge) => + !edge.hidden && + childNodeIds.has(edge.source) && + childNodeIds.has(edge.target), + ); + const maxChildWidth = Math.max( + ...childNodes.map((node) => node.measured?.width ?? 0), + ); + const conditionalNodeWidth = getLoopNodeWidth(node, nodes); + + // Reset child positions to (0,0) before layout to avoid stale positions + const childNodesWithResetPositions = childNodes.map((n) => ({ + ...n, + position: { x: 0, y: 0 }, + })); + + const layouted = layoutUtil(childNodesWithResetPositions, childEdges, { + marginx: (conditionalNodeWidth - maxChildWidth) / 2, + marginy: 225, + }); + + conditionalNodeChildren[index] = layouted.nodes; + }); + + const topLevelNodes = nodes.filter((node) => !node.parentId && !node.hidden); + const topLevelNodeIds = new Set(topLevelNodes.map((node) => node.id)); + + const layoutEdges = edges.filter( + (edge) => + !edge.hidden && + topLevelNodeIds.has(edge.source) && + topLevelNodeIds.has(edge.target), + ); + + const syntheticEdges: Array = []; + nodes.forEach((node) => { + if (node.type !== "conditional" || node.hidden) { + return; + } + const mergeTargetId = findConditionalMergeTargetId(node.id, nodes, edges); + if ( + mergeTargetId && + topLevelNodeIds.has(mergeTargetId) && + !nodes.find((n) => n.id === mergeTargetId)?.hidden + ) { + syntheticEdges.push({ + id: `conditional-layout-${node.id}-${mergeTargetId}`, + source: node.id, + target: mergeTargetId, + type: "edgeWithAddButton", + style: { strokeWidth: 0 }, + selectable: false, + }); + } + }); + + const topLevelNodesLayout = layoutUtil( + topLevelNodes, + layoutEdges.concat(syntheticEdges), + ); + + // Collect all hidden nodes to preserve them + const hiddenNodes = nodes.filter((node) => node.hidden); + + const finalNodes = topLevelNodesLayout.nodes + .concat(loopNodeChildren.flat()) + .concat(conditionalNodeChildren.flat()) + .concat(hiddenNodes); return { - nodes: topLevelNodesLayout.nodes.concat(loopNodeChildren.flat()), + nodes: finalNodes, edges, }; } @@ -228,6 +378,28 @@ function convertToNode( model: block.model, }; switch (block.block_type) { + case "conditional": { + const branches = + block.branch_conditions && block.branch_conditions.length > 0 + ? cloneBranchConditions(block.branch_conditions) + : createDefaultBranchConditions(); + const defaultBranch = + branches.find((branch) => branch.is_default) ?? null; + // Prefer the first branch for initial selection to display the first condition + const activeBranchId = branches[0]?.id ?? defaultBranch?.id ?? null; + return { + ...identifiers, + ...common, + type: "conditional", + data: { + ...conditionalNodeDefaultData, + ...commonData, + branches, + activeBranchId, + mergeLabel: block.next_block_label ?? null, + }, + }; + } case "task": { return { ...identifiers, @@ -597,6 +769,49 @@ function convertToNode( } } +function serializeConditionalBlock( + node: ConditionalNode, + nodes: Array, + edges: Array, +): ConditionalBlockYAML { + const mergeLabel = + findConditionalMergeLabel(node, nodes, edges) ?? + node.data.mergeLabel ?? + null; + + const branchConditions = node.data.branches.map((branch) => { + const orderedNodes = getConditionalBranchNodeSequence( + node.id, + branch.id, + nodes, + edges, + ); + const nextBlockLabel = + orderedNodes[0]?.data.label ?? + mergeLabel ?? + branch.next_block_label ?? + null; + + return { + ...branch, + next_block_label: nextBlockLabel, + criteria: branch.criteria + ? { + ...branch.criteria, + } + : null, + }; + }); + + return { + block_type: "conditional", + label: node.data.label, + continue_on_failure: node.data.continueOnFailure, + next_block_label: mergeLabel, + branch_conditions: branchConditions, + }; +} + function generateNodeData(blocks: Array): Array<{ id: string; previous: string | null; @@ -652,7 +867,347 @@ function getNodeData( return data; } -export function defaultEdge(source: string, target: string) { +function buildLabelToBlockMap( + blocks: Array, +): Map { + const map = new Map(); + + const traverse = (list: Array) => { + list.forEach((block) => { + map.set(block.label, block); + if (block.block_type === "for_loop") { + traverse(block.loop_blocks); + } + }); + }; + + traverse(blocks); + return map; +} + +function collectLabelsForBranch( + startLabel: string | null, + stopLabel: string | null, + blocksByLabel: Map, +): Array { + const labels: Array = []; + const visited = new Set(); + let current = startLabel ?? null; + + while (current && current !== stopLabel && !visited.has(current)) { + visited.add(current); + labels.push(current); + const block = blocksByLabel.get(current); + if (!block) { + break; + } + current = block.next_block_label ?? null; + } + + return labels; +} + +/** + * Reconstructs the proper hierarchical structure for conditional blocks from a flat blocks array. + * This is the deserialization counterpart to the edge-based serialization logic. + * + * Process: + * 1. Identifies conditional blocks + * 2. Follows next_block_label chains to determine branch membership + * 3. Sets parentId and conditional metadata for branch nodes + * 4. Creates START and NodeAdder nodes for each conditional + * 5. Creates branch-specific edges based on next_block_label + */ +function reconstructConditionalStructure( + blocks: Array, + nodes: Array, + labelToNodeMap: Map, + blocksByLabel: Map, +): { nodes: Array; edges: Array } { + const newNodes = [...nodes]; + const newEdges: Array = []; + const conditionalStartNodeIds = new Map(); + const conditionalAdderNodeIds = new Map(); + + // Initialize all workflow block nodes with null conditional metadata + newNodes.forEach((node) => { + if (isWorkflowBlockNode(node)) { + node.data.conditionalBranchId = node.data.conditionalBranchId ?? null; + node.data.conditionalLabel = node.data.conditionalLabel ?? null; + node.data.conditionalNodeId = node.data.conditionalNodeId ?? null; + node.data.conditionalMergeLabel = node.data.conditionalMergeLabel ?? null; + } + }); + + // Process each conditional block + blocks.forEach((block) => { + if (block.block_type !== "conditional") { + if (block.block_type === "for_loop") { + // Recursively handle conditionals inside loops + reconstructConditionalStructure( + block.loop_blocks, + newNodes, + labelToNodeMap, + blocksByLabel, + ); + } + return; + } + + const conditionalNode = labelToNodeMap.get(block.label); + if (!conditionalNode) { + return; + } + + // Create START and NodeAdder nodes for this conditional + const startNodeId = nanoid(); + const adderNodeId = nanoid(); + + newNodes.push( + startNode( + startNodeId, + { + withWorkflowSettings: false, + editable: true, + label: "__start_block__", + showCode: false, + parentNodeType: "conditional", + }, + conditionalNode.id, + ), + ); + + newNodes.push(nodeAdderNode(adderNodeId, conditionalNode.id)); + + conditionalStartNodeIds.set(conditionalNode.id, startNodeId); + conditionalAdderNodeIds.set(conditionalNode.id, adderNodeId); + + // Process each branch + block.branch_conditions.forEach((branch) => { + // Collect all block labels in this branch by following next_block_label chain + const labels = collectLabelsForBranch( + branch.next_block_label, + block.next_block_label ?? null, + blocksByLabel, + ); + + // Set metadata and parentId for all nodes in this branch + labels.forEach((label) => { + const targetNode = labelToNodeMap.get(label); + if (targetNode && isWorkflowBlockNode(targetNode)) { + targetNode.data = { + ...targetNode.data, + conditionalBranchId: branch.id, + conditionalLabel: block.label, + conditionalNodeId: conditionalNode.id, + conditionalMergeLabel: block.next_block_label ?? null, + }; + targetNode.parentId = conditionalNode.id; + } + }); + + // Create edges for this branch + if (labels.length === 0) { + // Empty branch: START → NodeAdder + newEdges.push({ + id: nanoid(), + type: "default", + source: startNodeId, + target: adderNodeId, + style: { strokeWidth: 2 }, + data: { + conditionalNodeId: conditionalNode.id, + conditionalBranchId: branch.id, + }, + }); + } else { + // Branch with blocks + const branchNodeIds = labels + .map((label) => labelToNodeMap.get(label)?.id) + .filter(Boolean) as string[]; + + // START → first block + if (branchNodeIds[0]) { + newEdges.push({ + id: nanoid(), + type: "edgeWithAddButton", + source: startNodeId, + target: branchNodeIds[0], + style: { strokeWidth: 2 }, + data: { + conditionalNodeId: conditionalNode.id, + conditionalBranchId: branch.id, + }, + }); + } + + // Chain blocks together based on next_block_label + for (let i = 0; i < labels.length - 1; i++) { + const currentLabel = labels[i]; + const nextLabel = labels[i + 1]; + const currentNodeId = labelToNodeMap.get(currentLabel!)?.id; + const nextNodeId = labelToNodeMap.get(nextLabel!)?.id; + + if (currentNodeId && nextNodeId) { + newEdges.push({ + id: nanoid(), + type: "edgeWithAddButton", + source: currentNodeId, + target: nextNodeId, + style: { strokeWidth: 2 }, + data: { + conditionalNodeId: conditionalNode.id, + conditionalBranchId: branch.id, + }, + }); + } + } + + // Last block → NodeAdder + const lastNodeId = branchNodeIds[branchNodeIds.length - 1]; + if (lastNodeId) { + newEdges.push({ + id: nanoid(), + type: "default", + source: lastNodeId, + target: adderNodeId, + style: { strokeWidth: 2 }, + data: { + conditionalNodeId: conditionalNode.id, + conditionalBranchId: branch.id, + }, + }); + } + } + }); + }); + + return { nodes: newNodes, edges: newEdges }; +} + +export function getConditionalBranchNodeSequence( + conditionalNodeId: string, + branchId: string, + nodes: Array, + edges: Array, +): Array { + const branchNodes = nodes.filter( + (node): node is WorkflowBlockNode => + isWorkflowBlockNode(node) && + node.data.conditionalNodeId === conditionalNodeId && + node.data.conditionalBranchId === branchId, + ); + + if (branchNodes.length === 0) { + return []; + } + + const nodeById = new Map(branchNodes.map((node) => [node.id, node])); + const branchNodeIds = new Set(nodeById.keys()); + + const heads = branchNodes.filter((node) => { + const incoming = edges.filter((edge) => edge.target === node.id); + return !incoming.some((edge) => branchNodeIds.has(edge.source)); + }); + + const startNode = heads[0] ?? branchNodes[0]!; + const ordered: Array = []; + const visited = new Set(); + let current: WorkflowBlockNode | undefined = startNode; + + while (current && !visited.has(current.id)) { + ordered.push(current); + visited.add(current.id); + const nextEdge = edges.find((edge) => edge.source === current!.id); + if (!nextEdge || !branchNodeIds.has(nextEdge.target)) { + break; + } + current = nodeById.get(nextEdge.target); + } + + return ordered; +} + +function getConditionalBranchNodeIds( + conditionalNodeId: string, + nodes: Array, +): Set { + return new Set( + nodes + .filter( + (node) => + isWorkflowBlockNode(node) && + !node.hidden && + node.data.conditionalNodeId === conditionalNodeId && + Boolean(node.data.conditionalBranchId), + ) + .map((node) => node.id), + ); +} + +function findConditionalMergeTargetId( + conditionalNodeId: string, + nodes: Array, + edges: Array, +): string | null { + const branchNodeIds = getConditionalBranchNodeIds(conditionalNodeId, nodes); + const visited = new Set(); + let currentSource = conditionalNodeId; + const maxIterations = 1000; + let iterations = 0; + // Use ALL edges when finding merge target, not just visible ones + // We need to consider all branches when serializing + const allEdges = edges; + + while (iterations < maxIterations) { + iterations++; + const nextEdge = allEdges.find((edge) => edge.source === currentSource); + if (!nextEdge) { + return null; + } + if (visited.has(nextEdge.target)) { + return null; + } + visited.add(nextEdge.target); + if (branchNodeIds.has(nextEdge.target)) { + currentSource = nextEdge.target; + continue; + } + const targetNode = nodes.find((node) => node.id === nextEdge.target); + // Don't filter by hidden when serializing - we need all nodes + if (!targetNode) { + return null; + } + if (targetNode.type === "nodeAdder" || targetNode.type === "start") { + currentSource = targetNode.id; + continue; + } + return targetNode.id; + } + + return null; +} + +function findConditionalMergeLabel( + conditionalNode: ConditionalNode, + nodes: Array, + edges: Array, +): string | null { + const mergeTargetId = findConditionalMergeTargetId( + conditionalNode.id, + nodes, + edges, + ); + if (!mergeTargetId) { + return null; + } + const targetNode = nodes.find( + (node) => node.id === mergeTargetId && isWorkflowBlockNode(node), + ) as WorkflowBlockNode | undefined; + return targetNode?.data.label ?? null; +} + +export function defaultEdge(source: string, target: string): Edge { return { id: nanoid(), type: "default", @@ -661,10 +1216,10 @@ export function defaultEdge(source: string, target: string) { style: { strokeWidth: 2, }, - }; + } as Edge; } -export function edgeWithAddButton(source: string, target: string) { +export function edgeWithAddButton(source: string, target: string): Edge { return { id: nanoid(), type: "edgeWithAddButton", @@ -674,7 +1229,7 @@ export function edgeWithAddButton(source: string, target: string) { strokeWidth: 2, }, zIndex: REACT_FLOW_EDGE_Z_INDEX, - }; + } as Edge; } export function startNode( @@ -722,6 +1277,7 @@ function getElements( const data = generateNodeData(blocks); const nodes: Array = []; const edges: Array = []; + const blocksByLabel = buildLabelToBlockMap(blocks); const startNodeId = nanoid(); nodes.push( @@ -744,7 +1300,10 @@ function getElements( }), ); - data.forEach((d, index) => { + const labelToNode = new Map(); + + // Create all nodes first (without edges) + data.forEach((d) => { const node = convertToNode( { id: d.id, @@ -754,11 +1313,8 @@ function getElements( editable, ); nodes.push(node); - if (d.previous) { - edges.push(edgeWithAddButton(d.previous, d.id)); - } - if (index === 0) { - edges.push(edgeWithAddButton(startNodeId, d.id)); + if (isWorkflowBlockNode(node)) { + labelToNode.set(node.data.label, node); } }); @@ -784,6 +1340,19 @@ function getElements( } else { const firstChild = children.find((c) => c.previous === null)!; edges.push(edgeWithAddButton(startNodeId, firstChild.id)); + + // Chain loop children based on their next pointers + const childById = new Map(); + children.forEach((c) => childById.set(c.id, c)); + let current = firstChild; + while (current?.next) { + const nextChild = childById.get(current.next); + if (!nextChild) { + break; + } + edges.push(edgeWithAddButton(current.id, nextChild.id)); + current = nextChild; + } } const lastChild = children.find((c) => c.next === null); nodes.push(nodeAdderNode(adderNodeId, block.id)); @@ -792,17 +1361,162 @@ function getElements( } }); - const adderNodeId = nanoid(); + // Reconstruct conditional hierarchy and create conditional edges + const conditionalResult = reconstructConditionalStructure( + blocks, + nodes, + labelToNode, + blocksByLabel, + ); + nodes.length = 0; + nodes.push(...conditionalResult.nodes); + edges.push(...conditionalResult.edges); - if (data.length === 0) { - nodes.push(nodeAdderNode(adderNodeId)); + // Create top-level edges based on next_block_label (not array order!) + // We'll filter out conditional branch blocks below by checking conditionalNodeId + blocks.forEach((block) => { + const sourceNode = labelToNode.get(block.label); + if (!sourceNode || !isWorkflowBlockNode(sourceNode)) { + return; + } + + // Skip if this block is inside a conditional branch (edges already created above) + if (sourceNode.data.conditionalNodeId) { + return; + } + + // Find target block using next_block_label + const nextLabel = block.next_block_label; + if (nextLabel) { + const targetNode = labelToNode.get(nextLabel); + if (targetNode) { + edges.push(edgeWithAddButton(sourceNode.id, targetNode.id)); + } + } + }); + + // Connect workflow START to first top-level block + if (blocks.length > 0) { + const firstBlock = blocks[0]; + const firstNode = labelToNode.get(firstBlock!.label); + if (firstNode) { + edges.push(edgeWithAddButton(startNodeId, firstNode.id)); + } + } + + // Create final NodeAdder at the end of the workflow + const adderNodeId = nanoid(); + nodes.push(nodeAdderNode(adderNodeId)); + + if (blocks.length === 0) { edges.push(defaultEdge(startNodeId, adderNodeId)); } else { - const lastNode = data.find((d) => d.next === null && d.parentId === null)!; - edges.push(defaultEdge(lastNode.id, adderNodeId)); - nodes.push(nodeAdderNode(adderNodeId)); + // Find the last top-level block (one with next_block_label === null and not in a branch) + // There might be multiple blocks with next_block_label === null (e.g., last block in nested branches) + // We need the one that's NOT inside any conditional + const lastBlock = blocks.find((block) => { + if (block.next_block_label !== null) { + return false; + } + const node = labelToNode.get(block.label); + return node && isWorkflowBlockNode(node) && !node.data.conditionalNodeId; + }); + + if (lastBlock) { + const lastNode = labelToNode.get(lastBlock.label); + if (lastNode) { + edges.push(defaultEdge(lastNode.id, adderNodeId)); + } + } } + // Determine the initial active branch for each conditional node (default branch if available) + const conditionalBlocks = blocks.filter( + (b) => b.block_type === "conditional", + ); + const conditionalNodeToActiveBranch = new Map(); + conditionalBlocks.forEach((block) => { + const conditionalBlock = block as ConditionalBlock; + const conditionalNode = labelToNode.get(block.label); + if (!conditionalNode) { + return; + } + const defaultBranch = + conditionalBlock.branch_conditions.find((branch) => branch.is_default) ?? + null; + // Prefer the first branch for initial selection to align with UI expectations + const activeBranch = + conditionalBlock.branch_conditions[0]?.id ?? defaultBranch?.id ?? null; + if (activeBranch) { + conditionalNodeToActiveBranch.set(conditionalNode.id, activeBranch); + } + }); + + // Hide branch nodes that are not part of the active branch + nodes.forEach((node) => { + if (!isWorkflowBlockNode(node)) { + return; + } + const conditionalNodeId = node.data.conditionalNodeId; + const branchId = node.data.conditionalBranchId; + if (!conditionalNodeId || !branchId) { + return; + } + + const activeBranchId = conditionalNodeToActiveBranch.get(conditionalNodeId); + node.hidden = Boolean( + activeBranchId && branchId !== activeBranchId && branchId !== null, + ); + }); + + // Cascade visibility to descendants (for nested conditionals) + // Collect all nodes that had their visibility set + const nodesWithVisibilitySet = nodes.filter( + (node) => + isWorkflowBlockNode(node) && + node.data.conditionalNodeId && + node.data.conditionalBranchId, + ); + + nodesWithVisibilitySet.forEach((node) => { + if (node.hidden) { + // Cascade hide to all descendants + const allNodes = updateNodeAndDescendantsVisibility(nodes, node.id, true); + // Update nodes array with cascaded visibility + allNodes.forEach((updatedNode) => { + const index = nodes.findIndex((n) => n.id === updatedNode.id); + if (index !== -1) { + nodes[index] = updatedNode; + } + }); + } + }); + + const hiddenNodeIds = new Set( + nodes.filter((node) => node.hidden).map((node) => node.id), + ); + + edges.forEach((edge) => { + const edgeData = edge.data as ConditionalEdgeData | undefined; + const conditionalNodeId = edgeData?.conditionalNodeId; + const conditionalBranchId = edgeData?.conditionalBranchId; + const activeBranchId = conditionalNodeId + ? conditionalNodeToActiveBranch.get(conditionalNodeId) + : null; + const branchHidden = + Boolean( + conditionalNodeId && + conditionalBranchId && + activeBranchId && + conditionalBranchId !== activeBranchId, + ) ?? false; + + const nodeHidden = + hiddenNodeIds.has(edge.source) || hiddenNodeIds.has(edge.target); + + edge.hidden = branchHidden || nodeHidden; + }); + return { nodes, edges }; } @@ -1047,6 +1761,20 @@ function createNode( }, }; } + case "conditional": { + const branches = createDefaultBranchConditions(); + return { + ...identifiers, + ...common, + type: "conditional", + data: { + ...conditionalNodeDefaultData, + label, + branches, + activeBranchId: branches[0]?.id ?? null, + }, + }; + } } } @@ -1071,12 +1799,101 @@ function JSONSafeOrString( } } -function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML { +function findNextBlockLabel( + nodeId: string, + nodes: Array, + edges: Array, +): string | null { + const currentNode = nodes.find((n) => n.id === nodeId); + + // Helper: get conditional node's merge label + const getConditionalMergeLabel = (): string | null => { + if (!currentNode || !isWorkflowBlockNode(currentNode)) { + return null; + } + + const conditionalNodeId = currentNode.data.conditionalNodeId; + if (!conditionalNodeId) { + return null; + } + + // Find the conditional node itself + const conditionalNode = nodes.find((n) => n.id === conditionalNodeId); + if (!conditionalNode || !isWorkflowBlockNode(conditionalNode)) { + return null; + } + + // Use the conditional node's next_block_label (computed from edges) + return findNextBlockLabel(conditionalNodeId, nodes, edges); + }; + + // Find the outgoing edge from this node + const outgoingEdge = edges.find((edge) => edge.source === nodeId); + + if (!outgoingEdge) { + // No outgoing edge - check if this node is inside a conditional branch + // If so, it should merge to the conditional's merge point + return getConditionalMergeLabel(); + } + + // Follow edges until we find a workflow block (skip NodeAdder, Start nodes) + let currentTargetId = outgoingEdge.target; + const visited = new Set(); + const maxIterations = 100; // Prevent infinite loops + let iterations = 0; + + while (currentTargetId && iterations < maxIterations) { + if (visited.has(currentTargetId)) { + // Cycle detected + return null; + } + visited.add(currentTargetId); + iterations++; + + const targetNode = nodes.find((n) => n.id === currentTargetId); + + if (!targetNode) { + return null; + } + + // If we found a workflow block node, return its label + if (isWorkflowBlockNode(targetNode)) { + return targetNode.data.label; + } + + // If it's a utility node (NodeAdder, Start), keep following edges + if (targetNode.type === "nodeAdder" || targetNode.type === "start") { + const nextEdge = edges.find((edge) => edge.source === currentTargetId); + if (!nextEdge) { + // Reached end of edges at a utility node + // If the original node is inside a conditional branch, look up the conditional's merge point + return getConditionalMergeLabel(); + } + currentTargetId = nextEdge.target; + continue; + } + + // Unknown node type + return null; + } + + return null; +} + +function getWorkflowBlock( + node: WorkflowBlockNode, + nodes: Array, + edges: Array, +): BlockYAML { + // Compute next_block_label from edges/graph structure + const nextBlockLabel = findNextBlockLabel(node.id, nodes, edges); + const base = { label: node.data.label, continue_on_failure: node.data.continueOnFailure, next_loop_on_failure: node.data.nextLoopOnFailure, model: node.data.model, + next_block_label: nextBlockLabel, }; switch (node.type) { case "task": { @@ -1381,6 +2198,9 @@ function getWorkflowBlock(node: WorkflowBlockNode): BlockYAML { parameter_keys: node.data.parameterKeys, }; } + case "conditional": { + return serializeConditionalBlock(node as ConditionalNode, nodes, edges); + } default: { throw new Error( `Invalid node type, '${node.type}', for getWorkflowBlock`, @@ -1431,7 +2251,7 @@ function getOrderedChildrenBlocks( complete_if_empty: currentNode.data.completeIfEmpty, }); } else { - children.push(getWorkflowBlock(currentNode)); + children.push(getWorkflowBlock(currentNode, nodes, edges)); } const nextId = edges.find( (edge) => edge.source === currentNode?.id, @@ -1447,23 +2267,39 @@ function getWorkflowBlocksUtil( edges: Array, ): Array { return nodes.flatMap((node) => { - if (node.parentId || node.type === "start" || node.type === "nodeAdder") { + // Skip utility nodes + if (node.type === "start" || node.type === "nodeAdder") { return []; } + + // Check if this node is inside a conditional branch + const isConditionalBranchNode = + isWorkflowBlockNode(node) && node.data.conditionalNodeId; + + // Skip nodes with parentId UNLESS they're in a conditional branch + // (loop children should be filtered out, conditional branch children should stay) + if (node.parentId && !isConditionalBranchNode) { + return []; + } + if (node.type === "loop") { + // Compute next_block_label for the loop block itself + const nextBlockLabel = findNextBlockLabel(node.id, nodes, edges); + return [ { block_type: "for_loop", label: node.data.label, continue_on_failure: node.data.continueOnFailure, next_loop_on_failure: node.data.nextLoopOnFailure, + next_block_label: nextBlockLabel, loop_blocks: getOrderedChildrenBlocks(nodes, edges, node.id), loop_variable_reference: node.data.loopVariableReference, complete_if_empty: node.data.completeIfEmpty, }, ]; } - return [getWorkflowBlock(node as Exclude)]; + return [getWorkflowBlock(node as WorkflowBlockNode, nodes, edges)]; }); } @@ -1917,22 +2753,31 @@ function clone(objectToClone: T): T { return JSON.parse(JSON.stringify(objectToClone)); } -function assignSequentialNextBlockLabels(blocks: Array): void { +export function upgradeWorkflowBlocksV1toV2( + blocks: Array, +): Array { if (!blocks || blocks.length === 0) { - return; + return blocks; } - for (let index = 0; index < blocks.length; index++) { - const block = blocks[index]!; - const nextBlock = - index < blocks.length - 1 ? blocks[index + 1]! : undefined; - block.next_block_label = nextBlock ? nextBlock.label : null; + return blocks.map((block, index) => { + const nextBlock = blocks[index + 1]; + const upgradedBlock = { + ...block, + next_block_label: nextBlock?.label ?? null, + }; + // Recursively handle loop blocks if (block.block_type === "for_loop") { - const loopBlock = block as ForLoopBlockYAML; - assignSequentialNextBlockLabels(loopBlock.loop_blocks); + const loopBlock = block as ForLoopBlock; + return { + ...upgradedBlock, + loop_blocks: upgradeWorkflowBlocksV1toV2(loopBlock.loop_blocks), + } as WorkflowBlock; } - } + + return upgradedBlock; + }); } export function upgradeWorkflowDefinitionToVersionTwo( @@ -1942,12 +2787,11 @@ export function upgradeWorkflowDefinitionToVersionTwo( const clonedBlocks = clone(blocks); const baseVersion = currentVersion ?? 1; - if (baseVersion <= 2) { - assignSequentialNextBlockLabels(clonedBlocks); - return { blocks: clonedBlocks, version: 2 }; - } + // Just ensure version is at least 2 + // next_block_label values are already correctly computed by getWorkflowBlocks from the graph + const targetVersion = baseVersion >= 2 ? baseVersion : 2; - return { blocks: clonedBlocks, version: baseVersion }; + return { blocks: clonedBlocks, version: targetVersion }; } function convertBlocksToBlockYAML( @@ -2011,6 +2855,21 @@ function convertBlocksToBlockYAML( }; return blockYaml; } + case "conditional": { + const blockYaml: ConditionalBlockYAML = { + ...base, + block_type: "conditional", + branch_conditions: block.branch_conditions.map((condition) => ({ + ...condition, + criteria: condition.criteria + ? { + ...condition.criteria, + } + : null, + })), + }; + return blockYaml; + } case "human_interaction": { const blockYaml: HumanInteractionBlockYAML = { ...base, @@ -2377,6 +3236,22 @@ function getWorkflowErrors(nodes: Array): Array { } }); + const conditionalNodes = nodes.filter((node) => node.type === "conditional"); + conditionalNodes.forEach((node) => { + const branches = (node as ConditionalNode).data.branches ?? []; + branches.forEach((branch, index) => { + if (branch.is_default) { + return; + } + const expression = branch.criteria?.expression ?? ""; + if (!expression.trim()) { + errors.push( + `${(node as ConditionalNode).data.label}: Expression is required for branch ${index + 1}.`, + ); + } + }); + }); + const extractionNodes = nodes.filter(isExtractionNode); extractionNodes.forEach((node) => { if (node.data.dataExtractionGoal.length === 0) { diff --git a/skyvern-frontend/src/routes/workflows/types/workflowRunTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowRunTypes.ts index efbcfe90..2ba020e8 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowRunTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowRunTypes.ts @@ -48,6 +48,10 @@ export type WorkflowRunBlock = { body?: string | null; prompt?: string | null; wait_sec?: number | null; + executed_branch_id?: string | null; + executed_branch_expression?: string | null; + executed_branch_result?: boolean | null; + executed_branch_next_block?: string | null; created_at: string; modified_at: string; duration: number | null; diff --git a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts index 676576ce..3bc6112b 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowTypes.ts @@ -192,6 +192,7 @@ export type Parameter = export type WorkflowBlock = | TaskBlock | ForLoopBlock + | ConditionalBlock | TextPromptBlock | CodeBlock | UploadToS3Block @@ -215,6 +216,7 @@ export type WorkflowBlock = export const WorkflowBlockTypes = { Task: "task", ForLoop: "for_loop", + Conditional: "conditional", Code: "code", TextPrompt: "text_prompt", DownloadToS3: "download_to_s3", @@ -292,6 +294,32 @@ export type WorkflowBlockBase = { next_block_label?: string | null; }; +export const BranchCriteriaTypes = { + Jinja2Template: "jinja2_template", +} as const; + +export type BranchCriteriaType = + (typeof BranchCriteriaTypes)[keyof typeof BranchCriteriaTypes]; + +export type BranchCriteria = { + criteria_type: BranchCriteriaType; + expression: string; + description: string | null; +}; + +export type BranchCondition = { + id: string; + criteria: BranchCriteria | null; + next_block_label: string | null; + description: string | null; + is_default: boolean; +}; + +export type ConditionalBlock = WorkflowBlockBase & { + block_type: "conditional"; + branch_conditions: Array; +}; + export type TaskBlock = WorkflowBlockBase & { block_type: "task"; url: string | null; diff --git a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts index feba5cd3..4db4cbc8 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts @@ -128,6 +128,7 @@ export type BlockYAML = | SendEmailBlockYAML | FileUrlParserBlockYAML | ForLoopBlockYAML + | ConditionalBlockYAML | ValidationBlockYAML | HumanInteractionBlockYAML | ActionBlockYAML @@ -359,6 +360,25 @@ export type ForLoopBlockYAML = BlockYAMLBase & { complete_if_empty: boolean; }; +export type BranchCriteriaYAML = { + criteria_type: string; + expression: string; + description?: string | null; +}; + +export type BranchConditionYAML = { + id: string; + criteria: BranchCriteriaYAML | null; + next_block_label: string | null; + description?: string | null; + is_default: boolean; +}; + +export type ConditionalBlockYAML = BlockYAMLBase & { + block_type: "conditional"; + branch_conditions: Array; +}; + export type PDFParserBlockYAML = BlockYAMLBase & { block_type: "pdf_parser"; file_url: string; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx index 57e8068c..36e4cceb 100644 --- a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunTimelineBlockItem.tsx @@ -171,6 +171,33 @@ function WorkflowRunTimelineBlockItem({ {block.description ? (
{block.description}
) : null} + {block.block_type === "conditional" && block.executed_branch_id && ( +
+ {block.executed_branch_expression !== null && + block.executed_branch_expression !== undefined ? ( +
+ Condition{" "} + + {block.executed_branch_expression} + {" "} + evaluated to{" "} + True +
+ ) : ( +
+ No conditions matched, executing default branch +
+ )} + {block.executed_branch_next_block && ( +
+ → Executing next block:{" "} + + {block.executed_branch_next_block} + +
+ )} +
+ )}
{block.block_type === "human_interaction" && ( diff --git a/skyvern-frontend/src/store/WorkflowPanelStore.ts b/skyvern-frontend/src/store/WorkflowPanelStore.ts index b966529d..10e5dec1 100644 --- a/skyvern-frontend/src/store/WorkflowPanelStore.ts +++ b/skyvern-frontend/src/store/WorkflowPanelStore.ts @@ -1,6 +1,13 @@ import { create } from "zustand"; import { WorkflowVersion } from "@/routes/workflows/hooks/useWorkflowVersionsQuery"; +export type BranchContext = { + conditionalNodeId: string; + conditionalLabel: string; + branchId: string; + mergeLabel: string | null; +}; + type WorkflowPanelState = { active: boolean; content: @@ -15,6 +22,7 @@ type WorkflowPanelState = { parent?: string; connectingEdgeType?: string; disableLoop?: boolean; + branchContext?: BranchContext; // For comparison panel version1?: WorkflowVersion; version2?: WorkflowVersion;