From d466173cbe5e545c52bc0a7398ad5b0a933261a5 Mon Sep 17 00:00:00 2001 From: Geoff Johnson Date: Tue, 14 Apr 2026 11:48:00 -0700 Subject: [PATCH 1/4] feat(ui): add workflow builder page with routing and persistence From 5a9f1a0ae6dfb5820390c2b074833ec556d55b7c Mon Sep 17 00:00:00 2001 From: Geoff Johnson Date: Tue, 14 Apr 2026 12:19:12 -0700 Subject: [PATCH 2/4] feat(ui): add workflow builder page with routing and persistence --- ui/src/App.tsx | 3 + ui/src/pages/workflows/WorkflowBuilder.tsx | 418 +++++++++++++++++++++ ui/src/pages/workflows/WorkflowDetail.tsx | 10 +- ui/src/pages/workflows/WorkflowList.tsx | 13 +- ui/src/test/pages/WorkflowBuilder.test.tsx | 280 ++++++++++++++ 5 files changed, 722 insertions(+), 2 deletions(-) create mode 100644 ui/src/pages/workflows/WorkflowBuilder.tsx create mode 100644 ui/src/test/pages/WorkflowBuilder.test.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 1fdcd9b6..154458af 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -18,6 +18,7 @@ import { } from "@/pages"; import { AgentDetail } from "@/pages/agents/AgentDetail"; import { QuestionDetail } from "@/pages/questions/QuestionDetail"; +import { WorkflowBuilder } from "@/pages/workflows/WorkflowBuilder"; import { WorkflowDetail } from "@/pages/workflows/WorkflowDetail"; function App() { @@ -34,7 +35,9 @@ function App() { } /> } /> } /> + } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/pages/workflows/WorkflowBuilder.tsx b/ui/src/pages/workflows/WorkflowBuilder.tsx new file mode 100644 index 00000000..1663f249 --- /dev/null +++ b/ui/src/pages/workflows/WorkflowBuilder.tsx @@ -0,0 +1,418 @@ +/** + * WorkflowBuilder — visual workflow composition page. + * + * Layout: + * ┌─────────────────────────────────────────────────┐ + * │ Header: name input │ [Save] [Cancel] │ + * ├──────────┬──────────────────────────────────────┤ + * │ NodePal │ WorkflowCanvas (React Flow) │ + * │ ette │ │ + * ├──────────┴──────────────────────────────────────┤ + * │ Status bar: validation errors / save state │ + * └─────────────────────────────────────────────────┘ + * + * Routes: + * /workflows/builder — create new workflow(s) + * /workflows/:id/edit — edit an existing workflow + */ + +import { + AlertCircle, + CheckCircle, + Loader2, + Save, + X, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { + useEdgesState, + useNodesState, + type Connection, + type Edge, + type Node, +} from "@xyflow/react"; +import { WorkflowCanvas } from "@/components/workflows/canvas/WorkflowCanvas"; +import { + NodePalette, + PALETTE_DRAG_KEY, + decodeDragData, +} from "@/components/workflows/canvas/NodePalette"; +import { workflowNodeTypes, workflowEdgeTypes } from "@/components/workflows/canvas/nodeTypes"; +import type { AgentNodeData } from "@/components/workflows/canvas/nodes/AgentNode"; +import type { TriggerNodeData } from "@/components/workflows/canvas/nodes/TriggerNode"; +import type { PromptEdgeData } from "@/components/workflows/canvas/edges/PromptEdge"; +import { + graphToWorkflows, + validateGraph, + workflowsToGraph, + loadLayout, + saveLayout, + layoutStorageKey, +} from "@/components/workflows/canvas/serialization"; +import type { SerializationError } from "@/components/workflows/canvas/serialization"; +import { useAgents } from "@/hooks/useAgents"; +import { orchestratorClient } from "@/services/orchestrator"; +import type { TriggerType } from "@/types/orchestrator"; +import { + getTriggerCategory, + getDefaultTriggerConfig, +} from "@/types/orchestrator"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function newNodeId(): string { + return `node-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; +} + +function newEdgeId(): string { + return `edge-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function WorkflowBuilder() { + const navigate = useNavigate(); + const { id: editWorkflowId } = useParams<{ id?: string }>(); + const isEditing = Boolean(editWorkflowId); + + // React Flow state + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + + // Builder state + const [workflowName, setWorkflowName] = useState(""); + const [isDirty, setIsDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(); + const [saveSuccess, setSaveSuccess] = useState(false); + const [validationErrors, setValidationErrors] = useState([]); + const [loading, setLoading] = useState(isEditing); + + // React Flow instance ref for screenToFlowPosition + const rfInstanceRef = useRef<{ screenToFlowPosition: (pos: { x: number; y: number }) => { x: number; y: number } } | null>(null); + + const { allAgents } = useAgents({ pageSize: 200 }); + + // ── Load existing workflow ───────────────────────────────────────────── + useEffect(() => { + if (!editWorkflowId) return; + + setLoading(true); + orchestratorClient + .getWorkflow(editWorkflowId) + .then((wf) => { + setWorkflowName(wf.name); + const layout = loadLayout([wf.id]); + const { nodes: n, edges: e } = workflowsToGraph([wf], allAgents, layout ?? undefined); + setNodes(n); + setEdges(e); + }) + .catch((err) => { + setSaveError( + err instanceof Error ? err.message : "Failed to load workflow", + ); + }) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editWorkflowId]); + + // ── Dirty tracking ───────────────────────────────────────────────────── + const markDirty = useCallback(() => { + setIsDirty(true); + setSaveSuccess(false); + }, []); + + // Intercept node/edge changes to mark dirty + const handleNodesChange = useCallback( + (changes: Parameters[0]) => { + onNodesChange(changes); + markDirty(); + }, + [onNodesChange, markDirty], + ); + + const handleEdgesChange = useCallback( + (changes: Parameters[0]) => { + onEdgesChange(changes); + markDirty(); + }, + [onEdgesChange, markDirty], + ); + + // ── Browser unload guard ─────────────────────────────────────────────── + useEffect(() => { + if (!isDirty) return; + function handleBeforeUnload(e: BeforeUnloadEvent) { + e.preventDefault(); + } + window.addEventListener("beforeunload", handleBeforeUnload); + return () => window.removeEventListener("beforeunload", handleBeforeUnload); + }, [isDirty]); + + // ── Connection validation ────────────────────────────────────────────── + const handleConnect = useCallback( + (connection: Connection) => { + const srcNode = nodes.find((n) => n.id === connection.source); + const tgtNode = nodes.find((n) => n.id === connection.target); + + // Only allow trigger → agent + if (srcNode?.type !== "trigger" || tgtNode?.type !== "agent") return; + + // Prevent duplicate edges for same source/target pair + const duplicate = edges.some( + (e) => + e.source === connection.source && + e.target === connection.target, + ); + if (duplicate) return; + + const newEdge: Edge = { + id: newEdgeId(), + source: connection.source!, + target: connection.target!, + type: "prompt", + data: { + promptTemplate: "", + pollIntervalSecs: 300, + enabled: true, + }, + }; + setEdges((prev) => [...prev, newEdge]); + markDirty(); + }, + [nodes, edges, setEdges, markDirty], + ); + + // ── Drag-and-drop from palette ───────────────────────────────────────── + function handleDragOver(e: React.DragEvent) { + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + } + + function handleDrop(e: React.DragEvent) { + e.preventDefault(); + const raw = e.dataTransfer.getData(PALETTE_DRAG_KEY); + if (!raw) return; + + const dragData = decodeDragData(raw); + if (!dragData) return; + + // Convert screen → flow coordinates + const canvasEl = e.currentTarget as HTMLElement; + const rect = canvasEl.getBoundingClientRect(); + const screenPos = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + const position = rfInstanceRef.current + ? rfInstanceRef.current.screenToFlowPosition(screenPos) + : screenPos; + + if (dragData.type === "trigger") { + const triggerType = dragData.triggerType as TriggerType; + const triggerConfig = getDefaultTriggerConfig(triggerType); + const category = getTriggerCategory(triggerType); + + const newNode: Node = { + id: newNodeId(), + type: "trigger", + position, + data: { + triggerConfig, + category, + enabled: true, + }, + }; + setNodes((prev) => [...prev, newNode]); + } else if (dragData.type === "agent") { + const agent = allAgents.find((a) => a.id === dragData.agentId); + if (!agent) return; + + const newNode: Node = { + id: newNodeId(), + type: "agent", + position, + data: { + agentId: agent.id, + name: agent.name, + status: agent.status, + model: agent.config?.model, + toolPolicy: agent.config.tool_policy, + }, + }; + setNodes((prev) => [...prev, newNode]); + } + + markDirty(); + } + + // ── Save ─────────────────────────────────────────────────────────────── + async function handleSave() { + const errors = validateGraph(nodes, edges); + setValidationErrors(errors); + if (errors.length > 0) return; + + setSaving(true); + setSaveError(undefined); + setSaveSuccess(false); + + try { + const requests = graphToWorkflows(nodes, edges); + const saved = await Promise.all( + requests.map((req) => { + const named = workflowName.trim() + ? { ...req, name: workflowName.trim() } + : req; + return orchestratorClient.createWorkflow(named); + }), + ); + + // Persist layout + const ids = saved.map((w) => w.id); + saveLayout(ids, { + nodes: Object.fromEntries( + nodes.map((n) => [n.id, n.position]), + ), + viewport: { x: 0, y: 0, zoom: 1 }, + }); + + setIsDirty(false); + setSaveSuccess(true); + + // Navigate to the first saved workflow's detail page after a beat + if (saved.length === 1) { + setTimeout(() => navigate(`/workflows/${saved[0].id}`), 800); + } else { + setTimeout(() => navigate("/workflows"), 800); + } + } catch (err) { + setSaveError( + err instanceof Error ? err.message : "Failed to save workflow", + ); + } finally { + setSaving(false); + } + } + + function handleCancel() { + navigate(isEditing ? `/workflows/${editWorkflowId}` : "/workflows"); + } + + // ── Render ───────────────────────────────────────────────────────────── + const hasErrors = validationErrors.length > 0; + + return ( +
+ {/* ── Header ───────────────────────────────────────────────── */} +
+ { + setWorkflowName(e.target.value); + markDirty(); + }} + placeholder="Workflow name…" + data-testid="builder-name-input" + className="flex-1 rounded border border-th-border-input bg-th-input px-3 py-1.5 text-sm text-th-text placeholder:text-th-text-faint focus:outline-none focus:ring-2 focus:ring-th-focus-ring" + /> + + + + + + {isDirty && !saving && !saveSuccess && ( + + Unsaved changes + + )} +
+ + {/* ── Main area: palette + canvas ───────────────────────────── */} +
+ + +
+ {loading ? ( +
+ +
+ ) : ( + + )} +
+
+ + {/* ── Status bar ───────────────────────────────────────────── */} + {(hasErrors || saveError || saveSuccess) && ( +
+ {saveSuccess ? ( + <> + + Saved — redirecting… + + ) : ( + <> + + + {saveError ?? + validationErrors.map((e) => e.message).join(" · ")} + + + )} +
+ )} +
+ ); +} + +export default WorkflowBuilder; diff --git a/ui/src/pages/workflows/WorkflowDetail.tsx b/ui/src/pages/workflows/WorkflowDetail.tsx index 438a96bb..b2ba2349 100644 --- a/ui/src/pages/workflows/WorkflowDetail.tsx +++ b/ui/src/pages/workflows/WorkflowDetail.tsx @@ -7,7 +7,7 @@ * - Dispatch history table */ -import { ArrowLeft, Edit2, RefreshCw, Trash2, Zap } from "lucide-react"; +import { ArrowLeft, Edit2, GitFork, RefreshCw, Trash2, Zap } from "lucide-react"; import { useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import { HighlightedCode } from "@/components/common"; @@ -205,6 +205,14 @@ export function WorkflowDetail() { > + + + Edit in builder + +