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

@@ -0,0 +1,26 @@
type Props = {
className?: string;
};
function GitBranchIcon({ className }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
className={className}
>
<path
d="M3 3V13.2C3 14.8802 3 15.7202 3.32698 16.362C3.6146 16.9265 4.07354 17.3854 4.63803 17.673C5.27976 18 6.11984 18 7.8 18H15M15 18C15 19.6569 16.3431 21 18 21C19.6569 21 21 19.6569 21 18C21 16.3431 19.6569 15 18 15C16.3431 15 15 16.3431 15 18ZM3 8L15 8M15 8C15 9.65686 16.3431 11 18 11C19.6569 11 21 9.65685 21 8C21 6.34315 19.6569 5 18 5C16.3431 5 15 6.34315 15 8Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export { GitBranchIcon };

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" && (

View File

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