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 - `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) - `label` — optional custom display label (overrides auto-derived label from `when` expression)
- `color` — optional custom CSS color for the transition edge (e.g., `"#ff6600"`) - `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**: - **Example YAML**:
``` ```
next: next:
@@ -245,7 +247,7 @@ Enforcement created → Execution scheduled → Worker executes Action
- error_handler - 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`. - **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` - **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` - **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`). - **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. - **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. - **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. - **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. - **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 ## Development Workflow

View File

@@ -16,6 +16,7 @@ import type {
PaletteAction, PaletteAction,
TaskTransition, TaskTransition,
PublishDirective, PublishDirective,
LineStyle,
} from "@/types/workflow"; } from "@/types/workflow";
import { import {
PRESET_WHEN, PRESET_WHEN,
@@ -196,6 +197,9 @@ export default function TaskInspector({
if (whenExpr) newTransition.when = whenExpr; if (whenExpr) newTransition.when = whenExpr;
newTransition.label = PRESET_LABELS[preset]; newTransition.label = PRESET_LABELS[preset];
newTransition.color = PRESET_COLORS[preset]; newTransition.color = PRESET_COLORS[preset];
if (preset === "failed") {
newTransition.line_style = "dashed";
}
} }
next.push(newTransition); next.push(newTransition);
update({ next }); update({ next });
@@ -432,7 +436,9 @@ export default function TaskInspector({
className={`border rounded-lg bg-gray-50 overflow-hidden transition-all duration-300 ${ className={`border rounded-lg bg-gray-50 overflow-hidden transition-all duration-300 ${
isFlashing isFlashing
? "border-blue-400 ring-2 ring-blue-300 shadow-md shadow-blue-100 animate-[flash-highlight_1.5s_ease-out]" ? "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 */} {/* Transition header */}
@@ -506,28 +512,87 @@ export default function TaskInspector({
title={swatch.label} title={swatch.label}
/> />
))} ))}
<div className="flex items-center gap-1 ml-1"> <input
<input type="color"
type="color" value={transition.color || "#6b7280"}
value={transition.color || "#6b7280"} onChange={(e) =>
onChange={(e) => updateTransition(ti, { color: e.target.value })
updateTransition(ti, { color: e.target.value }) }
} className="w-5 h-5 rounded cursor-pointer border border-gray-300 ml-1"
className="w-5 h-5 rounded cursor-pointer border border-gray-300" title="Custom color"
title="Custom color" />
/> </div>
{transition.color && ( </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 <button
key={style}
onClick={() => onClick={() =>
updateTransition(ti, { color: undefined }) updateTransition(ti, {
line_style:
style === "solid" ? undefined : style,
})
} }
className="text-[9px] text-gray-400 hover:text-gray-600" className={`flex items-center justify-center h-6 px-1.5 rounded border transition-all ${
title="Reset to default" isActive
? "border-gray-800 bg-gray-100"
: "border-gray-200 hover:border-gray-400"
}`}
title={labels[style]}
> >
reset <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> </button>
)} );
</div> })}
</div> </div>
</div> </div>

View File

@@ -1,11 +1,12 @@
import { memo, useCallback, useRef, useState } from "react"; 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 type { WorkflowTask, TransitionPreset } from "@/types/workflow";
import { import {
PRESET_LABELS, PRESET_LABELS,
PRESET_WHEN, PRESET_WHEN,
classifyTransitionWhen, classifyTransitionWhen,
} from "@/types/workflow"; } from "@/types/workflow";
import type { ScreenToCanvas } from "./WorkflowCanvas";
export type { TransitionPreset }; export type { TransitionPreset };
@@ -23,6 +24,8 @@ interface TaskNodeProps {
onStartConnection: (taskId: string, preset: TransitionPreset) => void; onStartConnection: (taskId: string, preset: TransitionPreset) => void;
connectingFrom: { taskId: string; preset: TransitionPreset } | null; connectingFrom: { taskId: string; preset: TransitionPreset } | null;
onCompleteConnection: (targetTaskId: string) => void; onCompleteConnection: (targetTaskId: string) => void;
/** Convert screen (client) coordinates to canvas-space coordinates. */
screenToCanvas: ScreenToCanvas;
} }
/** Handle visual configuration for each transition preset */ /** 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 { function transitionSummary(task: WorkflowTask): string | null {
if (!task.next || task.next.length === 0) return 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" : ""}`; 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({ function TaskNodeInner({
task, task,
isSelected, isSelected,
@@ -103,12 +168,15 @@ function TaskNodeInner({
onStartConnection, onStartConnection,
connectingFrom, connectingFrom,
onCompleteConnection, onCompleteConnection,
screenToCanvas,
}: TaskNodeProps) { }: TaskNodeProps) {
const nodeRef = useRef<HTMLDivElement>(null); const nodeRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [hoveredHandle, setHoveredHandle] = useState<TransitionPreset | null>( const [hoveredHandle, setHoveredHandle] = useState<TransitionPreset | null>(
null, null,
); );
const [showTooltip, setShowTooltip] = useState(false);
const tooltipTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const dragOffset = useRef({ x: 0, y: 0 }); const dragOffset = useRef({ x: 0, y: 0 });
const handleMouseDown = useCallback( const handleMouseDown = useCallback(
@@ -119,17 +187,21 @@ function TaskNodeInner({
e.stopPropagation(); e.stopPropagation();
setIsDragging(true); 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 = { dragOffset.current = {
x: e.clientX - task.position.x, x: canvasPos.x - task.position.x,
y: e.clientY - task.position.y, y: canvasPos.y - task.position.y,
}; };
const handleMouseMove = (moveEvent: MouseEvent) => { const handleMouseMove = (moveEvent: MouseEvent) => {
const newX = moveEvent.clientX - dragOffset.current.x; const cur = screenToCanvas(moveEvent.clientX, moveEvent.clientY);
const newY = moveEvent.clientY - dragOffset.current.y;
onPositionChange(task.id, { onPositionChange(task.id, {
x: Math.max(0, newX), x: Math.max(0, cur.x - dragOffset.current.x),
y: Math.max(0, newY), y: Math.max(0, cur.y - dragOffset.current.y),
}); });
}; };
@@ -142,7 +214,13 @@ function TaskNodeInner({
document.addEventListener("mousemove", handleMouseMove); document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp); 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( const handleClick = useCallback(
@@ -174,6 +252,19 @@ function TaskNodeInner({
[task.id, onStartConnection], [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 isConnectionTarget = connectingFrom !== null;
const borderColor = isSelected const borderColor = isSelected
@@ -197,6 +288,49 @@ function TaskNodeInner({
return ct === "custom"; return ct === "custom";
}).length; }).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 ( return (
<div <div
ref={nodeRef} ref={nodeRef}
@@ -243,7 +377,11 @@ function TaskNodeInner({
</div> </div>
{/* Body */} {/* Body */}
<div className="px-2.5 py-2"> <div
className="px-2.5 py-2 relative"
onMouseEnter={handleBodyMouseEnter}
onMouseLeave={handleBodyMouseLeave}
>
{hasAction ? ( {hasAction ? (
<div className="font-mono text-[11px] text-gray-600 truncate"> <div className="font-mono text-[11px] text-gray-600 truncate">
{task.action} {task.action}
@@ -254,19 +392,25 @@ function TaskNodeInner({
</div> </div>
)} )}
{/* Input summary */} {/* Inline inputs (12 populated) */}
{Object.keys(task.input).length > 0 && ( {showInlineInputs && (
<div className="mt-1.5 text-[10px] text-gray-400"> <div className="mt-1.5 space-y-0.5">
{Object.keys(task.input).length} input {populatedInputs.map(([key, val]) => (
{Object.keys(task.input).length !== 1 ? "s" : ""} <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> </div>
)} )}
{/* Transition summary */}
{summary && (
<div className="mt-1 text-[10px] text-gray-400">{summary}</div>
)}
{/* Delay badge */} {/* Delay badge */}
{task.delay && ( {task.delay && (
<div className="mt-1 inline-block px-1.5 py-0.5 bg-yellow-50 border border-yellow-200 rounded text-[10px] text-yellow-700 truncate max-w-full"> <div className="mt-1 inline-block px-1.5 py-0.5 bg-yellow-50 border border-yellow-200 rounded text-[10px] text-yellow-700 truncate max-w-full">
@@ -288,11 +432,36 @@ function TaskNodeInner({
</div> </div>
)} )}
{/* Custom transitions badge */} {/* Info icon hint — shown when there's tooltip content */}
{customTransitionCount > 0 && ( {hasTooltipContent && (
<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"> <div className="absolute top-1.5 right-1.5">
{customTransitionCount} custom transition <Info className="w-3 h-3 text-gray-300" />
{customTransitionCount !== 1 ? "s" : ""} </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>
)} )}
</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 TaskNode from "./TaskNode";
import type { TransitionPreset } from "./TaskNode"; import type { TransitionPreset } from "./TaskNode";
import WorkflowEdges from "./WorkflowEdges"; import WorkflowEdges from "./WorkflowEdges";
import type { EdgeHoverInfo } from "./WorkflowEdges"; import type { EdgeHoverInfo, SelectedEdgeInfo } from "./WorkflowEdges";
import type { WorkflowTask, WorkflowEdge } from "@/types/workflow"; import type {
WorkflowTask,
WorkflowEdge,
NodePosition,
} from "@/types/workflow";
import { import {
deriveEdges, deriveEdges,
generateUniqueTaskName, generateUniqueTaskName,
@@ -11,7 +15,7 @@ import {
findStartingTaskIds, findStartingTaskIds,
PRESET_LABELS, PRESET_LABELS,
} from "@/types/workflow"; } from "@/types/workflow";
import { Plus } from "lucide-react"; import { Plus, Maximize } from "lucide-react";
interface WorkflowCanvasProps { interface WorkflowCanvasProps {
tasks: WorkflowTask[]; tasks: WorkflowTask[];
@@ -35,6 +39,34 @@ const PRESET_BANNER_COLORS: Record<TransitionPreset, string> = {
always: "text-gray-200 font-bold", 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({ export default function WorkflowCanvas({
tasks, tasks,
selectedTaskId, selectedTaskId,
@@ -46,6 +78,17 @@ export default function WorkflowCanvas({
onEdgeClick, onEdgeClick,
}: WorkflowCanvasProps) { }: WorkflowCanvasProps) {
const canvasRef = useRef<HTMLDivElement>(null); 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<{ const [connectingFrom, setConnectingFrom] = useState<{
taskId: string; taskId: string;
preset: TransitionPreset; preset: TransitionPreset;
@@ -55,54 +98,189 @@ export default function WorkflowCanvas({
y: number; y: number;
} | null>(null); } | 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 allTaskNames = useMemo(() => tasks.map((t) => t.name), [tasks]);
const edges: WorkflowEdge[] = useMemo(() => deriveEdges(tasks), [tasks]); const edges: WorkflowEdge[] = useMemo(() => deriveEdges(tasks), [tasks]);
const startingTaskIds = useMemo(() => findStartingTaskIds(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( const handleCanvasClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
// Only deselect if clicking the canvas background const target = e.target as HTMLElement;
if ( if (
e.target === canvasRef.current || target === canvasRef.current ||
(e.target as HTMLElement).dataset.canvasBg === "true" target === innerRef.current ||
target.dataset.canvasBg === "true"
) { ) {
if (connectingFrom) { if (connectingFrom) {
setConnectingFrom(null); setConnectingFrom(null);
setMousePosition(null); setMousePosition(null);
} else { } else {
onSelectTask(null); onSelectTask(null);
setSelectedEdge(null);
onEdgeClick?.(null);
} }
} }
}, },
[onSelectTask, connectingFrom], [onSelectTask, onEdgeClick, connectingFrom],
); );
// ---- Mouse move: panning + connection preview ----
const handleCanvasMouseMove = useCallback( const handleCanvasMouseMove = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
if (connectingFrom && canvasRef.current) { // Right-click panning (direct DOM, no React re-render)
const rect = canvasRef.current.getBoundingClientRect(); if (isPanning.current) {
const scrollLeft = canvasRef.current.scrollLeft; const dx = e.clientX - panDragStart.current.x;
const scrollTop = canvasRef.current.scrollTop; const dy = e.clientY - panDragStart.current.y;
setMousePosition({ panRef.current = {
x: e.clientX - rect.left + scrollLeft, x: panDragStart.current.panX + dx,
y: e.clientY - rect.top + scrollTop, 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(() => { // ---- Mouse down: start panning on right-click ----
// If we're connecting and mouseup happens on the canvas (not on a node), const handleCanvasMouseDown = useCallback((e: React.MouseEvent) => {
// cancel the connection if (e.button === 2) {
if (connectingFrom) { e.preventDefault();
setConnectingFrom(null); isPanning.current = true;
setMousePosition(null); panDragStart.current = {
x: e.clientX,
y: e.clientY,
panX: panRef.current.x,
panY: panRef.current.y,
};
setPanningCursor(true);
} }
}, [connectingFrom]); }, []);
// ---- 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, 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( const handlePositionChange = useCallback(
(taskId: string, position: { x: number; y: number }) => { (taskId: string, position: { x: number; y: number }) => {
onUpdateTask(taskId, { position }); onUpdateTask(taskId, { position });
@@ -113,14 +291,100 @@ export default function WorkflowCanvas({
const handleStartConnection = useCallback( const handleStartConnection = useCallback(
(taskId: string, preset: TransitionPreset) => { (taskId: string, preset: TransitionPreset) => {
setConnectingFrom({ taskId, preset }); 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( const handleCompleteConnection = useCallback(
(targetTaskId: string) => { (targetTaskId: string) => {
if (!connectingFrom) return; if (!connectingFrom) return;
const targetTask = tasks.find((t) => t.id === targetTaskId); const targetTask = tasks.find((t) => t.id === targetTaskId);
if (!targetTask) return; if (!targetTask) return;
@@ -137,12 +401,9 @@ export default function WorkflowCanvas({
const handleAddEmptyTask = useCallback(() => { const handleAddEmptyTask = useCallback(() => {
const name = generateUniqueTaskName(tasks); const name = generateUniqueTaskName(tasks);
// Position new tasks below existing ones
let maxY = 0; let maxY = 0;
for (const task of tasks) { for (const task of tasks) {
if (task.position.y > maxY) { if (task.position.y > maxY) maxY = task.position.y;
maxY = task.position.y;
}
} }
const newTask: WorkflowTask = { const newTask: WorkflowTask = {
id: generateTaskId(), id: generateTaskId(),
@@ -158,43 +419,127 @@ export default function WorkflowCanvas({
onSelectTask(newTask.id); onSelectTask(newTask.id);
}, [tasks, onAddTask, onSelectTask]); }, [tasks, onAddTask, onSelectTask]);
// Calculate minimum canvas dimensions based on node positions /** Reset pan/zoom to fit all tasks (or default viewport). */
const canvasDimensions = useMemo(() => { const handleFitView = useCallback(() => {
let maxX = 800; if (tasks.length === 0) {
let maxY = 600; 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) { for (const task of tasks) {
maxX = Math.max(maxX, task.position.x + 340); maxX = Math.max(maxX, task.position.x + 500);
maxY = Math.max(maxY, task.position.y + 220); maxY = Math.max(maxY, task.position.y + 500);
} }
return { width: maxX, height: maxY }; return { width: maxX, height: maxY };
}, [tasks]); }, [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 ( return (
<div <div
className="flex-1 overflow-auto bg-gray-100 relative"
ref={canvasRef} 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} onClick={handleCanvasClick}
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleCanvasMouseMove} onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp} onMouseUp={handleCanvasMouseUp}
onContextMenu={handleContextMenu}
> >
{/* Grid background */} {/* Transformed canvas content */}
<div <div
ref={innerRef}
data-canvas-bg="true" data-canvas-bg="true"
className="absolute inset-0"
style={{ style={{
minWidth: canvasDimensions.width, position: "absolute",
minHeight: canvasDimensions.height, transformOrigin: "0 0",
backgroundImage: ` transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
linear-gradient(to right, rgba(0,0,0,0.03) 1px, transparent 1px), width: innerSize.width,
linear-gradient(to bottom, rgba(0,0,0,0.03) 1px, transparent 1px) height: innerSize.height,
`,
backgroundSize: "20px 20px",
}} }}
/> >
{/* Edge rendering layer */}
<WorkflowEdges
edges={edges}
tasks={tasks}
connectingFrom={connectingFrom}
mousePosition={mousePosition}
onEdgeClick={handleEdgeClick}
selectedEdge={selectedEdge}
onWaypointUpdate={handleWaypointUpdate}
onLabelPositionUpdate={handleLabelPositionUpdate}
screenToCanvas={screenToCanvas}
/>
{/* Task nodes */}
{tasks.map((task) => (
<TaskNode
key={task.id}
task={task}
isSelected={task.id === selectedTaskId}
isStartNode={startingTaskIds.has(task.id)}
allTaskNames={allTaskNames}
onSelect={handleSelectTask}
onDelete={onDeleteTask}
onPositionChange={handlePositionChange}
onStartConnection={handleStartConnection}
connectingFrom={connectingFrom}
onCompleteConnection={handleCompleteConnection}
screenToCanvas={screenToCanvas}
/>
))}
</div>
{/* ---- UI chrome (not transformed) ---- */}
{/* Connecting mode indicator */} {/* Connecting mode indicator */}
{connectingFrom && ( {connectingFrom && (
<div className="sticky top-0 left-0 right-0 z-50 flex justify-center pointer-events-none"> <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"> <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{" "} Drag to a task to connect as{" "}
<span className={PRESET_BANNER_COLORS[connectingFrom.preset]}> <span className={PRESET_BANNER_COLORS[connectingFrom.preset]}>
@@ -205,41 +550,23 @@ export default function WorkflowCanvas({
</div> </div>
)} )}
{/* Edge rendering layer */} {/* Zoom indicator + fit-view button */}
<WorkflowEdges <div className="absolute bottom-6 left-6 z-40 flex items-center gap-2">
edges={edges} <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">
tasks={tasks} {zoomPercent}%
connectingFrom={connectingFrom} </div>
mousePosition={mousePosition} <button
onEdgeClick={onEdgeClick} 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"
{/* Task nodes */} >
{tasks.map((task) => ( <Maximize className="w-3.5 h-3.5" />
<TaskNode </button>
key={task.id} </div>
task={task}
isSelected={task.id === selectedTaskId}
isStartNode={startingTaskIds.has(task.id)}
allTaskNames={allTaskNames}
onSelect={onSelectTask}
onDelete={onDeleteTask}
onPositionChange={handlePositionChange}
onStartConnection={handleStartConnection}
connectingFrom={connectingFrom}
onCompleteConnection={handleCompleteConnection}
/>
))}
{/* Empty state / Add task button */} {/* Empty state / Add task button */}
{tasks.length === 0 ? ( {tasks.length === 0 ? (
<div <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
className="absolute inset-0 flex items-center justify-center pointer-events-none"
style={{
minWidth: canvasDimensions.width,
minHeight: canvasDimensions.height,
}}
>
<div className="text-center pointer-events-auto"> <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"> <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" /> <Plus className="w-8 h-8 text-gray-400" />
@@ -263,7 +590,7 @@ export default function WorkflowCanvas({
) : ( ) : (
<button <button
onClick={handleAddEmptyTask} 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" title="Add a new task"
> >
<Plus className="w-6 h-6" /> <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, * Transitions are evaluated in order. When `when` is not defined,
* the transition is unconditional (fires on any completion). * the transition is unconditional (fires on any completion).
*/ */
/** Line style for transition edges */
export type LineStyle = "solid" | "dashed" | "dotted" | "dash-dot";
export interface TaskTransition { export interface TaskTransition {
/** Condition expression (e.g., "{{ succeeded() }}", "{{ failed() }}") */ /** Condition expression (e.g., "{{ succeeded() }}", "{{ failed() }}") */
when?: string; when?: string;
@@ -34,6 +37,12 @@ export interface TaskTransition {
label?: string; label?: string;
/** Custom color for the transition edge (CSS color string, e.g., "#ff6600") */ /** Custom color for the transition edge (CSS color string, e.g., "#ff6600") */
color?: string; 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 */ /** A task node in the workflow builder */
@@ -112,6 +121,12 @@ export const PRESET_COLORS: Record<TransitionPreset, string> = {
always: "#6b7280", // gray-500 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. * Classify a `when` expression into an edge visual type.
* Used for edge coloring and labeling. * Used for edge coloring and labeling.
@@ -144,6 +159,8 @@ export interface WorkflowEdge {
from: string; from: string;
/** Target task ID */ /** Target task ID */
to: string; to: string;
/** Target task name (stable key for waypoints) */
toName: string;
/** Visual type of transition (derived from `when`) */ /** Visual type of transition (derived from `when`) */
type: EdgeType; type: EdgeType;
/** Label to display on the edge */ /** Label to display on the edge */
@@ -152,6 +169,12 @@ export interface WorkflowEdge {
transitionIndex: number; transitionIndex: number;
/** Custom color override for the edge (CSS color string) */ /** Custom color override for the edge (CSS color string) */
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 */ /** Complete workflow builder state */
@@ -210,6 +233,12 @@ export interface TransitionChartMeta {
label?: string; label?: string;
/** Custom color for the transition edge (CSS color string) */ /** Custom color for the transition edge (CSS color string) */
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 */ /** Transition as represented in YAML format */
@@ -217,7 +246,7 @@ export interface WorkflowYamlTransition {
when?: string; when?: string;
publish?: PublishDirective[]; publish?: PublishDirective[];
do?: string[]; do?: string[];
/** Visual metadata (label, color) — ignored by backend */ /** Visual metadata (label, color, line style, waypoints) — ignored by backend */
__chart_meta__?: TransitionChartMeta; __chart_meta__?: TransitionChartMeta;
} }
@@ -383,11 +412,24 @@ export function builderStateToDefinition(
if (t.when) yt.when = t.when; if (t.when) yt.when = t.when;
if (t.publish && t.publish.length > 0) yt.publish = t.publish; if (t.publish && t.publish.length > 0) yt.publish = t.publish;
if (t.do && t.do.length > 0) yt.do = t.do; if (t.do && t.do.length > 0) yt.do = t.do;
// Store label/color in __chart_meta__ to avoid polluting the transition namespace // Store label/color/line_style/waypoints in __chart_meta__
if (t.label || t.color) { const hasChartMeta =
t.label ||
t.color ||
t.line_style ||
t.edge_waypoints ||
t.label_positions;
if (hasChartMeta) {
yt.__chart_meta__ = {}; yt.__chart_meta__ = {};
if (t.label) yt.__chart_meta__.label = t.label; if (t.label) yt.__chart_meta__.label = t.label;
if (t.color) yt.__chart_meta__.color = t.color; 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; return yt;
}); });
@@ -529,6 +571,9 @@ export function definitionToBuilderState(
do: t.do, do: t.do,
label: t.__chart_meta__?.label, label: t.__chart_meta__?.label,
color: t.__chart_meta__?.color, 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 { } else {
const converted = legacyTransitionsToNext(task); const converted = legacyTransitionsToNext(task);
@@ -607,10 +652,14 @@ export function deriveEdges(tasks: WorkflowTask[]): WorkflowEdge[] {
edges.push({ edges.push({
from: task.id, from: task.id,
to: targetId, to: targetId,
toName: targetName,
type: edgeType, type: edgeType,
label, label,
transitionIndex: ti, transitionIndex: ti,
color: transition.color, 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 }; 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 = { const newTransition: TaskTransition = {
label: PRESET_LABELS[preset], label: PRESET_LABELS[preset],
color: PRESET_COLORS[preset], color: PRESET_COLORS[preset],
line_style: PRESET_STYLES[preset],
}; };
if (whenExpr) newTransition.when = whenExpr; if (whenExpr) newTransition.when = whenExpr;
next.push(newTransition); next.push(newTransition);
@@ -698,7 +748,29 @@ export function removeTaskFromTransitions(
.map((t) => { .map((t) => {
if (!t.do || !t.do.includes(taskName)) return t; if (!t.do || !t.do.includes(taskName)) return t;
const newDo = t.do.filter((name) => name !== taskName); 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 // Keep transitions that still have `do` targets or `publish` directives
.filter( .filter(
@@ -723,12 +795,36 @@ export function renameTaskInTransitions(
let changed = false; let changed = false;
const updated = next.map((t) => { 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; changed = true;
return {
...t, const result = { ...t };
do: t.do.map((name) => (name === oldName ? newName : name)),
}; 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; 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