"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: (
Start
), 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 && (
Connection Selected
)} {/* Context Menu */} {contextMenu && (
)} ); } export default function WorkflowBuilder(props: WorkflowBuilderProps) { return ( ); }