Conditional Block (#4224)

This commit is contained in:
Celal Zamanoglu
2025-12-08 20:38:21 +03:00
committed by GitHub
parent e2242847bf
commit 464b72aeb1
21 changed files with 2303 additions and 129 deletions

View File

@@ -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 (
<div className="relative flex h-screen w-full">

View File

@@ -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" ||

View File

@@ -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 (
<div className="relative flex h-screen w-full">

View File

@@ -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<HTMLInputElement>(null),
};
// Track all used labels globally (including those in saved branch states)
// Initialize with labels from initial nodes
const usedLabelsRef = useRef<Set<string>>(
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<AppNode>, edges: Array<Edge>) {
const layoutedElements = layout(nodes, edges);
setNodes(layoutedElements.nodes);
setEdges(layoutedElements.edges);
}
const doLayout = useCallback(
(nodes: Array<AppNode>, edges: Array<Edge>) => {
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<AppNode>;
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<AppNode> = [];
const newEdges: Array<Edge> = [];
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);

View File

@@ -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 = () => {

View File

@@ -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<ConditionalNode>) {
const nodes = useNodes<AppNode>();
const { setNodes, setEdges } = useReactFlow();
const node = nodes.find((n) => n.id === id);
const update = useUpdate<ConditionalNodeData>({
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<AppNode> = [];
// Toggle node visibility with cascading to descendants
setNodes((currentNodes) => {
let updatedNodes = currentNodes as Array<AppNode>;
// 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<AppNode>).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 (
<div className="relative">
<Handle
type="target"
position={Position.Top}
id={`${id}-target`}
className="opacity-0"
/>
<div
className="rounded-xl border-2 border-dashed border-slate-600 p-2"
style={{
width: conditionalNodeWidth,
height: childrenHeightExtent,
}}
>
<div className="flex w-full justify-center">
<div
className={cn(
"w-[30rem] space-y-4 rounded-lg bg-slate-elevation3 px-6 py-4 transition-all",
data.comparisonColor,
)}
>
<NodeHeader
blockLabel={data.label}
editable={data.editable}
nodeId={id}
totpIdentifier={null}
totpUrl={null}
type="conditional"
/>
<div className="space-y-2">
<div className="flex items-center gap-2 overflow-x-auto">
<div className="flex items-center gap-2">
{(() => {
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<BranchCondition>;
let overflowBranches: Array<BranchCondition> = [];
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 (
<div key={branch.id} className="relative flex">
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
"h-auto rounded-full border p-0 text-xs font-normal transition-colors hover:bg-transparent",
showMenu ? "px-3 py-1 pr-7" : "px-3 py-1",
{
"border-slate-50 bg-slate-50 text-slate-950 hover:bg-slate-50 hover:text-slate-950":
branch.id === activeBranch?.id,
"border-transparent bg-slate-elevation5 text-slate-300 hover:bg-slate-elevation4 hover:text-slate-300":
branch.id !== activeBranch?.id,
},
)}
onClick={() => handleSelectBranch(branch.id)}
disabled={!data.editable}
>
{getConditionLabel(branch, index)}
</Button>
{showMenu && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className={cn(
"absolute right-1 top-1/2 size-auto -translate-y-1/2 rounded-full p-0.5",
{
"text-slate-950 hover:bg-slate-300 hover:text-slate-950":
branch.id === activeBranch?.id,
"text-slate-300 hover:bg-slate-600 hover:text-slate-300":
branch.id !== activeBranch?.id,
},
)}
onClick={(e) => e.stopPropagation()}
title="Branch options"
>
<DotsVerticalIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canReorder && (
<>
<DropdownMenuItem
disabled={!canMoveUp}
onClick={(e) => {
e.stopPropagation();
handleMoveBranchUp(branch.id);
}}
className="cursor-pointer"
>
Move Up
</DropdownMenuItem>
<DropdownMenuItem
disabled={!canMoveDown}
onClick={(e) => {
e.stopPropagation();
handleMoveBranchDown(branch.id);
}}
className="cursor-pointer"
>
Move Down
</DropdownMenuItem>
</>
)}
{canReorder && canDelete && (
<DropdownMenuSeparator />
)}
{canDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
handleRemoveBranch(branch.id);
}}
className="cursor-pointer text-red-400 focus:text-red-400"
>
Remove
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
})}
{overflowBranches.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
size="sm"
className="h-auto gap-1 rounded-full border border-transparent bg-slate-elevation5 p-0 px-3 py-1 text-xs font-normal text-slate-300 transition-colors hover:bg-slate-elevation4 hover:text-slate-300"
disabled={!data.editable}
>
{overflowBranches.length} More
<ChevronDownIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{overflowBranches.map((branch) => {
const index = orderedBranches.findIndex(
(b) => b.id === branch.id,
);
return (
<DropdownMenuItem
key={branch.id}
onClick={() =>
handleSelectBranch(branch.id)
}
className="cursor-pointer"
>
{getConditionLabel(branch, index)}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
{/* Add new condition button */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleAddCondition}
disabled={!data.editable}
className="size-7 rounded-full border border-transparent bg-slate-elevation5 p-0 text-slate-300 hover:bg-slate-elevation4 hover:text-slate-300"
title="Add new condition"
>
<PlusIcon className="size-4" />
</Button>
</>
);
})()}
</div>
</div>
{activeBranch && (
<div className="space-y-2">
<div className="flex items-center gap-1">
<Label className="text-xs text-slate-300">
{activeBranch.is_default ? "Else branch" : "Expression"}
</Label>
{!activeBranch.is_default && (
<HelpTooltip
content={`Jinja: {{ y > 100 }}
Natural language: y is greater than 100`}
/>
)}
</div>
<Input
value={
activeBranch.is_default
? "Executed when no other condition matches"
: activeBranch.criteria?.expression ?? ""
}
disabled={!data.editable || activeBranch.is_default}
onChange={(event) => {
handleExpressionChange(event.target.value);
}}
placeholder="Enter condition to evaluate (Jinja or natural language)"
/>
</div>
)}
</div>
</div>
</div>
</div>
<Handle
type="source"
position={Position.Bottom}
id={`${id}-source`}
className="opacity-0"
/>
</div>
);
}
export { ConditionalNodeComponent as ConditionalNode };

View File

@@ -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<BranchCondition>;
activeBranchId: string | null;
mergeLabel: string | null;
};
export type ConditionalNode = Node<ConditionalNodeData, "conditional">;
export const defaultBranchCriteria: BranchCriteria = {
criteria_type: BranchCriteriaTypes.Jinja2Template,
expression: "",
description: null,
};
export function createBranchCondition(
overrides: Partial<BranchCondition> = {},
): 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<BranchCondition> = [
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<BranchCondition>,
): Array<BranchCondition> {
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<BranchCondition> {
return [createBranchCondition(), createBranchCondition({ is_default: true })];
}

View File

@@ -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<NodeAdderNode>) {
const edges = useEdges();
const nodes = useNodes();
const debugStore = useDebugStore();
const recordingStore = useRecordingStore();
const settingsStore = useSettingsStore();
@@ -26,13 +31,89 @@ function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
(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<NodeAdderNode>) {
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<NodeAdderNode>) {
if (isDisabled) {
return;
}
updateWorkflowPanelState(true);
const branchContext = deriveBranchContext(previous);
updateWorkflowPanelState(true, branchContext);
};
const onRecord = () => {

View File

@@ -52,7 +52,7 @@ interface StartSettings {
extraHttpHeaders: string | Record<string, unknown> | null;
}
function StartNode({ id, data }: NodeProps<StartNode>) {
function StartNode({ id, data, parentId }: NodeProps<StartNode>) {
const workflowSettingsStore = useWorkflowSettingsStore();
const reactFlowInstance = useReactFlow();
const [facing, setFacing] = useState<"front" | "back">("front");
@@ -63,6 +63,10 @@ function StartNode({ id, data }: NodeProps<StartNode>) {
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<StartNode>) {
id="a"
className="opacity-0"
/>
<div className="w-[30rem] rounded-lg bg-slate-elevation3 px-6 py-4 text-center">
<div className="w-[30rem] rounded-lg bg-slate-elevation4 px-6 py-4 text-center text-xs font-semibold uppercase tracking-[0.2em] text-slate-400">
Start
<div className="mt-4 flex gap-3 rounded-md bg-slate-800 p-3">
<span className="rounded bg-slate-700 p-1 text-lg">💡</span>
<div className="space-y-1 text-left text-xs text-slate-400">
Use{" "}
<code className="text-white">
&#123;&#123;&nbsp;current_value&nbsp;&#125;&#125;
</code>{" "}
to get the current loop value for a given iteration.
{isInsideLoop && (
<div className="mt-4 flex gap-3 rounded-md bg-slate-800 p-3 normal-case tracking-normal">
<span className="rounded bg-slate-700 p-1 text-lg">💡</span>
<div className="space-y-1 text-left font-normal text-slate-400">
Use{" "}
<code className="text-white">
&#123;&#123;&nbsp;current_value&nbsp;&#125;&#125;
</code>{" "}
to get the current loop value for a given iteration.
</div>
</div>
</div>
)}
{isInsideConditional && (
<div className="mt-4 rounded-md border border-dashed border-slate-500 p-4 text-center font-normal normal-case tracking-normal text-slate-300">
Start adding blocks to be executed for the selected condition
</div>
)}
</div>
</div>
);

View File

@@ -26,6 +26,7 @@ export type OtherStartNodeData = {
editable: boolean;
label: "__start_block__";
showCode: boolean;
parentNodeType?: "loop" | "conditional";
};
export type StartNodeData = WorkflowStartNodeData | OtherStartNodeData;

View File

@@ -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 <CodeIcon className={className} />;
}
case "conditional": {
return <GitBranchIcon className={className} />;
}
case "download_to_s3": {
return <DownloadIcon className={className} />;
}

View File

@@ -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),

View File

@@ -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",

View File

@@ -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: (
<WorkflowBlockIcon
workflowBlockType={WorkflowBlockTypes.Conditional}
className="size-6"
/>
),
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<AppNode>;
const workflowPanelData = useWorkflowPanelStore(
(state) => state.workflowPanelState.data,
);
@@ -280,6 +300,46 @@ function WorkflowNodeLibraryPanel({
const [search, setSearch] = useState("");
const inputRef = useRef<HTMLInputElement>(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<WorkflowBlockNode["type"]>,
): { 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({
<ScrollAreaViewport className="h-full">
<div className="space-y-2">
{filteredItems.length > 0 ? (
filteredItems.map((item) => (
<div
key={item.nodeType}
className="flex cursor-pointer items-center justify-between rounded-sm bg-slate-elevation4 p-4 hover:bg-slate-elevation5"
onClick={() => {
onNodeClick({
nodeType: item.nodeType,
next: workflowPanelData?.next ?? null,
parent: workflowPanelData?.parent,
previous: workflowPanelData?.previous ?? null,
connectingEdgeType:
workflowPanelData?.connectingEdgeType ??
"edgeWithAddButton",
});
closeWorkflowPanel();
}}
>
<div className="flex gap-2">
<div className="flex h-[2.75rem] w-[2.75rem] shrink-0 items-center justify-center rounded border border-slate-600">
{item.icon}
</div>
<div className="flex flex-col gap-1">
<span className="max-w-64 truncate text-base">
{item.title}
</span>
<span className="text-xs text-slate-400">
{item.description}
</span>
filteredItems.map((item) => {
const { disabled, reason } = isBlockDisabled(item.nodeType);
const itemContent = (
<div
key={item.nodeType}
className={`flex items-center justify-between rounded-sm bg-slate-elevation4 p-4 ${
disabled
? "cursor-not-allowed opacity-50"
: "cursor-pointer hover:bg-slate-elevation5"
}`}
onClick={() => {
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();
}}
>
<div className="flex gap-2">
<div className="flex h-[2.75rem] w-[2.75rem] shrink-0 items-center justify-center rounded border border-slate-600">
{item.icon}
</div>
<div className="flex flex-col gap-1">
<span className="max-w-64 truncate text-base">
{item.title}
</span>
<span className="text-xs text-slate-400">
{item.description}
</span>
</div>
</div>
<PlusIcon className="size-6 shrink-0" />
</div>
<PlusIcon className="size-6 shrink-0" />
</div>
))
);
// Wrap with tooltip if disabled
if (disabled) {
return (
<TooltipProvider key={item.nodeType}>
<Tooltip>
<TooltipTrigger asChild>{itemContent}</TooltipTrigger>
<TooltipContent side="right">
<p className="max-w-xs">{reason}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return itemContent;
})
) : (
<div className="p-4 text-center text-sm text-slate-400">
No results found

View File

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

View File

@@ -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<BranchCondition>;
};
export type TaskBlock = WorkflowBlockBase & {
block_type: "task";
url: string | null;

View File

@@ -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<BranchConditionYAML>;
};
export type PDFParserBlockYAML = BlockYAMLBase & {
block_type: "pdf_parser";
file_url: string;

View File

@@ -171,6 +171,33 @@ function WorkflowRunTimelineBlockItem({
{block.description ? (
<div className="text-xs text-slate-400">{block.description}</div>
) : null}
{block.block_type === "conditional" && block.executed_branch_id && (
<div className="space-y-1 rounded bg-slate-elevation5 px-3 py-2 text-xs">
{block.executed_branch_expression !== null &&
block.executed_branch_expression !== undefined ? (
<div className="text-slate-300">
Condition{" "}
<code className="rounded bg-slate-elevation3 px-1.5 py-0.5 font-mono text-slate-200">
{block.executed_branch_expression}
</code>{" "}
evaluated to{" "}
<span className="font-medium text-success">True</span>
</div>
) : (
<div className="text-slate-300">
No conditions matched, executing default branch
</div>
)}
{block.executed_branch_next_block && (
<div className="text-slate-400">
Executing next block:{" "}
<span className="font-medium text-slate-300">
{block.executed_branch_next_block}
</span>
</div>
)}
</div>
)}
</div>
{block.block_type === "human_interaction" && (