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..5bda2b7 --- /dev/null +++ b/app/src/components/workflow-editor/WorkflowCanvas.tsx @@ -0,0 +1,277 @@ +import { + Background, + BackgroundVariant, + Controls, + type Edge, + 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 { EdgeContextMenu } from "./EdgeContextMenu" +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 [edgeMenu, setEdgeMenu] = useState<{ + edgeId: string + x: number + y: number + } | null>(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] + ) + + // ----------------------------------------------------------------------- + // 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 ( +
+ setShowMiniMap((v) => !v)} + /> + + + {/* Left panel — node palette */} + + + + + + + {/* Centre — canvas */} + +
+ + + + {showMiniMap && ( + + )} + +
+
+ + + + {/* Right panel — inspector */} + + + +
+ + {edgeMenu && ( + setEdgeMenu(null)} + /> + )} +
+ ) +} + +// --------------------------------------------------------------------------- +// 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..1f7aa7d 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 +
@@ -106,15 +110,21 @@ function RootComponent() { />

- AgentFlow
Automate. Innvovate. Collaborate. + AgentFlow
Automate. Innovate. 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..073af9b --- /dev/null +++ b/app/src/routes/workflows/index.tsx @@ -0,0 +1,146 @@ +import { createFileRoute, Link, useNavigate } 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 navigate = useNavigate() + 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: "/workflows/$workflowId", + params: { workflowId: 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