This commit is contained in:
2026-02-24 09:28:39 -06:00
parent 4c81ba1de8
commit 7d942f5dca
7 changed files with 1433 additions and 147 deletions

View File

@@ -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 */}

View File

@@ -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 12 populated inputs
const showInlineInputs = populatedCount > 0 && populatedCount <= 2;
// Build tooltip lines
const tooltipLines: string[] = [];
if (populatedCount > 0) {
tooltipLines.push(
`${populatedCount} input${populatedCount !== 1 ? "s" : ""} configured`,
);
// Show all input key-values in tooltip when > 2
if (populatedCount > 2) {
for (const [key, val] of populatedInputs) {
tooltipLines.push(` ${key}: ${formatValueTooltip(val)}`);
}
}
}
if (summary) {
tooltipLines.push(summary);
}
if (customTransitionCount > 0) {
tooltipLines.push(
`${customTransitionCount} custom transition${customTransitionCount !== 1 ? "s" : ""}`,
);
}
if (task.delay) {
tooltipLines.push(`Delay: ${task.delay}s`);
}
if (task.with_items) {
tooltipLines.push("with_items iteration");
}
if (task.retry) {
tooltipLines.push(`Retry: ${task.retry.count}×`);
}
if (task.timeout) {
tooltipLines.push(`Timeout: ${task.timeout}s`);
}
const hasTooltipContent = tooltipLines.length > 0;
return (
<div
ref={nodeRef}
@@ -243,7 +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 (12 populated) */}
{showInlineInputs && (
<div className="mt-1.5 space-y-0.5">
{populatedInputs.map(([key, val]) => (
<div
key={key}
className="flex items-baseline gap-1 text-[10px] leading-tight"
>
<span className="text-gray-400 font-medium shrink-0">
{key}:
</span>
<span className="text-gray-600 truncate font-mono">
{formatValueShort(val)}
</span>
</div>
))}
</div>
)}
{/* Transition summary */}
{summary && (
<div className="mt-1 text-[10px] text-gray-400">{summary}</div>
)}
{/* Delay badge */}
{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>

View File

@@ -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

View File

@@ -34,6 +34,10 @@ export interface TaskTransition {
label?: string;
/** Custom color for the transition edge (CSS color string, e.g., "#ff6600") */
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 (01) along the edge path */
label_positions?: Record<string, number>;
}
/** A task node in the workflow builder */
@@ -144,6 +148,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 +158,10 @@ export interface WorkflowEdge {
transitionIndex: number;
/** Custom color override for the edge (CSS color string) */
color?: string;
/** Intermediate waypoints for this specific edge */
waypoints?: NodePosition[];
/** Label position as t-parameter (01) along the edge path; default 0.5 */
labelPosition?: number;
}
/** Complete workflow builder state */
@@ -210,6 +220,10 @@ export interface TransitionChartMeta {
label?: string;
/** Custom color for the transition edge (CSS 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 (01) along the edge path */
label_positions?: Record<string, number>;
}
/** Transition as represented in YAML format */
@@ -383,11 +397,19 @@ 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/waypoints in __chart_meta__ to avoid polluting the transition namespace
const hasChartMeta =
t.label || t.color || 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.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 +551,8 @@ export function definitionToBuilderState(
do: t.do,
label: t.__chart_meta__?.label,
color: t.__chart_meta__?.color,
edge_waypoints: t.__chart_meta__?.edge_waypoints,
label_positions: t.__chart_meta__?.label_positions,
}));
} else {
const converted = legacyTransitionsToNext(task);
@@ -607,10 +631,13 @@ export function deriveEdges(tasks: WorkflowTask[]): WorkflowEdge[] {
edges.push({
from: task.id,
to: targetId,
toName: targetName,
type: edgeType,
label,
transitionIndex: ti,
color: transition.color,
waypoints: transition.edge_waypoints?.[targetName],
labelPosition: transition.label_positions?.[targetName],
});
}
}
@@ -698,7 +725,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 +772,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;