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
+
+