Compare commits

..

2 Commits

Author SHA1 Message Date
80c8eaaf22 more workflow editor polish 2026-02-24 12:30:33 -06:00
7d942f5dca splines! 2026-02-24 09:28:39 -06:00
7 changed files with 1920 additions and 295 deletions

View File

@@ -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<string, NodePosition[]>` of intermediate routing points per target task name (chart-only, stored in `__chart_meta__`)
- `label_positions` — optional `Record<string, NodePosition>` 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

View File

@@ -16,6 +16,7 @@ import type {
PaletteAction,
TaskTransition,
PublishDirective,
LineStyle,
} from "@/types/workflow";
import {
PRESET_WHEN,
@@ -196,6 +197,9 @@ export default function TaskInspector({
if (whenExpr) newTransition.when = whenExpr;
newTransition.label = PRESET_LABELS[preset];
newTransition.color = PRESET_COLORS[preset];
if (preset === "failed") {
newTransition.line_style = "dashed";
}
}
next.push(newTransition);
update({ next });
@@ -432,6 +436,8 @@ 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]"
: highlightTransitionIndex === ti
? "border-blue-400 ring-1 ring-blue-200 bg-blue-50/40"
: "border-gray-200"
}`}
>
@@ -506,29 +512,88 @@ export default function TaskInspector({
title={swatch.label}
/>
))}
<div className="flex items-center gap-1 ml-1">
<input
type="color"
value={transition.color || "#6b7280"}
onChange={(e) =>
updateTransition(ti, { color: e.target.value })
}
className="w-5 h-5 rounded cursor-pointer border border-gray-300"
className="w-5 h-5 rounded cursor-pointer border border-gray-300 ml-1"
title="Custom color"
/>
{transition.color && (
<button
onClick={() =>
updateTransition(ti, { color: undefined })
}
className="text-[9px] text-gray-400 hover:text-gray-600"
title="Reset to default"
>
reset
</button>
)}
</div>
</div>
{/* Line style */}
<div>
<label className="block text-[10px] font-medium text-gray-500 mb-0.5">
Line Style
</label>
<div className="flex items-center gap-1">
{(
[
"solid",
"dashed",
"dotted",
"dash-dot",
] as LineStyle[]
).map((style) => {
const effectiveStyle =
transition.line_style || "solid";
const isActive = effectiveStyle === style;
const dashArrays: Record<LineStyle, string> = {
solid: "",
dashed: "6,4",
dotted: "2,3",
"dash-dot": "8,4,2,4",
};
const labels: Record<LineStyle, string> = {
solid: "Solid",
dashed: "Dashed",
dotted: "Dotted",
"dash-dot": "Dash-dot",
};
return (
<button
key={style}
onClick={() =>
updateTransition(ti, {
line_style:
style === "solid" ? undefined : style,
})
}
className={`flex items-center justify-center h-6 px-1.5 rounded border transition-all ${
isActive
? "border-gray-800 bg-gray-100"
: "border-gray-200 hover:border-gray-400"
}`}
title={labels[style]}
>
<svg
width="28"
height="2"
className="overflow-visible"
>
<line
x1="0"
y1="1"
x2="28"
y2="1"
stroke={
transition.color ||
PRESET_COLORS[
classifyTransitionWhen(transition.when)
] ||
"#6b7280"
}
strokeWidth="2"
strokeDasharray={dashArrays[style]}
/>
</svg>
</button>
);
})}
</div>
</div>
{/* When condition */}

View File

@@ -1,11 +1,12 @@
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,
PRESET_WHEN,
classifyTransitionWhen,
} from "@/types/workflow";
import type { ScreenToCanvas } from "./WorkflowCanvas";
export type { TransitionPreset };
@@ -23,6 +24,8 @@ interface TaskNodeProps {
onStartConnection: (taskId: string, preset: TransitionPreset) => void;
connectingFrom: { taskId: string; preset: TransitionPreset } | null;
onCompleteConnection: (targetTaskId: string) => void;
/** Convert screen (client) coordinates to canvas-space coordinates. */
screenToCanvas: ScreenToCanvas;
}
/** Handle visual configuration for each transition preset */
@@ -75,7 +78,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 +96,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>,
): [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,
@@ -103,12 +168,15 @@ function TaskNodeInner({
onStartConnection,
connectingFrom,
onCompleteConnection,
screenToCanvas,
}: TaskNodeProps) {
const nodeRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [hoveredHandle, setHoveredHandle] = useState<TransitionPreset | null>(
null,
);
const [showTooltip, setShowTooltip] = useState(false);
const tooltipTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const dragOffset = useRef({ x: 0, y: 0 });
const handleMouseDown = useCallback(
@@ -119,17 +187,21 @@ function TaskNodeInner({
e.stopPropagation();
setIsDragging(true);
setShowTooltip(false);
// Convert the initial click to canvas-space so dragging stays
// accurate regardless of the current zoom / pan.
const canvasPos = screenToCanvas(e.clientX, e.clientY);
dragOffset.current = {
x: e.clientX - task.position.x,
y: e.clientY - task.position.y,
x: canvasPos.x - task.position.x,
y: canvasPos.y - task.position.y,
};
const handleMouseMove = (moveEvent: MouseEvent) => {
const newX = moveEvent.clientX - dragOffset.current.x;
const newY = moveEvent.clientY - dragOffset.current.y;
const cur = screenToCanvas(moveEvent.clientX, moveEvent.clientY);
onPositionChange(task.id, {
x: Math.max(0, newX),
y: Math.max(0, newY),
x: Math.max(0, cur.x - dragOffset.current.x),
y: Math.max(0, cur.y - dragOffset.current.y),
});
};
@@ -142,7 +214,13 @@ function TaskNodeInner({
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
},
[task.id, task.position.x, task.position.y, onPositionChange],
[
task.id,
task.position.x,
task.position.y,
onPositionChange,
screenToCanvas,
],
);
const handleClick = useCallback(
@@ -174,6 +252,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 +288,49 @@ function TaskNodeInner({
return ct === "custom";
}).length;
// Inputs that actually have values
const populatedInputs = getPopulatedInputs(task.input);
const populatedCount = populatedInputs.length;
// Show inline if 12 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 (
<div
ref={nodeRef}
@@ -243,7 +377,11 @@ function TaskNodeInner({
</div>
{/* Body */}
<div className="px-2.5 py-2">
<div
className="px-2.5 py-2 relative"
onMouseEnter={handleBodyMouseEnter}
onMouseLeave={handleBodyMouseLeave}
>
{hasAction ? (
<div className="font-mono text-[11px] text-gray-600 truncate">
{task.action}
@@ -254,17 +392,23 @@ function TaskNodeInner({
</div>
)}
{/* Input summary */}
{Object.keys(task.input).length > 0 && (
<div className="mt-1.5 text-[10px] text-gray-400">
{Object.keys(task.input).length} input
{Object.keys(task.input).length !== 1 ? "s" : ""}
{/* Inline inputs (12 populated) */}
{showInlineInputs && (
<div className="mt-1.5 space-y-0.5">
{populatedInputs.map(([key, val]) => (
<div
key={key}
className="flex items-baseline gap-1 text-[10px] leading-tight"
>
<span className="text-gray-400 font-medium shrink-0">
{key}:
</span>
<span className="text-gray-600 truncate font-mono">
{formatValueShort(val)}
</span>
</div>
))}
</div>
)}
{/* Transition summary */}
{summary && (
<div className="mt-1 text-[10px] text-gray-400">{summary}</div>
)}
{/* Delay badge */}
@@ -288,11 +432,36 @@ function TaskNodeInner({
</div>
)}
{/* Custom transitions badge */}
{customTransitionCount > 0 && (
<div className="mt-1 inline-block px-1.5 py-0.5 bg-violet-50 border border-violet-200 rounded text-[10px] text-violet-700 ml-1">
{customTransitionCount} custom transition
{customTransitionCount !== 1 ? "s" : ""}
{/* Info icon hint — shown when there's tooltip content */}
{hasTooltipContent && (
<div className="absolute top-1.5 right-1.5">
<Info className="w-3 h-3 text-gray-300" />
</div>
)}
{/* Tooltip */}
{showTooltip && hasTooltipContent && (
<div
className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-[100] pointer-events-none"
style={{ minWidth: 180, maxWidth: 260 }}
>
<div className="bg-gray-900 text-white text-[10px] leading-relaxed rounded-md shadow-xl px-2.5 py-2 whitespace-pre-wrap">
{tooltipLines.map((line, i) => (
<div
key={i}
className={
line.startsWith(" ")
? "pl-2 text-gray-300 font-mono"
: i > 0
? "mt-1 border-t border-gray-700 pt-1"
: ""
}
>
{line}
</div>
))}
</div>
<div className="absolute left-1/2 -translate-x-1/2 -bottom-1 w-2 h-2 bg-gray-900 rotate-45" />
</div>
)}
</div>

View File

@@ -1,9 +1,13 @@
import { useState, useCallback, useRef, useMemo } from "react";
import { useState, useCallback, useRef, useMemo, useEffect } 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,
@@ -11,7 +15,7 @@ import {
findStartingTaskIds,
PRESET_LABELS,
} from "@/types/workflow";
import { Plus } from "lucide-react";
import { Plus, Maximize } from "lucide-react";
interface WorkflowCanvasProps {
tasks: WorkflowTask[];
@@ -35,6 +39,34 @@ const PRESET_BANNER_COLORS: Record<TransitionPreset, string> = {
always: "text-gray-200 font-bold",
};
const MIN_ZOOM = 0.15;
const MAX_ZOOM = 3;
const ZOOM_SENSITIVITY = 0.0015;
/**
* Build CSS background style for the infinite grid.
* Two layers: regular lines every 20 canvas-units, bold lines every 100 (5th).
*/
function gridBackground(pan: { x: number; y: number }, zoom: number) {
const small = 20 * zoom;
const large = 100 * zoom;
return {
backgroundImage: [
`linear-gradient(to right, rgba(0,0,0,0.07) 1px, transparent 1px)`,
`linear-gradient(to bottom, rgba(0,0,0,0.07) 1px, transparent 1px)`,
`linear-gradient(to right, rgba(0,0,0,0.03) 1px, transparent 1px)`,
`linear-gradient(to bottom, rgba(0,0,0,0.03) 1px, transparent 1px)`,
].join(","),
backgroundSize: `${large}px ${large}px, ${large}px ${large}px, ${small}px ${small}px, ${small}px ${small}px`,
backgroundPosition: `${pan.x}px ${pan.y}px, ${pan.x}px ${pan.y}px, ${pan.x}px ${pan.y}px, ${pan.x}px ${pan.y}px`,
};
}
export type ScreenToCanvas = (
clientX: number,
clientY: number,
) => { x: number; y: number };
export default function WorkflowCanvas({
tasks,
selectedTaskId,
@@ -46,6 +78,17 @@ export default function WorkflowCanvas({
onEdgeClick,
}: WorkflowCanvasProps) {
const canvasRef = useRef<HTMLDivElement>(null);
const innerRef = useRef<HTMLDivElement>(null);
// ---- Camera state ----
// We keep refs for high-frequency updates (panning/zooming) and sync to
// state on mouseup / wheel-end so React can re-render once.
const panRef = useRef({ x: 0, y: 0 });
const zoomRef = useRef(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
// ---- Connection state ----
const [connectingFrom, setConnectingFrom] = useState<{
taskId: string;
preset: TransitionPreset;
@@ -55,54 +98,189 @@ export default function WorkflowCanvas({
y: number;
} | null>(null);
// ---- Panning state (right-click drag) ----
const isPanning = useRef(false);
const panDragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
const [panningCursor, setPanningCursor] = useState(false);
const [selectedEdge, setSelectedEdge] = useState<SelectedEdgeInfo | null>(
null,
);
const allTaskNames = useMemo(() => tasks.map((t) => t.name), [tasks]);
const edges: WorkflowEdge[] = useMemo(() => deriveEdges(tasks), [tasks]);
const startingTaskIds = useMemo(() => findStartingTaskIds(tasks), [tasks]);
// ---- Coordinate conversion ----
/** Convert screen (client) coordinates to canvas-space coordinates. */
const screenToCanvas: ScreenToCanvas = useCallback(
(clientX: number, clientY: number) => {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return { x: clientX, y: clientY };
return {
x: (clientX - rect.left - panRef.current.x) / zoomRef.current,
y: (clientY - rect.top - panRef.current.y) / zoomRef.current,
};
},
[],
);
// ---- Flush camera refs to React state (triggers re-render) ----
const commitCamera = useCallback(() => {
setPan({ ...panRef.current });
setZoom(zoomRef.current);
}, []);
/** Apply current ref values directly to the DOM for smooth animation. */
const applyTransformToDOM = useCallback(() => {
if (innerRef.current) {
innerRef.current.style.transform = `translate(${panRef.current.x}px, ${panRef.current.y}px) scale(${zoomRef.current})`;
}
if (canvasRef.current) {
const bg = gridBackground(panRef.current, zoomRef.current);
canvasRef.current.style.backgroundSize = bg.backgroundSize;
canvasRef.current.style.backgroundPosition = bg.backgroundPosition;
}
}, []);
// ---- Canvas click (deselect / cancel connection) ----
const handleCanvasClick = useCallback(
(e: React.MouseEvent) => {
// Only deselect if clicking the canvas background
const target = e.target as HTMLElement;
if (
e.target === canvasRef.current ||
(e.target as HTMLElement).dataset.canvasBg === "true"
target === canvasRef.current ||
target === innerRef.current ||
target.dataset.canvasBg === "true"
) {
if (connectingFrom) {
setConnectingFrom(null);
setMousePosition(null);
} else {
onSelectTask(null);
setSelectedEdge(null);
onEdgeClick?.(null);
}
}
},
[onSelectTask, connectingFrom],
[onSelectTask, onEdgeClick, connectingFrom],
);
// ---- Mouse move: panning + connection preview ----
const handleCanvasMouseMove = useCallback(
(e: React.MouseEvent) => {
if (connectingFrom && canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
const scrollLeft = canvasRef.current.scrollLeft;
const scrollTop = canvasRef.current.scrollTop;
setMousePosition({
x: e.clientX - rect.left + scrollLeft,
y: e.clientY - rect.top + scrollTop,
});
// Right-click panning (direct DOM, no React re-render)
if (isPanning.current) {
const dx = e.clientX - panDragStart.current.x;
const dy = e.clientY - panDragStart.current.y;
panRef.current = {
x: panDragStart.current.panX + dx,
y: panDragStart.current.panY + dy,
};
applyTransformToDOM();
return;
}
// Connection preview line
if (connectingFrom) {
const pos = screenToCanvas(e.clientX, e.clientY);
setMousePosition(pos);
}
},
[connectingFrom],
[connectingFrom, screenToCanvas, applyTransformToDOM],
);
const handleCanvasMouseUp = useCallback(() => {
// If we're connecting and mouseup happens on the canvas (not on a node),
// cancel the connection
// ---- Mouse down: start panning on right-click ----
const handleCanvasMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button === 2) {
e.preventDefault();
isPanning.current = true;
panDragStart.current = {
x: e.clientX,
y: e.clientY,
panX: panRef.current.x,
panY: panRef.current.y,
};
setPanningCursor(true);
}
}, []);
// ---- Mouse up: stop panning / cancel connection ----
const handleCanvasMouseUp = useCallback(
(e: React.MouseEvent) => {
if (e.button === 2 && isPanning.current) {
isPanning.current = false;
setPanningCursor(false);
commitCamera();
return;
}
if (connectingFrom) {
setConnectingFrom(null);
setMousePosition(null);
}
}, [connectingFrom]);
},
[connectingFrom, commitCamera],
);
// ---- Context menu suppression ----
const handleContextMenu = useCallback((e: React.MouseEvent) => {
e.preventDefault();
}, []);
// ---- Scroll wheel: zoom centred on cursor ----
// Must be a non-passive imperative listener so preventDefault() reliably
// stops the page from scrolling. React's onWheel is passive in some browsers.
const handleWheel = useCallback(
(e: WheelEvent) => {
e.preventDefault();
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const mouseScreenX = e.clientX - rect.left;
const mouseScreenY = e.clientY - rect.top;
const oldZoom = zoomRef.current;
const delta = -e.deltaY * ZOOM_SENSITIVITY;
const newZoom = Math.min(
MAX_ZOOM,
Math.max(MIN_ZOOM, oldZoom * (1 + delta)),
);
// Adjust pan so the point under the cursor stays fixed
const scale = newZoom / oldZoom;
panRef.current = {
x: mouseScreenX - (mouseScreenX - panRef.current.x) * scale,
y: mouseScreenY - (mouseScreenY - panRef.current.y) * scale,
};
zoomRef.current = newZoom;
applyTransformToDOM();
commitCamera();
},
[applyTransformToDOM, commitCamera],
);
// Attach wheel listener imperatively with { passive: false }
useEffect(() => {
const el = canvasRef.current;
if (!el) return;
el.addEventListener("wheel", handleWheel, { passive: false });
return () => el.removeEventListener("wheel", handleWheel);
}, [handleWheel]);
// Safety: cancel panning if mouse leaves the window
useEffect(() => {
const handleGlobalMouseUp = () => {
if (isPanning.current) {
isPanning.current = false;
setPanningCursor(false);
commitCamera();
}
};
window.addEventListener("mouseup", handleGlobalMouseUp);
return () => window.removeEventListener("mouseup", handleGlobalMouseUp);
}, [commitCamera]);
// ---- Node interactions ----
const handlePositionChange = useCallback(
(taskId: string, position: { x: number; y: number }) => {
onUpdateTask(taskId, { position });
@@ -113,14 +291,100 @@ export default function WorkflowCanvas({
const handleStartConnection = useCallback(
(taskId: string, preset: TransitionPreset) => {
setConnectingFrom({ taskId, preset });
setSelectedEdge(null);
onEdgeClick?.(null);
},
[],
[onEdgeClick],
);
const handleEdgeClick = useCallback(
(info: EdgeHoverInfo | null) => {
if (info) {
setSelectedEdge({
from: info.taskId,
to: info.targetTaskId,
transitionIndex: info.transitionIndex,
});
} else {
setSelectedEdge(null);
}
onEdgeClick?.(info);
},
[onEdgeClick],
);
const handleSelectTask = useCallback(
(taskId: string | null) => {
onSelectTask(taskId);
if (taskId !== null) {
if (selectedEdge && selectedEdge.from !== taskId) {
setSelectedEdge(null);
onEdgeClick?.(null);
}
}
},
[onSelectTask, onEdgeClick, selectedEdge],
);
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],
);
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(
(targetTaskId: string) => {
if (!connectingFrom) return;
const targetTask = tasks.find((t) => t.id === targetTaskId);
if (!targetTask) return;
@@ -137,12 +401,9 @@ export default function WorkflowCanvas({
const handleAddEmptyTask = useCallback(() => {
const name = generateUniqueTaskName(tasks);
// Position new tasks below existing ones
let maxY = 0;
for (const task of tasks) {
if (task.position.y > maxY) {
maxY = task.position.y;
}
if (task.position.y > maxY) maxY = task.position.y;
}
const newTask: WorkflowTask = {
id: generateTaskId(),
@@ -158,60 +419,101 @@ export default function WorkflowCanvas({
onSelectTask(newTask.id);
}, [tasks, onAddTask, onSelectTask]);
// Calculate minimum canvas dimensions based on node positions
const canvasDimensions = useMemo(() => {
let maxX = 800;
let maxY = 600;
/** Reset pan/zoom to fit all tasks (or default viewport). */
const handleFitView = useCallback(() => {
if (tasks.length === 0) {
panRef.current = { x: 0, y: 0 };
zoomRef.current = 1;
} else {
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
let minX = Infinity,
minY = Infinity,
maxX = -Infinity,
maxY = -Infinity;
for (const t of tasks) {
minX = Math.min(minX, t.position.x);
minY = Math.min(minY, t.position.y);
maxX = Math.max(maxX, t.position.x + 240);
maxY = Math.max(maxY, t.position.y + 140);
}
const contentW = maxX - minX;
const contentH = maxY - minY;
const pad = 80;
const scaleX = (rect.width - pad * 2) / contentW;
const scaleY = (rect.height - pad * 2) / contentH;
const newZoom = Math.min(
Math.max(Math.min(scaleX, scaleY), MIN_ZOOM),
MAX_ZOOM,
);
panRef.current = {
x: (rect.width - contentW * newZoom) / 2 - minX * newZoom,
y: (rect.height - contentH * newZoom) / 2 - minY * newZoom,
};
zoomRef.current = newZoom;
}
applyTransformToDOM();
commitCamera();
}, [tasks, applyTransformToDOM, commitCamera]);
// ---- Inner div dimensions (large enough to contain all content) ----
const innerSize = useMemo(() => {
let maxX = 4000;
let maxY = 4000;
for (const task of tasks) {
maxX = Math.max(maxX, task.position.x + 340);
maxY = Math.max(maxY, task.position.y + 220);
maxX = Math.max(maxX, task.position.x + 500);
maxY = Math.max(maxY, task.position.y + 500);
}
return { width: maxX, height: maxY };
}, [tasks]);
// ---- Grid background (recomputed from React state for the render) ----
const gridBg = useMemo(() => gridBackground(pan, zoom), [pan, zoom]);
// Zoom percentage for display
const zoomPercent = Math.round(zoom * 100);
return (
<div
className="flex-1 overflow-auto bg-gray-100 relative"
ref={canvasRef}
className={`flex-1 overflow-hidden bg-gray-100 relative ${panningCursor ? "!cursor-grabbing" : ""}`}
style={{
backgroundImage: gridBg.backgroundImage,
backgroundSize: gridBg.backgroundSize,
backgroundPosition: gridBg.backgroundPosition,
}}
onClick={handleCanvasClick}
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp}
onContextMenu={handleContextMenu}
>
{/* Grid background */}
{/* Transformed canvas content */}
<div
ref={innerRef}
data-canvas-bg="true"
className="absolute inset-0"
style={{
minWidth: canvasDimensions.width,
minHeight: canvasDimensions.height,
backgroundImage: `
linear-gradient(to right, rgba(0,0,0,0.03) 1px, transparent 1px),
linear-gradient(to bottom, rgba(0,0,0,0.03) 1px, transparent 1px)
`,
backgroundSize: "20px 20px",
position: "absolute",
transformOrigin: "0 0",
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
width: innerSize.width,
height: innerSize.height,
}}
/>
{/* Connecting mode indicator */}
{connectingFrom && (
<div className="sticky top-0 left-0 right-0 z-50 flex justify-center pointer-events-none">
<div className="mt-3 px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-full shadow-lg pointer-events-auto">
Drag to a task to connect as{" "}
<span className={PRESET_BANNER_COLORS[connectingFrom.preset]}>
{PRESET_LABELS[connectingFrom.preset]}
</span>{" "}
transition or release to cancel
</div>
</div>
)}
>
{/* Edge rendering layer */}
<WorkflowEdges
edges={edges}
tasks={tasks}
connectingFrom={connectingFrom}
mousePosition={mousePosition}
onEdgeClick={onEdgeClick}
onEdgeClick={handleEdgeClick}
selectedEdge={selectedEdge}
onWaypointUpdate={handleWaypointUpdate}
onLabelPositionUpdate={handleLabelPositionUpdate}
screenToCanvas={screenToCanvas}
/>
{/* Task nodes */}
@@ -222,24 +524,49 @@ 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}
connectingFrom={connectingFrom}
onCompleteConnection={handleCompleteConnection}
screenToCanvas={screenToCanvas}
/>
))}
</div>
{/* ---- UI chrome (not transformed) ---- */}
{/* Connecting mode indicator */}
{connectingFrom && (
<div className="absolute top-0 left-0 right-0 z-50 flex justify-center pointer-events-none">
<div className="mt-3 px-4 py-2 bg-purple-600 text-white text-sm font-medium rounded-full shadow-lg pointer-events-auto">
Drag to a task to connect as{" "}
<span className={PRESET_BANNER_COLORS[connectingFrom.preset]}>
{PRESET_LABELS[connectingFrom.preset]}
</span>{" "}
transition or release to cancel
</div>
</div>
)}
{/* Zoom indicator + fit-view button */}
<div className="absolute bottom-6 left-6 z-40 flex items-center gap-2">
<div className="px-2.5 py-1.5 bg-white/80 backdrop-blur-sm text-xs font-medium text-gray-500 rounded-lg shadow-sm border border-gray-200 select-none tabular-nums">
{zoomPercent}%
</div>
<button
onClick={handleFitView}
className="p-1.5 bg-white/80 backdrop-blur-sm text-gray-500 rounded-lg shadow-sm border border-gray-200 hover:bg-white hover:text-gray-700 transition-colors"
title="Fit view to content"
>
<Maximize className="w-3.5 h-3.5" />
</button>
</div>
{/* Empty state / Add task button */}
{tasks.length === 0 ? (
<div
className="absolute inset-0 flex items-center justify-center pointer-events-none"
style={{
minWidth: canvasDimensions.width,
minHeight: canvasDimensions.height,
}}
>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="text-center pointer-events-auto">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-200 flex items-center justify-center">
<Plus className="w-8 h-8 text-gray-400" />
@@ -263,7 +590,7 @@ export default function WorkflowCanvas({
) : (
<button
onClick={handleAddEmptyTask}
className="fixed bottom-6 right-6 z-40 w-12 h-12 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 transition-colors flex items-center justify-center"
className="absolute bottom-6 right-6 z-40 w-12 h-12 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 transition-colors flex items-center justify-center"
title="Add a new task"
>
<Plus className="w-6 h-6" />

File diff suppressed because it is too large Load Diff

View File

@@ -23,6 +23,9 @@ export interface NodePosition {
* Transitions are evaluated in order. When `when` is not defined,
* the transition is unconditional (fires on any completion).
*/
/** Line style for transition edges */
export type LineStyle = "solid" | "dashed" | "dotted" | "dash-dot";
export interface TaskTransition {
/** Condition expression (e.g., "{{ succeeded() }}", "{{ failed() }}") */
when?: string;
@@ -34,6 +37,12 @@ export interface TaskTransition {
label?: string;
/** Custom color for the transition edge (CSS color string, e.g., "#ff6600") */
color?: string;
/** Custom line style for the transition edge (overrides type-based default) */
line_style?: LineStyle;
/** Intermediate waypoints per target task (keyed by target task name) for edge routing */
edge_waypoints?: Record<string, NodePosition[]>;
/** Label position per target task as t-parameter (01) along the edge path */
label_positions?: Record<string, number>;
}
/** A task node in the workflow builder */
@@ -112,6 +121,12 @@ export const PRESET_COLORS: Record<TransitionPreset, string> = {
always: "#6b7280", // gray-500
};
export const PRESET_STYLES: Record<TransitionPreset, LineStyle> = {
succeeded: "solid",
failed: "dashed",
always: "solid",
};
/**
* Classify a `when` expression into an edge visual type.
* Used for edge coloring and labeling.
@@ -144,6 +159,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 +169,12 @@ export interface WorkflowEdge {
transitionIndex: number;
/** Custom color override for the edge (CSS color string) */
color?: string;
/** Custom line style override for the edge */
lineStyle?: LineStyle;
/** Intermediate waypoints for this specific edge */
waypoints?: NodePosition[];
/** Label position as t-parameter (01) along the edge path; default 0.5 */
labelPosition?: number;
}
/** Complete workflow builder state */
@@ -210,6 +233,12 @@ export interface TransitionChartMeta {
label?: string;
/** Custom color for the transition edge (CSS color string) */
color?: string;
/** Custom line style for the transition edge */
line_style?: LineStyle;
/** Intermediate waypoints per target task (keyed by target task name) */
edge_waypoints?: Record<string, NodePosition[]>;
/** Label position per target task as t-parameter (01) along the edge path */
label_positions?: Record<string, number>;
}
/** Transition as represented in YAML format */
@@ -217,7 +246,7 @@ export interface WorkflowYamlTransition {
when?: string;
publish?: PublishDirective[];
do?: string[];
/** Visual metadata (label, color) — ignored by backend */
/** Visual metadata (label, color, line style, waypoints) — ignored by backend */
__chart_meta__?: TransitionChartMeta;
}
@@ -383,11 +412,24 @@ 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/line_style/waypoints in __chart_meta__
const hasChartMeta =
t.label ||
t.color ||
t.line_style ||
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.line_style) yt.__chart_meta__.line_style = t.line_style;
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 +571,9 @@ export function definitionToBuilderState(
do: t.do,
label: t.__chart_meta__?.label,
color: t.__chart_meta__?.color,
line_style: t.__chart_meta__?.line_style,
edge_waypoints: t.__chart_meta__?.edge_waypoints,
label_positions: t.__chart_meta__?.label_positions,
}));
} else {
const converted = legacyTransitionsToNext(task);
@@ -607,10 +652,14 @@ export function deriveEdges(tasks: WorkflowTask[]): WorkflowEdge[] {
edges.push({
from: task.id,
to: targetId,
toName: targetName,
type: edgeType,
label,
transitionIndex: ti,
color: transition.color,
lineStyle: transition.line_style,
waypoints: transition.edge_waypoints?.[targetName],
labelPosition: transition.label_positions?.[targetName],
});
}
}
@@ -651,10 +700,11 @@ export function findOrCreateTransition(
return { next, index: existingIndex };
}
// Create new transition with default label and color for the preset
// Create new transition with default label, color, and line style for the preset
const newTransition: TaskTransition = {
label: PRESET_LABELS[preset],
color: PRESET_COLORS[preset],
line_style: PRESET_STYLES[preset],
};
if (whenExpr) newTransition.when = whenExpr;
next.push(newTransition);
@@ -698,7 +748,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 +795,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;

View File

@@ -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<string, NodePosition[]>` and `label_positions?: Record<string, NodePosition>` 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