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