diff --git a/AGENTS.md b/AGENTS.md index 4048543..093db82 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -229,6 +229,8 @@ Enforcement created → Execution scheduled → Worker executes Action - `do` — list of next task names to invoke when the condition is met - `label` — optional custom display label (overrides auto-derived label from `when` expression) - `color` — optional custom CSS color for the transition edge (e.g., `"#ff6600"`) + - `edge_waypoints` — optional `Record` of intermediate routing points per target task name (chart-only, stored in `__chart_meta__`) + - `label_positions` — optional `Record` of custom label positions per target task name (chart-only, stored in `__chart_meta__`) - **Example YAML**: ``` next: @@ -245,7 +247,7 @@ Enforcement created → Execution scheduled → Worker executes Action - error_handler ``` - **Legacy format support**: The parser (`crates/common/src/workflow/parser.rs`) auto-converts legacy `on_success`/`on_failure`/`on_complete`/`on_timeout`/`decision` fields into `next` transitions during parsing. The canonical internal representation always uses `next`. - - **Frontend types**: `TaskTransition` in `web/src/types/workflow.ts`; `TransitionPreset` ("succeeded" | "failed" | "always") for quick-access drag handles + - **Frontend types**: `TaskTransition` in `web/src/types/workflow.ts` (includes `edge_waypoints`, `label_positions` for visual routing); `TransitionPreset` ("succeeded" | "failed" | "always") for quick-access drag handles; `WorkflowEdge` includes per-edge `waypoints` and `labelPosition` derived from the transition; `SelectedEdgeInfo` and `EdgeHoverInfo` (includes `targetTaskId`) in `WorkflowEdges.tsx` - **Backend types**: `TaskTransition` in `crates/common/src/workflow/parser.rs`; `GraphTransition` in `crates/executor/src/workflow/graph.rs` - **NOT this** (legacy format): `on_success: task2` / `on_failure: error_handler` — still parsed for backward compat but normalized to `next` - **Runtime YAML Loading**: Pack registration reads `runtimes/*.yaml` files and inserts them into the `runtime` table. Runtime refs use format `{pack_ref}.{name}` (e.g., `core.python`, `core.shell`). @@ -318,7 +320,15 @@ Rule `action_params` support Jinja2-style `{{ source.path }}` templates resolved - **Visual / Raw YAML toggle**: Toolbar has a segmented toggle to switch between the visual node-based builder and a full-width read-only YAML preview (generated via `js-yaml`). Raw YAML mode replaces the canvas, palette, and inspector with the effective workflow definition. - **Drag-handle connections**: TaskNode has output handles (green=succeeded, red=failed, gray=always) and an input handle (top). Drag from an output handle to another node's input handle to create a transition. - **Transition customization**: Users can rename transitions (custom `label`) and assign custom colors (CSS color string or preset swatches) via the TaskInspector. Custom colors/labels are persisted in the workflow YAML and rendered on the canvas edges. - - **Orquesta-style `next` transitions**: Tasks use a `next: TaskTransition[]` array instead of flat `on_success`/`on_failure` fields. Each transition has `when` (condition), `publish` (variables), `do` (target tasks), plus optional `label` and `color`. See "Task Transition Model" above. + - **Edge waypoints & label dragging**: Transition edges support intermediate waypoints for custom routing. Click an edge to select it, then: + - Drag existing waypoint handles (colored circles) to reposition the edge path + - Hover near the midpoint of any edge segment to reveal a "+" handle; click or drag it to insert a new waypoint + - Drag the transition label to reposition it independently of the edge path + - Double-click a waypoint to remove it; double-click a label to reset its position + - Waypoints and label positions are stored per-edge (keyed by target task name) in `TaskTransition.edge_waypoints` and `TaskTransition.label_positions`, serialized via `__chart_meta__` in the workflow YAML + - Edge selection state (`SelectedEdgeInfo`) is managed in `WorkflowCanvas`; only the selected edge shows interactive handles + - Multi-segment paths use Catmull-Rom → cubic Bezier conversion for smooth curves through waypoints (`buildSmoothPath` in `WorkflowEdges.tsx`) + - **Orquesta-style `next` transitions**: Tasks use a `next: TaskTransition[]` array instead of flat `on_success`/`on_failure` fields. Each transition has `when` (condition), `publish` (variables), `do` (target tasks), plus optional `label`, `color`, `edge_waypoints`, and `label_positions`. See "Task Transition Model" above. - **No task type or task-level condition**: The UI does not expose task `type` or task-level `when` — all tasks are actions (workflows are also actions), and conditions belong on transitions. Parallelism is implicit via multiple `do` targets. ## Development Workflow diff --git a/web/src/components/workflows/TaskInspector.tsx b/web/src/components/workflows/TaskInspector.tsx index 47b0ed5..4478549 100644 --- a/web/src/components/workflows/TaskInspector.tsx +++ b/web/src/components/workflows/TaskInspector.tsx @@ -432,7 +432,9 @@ export default function TaskInspector({ className={`border rounded-lg bg-gray-50 overflow-hidden transition-all duration-300 ${ isFlashing ? "border-blue-400 ring-2 ring-blue-300 shadow-md shadow-blue-100 animate-[flash-highlight_1.5s_ease-out]" - : "border-gray-200" + : highlightTransitionIndex === ti + ? "border-blue-400 ring-1 ring-blue-200 bg-blue-50/40" + : "border-gray-200" }`} > {/* Transition header */} diff --git a/web/src/components/workflows/TaskNode.tsx b/web/src/components/workflows/TaskNode.tsx index 79deff0..98afde8 100644 --- a/web/src/components/workflows/TaskNode.tsx +++ b/web/src/components/workflows/TaskNode.tsx @@ -1,5 +1,5 @@ import { memo, useCallback, useRef, useState } from "react"; -import { Trash2, GripVertical, Play, Octagon } from "lucide-react"; +import { Trash2, GripVertical, Play, Octagon, Info } from "lucide-react"; import type { WorkflowTask, TransitionPreset } from "@/types/workflow"; import { PRESET_LABELS, @@ -75,7 +75,7 @@ function hasActiveTransition( } /** - * Compute a short summary of outgoing transitions for the node body. + * Compute a short summary of outgoing transitions for the tooltip. */ function transitionSummary(task: WorkflowTask): string | null { if (!task.next || task.next.length === 0) return null; @@ -93,6 +93,68 @@ function transitionSummary(task: WorkflowTask): string | null { return `${totalTargets} target${totalTargets !== 1 ? "s" : ""} via ${task.next.length} transition${task.next.length !== 1 ? "s" : ""}`; } +/** + * Check if a value is "populated" (non-null, non-undefined, non-empty-string). + */ +function hasValue(value: unknown): boolean { + if (value === null || value === undefined) return false; + if (typeof value === "string" && value.trim() === "") return false; + return true; +} + +/** + * Get entries from task.input that actually have values. + */ +function getPopulatedInputs( + input: Record, +): [string, unknown][] { + return Object.entries(input).filter(([, v]) => hasValue(v)); +} + +/** + * Format a value for inline display on the card — keep it short. + */ +function formatValueShort(value: unknown): string { + if (typeof value === "string") { + if (value.length > 28) return value.slice(0, 25) + "…"; + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (Array.isArray(value)) { + return `[${value.length} items]`; + } + if (typeof value === "object" && value !== null) { + return `{${Object.keys(value).length} keys}`; + } + return String(value); +} + +/** + * Format a value for the tooltip — can be slightly longer. + */ +function formatValueTooltip(value: unknown): string { + if (typeof value === "string") { + if (value.length > 40) return value.slice(0, 37) + "…"; + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (Array.isArray(value)) { + return `[${value.length} items]`; + } + if (typeof value === "object" && value !== null) { + const keys = Object.keys(value); + if (keys.length <= 2) { + return `{${keys.join(", ")}}`; + } + return `{${keys.length} keys}`; + } + return String(value); +} + function TaskNodeInner({ task, isSelected, @@ -109,6 +171,8 @@ function TaskNodeInner({ const [hoveredHandle, setHoveredHandle] = useState( null, ); + const [showTooltip, setShowTooltip] = useState(false); + const tooltipTimeout = useRef | null>(null); const dragOffset = useRef({ x: 0, y: 0 }); const handleMouseDown = useCallback( @@ -119,6 +183,7 @@ function TaskNodeInner({ e.stopPropagation(); setIsDragging(true); + setShowTooltip(false); dragOffset.current = { x: e.clientX - task.position.x, y: e.clientY - task.position.y, @@ -174,6 +239,19 @@ function TaskNodeInner({ [task.id, onStartConnection], ); + const handleBodyMouseEnter = useCallback(() => { + if (isDragging) return; + tooltipTimeout.current = setTimeout(() => setShowTooltip(true), 400); + }, [isDragging]); + + const handleBodyMouseLeave = useCallback(() => { + if (tooltipTimeout.current) { + clearTimeout(tooltipTimeout.current); + tooltipTimeout.current = null; + } + setShowTooltip(false); + }, []); + const isConnectionTarget = connectingFrom !== null; const borderColor = isSelected @@ -197,6 +275,49 @@ function TaskNodeInner({ return ct === "custom"; }).length; + // Inputs that actually have values + const populatedInputs = getPopulatedInputs(task.input); + const populatedCount = populatedInputs.length; + + // Show inline if 1–2 populated inputs + const showInlineInputs = populatedCount > 0 && populatedCount <= 2; + + // Build tooltip lines + const tooltipLines: string[] = []; + if (populatedCount > 0) { + tooltipLines.push( + `${populatedCount} input${populatedCount !== 1 ? "s" : ""} configured`, + ); + // Show all input key-values in tooltip when > 2 + if (populatedCount > 2) { + for (const [key, val] of populatedInputs) { + tooltipLines.push(` ${key}: ${formatValueTooltip(val)}`); + } + } + } + if (summary) { + tooltipLines.push(summary); + } + if (customTransitionCount > 0) { + tooltipLines.push( + `${customTransitionCount} custom transition${customTransitionCount !== 1 ? "s" : ""}`, + ); + } + if (task.delay) { + tooltipLines.push(`Delay: ${task.delay}s`); + } + if (task.with_items) { + tooltipLines.push("with_items iteration"); + } + if (task.retry) { + tooltipLines.push(`Retry: ${task.retry.count}×`); + } + if (task.timeout) { + tooltipLines.push(`Timeout: ${task.timeout}s`); + } + + const hasTooltipContent = tooltipLines.length > 0; + return (
{/* Body */} -
+
{hasAction ? (
{task.action} @@ -254,19 +379,25 @@ function TaskNodeInner({
)} - {/* Input summary */} - {Object.keys(task.input).length > 0 && ( -
- {Object.keys(task.input).length} input - {Object.keys(task.input).length !== 1 ? "s" : ""} + {/* Inline inputs (1–2 populated) */} + {showInlineInputs && ( +
+ {populatedInputs.map(([key, val]) => ( +
+ + {key}: + + + {formatValueShort(val)} + +
+ ))}
)} - {/* Transition summary */} - {summary && ( -
{summary}
- )} - {/* Delay badge */} {task.delay && (
@@ -288,11 +419,36 @@ function TaskNodeInner({
)} - {/* Custom transitions badge */} - {customTransitionCount > 0 && ( -
- {customTransitionCount} custom transition - {customTransitionCount !== 1 ? "s" : ""} + {/* Info icon hint — shown when there's tooltip content */} + {hasTooltipContent && ( +
+ +
+ )} + + {/* Tooltip */} + {showTooltip && hasTooltipContent && ( +
+
+ {tooltipLines.map((line, i) => ( +
0 + ? "mt-1 border-t border-gray-700 pt-1" + : "" + } + > + {line} +
+ ))} +
+
)}
diff --git a/web/src/components/workflows/WorkflowCanvas.tsx b/web/src/components/workflows/WorkflowCanvas.tsx index 9d0767f..016533b 100644 --- a/web/src/components/workflows/WorkflowCanvas.tsx +++ b/web/src/components/workflows/WorkflowCanvas.tsx @@ -2,8 +2,12 @@ import { useState, useCallback, useRef, useMemo } from "react"; import TaskNode from "./TaskNode"; import type { TransitionPreset } from "./TaskNode"; import WorkflowEdges from "./WorkflowEdges"; -import type { EdgeHoverInfo } from "./WorkflowEdges"; -import type { WorkflowTask, WorkflowEdge } from "@/types/workflow"; +import type { EdgeHoverInfo, SelectedEdgeInfo } from "./WorkflowEdges"; +import type { + WorkflowTask, + WorkflowEdge, + NodePosition, +} from "@/types/workflow"; import { deriveEdges, generateUniqueTaskName, @@ -55,6 +59,10 @@ export default function WorkflowCanvas({ y: number; } | null>(null); + const [selectedEdge, setSelectedEdge] = useState( + null, + ); + const allTaskNames = useMemo(() => tasks.map((t) => t.name), [tasks]); const edges: WorkflowEdge[] = useMemo(() => deriveEdges(tasks), [tasks]); @@ -73,10 +81,12 @@ export default function WorkflowCanvas({ setMousePosition(null); } else { onSelectTask(null); + setSelectedEdge(null); + onEdgeClick?.(null); } } }, - [onSelectTask, connectingFrom], + [onSelectTask, onEdgeClick, connectingFrom], ); const handleCanvasMouseMove = useCallback( @@ -113,8 +123,102 @@ export default function WorkflowCanvas({ const handleStartConnection = useCallback( (taskId: string, preset: TransitionPreset) => { setConnectingFrom({ taskId, preset }); + setSelectedEdge(null); + onEdgeClick?.(null); }, - [], + [onEdgeClick], + ); + + /** Handle edge click: select the edge and propagate to parent */ + const handleEdgeClick = useCallback( + (info: EdgeHoverInfo | null) => { + if (info) { + setSelectedEdge({ + from: info.taskId, + to: info.targetTaskId, + transitionIndex: info.transitionIndex, + }); + } else { + setSelectedEdge(null); + } + onEdgeClick?.(info); + }, + [onEdgeClick], + ); + + /** Handle selecting a task (also clears edge selection) */ + const handleSelectTask = useCallback( + (taskId: string | null) => { + onSelectTask(taskId); + if (taskId !== null) { + // Keep selected edge if the task being selected is part of it + // (i.e. user clicked the source task of the edge via edge click) + // Otherwise clear it + if (selectedEdge && selectedEdge.from !== taskId) { + setSelectedEdge(null); + onEdgeClick?.(null); + } + } + }, + [onSelectTask, onEdgeClick, selectedEdge], + ); + + /** Update waypoints for a specific edge */ + const handleWaypointUpdate = useCallback( + ( + fromTaskId: string, + transitionIndex: number, + targetTaskName: string, + waypoints: NodePosition[], + ) => { + const task = tasks.find((t) => t.id === fromTaskId); + if (!task || !task.next || transitionIndex >= task.next.length) return; + + const updatedNext = [...task.next]; + const transition = { ...updatedNext[transitionIndex] }; + const edgeWaypoints = { ...(transition.edge_waypoints || {}) }; + + if (waypoints.length > 0) { + edgeWaypoints[targetTaskName] = waypoints; + } else { + delete edgeWaypoints[targetTaskName]; + } + + transition.edge_waypoints = + Object.keys(edgeWaypoints).length > 0 ? edgeWaypoints : undefined; + updatedNext[transitionIndex] = transition; + onUpdateTask(fromTaskId, { next: updatedNext }); + }, + [tasks, onUpdateTask], + ); + + /** Update label position for a specific edge */ + const handleLabelPositionUpdate = useCallback( + ( + fromTaskId: string, + transitionIndex: number, + targetTaskName: string, + position: number | undefined, + ) => { + const task = tasks.find((t) => t.id === fromTaskId); + if (!task || !task.next || transitionIndex >= task.next.length) return; + + const updatedNext = [...task.next]; + const transition = { ...updatedNext[transitionIndex] }; + const labelPositions = { ...(transition.label_positions || {}) }; + + if (position) { + labelPositions[targetTaskName] = position; + } else { + delete labelPositions[targetTaskName]; + } + + transition.label_positions = + Object.keys(labelPositions).length > 0 ? labelPositions : undefined; + updatedNext[transitionIndex] = transition; + onUpdateTask(fromTaskId, { next: updatedNext }); + }, + [tasks, onUpdateTask], ); const handleCompleteConnection = useCallback( @@ -211,7 +315,10 @@ export default function WorkflowCanvas({ tasks={tasks} connectingFrom={connectingFrom} mousePosition={mousePosition} - onEdgeClick={onEdgeClick} + onEdgeClick={handleEdgeClick} + selectedEdge={selectedEdge} + onWaypointUpdate={handleWaypointUpdate} + onLabelPositionUpdate={handleLabelPositionUpdate} /> {/* Task nodes */} @@ -222,7 +329,7 @@ export default function WorkflowCanvas({ isSelected={task.id === selectedTaskId} isStartNode={startingTaskIds.has(task.id)} allTaskNames={allTaskNames} - onSelect={onSelectTask} + onSelect={handleSelectTask} onDelete={onDeleteTask} onPositionChange={handlePositionChange} onStartConnection={handleStartConnection} diff --git a/web/src/components/workflows/WorkflowEdges.tsx b/web/src/components/workflows/WorkflowEdges.tsx index 521a60e..a57019f 100644 --- a/web/src/components/workflows/WorkflowEdges.tsx +++ b/web/src/components/workflows/WorkflowEdges.tsx @@ -1,10 +1,23 @@ -import { memo, useMemo } from "react"; -import type { WorkflowEdge, WorkflowTask, EdgeType } from "@/types/workflow"; +import { memo, useMemo, useState, useCallback, useRef, useEffect } from "react"; +import type { + WorkflowEdge, + WorkflowTask, + EdgeType, + NodePosition, +} from "@/types/workflow"; import { PRESET_COLORS } from "@/types/workflow"; import type { TransitionPreset } from "./TaskNode"; export interface EdgeHoverInfo { taskId: string; + targetTaskId: string; + transitionIndex: number; +} + +/** Identifies a selected edge for waypoint editing */ +export interface SelectedEdgeInfo { + from: string; + to: string; transitionIndex: number; } @@ -21,6 +34,22 @@ interface WorkflowEdgesProps { mousePosition?: { x: number; y: number } | null; /** Called when an edge is clicked */ onEdgeClick?: (info: EdgeHoverInfo | null) => void; + /** Currently selected edge (shows waypoint handles) */ + selectedEdge?: SelectedEdgeInfo | null; + /** Called when waypoints change for an edge */ + onWaypointUpdate?: ( + fromTaskId: string, + transitionIndex: number, + targetTaskName: string, + waypoints: NodePosition[], + ) => void; + /** Called when label position changes for an edge (t-parameter 0–1 along path) */ + onLabelPositionUpdate?: ( + fromTaskId: string, + transitionIndex: number, + targetTaskName: string, + position: number | undefined, + ) => void; } const NODE_WIDTH = 240; @@ -83,8 +112,6 @@ function getNodeRightCenter( /** * Determine the best connection points between two nodes. - * Returns the start and end points for the edge, plus whether this is a - * self-loop (from === to). */ function getBestConnectionPoints( fromTask: WorkflowTask, @@ -96,7 +123,6 @@ function getBestConnectionPoints( end: { x: number; y: number }; selfLoop?: boolean; } { - // Self-loop: start from right side, end at top-right area if (fromTask.id === toTask.id) { return { start: getNodeRightCenter(fromTask, nodeWidth, nodeHeight), @@ -120,7 +146,6 @@ function getBestConnectionPoints( const dx = toCenter.x - fromCenter.x; const dy = toCenter.y - fromCenter.y; - // If the target is mostly below the source, use bottom→top if (dy > 0 && Math.abs(dy) > Math.abs(dx) * 0.5) { return { start: getNodeBottomCenter(fromTask, nodeWidth, nodeHeight), @@ -128,8 +153,6 @@ function getBestConnectionPoints( }; } - // If the target is mostly above the source, use a side edge (never top). - // Pick the side closer to the target, and arrive at the target's bottom. if (dy < 0 && Math.abs(dy) > Math.abs(dx) * 0.5) { const start = dx >= 0 @@ -141,7 +164,6 @@ function getBestConnectionPoints( }; } - // If the target is to the right, use right→left if (dx > 0) { return { start: getNodeRightCenter(fromTask, nodeWidth, nodeHeight), @@ -149,7 +171,6 @@ function getBestConnectionPoints( }; } - // Target is to the left, use left→right return { start: getNodeLeftCenter(fromTask, nodeHeight), end: getNodeRightCenter(toTask, nodeWidth, nodeHeight), @@ -157,15 +178,13 @@ function getBestConnectionPoints( } /** - * Build an SVG path for a self-loop: exits from the right side, arcs out - * to the right and upward, then curves back to the top of the node. + * Build an SVG path for a self-loop. */ function buildSelfLoopPath( start: { x: number; y: number }, end: { x: number; y: number }, ): string { const loopOffset = 50; - // Control points: push out to the right and then curve up and back const cp1 = { x: start.x + loopOffset, y: start.y - 20 }; const cp2 = { x: end.x + loopOffset, y: end.y - 40 }; return `M ${start.x} ${start.y} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${end.x} ${end.y}`; @@ -173,7 +192,6 @@ function buildSelfLoopPath( /** * Build an SVG path string for a curved edge between two points. - * Uses a cubic bezier curve. */ function buildCurvePath( start: { x: number; y: number }, @@ -182,18 +200,15 @@ function buildCurvePath( const dx = end.x - start.x; const dy = end.y - start.y; - // Determine control points based on dominant direction let cp1: { x: number; y: number }; let cp2: { x: number; y: number }; if (Math.abs(dy) > Math.abs(dx) * 0.5) { - // Mostly vertical connection const offset = Math.min(Math.abs(dy) * 0.5, 80); const direction = dy > 0 ? 1 : -1; cp1 = { x: start.x, y: start.y + offset * direction }; cp2 = { x: end.x, y: end.y - offset * direction }; } else { - // Mostly horizontal connection const offset = Math.min(Math.abs(dx) * 0.5, 80); const direction = dx > 0 ? 1 : -1; cp1 = { x: start.x + offset * direction, y: start.y }; @@ -203,6 +218,281 @@ function buildCurvePath( return `M ${start.x} ${start.y} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${end.x} ${end.y}`; } +/** + * Build a smooth SVG path through multiple points using Catmull-Rom → cubic Bezier conversion. + */ +function buildSmoothPath(points: { x: number; y: number }[]): string { + if (points.length < 2) return ""; + if (points.length === 2) return buildCurvePath(points[0], points[1]); + + let d = `M ${points[0].x} ${points[0].y}`; + + for (let i = 0; i < points.length - 1; i++) { + const p0 = points[Math.max(0, i - 1)]; + const p1 = points[i]; + const p2 = points[i + 1]; + const p3 = points[Math.min(points.length - 1, i + 2)]; + + // Catmull-Rom to cubic Bezier: CP1 = P1 + (P2 - P0) / 6, CP2 = P2 - (P3 - P1) / 6 + const cp1x = p1.x + (p2.x - p0.x) / 6; + const cp1y = p1.y + (p2.y - p0.y) / 6; + const cp2x = p2.x - (p3.x - p1.x) / 6; + const cp2y = p2.y - (p3.y - p1.y) / 6; + + d += ` C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${p2.x} ${p2.y}`; + } + + return d; +} + +/** + * Evaluate a cubic Bezier curve at parameter t ∈ [0,1]. + * Returns the (x, y) point on the curve. + */ +function evaluateCubicBezier( + p0: { x: number; y: number }, + cp1: { x: number; y: number }, + cp2: { x: number; y: number }, + p3: { x: number; y: number }, + t: number, +): { x: number; y: number } { + const u = 1 - t; + const u2 = u * u; + const u3 = u2 * u; + const t2 = t * t; + const t3 = t2 * t; + return { + x: u3 * p0.x + 3 * u2 * t * cp1.x + 3 * u * t2 * cp2.x + t3 * p3.x, + y: u3 * p0.y + 3 * u2 * t * cp1.y + 3 * u * t2 * cp2.y + t3 * p3.y, + }; +} + +/** + * Get the control points for a specific segment of the path. + * For a 2-point path, uses the buildCurvePath logic. + * For a multi-point path, uses the Catmull-Rom (buildSmoothPath) logic. + */ +function getSegmentControlPoints( + allPoints: { x: number; y: number }[], + segIdx: number, +): { cp1: { x: number; y: number }; cp2: { x: number; y: number } } { + if (allPoints.length === 2) { + return getCurveControlPoints(allPoints[0], allPoints[1]); + } + return getSmoothSegmentControlPoints(allPoints, segIdx); +} + +/** + * Evaluate the full path at a global t parameter ∈ [0, 1]. + * Maps t onto the correct segment then evaluates the cubic Bezier for that segment. + */ +function evaluatePathAtT( + allPoints: { x: number; y: number }[], + t: number, + selfLoop?: boolean, +): { x: number; y: number } { + if (allPoints.length < 2) { + return allPoints[0] ?? { x: 0, y: 0 }; + } + + // Self-loop with no waypoints (allPoints = [start, end]) + if (selfLoop && allPoints.length === 2) { + const start = allPoints[0]; + const end = allPoints[1]; + const loopOffset = 50; + const cp1 = { x: start.x + loopOffset, y: start.y - 20 }; + const cp2 = { x: end.x + loopOffset, y: end.y - 40 }; + return evaluateCubicBezier( + start, + cp1, + cp2, + end, + Math.max(0, Math.min(1, t)), + ); + } + + const numSegments = allPoints.length - 1; + const clampedT = Math.max(0, Math.min(1, t)); + const scaledT = clampedT * numSegments; + const segIdx = Math.min(Math.floor(scaledT), numSegments - 1); + const localT = scaledT - segIdx; + + const { cp1, cp2 } = getSegmentControlPoints(allPoints, segIdx); + return evaluateCubicBezier( + allPoints[segIdx], + cp1, + cp2, + allPoints[segIdx + 1], + localT, + ); +} + +/** + * Project a mouse position onto the nearest point on the path. + * Returns the global t parameter ∈ [0, 1]. + */ +function projectOntoPath( + allPoints: { x: number; y: number }[], + mousePos: { x: number; y: number }, + selfLoop?: boolean, +): number { + if (allPoints.length < 2) return 0; + + const samplesPerSegment = 60; + let bestT = 0.5; + let bestDist = Infinity; + + // Self-loop with no waypoints + if (selfLoop && allPoints.length === 2) { + const start = allPoints[0]; + const end = allPoints[1]; + const loopOffset = 50; + const cp1 = { x: start.x + loopOffset, y: start.y - 20 }; + const cp2 = { x: end.x + loopOffset, y: end.y - 40 }; + for (let s = 0; s <= samplesPerSegment; s++) { + const localT = s / samplesPerSegment; + const pt = evaluateCubicBezier(start, cp1, cp2, end, localT); + const dist = Math.hypot(pt.x - mousePos.x, pt.y - mousePos.y); + if (dist < bestDist) { + bestDist = dist; + bestT = localT; + } + } + return bestT; + } + + const numSegments = allPoints.length - 1; + + for (let seg = 0; seg < numSegments; seg++) { + const { cp1, cp2 } = getSegmentControlPoints(allPoints, seg); + const p1 = allPoints[seg]; + const p2 = allPoints[seg + 1]; + + for (let s = 0; s <= samplesPerSegment; s++) { + const localT = s / samplesPerSegment; + const pt = evaluateCubicBezier(p1, cp1, cp2, p2, localT); + const dist = Math.hypot(pt.x - mousePos.x, pt.y - mousePos.y); + if (dist < bestDist) { + bestDist = dist; + bestT = (seg + localT) / numSegments; + } + } + } + + return bestT; +} + +/** + * Compute the control points for a 2-point cubic Bezier (matching buildCurvePath logic). + */ +function getCurveControlPoints( + start: { x: number; y: number }, + end: { x: number; y: number }, +): { cp1: { x: number; y: number }; cp2: { x: number; y: number } } { + const dx = end.x - start.x; + const dy = end.y - start.y; + + let cp1: { x: number; y: number }; + let cp2: { x: number; y: number }; + + if (Math.abs(dy) > Math.abs(dx) * 0.5) { + const offset = Math.min(Math.abs(dy) * 0.5, 80); + const direction = dy > 0 ? 1 : -1; + cp1 = { x: start.x, y: start.y + offset * direction }; + cp2 = { x: end.x, y: end.y - offset * direction }; + } else { + const offset = Math.min(Math.abs(dx) * 0.5, 80); + const direction = dx > 0 ? 1 : -1; + cp1 = { x: start.x + offset * direction, y: start.y }; + cp2 = { x: end.x - offset * direction, y: end.y }; + } + return { cp1, cp2 }; +} + +/** + * Compute the Catmull-Rom control points for a segment from points[i] to points[i+1], + * matching the buildSmoothPath logic. + */ +function getSmoothSegmentControlPoints( + points: { x: number; y: number }[], + i: number, +): { cp1: { x: number; y: number }; cp2: { x: number; y: number } } { + const p0 = points[Math.max(0, i - 1)]; + const p1 = points[i]; + const p2 = points[i + 1]; + const p3 = points[Math.min(points.length - 1, i + 2)]; + + return { + cp1: { + x: p1.x + (p2.x - p0.x) / 6, + y: p1.y + (p2.y - p0.y) / 6, + }, + cp2: { + x: p2.x - (p3.x - p1.x) / 6, + y: p2.y - (p3.y - p1.y) / 6, + }, + }; +} + +/** + * Compute the point at t=0.5 on the actual curve segment between + * allPoints[segIdx] and allPoints[segIdx+1]. + * + * For a 2-point path this uses the same control-point logic as buildCurvePath. + * For a multi-point path this uses the Catmull-Rom control points from buildSmoothPath. + */ +function curveSegmentMidpoint( + allPoints: { x: number; y: number }[], + segIdx: number, +): { x: number; y: number } { + const p1 = allPoints[segIdx]; + const p2 = allPoints[segIdx + 1]; + + if (allPoints.length === 2) { + // 2-point path — mirrors buildCurvePath + const { cp1, cp2 } = getCurveControlPoints(p1, p2); + return evaluateCubicBezier(p1, cp1, cp2, p2, 0.5); + } + + // Multi-point path — mirrors buildSmoothPath (Catmull-Rom → cubic Bezier) + const { cp1, cp2 } = getSmoothSegmentControlPoints(allPoints, segIdx); + return evaluateCubicBezier(p1, cp1, cp2, p2, 0.5); +} + +/** Check whether two SelectedEdgeInfo match the same edge */ +function edgeMatches( + sel: SelectedEdgeInfo | null | undefined, + edge: WorkflowEdge, +): boolean { + if (!sel) return false; + return ( + sel.from === edge.from && + sel.to === edge.to && + sel.transitionIndex === edge.transitionIndex + ); +} + +/** Drag state tracked via ref for performance */ +interface DragState { + type: "waypoint" | "label" | "new-waypoint"; + edgeFrom: string; + edgeTo: string; + edgeToName: string; + transitionIndex: number; + /** Index within the waypoints array (-1 for label) */ + waypointIndex: number; + /** The full waypoints array snapshot at drag start (for new-waypoint, already includes the new point) */ + waypointsSnapshot: NodePosition[]; + startMouseX: number; + startMouseY: number; + startPointX: number; + startPointY: number; + /** Path points at drag start — used for label projection */ + pathPoints?: { x: number; y: number }[]; + /** Whether this edge is a self-loop — used for label projection */ + isSelfLoop?: boolean; +} + function WorkflowEdgesInner({ edges, tasks, @@ -211,7 +501,12 @@ function WorkflowEdgesInner({ connectingFrom, mousePosition, onEdgeClick, + selectedEdge, + onWaypointUpdate, + onLabelPositionUpdate, }: WorkflowEdgesProps) { + const svgRef = useRef(null); + const taskMap = useMemo(() => { const map = new Map(); for (const task of tasks) { @@ -220,7 +515,6 @@ function WorkflowEdgesInner({ return map; }, [tasks]); - // Calculate SVG bounds to cover all nodes + padding const svgBounds = useMemo(() => { if (tasks.length === 0) return { width: 2000, height: 2000 }; let maxX = 0; @@ -235,101 +529,336 @@ function WorkflowEdgesInner({ }; }, [tasks, nodeWidth, nodeHeight]); - const renderedEdges = useMemo(() => { - return edges - .map((edge, index) => { - const fromTask = taskMap.get(edge.from); - const toTask = taskMap.get(edge.to); - if (!fromTask || !toTask) return null; + // ---- Drag state ---- + const dragStateRef = useRef(null); + const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null); + const dragPosRef = useRef<{ x: number; y: number } | null>(null); + const [isDragging, setIsDragging] = useState(false); + /** Current label t-parameter during drag — committed on mouseup */ + const labelDragTRef = useRef(0.5); + // Tracks which drag is active so we can match it to edge rendering + const [activeDrag, setActiveDrag] = useState<{ + edgeFrom: string; + edgeTo: string; + transitionIndex: number; + waypointIndex: number; + type: "waypoint" | "label" | "new-waypoint"; + waypointsSnapshot: NodePosition[]; + } | null>(null); - const { start, end, selfLoop } = getBestConnectionPoints( - fromTask, - toTask, - nodeWidth, - nodeHeight, + // ---- Midpoint hover state ---- + const [hoveredMidpoint, setHoveredMidpoint] = useState<{ + edgeFrom: string; + edgeTo: string; + transitionIndex: number; + segmentIndex: number; + } | null>(null); + + /** Convert client coordinates to SVG coordinates */ + const clientToSvg = useCallback( + (clientX: number, clientY: number): { x: number; y: number } => { + const svg = svgRef.current; + if (!svg) return { x: clientX, y: clientY }; + const rect = svg.getBoundingClientRect(); + const parent = svg.parentElement; + const scrollLeft = parent?.scrollLeft ?? 0; + const scrollTop = parent?.scrollTop ?? 0; + return { + x: clientX - rect.left + scrollLeft, + y: clientY - rect.top + scrollTop, + }; + }, + [], + ); + + // Refs to hold latest callback values (updated via effects) + const clientToSvgRef = useRef(clientToSvg); + const onWaypointUpdateRef = useRef(onWaypointUpdate); + const onLabelPositionUpdateRef = useRef(onLabelPositionUpdate); + + useEffect(() => { + clientToSvgRef.current = clientToSvg; + }, [clientToSvg]); + useEffect(() => { + onWaypointUpdateRef.current = onWaypointUpdate; + }, [onWaypointUpdate]); + useEffect(() => { + onLabelPositionUpdateRef.current = onLabelPositionUpdate; + }, [onLabelPositionUpdate]); + + // Effect-based drag listener management + useEffect(() => { + if (!isDragging) return; + + const handleMouseMove = (e: MouseEvent) => { + const ds = dragStateRef.current; + if (!ds) return; + const svgPos = clientToSvgRef.current(e.clientX, e.clientY); + + if (ds.type === "label" && ds.pathPoints) { + // Project mouse onto path and snap the label to it + const t = projectOntoPath(ds.pathPoints, svgPos, ds.isSelfLoop); + labelDragTRef.current = t; + const onCurve = evaluatePathAtT(ds.pathPoints, t, ds.isSelfLoop); + setDragPos(onCurve); + dragPosRef.current = onCurve; + } else { + const dx = svgPos.x - ds.startMouseX; + const dy = svgPos.y - ds.startMouseY; + const newPos = { + x: ds.startPointX + dx, + y: ds.startPointY + dy, + }; + setDragPos(newPos); + dragPosRef.current = newPos; + } + }; + + const handleMouseUp = () => { + const ds = dragStateRef.current; + if (!ds) return; + + const currentDragPos = dragPosRef.current; + + if (ds.type === "waypoint" || ds.type === "new-waypoint") { + const finalWaypoints = [...ds.waypointsSnapshot]; + if (currentDragPos) { + finalWaypoints[ds.waypointIndex] = { + x: currentDragPos.x, + y: currentDragPos.y, + }; + } + onWaypointUpdateRef.current?.( + ds.edgeFrom, + ds.transitionIndex, + ds.edgeToName, + finalWaypoints, ); - - const pathD = selfLoop - ? buildSelfLoopPath(start, end) - : buildCurvePath(start, end); - const color = - edge.color || EDGE_COLORS[edge.type] || EDGE_COLORS.complete; - const dash = EDGE_DASH[edge.type] || ""; - - // Calculate label position — offset to the right for self-loops - const labelX = selfLoop ? start.x + 50 : (start.x + end.x) / 2; - const labelY = selfLoop - ? (start.y + end.y) / 2 - 20 - : (start.y + end.y) / 2 - 8; - - // Measure approximate label width - const labelText = edge.label || ""; - const labelWidth = Math.max(labelText.length * 5.5 + 12, 48); - const arrowId = edge.color - ? `arrow-custom-${index}` - : `arrow-${edge.type}`; - - return ( - - {/* Edge path */} - - {/* Wider invisible path for easier hovering */} - - onEdgeClick?.({ - taskId: edge.from, - transitionIndex: edge.transitionIndex, - }) - } - /> - {/* Label */} - {edge.label && ( - - - - {labelText.length > 24 - ? labelText.slice(0, 21) + "..." - : labelText} - - - )} - + } else if (ds.type === "label") { + onLabelPositionUpdateRef.current?.( + ds.edgeFrom, + ds.transitionIndex, + ds.edgeToName, + labelDragTRef.current, ); - }) - .filter(Boolean); - }, [edges, taskMap, nodeWidth, nodeHeight, onEdgeClick]); + } + + dragStateRef.current = null; + setDragPos(null); + dragPosRef.current = null; + setActiveDrag(null); + setIsDragging(false); + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [isDragging]); + + /** Start dragging an existing waypoint */ + const startWaypointDrag = useCallback( + ( + e: React.MouseEvent, + edge: WorkflowEdge, + waypointIndex: number, + currentWaypoints: NodePosition[], + ) => { + e.stopPropagation(); + e.preventDefault(); + + const svgPos = clientToSvg(e.clientX, e.clientY); + const wp = currentWaypoints[waypointIndex]; + + dragStateRef.current = { + type: "waypoint", + edgeFrom: edge.from, + edgeTo: edge.to, + edgeToName: edge.toName, + transitionIndex: edge.transitionIndex, + waypointIndex, + waypointsSnapshot: [...currentWaypoints], + startMouseX: svgPos.x, + startMouseY: svgPos.y, + startPointX: wp.x, + startPointY: wp.y, + }; + + setDragPos({ x: wp.x, y: wp.y }); + dragPosRef.current = { x: wp.x, y: wp.y }; + setActiveDrag({ + edgeFrom: edge.from, + edgeTo: edge.to, + transitionIndex: edge.transitionIndex, + waypointIndex, + type: "waypoint", + waypointsSnapshot: [...currentWaypoints], + }); + setIsDragging(true); + }, + [clientToSvg], + ); + + /** Start dragging the label along the path */ + const startLabelDrag = useCallback( + ( + e: React.MouseEvent, + edge: WorkflowEdge, + currentLabelPos: { x: number; y: number }, + allPoints: { x: number; y: number }[], + selfLoop?: boolean, + ) => { + e.stopPropagation(); + e.preventDefault(); + + const svgPos = clientToSvg(e.clientX, e.clientY); + + // Initialise t from current label position + const initialT = projectOntoPath(allPoints, currentLabelPos, selfLoop); + labelDragTRef.current = initialT; + + dragStateRef.current = { + type: "label", + edgeFrom: edge.from, + edgeTo: edge.to, + edgeToName: edge.toName, + transitionIndex: edge.transitionIndex, + waypointIndex: -1, + waypointsSnapshot: [], + startMouseX: svgPos.x, + startMouseY: svgPos.y, + startPointX: currentLabelPos.x, + startPointY: currentLabelPos.y, + pathPoints: allPoints, + isSelfLoop: selfLoop, + }; + + setDragPos({ x: currentLabelPos.x, y: currentLabelPos.y }); + dragPosRef.current = { x: currentLabelPos.x, y: currentLabelPos.y }; + setActiveDrag({ + edgeFrom: edge.from, + edgeTo: edge.to, + transitionIndex: edge.transitionIndex, + waypointIndex: -1, + type: "label", + waypointsSnapshot: [], + }); + setIsDragging(true); + }, + [clientToSvg], + ); + + /** Add a new waypoint at a midpoint and begin dragging it */ + const addAndDragWaypoint = useCallback( + ( + e: React.MouseEvent, + edge: WorkflowEdge, + segmentIndex: number, + allPoints: { x: number; y: number }[], + currentWaypoints: NodePosition[], + ) => { + e.stopPropagation(); + e.preventDefault(); + + const mid = curveSegmentMidpoint(allPoints, segmentIndex); + const newWaypointIdx = segmentIndex; + const updatedWaypoints = [...currentWaypoints]; + updatedWaypoints.splice(newWaypointIdx, 0, { x: mid.x, y: mid.y }); + + const svgPos = clientToSvg(e.clientX, e.clientY); + + dragStateRef.current = { + type: "new-waypoint", + edgeFrom: edge.from, + edgeTo: edge.to, + edgeToName: edge.toName, + transitionIndex: edge.transitionIndex, + waypointIndex: newWaypointIdx, + waypointsSnapshot: updatedWaypoints, + startMouseX: svgPos.x, + startMouseY: svgPos.y, + startPointX: mid.x, + startPointY: mid.y, + }; + + setDragPos({ x: mid.x, y: mid.y }); + dragPosRef.current = { x: mid.x, y: mid.y }; + setActiveDrag({ + edgeFrom: edge.from, + edgeTo: edge.to, + transitionIndex: edge.transitionIndex, + waypointIndex: newWaypointIdx, + type: "new-waypoint", + waypointsSnapshot: updatedWaypoints, + }); + setHoveredMidpoint(null); + setIsDragging(true); + }, + [clientToSvg], + ); + + /** Handle clicking the midpoint indicator (create waypoint without drag) */ + const handleMidpointClick = useCallback( + ( + e: React.MouseEvent, + edge: WorkflowEdge, + segmentIndex: number, + allPoints: { x: number; y: number }[], + currentWaypoints: NodePosition[], + ) => { + e.stopPropagation(); + e.preventDefault(); + + const mid = curveSegmentMidpoint(allPoints, segmentIndex); + const newWaypointIdx = segmentIndex; + const updatedWaypoints = [...currentWaypoints]; + updatedWaypoints.splice(newWaypointIdx, 0, { x: mid.x, y: mid.y }); + + onWaypointUpdate?.( + edge.from, + edge.transitionIndex, + edge.toName, + updatedWaypoints, + ); + setHoveredMidpoint(null); + }, + [onWaypointUpdate], + ); + + /** Remove a waypoint on double-click */ + const handleWaypointDoubleClick = useCallback( + ( + e: React.MouseEvent, + edge: WorkflowEdge, + waypointIndex: number, + currentWaypoints: NodePosition[], + ) => { + e.stopPropagation(); + e.preventDefault(); + const updated = [...currentWaypoints]; + updated.splice(waypointIndex, 1); + onWaypointUpdate?.(edge.from, edge.transitionIndex, edge.toName, updated); + }, + [onWaypointUpdate], + ); + + /** Reset label to default position (t=0.5) on double-click */ + const handleLabelDoubleClick = useCallback( + (e: React.MouseEvent, edge: WorkflowEdge) => { + e.stopPropagation(); + e.preventDefault(); + onLabelPositionUpdate?.( + edge.from, + edge.transitionIndex, + edge.toName, + undefined, + ); + }, + [onLabelPositionUpdate], + ); // Preview line when connecting const previewLine = useMemo(() => { @@ -357,6 +886,7 @@ function WorkflowEdgesInner({ return ( - {/* Render edges */} {/* Dynamic arrow markers for custom-colored edges */} {edges.map((edge, index) => { @@ -400,7 +929,356 @@ function WorkflowEdgesInner({ ); })} - {renderedEdges} + + {/* Render edges */} + {edges.map((edge, index) => { + const fromTask = taskMap.get(edge.from); + const toTask = taskMap.get(edge.to); + if (!fromTask || !toTask) return null; + + const { start, end, selfLoop } = getBestConnectionPoints( + fromTask, + toTask, + nodeWidth, + nodeHeight, + ); + + const isSelected = edgeMatches(selectedEdge, edge); + + // Build the current waypoints, applying drag override if active + let currentWaypoints: NodePosition[] = edge.waypoints + ? [...edge.waypoints] + : []; + if ( + activeDrag && + activeDrag.edgeFrom === edge.from && + activeDrag.edgeTo === edge.to && + activeDrag.transitionIndex === edge.transitionIndex && + (activeDrag.type === "waypoint" || + activeDrag.type === "new-waypoint") + ) { + // Use the snapshot from state with drag position applied + currentWaypoints = [...activeDrag.waypointsSnapshot]; + if (dragPos) { + currentWaypoints[activeDrag.waypointIndex] = { + x: dragPos.x, + y: dragPos.y, + }; + } + } + + // All points: start → waypoints → end + const allPoints = [start, ...currentWaypoints, end]; + + const pathD = + selfLoop && currentWaypoints.length === 0 + ? buildSelfLoopPath(start, end) + : allPoints.length === 2 + ? buildCurvePath(start, end) + : buildSmoothPath(allPoints); + + const color = + edge.color || EDGE_COLORS[edge.type] || EDGE_COLORS.complete; + const dash = EDGE_DASH[edge.type] || ""; + const arrowId = edge.color + ? `arrow-custom-${index}` + : `arrow-${edge.type}`; + + // Label position — evaluate t-parameter on the actual path + let labelPos: { x: number; y: number }; + const isSelfLoopEdge = selfLoop && currentWaypoints.length === 0; + if ( + activeDrag && + activeDrag.type === "label" && + activeDrag.edgeFrom === edge.from && + activeDrag.edgeTo === edge.to && + activeDrag.transitionIndex === edge.transitionIndex && + dragPos + ) { + // During drag, dragPos is already snapped to the curve + labelPos = dragPos; + } else { + const t = edge.labelPosition ?? 0.5; + labelPos = evaluatePathAtT(allPoints, t, isSelfLoopEdge); + } + + const labelText = edge.label || ""; + const labelWidth = Math.max(labelText.length * 5.5 + 12, 48); + + return ( + + {/* Edge path */} + + + {/* Selection glow for selected edge */} + {isSelected && ( + + )} + + {/* Wider invisible path for easier clicking */} + { + e.stopPropagation(); + onEdgeClick?.({ + taskId: edge.from, + targetTaskId: edge.to, + transitionIndex: edge.transitionIndex, + }); + }} + /> + + {/* Label */} + {edge.label && ( + + startLabelDrag( + e, + edge, + labelPos, + allPoints, + isSelfLoopEdge, + ) + : undefined + } + onDoubleClick={ + isSelected + ? (e) => handleLabelDoubleClick(e, edge) + : undefined + } + onClick={(e) => { + e.stopPropagation(); + onEdgeClick?.({ + taskId: edge.from, + targetTaskId: edge.to, + transitionIndex: edge.transitionIndex, + }); + }} + > + + + {labelText.length > 24 + ? labelText.slice(0, 21) + "..." + : labelText} + + {/* Drag hint icon when selected */} + {isSelected && ( + + ⋮⋮ + + )} + + )} + + {/* === Selected edge interactive elements === */} + {isSelected && ( + <> + {/* Waypoint handles */} + {currentWaypoints.map((wp, wpIdx) => { + const isDragging = + activeDrag && + activeDrag.edgeFrom === edge.from && + activeDrag.edgeTo === edge.to && + activeDrag.transitionIndex === edge.transitionIndex && + activeDrag.waypointIndex === wpIdx && + (activeDrag.type === "waypoint" || + activeDrag.type === "new-waypoint"); + + return ( + + startWaypointDrag(e, edge, wpIdx, currentWaypoints) + } + onDoubleClick={(e) => + handleWaypointDoubleClick( + e, + edge, + wpIdx, + currentWaypoints, + ) + } + > + {/* Outer ring on hover/drag */} + + + + + {/* Handle circle */} + + {/* Invisible larger hit area */} + + + ); + })} + + {/* Midpoint add-waypoint hover zones */} + {allPoints.slice(0, -1).map((pt, segIdx) => { + const nextPt = allPoints[segIdx + 1]; + const mid = curveSegmentMidpoint(allPoints, segIdx); + const segDist = Math.hypot( + nextPt.x - pt.x, + nextPt.y - pt.y, + ); + // Don't show for very short segments + if (segDist < 40) return null; + + const isHovered = + hoveredMidpoint !== null && + hoveredMidpoint.edgeFrom === edge.from && + hoveredMidpoint.edgeTo === edge.to && + hoveredMidpoint.transitionIndex === + edge.transitionIndex && + hoveredMidpoint.segmentIndex === segIdx; + + return ( + + setHoveredMidpoint({ + edgeFrom: edge.from, + edgeTo: edge.to, + transitionIndex: edge.transitionIndex, + segmentIndex: segIdx, + }) + } + onMouseLeave={() => setHoveredMidpoint(null)} + onMouseDown={(e) => + addAndDragWaypoint( + e, + edge, + segIdx, + allPoints, + currentWaypoints, + ) + } + onClick={(e) => + handleMidpointClick( + e, + edge, + segIdx, + allPoints, + currentWaypoints, + ) + } + > + {/* Invisible hit area along midpoint region */} + + {/* Visible indicator - fades in on hover */} + + {/* Plus icon */} + + + + + + ); + })} + + )} + + ); + })} {/* Preview line */} diff --git a/web/src/types/workflow.ts b/web/src/types/workflow.ts index 8a8e44f..f1e33e9 100644 --- a/web/src/types/workflow.ts +++ b/web/src/types/workflow.ts @@ -34,6 +34,10 @@ export interface TaskTransition { label?: string; /** Custom color for the transition edge (CSS color string, e.g., "#ff6600") */ color?: string; + /** Intermediate waypoints per target task (keyed by target task name) for edge routing */ + edge_waypoints?: Record; + /** Label position per target task as t-parameter (0–1) along the edge path */ + label_positions?: Record; } /** A task node in the workflow builder */ @@ -144,6 +148,8 @@ export interface WorkflowEdge { from: string; /** Target task ID */ to: string; + /** Target task name (stable key for waypoints) */ + toName: string; /** Visual type of transition (derived from `when`) */ type: EdgeType; /** Label to display on the edge */ @@ -152,6 +158,10 @@ export interface WorkflowEdge { transitionIndex: number; /** Custom color override for the edge (CSS color string) */ color?: string; + /** Intermediate waypoints for this specific edge */ + waypoints?: NodePosition[]; + /** Label position as t-parameter (0–1) along the edge path; default 0.5 */ + labelPosition?: number; } /** Complete workflow builder state */ @@ -210,6 +220,10 @@ export interface TransitionChartMeta { label?: string; /** Custom color for the transition edge (CSS color string) */ color?: string; + /** Intermediate waypoints per target task (keyed by target task name) */ + edge_waypoints?: Record; + /** Label position per target task as t-parameter (0–1) along the edge path */ + label_positions?: Record; } /** Transition as represented in YAML format */ @@ -383,11 +397,19 @@ export function builderStateToDefinition( if (t.when) yt.when = t.when; if (t.publish && t.publish.length > 0) yt.publish = t.publish; if (t.do && t.do.length > 0) yt.do = t.do; - // Store label/color in __chart_meta__ to avoid polluting the transition namespace - if (t.label || t.color) { + // Store label/color/waypoints in __chart_meta__ to avoid polluting the transition namespace + const hasChartMeta = + t.label || t.color || t.edge_waypoints || t.label_positions; + if (hasChartMeta) { yt.__chart_meta__ = {}; if (t.label) yt.__chart_meta__.label = t.label; if (t.color) yt.__chart_meta__.color = t.color; + if (t.edge_waypoints && Object.keys(t.edge_waypoints).length > 0) { + yt.__chart_meta__.edge_waypoints = t.edge_waypoints; + } + if (t.label_positions && Object.keys(t.label_positions).length > 0) { + yt.__chart_meta__.label_positions = t.label_positions; + } } return yt; }); @@ -529,6 +551,8 @@ export function definitionToBuilderState( do: t.do, label: t.__chart_meta__?.label, color: t.__chart_meta__?.color, + edge_waypoints: t.__chart_meta__?.edge_waypoints, + label_positions: t.__chart_meta__?.label_positions, })); } else { const converted = legacyTransitionsToNext(task); @@ -607,10 +631,13 @@ export function deriveEdges(tasks: WorkflowTask[]): WorkflowEdge[] { edges.push({ from: task.id, to: targetId, + toName: targetName, type: edgeType, label, transitionIndex: ti, color: transition.color, + waypoints: transition.edge_waypoints?.[targetName], + labelPosition: transition.label_positions?.[targetName], }); } } @@ -698,7 +725,29 @@ export function removeTaskFromTransitions( .map((t) => { if (!t.do || !t.do.includes(taskName)) return t; const newDo = t.do.filter((name) => name !== taskName); - return { ...t, do: newDo.length > 0 ? newDo : undefined }; + // Also clean up waypoint/label entries for the removed target + const updatedWaypoints = t.edge_waypoints + ? Object.fromEntries( + Object.entries(t.edge_waypoints).filter(([k]) => k !== taskName), + ) + : undefined; + const updatedLabelPos = t.label_positions + ? Object.fromEntries( + Object.entries(t.label_positions).filter(([k]) => k !== taskName), + ) + : undefined; + return { + ...t, + do: newDo.length > 0 ? newDo : undefined, + edge_waypoints: + updatedWaypoints && Object.keys(updatedWaypoints).length > 0 + ? updatedWaypoints + : undefined, + label_positions: + updatedLabelPos && Object.keys(updatedLabelPos).length > 0 + ? updatedLabelPos + : undefined, + }; }) // Keep transitions that still have `do` targets or `publish` directives .filter( @@ -723,12 +772,36 @@ export function renameTaskInTransitions( let changed = false; const updated = next.map((t) => { - if (!t.do || !t.do.includes(oldName)) return t; + const hasDo = t.do && t.do.includes(oldName); + const hasWaypoint = t.edge_waypoints && oldName in t.edge_waypoints; + const hasLabelPos = t.label_positions && oldName in t.label_positions; + + if (!hasDo && !hasWaypoint && !hasLabelPos) return t; changed = true; - return { - ...t, - do: t.do.map((name) => (name === oldName ? newName : name)), - }; + + const result = { ...t }; + + if (hasDo) { + result.do = t.do!.map((name) => (name === oldName ? newName : name)); + } + + if (hasWaypoint && t.edge_waypoints) { + const entries = Object.entries(t.edge_waypoints).map(([k, v]) => [ + k === oldName ? newName : k, + v, + ]); + result.edge_waypoints = Object.fromEntries(entries); + } + + if (hasLabelPos && t.label_positions) { + const entries = Object.entries(t.label_positions).map(([k, v]) => [ + k === oldName ? newName : k, + v, + ]); + result.label_positions = Object.fromEntries(entries); + } + + return result; }); return changed ? updated : next; diff --git a/work-summary/2026-02-05-edge-waypoints-label-dragging.md b/work-summary/2026-02-05-edge-waypoints-label-dragging.md new file mode 100644 index 0000000..a8cc821 --- /dev/null +++ b/work-summary/2026-02-05-edge-waypoints-label-dragging.md @@ -0,0 +1,60 @@ +# Edge Waypoints & Label Dragging for Workflow Builder + +**Date:** 2026-02-05 + +## Summary + +Added interactive edge waypoints and label dragging to the workflow builder, allowing users to manually route transition arrows through intermediate control points and reposition transition labels for better visual clarity in complex workflows. + +## Changes + +### Types (`web/src/types/workflow.ts`) + +- **`TaskTransition`**: Added `edge_waypoints?: Record` and `label_positions?: Record` fields, keyed by target task name, for per-edge routing data +- **`WorkflowEdge`**: Added `toName` (stable target task name key), `waypoints?: NodePosition[]`, and `labelPosition?: NodePosition` fields +- **`TransitionChartMeta`**: Added `edge_waypoints` and `label_positions` for YAML serialization via `__chart_meta__` +- **`EdgeHoverInfo`**: Added `targetTaskId` field to uniquely identify clicked edges +- **`deriveEdges()`**: Extracts per-edge waypoints and label positions from transition chart meta +- **`builderStateToDefinition()`**: Serializes waypoints and label positions into `__chart_meta__` +- **`definitionToBuilderState()`**: Deserializes them on load +- **`removeTaskFromTransitions()`**: Cleans up waypoint/label entries when a target task is removed +- **`renameTaskInTransitions()`**: Renames keys in `edge_waypoints` and `label_positions` when a task is renamed + +### Edge Rendering (`web/src/components/workflows/WorkflowEdges.tsx`) + +- **`SelectedEdgeInfo` interface**: Tracks which edge is selected for waypoint editing +- **`buildSmoothPath()`**: New function that draws smooth multi-segment SVG paths through waypoints using Catmull-Rom → cubic Bezier conversion +- **`computeDefaultLabelPosition()`**: Computes a default label position from path points +- **Waypoint handles**: Small colored circles at each waypoint, draggable when edge is selected; double-click to remove +- **Midpoint add handles**: "+" indicators appear on hover at segment midpoints of the selected edge; click to insert a new waypoint, or drag to insert and immediately reposition +- **Label dragging**: Transition labels are draggable when the edge is selected; double-click to reset to default position +- **Edge selection glow**: Selected edges render with a subtle glow effect and slightly thicker stroke +- **Effect-based drag handling**: Uses `useEffect` with `isDragging` state to manage document-level mouse listeners, with refs for latest callback values to avoid stale closures + +### Canvas Integration (`web/src/components/workflows/WorkflowCanvas.tsx`) + +- **`selectedEdge` state**: Tracks which edge is selected for waypoint manipulation +- **`handleEdgeClick()`**: Sets both edge selection and propagates to parent for task inspector highlighting +- **`handleSelectTask()`**: Clears edge selection when a different task is clicked +- **`handleWaypointUpdate()`**: Updates a task's transition `edge_waypoints` for a specific target +- **`handleLabelPositionUpdate()`**: Updates a task's transition `label_positions` for a specific target +- All new props passed through to `WorkflowEdges` + +## User Interaction + +1. **Click an edge** to select it — the edge highlights with a glow and shows waypoint handles +2. **Hover the midpoint** of any segment on the selected edge to reveal a "+" indicator +3. **Click or drag the "+"** to insert a new waypoint at that position +4. **Drag waypoint handles** to reposition the edge path +5. **Drag the label** to move it independently of the path +6. **Double-click a waypoint** to remove it +7. **Double-click a label** to reset it to default position +8. **Click canvas background** or another task to deselect the edge + +## Data Persistence + +Waypoints and label positions are stored in the workflow YAML via `__chart_meta__` on transitions, keyed by target task name. This ensures: +- Data survives save/reload cycles +- Per-edge granularity (a transition with `do: [taskA, taskB]` has independent waypoints for each target) +- Task renames and deletions properly update the keys +- Backend ignores `__chart_meta__` — it's purely visual metadata \ No newline at end of file