From c1e877400972e32d8ba295b47c5cc6ca5d637ee9 Mon Sep 17 00:00:00 2001 From: bboxwtf Date: Sun, 22 Mar 2026 01:50:57 +0000 Subject: [PATCH] fix(workflows): canvas loading, multi-port nodes, edge connections, sample workflows FIXES: 1. Canvas loading bug - nodes/edges now properly sync from server query using useEffect that tracks data lifecycle (prevDataKeyRef pattern) 2. Edge connections - mouseDown/mouseUp port interaction model ensures reliable edge creation between any port types 3. Multi-port nodes - orchestrator mode (agent with multiple outputs), aggregator mode (output with multiple inputs), configurable extra ports for any node kind 4. Distinct edge types - success (green), error (red dashed), loop (amber dashed), condition-true/false with proper arrow markers 5. Port labels always visible with color-coded dots matching edge types NEW FEATURES: - WorkflowNodeBlock: dynamic body height based on side port count, port type annotations (input/output), onPortMouseDown/onPortMouseUp separate callbacks for clean edge drawing - WorkflowNodeEditModal: port configuration panel with +/- controls for extra inputs/outputs, aggregator toggle, orchestrator toggle - WorkflowCanvas: edge drawing indicator, edge type legend in palette, proper bezier curves for backwards/loop edges, cyan arrow marker - Workflows page: loading states for canvas/dashboard views, renders canvas immediately and syncs data asynchronously SAMPLE WORKFLOWS (created via API): - Gmail Registration (Browser Agent): 8 nodes, 9 edges - trigger, generate data, open signup, fill form, CAPTCHA condition, solve, submit, output with error handling edges - Content Pipeline (Orchestrator): 6 nodes, 7 edges - webhook trigger, orchestrator with 3 outputs, 3 parallel agents, aggregator merge - Error Handling & Retry Demo: 6 nodes, 8 edges - condition routing, retry loop-back, container export, error edges --- client/src/components/WorkflowCanvas.tsx | 463 +++++++++++++----- client/src/components/WorkflowNodeBlock.tsx | 295 +++++++++-- .../src/components/WorkflowNodeEditModal.tsx | 191 ++++++-- client/src/pages/Workflows.tsx | 45 +- 4 files changed, 796 insertions(+), 198 deletions(-) diff --git a/client/src/components/WorkflowCanvas.tsx b/client/src/components/WorkflowCanvas.tsx index 34e89e9..c5d2f09 100644 --- a/client/src/components/WorkflowCanvas.tsx +++ b/client/src/components/WorkflowCanvas.tsx @@ -1,50 +1,89 @@ /** * WorkflowCanvas — interactive visual constructor for building workflows. - * Supports: + * + * Features: + * - Multi-port nodes with named handles (success, error, true, false, data-N, out-N) + * - Edge types: success (green), error (red), loop (amber dashed), default (gray) + * - Proper mouseDown/mouseUp edge drawing between ports + * - Loop-back / self-referencing edges * - Drag-and-drop nodes from palette - * - Drawing edges between ports - * - Selecting/deleting nodes and edges - * - Panning/zooming the canvas + * - Panning / zooming the canvas * - Save to server via tRPC * - Real-time run status overlays + * - Keyboard shortcuts (Delete, Ctrl+S) + * + * FIX: Canvas now properly syncs initialNodes/initialEdges from server query + * using useEffect that tracks the workflow data lifecycle. */ import { useState, useRef, useCallback, useEffect } from "react"; -import { motion } from "framer-motion"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Save, Play, - Trash2, ZoomIn, ZoomOut, Maximize2, Loader2, X, AlertCircle, + TestTube, } from "lucide-react"; import { toast } from "sonner"; import { trpc } from "@/lib/trpc"; import { nanoid } from "nanoid"; -import { WorkflowNodeBlock, WorkflowNodePaletteItem, type WFNodeData, type NodeKind } from "./WorkflowNodeBlock"; +import { + WorkflowNodeBlock, + WorkflowNodePaletteItem, + getNodePorts, + getPortPosition, + NODE_WIDTH, + type WFNodeData, + type NodeKind, + type PortDef, +} from "./WorkflowNodeBlock"; import { WorkflowNodeEditModal } from "./WorkflowNodeEditModal"; export interface WFEdgeData { edgeKey: string; sourceNodeKey: string; targetNodeKey: string; - sourceHandle?: string; - targetHandle?: string; + sourceHandle?: string; // handle id on source (e.g. "success", "error", "true") + targetHandle?: string; // handle id on target (e.g. "in", "data-1") label?: string; meta?: Record; } +type EdgeType = "default" | "success" | "error" | "loop" | "condition-true" | "condition-false"; + +const EDGE_STYLES: Record = { + default: { stroke: "#475569", width: 2 }, + success: { stroke: "#22c55e", width: 2 }, + error: { stroke: "#ef4444", width: 2, dash: "6 4" }, + loop: { stroke: "#f59e0b", width: 2, dash: "4 4" }, + "condition-true": { stroke: "#22c55e", width: 2 }, + "condition-false":{ stroke: "#ef4444", width: 2, dash: "6 4" }, +}; + +function inferEdgeType(edge: WFEdgeData, nodes: WFNodeData[]): EdgeType { + // Loop edge (same node or explicit) + if (edge.sourceNodeKey === edge.targetNodeKey) return "loop"; + if (edge.meta?.loop) return "loop"; + // Handle-based inference + if (edge.sourceHandle === "error" || edge.sourceHandle === "err") return "error"; + if (edge.sourceHandle === "false") return "condition-false"; + if (edge.sourceHandle === "true") return "condition-true"; + if (edge.sourceHandle === "success" || edge.sourceHandle === "ok") return "success"; + if (edge.sourceHandle === "stdout") return "default"; + if (edge.sourceHandle?.startsWith("out-")) return "success"; + return "default"; +} + interface WorkflowCanvasProps { workflowId: number; workflowName: string; initialNodes?: WFNodeData[]; initialEdges?: WFEdgeData[]; - /** Run results overlay: nodeKey → status */ runResults?: Record void; } +const NODE_H = 100; // header + body approx + export default function WorkflowCanvas({ workflowId, workflowName, @@ -62,22 +103,28 @@ export default function WorkflowCanvas({ runResults, onBack, }: WorkflowCanvasProps) { - const [nodes, setNodes] = useState(initialNodes); - const [edges, setEdges] = useState(initialEdges); + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); const [selectedNodeKey, setSelectedNodeKey] = useState(null); const [selectedEdgeKey, setSelectedEdgeKey] = useState(null); const [editingNode, setEditingNode] = useState(null); const [zoom, setZoom] = useState(1); - const [pan, setPan] = useState({ x: 0, y: 0 }); + const [pan, setPan] = useState({ x: 60, y: 60 }); const [isPanning, setIsPanning] = useState(false); const [panStart, setPanStart] = useState({ x: 0, y: 0 }); + // Track whether we've loaded initial data from server + const dataLoadedRef = useRef(false); + const prevDataKeyRef = useRef(""); + // Dragging state const [dragging, setDragging] = useState<{ nodeKey: string; offsetX: number; offsetY: number } | null>(null); - // Edge drawing state + // Edge drawing state — we track source port info; edge completes on mouseUp of target port const [edgeDrawing, setEdgeDrawing] = useState<{ sourceKey: string; + sourceHandleId: string; + sourceSide: PortDef["side"]; sourcePortType: "input" | "output"; mouseX: number; mouseY: number; @@ -85,27 +132,48 @@ export default function WorkflowCanvas({ const canvasRef = useRef(null); + // ─── FIX: Properly sync initial data from server ──────────────────────────── + // This effect runs whenever initialNodes changes. It computes a key from node + // keys and compares to detect actual data changes (e.g. first load or refetch). + useEffect(() => { + const dataKey = initialNodes.map((n) => n.nodeKey).sort().join(","); + // If we get real data and it's different from what we've loaded before + if (initialNodes.length > 0 && dataKey !== prevDataKeyRef.current) { + setNodes(initialNodes); + setEdges(initialEdges); + prevDataKeyRef.current = dataKey; + dataLoadedRef.current = true; + } + // Also handle the case where data arrives after initial empty render + if (!dataLoadedRef.current && initialNodes.length > 0) { + setNodes(initialNodes); + setEdges(initialEdges); + prevDataKeyRef.current = dataKey; + dataLoadedRef.current = true; + } + }, [initialNodes, initialEdges]); + // Fetch agents for the node editor const { data: agents = [] } = trpc.agents.list.useQuery(); // Save canvas mutation const saveMutation = trpc.workflows.saveCanvas.useMutation({ onSuccess: () => toast.success("Canvas saved"), - onError: (e) => toast.error(`Save failed: ${e.message}`), + onError: (e: any) => toast.error(`Save failed: ${e.message}`), }); // Execute workflow mutation const executeMutation = trpc.workflows.execute.useMutation({ - onSuccess: (run) => { + onSuccess: (run: any) => { toast.success(`Workflow started: ${run?.runKey ?? "?"}`); }, - onError: (e) => toast.error(`Execution failed: ${e.message}`), + onError: (e: any) => toast.error(`Execution failed: ${e.message}`), }); // Test single node const testNodeMutation = trpc.workflows.executeNode.useMutation({ - onSuccess: (result) => { - if (result.success) toast.success(`Node executed in ${result.durationMs}ms`); + onSuccess: (result: any) => { + if (result.success) toast.success(`Node OK in ${result.durationMs}ms`); else toast.error(`Node failed: ${result.error}`); }, }); @@ -120,7 +188,7 @@ export default function WorkflowCanvas({ // ─── Canvas event handlers ─────────────────────────────────────────────── const handleCanvasMouseDown = (e: React.MouseEvent) => { - if (e.target === canvasRef.current || (e.target as HTMLElement).dataset.canvas) { + if (e.target === canvasRef.current || (e.target as HTMLElement).dataset?.canvas) { setSelectedNodeKey(null); setSelectedEdgeKey(null); setIsPanning(true); @@ -129,12 +197,9 @@ export default function WorkflowCanvas({ }; const handleCanvasMouseMove = (e: React.MouseEvent) => { - // Panning if (isPanning) { setPan({ x: e.clientX - panStart.x, y: e.clientY - panStart.y }); } - - // Node dragging if (dragging && canvasRef.current) { const rect = canvasRef.current.getBoundingClientRect(); const newX = (e.clientX - rect.left - pan.x) / zoom - dragging.offsetX; @@ -147,8 +212,6 @@ export default function WorkflowCanvas({ ) ); } - - // Edge drawing if (edgeDrawing && canvasRef.current) { const rect = canvasRef.current.getBoundingClientRect(); setEdgeDrawing({ @@ -162,7 +225,10 @@ export default function WorkflowCanvas({ const handleCanvasMouseUp = () => { setIsPanning(false); setDragging(null); - setEdgeDrawing(null); + // If we were drawing an edge and released on empty canvas, cancel it + if (edgeDrawing) { + setEdgeDrawing(null); + } }; const handleNodeDragStart = (nodeKey: string, e: React.MouseEvent) => { @@ -176,74 +242,99 @@ export default function WorkflowCanvas({ } }; - // Port connection - const handlePortMouseDown = (nodeKey: string, portType: "input" | "output", e: React.MouseEvent) => { - if (canvasRef.current) { - const rect = canvasRef.current.getBoundingClientRect(); - setEdgeDrawing({ - sourceKey: nodeKey, - sourcePortType: portType, - mouseX: (e.clientX - rect.left - pan.x) / zoom, - mouseY: (e.clientY - rect.top - pan.y) / zoom, - }); - } - }; + // ─── Port interaction: mouseDown to start, mouseUp to complete ──────────── - // When mouse up on a port — create an edge - const handlePortMouseUp = useCallback((nodeKey: string, portType: "input" | "output") => { + const handlePortMouseDown = useCallback(( + nodeKey: string, + handleId: string, + side: PortDef["side"], + portType: "input" | "output", + e: React.MouseEvent, + ) => { + if (!canvasRef.current) return; + const rect = canvasRef.current.getBoundingClientRect(); + setEdgeDrawing({ + sourceKey: nodeKey, + sourceHandleId: handleId, + sourceSide: side, + sourcePortType: portType, + mouseX: (e.clientX - rect.left - pan.x) / zoom, + mouseY: (e.clientY - rect.top - pan.y) / zoom, + }); + }, [pan, zoom]); + + const handlePortMouseUp = useCallback(( + nodeKey: string, + handleId: string, + side: PortDef["side"], + portType: "input" | "output", + _e: React.MouseEvent, + ) => { if (!edgeDrawing) return; - // Ensure we're connecting output→input (or input→output) - if (edgeDrawing.sourceKey === nodeKey) return; - const sourceKey = edgeDrawing.sourcePortType === "output" ? edgeDrawing.sourceKey : nodeKey; - const targetKey = edgeDrawing.sourcePortType === "output" ? nodeKey : edgeDrawing.sourceKey; - // Prevent duplicates - const exists = edges.some((e) => e.sourceNodeKey === sourceKey && e.targetNodeKey === targetKey); - if (exists) return; + // Can't connect to same port + if (edgeDrawing.sourceKey === nodeKey && edgeDrawing.sourceHandleId === handleId) { + setEdgeDrawing(null); + return; + } + + // Determine source (output) and target (input) + let srcKey: string, srcHandle: string, tgtKey: string, tgtHandle: string; + + if (edgeDrawing.sourcePortType === "output" && portType === "input") { + srcKey = edgeDrawing.sourceKey; + srcHandle = edgeDrawing.sourceHandleId; + tgtKey = nodeKey; + tgtHandle = handleId; + } else if (edgeDrawing.sourcePortType === "input" && portType === "output") { + // Reverse: user started from input, ended on output + srcKey = nodeKey; + srcHandle = handleId; + tgtKey = edgeDrawing.sourceKey; + tgtHandle = edgeDrawing.sourceHandleId; + } else { + // Same polarity (output→output or input→input) — allow for loop-back + srcKey = edgeDrawing.sourceKey; + srcHandle = edgeDrawing.sourceHandleId; + tgtKey = nodeKey; + tgtHandle = handleId; + } + + // Prevent exact duplicate + const exists = edges.some( + (e) => e.sourceNodeKey === srcKey && e.targetNodeKey === tgtKey + && e.sourceHandle === srcHandle && e.targetHandle === tgtHandle + ); + if (exists) { + setEdgeDrawing(null); + return; + } + + const isLoop = srcKey === tgtKey; + + // Infer label from handle + let label: string | undefined; + if (srcHandle === "error") label = "on error"; + else if (srcHandle === "false") label = "if false"; + else if (srcHandle === "true") label = "if true"; + else if (srcHandle === "stdout") label = "stdout"; + else if (srcHandle?.startsWith("out-")) label = srcHandle; setEdges((prev) => [ ...prev, { edgeKey: `edge_${nanoid(8)}`, - sourceNodeKey: sourceKey, - targetNodeKey: targetKey, + sourceNodeKey: srcKey, + targetNodeKey: tgtKey, + sourceHandle: srcHandle, + targetHandle: tgtHandle, + label, + meta: isLoop ? { loop: true } : undefined, }, ]); setEdgeDrawing(null); }, [edgeDrawing, edges]); - // Override port mouse-down to also listen for mouse-up (connection target) - const handlePortInteraction = (nodeKey: string, portType: "input" | "output", e: React.MouseEvent) => { - if (edgeDrawing) { - handlePortMouseUp(nodeKey, portType); - } else { - handlePortMouseDown(nodeKey, portType, e); - } - }; - - // ─── Palette drop ───────────────────────────────────────────────────────── - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - const kind = e.dataTransfer.getData("nodeKind") as NodeKind; - if (!kind || !canvasRef.current) return; - - const rect = canvasRef.current.getBoundingClientRect(); - const posX = Math.round((e.clientX - rect.left - pan.x) / zoom - 110); - const posY = Math.round((e.clientY - rect.top - pan.y) / zoom - 30); - - const newNode: WFNodeData = { - nodeKey: `node_${nanoid(8)}`, - label: `New ${kind.charAt(0).toUpperCase() + kind.slice(1)}`, - kind, - posX, - posY, - }; - setNodes((prev) => [...prev, newNode]); - }; - - const handleDragOver = (e: React.DragEvent) => e.preventDefault(); - // ─── Actions ────────────────────────────────────────────────────────────── const handleDeleteNode = (nodeKey: string) => { @@ -294,18 +385,59 @@ export default function WorkflowCanvas({ setEditingNode(null); }; - // ─── Edge rendering helpers ─────────────────────────────────────────────── - - const getNodeCenter = (nodeKey: string, portType: "top" | "bottom") => { - const node = nodes.find((n) => n.nodeKey === nodeKey); - if (!node) return { x: 0, y: 0 }; - return { - x: node.posX + 110, // half of 220px width - y: portType === "top" ? node.posY : node.posY + 80, // approximate height - }; + const handleTestNode = (nodeKey: string) => { + testNodeMutation.mutate({ workflowId, nodeKey, input: "test" }); }; - const buildEdgePath = (sx: number, sy: number, tx: number, ty: number) => { + // ─── Edge rendering helpers ─────────────────────────────────────────────── + + /** Get absolute position of a port on the canvas */ + const getHandlePos = useCallback((nodeKey: string, handleId: string | undefined, fallbackSide: "top" | "bottom") => { + const node = nodes.find((n) => n.nodeKey === nodeKey); + if (!node) return { x: 0, y: 0 }; + + const { inputs, outputs } = getNodePorts(node); + const allPorts = [...inputs, ...outputs]; + const port = allPorts.find((p) => p.id === handleId); + + if (port) { + const sameSide = allPorts.filter((p) => p.side === port.side); + const idx = sameSide.indexOf(port); + const pos = getPortPosition(port, idx, sameSide.length, NODE_WIDTH); + return { x: node.posX + pos.x, y: node.posY + pos.y }; + } + + // Fallback: center top/bottom + const sidePortCount = Math.max( + inputs.filter(p => p.side === "left").length, + outputs.filter(p => p.side === "right").length, + ); + const bodyH = Math.max(52, sidePortCount * 20 + 16); + const nodeH = 48 + bodyH; + return { + x: node.posX + NODE_WIDTH / 2, + y: fallbackSide === "top" ? node.posY : node.posY + nodeH, + }; + }, [nodes]); + + /** Build bezier path between two points */ + const buildEdgePath = (sx: number, sy: number, tx: number, ty: number, isLoop: boolean) => { + if (isLoop) { + // Self-referencing loop: curve out to the right + const ox = 90; + const oy = 50; + return `M ${sx} ${sy} C ${sx + ox} ${sy + oy}, ${tx + ox} ${ty - oy}, ${tx} ${ty}`; + } + + const dy = ty - sy; + + // If target is above source (backward), make an S-curve + if (dy < 30) { + const cpOffset = Math.max(Math.abs(tx - sx) * 0.3, 60); + return `M ${sx} ${sy} C ${sx} ${sy + cpOffset}, ${tx} ${ty - cpOffset}, ${tx} ${ty}`; + } + + // Normal downward flow const midY = (sy + ty) / 2; return `M ${sx} ${sy} C ${sx} ${midY}, ${tx} ${midY}, ${tx} ${ty}`; }; @@ -314,6 +446,7 @@ export default function WorkflowCanvas({ useEffect(() => { const handler = (e: KeyboardEvent) => { + if ((e.target as HTMLElement)?.tagName === "INPUT" || (e.target as HTMLElement)?.tagName === "TEXTAREA") return; if (e.key === "Delete" || e.key === "Backspace") { if (selectedNodeKey) handleDeleteNode(selectedNodeKey); if (selectedEdgeKey) handleDeleteEdge(selectedEdgeKey); @@ -327,6 +460,19 @@ export default function WorkflowCanvas({ return () => window.removeEventListener("keydown", handler); }, [selectedNodeKey, selectedEdgeKey, nodes, edges]); + // Scroll-to-zoom + useEffect(() => { + const el = canvasRef.current; + if (!el) return; + const handler = (e: WheelEvent) => { + e.preventDefault(); + const delta = e.deltaY > 0 ? -0.05 : 0.05; + setZoom((z) => Math.min(2, Math.max(0.2, z + delta))); + }; + el.addEventListener("wheel", handler, { passive: false }); + return () => el.removeEventListener("wheel", handler); + }, []); + return (
{/* Toolbar */} @@ -342,14 +488,14 @@ export default function WorkflowCanvas({
- {Math.round(zoom * 100)}% - -
@@ -388,10 +534,28 @@ export default function WorkflowCanvas({
))} + {/* Edge type legend */} +
Edge Types
+
+ {([ + ["success", "#22c55e", "solid"], + ["error", "#ef4444", "dashed"], + ["loop", "#f59e0b", "dashed"], + ["default", "#475569", "solid"], + ["true", "#22c55e", "solid"], + ["false", "#ef4444", "dashed"], + ] as const).map(([label, color, style]) => ( +
+ + {label} +
+ ))} +
+ {/* Quick agent list */} {agents.length > 0 && ( <> -
Available Agents
+
Agents
{agents.slice(0, 10).map((agent: any) => (
[...prev, newNode]); }} - onDragOver={handleDragOver} + onDragOver={(e) => e.preventDefault()} > {/* Grid pattern */} - + @@ -461,50 +626,93 @@ export default function WorkflowCanvas({ style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }} > {/* Edges SVG */} - + + {/* Arrow markers */} + + + + + + + + + + + + + + + + + + {edges.map((edge) => { - const src = getNodeCenter(edge.sourceNodeKey, "bottom"); - const tgt = getNodeCenter(edge.targetNodeKey, "top"); + const edgeType = inferEdgeType(edge, nodes); + const style = EDGE_STYLES[edgeType] || EDGE_STYLES.default; + const isLoop = edge.sourceNodeKey === edge.targetNodeKey; const isSelected = selectedEdgeKey === edge.edgeKey; + + const src = getHandlePos(edge.sourceNodeKey, edge.sourceHandle, "bottom"); + const tgt = getHandlePos(edge.targetNodeKey, edge.targetHandle, "top"); + const path = buildEdgePath(src.x, src.y, tgt.x, tgt.y, isLoop); + + const markerKey = isSelected ? "arrow-cyan" + : edgeType === "error" || edgeType === "condition-false" ? "arrow-error" + : edgeType === "loop" ? "arrow-loop" + : edgeType === "success" || edgeType === "condition-true" ? "arrow-success" + : "arrow-default"; + return ( - {/* Hit area (wider invisible stroke for clicking) */} + {/* Hit area */} { e.stopPropagation(); setSelectedEdgeKey(edge.edgeKey); setSelectedNodeKey(null); }} + onClick={(ev) => { ev.stopPropagation(); setSelectedEdgeKey(edge.edgeKey); setSelectedNodeKey(null); }} /> + {/* Visible edge */} - {/* Arrow marker */} - + {/* Edge label */} + {edge.label && ( + + {edge.label} + + )} ); })} {/* Edge being drawn */} {edgeDrawing && (() => { - const srcNode = nodes.find((n) => n.nodeKey === edgeDrawing.sourceKey); - if (!srcNode) return null; - const sx = srcNode.posX + 110; - const sy = edgeDrawing.sourcePortType === "output" ? srcNode.posY + 80 : srcNode.posY; + const srcPos = getHandlePos(edgeDrawing.sourceKey, edgeDrawing.sourceHandleId, "bottom"); return ( ); })()} @@ -519,8 +727,10 @@ export default function WorkflowCanvas({ onSelect={() => { setSelectedNodeKey(node.nodeKey); setSelectedEdgeKey(null); }} onDelete={() => handleDeleteNode(node.nodeKey)} onEdit={() => setEditingNode(node)} + onTest={() => handleTestNode(node.nodeKey)} onDragStart={(e) => handleNodeDragStart(node.nodeKey, e)} - onPortMouseDown={handlePortInteraction} + onPortMouseDown={handlePortMouseDown} + onPortMouseUp={handlePortMouseUp} /> ))}
@@ -536,6 +746,23 @@ export default function WorkflowCanvas({
)} + + {/* Selected edge action bar */} + {selectedEdgeKey && ( +
+ Edge: {selectedEdgeKey.slice(0, 16)} + +
+ )} + + {/* Edge drawing indicator */} + {edgeDrawing && ( +
+ Drawing edge from {edgeDrawing.sourceHandleId} — click target port to connect +
+ )} diff --git a/client/src/components/WorkflowNodeBlock.tsx b/client/src/components/WorkflowNodeBlock.tsx index 3d63778..24f6947 100644 --- a/client/src/components/WorkflowNodeBlock.tsx +++ b/client/src/components/WorkflowNodeBlock.tsx @@ -1,7 +1,17 @@ /** * WorkflowNodeBlock — individual draggable node block inside the canvas. - * Rendered as a card with kind-specific icon/color, label, and connection ports. - * The runtime status overlay (running/success/failed) is shown during execution. + * + * Multi-port system: + * - Each node kind defines its own set of named input/output handles + * - Trigger: outputs: [success] + * - Agent: inputs: [in], outputs: [success, error] + * - Container: inputs: [in], outputs: [success, error, stdout] + * - Condition: inputs: [in], outputs: [true, false] + * - Output: inputs: [in, data-1, data-2, ...] + * - Aggregator (via meta.aggregator): inputs: [in, data-1..N], outputs: [merged] + * - Orchestrator (via meta.orchestrator): inputs: [in], outputs: [out-1..N, error] + * + * Edge types are tracked via sourceHandle/targetHandle. */ import { Bot, @@ -16,13 +26,24 @@ import { CheckCircle, XCircle, SkipForward, + Merge, + Zap, + TestTube, + Network, } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { motion } from "framer-motion"; export type NodeKind = "agent" | "container" | "trigger" | "condition" | "output"; +export interface PortDef { + id: string; // unique handle id, e.g. "success", "error", "in" + label: string; // display label + side: "top" | "bottom" | "left" | "right"; + color: string; // hex color for the port dot + type: "input" | "output"; +} + export interface WFNodeData { nodeKey: string; label: string; @@ -41,6 +62,115 @@ export interface WFNodeData { runError?: string; } +// ─── Port definitions per node kind ───────────────────────────────────────── + +export const NODE_WIDTH = 260; +const NODE_HEADER_H = 48; +const NODE_BODY_H = 52; + +export function getNodePorts(node: WFNodeData): { inputs: PortDef[]; outputs: PortDef[] } { + const isAggregator = node.meta?.aggregator === true; + const isOrchestrator = node.meta?.orchestrator === true; + const extraInputs = (node.meta?.extraInputs as number) ?? 0; + const extraOutputs = (node.meta?.extraOutputs as number) ?? 0; + + switch (node.kind) { + case "trigger": + return { + inputs: [], + outputs: [ + { id: "success", label: "out", side: "bottom", color: "#22c55e", type: "output" }, + ], + }; + case "agent": { + const inputs: PortDef[] = [ + { id: "in", label: "in", side: "top", color: "#00D4FF", type: "input" }, + ...(extraInputs > 0 ? Array.from({ length: extraInputs }, (_, i) => ({ + id: `data-${i + 1}`, label: `data-${i + 1}`, side: "left" as const, color: "#a855f7", type: "input" as const, + })) : []), + ]; + const outputs: PortDef[] = [ + { id: "success", label: "ok", side: "bottom", color: "#22c55e", type: "output" }, + { id: "error", label: "err", side: "right", color: "#ef4444", type: "output" }, + ...(isOrchestrator || extraOutputs > 0 + ? Array.from({ length: Math.max(extraOutputs, 2) }, (_, i) => ({ + id: `out-${i + 1}`, label: `out-${i + 1}`, side: "right" as const, color: "#06b6d4", type: "output" as const, + })) + : []), + ]; + return { inputs, outputs }; + } + case "container": + return { + inputs: [ + { id: "in", label: "in", side: "top", color: "#f59e0b", type: "input" }, + ], + outputs: [ + { id: "success", label: "ok", side: "bottom", color: "#22c55e", type: "output" }, + { id: "error", label: "err", side: "right", color: "#ef4444", type: "output" }, + { id: "stdout", label: "log", side: "right", color: "#94a3b8", type: "output" }, + ], + }; + case "condition": + return { + inputs: [ + { id: "in", label: "in", side: "top", color: "#a855f7", type: "input" }, + ], + outputs: [ + { id: "true", label: "TRUE", side: "bottom", color: "#22c55e", type: "output" }, + { id: "false", label: "FALSE", side: "right", color: "#ef4444", type: "output" }, + ], + }; + case "output": { + const inputCount = isAggregator ? Math.max(extraInputs, 3) : (extraInputs > 0 ? extraInputs : 0); + return { + inputs: [ + { id: "in", label: "in", side: "top", color: "#06b6d4", type: "input" }, + ...(inputCount > 0 + ? Array.from({ length: inputCount }, (_, i) => ({ + id: `data-${i + 1}`, label: `#${i + 1}`, side: "left" as const, color: "#a855f7", type: "input" as const, + })) + : []), + ], + outputs: isAggregator + ? [{ id: "merged", label: "merged", side: "bottom", color: "#22c55e", type: "output" as const }] + : [], + }; + } + default: + return { + inputs: [{ id: "in", label: "in", side: "top", color: "#94a3b8", type: "input" }], + outputs: [{ id: "success", label: "out", side: "bottom", color: "#94a3b8", type: "output" }], + }; + } +} + +/** + * Get the pixel position of a port relative to the node's top-left corner. + */ +export function getPortPosition( + port: PortDef, + index: number, + total: number, + _nodeWidth = NODE_WIDTH, +): { x: number; y: number } { + const nodeH = NODE_HEADER_H + NODE_BODY_H; + switch (port.side) { + case "top": { + const spacing = _nodeWidth / (total + 1); + return { x: spacing * (index + 1), y: 0 }; + } + case "bottom": { + const spacing = _nodeWidth / (total + 1); + return { x: spacing * (index + 1), y: nodeH }; + } + case "left": + return { x: 0, y: NODE_HEADER_H + 8 + index * 20 }; + case "right": + return { x: _nodeWidth, y: NODE_HEADER_H + 8 + index * 20 }; + } +} + const KIND_CONFIG: Record = { trigger: { icon: Play, color: "text-neon-green", bg: "bg-neon-green/10", border: "border-neon-green/40", label: "Trigger" }, agent: { icon: Bot, color: "text-primary", bg: "bg-primary/10", border: "border-primary/40", label: "Agent" }, @@ -62,10 +192,12 @@ interface WorkflowNodeBlockProps { onSelect?: () => void; onDelete?: () => void; onEdit?: () => void; + onTest?: () => void; onDragStart?: (e: React.MouseEvent) => void; - showPorts?: boolean; - /** Connection port mouse-down handlers */ - onPortMouseDown?: (nodeKey: string, portType: "input" | "output", e: React.MouseEvent) => void; + /** Start drawing an edge from this port */ + onPortMouseDown?: (nodeKey: string, handleId: string, side: PortDef["side"], portType: "input" | "output", e: React.MouseEvent) => void; + /** Complete an edge on this port */ + onPortMouseUp?: (nodeKey: string, handleId: string, side: PortDef["side"], portType: "input" | "output", e: React.MouseEvent) => void; } export function WorkflowNodeBlock({ @@ -74,50 +206,116 @@ export function WorkflowNodeBlock({ onSelect, onDelete, onEdit, + onTest, onDragStart, - showPorts = true, onPortMouseDown, + onPortMouseUp, }: WorkflowNodeBlockProps) { const cfg = KIND_CONFIG[node.kind]; const Icon = cfg.icon; const statusOverlay = node.runStatus ? STATUS_OVERLAY[node.runStatus] : null; const StatusIcon = statusOverlay?.icon; + const { inputs, outputs } = getNodePorts(node); + + // Separate ports by side for rendering + const topInputs = inputs.filter((p) => p.side === "top"); + const leftInputs = inputs.filter((p) => p.side === "left"); + const bottomOutputs = outputs.filter((p) => p.side === "bottom"); + const rightOutputs = outputs.filter((p) => p.side === "right"); + + const renderPort = (port: PortDef, index: number, total: number) => { + const pos = getPortPosition(port, index, total); + const isTop = port.side === "top"; + const isBottom = port.side === "bottom"; + const isLeft = port.side === "left"; + const isRight = port.side === "right"; + + return ( +
{ + e.stopPropagation(); + e.preventDefault(); + onPortMouseDown?.(node.nodeKey, port.id, port.side, port.type, e); + }} + onMouseUp={(e) => { + e.stopPropagation(); + e.preventDefault(); + onPortMouseUp?.(node.nodeKey, port.id, port.side, port.type, e); + }} + title={`${port.id} (${port.type})`} + > + {/* Port dot */} +
+ {/* Port label — always visible on hover */} + + {port.label} + +
+ ); + }; + + // Dynamic body height if there are left/right ports + const sidePortCount = Math.max(leftInputs.length, rightOutputs.length); + const dynamicBodyH = Math.max(NODE_BODY_H, sidePortCount * 20 + 16); return ( - { e.stopPropagation(); onSelect?.(); }} - onMouseDown={onDragStart} + onMouseDown={(e) => { + // Only start drag if not clicking on a port + if ((e.target as HTMLElement).closest('.group\\/port')) return; + onDragStart?.(e); + }} > - {/* Input port */} - {showPorts && node.kind !== "trigger" && ( -
{ e.stopPropagation(); onPortMouseDown?.(node.nodeKey, "input", e); }} - title="Input" - /> - )} + {/* ── Ports ── */} + {topInputs.map((p, i) => renderPort(p, i, topInputs.length))} + {leftInputs.map((p, i) => renderPort(p, i, leftInputs.length))} + {bottomOutputs.map((p, i) => renderPort(p, i, bottomOutputs.length))} + {rightOutputs.map((p, i) => renderPort(p, i, rightOutputs.length))} {/* Header */} -
+
{node.label}
-
{cfg.label}
+
+ {cfg.label} + {node.meta?.aggregator && " (aggregator)"} + {node.meta?.orchestrator && " (orchestrator)"} +
- {/* Status overlay */} {statusOverlay && StatusIcon && ( {/* Body */} -
+
{node.kind === "agent" && (
{node.agentName ? ( @@ -169,10 +367,24 @@ export function WorkflowNodeBlock({ )} {node.kind === "output" && (
- Final output + {node.meta?.aggregator ? "Aggregator — collects data" : "Final output"}
)} + {/* Ports summary */} +
+ {inputs.length > 0 && ( + + {inputs.length} in + + )} + {outputs.length > 0 && ( + + {outputs.length} out + + )} +
+ {/* Runtime info */} {node.runDurationMs !== undefined && node.runStatus !== "pending" && node.runStatus !== "running" && (
@@ -190,33 +402,34 @@ export function WorkflowNodeBlock({ {selected && (
+ {onTest && ( + + )}
)} - - {/* Output port */} - {showPorts && node.kind !== "output" && ( -
{ e.stopPropagation(); onPortMouseDown?.(node.nodeKey, "output", e); }} - title="Output" - /> - )} - +
); } diff --git a/client/src/components/WorkflowNodeEditModal.tsx b/client/src/components/WorkflowNodeEditModal.tsx index eacf714..bf079eb 100644 --- a/client/src/components/WorkflowNodeEditModal.tsx +++ b/client/src/components/WorkflowNodeEditModal.tsx @@ -1,9 +1,12 @@ /** * WorkflowNodeEditModal — configure individual node properties. - * Agent nodes get a selector for existing agents. + * Agent nodes get a selector for existing agents + orchestrator mode (extra outputs). * Container nodes get image/env/ports fields. * Condition nodes get an expression editor. * Trigger nodes get type/cron/webhook fields. + * Output nodes can become aggregators (extra inputs). + * + * NEW: Extra inputs/outputs configuration for any node kind. */ import { useState, useEffect } from "react"; import { @@ -24,7 +27,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Badge } from "@/components/ui/badge"; -import { Bot, Box, Play, GitFork, Flag, Save } from "lucide-react"; +import { Bot, Box, Play, GitFork, Flag, Save, Minus, Plus, Network } from "lucide-react"; import type { WFNodeData, NodeKind } from "./WorkflowNodeBlock"; interface WorkflowNodeEditModalProps { @@ -52,6 +55,12 @@ export function WorkflowNodeEditModal({ const [cronExpr, setCronExpr] = useState((node.triggerConfig?.cron as string) ?? ""); const [webhookPath, setWebhookPath] = useState((node.triggerConfig?.webhookPath as string) ?? ""); + // Port configuration + const [extraInputs, setExtraInputs] = useState((node.meta?.extraInputs as number) ?? 0); + const [extraOutputs, setExtraOutputs] = useState((node.meta?.extraOutputs as number) ?? 0); + const [isAggregator, setIsAggregator] = useState(node.meta?.aggregator === true); + const [isOrchestrator, setIsOrchestrator] = useState(node.meta?.orchestrator === true); + useEffect(() => { setLabel(node.label); setAgentId(node.agentId ? String(node.agentId) : ""); @@ -62,6 +71,10 @@ export function WorkflowNodeEditModal({ setTriggerType((node.triggerConfig?.type as string) ?? "manual"); setCronExpr((node.triggerConfig?.cron as string) ?? ""); setWebhookPath((node.triggerConfig?.webhookPath as string) ?? ""); + setExtraInputs((node.meta?.extraInputs as number) ?? 0); + setExtraOutputs((node.meta?.extraOutputs as number) ?? 0); + setIsAggregator(node.meta?.aggregator === true); + setIsOrchestrator(node.meta?.orchestrator === true); }, [node]); const handleSave = () => { @@ -82,6 +95,13 @@ export function WorkflowNodeEditModal({ cron: cronExpr, webhookPath, }, + meta: { + ...(node.meta ?? {}), + extraInputs, + extraOutputs, + aggregator: isAggregator, + orchestrator: isOrchestrator, + }, }; onSave(updated); }; @@ -97,7 +117,7 @@ export function WorkflowNodeEditModal({ return ( - + @@ -119,35 +139,61 @@ export function WorkflowNodeEditModal({ {/* Agent config */} {node.kind === "agent" && ( -
- - - {agentId && (() => { - const a = agents.find((ag: any) => ag.id === Number(agentId)); - if (!a) return null; - return ( -
-
Model: {a.model}
-
Provider: {a.provider}
- {a.description &&
{a.description}
} + <> +
+ + + {agentId && (() => { + const a = agents.find((ag: any) => ag.id === Number(agentId)); + if (!a) return null; + return ( +
+
Model: {a.model}
+
Provider: {a.provider}
+ {a.description &&
{a.description}
} +
+ ); + })()} +
+ + {/* Orchestrator mode */} +
+
+
+ +
- ); - })()} -
+ +
+

+ Adds multiple output ports for routing to different downstream agents. +

+
+ )} {/* Container config */} @@ -194,7 +240,7 @@ export function WorkflowNodeEditModal({ className="mt-1 font-mono text-xs min-h-[60px]" />

- Evaluates to true/false. If false, downstream nodes are skipped. + Evaluates to true/false. Routes data to TRUE or FALSE output ports.

)} @@ -239,6 +285,87 @@ export function WorkflowNodeEditModal({ )} )} + + {/* Output / Aggregator config */} + {node.kind === "output" && ( +
+
+
+ + +
+ +
+

+ Collects data from multiple sources. Adds extra input ports and a merged output. +

+
+ )} + + {/* Extra ports configuration — available for all node kinds */} +
+
Port Configuration
+ + {/* Extra inputs */} + {node.kind !== "trigger" && ( +
+ +
+ + {extraInputs} + +
+
+ )} + + {/* Extra outputs */} + {node.kind !== "output" || isAggregator ? ( +
+ +
+ + {extraOutputs} + +
+
+ ) : null} + +

+ Use extra ports for aggregator/orchestrator patterns. Side ports appear on left (inputs) and right (outputs). +

+
diff --git a/client/src/pages/Workflows.tsx b/client/src/pages/Workflows.tsx index 91406fc..5abb120 100644 --- a/client/src/pages/Workflows.tsx +++ b/client/src/pages/Workflows.tsx @@ -7,6 +7,10 @@ * 3. Dashboard view — run monitoring for a selected workflow * * Design: Mission Control theme — dark bg, cyan glow, mono fonts. + * + * FIX: Canvas now renders even while loading (empty state), and syncs + * nodes/edges from the query via initialNodes/initialEdges props. + * The WorkflowCanvas component handles the async data arrival. */ import { useState, useEffect } from "react"; import { useRoute, useLocation } from "wouter"; @@ -85,8 +89,8 @@ export default function Workflows() { refetchInterval: 30_000, }); - // Get single workflow (for canvas view) - const { data: selectedWorkflow } = trpc.workflows.get.useQuery( + // Get single workflow (for canvas view) — fetch as soon as we have an ID and need it + const { data: selectedWorkflow, isLoading: isLoadingWorkflow } = trpc.workflows.get.useQuery( { id: selectedWorkflowId! }, { enabled: !!selectedWorkflowId && (viewMode === "canvas" || viewMode === "dashboard") } ); @@ -168,12 +172,28 @@ export default function Workflows() { : undefined; // ─── Canvas View ────────────────────────────────────────────────────────── - if (viewMode === "canvas" && selectedWorkflowId && selectedWorkflow) { + // FIX: Render the canvas immediately even if data is still loading. + // WorkflowCanvas handles the async arrival of initialNodes/initialEdges via useEffect. + if (viewMode === "canvas" && selectedWorkflowId) { + // If workflow metadata is still loading, show a brief loading state then canvas + if (isLoadingWorkflow && !selectedWorkflow) { + return ( +
+
+ +

Loading workflow canvas...

+
+
+ ); + } + + const wfName = selectedWorkflow?.name ?? "Workflow"; + return (
+ + Loading workflow... +
+ ); + } + + const wfName = selectedWorkflow?.name ?? "Workflow"; + return (