From 14a30d55c2e01c32822ecd970fcb08f36d905895 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 26 Apr 2026 09:35:22 +0000
Subject: [PATCH 1/2] feat(workflow): add workflow automation builder with
@xyflow/react canvas editor
Agent-Logs-Url: https://github.com/mdirshadengineer/agentflow/sessions/05f6263c-4f3a-4e09-95ef-e70b4978d542
Co-authored-by: mdirshadengineer <191547746+mdirshadengineer@users.noreply.github.com>
---
app/package.json | 1 +
.../workflow-editor/EdgeContextMenu.tsx | 60 +++++
.../workflow-editor/InspectorPanel.tsx | 90 +++++++
.../components/workflow-editor/NodePanel.tsx | 99 +++++++
.../workflow-editor/WorkflowCanvas.tsx | 223 ++++++++++++++++
.../workflow-editor/WorkflowToolbar.tsx | 123 +++++++++
.../workflow-editor/nodes/ActionNode.tsx | 37 +++
.../workflow-editor/nodes/AgentNode.tsx | 32 +++
.../workflow-editor/nodes/BaseNode.tsx | 94 +++++++
.../workflow-editor/nodes/ConditionNode.tsx | 49 ++++
.../workflow-editor/nodes/OutputNode.tsx | 37 +++
.../workflow-editor/nodes/TriggerNode.tsx | 37 +++
app/src/hooks/use-workflow.ts | 248 ++++++++++++++++++
app/src/routeTree.gen.ts | 42 ++-
app/src/routes/index.tsx | 28 +-
app/src/routes/workflows/$workflowId.tsx | 35 +++
app/src/routes/workflows/index.tsx | 143 ++++++++++
.../src/server/routes/v1/workflows/route.ts | 146 +++++++++++
pnpm-lock.yaml | 136 ++++++++++
19 files changed, 1648 insertions(+), 12 deletions(-)
create mode 100644 app/src/components/workflow-editor/EdgeContextMenu.tsx
create mode 100644 app/src/components/workflow-editor/InspectorPanel.tsx
create mode 100644 app/src/components/workflow-editor/NodePanel.tsx
create mode 100644 app/src/components/workflow-editor/WorkflowCanvas.tsx
create mode 100644 app/src/components/workflow-editor/WorkflowToolbar.tsx
create mode 100644 app/src/components/workflow-editor/nodes/ActionNode.tsx
create mode 100644 app/src/components/workflow-editor/nodes/AgentNode.tsx
create mode 100644 app/src/components/workflow-editor/nodes/BaseNode.tsx
create mode 100644 app/src/components/workflow-editor/nodes/ConditionNode.tsx
create mode 100644 app/src/components/workflow-editor/nodes/OutputNode.tsx
create mode 100644 app/src/components/workflow-editor/nodes/TriggerNode.tsx
create mode 100644 app/src/hooks/use-workflow.ts
create mode 100644 app/src/routes/workflows/$workflowId.tsx
create mode 100644 app/src/routes/workflows/index.tsx
create mode 100644 packages/cli/src/server/routes/v1/workflows/route.ts
diff --git a/app/package.json b/app/package.json
index 36600ae..ec5496d 100644
--- a/app/package.json
+++ b/app/package.json
@@ -32,6 +32,7 @@
"@tailwindcss/vite": "^4.2.1",
"@tanstack/react-router": "1.168.23",
"@tanstack/react-router-devtools": "1.166.13",
+ "@xyflow/react": "12.6.4",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"cmdk": "1.1.1",
diff --git a/app/src/components/workflow-editor/EdgeContextMenu.tsx b/app/src/components/workflow-editor/EdgeContextMenu.tsx
new file mode 100644
index 0000000..5d20f31
--- /dev/null
+++ b/app/src/components/workflow-editor/EdgeContextMenu.tsx
@@ -0,0 +1,60 @@
+import { GitBranch, Trash2 } from "lucide-react"
+
+interface EdgeContextMenuProps {
+ x: number
+ y: number
+ edgeId: string
+ onDelete: (id: string) => void
+ onAddCondition: (id: string) => void
+ onClose: () => void
+}
+
+export function EdgeContextMenu({
+ x,
+ y,
+ edgeId,
+ onDelete,
+ onAddCondition,
+ onClose,
+}: EdgeContextMenuProps) {
+ const handleDelete = () => {
+ onDelete(edgeId)
+ onClose()
+ }
+
+ const handleAddCondition = () => {
+ onAddCondition(edgeId)
+ onClose()
+ }
+
+ return (
+ <>
+ {/* Backdrop */}
+
+
+ {/* Menu */}
+
+
+
+
+
+ >
+ )
+}
diff --git a/app/src/components/workflow-editor/InspectorPanel.tsx b/app/src/components/workflow-editor/InspectorPanel.tsx
new file mode 100644
index 0000000..5e2d724
--- /dev/null
+++ b/app/src/components/workflow-editor/InspectorPanel.tsx
@@ -0,0 +1,90 @@
+import type { Node } from "@xyflow/react"
+import { Trash2 } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { cn } from "@/lib/utils"
+
+interface InspectorPanelProps {
+ selectedNode: Node | null
+ onUpdateNode: (id: string, data: Record) => void
+ onDeleteNode: (id: string) => void
+ className?: string
+}
+
+export function InspectorPanel({
+ selectedNode,
+ onUpdateNode,
+ onDeleteNode,
+ className,
+}: InspectorPanelProps) {
+ if (!selectedNode) {
+ return (
+
+
+ Select a node to inspect its properties
+
+
+ )
+ }
+
+ const data = selectedNode.data as Record
+
+ const handleChange = (key: string, value: string) => {
+ onUpdateNode(selectedNode.id, { ...data, [key]: value })
+ }
+
+ // Generic field renderers
+ const fields: Array<{ key: string; label: string }> = Object.keys(data)
+ .filter((k) => k !== "status")
+ .map((k) => ({
+ key: k,
+ label: k.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase()),
+ }))
+
+ return (
+
+
+
+ Properties
+
+
+
+
+
+
Type
+
+ {selectedNode.type}
+
+
+
+ {fields.map(({ key, label }) => (
+
+
+ handleChange(key, e.target.value)}
+ className="border-neutral-700 bg-neutral-800 text-neutral-100 text-xs"
+ />
+
+ ))}
+
+ )
+}
diff --git a/app/src/components/workflow-editor/NodePanel.tsx b/app/src/components/workflow-editor/NodePanel.tsx
new file mode 100644
index 0000000..99b3c9d
--- /dev/null
+++ b/app/src/components/workflow-editor/NodePanel.tsx
@@ -0,0 +1,99 @@
+import { Bot, GitBranch, Play, Send, Zap } from "lucide-react"
+import type { DragEvent } from "react"
+import type { WorkflowNodeType } from "@/hooks/use-workflow"
+import { cn } from "@/lib/utils"
+
+interface NodeCategory {
+ type: WorkflowNodeType
+ label: string
+ icon: React.ReactNode
+ description: string
+ color: string
+}
+
+const NODE_CATEGORIES: NodeCategory[] = [
+ {
+ type: "trigger",
+ label: "Trigger",
+ icon: ,
+ description: "Start your workflow",
+ color: "text-yellow-400 border-yellow-400/30 bg-yellow-400/5",
+ },
+ {
+ type: "action",
+ label: "Action",
+ icon: ,
+ description: "HTTP, script, transform",
+ color: "text-sky-400 border-sky-400/30 bg-sky-400/5",
+ },
+ {
+ type: "agent",
+ label: "Agent",
+ icon: ,
+ description: "AI agent invocation",
+ color: "text-violet-400 border-violet-400/30 bg-violet-400/5",
+ },
+ {
+ type: "condition",
+ label: "Condition",
+ icon: ,
+ description: "Branch on if/else",
+ color: "text-orange-400 border-orange-400/30 bg-orange-400/5",
+ },
+ {
+ type: "output",
+ label: "Output",
+ icon: ,
+ description: "Email, file, webhook",
+ color: "text-emerald-400 border-emerald-400/30 bg-emerald-400/5",
+ },
+]
+
+interface NodePanelProps {
+ className?: string
+}
+
+export function NodePanel({ className }: NodePanelProps) {
+ const onDragStart = (
+ e: DragEvent,
+ type: WorkflowNodeType
+ ) => {
+ e.dataTransfer.setData("application/workflow-node-type", type)
+ e.dataTransfer.effectAllowed = "copy"
+ }
+
+ return (
+
+
+ Node Types
+
+ {NODE_CATEGORIES.map((cat) => (
+
onDragStart(e, cat.type)}
+ className={cn(
+ "flex cursor-grab items-center gap-2.5 rounded-lg border px-2.5 py-2 transition-colors select-none active:cursor-grabbing hover:brightness-110",
+ cat.color
+ )}
+ >
+ {cat.icon}
+
+
{cat.label}
+
+ {cat.description}
+
+
+
+ ))}
+
+ Drag nodes onto the canvas
+
+
+ )
+}
diff --git a/app/src/components/workflow-editor/WorkflowCanvas.tsx b/app/src/components/workflow-editor/WorkflowCanvas.tsx
new file mode 100644
index 0000000..620ad90
--- /dev/null
+++ b/app/src/components/workflow-editor/WorkflowCanvas.tsx
@@ -0,0 +1,223 @@
+import {
+ Background,
+ BackgroundVariant,
+ Controls,
+ MiniMap,
+ type Node,
+ ReactFlow,
+ ReactFlowProvider,
+ type XYPosition,
+} from "@xyflow/react"
+import { useCallback, useEffect, useRef, useState } from "react"
+import "@xyflow/react/dist/style.css"
+
+import {
+ ResizableHandle,
+ ResizablePanel,
+ ResizablePanelGroup,
+} from "@/components/ui/resizable"
+import type { WorkflowNodeType } from "@/hooks/use-workflow"
+import { useWorkflow } from "@/hooks/use-workflow"
+import { InspectorPanel } from "./InspectorPanel"
+import { NodePanel } from "./NodePanel"
+import { ActionNode } from "./nodes/ActionNode"
+import { AgentNode } from "./nodes/AgentNode"
+import { ConditionNode } from "./nodes/ConditionNode"
+import { OutputNode } from "./nodes/OutputNode"
+import { TriggerNode } from "./nodes/TriggerNode"
+import { WorkflowToolbar } from "./WorkflowToolbar"
+
+// ---------------------------------------------------------------------------
+// Node type registry
+// ---------------------------------------------------------------------------
+
+const nodeTypes = {
+ trigger: TriggerNode,
+ action: ActionNode,
+ agent: AgentNode,
+ condition: ConditionNode,
+ output: OutputNode,
+} as const
+
+// ---------------------------------------------------------------------------
+// Inner canvas (must be inside ReactFlowProvider)
+// ---------------------------------------------------------------------------
+
+function Canvas({ workflowId }: { workflowId?: string }) {
+ const {
+ nodes,
+ edges,
+ onNodesChange,
+ onEdgesChange,
+ onConnect,
+ addNode,
+ undo,
+ redo,
+ workflowName,
+ setWorkflowName,
+ saveWorkflow,
+ isSaving,
+ loadWorkflow,
+ setNodes,
+ } = useWorkflow(workflowId)
+
+ const [showMiniMap, setShowMiniMap] = useState(true)
+ const [selectedNode, setSelectedNode] = useState(null)
+ const reactFlowWrapper = useRef(null)
+
+ // Load workflow if id provided
+ useEffect(() => {
+ if (workflowId) {
+ loadWorkflow(workflowId)
+ }
+ }, [workflowId, loadWorkflow])
+
+ // -----------------------------------------------------------------------
+ // Drag-and-drop from NodePanel
+ // -----------------------------------------------------------------------
+
+ const onDragOver = useCallback((e: React.DragEvent) => {
+ e.preventDefault()
+ e.dataTransfer.dropEffect = "copy"
+ }, [])
+
+ const onDrop = useCallback(
+ (e: React.DragEvent) => {
+ e.preventDefault()
+ const type = e.dataTransfer.getData(
+ "application/workflow-node-type"
+ ) as WorkflowNodeType
+ if (!type) return
+
+ const bounds = reactFlowWrapper.current?.getBoundingClientRect()
+ if (!bounds) return
+
+ const position: XYPosition = {
+ x: e.clientX - bounds.left,
+ y: e.clientY - bounds.top,
+ }
+ addNode(type, position)
+ },
+ [addNode]
+ )
+
+ // -----------------------------------------------------------------------
+ // Inspector: sync selected node
+ // -----------------------------------------------------------------------
+
+ const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
+ setSelectedNode(node)
+ }, [])
+
+ const onPaneClick = useCallback(() => {
+ setSelectedNode(null)
+ }, [])
+
+ const onUpdateNode = useCallback(
+ (id: string, data: Record) => {
+ setNodes((nds) => nds.map((n) => (n.id === id ? { ...n, data } : n)))
+ setSelectedNode((prev) => (prev?.id === id ? { ...prev, data } : prev))
+ },
+ [setNodes]
+ )
+
+ const onDeleteNode = useCallback(
+ (id: string) => {
+ setNodes((nds) => nds.filter((n) => n.id !== id))
+ setSelectedNode(null)
+ },
+ [setNodes]
+ )
+
+ return (
+
+
setShowMiniMap((v) => !v)}
+ />
+
+
+ {/* Left panel — node palette */}
+
+
+
+
+
+
+ {/* Centre — canvas */}
+
+
+
+
+
+ {showMiniMap && (
+
+ )}
+
+
+
+
+
+
+ {/* Right panel — inspector */}
+
+
+
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Public export — wraps with ReactFlowProvider
+// ---------------------------------------------------------------------------
+
+interface WorkflowCanvasProps {
+ workflowId?: string
+}
+
+export function WorkflowCanvas({ workflowId }: WorkflowCanvasProps) {
+ return (
+
+
+
+ )
+}
diff --git a/app/src/components/workflow-editor/WorkflowToolbar.tsx b/app/src/components/workflow-editor/WorkflowToolbar.tsx
new file mode 100644
index 0000000..78b745d
--- /dev/null
+++ b/app/src/components/workflow-editor/WorkflowToolbar.tsx
@@ -0,0 +1,123 @@
+import { useReactFlow } from "@xyflow/react"
+import { Map, Maximize2, Minus, Plus, Redo2, Save, Undo2 } from "lucide-react"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+
+interface WorkflowToolbarProps {
+ name: string
+ onNameChange: (value: string) => void
+ onSave: () => void
+ onUndo: () => void
+ onRedo: () => void
+ isSaving?: boolean
+ showMiniMap: boolean
+ onToggleMiniMap: () => void
+}
+
+export function WorkflowToolbar({
+ name,
+ onNameChange,
+ onSave,
+ onUndo,
+ onRedo,
+ isSaving,
+ showMiniMap,
+ onToggleMiniMap,
+}: WorkflowToolbarProps) {
+ const { zoomIn, zoomOut, fitView } = useReactFlow()
+
+ return (
+
+ {/* Workflow name */}
+
onNameChange(e.target.value)}
+ className="h-6 w-44 border-neutral-700 bg-neutral-800 text-neutral-100 text-xs placeholder:text-neutral-500 focus-visible:border-sky-500"
+ placeholder="Workflow name"
+ />
+
+
+
+ {/* History */}
+
+
+
+
+
+ {/* Zoom */}
+
+
+
+
+
+
+ {/* MiniMap toggle */}
+
+
+
+
+ {/* Save */}
+
+
+ )
+}
diff --git a/app/src/components/workflow-editor/nodes/ActionNode.tsx b/app/src/components/workflow-editor/nodes/ActionNode.tsx
new file mode 100644
index 0000000..f211a21
--- /dev/null
+++ b/app/src/components/workflow-editor/nodes/ActionNode.tsx
@@ -0,0 +1,37 @@
+import { Handle, type NodeProps, Position } from "@xyflow/react"
+import { Play } from "lucide-react"
+import type { NodeStatus } from "./BaseNode"
+import { BaseNode } from "./BaseNode"
+
+export type ActionNodeData = {
+ label?: string
+ actionType?: "http" | "script" | "transform"
+ description?: string
+ status?: NodeStatus
+}
+
+export function ActionNode({ data, selected }: NodeProps) {
+ const d = data as ActionNodeData
+ const actionLabels = {
+ http: "HTTP",
+ script: "Script",
+ transform: "Transform",
+ }
+ const badge = d.actionType ? actionLabels[d.actionType] : "Action"
+
+ return (
+ <>
+
+ }
+ label={d.label ?? "Action"}
+ description={d.description}
+ badge={badge}
+ badgeColor="bg-sky-400/10 text-sky-300"
+ />
+
+ >
+ )
+}
diff --git a/app/src/components/workflow-editor/nodes/AgentNode.tsx b/app/src/components/workflow-editor/nodes/AgentNode.tsx
new file mode 100644
index 0000000..5ed8c62
--- /dev/null
+++ b/app/src/components/workflow-editor/nodes/AgentNode.tsx
@@ -0,0 +1,32 @@
+import { Handle, type NodeProps, Position } from "@xyflow/react"
+import { Bot } from "lucide-react"
+import type { NodeStatus } from "./BaseNode"
+import { BaseNode } from "./BaseNode"
+
+export type AgentNodeData = {
+ label?: string
+ agentName?: string
+ model?: string
+ description?: string
+ status?: NodeStatus
+}
+
+export function AgentNode({ data, selected }: NodeProps) {
+ const d = data as AgentNodeData
+
+ return (
+ <>
+
+ }
+ label={d.label ?? d.agentName ?? "Agent"}
+ description={d.description}
+ badge={d.model ?? "AI"}
+ badgeColor="bg-violet-400/10 text-violet-300"
+ />
+
+ >
+ )
+}
diff --git a/app/src/components/workflow-editor/nodes/BaseNode.tsx b/app/src/components/workflow-editor/nodes/BaseNode.tsx
new file mode 100644
index 0000000..e3b6b6a
--- /dev/null
+++ b/app/src/components/workflow-editor/nodes/BaseNode.tsx
@@ -0,0 +1,94 @@
+import type { ReactNode } from "react"
+import { cn } from "@/lib/utils"
+
+export type NodeStatus = "idle" | "running" | "success" | "error"
+
+interface BaseNodeProps {
+ selected?: boolean
+ status?: NodeStatus
+ icon?: ReactNode
+ label: string
+ description?: string
+ children?: ReactNode
+ className?: string
+ badge?: string
+ badgeColor?: string
+}
+
+const statusRing: Record = {
+ idle: "",
+ running: "ring-2 ring-yellow-400",
+ success: "ring-2 ring-green-500",
+ error: "ring-2 ring-red-500",
+}
+
+export function BaseNode({
+ selected,
+ status = "idle",
+ icon,
+ label,
+ description,
+ children,
+ className,
+ badge,
+ badgeColor = "bg-neutral-700 text-neutral-200",
+}: BaseNodeProps) {
+ return (
+
+ {/* Header */}
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+ {label}
+
+ {badge && (
+
+ {badge}
+
+ )}
+
+
+ {/* Body */}
+ {(description || children) && (
+
+ {description && (
+
{description}
+ )}
+ {children}
+
+ )}
+
+ {/* Status bar */}
+ {status !== "idle" && (
+
+ {status === "running" && "Running…"}
+ {status === "success" && "Success"}
+ {status === "error" && "Error"}
+
+ )}
+
+ )
+}
diff --git a/app/src/components/workflow-editor/nodes/ConditionNode.tsx b/app/src/components/workflow-editor/nodes/ConditionNode.tsx
new file mode 100644
index 0000000..dbb4f61
--- /dev/null
+++ b/app/src/components/workflow-editor/nodes/ConditionNode.tsx
@@ -0,0 +1,49 @@
+import { Handle, type NodeProps, Position } from "@xyflow/react"
+import { GitBranch } from "lucide-react"
+import type { NodeStatus } from "./BaseNode"
+import { BaseNode } from "./BaseNode"
+
+export type ConditionNodeData = {
+ label?: string
+ condition?: string
+ description?: string
+ status?: NodeStatus
+}
+
+export function ConditionNode({ data, selected }: NodeProps) {
+ const d = data as ConditionNodeData
+
+ return (
+ <>
+
+ }
+ label={d.label ?? "Condition"}
+ description={d.condition ?? d.description}
+ badge="if/else"
+ badgeColor="bg-orange-400/10 text-orange-300"
+ >
+
+ true ↙
+ ↘ false
+
+
+ {/* true branch — bottom-left */}
+
+ {/* false branch — bottom-right */}
+
+ >
+ )
+}
diff --git a/app/src/components/workflow-editor/nodes/OutputNode.tsx b/app/src/components/workflow-editor/nodes/OutputNode.tsx
new file mode 100644
index 0000000..a0e6252
--- /dev/null
+++ b/app/src/components/workflow-editor/nodes/OutputNode.tsx
@@ -0,0 +1,37 @@
+import { Handle, type NodeProps, Position } from "@xyflow/react"
+import { Send } from "lucide-react"
+import type { NodeStatus } from "./BaseNode"
+import { BaseNode } from "./BaseNode"
+
+export type OutputNodeData = {
+ label?: string
+ outputType?: "email" | "file" | "webhook" | "log"
+ description?: string
+ status?: NodeStatus
+}
+
+export function OutputNode({ data, selected }: NodeProps) {
+ const d = data as OutputNodeData
+ const outputLabels = {
+ email: "Email",
+ file: "File",
+ webhook: "Webhook",
+ log: "Log",
+ }
+ const badge = d.outputType ? outputLabels[d.outputType] : "Output"
+
+ return (
+ <>
+
+ }
+ label={d.label ?? "Output"}
+ description={d.description}
+ badge={badge}
+ badgeColor="bg-emerald-400/10 text-emerald-300"
+ />
+ >
+ )
+}
diff --git a/app/src/components/workflow-editor/nodes/TriggerNode.tsx b/app/src/components/workflow-editor/nodes/TriggerNode.tsx
new file mode 100644
index 0000000..4b22afc
--- /dev/null
+++ b/app/src/components/workflow-editor/nodes/TriggerNode.tsx
@@ -0,0 +1,37 @@
+import { Handle, type NodeProps, Position } from "@xyflow/react"
+import { Zap } from "lucide-react"
+import type { NodeStatus } from "./BaseNode"
+import { BaseNode } from "./BaseNode"
+
+export type TriggerNodeData = {
+ label?: string
+ triggerType?: "manual" | "webhook" | "schedule"
+ description?: string
+ status?: NodeStatus
+}
+
+export function TriggerNode({ data, selected }: NodeProps) {
+ const d = data as TriggerNodeData
+ const triggerLabels = {
+ manual: "Manual",
+ webhook: "Webhook",
+ schedule: "Schedule",
+ }
+ const badge = d.triggerType ? triggerLabels[d.triggerType] : "Manual"
+
+ return (
+ <>
+ }
+ label={d.label ?? "Trigger"}
+ description={d.description}
+ badge={badge}
+ badgeColor="bg-yellow-400/10 text-yellow-300"
+ />
+ {/* source handle at bottom */}
+
+ >
+ )
+}
diff --git a/app/src/hooks/use-workflow.ts b/app/src/hooks/use-workflow.ts
new file mode 100644
index 0000000..2b48258
--- /dev/null
+++ b/app/src/hooks/use-workflow.ts
@@ -0,0 +1,248 @@
+import {
+ addEdge,
+ applyEdgeChanges,
+ applyNodeChanges,
+ type Edge,
+ type Node,
+ type OnConnect,
+ type OnEdgesChange,
+ type OnNodesChange,
+ type XYPosition,
+} from "@xyflow/react"
+import { useCallback, useRef, useState } from "react"
+
+const API_BASE = "/api/v1/workflows"
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+export type WorkflowNodeType =
+ | "trigger"
+ | "action"
+ | "agent"
+ | "condition"
+ | "output"
+
+export type WorkflowGraph = {
+ nodes: Node[]
+ edges: Edge[]
+}
+
+export type WorkflowRecord = {
+ id: string
+ name: string
+ description: string
+ graph: WorkflowGraph
+ createdAt: string
+ updatedAt: string
+}
+
+// ---------------------------------------------------------------------------
+// History helpers
+// ---------------------------------------------------------------------------
+
+type Snapshot = { nodes: Node[]; edges: Edge[] }
+
+function useHistory(initial: Snapshot) {
+ const past = useRef([])
+ const future = useRef([])
+ const current = useRef(initial)
+
+ const push = useCallback((snap: Snapshot) => {
+ past.current.push(current.current)
+ future.current = []
+ current.current = snap
+ }, [])
+
+ const undo = useCallback((): Snapshot | null => {
+ const prev = past.current.pop()
+ if (!prev) return null
+ future.current.push(current.current)
+ current.current = prev
+ return prev
+ }, [])
+
+ const redo = useCallback((): Snapshot | null => {
+ const next = future.current.pop()
+ if (!next) return null
+ past.current.push(current.current)
+ current.current = next
+ return next
+ }, [])
+
+ return { push, undo, redo }
+}
+
+// ---------------------------------------------------------------------------
+// Hook
+// ---------------------------------------------------------------------------
+
+export function useWorkflow(workflowId?: string) {
+ const [nodes, setNodes] = useState([])
+ const [edges, setEdges] = useState([])
+ const [workflowName, setWorkflowName] = useState("Untitled Workflow")
+ const [workflowDescription, setWorkflowDescription] = useState("")
+ const [isSaving, setIsSaving] = useState(false)
+ const [isLoading, setIsLoading] = useState(false)
+ const [error, setError] = useState(null)
+
+ const history = useHistory({ nodes: [], edges: [] })
+ const savedIdRef = useRef(workflowId)
+
+ // -----------------------------------------------------------------------
+ // ReactFlow change handlers
+ // -----------------------------------------------------------------------
+
+ const onNodesChange: OnNodesChange = useCallback(
+ (changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
+ []
+ )
+
+ const onEdgesChange: OnEdgesChange = useCallback(
+ (changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
+ []
+ )
+
+ const onConnect: OnConnect = useCallback(
+ (connection) =>
+ setEdges((eds) => addEdge({ ...connection, animated: true }, eds)),
+ []
+ )
+
+ // -----------------------------------------------------------------------
+ // Add node factory
+ // -----------------------------------------------------------------------
+
+ const addNode = useCallback(
+ (type: WorkflowNodeType, position: XYPosition) => {
+ const id = `${type}_${Date.now()}`
+ const defaultData: Record = {
+ trigger: { label: "Trigger", triggerType: "manual" },
+ action: { label: "Action", actionType: "http" },
+ agent: { label: "Agent", agentName: "My Agent", model: "gpt-4o" },
+ condition: { label: "Condition", condition: "value > 0" },
+ output: { label: "Output", outputType: "log" },
+ }
+ const newNode: Node = {
+ id,
+ type,
+ position,
+ data: defaultData[type],
+ }
+ setNodes((nds) => {
+ const next = [...nds, newNode]
+ history.push({ nodes: next, edges })
+ return next
+ })
+ },
+ [edges, history]
+ )
+
+ // -----------------------------------------------------------------------
+ // Undo / Redo
+ // -----------------------------------------------------------------------
+
+ const undo = useCallback(() => {
+ const snap = history.undo()
+ if (snap) {
+ setNodes(snap.nodes)
+ setEdges(snap.edges)
+ }
+ }, [history])
+
+ const redo = useCallback(() => {
+ const snap = history.redo()
+ if (snap) {
+ setNodes(snap.nodes)
+ setEdges(snap.edges)
+ }
+ }, [history])
+
+ // -----------------------------------------------------------------------
+ // Persistence
+ // -----------------------------------------------------------------------
+
+ const loadWorkflow = useCallback(async (id: string) => {
+ setIsLoading(true)
+ setError(null)
+ try {
+ const res = await fetch(`${API_BASE}/${id}`)
+ if (!res.ok) throw new Error(`Failed to load workflow: ${res.status}`)
+ const data: WorkflowRecord = await res.json()
+ setWorkflowName(data.name)
+ setWorkflowDescription(data.description)
+ const graph = data.graph ?? { nodes: [], edges: [] }
+ setNodes(graph.nodes ?? [])
+ setEdges(graph.edges ?? [])
+ savedIdRef.current = id
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unknown error")
+ } finally {
+ setIsLoading(false)
+ }
+ }, [])
+
+ const saveWorkflow = useCallback(async () => {
+ setIsSaving(true)
+ setError(null)
+ try {
+ const body = {
+ name: workflowName,
+ description: workflowDescription,
+ graph: { nodes, edges },
+ }
+ let res: Response
+ if (savedIdRef.current) {
+ res = await fetch(`${API_BASE}/${savedIdRef.current}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ })
+ } else {
+ res = await fetch(API_BASE, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ })
+ }
+ if (!res.ok) throw new Error(`Failed to save workflow: ${res.status}`)
+ const data: WorkflowRecord = await res.json()
+ savedIdRef.current = data.id
+ return data
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unknown error")
+ return null
+ } finally {
+ setIsSaving(false)
+ }
+ }, [workflowName, workflowDescription, nodes, edges])
+
+ return {
+ // graph state
+ nodes,
+ edges,
+ setNodes,
+ setEdges,
+ // change handlers
+ onNodesChange,
+ onEdgesChange,
+ onConnect,
+ // helpers
+ addNode,
+ undo,
+ redo,
+ // metadata
+ workflowName,
+ setWorkflowName,
+ workflowDescription,
+ setWorkflowDescription,
+ // async
+ loadWorkflow,
+ saveWorkflow,
+ isSaving,
+ isLoading,
+ error,
+ savedId: savedIdRef.current,
+ }
+}
diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts
index 05e7907..a3dfa47 100644
--- a/app/src/routeTree.gen.ts
+++ b/app/src/routeTree.gen.ts
@@ -12,6 +12,8 @@ import { Route as rootRouteImport } from './routes/__root'
import { Route as SignupRouteImport } from './routes/signup'
import { Route as LoginRouteImport } from './routes/login'
import { Route as IndexRouteImport } from './routes/index'
+import { Route as WorkflowsIndexRouteImport } from './routes/workflows/index'
+import { Route as WorkflowsWorkflowIdRouteImport } from './routes/workflows/$workflowId'
const SignupRoute = SignupRouteImport.update({
id: '/signup',
@@ -28,35 +30,53 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
+const WorkflowsIndexRoute = WorkflowsIndexRouteImport.update({
+ id: '/workflows/',
+ path: '/workflows/',
+ getParentRoute: () => rootRouteImport,
+} as any)
+const WorkflowsWorkflowIdRoute = WorkflowsWorkflowIdRouteImport.update({
+ id: '/workflows/$workflowId',
+ path: '/workflows/$workflowId',
+ getParentRoute: () => rootRouteImport,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/signup': typeof SignupRoute
+ '/workflows/': typeof WorkflowsIndexRoute
+ '/workflows/$workflowId': typeof WorkflowsWorkflowIdRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/signup': typeof SignupRoute
+ '/workflows': typeof WorkflowsIndexRoute
+ '/workflows/$workflowId': typeof WorkflowsWorkflowIdRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/login': typeof LoginRoute
'/signup': typeof SignupRoute
+ '/workflows/': typeof WorkflowsIndexRoute
+ '/workflows/$workflowId': typeof WorkflowsWorkflowIdRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
- fullPaths: '/' | '/login' | '/signup'
+ fullPaths: '/' | '/login' | '/signup' | '/workflows/' | '/workflows/$workflowId'
fileRoutesByTo: FileRoutesByTo
- to: '/' | '/login' | '/signup'
- id: '__root__' | '/' | '/login' | '/signup'
+ to: '/' | '/login' | '/signup' | '/workflows' | '/workflows/$workflowId'
+ id: '__root__' | '/' | '/login' | '/signup' | '/workflows/' | '/workflows/$workflowId'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
LoginRoute: typeof LoginRoute
SignupRoute: typeof SignupRoute
+ WorkflowsIndexRoute: typeof WorkflowsIndexRoute
+ WorkflowsWorkflowIdRoute: typeof WorkflowsWorkflowIdRoute
}
declare module '@tanstack/react-router' {
@@ -82,6 +102,20 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
+ '/workflows/': {
+ id: '/workflows/'
+ path: '/workflows/'
+ fullPath: '/workflows/'
+ preLoaderRoute: typeof WorkflowsIndexRouteImport
+ parentRoute: typeof rootRouteImport
+ }
+ '/workflows/$workflowId': {
+ id: '/workflows/$workflowId'
+ path: '/workflows/$workflowId'
+ fullPath: '/workflows/$workflowId'
+ preLoaderRoute: typeof WorkflowsWorkflowIdRouteImport
+ parentRoute: typeof rootRouteImport
+ }
}
}
@@ -89,6 +123,8 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
LoginRoute: LoginRoute,
SignupRoute: SignupRoute,
+ WorkflowsIndexRoute: WorkflowsIndexRoute,
+ WorkflowsWorkflowIdRoute: WorkflowsWorkflowIdRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
diff --git a/app/src/routes/index.tsx b/app/src/routes/index.tsx
index 051b447..c07ee69 100644
--- a/app/src/routes/index.tsx
+++ b/app/src/routes/index.tsx
@@ -1,4 +1,4 @@
-import { createFileRoute } from "@tanstack/react-router"
+import { createFileRoute, Link } from "@tanstack/react-router"
import { useState } from "react"
import {
MobileNav,
@@ -44,8 +44,12 @@ function RootComponent() {
- Login
- Book a call
+
+ Login
+
+
+ Get Started
+
@@ -109,12 +113,18 @@ function RootComponent() {
AgentFlow
Automate. Innvovate. Collaborate.
-
-
+
+ Build a Workflow
+
+
+ Sign In
+
diff --git a/app/src/routes/workflows/$workflowId.tsx b/app/src/routes/workflows/$workflowId.tsx
new file mode 100644
index 0000000..61e2c19
--- /dev/null
+++ b/app/src/routes/workflows/$workflowId.tsx
@@ -0,0 +1,35 @@
+import { createFileRoute, Link } from "@tanstack/react-router"
+import { ArrowLeft } from "lucide-react"
+import { WorkflowCanvas } from "@/components/workflow-editor/WorkflowCanvas"
+
+export const Route = createFileRoute("/workflows/$workflowId")({
+ component: WorkflowEditorPage,
+})
+
+function WorkflowEditorPage() {
+ const { workflowId } = Route.useParams()
+
+ return (
+
+ {/* Top bar */}
+
+
+
+ Workflows
+
+
/
+
+ {workflowId}
+
+
+
+ {/* Canvas — takes remaining height */}
+
+
+
+
+ )
+}
diff --git a/app/src/routes/workflows/index.tsx b/app/src/routes/workflows/index.tsx
new file mode 100644
index 0000000..2763a38
--- /dev/null
+++ b/app/src/routes/workflows/index.tsx
@@ -0,0 +1,143 @@
+import { createFileRoute, Link } from "@tanstack/react-router"
+import { formatDistanceToNow } from "date-fns"
+import { ArrowRight, Calendar, Plus, Workflow } from "lucide-react"
+import { useEffect, useState } from "react"
+import { Button } from "@/components/ui/button"
+
+type WorkflowRecord = {
+ id: string
+ name: string
+ description: string
+ createdAt: string
+ updatedAt: string
+}
+
+export const Route = createFileRoute("/workflows/")({
+ component: WorkflowsPage,
+})
+
+function WorkflowsPage() {
+ const [workflows, setWorkflows] = useState([])
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [isCreating, setIsCreating] = useState(false)
+
+ useEffect(() => {
+ fetch("/api/v1/workflows")
+ .then((r) => r.json())
+ .then((data) => setWorkflows(data as WorkflowRecord[]))
+ .catch((e) => setError(e.message))
+ .finally(() => setIsLoading(false))
+ }, [])
+
+ const handleCreate = async () => {
+ setIsCreating(true)
+ try {
+ const res = await fetch("/api/v1/workflows", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name: "Untitled Workflow", description: "" }),
+ })
+ if (!res.ok) throw new Error("Failed to create workflow")
+ const data: WorkflowRecord = await res.json()
+ // navigate to the editor
+ window.location.href = `/workflows/${data.id}`
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Unknown error")
+ } finally {
+ setIsCreating(false)
+ }
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+
Workflows
+
+
+
+
+
+ {/* Content */}
+
+ {isLoading && (
+
+ )}
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {!isLoading && !error && workflows.length === 0 && (
+
+
+
No workflows yet
+
+
+ )}
+
+ {!isLoading && workflows.length > 0 && (
+
+ {workflows.map((wf) => (
+
+
+
+
+
+ {wf.name}
+
+
+
+
+
+ {wf.description && (
+
+ {wf.description}
+
+ )}
+
+
+
+
+ Updated{" "}
+ {formatDistanceToNow(new Date(wf.updatedAt), {
+ addSuffix: true,
+ })}
+
+
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/packages/cli/src/server/routes/v1/workflows/route.ts b/packages/cli/src/server/routes/v1/workflows/route.ts
new file mode 100644
index 0000000..f24e613
--- /dev/null
+++ b/packages/cli/src/server/routes/v1/workflows/route.ts
@@ -0,0 +1,146 @@
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import { homedir } from "node:os";
+import { join } from "node:path";
+import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
+
+// ---------------------------------------------------------------------------
+// Persistence helpers — plain JSON file at ~/.agentflow/workflows.json
+// ---------------------------------------------------------------------------
+
+type WorkflowRecord = {
+ id: string;
+ name: string;
+ description: string;
+ graph: unknown; // serialised ReactFlow nodes + edges
+ createdAt: string;
+ updatedAt: string;
+};
+
+type WorkflowStore = Record;
+
+const DATA_DIR = join(
+ process.env.AGENTFLOW_DATA_DIR ?? join(homedir(), ".agentflow"),
+);
+const WORKFLOWS_FILE = join(DATA_DIR, "workflows.json");
+
+async function readStore(): Promise {
+ try {
+ const raw = await readFile(WORKFLOWS_FILE, "utf8");
+ return JSON.parse(raw) as WorkflowStore;
+ } catch {
+ return {};
+ }
+}
+
+async function writeStore(store: WorkflowStore): Promise {
+ await mkdir(DATA_DIR, { recursive: true });
+ await writeFile(WORKFLOWS_FILE, JSON.stringify(store, null, 2), "utf8");
+}
+
+function generateId(): string {
+ return `wf_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
+}
+
+// ---------------------------------------------------------------------------
+// Route handler
+// ---------------------------------------------------------------------------
+
+export default async function (fastify: FastifyInstance) {
+ // GET /api/v1/workflows
+ fastify.get("/", async (_request: FastifyRequest, reply: FastifyReply) => {
+ const store = await readStore();
+ const workflows = Object.values(store).sort(
+ (a, b) =>
+ new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
+ );
+ return reply.send(workflows);
+ });
+
+ // POST /api/v1/workflows
+ fastify.post(
+ "/",
+ async (
+ request: FastifyRequest<{
+ Body: { name?: string; description?: string; graph?: unknown };
+ }>,
+ reply: FastifyReply,
+ ) => {
+ const body = request.body ?? {};
+ const now = new Date().toISOString();
+ const record: WorkflowRecord = {
+ id: generateId(),
+ name: (body.name as string) ?? "Untitled Workflow",
+ description: (body.description as string) ?? "",
+ graph: body.graph ?? { nodes: [], edges: [] },
+ createdAt: now,
+ updatedAt: now,
+ };
+ const store = await readStore();
+ store[record.id] = record;
+ await writeStore(store);
+ return reply.code(201).send(record);
+ },
+ );
+
+ // GET /api/v1/workflows/:id
+ fastify.get(
+ "/:id",
+ async (
+ request: FastifyRequest<{ Params: { id: string } }>,
+ reply: FastifyReply,
+ ) => {
+ const store = await readStore();
+ const record = store[request.params.id];
+ if (!record) {
+ return reply.code(404).send({ error: "Workflow not found" });
+ }
+ return reply.send(record);
+ },
+ );
+
+ // PUT /api/v1/workflows/:id
+ fastify.put(
+ "/:id",
+ async (
+ request: FastifyRequest<{
+ Params: { id: string };
+ Body: { name?: string; description?: string; graph?: unknown };
+ }>,
+ reply: FastifyReply,
+ ) => {
+ const store = await readStore();
+ const existing = store[request.params.id];
+ if (!existing) {
+ return reply.code(404).send({ error: "Workflow not found" });
+ }
+ const body = request.body ?? {};
+ const updated: WorkflowRecord = {
+ ...existing,
+ name: (body.name as string) ?? existing.name,
+ description: (body.description as string) ?? existing.description,
+ graph: body.graph ?? existing.graph,
+ updatedAt: new Date().toISOString(),
+ };
+ store[request.params.id] = updated;
+ await writeStore(store);
+ return reply.send(updated);
+ },
+ );
+
+ // DELETE /api/v1/workflows/:id
+ fastify.delete(
+ "/:id",
+ async (
+ request: FastifyRequest<{ Params: { id: string } }>,
+ reply: FastifyReply,
+ ) => {
+ const store = await readStore();
+ if (!store[request.params.id]) {
+ return reply.code(404).send({ error: "Workflow not found" });
+ }
+ delete store[request.params.id];
+ await writeStore(store);
+ return reply.code(204).send();
+ },
+ );
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 551e04d..db03dab 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -53,6 +53,9 @@ importers:
'@tanstack/react-router-devtools':
specifier: 1.166.13
version: 1.166.13(@tanstack/react-router@1.168.23(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@tanstack/router-core@1.168.15)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@xyflow/react':
+ specifier: 12.6.4
+ version: 12.6.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
class-variance-authority:
specifier: 0.7.1
version: 0.7.1
@@ -1919,6 +1922,9 @@ packages:
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
+ '@types/d3-drag@3.0.7':
+ resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==}
+
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
@@ -1931,6 +1937,9 @@ packages:
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
+ '@types/d3-selection@3.0.11':
+ resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==}
+
'@types/d3-shape@3.1.8':
resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==}
@@ -1940,6 +1949,12 @@ packages:
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
+ '@types/d3-transition@3.0.9':
+ resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==}
+
+ '@types/d3-zoom@3.0.8':
+ resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==}
+
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
@@ -1978,6 +1993,15 @@ packages:
peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0
+ '@xyflow/react@12.6.4':
+ resolution: {integrity: sha512-/dOQ43Nu217cwHzy7f8kNUrFMeJJENzftVgT2VdFFHi6fHlG83pF+gLmvkRW9Be7alCsR6G+LFxxCdsQQbazHg==}
+ peerDependencies:
+ react: '>=17'
+ react-dom: '>=17'
+
+ '@xyflow/system@0.0.61':
+ resolution: {integrity: sha512-TsZG/Ez8dzxX6/Ol44LvFqVZsYvyz6dpDlAQZZk6hTL7JLGO5vN3dboRJqMwU8/Qtr5IEv5YBzojjAwIqW1HCA==}
+
abstract-logging@2.0.1:
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
@@ -2137,6 +2161,9 @@ packages:
class-variance-authority@0.7.1:
resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==}
+ classcat@5.0.5:
+ resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==}
+
cli-cursor@5.0.0:
resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==}
engines: {node: '>=18'}
@@ -2244,6 +2271,14 @@ packages:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
+ d3-dispatch@3.0.1:
+ resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
+ engines: {node: '>=12'}
+
+ d3-drag@3.0.0:
+ resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==}
+ engines: {node: '>=12'}
+
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
@@ -2264,6 +2299,10 @@ packages:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
+ d3-selection@3.0.0:
+ resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==}
+ engines: {node: '>=12'}
+
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
@@ -2280,6 +2319,16 @@ packages:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
+ d3-transition@3.0.1:
+ resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==}
+ engines: {node: '>=12'}
+ peerDependencies:
+ d3-selection: 2 - 3
+
+ d3-zoom@3.0.0:
+ resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==}
+ engines: {node: '>=12'}
+
data-uri-to-buffer@4.0.1:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
@@ -4099,6 +4148,21 @@ packages:
zod@3.25.76:
resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==}
+ zustand@4.5.7:
+ resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==}
+ engines: {node: '>=12.7.0'}
+ peerDependencies:
+ '@types/react': '>=16.8'
+ immer: '>=9.0.6'
+ react: '>=16.8'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+
snapshots:
'@ark/schema@0.56.0':
@@ -5912,6 +5976,10 @@ snapshots:
'@types/d3-color@3.1.3': {}
+ '@types/d3-drag@3.0.7':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
@@ -5924,6 +5992,8 @@ snapshots:
dependencies:
'@types/d3-time': 3.0.4
+ '@types/d3-selection@3.0.11': {}
+
'@types/d3-shape@3.1.8':
dependencies:
'@types/d3-path': 3.1.1
@@ -5932,6 +6002,15 @@ snapshots:
'@types/d3-timer@3.0.2': {}
+ '@types/d3-transition@3.0.9':
+ dependencies:
+ '@types/d3-selection': 3.0.11
+
+ '@types/d3-zoom@3.0.8':
+ dependencies:
+ '@types/d3-interpolate': 3.0.4
+ '@types/d3-selection': 3.0.11
+
'@types/estree@1.0.8': {}
'@types/node@12.20.55': {}
@@ -5974,6 +6053,27 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@xyflow/react@12.6.4(@types/react@19.2.14)(immer@11.1.4)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ dependencies:
+ '@xyflow/system': 0.0.61
+ classcat: 5.0.5
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ zustand: 4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5)
+ transitivePeerDependencies:
+ - '@types/react'
+ - immer
+
+ '@xyflow/system@0.0.61':
+ dependencies:
+ '@types/d3-drag': 3.0.7
+ '@types/d3-selection': 3.0.11
+ '@types/d3-transition': 3.0.9
+ '@types/d3-zoom': 3.0.8
+ d3-drag: 3.0.0
+ d3-selection: 3.0.0
+ d3-zoom: 3.0.0
+
abstract-logging@2.0.1: {}
accepts@2.0.0:
@@ -6137,6 +6237,8 @@ snapshots:
dependencies:
clsx: 2.1.1
+ classcat@5.0.5: {}
+
cli-cursor@5.0.0:
dependencies:
restore-cursor: 5.1.0
@@ -6229,6 +6331,13 @@ snapshots:
d3-color@3.1.0: {}
+ d3-dispatch@3.0.1: {}
+
+ d3-drag@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-selection: 3.0.0
+
d3-ease@3.0.1: {}
d3-format@3.1.2: {}
@@ -6247,6 +6356,8 @@ snapshots:
d3-time: 3.1.0
d3-time-format: 4.1.0
+ d3-selection@3.0.0: {}
+
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
@@ -6261,6 +6372,23 @@ snapshots:
d3-timer@3.0.1: {}
+ d3-transition@3.0.1(d3-selection@3.0.0):
+ dependencies:
+ d3-color: 3.1.0
+ d3-dispatch: 3.0.1
+ d3-ease: 3.0.1
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-timer: 3.0.1
+
+ d3-zoom@3.0.0:
+ dependencies:
+ d3-dispatch: 3.0.1
+ d3-drag: 3.0.0
+ d3-interpolate: 3.0.1
+ d3-selection: 3.0.0
+ d3-transition: 3.0.1(d3-selection@3.0.0)
+
data-uri-to-buffer@4.0.1: {}
dataloader@1.4.0: {}
@@ -8028,3 +8156,11 @@ snapshots:
zod: 3.25.76
zod@3.25.76: {}
+
+ zustand@4.5.7(@types/react@19.2.14)(immer@11.1.4)(react@19.2.5):
+ dependencies:
+ use-sync-external-store: 1.6.0(react@19.2.5)
+ optionalDependencies:
+ '@types/react': 19.2.14
+ immer: 11.1.4
+ react: 19.2.5
From 1870f395aa71ce2fcc9bdc0602d1612a79da443b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sun, 26 Apr 2026 09:38:45 +0000
Subject: [PATCH 2/2] fix: wire EdgeContextMenu into canvas, use navigate hook,
fix typo
Agent-Logs-Url: https://github.com/mdirshadengineer/agentflow/sessions/05f6263c-4f3a-4e09-95ef-e70b4978d542
Co-authored-by: mdirshadengineer <191547746+mdirshadengineer@users.noreply.github.com>
---
.../workflow-editor/WorkflowCanvas.tsx | 54 +++++++++++++++++++
app/src/routes/index.tsx | 2 +-
app/src/routes/workflows/index.tsx | 9 ++--
3 files changed, 61 insertions(+), 4 deletions(-)
diff --git a/app/src/components/workflow-editor/WorkflowCanvas.tsx b/app/src/components/workflow-editor/WorkflowCanvas.tsx
index 620ad90..5bda2b7 100644
--- a/app/src/components/workflow-editor/WorkflowCanvas.tsx
+++ b/app/src/components/workflow-editor/WorkflowCanvas.tsx
@@ -2,6 +2,7 @@ import {
Background,
BackgroundVariant,
Controls,
+ type Edge,
MiniMap,
type Node,
ReactFlow,
@@ -18,6 +19,7 @@ import {
} from "@/components/ui/resizable"
import type { WorkflowNodeType } from "@/hooks/use-workflow"
import { useWorkflow } from "@/hooks/use-workflow"
+import { EdgeContextMenu } from "./EdgeContextMenu"
import { InspectorPanel } from "./InspectorPanel"
import { NodePanel } from "./NodePanel"
import { ActionNode } from "./nodes/ActionNode"
@@ -63,6 +65,11 @@ function Canvas({ workflowId }: { workflowId?: string }) {
const [showMiniMap, setShowMiniMap] = useState(true)
const [selectedNode, setSelectedNode] = useState(null)
+ const [edgeMenu, setEdgeMenu] = useState<{
+ edgeId: string
+ x: number
+ y: number
+ } | null>(null)
const reactFlowWrapper = useRef(null)
// Load workflow if id provided
@@ -129,6 +136,41 @@ function Canvas({ workflowId }: { workflowId?: string }) {
[setNodes]
)
+ // -----------------------------------------------------------------------
+ // Edge context menu
+ // -----------------------------------------------------------------------
+
+ const onEdgeContextMenu = useCallback((e: React.MouseEvent, edge: Edge) => {
+ e.preventDefault()
+ setEdgeMenu({ edgeId: edge.id, x: e.clientX, y: e.clientY })
+ }, [])
+
+ const onDeleteEdge = useCallback(
+ (id: string) => {
+ setEdges((eds) => eds.filter((e) => e.id !== id))
+ },
+ [setEdges]
+ )
+
+ const onAddConditionToEdge = useCallback(
+ (id: string) => {
+ const edge = edges.find((e) => e.id === id)
+ if (!edge) return
+ const sourceNode = nodes.find((n) => n.id === edge.source)
+ const targetNode = nodes.find((n) => n.id === edge.target)
+ const midX =
+ sourceNode && targetNode
+ ? (sourceNode.position.x + targetNode.position.x) / 2
+ : 200
+ const midY =
+ sourceNode && targetNode
+ ? (sourceNode.position.y + targetNode.position.y) / 2
+ : 200
+ addNode("condition", { x: midX, y: midY })
+ },
+ [edges, nodes, addNode]
+ )
+
return (
+
+ {edgeMenu && (
+ setEdgeMenu(null)}
+ />
+ )}
)
}
diff --git a/app/src/routes/index.tsx b/app/src/routes/index.tsx
index c07ee69..1f7aa7d 100644
--- a/app/src/routes/index.tsx
+++ b/app/src/routes/index.tsx
@@ -110,7 +110,7 @@ function RootComponent() {
/>
- AgentFlow
Automate. Innvovate. Collaborate.
+ AgentFlow
Automate. Innovate. Collaborate.
([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState(null)
@@ -40,8 +41,10 @@ function WorkflowsPage() {
})
if (!res.ok) throw new Error("Failed to create workflow")
const data: WorkflowRecord = await res.json()
- // navigate to the editor
- window.location.href = `/workflows/${data.id}`
+ navigate({
+ to: "/workflows/$workflowId",
+ params: { workflowId: data.id },
+ })
} catch (e) {
setError(e instanceof Error ? e.message : "Unknown error")
} finally {