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:
@@ -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} — click target port to connect
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user