"use client";
import { useCallback, useRef, DragEvent, useState, useEffect } from "react";
import { ErrorBoundary } from "@/components/shared/ErrorBoundary";
import {
ReactFlow,
Background,
Controls,
addEdge,
useNodesState,
useEdgesState,
useReactFlow,
ReactFlowProvider,
type OnConnect,
type Node,
type Edge,
type NodeMouseHandler,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
import "@/styles/workflow-execution.css";
import { motion } from "framer-motion";
import {
Brain,
GitBranch,
RefreshCw,
UserCheck,
Shuffle,
ScanSearch,
Sparkles,
Play,
Flag,
Zap,
StickyNote,
MoreHorizontal,
Globe,
Database,
} from "lucide-react";
import NodePanel from "./NodePanel";
import MCPPanel from "./MCPPanel";
import PreviewPanel from "./PreviewPanel";
import ExecutionPanel from "./ExecutionPanel";
import TestEndpointPanel from "./TestEndpointPanel";
import LogicNodePanel from "./LogicNodePanel";
import DataNodePanel from "./DataNodePanel";
import ToolsNodePanel from "./ToolsNodePanel";
import NoteNodePanel from "./NoteNodePanel";
import HTTPNodePanel from "./HTTPNodePanel";
import ExtractNodePanel from "./ExtractNodePanel";
import StartNodePanel from "./StartNodePanel";
import WorkflowNameEditor from "./WorkflowNameEditor";
import SettingsPanel from "./SettingsPanelSimple";
import ConfirmDialog from "./ConfirmDialog";
import EdgeLabelModal from "./EdgeLabelModal";
import ShareWorkflowModal from "./ShareWorkflowModal";
import SaveAsTemplateModal from "./SaveAsTemplateModal";
import { toast } from "sonner";
import { useWorkflow } from "@/hooks/useWorkflow";
import { useWorkflowExecution } from "@/hooks/useWorkflowExecution";
// Remove static template import - now loading from Convex
// import { getTemplate } from "@/lib/workflow/templates";
import { getWorkflow } from "@/lib/workflow/storage";
import type { WorkflowNode, WorkflowEdge } from "@/lib/workflow/types";
import { nodeTypes } from "./CustomNodes";
import { detectDuplicateCredentials } from "@/lib/workflow/duplicate-detection";
import { cleanupInvalidEdges } from "@/lib/workflow/edge-cleanup";
import { useQuery, useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
interface WorkflowBuilderProps {
onBack: () => void;
initialWorkflowId?: string | null;
initialTemplateId?: string | null;
}
let nodeId = 2;
const getId = () => `node_${nodeId++}`;
// Helper function to reset node ID counter based on existing nodes
const resetNodeIdCounter = (nodes: Node[]) => {
const maxId = nodes.reduce((max, node) => {
const match = node.id.match(/^node_(\d+)$/);
if (match) {
const id = parseInt(match[1], 10);
return Math.max(max, id);
}
return max;
}, 1);
nodeId = maxId + 1;
console.log('๐ข Reset node ID counter to:', nodeId);
};
const initialNodes: Node[] = [
{
id: 'node_0',
type: 'start',
position: { x: 250, y: 250 },
data: {
label: (
),
nodeType: 'start',
nodeName: 'Start',
},
},
];
const initialEdges: Edge[] = [];
const nodeCategories = [
{
category: "Core",
nodes: [
{ type: "agent", label: "Agent", color: "bg-blue-500", icon: Brain },
{ type: "end", label: "End", color: "bg-teal-500", icon: Flag },
{ type: "note", label: "Note", color: "bg-[#E4E4E7] dark:bg-[#52525B]", icon: StickyNote },
],
},
{
category: "Tools",
nodes: [
{ type: "mcp", label: "Composio", color: "bg-[#FFEFA4] dark:bg-[#FFDD40]", icon: Sparkles },
],
},
{
category: "Logic",
nodes: [
{ type: "if-else", label: "Condition", color: "bg-[#FEE7C2] dark:bg-[#FFAE2B]", icon: GitBranch },
{ type: "while", label: "While", color: "bg-[#FEE7C2] dark:bg-[#FFAE2B]", icon: RefreshCw },
{ type: "user-approval", label: "User approval", color: "bg-[#E5E7EB] dark:bg-[#9CA3AF]", icon: UserCheck },
],
},
{
category: "Data",
nodes: [
{ type: "transform", label: "Transform", color: "bg-[#ECE3FF] dark:bg-[#9665FF]", icon: Shuffle },
{ type: "extract", label: "Extract", color: "bg-[#ECE3FF] dark:bg-[#9665FF]", icon: ScanSearch },
{ type: "http", label: "HTTP", color: "bg-[#ECE3FF] dark:bg-[#9665FF]", icon: Globe },
{ type: "set-state", label: "Set state", color: "bg-[#ECE3FF] dark:bg-[#9665FF]", icon: Database },
],
},
];
// Auto-layout function to position nodes left to right
const autoLayoutNodes = (nodes: Node[], edges: Edge[]) => {
if (nodes.length === 0) return nodes;
const LAYER_SPACING = 350; // Horizontal spacing between layers (left to right)
const NODE_SPACING = 150; // Vertical spacing between nodes in same layer
const START_X = 100;
const START_Y = 100;
// Build adjacency list from edges
const adjacency: { [key: string]: string[] } = {};
nodes.forEach(n => (adjacency[n.id] = []));
// Only add edges where both source and target nodes exist
edges.forEach(e => {
if (!adjacency[e.source]) adjacency[e.source] = [];
// Verify target node exists before adding edge
if (adjacency[e.target] !== undefined) {
adjacency[e.source].push(e.target);
}
});
// Calculate node layers using BFS
const layers: { [key: string]: number } = {};
const queue: string[] = [];
// Find start node
const startNode = nodes.find(n => (n.data as any)?.nodeType === 'start');
if (startNode) {
layers[startNode.id] = 0;
queue.push(startNode.id);
} else if (nodes.length > 0) {
layers[nodes[0].id] = 0;
queue.push(nodes[0].id);
}
// BFS to assign layers
while (queue.length > 0) {
const nodeId = queue.shift()!;
const currentLayer = layers[nodeId];
// Safety check: ensure adjacency entry exists and is iterable
const children = adjacency[nodeId];
if (children && Array.isArray(children)) {
for (const childId of children) {
if (!(childId in layers)) {
layers[childId] = currentLayer + 1;
queue.push(childId);
}
}
}
}
// Assign unvisited nodes to appropriate layers
for (const node of nodes) {
if (!(node.id in layers)) {
layers[node.id] = Math.max(...Object.values(layers), -1) + 1;
}
}
// Group nodes by layer and calculate positions
const nodesByLayer: { [key: number]: Node[] } = {};
for (const node of nodes) {
const layer = layers[node.id];
if (!nodesByLayer[layer]) nodesByLayer[layer] = [];
nodesByLayer[layer].push(node);
}
// Position nodes left to right
const layoutNodes: Node[] = [];
for (const layer in nodesByLayer) {
const layerNodes = nodesByLayer[layer];
const nodesInLayer = layerNodes.length;
const totalHeight = (nodesInLayer - 1) * NODE_SPACING;
const startYForLayer = START_Y + (300 - totalHeight / 2); // Center vertically
layerNodes.forEach((node, index) => {
layoutNodes.push({
...node,
position: {
x: START_X + parseInt(layer) * LAYER_SPACING,
y: startYForLayer + index * NODE_SPACING,
},
});
});
}
return layoutNodes;
};
function WorkflowBuilderInner({ onBack, initialWorkflowId, initialTemplateId }: WorkflowBuilderProps) {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const [initialized, setInitialized] = useState(false);
const [currentTemplateId, setCurrentTemplateId] = useState(initialTemplateId ?? null);
// Convex queries and mutations for templates
const template = useQuery(api.workflows.getTemplateByCustomId,
currentTemplateId ? { customId: currentTemplateId } : "skip"
);
const updateTemplateStructure = useMutation(api.workflows.updateTemplateStructure);
// Function to seed templates via API
const seedTemplates = async () => {
const response = await fetch('/api/templates/seed', { method: 'POST' });
const data = await response.json();
return data;
};
const [selectedNode, setSelectedNode] = useState(null);
const [showPreview, setShowPreview] = useState(false);
const [showExecution, setShowExecution] = useState(false);
const [showTestEndpoint, setShowTestEndpoint] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [showComingSoonModal, setShowComingSoonModal] = useState(false);
const [showSaveAsTemplateModal, setShowSaveAsTemplateModal] = useState(false);
const [confirmDialog, setConfirmDialog] = useState<{
isOpen: boolean;
title: string;
description: string;
onConfirm: () => void;
variant?: "danger" | "warning" | "default";
}>({
isOpen: false,
title: "",
description: "",
onConfirm: () => {},
});
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; nodeId: string } | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState(null);
const [editingEdge, setEditingEdge] = useState(null);
const [showMCPSelector, setShowMCPSelector] = useState(false);
const [targetAgentForMCP, setTargetAgentForMCP] = useState(null);
const [duplicateWarnings, setDuplicateWarnings] = useState([]);
const [showWorkflowMenu, setShowWorkflowMenu] = useState(false);
const workflowMenuRef = useRef(null);
const [renameTrigger, setRenameTrigger] = useState(0);
const [environment, setEnvironment] = useState<'draft' | 'production'>('draft');
const reactFlowWrapper = useRef(null);
const { screenToFlowPosition, getNode, setCenter } = useReactFlow();
// Workflow management
const { workflow, convexId, updateNodes, updateEdges, saveWorkflow, saveWorkflowImmediate, deleteWorkflow, createNewWorkflow } = useWorkflow(initialWorkflowId || undefined);
// AUTO-SAVE DISABLED - Use manual Save button instead
// Smart auto-save: only save when nodes/edges actually change, with debounce
// const lastSavedNodesRef = useRef('');
// const lastSavedEdgesRef = useRef('');
// const autoSaveTimeoutRef = useRef(null);
// useEffect(() => {
// if (!initialized || !workflow) return;
// const nodesJson = JSON.stringify(nodes);
// const edgesJson = JSON.stringify(edges);
// // Only save if something actually changed
// if (nodesJson === lastSavedNodesRef.current && edgesJson === lastSavedEdgesRef.current) {
// return;
// }
// // Clear previous timeout
// if (autoSaveTimeoutRef.current) {
// clearTimeout(autoSaveTimeoutRef.current);
// }
// // Debounced save
// autoSaveTimeoutRef.current = setTimeout(async () => {
// console.log('๐ [AUTO-SYNC] Saving changes to Convex...', {
// nodeCount: nodes.length,
// edgeCount: edges.length,
// isTemplate: workflow.isTemplate,
// templateId: currentTemplateId,
// });
// // Update refs BEFORE saving to prevent loops
// lastSavedNodesRef.current = nodesJson;
// lastSavedEdgesRef.current = edgesJson;
// // If this is a template-based workflow, also save to the template
// if (workflow.isTemplate && currentTemplateId) {
// try {
// await updateTemplateStructure({
// customId: currentTemplateId,
// nodes: nodes.map(n => ({
// ...n,
// data: {
// ...n.data,
// nodeType: n.data?.nodeType || n.type,
// }
// })),
// edges,
// });
// console.log('โ
Template structure updated in Convex');
// } catch (error) {
// console.error('Failed to update template structure:', error);
// }
// }
// // Save to workflow (regular save)
// saveWorkflow({ nodes, edges });
// }, 1500); // 1.5 second debounce
// return () => {
// if (autoSaveTimeoutRef.current) {
// clearTimeout(autoSaveTimeoutRef.current);
// }
// };
// }, [nodes, edges, initialized, currentTemplateId, updateTemplateStructure]); // Don't include workflow or saveWorkflow to prevent loops
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (workflowMenuRef.current && !workflowMenuRef.current.contains(event.target as any)) {
setShowWorkflowMenu(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
useEffect(() => {
setEnvironment('draft');
setShowTestEndpoint(false); // Close API panel when switching workflows
}, [workflow?.id]);
const handleDuplicateWorkflow = useCallback(() => {
if (!workflow) return;
const original = workflow;
const newWorkflow = createNewWorkflow();
setShowWorkflowMenu(false);
// Allow state to update before saving copied structure
setTimeout(() => {
saveWorkflow({
name: `${original.name || 'Workflow'} Copy`,
description: original.description,
nodes: original.nodes,
edges: original.edges,
});
toast.success('Workflow duplicated');
}, 0);
}, [workflow, createNewWorkflow, saveWorkflow, setShowWorkflowMenu]);
const handleRenameWorkflow = useCallback(() => {
setRenameTrigger(prev => prev + 1);
setShowWorkflowMenu(false);
}, [setRenameTrigger, setShowWorkflowMenu]);
const handleSaveWorkflowImmediate = useCallback(() => {
if (!workflow) {
console.error('โ Cannot save: no workflow exists');
return;
}
console.log('๐พ [MANUAL SAVE] Saving workflow with', nodes.length, 'nodes and', edges.length, 'edges');
const updatedWorkflow = {
...workflow,
nodes: nodes.map(n => ({
...n,
type: n.type || 'default',
data: {
...n.data,
label: typeof n.data.label === 'string' ? n.data.label : 'Node',
nodeType: n.data.nodeType || n.type, // Ensure nodeType is preserved
},
})) as any,
edges: edges as any,
};
saveWorkflow(updatedWorkflow);
toast.success('Workflow saved', {
description: `Saved ${nodes.length} nodes to Convex`,
});
setShowShareModal(true);
setShowWorkflowMenu(false);
}, [workflow, nodes, edges, saveWorkflow, setShowShareModal, setShowWorkflowMenu]);
const handleClearCanvas = useCallback(() => {
setConfirmDialog({
isOpen: true,
title: 'Clear Canvas',
description: 'This will remove all nodes and reset to the default workflow. This action cannot be undone.',
variant: 'warning',
onConfirm: () => {
setNodes(initialNodes);
setEdges(initialEdges);
setSelectedNode(null);
// Reset node ID counter to initial state
resetNodeIdCounter(initialNodes);
toast.success('Canvas cleared', {
description: 'Workflow reset to default',
});
},
});
setShowWorkflowMenu(false);
}, [setConfirmDialog, setNodes, setEdges, setSelectedNode, setShowWorkflowMenu]);
const confirmDeleteWorkflow = useCallback(() => {
if (!workflow) return;
setConfirmDialog({
isOpen: true,
title: 'Delete Workflow',
description: 'This will permanently delete the current workflow. This action cannot be undone.',
variant: 'danger',
onConfirm: () => {
deleteWorkflow(workflow.id);
toast.success('Workflow deleted');
},
});
setShowWorkflowMenu(false);
}, [workflow, deleteWorkflow, setConfirmDialog, setShowWorkflowMenu]);
const { runWorkflow, stopWorkflow, isRunning, nodeResults, execution, currentNodeId, pendingAuth, resumeWorkflow } = useWorkflowExecution();
// Load template or workflow on mount
useEffect(() => {
if (initialized) return;
if (initialTemplateId) {
// Check if template is loading from Convex
if (template === undefined) {
// Still loading from Convex
return;
}
// If template is null, it doesn't exist in Convex yet - seed templates
if (template === null) {
console.log('Template not found in Convex, seeding templates...');
seedTemplates()
.then(() => {
console.log('Templates seeded successfully');
// The component will re-render when the template query updates
})
.catch(err => {
console.error('Failed to seed templates:', err);
toast.error('Failed to load template');
});
return;
}
if (template) {
console.log('Loading template from Convex:', template);
// Clean up any invalid edges in the template
const cleaned = cleanupInvalidEdges(template.nodes, template.edges);
const cleanedNodes = cleaned.nodes;
const cleanedEdges = cleaned.edges;
if (cleaned.removedCount > 0) {
console.warn(`๐งน Removed ${cleaned.removedCount} invalid edge(s) from template`);
toast.warning(`Template had ${cleaned.removedCount} invalid connection(s)`, {
description: 'These have been automatically removed',
});
}
// Convert template nodes to React Flow format
const templateNodes = cleanedNodes.map((n: any) => {
const nodeData = n.data as any;
return {
...n,
data: {
...n.data,
label: createNodeLabel(nodeData.nodeName || nodeData.label as string, getNodeColor(n.type), n.type),
},
};
});
console.log('Template nodes with icons:', templateNodes.map(n => ({ id: n.id, label: n.data.label })));
// Apply auto-layout for even spacing
const layoutedNodes = autoLayoutNodes(templateNodes as any, cleanedEdges as any);
setNodes(layoutedNodes as any);
setEdges(cleanedEdges as any);
// Reset node ID counter based on loaded nodes to prevent duplicates
resetNodeIdCounter(layoutedNodes as any);
// Save template as a new workflow with all node data intact
// Ensure nodes have proper nodeType for LangGraph compatibility
const workflowNodes = template.nodes.map((n: any) => ({
...n,
data: {
...n.data,
nodeType: n.data.nodeType || n.type, // Ensure nodeType is set
}
}));
// Generate a unique workflow ID for this template instance
const workflowId = `workflow_${Date.now()}_${template.id}`;
saveWorkflow({
id: workflowId,
name: template.name,
description: template.description,
nodes: workflowNodes,
edges: template.edges,
});
setInitialized(true);
}
} else if (initialWorkflowId && workflow && !initialized) {
// Use workflow data from useWorkflow hook (loaded via API)
console.log('Loading workflow from hook:', {
id: workflow.id,
name: workflow.name,
nodeCount: workflow.nodes?.length,
nodes: workflow.nodes?.map((n: any) => ({ id: n.id, type: n.type }))
});
// Clean up any invalid edges before rendering
const cleaned = cleanupInvalidEdges(workflow.nodes, workflow.edges);
const cleanedNodes = cleaned.nodes;
const cleanedEdges = cleaned.edges;
if (cleaned.removedCount > 0) {
console.warn(`๐งน Removed ${cleaned.removedCount} invalid edge(s) from workflow`);
toast.warning(`Workflow had ${cleaned.removedCount} invalid connection(s)`, {
description: 'These have been automatically removed',
});
}
// Convert workflow nodes to React Flow format
const workflowNodes = cleanedNodes.map(n => {
const nodeData = n.data as any;
// Get the label text (not React element)
const labelText = nodeData.nodeName || nodeData.label || n.type;
return {
...n,
data: {
...n.data,
// Create the label JSX element
label: createNodeLabel(labelText, getNodeColor(n.type), n.type),
},
};
});
// Apply auto-layout for even spacing
const layoutedNodes = autoLayoutNodes(workflowNodes as any, cleanedEdges as any);
console.log('Setting nodes to:', layoutedNodes.length, 'nodes');
setNodes(layoutedNodes as any);
setEdges(cleanedEdges as any);
// Reset node ID counter based on loaded nodes to prevent duplicates
resetNodeIdCounter(layoutedNodes as any);
setInitialized(true);
} else if (!initialized && !initialTemplateId && !initialWorkflowId) {
setInitialized(true);
// For new workflows, don't select any node by default
// User can click the start node or drag new nodes to build their workflow
}
}, [initialTemplateId, initialWorkflowId, initialized, setNodes, setEdges, saveWorkflow, template, seedTemplates, workflow]);
const createNodeLabel = (label: string, color: string, nodeType?: string) => {
// Get icon for this node type
const nodeCategory = nodeCategories.find(cat =>
cat.nodes.some(n => n.type === nodeType || n.label === label)
);
const nodeConfig = nodeCategory?.nodes.find(n => n.type === nodeType || n.label === label);
const IconComponent = nodeConfig?.icon;
// Determine text color based on node type
const getTextColor = () => {
if (nodeType === 'note') return 'text-white'; // White text for note nodes (yellow background)
if (nodeType === 'if-else' || nodeType === 'while' || nodeType === 'user-approval') {
return 'text-[#18181b]'; // Dark text for orange background nodes
}
return 'text-[#18181b]'; // Default dark text
};
return (
{IconComponent ? (
) : (
)}
{label}
);
};
const getNodeColor = (type: string): string => {
const colorMap: Record = {
'agent': 'bg-blue-500',
'mcp': 'bg-[#FFEFA4] dark:bg-[#FFDD40]',
'firecrawl': 'bg-heat-100',
'if-else': 'bg-[#FEE7C2] dark:bg-[#FFAE2B]',
'while': 'bg-[#FEE7C2] dark:bg-[#FFAE2B]',
'user-approval': 'bg-[#E5E7EB] dark:bg-[#9CA3AF]',
'transform': 'bg-[#ECE3FF] dark:bg-[#9665FF]',
'set-state': 'bg-[#ECE3FF] dark:bg-[#9665FF]',
'file-search': 'bg-indigo-500',
'extract': 'bg-purple-500',
'note': 'bg-[#E4E4E7] dark:bg-[#52525B]',
'end': 'bg-teal-500',
'start': 'bg-gray-600',
};
return colorMap[type] || 'bg-gray-500';
};
// Detect duplicate credentials
useEffect(() => {
if (workflow) {
const warnings = detectDuplicateCredentials(workflow);
setDuplicateWarnings(warnings);
// Show toast for new warnings
if (warnings.length > 0 && duplicateWarnings.length === 0) {
toast.warning(`Found ${warnings.length} duplicate credential${warnings.length > 1 ? 's' : ''}`, {
description: 'Click the warning icon to view details',
duration: 5000,
});
}
}
}, [workflow?.nodes, workflow?.edges]);
// Sync React Flow state with workflow state (debounced to avoid loops)
// Skip auto-save for nodes - only save when user explicitly modifies the workflow
// This prevents infinite loops when node visual states change during execution
const nodesSaveTimeoutRef = useRef(null);
const edgesSaveTimeoutRef = useRef(null);
// Cleanup timeouts on unmount to prevent memory leaks
useEffect(() => {
return () => {
if (nodesSaveTimeoutRef.current) clearTimeout(nodesSaveTimeoutRef.current);
if (edgesSaveTimeoutRef.current) clearTimeout(edgesSaveTimeoutRef.current);
};
}, []);
useEffect(() => {
// Clear any pending saves when execution starts
if (isRunning) {
if (nodesSaveTimeoutRef.current) clearTimeout(nodesSaveTimeoutRef.current);
if (edgesSaveTimeoutRef.current) clearTimeout(edgesSaveTimeoutRef.current);
}
}, [isRunning]);
// AUTO-SAVE DISABLED - Use manual Save button instead
// Only auto-save nodes when NOT running and after a significant delay
// This prevents saves triggered by execution state changes
// useEffect(() => {
// // Skip auto-save during initialization or execution
// // Note: initialTemplateId is cleared after creating the workflow, so don't check it here
// if (!initialized || !workflow || nodes.length === 0 || isRunning || currentNodeId) {
// console.log('โญ๏ธ Skipping node auto-save:', {
// initialized,
// hasWorkflow: !!workflow,
// nodeCount: nodes.length,
// isRunning,
// currentNodeId
// });
// return;
// }
// // Only auto-save if user has made manual changes (not from template loading)
// // Check if all required nodes are present before saving
// const hasStartNode = nodes.some(n => n.type === 'start' || n.id === 'start');
// if (!hasStartNode) {
// console.warn('โ ๏ธ Skipping auto-save: missing start node');
// return;
// }
// console.log('โ
Node auto-save scheduled for', nodes.length, 'nodes');
// if (nodesSaveTimeoutRef.current) clearTimeout(nodesSaveTimeoutRef.current);
// nodesSaveTimeoutRef.current = setTimeout(() => {
// console.log('๐พ Executing node auto-save NOW');
// updateNodes(nodes.map(n => ({
// ...n,
// type: n.type || 'default',
// data: {
// ...n.data,
// label: typeof n.data.label === 'string' ? n.data.label : 'Node',
// // Preserve nodeType for LangGraph compatibility
// nodeType: n.data.nodeType || n.type,
// },
// })) as any);
// }, 2000); // Increased delay to 2 seconds to reduce save frequency
// return () => {
// if (nodesSaveTimeoutRef.current) clearTimeout(nodesSaveTimeoutRef.current);
// };
// }, [nodes, workflow, isRunning, currentNodeId, updateNodes, initialized, initialTemplateId]);
// useEffect(() => {
// // Skip auto-save during initialization or execution
// if (!initialized || !workflow || edges.length === 0 || isRunning || currentNodeId) {
// return;
// }
// if (edgesSaveTimeoutRef.current) clearTimeout(edgesSaveTimeoutRef.current);
// edgesSaveTimeoutRef.current = setTimeout(() => {
// updateEdges(edges as any);
// }, 2000); // Increased delay to 2 seconds to reduce save frequency
// return () => {
// if (edgesSaveTimeoutRef.current) clearTimeout(edgesSaveTimeoutRef.current);
// };
// }, [edges, workflow, isRunning, currentNodeId, updateEdges, initialized, initialTemplateId]);
// Update node visual states during execution and add IO badges
useEffect(() => {
console.log('๐จ Visual update triggered - currentNodeId:', currentNodeId, 'nodeResults:', Object.keys(nodeResults));
setNodes((nds) =>
nds.map((node) => {
const isCurrentlyRunning = currentNodeId === node.id;
const result = nodeResults[node.id];
// Explicitly clear executing class from nodes that are no longer running
const wasExecuting = (node.data as any)?.isRunning === true;
const stoppedExecuting = wasExecuting && !isCurrentlyRunning;
const nextClassName = isCurrentlyRunning
? 'executing-node'
: result?.status === 'completed'
? 'completed-node'
: result?.status === 'failed'
? 'failed-node'
: '';
const currentClassName = node.className || '';
const isRunningChanged = (node.data as any)?.isRunning !== isCurrentlyRunning;
const statusChanged = (node.data as any)?.executionStatus !== result?.status;
const classChanged = currentClassName !== nextClassName;
if (isRunningChanged || statusChanged || classChanged) {
console.log(`๐จ Updating node ${node.id}:`, {
isCurrentlyRunning,
resultStatus: result?.status,
nextClassName,
currentClassName,
stoppedExecuting,
});
}
if (!isRunningChanged && !statusChanged && !classChanged) {
return node;
}
return {
...node,
// Force clear className if stopped executing to immediately stop animation
className: stoppedExecuting ? '' : nextClassName,
data: {
...node.data,
isRunning: isCurrentlyRunning,
executionStatus: result?.status,
label: node.data.label as any,
// Add timestamp to force re-render when execution state changes
_executionUpdate: stoppedExecuting ? Date.now() : (node.data as any)?._executionUpdate,
},
};
})
);
// Highlight active and selected edges
setEdges((eds) =>
eds.map((edge) => {
const sourceCompleted = nodeResults[edge.source]?.status === 'completed';
const targetRunning = currentNodeId === edge.target;
const isActive = sourceCompleted && targetRunning;
const isSelected = edge.id === selectedEdgeId;
const nextClassName = isActive ? 'active-edge' : isSelected ? 'selected-edge' : '';
const currentClassName = edge.className || '';
const nextStroke = isActive ? '#22C55E' : isSelected ? '#22C55E' : '#d1d5db';
const nextWidth = isActive ? 2 : isSelected ? 2 : 1;
const classChanged = currentClassName !== nextClassName;
const strokeChanged = edge.style?.stroke !== nextStroke;
const widthChanged = edge.style?.strokeWidth !== nextWidth;
const animatedChanged = !!edge.animated !== isActive;
if (!classChanged && !strokeChanged && !widthChanged && !animatedChanged) {
return edge;
}
return {
...edge,
className: nextClassName,
style: {
...edge.style,
stroke: nextStroke,
strokeWidth: nextWidth,
},
animated: isActive,
};
})
);
}, [currentNodeId, nodeResults, selectedEdgeId, setNodes, setEdges]);
// Auto-track executing node
useEffect(() => {
if (currentNodeId && isRunning) {
const node = getNode(currentNodeId);
if (node) {
setCenter(node.position.x + 100, node.position.y + 50, {
zoom: 1,
duration: 400
});
}
}
}, [currentNodeId, isRunning, getNode, setCenter]);
const onConnect: OnConnect = useCallback(
(connection) => {
console.log('๐ Creating edge connection:', {
source: connection.source,
target: connection.target,
sourceHandle: connection.sourceHandle,
targetHandle: connection.targetHandle,
});
// Directly connect nodes without showing mapping modal
setEdges((eds) => addEdge(connection, eds));
},
[setEdges],
);
const onDragOver = useCallback((event: DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
const onDrop = useCallback(
(event: DragEvent) => {
event.preventDefault();
const type = event.dataTransfer.getData('application/reactflow');
const label = event.dataTransfer.getData('application/reactflow-label');
const color = event.dataTransfer.getData('application/reactflow-color');
if (!type) {
return;
}
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
// Get the icon component for this node type
const nodeCategory = nodeCategories.find(cat =>
cat.nodes.some(n => n.type === type)
);
const nodeConfig = nodeCategory?.nodes.find(n => n.type === type);
const IconComponent = nodeConfig?.icon;
const newNode: Node = {
id: getId(),
type: type === 'firecrawl' ? 'mcp' : type,
position,
data: {
label: (
{IconComponent ? (
) : (
)}
{label}
),
nodeType: type === 'firecrawl' ? 'mcp' : type,
nodeName: label,
// Pre-configure Firecrawl MCP if this is a Firecrawl node
...(type === 'firecrawl' && {
mcpServers: [
{
id: 'firecrawl',
name: 'Firecrawl',
label: 'firecrawl',
url: 'https://mcp.firecrawl.dev/{FIRECRAWL_API_KEY}/v2/mcp',
authType: 'Access token / API key',
},
],
}),
},
};
setNodes((nds) => nds.concat(newNode));
},
[screenToFlowPosition, setNodes],
);
const onDragStart = (event: DragEvent, nodeType: string, nodeLabel: string, nodeColor: string) => {
event.dataTransfer.setData('application/reactflow', nodeType);
event.dataTransfer.setData('application/reactflow-label', nodeLabel);
event.dataTransfer.setData('application/reactflow-color', nodeColor);
event.dataTransfer.effectAllowed = 'move';
};
const handleDeleteNode = useCallback((nodeId: string) => {
setConfirmDialog({
isOpen: true,
title: "Delete Node",
description: "Are you sure you want to delete this node? This will also remove all connected edges.",
variant: "danger",
onConfirm: () => {
setNodes(nds => nds.filter(n => n.id !== nodeId));
setEdges(eds => eds.filter(e => e.source !== nodeId && e.target !== nodeId));
setSelectedNode(null);
toast.success('Node deleted');
},
});
}, [setNodes, setEdges]);
const onNodeClick: NodeMouseHandler = useCallback((_event, node) => {
// Check if it's a file-search node - show coming soon modal
if ((node.data as any)?.nodeType === 'file-search') {
setShowComingSoonModal(true);
return;
}
// Close all other panels when clicking a node
setShowExecution(false);
setShowTestEndpoint(false);
setShowPreview(false);
setSelectedEdgeId(null);
// Set the selected node
setSelectedNode(node);
}, []);
const onEdgeClick = useCallback((_event: React.MouseEvent, edge: Edge) => {
setSelectedEdgeId(edge.id);
setSelectedNode(null); // Deselect node when clicking edge
toast.info('Connection selected - Click Delete or use the toolbar below', {
duration: 2000,
});
}, []);
const onEdgeDoubleClick = useCallback((_event: React.MouseEvent, edge: Edge) => {
setEditingEdge(edge);
}, []);
const onPaneClick = useCallback(() => {
// Deselect edge when clicking canvas background
if (selectedEdgeId) {
setSelectedEdgeId(null);
}
}, [selectedEdgeId]);
const handleSaveEdgeLabel = useCallback((edgeId: string, label: string) => {
setEdges((eds) =>
eds.map((edge) =>
edge.id === edgeId
? {
...edge,
label,
labelBgStyle: { fill: 'white', fillOpacity: 1 },
labelStyle: { fill: '#18181b', fontWeight: 600, fontSize: 12 }
}
: edge
)
);
toast.success('Connection label updated');
}, [setEdges]);
const onNodeContextMenu = useCallback((event: React.MouseEvent, node: Node) => {
event.preventDefault();
setContextMenu({
x: event.clientX,
y: event.clientY,
nodeId: node.id,
});
}, []);
const handleContextMenuDelete = useCallback(() => {
if (contextMenu) {
const nodeId = contextMenu.nodeId;
setContextMenu(null);
handleDeleteNode(nodeId);
}
}, [contextMenu, handleDeleteNode]);
const handleContextMenuDuplicate = useCallback(() => {
if (contextMenu) {
const nodeToDuplicate = nodes.find(n => n.id === contextMenu.nodeId);
if (nodeToDuplicate) {
const newNode = {
...nodeToDuplicate,
id: getId(),
position: {
x: nodeToDuplicate.position.x + 300,
y: nodeToDuplicate.position.y,
},
};
setNodes((nds) => nds.concat(newNode));
}
setContextMenu(null);
}
}, [contextMenu, nodes, setNodes]);
// Close context menu on click outside
useEffect(() => {
const handleClick = () => setContextMenu(null);
if (contextMenu) {
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}
}, [contextMenu]);
// Handler for opening execution panel
const handlePreview = useCallback(() => {
console.log('โถ๏ธ Run button clicked - opening ExecutionPanel');
setShowPreview(false);
setShowTestEndpoint(false);
setSelectedNode(null); // Close node panel
setShowExecution(true);
}, []);
// Handler for auto-arranging nodes
const handleAutoArrange = useCallback(() => {
console.log('๐ Auto-arranging nodes');
const layoutedNodes = autoLayoutNodes(nodes, edges);
setNodes(layoutedNodes as any);
toast.success('Nodes arranged', {
description: 'Nodes have been automatically spaced evenly',
});
}, [nodes, edges, setNodes]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger shortcuts if user is typing in an input/textarea
const target = e.target as HTMLElement;
const isTyping = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
// Delete selected nodes
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedNode && selectedNode.data?.nodeType !== 'start' && !isTyping) {
e.preventDefault();
handleDeleteNode(selectedNode.id);
}
// Delete selected edges
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedEdgeId && !selectedNode && !isTyping) {
e.preventDefault();
setEdges(eds => eds.filter(e => e.id !== selectedEdgeId));
setSelectedEdgeId(null);
toast.success('Connection deleted');
}
// Copy node (Ctrl/Cmd + C)
if ((e.ctrlKey || e.metaKey) && e.key === 'c' && selectedNode && !isTyping) {
e.preventDefault();
// Store in clipboard-like state
localStorage.setItem('copiedNode', JSON.stringify(
nodes.find(n => n.id === selectedNode.id)
));
}
// Paste node (Ctrl/Cmd + V)
if ((e.ctrlKey || e.metaKey) && e.key === 'v' && !isTyping) {
e.preventDefault();
const copiedNodeStr = localStorage.getItem('copiedNode');
if (copiedNodeStr) {
try {
const copiedNode = JSON.parse(copiedNodeStr);
const newNode = {
...copiedNode,
id: getId(),
position: {
x: copiedNode.position.x + 200,
y: copiedNode.position.y,
},
};
setNodes((nds) => nds.concat(newNode));
} catch (e) {
console.error('Failed to paste node:', e);
}
}
}
// Duplicate (Ctrl/Cmd + D)
if ((e.ctrlKey || e.metaKey) && e.key === 'd' && selectedNode && !isTyping) {
e.preventDefault();
const nodeToDuplicate = nodes.find(n => n.id === selectedNode.id);
if (nodeToDuplicate) {
const newNode = {
...nodeToDuplicate,
id: getId(),
position: {
x: nodeToDuplicate.position.x + 200,
y: nodeToDuplicate.position.y,
},
};
setNodes((nds) => nds.concat(newNode));
}
}
// Run workflow (Cmd + Enter)
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !isTyping) {
e.preventDefault();
if (!isRunning) {
handlePreview();
}
}
// Auto-arrange nodes (Cmd/Ctrl + Shift + A)
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'A' && !isTyping) {
e.preventDefault();
handleAutoArrange();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [selectedNode, selectedEdgeId, nodes, edges, handleDeleteNode, setNodes, setEdges, isRunning, handleAutoArrange]);
const handleRunWithInput = useCallback(async (input: string) => {
console.log('๐ handleRunWithInput called with input:', input);
if (!workflow) {
console.error('โ No workflow to run');
return;
}
console.log('โ
Workflow exists:', workflow.name);
// Save the workflow before running to ensure it exists in Convex
console.log('๐พ Saving workflow before execution...');
toast.info('Saving workflow...', { duration: 1000 });
const saveSuccess = await saveWorkflowImmediate({
nodes: nodes.map(n => ({
...n,
type: n.type || 'default',
data: {
...n.data,
label: typeof n.data.label === 'string' ? n.data.label : 'Node',
nodeType: n.data.nodeType || n.type,
},
})) as any,
edges: edges as any,
});
if (!saveSuccess) {
toast.error('Failed to save workflow', {
description: 'Cannot run workflow until it is saved',
});
return;
}
// Create a fresh workflow object with current nodes/edges
const currentWorkflow = {
...workflow,
nodes: nodes.map(n => ({
id: n.id,
type: n.type,
position: n.position,
data: n.data,
})) as any,
edges: edges.map(e => ({
id: e.id,
source: e.source,
target: e.target,
sourceHandle: e.sourceHandle,
targetHandle: e.targetHandle,
label: e.label,
})) as any,
};
console.log('๐ Running workflow with', currentWorkflow.nodes.length, 'nodes');
await runWorkflow(currentWorkflow, input);
}, [workflow, nodes, edges, runWorkflow, saveWorkflowImmediate]);
const handleShowTestAPI = useCallback(() => {
// Save workflow before opening API panel
if (workflow) {
saveWorkflow({
nodes: nodes.map(n => ({
...n,
type: n.type || 'default',
data: {
...n.data,
label: typeof n.data.label === 'string' ? n.data.label : 'Node',
},
})) as any,
edges: edges as any,
});
}
setShowPreview(false);
setShowExecution(false);
setSelectedNode(null); // Close node panel
setShowTestEndpoint(true);
}, [workflow, nodes, edges, saveWorkflow]);
const handleSaveWorkflow = useCallback(() => {
if (!workflow) {
toast.error('No workflow to save');
return;
}
// Serialize nodes by removing React elements (labels) and keeping only data
const serializedNodes = nodes.map(n => ({
...n,
data: {
...n.data,
// Don't save the React element label - save the text/name instead
label: n.data.nodeName || n.type, // Save simple text, not JSX
nodeType: n.data.nodeType || n.type, // Ensure nodeType is saved
},
}));
console.log('๐พ Saving', serializedNodes.length, 'serialized nodes');
// Force immediate save with all current data
saveWorkflow({
nodes: serializedNodes as unknown as WorkflowNode[],
edges: edges as unknown as WorkflowEdge[],
name: workflow.name,
description: workflow.description,
});
toast.success('Workflow saved', {
description: `${nodes.length} nodes, ${edges.length} connections saved to Convex`,
});
}, [workflow, nodes, edges, saveWorkflow]);
const handleUpdateNodeData = useCallback((nodeId: string, data: any) => {
try {
setNodes((nds) => {
const updated = nds.map((node) => {
if (node.id === nodeId) {
const updatedData = {
...node.data,
...data,
};
// If name is being updated, also update the label
if (data.name && data.name !== (node.data as any).nodeName) {
const nodeType = (node.data as any).nodeType;
// Find the node configuration to get the icon and color
let IconComponent: any = null;
let color = "bg-gray-500";
for (const category of nodeCategories) {
const nodeConfig = category.nodes.find(n => n.type === nodeType);
if (nodeConfig) {
IconComponent = nodeConfig.icon;
color = nodeConfig.color;
break;
}
}
// Create the new label with the updated name
updatedData.label = (
{IconComponent ? (
) : (
)}
{data.name}
);
}
return {
...node,
data: updatedData,
};
}
return node;
});
// Immediately persist to Convex after updating React state
if (workflow) {
updateNodes(updated as any);
}
return updated;
});
} catch (error) {
console.error('Error updating node data:', error);
toast.error('Failed to update node', {
description: error instanceof Error ? error.message : 'Unable to save node changes',
});
}
}, [setNodes, workflow, updateNodes]);
// Inject onUpdate callback into note nodes for inline editing
useEffect(() => {
setNodes((nds) =>
nds.map((node) => {
const nodeType = (node.data as any)?.nodeType;
if (nodeType === 'note' && !(node.data as any).onUpdate) {
return {
...node,
data: {
...node.data,
onUpdate: (updates: any) => handleUpdateNodeData(node.id, updates),
},
};
}
return node;
})
);
}, [nodes.length]); // Only re-run when node count changes
return (
{/* Workflow Name Editor */}
)}
/>
{/* Top Header */}
{/* Duplicate Credential Warning */}
{duplicateWarnings.length > 0 && (
)}
{showWorkflowMenu && (
)}
{environment === 'production' && (
)}
{!isRunning ? (
) : (
)}
{/* Left Sidebar */}
{/* Node Types */}
{nodeCategories.map((category) => (
{category.category}
{category.nodes.map((node) => {
const Icon = node.icon;
return (
onDragStart(e as any, node.type, node.label, node.color)}
className="rounded-8 px-10 py-8 hover:bg-black-alpha-4 transition-all cursor-move flex items-center gap-10"
whileTap={{ scale: 0.98 }}
>
{node.label}
);
})}
))}
{/* Main Canvas */}
{/* Right Side Panels */}
{showTestEndpoint && workflow ? (
({
id: n.id,
type: n.type,
position: n.position,
data: n.data,
})) as any,
}}
environment={environment}
onClose={() => setShowTestEndpoint(false)}
/>
) : showExecution ? (
({
id: n.id,
type: n.type,
position: n.position,
data: n.data,
})) as any,
} : null}
execution={execution}
nodeResults={nodeResults}
isRunning={isRunning}
currentNodeId={currentNodeId}
onRun={handleRunWithInput}
onResumePendingAuth={resumeWorkflow}
onClose={() => setShowExecution(false)}
environment={environment}
pendingAuth={pendingAuth}
/>
) : showPreview ? (
setShowPreview(false)}
/>
) : (selectedNode?.data as any)?.nodeType === 'mcp' ? (
setSelectedNode(null)}
onUpdate={handleUpdateNodeData}
onOpenSettings={() => setShowSettings(true)}
/>
) : (selectedNode?.data as any)?.nodeType?.includes('if') || (selectedNode?.data as any)?.nodeType?.includes('while') || (selectedNode?.data as any)?.nodeType?.includes('approval') ? (
setSelectedNode(null)}
onDelete={handleDeleteNode}
onUpdate={handleUpdateNodeData}
/>
) : (selectedNode?.data as any)?.nodeType?.includes('transform') ? (
setSelectedNode(null)}
onDelete={handleDeleteNode}
onUpdate={handleUpdateNodeData}
/>
) : (selectedNode?.data as any)?.nodeType === 'extract' ? (
setSelectedNode(null)}
onDelete={handleDeleteNode}
onUpdate={handleUpdateNodeData}
/>
) : (selectedNode?.data as any)?.nodeType === 'http' ? (
setSelectedNode(null)}
onDelete={handleDeleteNode}
onUpdate={handleUpdateNodeData}
/>
) : (selectedNode?.data as any)?.nodeType?.includes('set-state') ? (
setSelectedNode(null)}
onDelete={handleDeleteNode}
onUpdate={handleUpdateNodeData}
/>
) : (selectedNode?.data as any)?.nodeType === 'start' ? (
setSelectedNode(null)}
onUpdate={handleUpdateNodeData}
/>
) : (selectedNode?.data as any)?.nodeType !== 'end' && (selectedNode?.data as any)?.nodeType !== 'note' && selectedNode ? (
setSelectedNode(null)}
onAddMCP={() => {
setTargetAgentForMCP(selectedNode);
setShowMCPSelector(true);
}}
onDelete={handleDeleteNode}
onUpdate={handleUpdateNodeData}
onOpenSettings={() => setShowSettings(true)}
/>
) : null}
{/* MCP Selector for adding to agents */}
{showMCPSelector && (
{
setShowMCPSelector(false);
setTargetAgentForMCP(null);
}}
onUpdate={() => {}}
onOpenSettings={() => setShowSettings(true)}
onAddToAgent={(mcpConfig) => {
if (targetAgentForMCP) {
const currentTools = (targetAgentForMCP.data as any).mcpTools || [];
handleUpdateNodeData(targetAgentForMCP.id, {
mcpTools: [...currentTools, mcpConfig],
});
toast.success('MCP added to agent', {
description: `${mcpConfig.availableTools.length} tools available`,
});
}
setShowMCPSelector(false);
setTargetAgentForMCP(null);
}}
/>
)}
{/* Settings Panel */}
setShowSettings(false)}
/>
{/* Share Workflow Modal */}
setShowShareModal(false)}
workflowId={workflow?.id || ''}
workflowName={workflow?.name || 'Workflow'}
/>
{/* Save as Template Modal */}
setShowSaveAsTemplateModal(false)}
workflowId={convexId || ''}
workflowName={workflow?.name || 'Workflow'}
/>
{/* Coming Soon Modal */}
{showComingSoonModal && (
setShowComingSoonModal(false)}
>
e.stopPropagation()}
className="bg-accent-white rounded-16 shadow-2xl w-[480px] overflow-hidden"
>
Coming Soon
File Search Node
This feature is currently in development
The File Search node will allow you to search through files and code in your workflows. Stay tuned for updates!
)}
{/* Confirm Dialog */}
setConfirmDialog({ ...confirmDialog, isOpen: false })}
/>
{/* Edge Label Modal */}
setEditingEdge(null)}
onSave={handleSaveEdgeLabel}
/>
{/* Selected Edge Actions */}
{selectedEdgeId && !selectedNode && (
)}
{/* Context Menu */}
{contextMenu && (
)}
);
}
export default function WorkflowBuilder(props: WorkflowBuilderProps) {
return (
);
}