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/components/workflows/canvas/WorkflowCanvas.tsx b/ui/src/components/workflows/canvas/WorkflowCanvas.tsx index 469f7c4b..5d8bc06b 100644 --- a/ui/src/components/workflows/canvas/WorkflowCanvas.tsx +++ b/ui/src/components/workflows/canvas/WorkflowCanvas.tsx @@ -25,6 +25,7 @@ import { type OnConnect, type OnEdgesChange, type OnNodesChange, + type ReactFlowInstance, } from "@xyflow/react"; // --------------------------------------------------------------------------- @@ -48,6 +49,8 @@ export interface WorkflowCanvasProps { edgeTypes?: EdgeTypes; /** Optional CSS class applied to the outer wrapper */ className?: string; + /** Called with the ReactFlowInstance once the canvas is ready */ + onInit?: (instance: ReactFlowInstance) => void; } // --------------------------------------------------------------------------- @@ -63,6 +66,7 @@ function Canvas({ nodeTypes, edgeTypes, className = "", + onInit, }: WorkflowCanvasProps) { return (
(); + 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(null); + + // Guard so the edit-mode load runs only once per session. useAgents + // auto-refreshes every 10 s which would produce a new allAgents array + // reference on each poll, re-triggering the effect and overwriting any + // unsaved canvas edits (data-loss bug). + const hasLoadedRef = useRef(false); + + const { allAgents } = useAgents({ pageSize: 200 }); + + // ── Load existing workflow ───────────────────────────────────────────── + useEffect(() => { + if (!editWorkflowId || allAgents.length === 0 || hasLoadedRef.current) return; + hasLoadedRef.current = true; + + 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)); + }, [editWorkflowId, allAgents]); + + // ── Dirty tracking ───────────────────────────────────────────────────── + const markDirty = useCallback(() => { + setIsDirty(true); + setSaveSuccess(false); + }, []); + + // Intercept node/edge changes to mark dirty — only for genuine user edits. + // React Flow fires onNodesChange for internal housekeeping events + // (dimensions measurement, select/deselect, viewport fit) that are NOT user + // edits; marking dirty on those causes the "Unsaved changes" badge to appear + // immediately on page load in edit mode and arms the beforeunload guard + // unnecessarily. + const handleNodesChange = useCallback( + (changes: Parameters[0]) => { + onNodesChange(changes); + const isUserEdit = changes.some( + (c) => + c.type === "add" || + c.type === "remove" || + (c.type === "position" && c.dragging === true), + ); + if (isUserEdit) markDirty(); + }, + [onNodesChange, markDirty], + ); + + const handleEdgesChange = useCallback( + (changes: Parameters[0]) => { + onEdgesChange(changes); + // "select" is internal; all other edge changes (add, remove, replace, + // reset) are genuine user edits. + const isUserEdit = changes.some((c) => c.type !== "select"); + if (isUserEdit) 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); + if (requests.length === 0) { + setSaveError( + "Add at least one trigger connected to an agent before saving.", + ); + setSaving(false); + return; + } + const saved = await Promise.all( + requests.map((req) => { + const named = workflowName.trim() + ? { ...req, name: workflowName.trim() } + : req; + if (isEditing && editWorkflowId) { + return orchestratorClient.updateWorkflow(editWorkflowId, named); + } + 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 ? ( +
+ +
+ ) : ( + { + rfInstanceRef.current = instance; + }} + /> + )} +
+
+ + {/* ── 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 + +