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
This commit is contained in:
bboxwtf
2026-03-22 01:50:57 +00:00
parent 5ff2ade579
commit c1e8774009
4 changed files with 796 additions and 198 deletions

View File

@@ -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<string, any>;
}
type EdgeType = "default" | "success" | "error" | "loop" | "condition-true" | "condition-false";
const EDGE_STYLES: Record<EdgeType, { stroke: string; dash?: string; width: number }> = {
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<string, {
status: "pending" | "running" | "success" | "failed" | "skipped";
output?: string;
@@ -54,6 +93,8 @@ interface WorkflowCanvasProps {
onBack: () => 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<WFNodeData[]>(initialNodes);
const [edges, setEdges] = useState<WFEdgeData[]>(initialEdges);
const [nodes, setNodes] = useState<WFNodeData[]>([]);
const [edges, setEdges] = useState<WFEdgeData[]>([]);
const [selectedNodeKey, setSelectedNodeKey] = useState<string | null>(null);
const [selectedEdgeKey, setSelectedEdgeKey] = useState<string | null>(null);
const [editingNode, setEditingNode] = useState<WFNodeData | null>(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<HTMLDivElement>(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 (
<div className="flex flex-col h-full">
{/* Toolbar */}
@@ -342,14 +488,14 @@ export default function WorkflowCanvas({
</Badge>
</div>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" onClick={() => setZoom((z) => Math.min(z + 0.1, 2))} className="h-7 w-7 p-0">
<Button size="sm" variant="outline" onClick={() => setZoom((z) => Math.min(z + 0.15, 2))} className="h-7 w-7 p-0">
<ZoomIn className="w-3.5 h-3.5" />
</Button>
<span className="text-[10px] font-mono text-muted-foreground w-10 text-center">{Math.round(zoom * 100)}%</span>
<Button size="sm" variant="outline" onClick={() => setZoom((z) => Math.max(z - 0.1, 0.3))} className="h-7 w-7 p-0">
<Button size="sm" variant="outline" onClick={() => setZoom((z) => Math.max(z - 0.15, 0.2))} className="h-7 w-7 p-0">
<ZoomOut className="w-3.5 h-3.5" />
</Button>
<Button size="sm" variant="outline" onClick={() => { setZoom(1); setPan({ x: 0, y: 0 }); }} className="h-7 w-7 p-0">
<Button size="sm" variant="outline" onClick={() => { setZoom(1); setPan({ x: 60, y: 60 }); }} className="h-7 w-7 p-0" title="Reset view">
<Maximize2 className="w-3.5 h-3.5" />
</Button>
<div className="h-5 w-px bg-border/50" />
@@ -388,10 +534,28 @@ export default function WorkflowCanvas({
</div>
))}
{/* Edge type legend */}
<div className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider mt-4 mb-2">Edge Types</div>
<div className="space-y-1">
{([
["success", "#22c55e", "solid"],
["error", "#ef4444", "dashed"],
["loop", "#f59e0b", "dashed"],
["default", "#475569", "solid"],
["true", "#22c55e", "solid"],
["false", "#ef4444", "dashed"],
] as const).map(([label, color, style]) => (
<div key={label} className="flex items-center gap-2 px-2 py-1 text-[10px] font-mono text-muted-foreground">
<svg width="24" height="6"><line x1="0" y1="3" x2="24" y2="3" stroke={color} strokeWidth="2" strokeDasharray={style === "dashed" ? "4 3" : "none"} /></svg>
{label}
</div>
))}
</div>
{/* Quick agent list */}
{agents.length > 0 && (
<>
<div className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider mt-4 mb-2">Available Agents</div>
<div className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider mt-4 mb-2">Agents</div>
{agents.slice(0, 10).map((agent: any) => (
<div
key={agent.id}
@@ -426,7 +590,7 @@ export default function WorkflowCanvas({
if (!kind || !canvasRef.current) return;
const rect = canvasRef.current.getBoundingClientRect();
const posX = Math.round((e.clientX - rect.left - pan.x) / zoom - 110);
const posX = Math.round((e.clientX - rect.left - pan.x) / zoom - NODE_WIDTH / 2);
const posY = Math.round((e.clientY - rect.top - pan.y) / zoom - 30);
const agentIdStr = e.dataTransfer.getData("agentId");
@@ -443,12 +607,13 @@ export default function WorkflowCanvas({
};
setNodes((prev) => [...prev, newNode]);
}}
onDragOver={handleDragOver}
onDragOver={(e) => e.preventDefault()}
>
{/* Grid pattern */}
<svg className="absolute inset-0 w-full h-full pointer-events-none opacity-20">
<defs>
<pattern id="grid" width={20 * zoom} height={20 * zoom} patternUnits="userSpaceOnUse" x={pan.x % (20 * zoom)} y={pan.y % (20 * zoom)}>
<pattern id="grid" width={20 * zoom} height={20 * zoom} patternUnits="userSpaceOnUse"
x={pan.x % (20 * zoom)} y={pan.y % (20 * zoom)}>
<circle cx="1" cy="1" r="1" fill="#334155" />
</pattern>
</defs>
@@ -461,50 +626,93 @@ export default function WorkflowCanvas({
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
>
{/* Edges SVG */}
<svg className="absolute inset-0 pointer-events-none" style={{ width: 5000, height: 5000, overflow: "visible" }}>
<svg className="absolute inset-0 pointer-events-none" style={{ width: 8000, height: 8000, overflow: "visible" }}>
{/* Arrow markers */}
<defs>
<marker id="arrow-default" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
<path d="M 0 0 L 8 4 L 0 8 Z" fill="#475569" />
</marker>
<marker id="arrow-success" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
<path d="M 0 0 L 8 4 L 0 8 Z" fill="#22c55e" />
</marker>
<marker id="arrow-error" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
<path d="M 0 0 L 8 4 L 0 8 Z" fill="#ef4444" />
</marker>
<marker id="arrow-loop" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
<path d="M 0 0 L 8 4 L 0 8 Z" fill="#f59e0b" />
</marker>
<marker id="arrow-cyan" markerWidth="8" markerHeight="8" refX="6" refY="4" orient="auto">
<path d="M 0 0 L 8 4 L 0 8 Z" fill="#00D4FF" />
</marker>
</defs>
{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 (
<g key={edge.edgeKey}>
{/* Hit area (wider invisible stroke for clicking) */}
{/* Hit area */}
<path
d={buildEdgePath(src.x, src.y, tgt.x, tgt.y)}
d={path}
fill="none"
stroke="transparent"
strokeWidth={12}
strokeWidth={14}
className="pointer-events-auto cursor-pointer"
onClick={(e) => { e.stopPropagation(); setSelectedEdgeKey(edge.edgeKey); setSelectedNodeKey(null); }}
onClick={(ev) => { ev.stopPropagation(); setSelectedEdgeKey(edge.edgeKey); setSelectedNodeKey(null); }}
/>
{/* Visible edge */}
<path
d={buildEdgePath(src.x, src.y, tgt.x, tgt.y)}
d={path}
fill="none"
stroke={isSelected ? "#00D4FF" : "#334155"}
strokeWidth={isSelected ? 2.5 : 2}
strokeDasharray={isSelected ? "none" : "none"}
markerEnd=""
stroke={isSelected ? "#00D4FF" : style.stroke}
strokeWidth={isSelected ? 3 : style.width}
strokeDasharray={style.dash}
markerEnd={`url(#${markerKey})`}
/>
{/* Arrow marker */}
<circle cx={tgt.x} cy={tgt.y} r={3} fill={isSelected ? "#00D4FF" : "#334155"} />
{/* Edge label */}
{edge.label && (
<text
x={(src.x + tgt.x) / 2}
y={(src.y + tgt.y) / 2 - 8}
textAnchor="middle"
className="pointer-events-none select-none"
fill={isSelected ? "#00D4FF" : style.stroke}
fontSize={10}
fontFamily="JetBrains Mono, monospace"
fontWeight="600"
>
{edge.label}
</text>
)}
</g>
);
})}
{/* 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 (
<path
d={buildEdgePath(sx, sy, edgeDrawing.mouseX, edgeDrawing.mouseY)}
d={buildEdgePath(srcPos.x, srcPos.y, edgeDrawing.mouseX, edgeDrawing.mouseY, false)}
fill="none"
stroke="#00D4FF"
strokeWidth={2}
strokeDasharray="6 3"
opacity={0.7}
markerEnd="url(#arrow-cyan)"
/>
);
})()}
@@ -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}
/>
))}
</div>
@@ -536,6 +746,23 @@ export default function WorkflowCanvas({
</div>
</div>
)}
{/* Selected edge action bar */}
{selectedEdgeKey && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-2 px-3 py-2 rounded-lg bg-secondary/90 border border-border/50 backdrop-blur">
<span className="text-[10px] font-mono text-muted-foreground">Edge: {selectedEdgeKey.slice(0, 16)}</span>
<Button size="sm" variant="ghost" className="h-6 text-[10px] text-neon-red" onClick={() => handleDeleteEdge(selectedEdgeKey)}>
Delete
</Button>
</div>
)}
{/* Edge drawing indicator */}
{edgeDrawing && (
<div className="absolute top-3 right-3 px-3 py-1.5 rounded-md bg-primary/20 border border-primary/40 text-[10px] font-mono text-primary animate-pulse">
Drawing edge from {edgeDrawing.sourceHandleId} &mdash; click target port to connect
</div>
)}
</div>
</div>

View File

@@ -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<NodeKind, { icon: any; color: string; bg: string; border: string; label: string }> = {
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 (
<div
key={port.id}
className="absolute z-30 group/port"
style={{
left: pos.x - 7,
top: pos.y - 7,
}}
onMouseDown={(e) => {
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 */}
<div
className="w-[14px] h-[14px] rounded-full border-2 cursor-crosshair transition-all hover:scale-[1.6] hover:shadow-lg"
style={{
borderColor: port.color,
backgroundColor: `${port.color}33`,
}}
/>
{/* Port label — always visible on hover */}
<span
className={`absolute whitespace-nowrap text-[9px] font-mono font-semibold opacity-70 group-hover/port:opacity-100 transition-opacity pointer-events-none select-none`}
style={{
color: port.color,
...(isTop ? { bottom: '100%', left: '50%', transform: 'translateX(-50%)', marginBottom: 2 } : {}),
...(isBottom ? { top: '100%', left: '50%', transform: 'translateX(-50%)', marginTop: 2 } : {}),
...(isLeft ? { top: '50%', right: '100%', transform: 'translateY(-50%)', marginRight: 4 } : {}),
...(isRight ? { top: '50%', left: '100%', transform: 'translateY(-50%)', marginLeft: 4 } : {}),
}}
>
{port.label}
</span>
</div>
);
};
// 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 (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
<div
className={`
absolute select-none cursor-grab active:cursor-grabbing
w-[220px] rounded-lg border ${cfg.border} ${cfg.bg}
rounded-lg border ${cfg.border} ${cfg.bg}
${selected ? "ring-2 ring-primary/60 shadow-lg shadow-primary/10" : ""}
${node.runStatus === "running" ? "ring-2 ring-primary/40 animate-pulse" : ""}
backdrop-blur-sm transition-shadow
`}
style={{ left: node.posX, top: node.posY }}
style={{ left: node.posX, top: node.posY, width: NODE_WIDTH }}
onClick={(e) => { 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" && (
<div
className="absolute -top-2 left-1/2 -translate-x-1/2 w-4 h-4 rounded-full bg-secondary border-2 border-border hover:border-primary hover:bg-primary/20 cursor-crosshair z-20 transition-colors"
onMouseDown={(e) => { 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 */}
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/30">
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border/30" style={{ height: NODE_HEADER_H }}>
<GripVertical className="w-3 h-3 text-muted-foreground/50 shrink-0" />
<div className={`w-7 h-7 rounded-md ${cfg.bg} border ${cfg.border} flex items-center justify-center shrink-0`}>
<Icon className={`w-4 h-4 ${cfg.color}`} />
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-semibold text-foreground truncate">{node.label}</div>
<div className={`text-[10px] font-mono ${cfg.color}`}>{cfg.label}</div>
<div className={`text-[10px] font-mono ${cfg.color}`}>
{cfg.label}
{node.meta?.aggregator && " (aggregator)"}
{node.meta?.orchestrator && " (orchestrator)"}
</div>
</div>
{/* Status overlay */}
{statusOverlay && StatusIcon && (
<StatusIcon
className={`w-4 h-4 ${statusOverlay.color} shrink-0 ${statusOverlay.animate ? "animate-spin" : ""}`}
@@ -126,7 +324,7 @@ export function WorkflowNodeBlock({
</div>
{/* Body */}
<div className="px-3 py-2 space-y-1">
<div className="px-3 py-2 space-y-0.5" style={{ minHeight: dynamicBodyH }}>
{node.kind === "agent" && (
<div className="text-[10px] font-mono text-muted-foreground">
{node.agentName ? (
@@ -169,10 +367,24 @@ export function WorkflowNodeBlock({
)}
{node.kind === "output" && (
<div className="text-[10px] font-mono text-muted-foreground">
Final output
{node.meta?.aggregator ? "Aggregator — collects data" : "Final output"}
</div>
)}
{/* Ports summary */}
<div className="flex items-center gap-1 pt-0.5">
{inputs.length > 0 && (
<Badge variant="outline" className="text-[8px] font-mono px-1 py-0 bg-transparent border-border/30 text-muted-foreground">
{inputs.length} in
</Badge>
)}
{outputs.length > 0 && (
<Badge variant="outline" className="text-[8px] font-mono px-1 py-0 bg-transparent border-border/30 text-muted-foreground">
{outputs.length} out
</Badge>
)}
</div>
{/* Runtime info */}
{node.runDurationMs !== undefined && node.runStatus !== "pending" && node.runStatus !== "running" && (
<div className="text-[10px] font-mono text-muted-foreground">
@@ -190,33 +402,34 @@ export function WorkflowNodeBlock({
{selected && (
<div className="flex items-center gap-1 px-2 py-1.5 border-t border-border/30">
<Button
size="sm"
variant="ghost"
size="sm" variant="ghost"
className="h-6 w-6 p-0 text-muted-foreground hover:text-primary"
onClick={(e) => { e.stopPropagation(); onEdit?.(); }}
title="Edit"
>
<Settings className="w-3 h-3" />
</Button>
{onTest && (
<Button
size="sm" variant="ghost"
className="h-6 w-6 p-0 text-muted-foreground hover:text-neon-green"
onClick={(e) => { e.stopPropagation(); onTest(); }}
title="Test node"
>
<TestTube className="w-3 h-3" />
</Button>
)}
<Button
size="sm"
variant="ghost"
className="h-6 w-6 p-0 text-muted-foreground hover:text-neon-red"
size="sm" variant="ghost"
className="h-6 w-6 p-0 text-muted-foreground hover:text-neon-red ml-auto"
onClick={(e) => { e.stopPropagation(); onDelete?.(); }}
title="Delete"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
)}
{/* Output port */}
{showPorts && node.kind !== "output" && (
<div
className="absolute -bottom-2 left-1/2 -translate-x-1/2 w-4 h-4 rounded-full bg-secondary border-2 border-border hover:border-primary hover:bg-primary/20 cursor-crosshair z-20 transition-colors"
onMouseDown={(e) => { e.stopPropagation(); onPortMouseDown?.(node.nodeKey, "output", e); }}
title="Output"
/>
)}
</motion.div>
</div>
);
}

View File

@@ -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<number>((node.meta?.extraInputs as number) ?? 0);
const [extraOutputs, setExtraOutputs] = useState<number>((node.meta?.extraOutputs as number) ?? 0);
const [isAggregator, setIsAggregator] = useState<boolean>(node.meta?.aggregator === true);
const [isOrchestrator, setIsOrchestrator] = useState<boolean>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md bg-card border-border">
<DialogContent className="max-w-md bg-card border-border max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<KindIcon className="w-5 h-5 text-primary" />
@@ -119,35 +139,61 @@ export function WorkflowNodeEditModal({
{/* Agent config */}
{node.kind === "agent" && (
<div>
<Label className="text-xs text-muted-foreground font-mono">Agent</Label>
<Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select an agent" />
</SelectTrigger>
<SelectContent>
{agents.map((agent: any) => (
<SelectItem key={agent.id} value={String(agent.id)}>
<div className="flex items-center gap-2">
<span>{agent.name}</span>
<Badge variant="outline" className="text-[9px] font-mono">{agent.role}</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{agentId && (() => {
const a = agents.find((ag: any) => ag.id === Number(agentId));
if (!a) return null;
return (
<div className="mt-2 p-2 rounded bg-secondary/30 border border-border/30 text-[10px] font-mono space-y-1">
<div>Model: <span className="text-primary">{a.model}</span></div>
<div>Provider: <span className="text-muted-foreground">{a.provider}</span></div>
{a.description && <div className="text-muted-foreground">{a.description}</div>}
<>
<div>
<Label className="text-xs text-muted-foreground font-mono">Agent</Label>
<Select value={agentId} onValueChange={setAgentId}>
<SelectTrigger className="mt-1">
<SelectValue placeholder="Select an agent" />
</SelectTrigger>
<SelectContent>
{agents.map((agent: any) => (
<SelectItem key={agent.id} value={String(agent.id)}>
<div className="flex items-center gap-2">
<span>{agent.name}</span>
<Badge variant="outline" className="text-[9px] font-mono">{agent.role}</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{agentId && (() => {
const a = agents.find((ag: any) => ag.id === Number(agentId));
if (!a) return null;
return (
<div className="mt-2 p-2 rounded bg-secondary/30 border border-border/30 text-[10px] font-mono space-y-1">
<div>Model: <span className="text-primary">{a.model}</span></div>
<div>Provider: <span className="text-muted-foreground">{a.provider}</span></div>
{a.description && <div className="text-muted-foreground">{a.description}</div>}
</div>
);
})()}
</div>
{/* Orchestrator mode */}
<div className="p-3 rounded-md border border-border/30 bg-secondary/10">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Network className="w-3.5 h-3.5 text-cyan-400" />
<Label className="text-xs text-muted-foreground font-mono">Orchestrator Mode</Label>
</div>
);
})()}
</div>
<Button
size="sm"
variant={isOrchestrator ? "default" : "outline"}
className="h-6 text-[10px]"
onClick={() => {
setIsOrchestrator(!isOrchestrator);
if (!isOrchestrator) setExtraOutputs(Math.max(extraOutputs, 2));
}}
>
{isOrchestrator ? "ON" : "OFF"}
</Button>
</div>
<p className="text-[10px] text-muted-foreground">
Adds multiple output ports for routing to different downstream agents.
</p>
</div>
</>
)}
{/* Container config */}
@@ -194,7 +240,7 @@ export function WorkflowNodeEditModal({
className="mt-1 font-mono text-xs min-h-[60px]"
/>
<p className="text-[10px] text-muted-foreground mt-1">
Evaluates to true/false. If false, downstream nodes are skipped.
Evaluates to true/false. Routes data to TRUE or FALSE output ports.
</p>
</div>
)}
@@ -239,6 +285,87 @@ export function WorkflowNodeEditModal({
)}
</>
)}
{/* Output / Aggregator config */}
{node.kind === "output" && (
<div className="p-3 rounded-md border border-border/30 bg-secondary/10">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Network className="w-3.5 h-3.5 text-purple-400" />
<Label className="text-xs text-muted-foreground font-mono">Aggregator Mode</Label>
</div>
<Button
size="sm"
variant={isAggregator ? "default" : "outline"}
className="h-6 text-[10px]"
onClick={() => {
setIsAggregator(!isAggregator);
if (!isAggregator) setExtraInputs(Math.max(extraInputs, 3));
}}
>
{isAggregator ? "ON" : "OFF"}
</Button>
</div>
<p className="text-[10px] text-muted-foreground">
Collects data from multiple sources. Adds extra input ports and a merged output.
</p>
</div>
)}
{/* Extra ports configuration — available for all node kinds */}
<div className="p-3 rounded-md border border-border/30 bg-secondary/10 space-y-3">
<div className="text-[10px] font-mono text-muted-foreground uppercase tracking-wider">Port Configuration</div>
{/* Extra inputs */}
{node.kind !== "trigger" && (
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground font-mono">Extra Input Ports</Label>
<div className="flex items-center gap-2">
<Button
size="sm" variant="outline" className="h-6 w-6 p-0"
onClick={() => setExtraInputs(Math.max(0, extraInputs - 1))}
disabled={extraInputs === 0}
>
<Minus className="w-3 h-3" />
</Button>
<span className="text-xs font-mono w-6 text-center text-foreground">{extraInputs}</span>
<Button
size="sm" variant="outline" className="h-6 w-6 p-0"
onClick={() => setExtraInputs(Math.min(8, extraInputs + 1))}
>
<Plus className="w-3 h-3" />
</Button>
</div>
</div>
)}
{/* Extra outputs */}
{node.kind !== "output" || isAggregator ? (
<div className="flex items-center justify-between">
<Label className="text-xs text-muted-foreground font-mono">Extra Output Ports</Label>
<div className="flex items-center gap-2">
<Button
size="sm" variant="outline" className="h-6 w-6 p-0"
onClick={() => setExtraOutputs(Math.max(0, extraOutputs - 1))}
disabled={extraOutputs === 0}
>
<Minus className="w-3 h-3" />
</Button>
<span className="text-xs font-mono w-6 text-center text-foreground">{extraOutputs}</span>
<Button
size="sm" variant="outline" className="h-6 w-6 p-0"
onClick={() => setExtraOutputs(Math.min(8, extraOutputs + 1))}
>
<Plus className="w-3 h-3" />
</Button>
</div>
</div>
) : null}
<p className="text-[10px] text-muted-foreground">
Use extra ports for aggregator/orchestrator patterns. Side ports appear on left (inputs) and right (outputs).
</p>
</div>
</div>
<div className="flex justify-end gap-2 pt-4 border-t border-border/30">

View File

@@ -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 (
<div className="-m-6 h-[calc(100vh-3.5rem)] flex items-center justify-center bg-[#0A0E1A]">
<div className="text-center">
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-3" />
<p className="text-sm font-mono text-muted-foreground">Loading workflow canvas...</p>
</div>
</div>
);
}
const wfName = selectedWorkflow?.name ?? "Workflow";
return (
<div className="-m-6 h-[calc(100vh-3.5rem)]">
<WorkflowCanvas
workflowId={selectedWorkflowId}
workflowName={selectedWorkflow.name}
workflowName={wfName}
initialNodes={canvasNodes}
initialEdges={canvasEdges}
runResults={runResults}
@@ -184,7 +204,18 @@ export default function Workflows() {
}
// ─── Dashboard View ───────────────────────────────────────────────────────
if (viewMode === "dashboard" && selectedWorkflowId && selectedWorkflow) {
if (viewMode === "dashboard" && selectedWorkflowId) {
if (isLoadingWorkflow && !selectedWorkflow) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-primary mr-3" />
<span className="text-sm font-mono text-muted-foreground">Loading workflow...</span>
</div>
);
}
const wfName = selectedWorkflow?.name ?? "Workflow";
return (
<div className="space-y-6">
<Button size="sm" variant="ghost" onClick={handleBackToList} className="text-muted-foreground hover:text-foreground mb-2">
@@ -203,7 +234,7 @@ export default function Workflows() {
<TabsContent value="dashboard" className="mt-4">
<WorkflowDashboard
workflowId={selectedWorkflowId}
workflowName={selectedWorkflow.name}
workflowName={wfName}
onOpenCanvas={() => setViewMode("canvas")}
/>
</TabsContent>
@@ -211,7 +242,7 @@ export default function Workflows() {
<div className="-mx-6 -mb-6 h-[calc(100vh-14rem)]">
<WorkflowCanvas
workflowId={selectedWorkflowId}
workflowName={selectedWorkflow.name}
workflowName={wfName}
initialNodes={canvasNodes}
initialEdges={canvasEdges}
runResults={runResults}