Compare commits
2 Commits
4c81ba1de8
...
80c8eaaf22
| Author | SHA1 | Date | |
|---|---|---|---|
| 80c8eaaf22 | |||
| 7d942f5dca |
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
|
||||
- `label` — optional custom display label (overrides auto-derived label from `when` expression)
|
||||
- `color` — optional custom CSS color for the transition edge (e.g., `"#ff6600"`)
|
||||
- `edge_waypoints` — optional `Record<string, NodePosition[]>` of intermediate routing points per target task name (chart-only, stored in `__chart_meta__`)
|
||||
- `label_positions` — optional `Record<string, NodePosition>` of custom label positions per target task name (chart-only, stored in `__chart_meta__`)
|
||||
- **Example YAML**:
|
||||
```
|
||||
next:
|
||||
@@ -245,7 +247,7 @@ Enforcement created → Execution scheduled → Worker executes Action
|
||||
- error_handler
|
||||
```
|
||||
- **Legacy format support**: The parser (`crates/common/src/workflow/parser.rs`) auto-converts legacy `on_success`/`on_failure`/`on_complete`/`on_timeout`/`decision` fields into `next` transitions during parsing. The canonical internal representation always uses `next`.
|
||||
- **Frontend types**: `TaskTransition` in `web/src/types/workflow.ts`; `TransitionPreset` ("succeeded" | "failed" | "always") for quick-access drag handles
|
||||
- **Frontend types**: `TaskTransition` in `web/src/types/workflow.ts` (includes `edge_waypoints`, `label_positions` for visual routing); `TransitionPreset` ("succeeded" | "failed" | "always") for quick-access drag handles; `WorkflowEdge` includes per-edge `waypoints` and `labelPosition` derived from the transition; `SelectedEdgeInfo` and `EdgeHoverInfo` (includes `targetTaskId`) in `WorkflowEdges.tsx`
|
||||
- **Backend types**: `TaskTransition` in `crates/common/src/workflow/parser.rs`; `GraphTransition` in `crates/executor/src/workflow/graph.rs`
|
||||
- **NOT this** (legacy format): `on_success: task2` / `on_failure: error_handler` — still parsed for backward compat but normalized to `next`
|
||||
- **Runtime YAML Loading**: Pack registration reads `runtimes/*.yaml` files and inserts them into the `runtime` table. Runtime refs use format `{pack_ref}.{name}` (e.g., `core.python`, `core.shell`).
|
||||
@@ -318,7 +320,15 @@ Rule `action_params` support Jinja2-style `{{ source.path }}` templates resolved
|
||||
- **Visual / Raw YAML toggle**: Toolbar has a segmented toggle to switch between the visual node-based builder and a full-width read-only YAML preview (generated via `js-yaml`). Raw YAML mode replaces the canvas, palette, and inspector with the effective workflow definition.
|
||||
- **Drag-handle connections**: TaskNode has output handles (green=succeeded, red=failed, gray=always) and an input handle (top). Drag from an output handle to another node's input handle to create a transition.
|
||||
- **Transition customization**: Users can rename transitions (custom `label`) and assign custom colors (CSS color string or preset swatches) via the TaskInspector. Custom colors/labels are persisted in the workflow YAML and rendered on the canvas edges.
|
||||
- **Orquesta-style `next` transitions**: Tasks use a `next: TaskTransition[]` array instead of flat `on_success`/`on_failure` fields. Each transition has `when` (condition), `publish` (variables), `do` (target tasks), plus optional `label` and `color`. See "Task Transition Model" above.
|
||||
- **Edge waypoints & label dragging**: Transition edges support intermediate waypoints for custom routing. Click an edge to select it, then:
|
||||
- Drag existing waypoint handles (colored circles) to reposition the edge path
|
||||
- Hover near the midpoint of any edge segment to reveal a "+" handle; click or drag it to insert a new waypoint
|
||||
- Drag the transition label to reposition it independently of the edge path
|
||||
- Double-click a waypoint to remove it; double-click a label to reset its position
|
||||
- Waypoints and label positions are stored per-edge (keyed by target task name) in `TaskTransition.edge_waypoints` and `TaskTransition.label_positions`, serialized via `__chart_meta__` in the workflow YAML
|
||||
- Edge selection state (`SelectedEdgeInfo`) is managed in `WorkflowCanvas`; only the selected edge shows interactive handles
|
||||
- Multi-segment paths use Catmull-Rom → cubic Bezier conversion for smooth curves through waypoints (`buildSmoothPath` in `WorkflowEdges.tsx`)
|
||||
- **Orquesta-style `next` transitions**: Tasks use a `next: TaskTransition[]` array instead of flat `on_success`/`on_failure` fields. Each transition has `when` (condition), `publish` (variables), `do` (target tasks), plus optional `label`, `color`, `edge_waypoints`, and `label_positions`. See "Task Transition Model" above.
|
||||
- **No task type or task-level condition**: The UI does not expose task `type` or task-level `when` — all tasks are actions (workflows are also actions), and conditions belong on transitions. Parallelism is implicit via multiple `do` targets.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
PaletteAction,
|
||||
TaskTransition,
|
||||
PublishDirective,
|
||||
LineStyle,
|
||||
} from "@/types/workflow";
|
||||
import {
|
||||
PRESET_WHEN,
|
||||
@@ -196,6 +197,9 @@ export default function TaskInspector({
|
||||
if (whenExpr) newTransition.when = whenExpr;
|
||||
newTransition.label = PRESET_LABELS[preset];
|
||||
newTransition.color = PRESET_COLORS[preset];
|
||||
if (preset === "failed") {
|
||||
newTransition.line_style = "dashed";
|
||||
}
|
||||
}
|
||||
next.push(newTransition);
|
||||
update({ next });
|
||||
@@ -432,7 +436,9 @@ export default function TaskInspector({
|
||||
className={`border rounded-lg bg-gray-50 overflow-hidden transition-all duration-300 ${
|
||||
isFlashing
|
||||
? "border-blue-400 ring-2 ring-blue-300 shadow-md shadow-blue-100 animate-[flash-highlight_1.5s_ease-out]"
|
||||
: "border-gray-200"
|
||||
: highlightTransitionIndex === ti
|
||||
? "border-blue-400 ring-1 ring-blue-200 bg-blue-50/40"
|
||||
: "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* Transition header */}
|
||||
@@ -506,28 +512,87 @@ export default function TaskInspector({
|
||||
title={swatch.label}
|
||||
/>
|
||||
))}
|
||||
<div className="flex items-center gap-1 ml-1">
|
||||
<input
|
||||
type="color"
|
||||
value={transition.color || "#6b7280"}
|
||||
onChange={(e) =>
|
||||
updateTransition(ti, { color: e.target.value })
|
||||
}
|
||||
className="w-5 h-5 rounded cursor-pointer border border-gray-300"
|
||||
title="Custom color"
|
||||
/>
|
||||
{transition.color && (
|
||||
<input
|
||||
type="color"
|
||||
value={transition.color || "#6b7280"}
|
||||
onChange={(e) =>
|
||||
updateTransition(ti, { color: e.target.value })
|
||||
}
|
||||
className="w-5 h-5 rounded cursor-pointer border border-gray-300 ml-1"
|
||||
title="Custom color"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Line style */}
|
||||
<div>
|
||||
<label className="block text-[10px] font-medium text-gray-500 mb-0.5">
|
||||
Line Style
|
||||
</label>
|
||||
<div className="flex items-center gap-1">
|
||||
{(
|
||||
[
|
||||
"solid",
|
||||
"dashed",
|
||||
"dotted",
|
||||
"dash-dot",
|
||||
] as LineStyle[]
|
||||
).map((style) => {
|
||||
const effectiveStyle =
|
||||
transition.line_style || "solid";
|
||||
const isActive = effectiveStyle === style;
|
||||
const dashArrays: Record<LineStyle, string> = {
|
||||
solid: "",
|
||||
dashed: "6,4",
|
||||
dotted: "2,3",
|
||||
"dash-dot": "8,4,2,4",
|
||||
};
|
||||
const labels: Record<LineStyle, string> = {
|
||||
solid: "Solid",
|
||||
dashed: "Dashed",
|
||||
dotted: "Dotted",
|
||||
"dash-dot": "Dash-dot",
|
||||
};
|
||||
return (
|
||||
<button
|
||||
key={style}
|
||||
onClick={() =>
|
||||
updateTransition(ti, { color: undefined })
|
||||
updateTransition(ti, {
|
||||
line_style:
|
||||
style === "solid" ? undefined : style,
|
||||
})
|
||||
}
|
||||
className="text-[9px] text-gray-400 hover:text-gray-600"
|
||||
title="Reset to default"
|
||||
className={`flex items-center justify-center h-6 px-1.5 rounded border transition-all ${
|
||||
isActive
|
||||
? "border-gray-800 bg-gray-100"
|
||||
: "border-gray-200 hover:border-gray-400"
|
||||
}`}
|
||||
title={labels[style]}
|
||||
>
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import { Trash2, GripVertical, Play, Octagon } from "lucide-react";
|
||||
import { Trash2, GripVertical, Play, Octagon, Info } from "lucide-react";
|
||||
import type { WorkflowTask, TransitionPreset } from "@/types/workflow";
|
||||
import {
|
||||
PRESET_LABELS,
|
||||
PRESET_WHEN,
|
||||
classifyTransitionWhen,
|
||||
} from "@/types/workflow";
|
||||
import type { ScreenToCanvas } from "./WorkflowCanvas";
|
||||
|
||||
export type { TransitionPreset };
|
||||
|
||||
@@ -23,6 +24,8 @@ interface TaskNodeProps {
|
||||
onStartConnection: (taskId: string, preset: TransitionPreset) => void;
|
||||
connectingFrom: { taskId: string; preset: TransitionPreset } | null;
|
||||
onCompleteConnection: (targetTaskId: string) => void;
|
||||
/** Convert screen (client) coordinates to canvas-space coordinates. */
|
||||
screenToCanvas: ScreenToCanvas;
|
||||
}
|
||||
|
||||
/** Handle visual configuration for each transition preset */
|
||||
@@ -75,7 +78,7 @@ function hasActiveTransition(
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a short summary of outgoing transitions for the node body.
|
||||
* Compute a short summary of outgoing transitions for the tooltip.
|
||||
*/
|
||||
function transitionSummary(task: WorkflowTask): string | null {
|
||||
if (!task.next || task.next.length === 0) return null;
|
||||
@@ -93,6 +96,68 @@ function transitionSummary(task: WorkflowTask): string | null {
|
||||
return `${totalTargets} target${totalTargets !== 1 ? "s" : ""} via ${task.next.length} transition${task.next.length !== 1 ? "s" : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is "populated" (non-null, non-undefined, non-empty-string).
|
||||
*/
|
||||
function hasValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value === "string" && value.trim() === "") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entries from task.input that actually have values.
|
||||
*/
|
||||
function getPopulatedInputs(
|
||||
input: Record<string, unknown>,
|
||||
): [string, unknown][] {
|
||||
return Object.entries(input).filter(([, v]) => hasValue(v));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for inline display on the card — keep it short.
|
||||
*/
|
||||
function formatValueShort(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
if (value.length > 28) return value.slice(0, 25) + "…";
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.length} items]`;
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return `{${Object.keys(value).length} keys}`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for the tooltip — can be slightly longer.
|
||||
*/
|
||||
function formatValueTooltip(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
if (value.length > 40) return value.slice(0, 37) + "…";
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.length} items]`;
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length <= 2) {
|
||||
return `{${keys.join(", ")}}`;
|
||||
}
|
||||
return `{${keys.length} keys}`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function TaskNodeInner({
|
||||
task,
|
||||
isSelected,
|
||||
@@ -103,12 +168,15 @@ function TaskNodeInner({
|
||||
onStartConnection,
|
||||
connectingFrom,
|
||||
onCompleteConnection,
|
||||
screenToCanvas,
|
||||
}: TaskNodeProps) {
|
||||
const nodeRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [hoveredHandle, setHoveredHandle] = useState<TransitionPreset | null>(
|
||||
null,
|
||||
);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
const tooltipTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const dragOffset = useRef({ x: 0, y: 0 });
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
@@ -119,17 +187,21 @@ function TaskNodeInner({
|
||||
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
setShowTooltip(false);
|
||||
|
||||
// Convert the initial click to canvas-space so dragging stays
|
||||
// accurate regardless of the current zoom / pan.
|
||||
const canvasPos = screenToCanvas(e.clientX, e.clientY);
|
||||
dragOffset.current = {
|
||||
x: e.clientX - task.position.x,
|
||||
y: e.clientY - task.position.y,
|
||||
x: canvasPos.x - task.position.x,
|
||||
y: canvasPos.y - task.position.y,
|
||||
};
|
||||
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
const newX = moveEvent.clientX - dragOffset.current.x;
|
||||
const newY = moveEvent.clientY - dragOffset.current.y;
|
||||
const cur = screenToCanvas(moveEvent.clientX, moveEvent.clientY);
|
||||
onPositionChange(task.id, {
|
||||
x: Math.max(0, newX),
|
||||
y: Math.max(0, newY),
|
||||
x: Math.max(0, cur.x - dragOffset.current.x),
|
||||
y: Math.max(0, cur.y - dragOffset.current.y),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -142,7 +214,13 @@ function TaskNodeInner({
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
},
|
||||
[task.id, task.position.x, task.position.y, onPositionChange],
|
||||
[
|
||||
task.id,
|
||||
task.position.x,
|
||||
task.position.y,
|
||||
onPositionChange,
|
||||
screenToCanvas,
|
||||
],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
@@ -174,6 +252,19 @@ function TaskNodeInner({
|
||||
[task.id, onStartConnection],
|
||||
);
|
||||
|
||||
const handleBodyMouseEnter = useCallback(() => {
|
||||
if (isDragging) return;
|
||||
tooltipTimeout.current = setTimeout(() => setShowTooltip(true), 400);
|
||||
}, [isDragging]);
|
||||
|
||||
const handleBodyMouseLeave = useCallback(() => {
|
||||
if (tooltipTimeout.current) {
|
||||
clearTimeout(tooltipTimeout.current);
|
||||
tooltipTimeout.current = null;
|
||||
}
|
||||
setShowTooltip(false);
|
||||
}, []);
|
||||
|
||||
const isConnectionTarget = connectingFrom !== null;
|
||||
|
||||
const borderColor = isSelected
|
||||
@@ -197,6 +288,49 @@ function TaskNodeInner({
|
||||
return ct === "custom";
|
||||
}).length;
|
||||
|
||||
// Inputs that actually have values
|
||||
const populatedInputs = getPopulatedInputs(task.input);
|
||||
const populatedCount = populatedInputs.length;
|
||||
|
||||
// Show inline if 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 (
|
||||
<div
|
||||
ref={nodeRef}
|
||||
@@ -243,7 +377,11 @@ function TaskNodeInner({
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-2.5 py-2">
|
||||
<div
|
||||
className="px-2.5 py-2 relative"
|
||||
onMouseEnter={handleBodyMouseEnter}
|
||||
onMouseLeave={handleBodyMouseLeave}
|
||||
>
|
||||
{hasAction ? (
|
||||
<div className="font-mono text-[11px] text-gray-600 truncate">
|
||||
{task.action}
|
||||
@@ -254,19 +392,25 @@ function TaskNodeInner({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input summary */}
|
||||
{Object.keys(task.input).length > 0 && (
|
||||
<div className="mt-1.5 text-[10px] text-gray-400">
|
||||
{Object.keys(task.input).length} input
|
||||
{Object.keys(task.input).length !== 1 ? "s" : ""}
|
||||
{/* Inline inputs (1–2 populated) */}
|
||||
{showInlineInputs && (
|
||||
<div className="mt-1.5 space-y-0.5">
|
||||
{populatedInputs.map(([key, val]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-baseline gap-1 text-[10px] leading-tight"
|
||||
>
|
||||
<span className="text-gray-400 font-medium shrink-0">
|
||||
{key}:
|
||||
</span>
|
||||
<span className="text-gray-600 truncate font-mono">
|
||||
{formatValueShort(val)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transition summary */}
|
||||
{summary && (
|
||||
<div className="mt-1 text-[10px] text-gray-400">{summary}</div>
|
||||
)}
|
||||
|
||||
{/* Delay badge */}
|
||||
{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">
|
||||
@@ -288,11 +432,36 @@ function TaskNodeInner({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom transitions badge */}
|
||||
{customTransitionCount > 0 && (
|
||||
<div className="mt-1 inline-block px-1.5 py-0.5 bg-violet-50 border border-violet-200 rounded text-[10px] text-violet-700 ml-1">
|
||||
{customTransitionCount} custom transition
|
||||
{customTransitionCount !== 1 ? "s" : ""}
|
||||
{/* Info icon hint — shown when there's tooltip content */}
|
||||
{hasTooltipContent && (
|
||||
<div className="absolute top-1.5 right-1.5">
|
||||
<Info className="w-3 h-3 text-gray-300" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tooltip */}
|
||||
{showTooltip && hasTooltipContent && (
|
||||
<div
|
||||
className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 z-[100] pointer-events-none"
|
||||
style={{ minWidth: 180, maxWidth: 260 }}
|
||||
>
|
||||
<div className="bg-gray-900 text-white text-[10px] leading-relaxed rounded-md shadow-xl px-2.5 py-2 whitespace-pre-wrap">
|
||||
{tooltipLines.map((line, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={
|
||||
line.startsWith(" ")
|
||||
? "pl-2 text-gray-300 font-mono"
|
||||
: i > 0
|
||||
? "mt-1 border-t border-gray-700 pt-1"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute left-1/2 -translate-x-1/2 -bottom-1 w-2 h-2 bg-gray-900 rotate-45" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useState, useCallback, useRef, useMemo } from "react";
|
||||
import { useState, useCallback, useRef, useMemo, useEffect } from "react";
|
||||
import TaskNode from "./TaskNode";
|
||||
import type { TransitionPreset } from "./TaskNode";
|
||||
import WorkflowEdges from "./WorkflowEdges";
|
||||
import type { EdgeHoverInfo } from "./WorkflowEdges";
|
||||
import type { WorkflowTask, WorkflowEdge } from "@/types/workflow";
|
||||
import type { EdgeHoverInfo, SelectedEdgeInfo } from "./WorkflowEdges";
|
||||
import type {
|
||||
WorkflowTask,
|
||||
WorkflowEdge,
|
||||
NodePosition,
|
||||
} from "@/types/workflow";
|
||||
import {
|
||||
deriveEdges,
|
||||
generateUniqueTaskName,
|
||||
@@ -11,7 +15,7 @@ import {
|
||||
findStartingTaskIds,
|
||||
PRESET_LABELS,
|
||||
} from "@/types/workflow";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Plus, Maximize } from "lucide-react";
|
||||
|
||||
interface WorkflowCanvasProps {
|
||||
tasks: WorkflowTask[];
|
||||
@@ -35,6 +39,34 @@ const PRESET_BANNER_COLORS: Record<TransitionPreset, string> = {
|
||||
always: "text-gray-200 font-bold",
|
||||
};
|
||||
|
||||
const MIN_ZOOM = 0.15;
|
||||
const MAX_ZOOM = 3;
|
||||
const ZOOM_SENSITIVITY = 0.0015;
|
||||
|
||||
/**
|
||||
* Build CSS background style for the infinite grid.
|
||||
* Two layers: regular lines every 20 canvas-units, bold lines every 100 (5th).
|
||||
*/
|
||||
function gridBackground(pan: { x: number; y: number }, zoom: number) {
|
||||
const small = 20 * zoom;
|
||||
const large = 100 * zoom;
|
||||
return {
|
||||
backgroundImage: [
|
||||
`linear-gradient(to right, rgba(0,0,0,0.07) 1px, transparent 1px)`,
|
||||
`linear-gradient(to bottom, rgba(0,0,0,0.07) 1px, transparent 1px)`,
|
||||
`linear-gradient(to right, rgba(0,0,0,0.03) 1px, transparent 1px)`,
|
||||
`linear-gradient(to bottom, rgba(0,0,0,0.03) 1px, transparent 1px)`,
|
||||
].join(","),
|
||||
backgroundSize: `${large}px ${large}px, ${large}px ${large}px, ${small}px ${small}px, ${small}px ${small}px`,
|
||||
backgroundPosition: `${pan.x}px ${pan.y}px, ${pan.x}px ${pan.y}px, ${pan.x}px ${pan.y}px, ${pan.x}px ${pan.y}px`,
|
||||
};
|
||||
}
|
||||
|
||||
export type ScreenToCanvas = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
) => { x: number; y: number };
|
||||
|
||||
export default function WorkflowCanvas({
|
||||
tasks,
|
||||
selectedTaskId,
|
||||
@@ -46,6 +78,17 @@ export default function WorkflowCanvas({
|
||||
onEdgeClick,
|
||||
}: WorkflowCanvasProps) {
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// ---- Camera state ----
|
||||
// We keep refs for high-frequency updates (panning/zooming) and sync to
|
||||
// state on mouseup / wheel-end so React can re-render once.
|
||||
const panRef = useRef({ x: 0, y: 0 });
|
||||
const zoomRef = useRef(1);
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
|
||||
// ---- Connection state ----
|
||||
const [connectingFrom, setConnectingFrom] = useState<{
|
||||
taskId: string;
|
||||
preset: TransitionPreset;
|
||||
@@ -55,54 +98,189 @@ export default function WorkflowCanvas({
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
// ---- Panning state (right-click drag) ----
|
||||
const isPanning = useRef(false);
|
||||
const panDragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
|
||||
const [panningCursor, setPanningCursor] = useState(false);
|
||||
|
||||
const [selectedEdge, setSelectedEdge] = useState<SelectedEdgeInfo | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const allTaskNames = useMemo(() => tasks.map((t) => t.name), [tasks]);
|
||||
|
||||
const edges: WorkflowEdge[] = useMemo(() => deriveEdges(tasks), [tasks]);
|
||||
|
||||
const startingTaskIds = useMemo(() => findStartingTaskIds(tasks), [tasks]);
|
||||
|
||||
// ---- Coordinate conversion ----
|
||||
/** Convert screen (client) coordinates to canvas-space coordinates. */
|
||||
const screenToCanvas: ScreenToCanvas = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return { x: clientX, y: clientY };
|
||||
return {
|
||||
x: (clientX - rect.left - panRef.current.x) / zoomRef.current,
|
||||
y: (clientY - rect.top - panRef.current.y) / zoomRef.current,
|
||||
};
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// ---- Flush camera refs to React state (triggers re-render) ----
|
||||
const commitCamera = useCallback(() => {
|
||||
setPan({ ...panRef.current });
|
||||
setZoom(zoomRef.current);
|
||||
}, []);
|
||||
|
||||
/** Apply current ref values directly to the DOM for smooth animation. */
|
||||
const applyTransformToDOM = useCallback(() => {
|
||||
if (innerRef.current) {
|
||||
innerRef.current.style.transform = `translate(${panRef.current.x}px, ${panRef.current.y}px) scale(${zoomRef.current})`;
|
||||
}
|
||||
if (canvasRef.current) {
|
||||
const bg = gridBackground(panRef.current, zoomRef.current);
|
||||
canvasRef.current.style.backgroundSize = bg.backgroundSize;
|
||||
canvasRef.current.style.backgroundPosition = bg.backgroundPosition;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ---- Canvas click (deselect / cancel connection) ----
|
||||
const handleCanvasClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Only deselect if clicking the canvas background
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
e.target === canvasRef.current ||
|
||||
(e.target as HTMLElement).dataset.canvasBg === "true"
|
||||
target === canvasRef.current ||
|
||||
target === innerRef.current ||
|
||||
target.dataset.canvasBg === "true"
|
||||
) {
|
||||
if (connectingFrom) {
|
||||
setConnectingFrom(null);
|
||||
setMousePosition(null);
|
||||
} else {
|
||||
onSelectTask(null);
|
||||
setSelectedEdge(null);
|
||||
onEdgeClick?.(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onSelectTask, connectingFrom],
|
||||
[onSelectTask, onEdgeClick, connectingFrom],
|
||||
);
|
||||
|
||||
// ---- Mouse move: panning + connection preview ----
|
||||
const handleCanvasMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (connectingFrom && canvasRef.current) {
|
||||
const rect = canvasRef.current.getBoundingClientRect();
|
||||
const scrollLeft = canvasRef.current.scrollLeft;
|
||||
const scrollTop = canvasRef.current.scrollTop;
|
||||
setMousePosition({
|
||||
x: e.clientX - rect.left + scrollLeft,
|
||||
y: e.clientY - rect.top + scrollTop,
|
||||
});
|
||||
// Right-click panning (direct DOM, no React re-render)
|
||||
if (isPanning.current) {
|
||||
const dx = e.clientX - panDragStart.current.x;
|
||||
const dy = e.clientY - panDragStart.current.y;
|
||||
panRef.current = {
|
||||
x: panDragStart.current.panX + dx,
|
||||
y: panDragStart.current.panY + dy,
|
||||
};
|
||||
applyTransformToDOM();
|
||||
return;
|
||||
}
|
||||
|
||||
// Connection preview line
|
||||
if (connectingFrom) {
|
||||
const pos = screenToCanvas(e.clientX, e.clientY);
|
||||
setMousePosition(pos);
|
||||
}
|
||||
},
|
||||
[connectingFrom],
|
||||
[connectingFrom, screenToCanvas, applyTransformToDOM],
|
||||
);
|
||||
|
||||
const handleCanvasMouseUp = useCallback(() => {
|
||||
// If we're connecting and mouseup happens on the canvas (not on a node),
|
||||
// cancel the connection
|
||||
if (connectingFrom) {
|
||||
setConnectingFrom(null);
|
||||
setMousePosition(null);
|
||||
// ---- Mouse down: start panning on right-click ----
|
||||
const handleCanvasMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
if (e.button === 2) {
|
||||
e.preventDefault();
|
||||
isPanning.current = true;
|
||||
panDragStart.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
panX: panRef.current.x,
|
||||
panY: panRef.current.y,
|
||||
};
|
||||
setPanningCursor(true);
|
||||
}
|
||||
}, [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(
|
||||
(taskId: string, position: { x: number; y: number }) => {
|
||||
onUpdateTask(taskId, { position });
|
||||
@@ -113,14 +291,100 @@ export default function WorkflowCanvas({
|
||||
const handleStartConnection = useCallback(
|
||||
(taskId: string, preset: TransitionPreset) => {
|
||||
setConnectingFrom({ taskId, preset });
|
||||
setSelectedEdge(null);
|
||||
onEdgeClick?.(null);
|
||||
},
|
||||
[],
|
||||
[onEdgeClick],
|
||||
);
|
||||
|
||||
const handleEdgeClick = useCallback(
|
||||
(info: EdgeHoverInfo | null) => {
|
||||
if (info) {
|
||||
setSelectedEdge({
|
||||
from: info.taskId,
|
||||
to: info.targetTaskId,
|
||||
transitionIndex: info.transitionIndex,
|
||||
});
|
||||
} else {
|
||||
setSelectedEdge(null);
|
||||
}
|
||||
onEdgeClick?.(info);
|
||||
},
|
||||
[onEdgeClick],
|
||||
);
|
||||
|
||||
const handleSelectTask = useCallback(
|
||||
(taskId: string | null) => {
|
||||
onSelectTask(taskId);
|
||||
if (taskId !== null) {
|
||||
if (selectedEdge && selectedEdge.from !== taskId) {
|
||||
setSelectedEdge(null);
|
||||
onEdgeClick?.(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onSelectTask, onEdgeClick, selectedEdge],
|
||||
);
|
||||
|
||||
const handleWaypointUpdate = useCallback(
|
||||
(
|
||||
fromTaskId: string,
|
||||
transitionIndex: number,
|
||||
targetTaskName: string,
|
||||
waypoints: NodePosition[],
|
||||
) => {
|
||||
const task = tasks.find((t) => t.id === fromTaskId);
|
||||
if (!task || !task.next || transitionIndex >= task.next.length) return;
|
||||
|
||||
const updatedNext = [...task.next];
|
||||
const transition = { ...updatedNext[transitionIndex] };
|
||||
const edgeWaypoints = { ...(transition.edge_waypoints || {}) };
|
||||
|
||||
if (waypoints.length > 0) {
|
||||
edgeWaypoints[targetTaskName] = waypoints;
|
||||
} else {
|
||||
delete edgeWaypoints[targetTaskName];
|
||||
}
|
||||
|
||||
transition.edge_waypoints =
|
||||
Object.keys(edgeWaypoints).length > 0 ? edgeWaypoints : undefined;
|
||||
updatedNext[transitionIndex] = transition;
|
||||
onUpdateTask(fromTaskId, { next: updatedNext });
|
||||
},
|
||||
[tasks, onUpdateTask],
|
||||
);
|
||||
|
||||
const handleLabelPositionUpdate = useCallback(
|
||||
(
|
||||
fromTaskId: string,
|
||||
transitionIndex: number,
|
||||
targetTaskName: string,
|
||||
position: number | undefined,
|
||||
) => {
|
||||
const task = tasks.find((t) => t.id === fromTaskId);
|
||||
if (!task || !task.next || transitionIndex >= task.next.length) return;
|
||||
|
||||
const updatedNext = [...task.next];
|
||||
const transition = { ...updatedNext[transitionIndex] };
|
||||
const labelPositions = { ...(transition.label_positions || {}) };
|
||||
|
||||
if (position) {
|
||||
labelPositions[targetTaskName] = position;
|
||||
} else {
|
||||
delete labelPositions[targetTaskName];
|
||||
}
|
||||
|
||||
transition.label_positions =
|
||||
Object.keys(labelPositions).length > 0 ? labelPositions : undefined;
|
||||
updatedNext[transitionIndex] = transition;
|
||||
onUpdateTask(fromTaskId, { next: updatedNext });
|
||||
},
|
||||
[tasks, onUpdateTask],
|
||||
);
|
||||
|
||||
const handleCompleteConnection = useCallback(
|
||||
(targetTaskId: string) => {
|
||||
if (!connectingFrom) return;
|
||||
|
||||
const targetTask = tasks.find((t) => t.id === targetTaskId);
|
||||
if (!targetTask) return;
|
||||
|
||||
@@ -137,12 +401,9 @@ export default function WorkflowCanvas({
|
||||
|
||||
const handleAddEmptyTask = useCallback(() => {
|
||||
const name = generateUniqueTaskName(tasks);
|
||||
// Position new tasks below existing ones
|
||||
let maxY = 0;
|
||||
for (const task of tasks) {
|
||||
if (task.position.y > maxY) {
|
||||
maxY = task.position.y;
|
||||
}
|
||||
if (task.position.y > maxY) maxY = task.position.y;
|
||||
}
|
||||
const newTask: WorkflowTask = {
|
||||
id: generateTaskId(),
|
||||
@@ -158,43 +419,127 @@ export default function WorkflowCanvas({
|
||||
onSelectTask(newTask.id);
|
||||
}, [tasks, onAddTask, onSelectTask]);
|
||||
|
||||
// Calculate minimum canvas dimensions based on node positions
|
||||
const canvasDimensions = useMemo(() => {
|
||||
let maxX = 800;
|
||||
let maxY = 600;
|
||||
/** Reset pan/zoom to fit all tasks (or default viewport). */
|
||||
const handleFitView = useCallback(() => {
|
||||
if (tasks.length === 0) {
|
||||
panRef.current = { x: 0, y: 0 };
|
||||
zoomRef.current = 1;
|
||||
} else {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
let minX = Infinity,
|
||||
minY = Infinity,
|
||||
maxX = -Infinity,
|
||||
maxY = -Infinity;
|
||||
for (const t of tasks) {
|
||||
minX = Math.min(minX, t.position.x);
|
||||
minY = Math.min(minY, t.position.y);
|
||||
maxX = Math.max(maxX, t.position.x + 240);
|
||||
maxY = Math.max(maxY, t.position.y + 140);
|
||||
}
|
||||
|
||||
const contentW = maxX - minX;
|
||||
const contentH = maxY - minY;
|
||||
const pad = 80;
|
||||
const scaleX = (rect.width - pad * 2) / contentW;
|
||||
const scaleY = (rect.height - pad * 2) / contentH;
|
||||
const newZoom = Math.min(
|
||||
Math.max(Math.min(scaleX, scaleY), MIN_ZOOM),
|
||||
MAX_ZOOM,
|
||||
);
|
||||
|
||||
panRef.current = {
|
||||
x: (rect.width - contentW * newZoom) / 2 - minX * newZoom,
|
||||
y: (rect.height - contentH * newZoom) / 2 - minY * newZoom,
|
||||
};
|
||||
zoomRef.current = newZoom;
|
||||
}
|
||||
applyTransformToDOM();
|
||||
commitCamera();
|
||||
}, [tasks, applyTransformToDOM, commitCamera]);
|
||||
|
||||
// ---- Inner div dimensions (large enough to contain all content) ----
|
||||
const innerSize = useMemo(() => {
|
||||
let maxX = 4000;
|
||||
let maxY = 4000;
|
||||
for (const task of tasks) {
|
||||
maxX = Math.max(maxX, task.position.x + 340);
|
||||
maxY = Math.max(maxY, task.position.y + 220);
|
||||
maxX = Math.max(maxX, task.position.x + 500);
|
||||
maxY = Math.max(maxY, task.position.y + 500);
|
||||
}
|
||||
return { width: maxX, height: maxY };
|
||||
}, [tasks]);
|
||||
|
||||
// ---- Grid background (recomputed from React state for the render) ----
|
||||
const gridBg = useMemo(() => gridBackground(pan, zoom), [pan, zoom]);
|
||||
|
||||
// Zoom percentage for display
|
||||
const zoomPercent = Math.round(zoom * 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 overflow-auto bg-gray-100 relative"
|
||||
ref={canvasRef}
|
||||
className={`flex-1 overflow-hidden bg-gray-100 relative ${panningCursor ? "!cursor-grabbing" : ""}`}
|
||||
style={{
|
||||
backgroundImage: gridBg.backgroundImage,
|
||||
backgroundSize: gridBg.backgroundSize,
|
||||
backgroundPosition: gridBg.backgroundPosition,
|
||||
}}
|
||||
onClick={handleCanvasClick}
|
||||
onMouseDown={handleCanvasMouseDown}
|
||||
onMouseMove={handleCanvasMouseMove}
|
||||
onMouseUp={handleCanvasMouseUp}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* Grid background */}
|
||||
{/* Transformed canvas content */}
|
||||
<div
|
||||
ref={innerRef}
|
||||
data-canvas-bg="true"
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
minWidth: canvasDimensions.width,
|
||||
minHeight: canvasDimensions.height,
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, rgba(0,0,0,0.03) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(0,0,0,0.03) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: "20px 20px",
|
||||
position: "absolute",
|
||||
transformOrigin: "0 0",
|
||||
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
|
||||
width: innerSize.width,
|
||||
height: innerSize.height,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{/* 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 */}
|
||||
{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">
|
||||
Drag to a task to connect as{" "}
|
||||
<span className={PRESET_BANNER_COLORS[connectingFrom.preset]}>
|
||||
@@ -205,41 +550,23 @@ export default function WorkflowCanvas({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edge rendering layer */}
|
||||
<WorkflowEdges
|
||||
edges={edges}
|
||||
tasks={tasks}
|
||||
connectingFrom={connectingFrom}
|
||||
mousePosition={mousePosition}
|
||||
onEdgeClick={onEdgeClick}
|
||||
/>
|
||||
|
||||
{/* Task nodes */}
|
||||
{tasks.map((task) => (
|
||||
<TaskNode
|
||||
key={task.id}
|
||||
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}
|
||||
/>
|
||||
))}
|
||||
{/* Zoom indicator + fit-view button */}
|
||||
<div className="absolute bottom-6 left-6 z-40 flex items-center gap-2">
|
||||
<div className="px-2.5 py-1.5 bg-white/80 backdrop-blur-sm text-xs font-medium text-gray-500 rounded-lg shadow-sm border border-gray-200 select-none tabular-nums">
|
||||
{zoomPercent}%
|
||||
</div>
|
||||
<button
|
||||
onClick={handleFitView}
|
||||
className="p-1.5 bg-white/80 backdrop-blur-sm text-gray-500 rounded-lg shadow-sm border border-gray-200 hover:bg-white hover:text-gray-700 transition-colors"
|
||||
title="Fit view to content"
|
||||
>
|
||||
<Maximize className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Empty state / Add task button */}
|
||||
{tasks.length === 0 ? (
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center pointer-events-none"
|
||||
style={{
|
||||
minWidth: canvasDimensions.width,
|
||||
minHeight: canvasDimensions.height,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div className="text-center pointer-events-auto">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<Plus className="w-8 h-8 text-gray-400" />
|
||||
@@ -263,7 +590,7 @@ export default function WorkflowCanvas({
|
||||
) : (
|
||||
<button
|
||||
onClick={handleAddEmptyTask}
|
||||
className="fixed bottom-6 right-6 z-40 w-12 h-12 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 transition-colors flex items-center justify-center"
|
||||
className="absolute bottom-6 right-6 z-40 w-12 h-12 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 transition-colors flex items-center justify-center"
|
||||
title="Add a new task"
|
||||
>
|
||||
<Plus className="w-6 h-6" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,9 @@ export interface NodePosition {
|
||||
* Transitions are evaluated in order. When `when` is not defined,
|
||||
* the transition is unconditional (fires on any completion).
|
||||
*/
|
||||
/** Line style for transition edges */
|
||||
export type LineStyle = "solid" | "dashed" | "dotted" | "dash-dot";
|
||||
|
||||
export interface TaskTransition {
|
||||
/** Condition expression (e.g., "{{ succeeded() }}", "{{ failed() }}") */
|
||||
when?: string;
|
||||
@@ -34,6 +37,12 @@ export interface TaskTransition {
|
||||
label?: string;
|
||||
/** Custom color for the transition edge (CSS color string, e.g., "#ff6600") */
|
||||
color?: string;
|
||||
/** Custom line style for the transition edge (overrides type-based default) */
|
||||
line_style?: LineStyle;
|
||||
/** Intermediate waypoints per target task (keyed by target task name) for edge routing */
|
||||
edge_waypoints?: Record<string, NodePosition[]>;
|
||||
/** Label position per target task as t-parameter (0–1) along the edge path */
|
||||
label_positions?: Record<string, number>;
|
||||
}
|
||||
|
||||
/** A task node in the workflow builder */
|
||||
@@ -112,6 +121,12 @@ export const PRESET_COLORS: Record<TransitionPreset, string> = {
|
||||
always: "#6b7280", // gray-500
|
||||
};
|
||||
|
||||
export const PRESET_STYLES: Record<TransitionPreset, LineStyle> = {
|
||||
succeeded: "solid",
|
||||
failed: "dashed",
|
||||
always: "solid",
|
||||
};
|
||||
|
||||
/**
|
||||
* Classify a `when` expression into an edge visual type.
|
||||
* Used for edge coloring and labeling.
|
||||
@@ -144,6 +159,8 @@ export interface WorkflowEdge {
|
||||
from: string;
|
||||
/** Target task ID */
|
||||
to: string;
|
||||
/** Target task name (stable key for waypoints) */
|
||||
toName: string;
|
||||
/** Visual type of transition (derived from `when`) */
|
||||
type: EdgeType;
|
||||
/** Label to display on the edge */
|
||||
@@ -152,6 +169,12 @@ export interface WorkflowEdge {
|
||||
transitionIndex: number;
|
||||
/** Custom color override for the edge (CSS color string) */
|
||||
color?: string;
|
||||
/** Custom line style override for the edge */
|
||||
lineStyle?: LineStyle;
|
||||
/** Intermediate waypoints for this specific edge */
|
||||
waypoints?: NodePosition[];
|
||||
/** Label position as t-parameter (0–1) along the edge path; default 0.5 */
|
||||
labelPosition?: number;
|
||||
}
|
||||
|
||||
/** Complete workflow builder state */
|
||||
@@ -210,6 +233,12 @@ export interface TransitionChartMeta {
|
||||
label?: string;
|
||||
/** Custom color for the transition edge (CSS color string) */
|
||||
color?: string;
|
||||
/** Custom line style for the transition edge */
|
||||
line_style?: LineStyle;
|
||||
/** Intermediate waypoints per target task (keyed by target task name) */
|
||||
edge_waypoints?: Record<string, NodePosition[]>;
|
||||
/** Label position per target task as t-parameter (0–1) along the edge path */
|
||||
label_positions?: Record<string, number>;
|
||||
}
|
||||
|
||||
/** Transition as represented in YAML format */
|
||||
@@ -217,7 +246,7 @@ export interface WorkflowYamlTransition {
|
||||
when?: string;
|
||||
publish?: PublishDirective[];
|
||||
do?: string[];
|
||||
/** Visual metadata (label, color) — ignored by backend */
|
||||
/** Visual metadata (label, color, line style, waypoints) — ignored by backend */
|
||||
__chart_meta__?: TransitionChartMeta;
|
||||
}
|
||||
|
||||
@@ -383,11 +412,24 @@ export function builderStateToDefinition(
|
||||
if (t.when) yt.when = t.when;
|
||||
if (t.publish && t.publish.length > 0) yt.publish = t.publish;
|
||||
if (t.do && t.do.length > 0) yt.do = t.do;
|
||||
// Store label/color in __chart_meta__ to avoid polluting the transition namespace
|
||||
if (t.label || t.color) {
|
||||
// Store label/color/line_style/waypoints in __chart_meta__
|
||||
const hasChartMeta =
|
||||
t.label ||
|
||||
t.color ||
|
||||
t.line_style ||
|
||||
t.edge_waypoints ||
|
||||
t.label_positions;
|
||||
if (hasChartMeta) {
|
||||
yt.__chart_meta__ = {};
|
||||
if (t.label) yt.__chart_meta__.label = t.label;
|
||||
if (t.color) yt.__chart_meta__.color = t.color;
|
||||
if (t.line_style) yt.__chart_meta__.line_style = t.line_style;
|
||||
if (t.edge_waypoints && Object.keys(t.edge_waypoints).length > 0) {
|
||||
yt.__chart_meta__.edge_waypoints = t.edge_waypoints;
|
||||
}
|
||||
if (t.label_positions && Object.keys(t.label_positions).length > 0) {
|
||||
yt.__chart_meta__.label_positions = t.label_positions;
|
||||
}
|
||||
}
|
||||
return yt;
|
||||
});
|
||||
@@ -529,6 +571,9 @@ export function definitionToBuilderState(
|
||||
do: t.do,
|
||||
label: t.__chart_meta__?.label,
|
||||
color: t.__chart_meta__?.color,
|
||||
line_style: t.__chart_meta__?.line_style,
|
||||
edge_waypoints: t.__chart_meta__?.edge_waypoints,
|
||||
label_positions: t.__chart_meta__?.label_positions,
|
||||
}));
|
||||
} else {
|
||||
const converted = legacyTransitionsToNext(task);
|
||||
@@ -607,10 +652,14 @@ export function deriveEdges(tasks: WorkflowTask[]): WorkflowEdge[] {
|
||||
edges.push({
|
||||
from: task.id,
|
||||
to: targetId,
|
||||
toName: targetName,
|
||||
type: edgeType,
|
||||
label,
|
||||
transitionIndex: ti,
|
||||
color: transition.color,
|
||||
lineStyle: transition.line_style,
|
||||
waypoints: transition.edge_waypoints?.[targetName],
|
||||
labelPosition: transition.label_positions?.[targetName],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -651,10 +700,11 @@ export function findOrCreateTransition(
|
||||
return { next, index: existingIndex };
|
||||
}
|
||||
|
||||
// Create new transition with default label and color for the preset
|
||||
// Create new transition with default label, color, and line style for the preset
|
||||
const newTransition: TaskTransition = {
|
||||
label: PRESET_LABELS[preset],
|
||||
color: PRESET_COLORS[preset],
|
||||
line_style: PRESET_STYLES[preset],
|
||||
};
|
||||
if (whenExpr) newTransition.when = whenExpr;
|
||||
next.push(newTransition);
|
||||
@@ -698,7 +748,29 @@ export function removeTaskFromTransitions(
|
||||
.map((t) => {
|
||||
if (!t.do || !t.do.includes(taskName)) return t;
|
||||
const newDo = t.do.filter((name) => name !== taskName);
|
||||
return { ...t, do: newDo.length > 0 ? newDo : undefined };
|
||||
// Also clean up waypoint/label entries for the removed target
|
||||
const updatedWaypoints = t.edge_waypoints
|
||||
? Object.fromEntries(
|
||||
Object.entries(t.edge_waypoints).filter(([k]) => k !== taskName),
|
||||
)
|
||||
: undefined;
|
||||
const updatedLabelPos = t.label_positions
|
||||
? Object.fromEntries(
|
||||
Object.entries(t.label_positions).filter(([k]) => k !== taskName),
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
...t,
|
||||
do: newDo.length > 0 ? newDo : undefined,
|
||||
edge_waypoints:
|
||||
updatedWaypoints && Object.keys(updatedWaypoints).length > 0
|
||||
? updatedWaypoints
|
||||
: undefined,
|
||||
label_positions:
|
||||
updatedLabelPos && Object.keys(updatedLabelPos).length > 0
|
||||
? updatedLabelPos
|
||||
: undefined,
|
||||
};
|
||||
})
|
||||
// Keep transitions that still have `do` targets or `publish` directives
|
||||
.filter(
|
||||
@@ -723,12 +795,36 @@ export function renameTaskInTransitions(
|
||||
|
||||
let changed = false;
|
||||
const updated = next.map((t) => {
|
||||
if (!t.do || !t.do.includes(oldName)) return t;
|
||||
const hasDo = t.do && t.do.includes(oldName);
|
||||
const hasWaypoint = t.edge_waypoints && oldName in t.edge_waypoints;
|
||||
const hasLabelPos = t.label_positions && oldName in t.label_positions;
|
||||
|
||||
if (!hasDo && !hasWaypoint && !hasLabelPos) return t;
|
||||
changed = true;
|
||||
return {
|
||||
...t,
|
||||
do: t.do.map((name) => (name === oldName ? newName : name)),
|
||||
};
|
||||
|
||||
const result = { ...t };
|
||||
|
||||
if (hasDo) {
|
||||
result.do = t.do!.map((name) => (name === oldName ? newName : name));
|
||||
}
|
||||
|
||||
if (hasWaypoint && t.edge_waypoints) {
|
||||
const entries = Object.entries(t.edge_waypoints).map(([k, v]) => [
|
||||
k === oldName ? newName : k,
|
||||
v,
|
||||
]);
|
||||
result.edge_waypoints = Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
if (hasLabelPos && t.label_positions) {
|
||||
const entries = Object.entries(t.label_positions).map(([k, v]) => [
|
||||
k === oldName ? newName : k,
|
||||
v,
|
||||
]);
|
||||
result.label_positions = Object.fromEntries(entries);
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
return changed ? updated : next;
|
||||
|
||||
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