splines!
This commit is contained in:
@@ -432,7 +432,9 @@ export default function TaskInspector({
|
||||
className={`border rounded-lg bg-gray-50 overflow-hidden transition-all duration-300 ${
|
||||
isFlashing
|
||||
? "border-blue-400 ring-2 ring-blue-300 shadow-md shadow-blue-100 animate-[flash-highlight_1.5s_ease-out]"
|
||||
: "border-gray-200"
|
||||
: highlightTransitionIndex === ti
|
||||
? "border-blue-400 ring-1 ring-blue-200 bg-blue-50/40"
|
||||
: "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* Transition header */}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useRef, useState } from "react";
|
||||
import { Trash2, GripVertical, Play, Octagon } from "lucide-react";
|
||||
import { Trash2, GripVertical, Play, Octagon, Info } from "lucide-react";
|
||||
import type { WorkflowTask, TransitionPreset } from "@/types/workflow";
|
||||
import {
|
||||
PRESET_LABELS,
|
||||
@@ -75,7 +75,7 @@ function hasActiveTransition(
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute a short summary of outgoing transitions for the node body.
|
||||
* Compute a short summary of outgoing transitions for the tooltip.
|
||||
*/
|
||||
function transitionSummary(task: WorkflowTask): string | null {
|
||||
if (!task.next || task.next.length === 0) return null;
|
||||
@@ -93,6 +93,68 @@ function transitionSummary(task: WorkflowTask): string | null {
|
||||
return `${totalTargets} target${totalTargets !== 1 ? "s" : ""} via ${task.next.length} transition${task.next.length !== 1 ? "s" : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a value is "populated" (non-null, non-undefined, non-empty-string).
|
||||
*/
|
||||
function hasValue(value: unknown): boolean {
|
||||
if (value === null || value === undefined) return false;
|
||||
if (typeof value === "string" && value.trim() === "") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entries from task.input that actually have values.
|
||||
*/
|
||||
function getPopulatedInputs(
|
||||
input: Record<string, unknown>,
|
||||
): [string, unknown][] {
|
||||
return Object.entries(input).filter(([, v]) => hasValue(v));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for inline display on the card — keep it short.
|
||||
*/
|
||||
function formatValueShort(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
if (value.length > 28) return value.slice(0, 25) + "…";
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.length} items]`;
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
return `{${Object.keys(value).length} keys}`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value for the tooltip — can be slightly longer.
|
||||
*/
|
||||
function formatValueTooltip(value: unknown): string {
|
||||
if (typeof value === "string") {
|
||||
if (value.length > 40) return value.slice(0, 37) + "…";
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.length} items]`;
|
||||
}
|
||||
if (typeof value === "object" && value !== null) {
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length <= 2) {
|
||||
return `{${keys.join(", ")}}`;
|
||||
}
|
||||
return `{${keys.length} keys}`;
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function TaskNodeInner({
|
||||
task,
|
||||
isSelected,
|
||||
@@ -109,6 +171,8 @@ function TaskNodeInner({
|
||||
const [hoveredHandle, setHoveredHandle] = useState<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,6 +183,7 @@ function TaskNodeInner({
|
||||
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
setShowTooltip(false);
|
||||
dragOffset.current = {
|
||||
x: e.clientX - task.position.x,
|
||||
y: e.clientY - task.position.y,
|
||||
@@ -174,6 +239,19 @@ function TaskNodeInner({
|
||||
[task.id, onStartConnection],
|
||||
);
|
||||
|
||||
const handleBodyMouseEnter = useCallback(() => {
|
||||
if (isDragging) return;
|
||||
tooltipTimeout.current = setTimeout(() => setShowTooltip(true), 400);
|
||||
}, [isDragging]);
|
||||
|
||||
const handleBodyMouseLeave = useCallback(() => {
|
||||
if (tooltipTimeout.current) {
|
||||
clearTimeout(tooltipTimeout.current);
|
||||
tooltipTimeout.current = null;
|
||||
}
|
||||
setShowTooltip(false);
|
||||
}, []);
|
||||
|
||||
const isConnectionTarget = connectingFrom !== null;
|
||||
|
||||
const borderColor = isSelected
|
||||
@@ -197,6 +275,49 @@ function TaskNodeInner({
|
||||
return ct === "custom";
|
||||
}).length;
|
||||
|
||||
// Inputs that actually have values
|
||||
const populatedInputs = getPopulatedInputs(task.input);
|
||||
const populatedCount = populatedInputs.length;
|
||||
|
||||
// Show inline if 1–2 populated inputs
|
||||
const showInlineInputs = populatedCount > 0 && populatedCount <= 2;
|
||||
|
||||
// Build tooltip lines
|
||||
const tooltipLines: string[] = [];
|
||||
if (populatedCount > 0) {
|
||||
tooltipLines.push(
|
||||
`${populatedCount} input${populatedCount !== 1 ? "s" : ""} configured`,
|
||||
);
|
||||
// Show all input key-values in tooltip when > 2
|
||||
if (populatedCount > 2) {
|
||||
for (const [key, val] of populatedInputs) {
|
||||
tooltipLines.push(` ${key}: ${formatValueTooltip(val)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (summary) {
|
||||
tooltipLines.push(summary);
|
||||
}
|
||||
if (customTransitionCount > 0) {
|
||||
tooltipLines.push(
|
||||
`${customTransitionCount} custom transition${customTransitionCount !== 1 ? "s" : ""}`,
|
||||
);
|
||||
}
|
||||
if (task.delay) {
|
||||
tooltipLines.push(`Delay: ${task.delay}s`);
|
||||
}
|
||||
if (task.with_items) {
|
||||
tooltipLines.push("with_items iteration");
|
||||
}
|
||||
if (task.retry) {
|
||||
tooltipLines.push(`Retry: ${task.retry.count}×`);
|
||||
}
|
||||
if (task.timeout) {
|
||||
tooltipLines.push(`Timeout: ${task.timeout}s`);
|
||||
}
|
||||
|
||||
const hasTooltipContent = tooltipLines.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={nodeRef}
|
||||
@@ -243,7 +364,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 +379,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 +419,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>
|
||||
|
||||
@@ -2,8 +2,12 @@ import { useState, useCallback, useRef, useMemo } from "react";
|
||||
import TaskNode from "./TaskNode";
|
||||
import type { TransitionPreset } from "./TaskNode";
|
||||
import WorkflowEdges from "./WorkflowEdges";
|
||||
import type { EdgeHoverInfo } from "./WorkflowEdges";
|
||||
import type { WorkflowTask, WorkflowEdge } from "@/types/workflow";
|
||||
import type { EdgeHoverInfo, SelectedEdgeInfo } from "./WorkflowEdges";
|
||||
import type {
|
||||
WorkflowTask,
|
||||
WorkflowEdge,
|
||||
NodePosition,
|
||||
} from "@/types/workflow";
|
||||
import {
|
||||
deriveEdges,
|
||||
generateUniqueTaskName,
|
||||
@@ -55,6 +59,10 @@ export default function WorkflowCanvas({
|
||||
y: number;
|
||||
} | null>(null);
|
||||
|
||||
const [selectedEdge, setSelectedEdge] = useState<SelectedEdgeInfo | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const allTaskNames = useMemo(() => tasks.map((t) => t.name), [tasks]);
|
||||
|
||||
const edges: WorkflowEdge[] = useMemo(() => deriveEdges(tasks), [tasks]);
|
||||
@@ -73,10 +81,12 @@ export default function WorkflowCanvas({
|
||||
setMousePosition(null);
|
||||
} else {
|
||||
onSelectTask(null);
|
||||
setSelectedEdge(null);
|
||||
onEdgeClick?.(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onSelectTask, connectingFrom],
|
||||
[onSelectTask, onEdgeClick, connectingFrom],
|
||||
);
|
||||
|
||||
const handleCanvasMouseMove = useCallback(
|
||||
@@ -113,8 +123,102 @@ export default function WorkflowCanvas({
|
||||
const handleStartConnection = useCallback(
|
||||
(taskId: string, preset: TransitionPreset) => {
|
||||
setConnectingFrom({ taskId, preset });
|
||||
setSelectedEdge(null);
|
||||
onEdgeClick?.(null);
|
||||
},
|
||||
[],
|
||||
[onEdgeClick],
|
||||
);
|
||||
|
||||
/** Handle edge click: select the edge and propagate to parent */
|
||||
const handleEdgeClick = useCallback(
|
||||
(info: EdgeHoverInfo | null) => {
|
||||
if (info) {
|
||||
setSelectedEdge({
|
||||
from: info.taskId,
|
||||
to: info.targetTaskId,
|
||||
transitionIndex: info.transitionIndex,
|
||||
});
|
||||
} else {
|
||||
setSelectedEdge(null);
|
||||
}
|
||||
onEdgeClick?.(info);
|
||||
},
|
||||
[onEdgeClick],
|
||||
);
|
||||
|
||||
/** Handle selecting a task (also clears edge selection) */
|
||||
const handleSelectTask = useCallback(
|
||||
(taskId: string | null) => {
|
||||
onSelectTask(taskId);
|
||||
if (taskId !== null) {
|
||||
// Keep selected edge if the task being selected is part of it
|
||||
// (i.e. user clicked the source task of the edge via edge click)
|
||||
// Otherwise clear it
|
||||
if (selectedEdge && selectedEdge.from !== taskId) {
|
||||
setSelectedEdge(null);
|
||||
onEdgeClick?.(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
[onSelectTask, onEdgeClick, selectedEdge],
|
||||
);
|
||||
|
||||
/** Update waypoints for a specific edge */
|
||||
const handleWaypointUpdate = useCallback(
|
||||
(
|
||||
fromTaskId: string,
|
||||
transitionIndex: number,
|
||||
targetTaskName: string,
|
||||
waypoints: NodePosition[],
|
||||
) => {
|
||||
const task = tasks.find((t) => t.id === fromTaskId);
|
||||
if (!task || !task.next || transitionIndex >= task.next.length) return;
|
||||
|
||||
const updatedNext = [...task.next];
|
||||
const transition = { ...updatedNext[transitionIndex] };
|
||||
const edgeWaypoints = { ...(transition.edge_waypoints || {}) };
|
||||
|
||||
if (waypoints.length > 0) {
|
||||
edgeWaypoints[targetTaskName] = waypoints;
|
||||
} else {
|
||||
delete edgeWaypoints[targetTaskName];
|
||||
}
|
||||
|
||||
transition.edge_waypoints =
|
||||
Object.keys(edgeWaypoints).length > 0 ? edgeWaypoints : undefined;
|
||||
updatedNext[transitionIndex] = transition;
|
||||
onUpdateTask(fromTaskId, { next: updatedNext });
|
||||
},
|
||||
[tasks, onUpdateTask],
|
||||
);
|
||||
|
||||
/** Update label position for a specific edge */
|
||||
const handleLabelPositionUpdate = useCallback(
|
||||
(
|
||||
fromTaskId: string,
|
||||
transitionIndex: number,
|
||||
targetTaskName: string,
|
||||
position: number | undefined,
|
||||
) => {
|
||||
const task = tasks.find((t) => t.id === fromTaskId);
|
||||
if (!task || !task.next || transitionIndex >= task.next.length) return;
|
||||
|
||||
const updatedNext = [...task.next];
|
||||
const transition = { ...updatedNext[transitionIndex] };
|
||||
const labelPositions = { ...(transition.label_positions || {}) };
|
||||
|
||||
if (position) {
|
||||
labelPositions[targetTaskName] = position;
|
||||
} else {
|
||||
delete labelPositions[targetTaskName];
|
||||
}
|
||||
|
||||
transition.label_positions =
|
||||
Object.keys(labelPositions).length > 0 ? labelPositions : undefined;
|
||||
updatedNext[transitionIndex] = transition;
|
||||
onUpdateTask(fromTaskId, { next: updatedNext });
|
||||
},
|
||||
[tasks, onUpdateTask],
|
||||
);
|
||||
|
||||
const handleCompleteConnection = useCallback(
|
||||
@@ -211,7 +315,10 @@ export default function WorkflowCanvas({
|
||||
tasks={tasks}
|
||||
connectingFrom={connectingFrom}
|
||||
mousePosition={mousePosition}
|
||||
onEdgeClick={onEdgeClick}
|
||||
onEdgeClick={handleEdgeClick}
|
||||
selectedEdge={selectedEdge}
|
||||
onWaypointUpdate={handleWaypointUpdate}
|
||||
onLabelPositionUpdate={handleLabelPositionUpdate}
|
||||
/>
|
||||
|
||||
{/* Task nodes */}
|
||||
@@ -222,7 +329,7 @@ export default function WorkflowCanvas({
|
||||
isSelected={task.id === selectedTaskId}
|
||||
isStartNode={startingTaskIds.has(task.id)}
|
||||
allTaskNames={allTaskNames}
|
||||
onSelect={onSelectTask}
|
||||
onSelect={handleSelectTask}
|
||||
onDelete={onDeleteTask}
|
||||
onPositionChange={handlePositionChange}
|
||||
onStartConnection={handleStartConnection}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user