+
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;