Files
patchy631--ai-engineering-hub/open-agent-builder/components/app/(home)/sections/workflow-builder/WorkflowBuilder.tsx
T
2026-07-13 12:37:47 +08:00

2007 lines
74 KiB
TypeScript

"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: (
<div className="flex items-center gap-8">
<div className="w-32 h-32 rounded-8 bg-gray-600 flex items-center justify-center flex-shrink-0">
<Play className="w-18 h-18 text-white" strokeWidth={2} />
</div>
<span className="text-sm font-medium text-[#18181b]">Start</span>
</div>
),
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<string | null>(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<Node | null>(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<string | null>(null);
const [editingEdge, setEditingEdge] = useState<Edge | null>(null);
const [showMCPSelector, setShowMCPSelector] = useState(false);
const [targetAgentForMCP, setTargetAgentForMCP] = useState<Node | null>(null);
const [duplicateWarnings, setDuplicateWarnings] = useState<any[]>([]);
const [showWorkflowMenu, setShowWorkflowMenu] = useState(false);
const workflowMenuRef = useRef<HTMLDivElement>(null);
const [renameTrigger, setRenameTrigger] = useState(0);
const [environment, setEnvironment] = useState<'draft' | 'production'>('draft');
const reactFlowWrapper = useRef<HTMLDivElement>(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<string>('');
// const lastSavedEdgesRef = useRef<string>('');
// const autoSaveTimeoutRef = useRef<NodeJS.Timeout | null>(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 (
<div className="flex items-center gap-8">
<div className={`w-32 h-32 rounded-8 ${color} flex items-center justify-center flex-shrink-0`}>
{IconComponent ? (
<IconComponent className="w-18 h-18 text-white" strokeWidth={2} />
) : (
<div className="w-16 h-16 bg-white rounded-2" />
)}
</div>
<span className={`text-sm font-medium ${getTextColor()}`}>{label}</span>
</div>
);
};
const getNodeColor = (type: string): string => {
const colorMap: Record<string, string> = {
'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<NodeJS.Timeout | null>(null);
const edgesSaveTimeoutRef = useRef<NodeJS.Timeout | null>(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: (
<div className="flex items-center gap-8">
<div className={`w-32 h-32 rounded-8 ${color} flex items-center justify-center flex-shrink-0`}>
{IconComponent ? (
<IconComponent className="w-18 h-18 text-white" strokeWidth={2} />
) : (
<div className="w-16 h-16 bg-white rounded-2" />
)}
</div>
<span className="text-sm font-medium text-[#18181b]">{label}</span>
</div>
),
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 = (
<div className="flex items-center gap-8">
<div className={`w-32 h-32 rounded-8 ${color} flex items-center justify-center flex-shrink-0`}>
{IconComponent ? (
<IconComponent className="w-18 h-18 text-white" strokeWidth={2} />
) : (
<div className="w-16 h-16 bg-white rounded-2" />
)}
</div>
<span className="text-sm font-medium text-[#18181b]">{data.name}</span>
</div>
);
}
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 (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
className="fixed inset-0 bg-background-base flex flex-col"
>
{/* Workflow Name Editor */}
<WorkflowNameEditor
workflow={workflow}
onUpdate={saveWorkflow}
renameTrigger={renameTrigger}
rightAccessory={(
<div className="flex items-center gap-0 border border-border-faint rounded-8 overflow-hidden bg-background-base">
<button
type="button"
onClick={() => setEnvironment('draft')}
className={`px-12 py-6 text-label-small transition-colors ${environment === 'draft' ? 'bg-heat-100 text-white shadow-sm' : 'text-black-alpha-48 hover:text-accent-black'}`}
>
Draft
</button>
<button
type="button"
onClick={() => {
// Save workflow when switching to production
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,
});
}
setEnvironment('production');
}}
className={`px-12 py-6 text-label-small transition-colors ${environment === 'production' ? 'bg-heat-100 text-white shadow-sm' : 'text-black-alpha-48 hover:text-accent-black'}`}
>
Production
</button>
</div>
)}
/>
{/* Top Header */}
<motion.div
initial={{ y: -50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.1 }}
className="fixed top-20 right-20 flex items-center gap-8 z-[60]"
>
{/* Duplicate Credential Warning */}
{duplicateWarnings.length > 0 && (
<button
onClick={() => {
const warning = duplicateWarnings[0];
toast.warning(warning.message, {
description: `Nodes: ${warning.nodeNames.join(', ')}`,
duration: 10000,
action: {
label: `${duplicateWarnings.length > 1 ? `+${duplicateWarnings.length - 1} more` : 'Dismiss'}`,
onClick: () => {
if (duplicateWarnings.length > 1) {
duplicateWarnings.forEach((w, i) => {
setTimeout(() => {
toast.warning(w.message, {
description: `Nodes: ${w.nodeNames.join(', ')}`,
duration: 8000,
});
}, i * 300);
});
}
},
},
});
}}
className="px-12 py-8 bg-yellow-50 hover:bg-yellow-100 border border-yellow-300 rounded-8 text-body-medium text-yellow-800 transition-colors flex items-center gap-8"
title="Duplicate credentials detected"
>
<svg className="w-16 h-16 animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{duplicateWarnings.length} Warning{duplicateWarnings.length > 1 ? 's' : ''}
</button>
)}
<div className="relative" ref={workflowMenuRef}>
<button
onClick={() => setShowWorkflowMenu(prev => !prev)}
className="w-36 h-36 border border-border-faint rounded-8 bg-accent-white hover:bg-black-alpha-4 text-black-alpha-48 hover:text-accent-black transition-colors flex items-center justify-center"
title="Workflow actions"
>
<MoreHorizontal className="w-18 h-18" />
</button>
{showWorkflowMenu && (
<div className="absolute right-0 mt-4 w-200 bg-accent-white border border-border-faint rounded-12 shadow-lg z-50 overflow-hidden">
<button
onClick={handleDuplicateWorkflow}
className="w-full px-16 py-10 text-left text-body-small hover:bg-black-alpha-4 transition-colors"
>
Duplicate workflow
</button>
<button
onClick={handleSaveWorkflowImmediate}
className="w-full px-16 py-10 text-left text-body-small hover:bg-black-alpha-4 transition-colors"
>
Save workflow
</button>
<button
onClick={() => {
if (!convexId) {
toast.error('Please save the workflow first', {
description: 'Make some changes and wait for auto-save to complete'
});
return;
}
setShowSaveAsTemplateModal(true);
setShowWorkflowMenu(false);
}}
className="w-full px-16 py-10 text-left text-body-small hover:bg-black-alpha-4 transition-colors border-b border-border-faint"
>
Save as Template
</button>
<button
onClick={handleClearCanvas}
className="w-full px-16 py-10 text-left text-body-small hover:bg-black-alpha-4 transition-colors"
>
Clear canvas
</button>
<button
onClick={handleRenameWorkflow}
className="w-full px-16 py-10 text-left text-body-small hover:bg-black-alpha-4 transition-colors"
>
Rename workflow
</button>
<button
onClick={confirmDeleteWorkflow}
className="w-full px-16 py-10 text-left text-body-small text-red-600 hover:bg-red-50 transition-colors"
>
Delete workflow
</button>
</div>
)}
</div>
<button
onClick={() => setShowSettings(true)}
className="px-16 py-8 bg-accent-white hover:bg-black-alpha-4 border border-border-faint rounded-8 text-body-medium text-accent-black transition-colors flex items-center gap-8"
title="API Settings"
>
<svg className="w-16 h-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</button>
{environment === 'production' && (
<button
onClick={handleShowTestAPI}
className="px-16 py-8 bg-accent-white hover:bg-black-alpha-4 border border-border-faint rounded-8 text-body-medium text-accent-black transition-colors flex items-center gap-8"
title="Test API"
>
<Server className="w-16 h-16" strokeWidth={2} />
API
</button>
)}
{!isRunning ? (
<button
onClick={handlePreview}
className={`px-16 py-8 border rounded-8 text-body-medium transition-colors flex items-center gap-8 ${
showExecution
? 'bg-heat-100 text-white border-heat-100'
: 'bg-accent-white text-accent-black border-border-faint hover:bg-black-alpha-4'
}`}
>
<svg className="w-16 h-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Run
</button>
) : (
<button
onClick={stopWorkflow}
className="px-16 py-8 border border-border-faint rounded-8 text-body-medium transition-colors flex items-center gap-8 bg-accent-white text-accent-black hover:bg-black-alpha-4"
>
<svg className="w-16 h-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} />
</svg>
Stop
</button>
)}
<button
onClick={handleSaveWorkflow}
className="px-20 py-8 bg-heat-100 hover:bg-heat-200 text-white rounded-8 text-body-medium font-medium transition-all active:scale-[0.98] flex items-center gap-8"
>
<svg className="w-16 h-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" />
</svg>
Save
</button>
</motion.div>
<div className="flex flex-1">
{/* Left Sidebar */}
<motion.aside
initial={{ x: -300, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="w-200 lg:w-200 md:w-180 sm:w-160 m-20 rounded-16 border border-border-faint bg-accent-white p-16 shadow-lg flex-shrink-0 z-10 self-start max-h-[calc(100vh-80px)] overflow-y-auto"
>
<div className="mb-24">
<button
onClick={onBack}
className="text-body-small text-black-alpha-48 hover:text-accent-black transition-colors flex items-center gap-8"
>
<svg className="w-16 h-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Back
</button>
</div>
{/* Node Types */}
<div className="space-y-12">
{nodeCategories.map((category) => (
<div key={category.category}>
<h3 className="text-xs font-semibold text-black-alpha-64 uppercase tracking-wide mb-8">
{category.category}
</h3>
<div className="space-y-2">
{category.nodes.map((node) => {
const Icon = node.icon;
return (
<motion.div
key={node.type}
draggable
onDragStart={(e) => 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 }}
>
<div className={`w-24 h-24 rounded-6 ${node.color} flex items-center justify-center flex-shrink-0`}>
<Icon className="w-14 h-14 text-white" strokeWidth={2.5} />
</div>
<span className="text-sm font-medium text-accent-black">{node.label}</span>
</motion.div>
);
})}
</div>
</div>
))}
</div>
<div className="mt-32" />
</motion.aside>
{/* Main Canvas */}
<motion.main
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.3 }}
className="flex-1 relative"
ref={reactFlowWrapper}
>
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onDrop={onDrop}
onDragOver={onDragOver}
onNodeClick={onNodeClick}
onNodeContextMenu={onNodeContextMenu}
onEdgeClick={onEdgeClick}
onEdgeDoubleClick={onEdgeDoubleClick}
onPaneClick={onPaneClick}
defaultEdgeOptions={{
type: 'smoothstep',
style: { strokeWidth: 2, cursor: 'pointer' },
interactionWidth: 20, // Make edges easier to click
}}
className="bg-background-base"
proOptions={{ hideAttribution: true }}
>
<Background
variant="dots"
color="#D0D0D0"
gap={24}
size={2}
/>
<Controls
className="!bg-accent-white !border-border-faint"
/>
</ReactFlow>
</motion.main>
{/* Right Side Panels */}
{showTestEndpoint && workflow ? (
<TestEndpointPanel
key={workflow.id}
workflowId={workflow.id}
workflow={{
...workflow,
nodes: nodes.map(n => ({
id: n.id,
type: n.type,
position: n.position,
data: n.data,
})) as any,
}}
environment={environment}
onClose={() => setShowTestEndpoint(false)}
/>
) : showExecution ? (
<ExecutionPanel
workflow={workflow ? {
...workflow,
nodes: nodes.map(n => ({
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 ? (
<PreviewPanel
execution={execution}
nodeResults={nodeResults}
isRunning={isRunning}
onClose={() => setShowPreview(false)}
/>
) : (selectedNode?.data as any)?.nodeType === 'mcp' ? (
<MCPPanel
node={selectedNode}
mode="configure"
onClose={() => 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') ? (
<LogicNodePanel
node={selectedNode}
nodes={nodes}
onClose={() => setSelectedNode(null)}
onDelete={handleDeleteNode}
onUpdate={handleUpdateNodeData}
/>
) : (selectedNode?.data as any)?.nodeType?.includes('transform') ? (
<DataNodePanel
node={selectedNode}
nodes={nodes}
onClose={() => setSelectedNode(null)}
onDelete={handleDeleteNode}
onUpdate={handleUpdateNodeData}
/>
) : (selectedNode?.data as any)?.nodeType === 'extract' ? (
<ExtractNodePanel
node={selectedNode}
nodes={nodes}
onClose={() => setSelectedNode(null)}
onDelete={handleDeleteNode}
onUpdate={handleUpdateNodeData}
/>
) : (selectedNode?.data as any)?.nodeType === 'http' ? (
<HTTPNodePanel
node={selectedNode}
nodes={nodes}
onClose={() => setSelectedNode(null)}
onDelete={handleDeleteNode}
onUpdate={handleUpdateNodeData}
/>
) : (selectedNode?.data as any)?.nodeType?.includes('set-state') ? (
<DataNodePanel
node={selectedNode}
nodes={nodes}
onClose={() => setSelectedNode(null)}
onDelete={handleDeleteNode}
onUpdate={handleUpdateNodeData}
/>
) : (selectedNode?.data as any)?.nodeType === 'start' ? (
<StartNodePanel
node={selectedNode}
onClose={() => setSelectedNode(null)}
onUpdate={handleUpdateNodeData}
/>
) : (selectedNode?.data as any)?.nodeType !== 'end' && (selectedNode?.data as any)?.nodeType !== 'note' && selectedNode ? (
<NodePanel
nodeData={{
id: selectedNode.id,
label: (selectedNode.data as any).nodeName || 'Agent',
type: (selectedNode.data as any).nodeType || 'agent',
}}
nodes={nodes}
onClose={() => setSelectedNode(null)}
onAddMCP={() => {
setTargetAgentForMCP(selectedNode);
setShowMCPSelector(true);
}}
onDelete={handleDeleteNode}
onUpdate={handleUpdateNodeData}
onOpenSettings={() => setShowSettings(true)}
/>
) : null}
{/* MCP Selector for adding to agents */}
{showMCPSelector && (
<MCPPanel
node={null}
mode="add-to-agent"
onClose={() => {
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);
}}
/>
)}
</div>
{/* Settings Panel */}
<SettingsPanel
isOpen={showSettings}
onClose={() => setShowSettings(false)}
/>
{/* Share Workflow Modal */}
<ShareWorkflowModal
isOpen={showShareModal}
onClose={() => setShowShareModal(false)}
workflowId={workflow?.id || ''}
workflowName={workflow?.name || 'Workflow'}
/>
{/* Save as Template Modal */}
<SaveAsTemplateModal
isOpen={showSaveAsTemplateModal}
onClose={() => setShowSaveAsTemplateModal(false)}
workflowId={convexId || ''}
workflowName={workflow?.name || 'Workflow'}
/>
{/* Coming Soon Modal */}
{showComingSoonModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 z-[200] flex items-center justify-center"
onClick={() => setShowComingSoonModal(false)}
>
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
onClick={(e) => e.stopPropagation()}
className="bg-accent-white rounded-16 shadow-2xl w-[480px] overflow-hidden"
>
<div className="p-24 border-b border-border-faint">
<div className="flex items-center justify-between">
<h3 className="text-xl font-semibold text-accent-black">
Coming Soon
</h3>
<button
onClick={() => setShowComingSoonModal(false)}
className="w-32 h-32 rounded-6 hover:bg-black-alpha-4 transition-colors flex items-center justify-center"
>
<svg className="w-18 h-18 text-black-alpha-48" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<div className="p-24">
<div className="flex items-center gap-16 mb-16">
<div className="w-48 h-48 rounded-12 bg-indigo-100 flex items-center justify-center flex-shrink-0">
<Search className="w-24 h-24 text-indigo-600" />
</div>
<div>
<h4 className="text-lg font-medium text-accent-black mb-4">
File Search Node
</h4>
<p className="text-sm text-black-alpha-48">
This feature is currently in development
</p>
</div>
</div>
<p className="text-body-medium text-black-alpha-64 mb-20">
The File Search node will allow you to search through files and code in your workflows. Stay tuned for updates!
</p>
<button
onClick={() => setShowComingSoonModal(false)}
className="w-full px-20 py-12 bg-heat-100 hover:bg-heat-200 text-white rounded-10 text-sm font-medium transition-colors"
>
Got it
</button>
</div>
</motion.div>
</motion.div>
)}
{/* Confirm Dialog */}
<ConfirmDialog
isOpen={confirmDialog.isOpen}
title={confirmDialog.title}
description={confirmDialog.description}
variant={confirmDialog.variant}
onConfirm={confirmDialog.onConfirm}
onCancel={() => setConfirmDialog({ ...confirmDialog, isOpen: false })}
/>
{/* Edge Label Modal */}
<EdgeLabelModal
edge={editingEdge}
isOpen={!!editingEdge}
onClose={() => setEditingEdge(null)}
onSave={handleSaveEdgeLabel}
/>
{/* Selected Edge Actions */}
{selectedEdgeId && !selectedNode && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
className="fixed bottom-80 left-1/2 -translate-x-1/2 z-50 flex items-center gap-8 px-16 py-12 bg-accent-white border border-border-faint rounded-12 shadow-2xl"
>
<div className="flex items-center gap-8 pr-12 border-r border-border-faint">
<svg className="w-16 h-16 text-heat-100" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span className="text-body-small text-accent-black font-medium">Connection Selected</span>
</div>
<button
onClick={() => {
const edge = edges.find(e => e.id === selectedEdgeId);
if (edge) setEditingEdge(edge);
}}
className="px-12 py-6 bg-background-base hover:bg-black-alpha-4 border border-border-faint rounded-6 text-body-small text-accent-black transition-colors flex items-center gap-6"
>
<svg className="w-14 h-14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
Edit Label
</button>
<button
onClick={() => {
setEdges(eds => eds.filter(e => e.id !== selectedEdgeId));
setSelectedEdgeId(null);
toast.success('Connection deleted');
}}
className="px-12 py-6 bg-red-50 hover:bg-red-100 border border-red-200 rounded-6 text-body-small text-red-700 transition-colors flex items-center gap-6"
>
<svg className="w-14 h-14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</button>
<div className="pl-12 border-l border-border-faint">
<button
onClick={() => setSelectedEdgeId(null)}
className="w-24 h-24 rounded-4 hover:bg-black-alpha-4 transition-colors flex items-center justify-center"
>
<svg className="w-12 h-12 text-black-alpha-48" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</motion.div>
)}
{/* Context Menu */}
{contextMenu && (
<div
style={{
position: 'fixed',
left: contextMenu.x,
top: contextMenu.y,
zIndex: 1000,
}}
className="bg-accent-white border border-border-faint rounded-8 shadow-lg overflow-hidden min-w-160"
>
<button
onClick={handleContextMenuDuplicate}
className="w-full px-16 py-10 text-left text-body-small text-accent-black hover:bg-black-alpha-4 transition-colors flex items-center gap-8"
>
<svg className="w-14 h-14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Duplicate
</button>
<button
onClick={handleContextMenuDelete}
className="w-full px-16 py-10 text-left text-body-small text-red-600 hover:bg-red-50 transition-colors flex items-center gap-8"
>
<svg className="w-14 h-14" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Delete
</button>
</div>
)}
</motion.div>
);
}
export default function WorkflowBuilder(props: WorkflowBuilderProps) {
return (
<ErrorBoundary>
<ReactFlowProvider>
<WorkflowBuilderInner {...props} />
</ReactFlowProvider>
</ErrorBoundary>
);
}