splines!
This commit is contained in:
14
AGENTS.md
14
AGENTS.md
@@ -229,6 +229,8 @@ Enforcement created → Execution scheduled → Worker executes Action
|
|||||||
- `do` — list of next task names to invoke when the condition is met
|
- `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
|
||||||
|
|||||||
@@ -432,7 +432,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 */}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
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,
|
||||||
@@ -75,7 +75,7 @@ function hasActiveTransition(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute a short summary of outgoing transitions for the node body.
|
* Compute a short summary of outgoing transitions for the tooltip.
|
||||||
*/
|
*/
|
||||||
function transitionSummary(task: WorkflowTask): string | null {
|
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 +93,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,
|
||||||
@@ -109,6 +171,8 @@ function TaskNodeInner({
|
|||||||
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,6 +183,7 @@ function TaskNodeInner({
|
|||||||
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsDragging(true);
|
setIsDragging(true);
|
||||||
|
setShowTooltip(false);
|
||||||
dragOffset.current = {
|
dragOffset.current = {
|
||||||
x: e.clientX - task.position.x,
|
x: e.clientX - task.position.x,
|
||||||
y: e.clientY - task.position.y,
|
y: e.clientY - task.position.y,
|
||||||
@@ -174,6 +239,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 +275,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 1–2 populated inputs
|
||||||
|
const showInlineInputs = populatedCount > 0 && populatedCount <= 2;
|
||||||
|
|
||||||
|
// Build tooltip lines
|
||||||
|
const tooltipLines: string[] = [];
|
||||||
|
if (populatedCount > 0) {
|
||||||
|
tooltipLines.push(
|
||||||
|
`${populatedCount} input${populatedCount !== 1 ? "s" : ""} configured`,
|
||||||
|
);
|
||||||
|
// Show all input key-values in tooltip when > 2
|
||||||
|
if (populatedCount > 2) {
|
||||||
|
for (const [key, val] of populatedInputs) {
|
||||||
|
tooltipLines.push(` ${key}: ${formatValueTooltip(val)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (summary) {
|
||||||
|
tooltipLines.push(summary);
|
||||||
|
}
|
||||||
|
if (customTransitionCount > 0) {
|
||||||
|
tooltipLines.push(
|
||||||
|
`${customTransitionCount} custom transition${customTransitionCount !== 1 ? "s" : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (task.delay) {
|
||||||
|
tooltipLines.push(`Delay: ${task.delay}s`);
|
||||||
|
}
|
||||||
|
if (task.with_items) {
|
||||||
|
tooltipLines.push("with_items iteration");
|
||||||
|
}
|
||||||
|
if (task.retry) {
|
||||||
|
tooltipLines.push(`Retry: ${task.retry.count}×`);
|
||||||
|
}
|
||||||
|
if (task.timeout) {
|
||||||
|
tooltipLines.push(`Timeout: ${task.timeout}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTooltipContent = tooltipLines.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={nodeRef}
|
ref={nodeRef}
|
||||||
@@ -243,7 +364,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 +379,25 @@ function TaskNodeInner({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Input summary */}
|
{/* Inline inputs (1–2 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 +419,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>
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ import { useState, useCallback, useRef, useMemo } 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,
|
||||||
@@ -55,6 +59,10 @@ export default function WorkflowCanvas({
|
|||||||
y: number;
|
y: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
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]);
|
||||||
@@ -73,10 +81,12 @@ export default function WorkflowCanvas({
|
|||||||
setMousePosition(null);
|
setMousePosition(null);
|
||||||
} else {
|
} else {
|
||||||
onSelectTask(null);
|
onSelectTask(null);
|
||||||
|
setSelectedEdge(null);
|
||||||
|
onEdgeClick?.(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onSelectTask, connectingFrom],
|
[onSelectTask, onEdgeClick, connectingFrom],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCanvasMouseMove = useCallback(
|
const handleCanvasMouseMove = useCallback(
|
||||||
@@ -113,8 +123,102 @@ 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],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Handle edge click: select the edge and propagate to parent */
|
||||||
|
const handleEdgeClick = useCallback(
|
||||||
|
(info: EdgeHoverInfo | null) => {
|
||||||
|
if (info) {
|
||||||
|
setSelectedEdge({
|
||||||
|
from: info.taskId,
|
||||||
|
to: info.targetTaskId,
|
||||||
|
transitionIndex: info.transitionIndex,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setSelectedEdge(null);
|
||||||
|
}
|
||||||
|
onEdgeClick?.(info);
|
||||||
|
},
|
||||||
|
[onEdgeClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Handle selecting a task (also clears edge selection) */
|
||||||
|
const handleSelectTask = useCallback(
|
||||||
|
(taskId: string | null) => {
|
||||||
|
onSelectTask(taskId);
|
||||||
|
if (taskId !== null) {
|
||||||
|
// Keep selected edge if the task being selected is part of it
|
||||||
|
// (i.e. user clicked the source task of the edge via edge click)
|
||||||
|
// Otherwise clear it
|
||||||
|
if (selectedEdge && selectedEdge.from !== taskId) {
|
||||||
|
setSelectedEdge(null);
|
||||||
|
onEdgeClick?.(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSelectTask, onEdgeClick, selectedEdge],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Update waypoints for a specific edge */
|
||||||
|
const handleWaypointUpdate = useCallback(
|
||||||
|
(
|
||||||
|
fromTaskId: string,
|
||||||
|
transitionIndex: number,
|
||||||
|
targetTaskName: string,
|
||||||
|
waypoints: NodePosition[],
|
||||||
|
) => {
|
||||||
|
const task = tasks.find((t) => t.id === fromTaskId);
|
||||||
|
if (!task || !task.next || transitionIndex >= task.next.length) return;
|
||||||
|
|
||||||
|
const updatedNext = [...task.next];
|
||||||
|
const transition = { ...updatedNext[transitionIndex] };
|
||||||
|
const edgeWaypoints = { ...(transition.edge_waypoints || {}) };
|
||||||
|
|
||||||
|
if (waypoints.length > 0) {
|
||||||
|
edgeWaypoints[targetTaskName] = waypoints;
|
||||||
|
} else {
|
||||||
|
delete edgeWaypoints[targetTaskName];
|
||||||
|
}
|
||||||
|
|
||||||
|
transition.edge_waypoints =
|
||||||
|
Object.keys(edgeWaypoints).length > 0 ? edgeWaypoints : undefined;
|
||||||
|
updatedNext[transitionIndex] = transition;
|
||||||
|
onUpdateTask(fromTaskId, { next: updatedNext });
|
||||||
|
},
|
||||||
|
[tasks, onUpdateTask],
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Update label position for a specific edge */
|
||||||
|
const handleLabelPositionUpdate = useCallback(
|
||||||
|
(
|
||||||
|
fromTaskId: string,
|
||||||
|
transitionIndex: number,
|
||||||
|
targetTaskName: string,
|
||||||
|
position: number | undefined,
|
||||||
|
) => {
|
||||||
|
const task = tasks.find((t) => t.id === fromTaskId);
|
||||||
|
if (!task || !task.next || transitionIndex >= task.next.length) return;
|
||||||
|
|
||||||
|
const updatedNext = [...task.next];
|
||||||
|
const transition = { ...updatedNext[transitionIndex] };
|
||||||
|
const labelPositions = { ...(transition.label_positions || {}) };
|
||||||
|
|
||||||
|
if (position) {
|
||||||
|
labelPositions[targetTaskName] = position;
|
||||||
|
} else {
|
||||||
|
delete labelPositions[targetTaskName];
|
||||||
|
}
|
||||||
|
|
||||||
|
transition.label_positions =
|
||||||
|
Object.keys(labelPositions).length > 0 ? labelPositions : undefined;
|
||||||
|
updatedNext[transitionIndex] = transition;
|
||||||
|
onUpdateTask(fromTaskId, { next: updatedNext });
|
||||||
|
},
|
||||||
|
[tasks, onUpdateTask],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCompleteConnection = useCallback(
|
const handleCompleteConnection = useCallback(
|
||||||
@@ -211,7 +315,10 @@ export default function WorkflowCanvas({
|
|||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
connectingFrom={connectingFrom}
|
connectingFrom={connectingFrom}
|
||||||
mousePosition={mousePosition}
|
mousePosition={mousePosition}
|
||||||
onEdgeClick={onEdgeClick}
|
onEdgeClick={handleEdgeClick}
|
||||||
|
selectedEdge={selectedEdge}
|
||||||
|
onWaypointUpdate={handleWaypointUpdate}
|
||||||
|
onLabelPositionUpdate={handleLabelPositionUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Task nodes */}
|
{/* Task nodes */}
|
||||||
@@ -222,7 +329,7 @@ export default function WorkflowCanvas({
|
|||||||
isSelected={task.id === selectedTaskId}
|
isSelected={task.id === selectedTaskId}
|
||||||
isStartNode={startingTaskIds.has(task.id)}
|
isStartNode={startingTaskIds.has(task.id)}
|
||||||
allTaskNames={allTaskNames}
|
allTaskNames={allTaskNames}
|
||||||
onSelect={onSelectTask}
|
onSelect={handleSelectTask}
|
||||||
onDelete={onDeleteTask}
|
onDelete={onDeleteTask}
|
||||||
onPositionChange={handlePositionChange}
|
onPositionChange={handlePositionChange}
|
||||||
onStartConnection={handleStartConnection}
|
onStartConnection={handleStartConnection}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -34,6 +34,10 @@ 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;
|
||||||
|
/** 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 (0–1) along the edge path */
|
||||||
|
label_positions?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A task node in the workflow builder */
|
/** A task node in the workflow builder */
|
||||||
@@ -144,6 +148,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 +158,10 @@ 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;
|
||||||
|
/** Intermediate waypoints for this specific edge */
|
||||||
|
waypoints?: NodePosition[];
|
||||||
|
/** Label position as t-parameter (0–1) along the edge path; default 0.5 */
|
||||||
|
labelPosition?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Complete workflow builder state */
|
/** Complete workflow builder state */
|
||||||
@@ -210,6 +220,10 @@ 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;
|
||||||
|
/** Intermediate waypoints per target task (keyed by target task name) */
|
||||||
|
edge_waypoints?: Record<string, NodePosition[]>;
|
||||||
|
/** Label position per target task as t-parameter (0–1) along the edge path */
|
||||||
|
label_positions?: Record<string, number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Transition as represented in YAML format */
|
/** Transition as represented in YAML format */
|
||||||
@@ -383,11 +397,19 @@ 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/waypoints in __chart_meta__ to avoid polluting the transition namespace
|
||||||
if (t.label || t.color) {
|
const hasChartMeta =
|
||||||
|
t.label || t.color || 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.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 +551,8 @@ 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,
|
||||||
|
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 +631,13 @@ 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,
|
||||||
|
waypoints: transition.edge_waypoints?.[targetName],
|
||||||
|
labelPosition: transition.label_positions?.[targetName],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -698,7 +725,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 +772,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;
|
||||||
|
|||||||
60
work-summary/2026-02-05-edge-waypoints-label-dragging.md
Normal file
60
work-summary/2026-02-05-edge-waypoints-label-dragging.md
Normal 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
|
||||||
Reference in New Issue
Block a user