workflow builder, first edition

This commit is contained in:
2026-02-23 22:51:49 -06:00
parent 53a3fbb6b1
commit 4c81ba1de8
7 changed files with 583 additions and 359 deletions

View File

@@ -68,6 +68,12 @@ interface ParamSchemaFormProps {
* at enforcement time rather than set to literal values. * at enforcement time rather than set to literal values.
*/ */
allowTemplates?: boolean; allowTemplates?: boolean;
/**
* When true, hides the amber "Template expressions" info banner at the top
* while still enabling template-mode inputs. Useful when template syntax is
* supported but the contextual hint (rule-specific namespaces) doesn't apply.
*/
hideTemplateHint?: boolean;
} }
/** /**
@@ -140,6 +146,7 @@ export default function ParamSchemaForm({
disabled = false, disabled = false,
className = "", className = "",
allowTemplates = false, allowTemplates = false,
hideTemplateHint = false,
}: ParamSchemaFormProps) { }: ParamSchemaFormProps) {
const [localErrors, setLocalErrors] = useState<Record<string, string>>({}); const [localErrors, setLocalErrors] = useState<Record<string, string>>({});
@@ -450,7 +457,7 @@ export default function ParamSchemaForm({
return ( return (
<div className={`space-y-4 ${className}`}> <div className={`space-y-4 ${className}`}>
{allowTemplates && ( {allowTemplates && !hideTemplateHint && (
<div className="px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg"> <div className="px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg">
<p className="text-xs text-amber-800"> <p className="text-xs text-amber-800">
<span className="font-semibold">Template expressions</span> are <span className="font-semibold">Template expressions</span> are

View File

@@ -8,6 +8,7 @@ import {
GripVertical, GripVertical,
ArrowRight, ArrowRight,
Palette, Palette,
Loader2,
} from "lucide-react"; } from "lucide-react";
import type { import type {
WorkflowTask, WorkflowTask,
@@ -19,6 +20,7 @@ import type {
import { import {
PRESET_WHEN, PRESET_WHEN,
PRESET_LABELS, PRESET_LABELS,
PRESET_COLORS,
classifyTransitionWhen, classifyTransitionWhen,
transitionLabel, transitionLabel,
} from "@/types/workflow"; } from "@/types/workflow";
@@ -26,6 +28,7 @@ import ParamSchemaForm, {
extractProperties, extractProperties,
type ParamSchema, type ParamSchema,
} from "@/components/common/ParamSchemaForm"; } from "@/components/common/ParamSchemaForm";
import { useAction } from "@/hooks/useActions";
/** Preset color swatches for quick transition color selection */ /** Preset color swatches for quick transition color selection */
const TRANSITION_COLOR_SWATCHES = [ const TRANSITION_COLOR_SWATCHES = [
@@ -67,6 +70,10 @@ export default function TaskInspector({
onClose, onClose,
highlightTransitionIndex, highlightTransitionIndex,
}: TaskInspectorProps) { }: TaskInspectorProps) {
// Fetch full action details (including param_schema) on demand
const { data: actionDetail, isLoading: actionLoading } = useAction(
task.action || "",
);
const transitionRefs = useRef<Map<number, HTMLDivElement>>(new Map()); const transitionRefs = useRef<Map<number, HTMLDivElement>>(new Map());
const [flashIndex, setFlashIndex] = useState<number | null>(null); const [flashIndex, setFlashIndex] = useState<number | null>(null);
const [expandedSections, setExpandedSections] = useState<Set<string>>( const [expandedSections, setExpandedSections] = useState<Set<string>>(
@@ -187,6 +194,8 @@ export default function TaskInspector({
if (preset) { if (preset) {
const whenExpr = PRESET_WHEN[preset]; const whenExpr = PRESET_WHEN[preset];
if (whenExpr) newTransition.when = whenExpr; if (whenExpr) newTransition.when = whenExpr;
newTransition.label = PRESET_LABELS[preset];
newTransition.color = PRESET_COLORS[preset];
} }
next.push(newTransition); next.push(newTransition);
update({ next }); update({ next });
@@ -263,11 +272,12 @@ export default function TaskInspector({
[task.next, update], [task.next, update],
); );
// Get the selected action's param schema // Get the selected action's param schema from fetched action detail
const selectedAction = availableActions.find((a) => a.ref === task.action); const selectedAction = availableActions.find((a) => a.ref === task.action);
const fetchedAction = actionDetail?.data;
const actionParamSchema: ParamSchema = useMemo( const actionParamSchema: ParamSchema = useMemo(
() => (selectedAction?.param_schema as ParamSchema) || {}, () => (fetchedAction?.param_schema as ParamSchema) || {},
[selectedAction?.param_schema], [fetchedAction?.param_schema],
); );
const schemaProperties = useMemo( const schemaProperties = useMemo(
() => extractProperties(actionParamSchema), () => extractProperties(actionParamSchema),
@@ -275,30 +285,6 @@ export default function TaskInspector({
); );
const hasSchema = Object.keys(schemaProperties).length > 0; const hasSchema = Object.keys(schemaProperties).length > 0;
// Separate task inputs into schema-driven and extra custom params
const schemaKeys = useMemo(
() => new Set(Object.keys(schemaProperties)),
[schemaProperties],
);
const schemaValues = useMemo(() => {
const vals: Record<string, unknown> = {};
for (const key of schemaKeys) {
if (task.input[key] !== undefined) {
vals[key] = task.input[key];
}
}
return vals;
}, [task.input, schemaKeys]);
const extraParams = useMemo(() => {
const extras: Record<string, unknown> = {};
for (const [key, value] of Object.entries(task.input)) {
if (!schemaKeys.has(key)) {
extras[key] = value;
}
}
return extras;
}, [task.input, schemaKeys]);
const otherTaskNames = allTaskNames.filter((n) => n !== task.name); const otherTaskNames = allTaskNames.filter((n) => n !== task.name);
return ( return (
@@ -375,14 +361,20 @@ export default function TaskInspector({
</option> </option>
))} ))}
</select> </select>
{selectedAction?.description && ( {(fetchedAction?.description || selectedAction?.description) && (
<p className="text-[10px] text-gray-400 mt-1"> <p className="text-[10px] text-gray-400 mt-1">
{selectedAction.description} {fetchedAction?.description || selectedAction?.description}
</p> </p>
)} )}
</div> </div>
{/* Input Parameters — schema-driven form */} {/* Input Parameters — schema-driven form */}
{task.action && actionLoading && (
<div className="flex items-center gap-2 text-xs text-gray-400 py-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
Loading parameters
</div>
)}
{hasSchema && ( {hasSchema && (
<div> <div>
<label className="block text-xs font-medium text-gray-700 mb-1.5"> <label className="block text-xs font-medium text-gray-700 mb-1.5">
@@ -390,130 +382,21 @@ export default function TaskInspector({
</label> </label>
<ParamSchemaForm <ParamSchemaForm
schema={actionParamSchema} schema={actionParamSchema}
values={schemaValues} values={task.input}
onChange={(newValues) => { onChange={(newValues) => {
// Merge schema-driven values back with extra params update({ input: newValues });
update({ input: { ...newValues, ...extraParams } });
}} }}
allowTemplates allowTemplates
hideTemplateHint
className="text-xs" className="text-xs"
/> />
</div> </div>
)} )}
{task.action && !actionLoading && !hasSchema && (
{/* Extra custom parameters (not in schema) */} <p className="text-[10px] text-gray-400 italic">
{hasSchema && Object.keys(extraParams).length > 0 && ( This action has no declared parameters.
<div> </p>
<label className="block text-xs font-medium text-gray-700 mb-1">
Custom Parameters
</label>
<div className="space-y-2">
{Object.entries(extraParams).map(([key, value]) => (
<div
key={key}
className="border border-gray-200 rounded p-2 bg-gray-50"
>
<div className="flex items-center justify-between mb-1">
<span className="font-mono text-xs font-medium text-gray-800">
{key}
</span>
<button
onClick={() => {
const newInput = { ...task.input };
delete newInput[key];
update({ input: newInput });
}}
className="p-0.5 text-gray-400 hover:text-red-500"
title="Remove parameter"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
<input
type="text"
value={
typeof value === "string"
? value
: JSON.stringify(value ?? "")
}
onChange={(e) =>
update({
input: { ...task.input, [key]: e.target.value },
})
}
className="w-full px-2 py-1 border border-gray-300 rounded text-xs font-mono focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
placeholder="{{ parameters.value }} or literal"
/>
</div>
))}
</div>
</div>
)} )}
{/* No schema — free-form input editing */}
{!hasSchema && task.action && (
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Input Parameters
</label>
<div className="space-y-2">
{Object.entries(task.input).map(([key, value]) => (
<div
key={key}
className="border border-gray-200 rounded p-2 bg-gray-50"
>
<div className="flex items-center justify-between mb-1">
<span className="font-mono text-xs font-medium text-gray-800">
{key}
</span>
<button
onClick={() => {
const newInput = { ...task.input };
delete newInput[key];
update({ input: newInput });
}}
className="p-0.5 text-gray-400 hover:text-red-500"
title="Remove parameter"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
<input
type="text"
value={
typeof value === "string"
? value
: JSON.stringify(value ?? "")
}
onChange={(e) =>
update({
input: { ...task.input, [key]: e.target.value },
})
}
className="w-full px-2 py-1 border border-gray-300 rounded text-xs font-mono focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
placeholder="{{ parameters.value }} or literal"
/>
</div>
))}
</div>
</div>
)}
{/* Add custom parameter button */}
<button
onClick={() => {
const key = prompt("Parameter name:");
if (key && key.trim()) {
update({
input: { ...task.input, [key.trim()]: "" },
});
}
}}
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
>
<Plus className="w-3 h-3" />
Add custom parameter
</button>
</div> </div>
</CollapsibleSection> </CollapsibleSection>
@@ -850,48 +733,50 @@ export default function TaskInspector({
</p> </p>
</div> </div>
{task.with_items && ( <div>
<> <label
<div> className={`block text-xs font-medium mb-1 ${localWithItems ? "text-gray-700" : "text-gray-400"}`}
<label className="block text-xs font-medium text-gray-700 mb-1"> >
Batch Size Batch Size
</label> </label>
<input <input
type="number" type="number"
value={task.batch_size || ""} value={task.batch_size || ""}
onChange={(e) => disabled={!localWithItems}
update({ onChange={(e) =>
batch_size: e.target.value update({
? parseInt(e.target.value) batch_size: e.target.value
: undefined, ? parseInt(e.target.value)
}) : undefined,
} })
className="w-full px-2.5 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" }
placeholder="Process all at once" className="w-full px-2.5 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed"
min={1} placeholder="Process all at once"
/> min={1}
</div> />
<div> </div>
<label className="block text-xs font-medium text-gray-700 mb-1"> <div>
Concurrency <label
</label> className={`block text-xs font-medium mb-1 ${localWithItems ? "text-gray-700" : "text-gray-400"}`}
<input >
type="number" Concurrency
value={task.concurrency || ""} </label>
onChange={(e) => <input
update({ type="number"
concurrency: e.target.value value={task.concurrency || ""}
? parseInt(e.target.value) disabled={!localWithItems}
: undefined, onChange={(e) =>
}) update({
} concurrency: e.target.value
className="w-full px-2.5 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" ? parseInt(e.target.value)
placeholder="No limit" : undefined,
min={1} })
/> }
</div> className="w-full px-2.5 py-1.5 border border-gray-300 rounded text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed"
</> placeholder="No limit"
)} min={1}
/>
</div>
</div> </div>
</CollapsibleSection> </CollapsibleSection>

View File

@@ -1,5 +1,5 @@
import { memo, useCallback, useRef, useState } from "react"; import { memo, useCallback, useRef, useState } from "react";
import { Trash2, Settings, GripVertical } from "lucide-react"; import { Trash2, GripVertical, Play, Octagon } from "lucide-react";
import type { WorkflowTask, TransitionPreset } from "@/types/workflow"; import type { WorkflowTask, TransitionPreset } from "@/types/workflow";
import { import {
PRESET_LABELS, PRESET_LABELS,
@@ -12,6 +12,7 @@ export type { TransitionPreset };
interface TaskNodeProps { interface TaskNodeProps {
task: WorkflowTask; task: WorkflowTask;
isSelected: boolean; isSelected: boolean;
isStartNode: boolean;
allTaskNames: string[]; allTaskNames: string[];
onSelect: (taskId: string) => void; onSelect: (taskId: string) => void;
onDelete: (taskId: string) => void; onDelete: (taskId: string) => void;
@@ -95,6 +96,7 @@ function transitionSummary(task: WorkflowTask): string | null {
function TaskNodeInner({ function TaskNodeInner({
task, task,
isSelected, isSelected,
isStartNode,
onSelect, onSelect,
onDelete, onDelete,
onPositionChange, onPositionChange,
@@ -107,7 +109,6 @@ function TaskNodeInner({
const [hoveredHandle, setHoveredHandle] = useState<TransitionPreset | null>( const [hoveredHandle, setHoveredHandle] = useState<TransitionPreset | null>(
null, null,
); );
const [isInputHandleHovered, setIsInputHandleHovered] = useState(false);
const dragOffset = useRef({ x: 0, y: 0 }); const dragOffset = useRef({ x: 0, y: 0 });
const handleMouseDown = useCallback( const handleMouseDown = useCallback(
@@ -147,9 +148,9 @@ function TaskNodeInner({
const handleClick = useCallback( const handleClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
if (connectingFrom && connectingFrom.taskId !== task.id) { if (connectingFrom) {
onCompleteConnection(task.id); onCompleteConnection(task.id);
} else if (!connectingFrom) { } else {
onSelect(task.id); onSelect(task.id);
} }
}, },
@@ -173,18 +174,7 @@ function TaskNodeInner({
[task.id, onStartConnection], [task.id, onStartConnection],
); );
const handleInputHandleMouseUp = useCallback( const isConnectionTarget = connectingFrom !== null;
(e: React.MouseEvent) => {
e.stopPropagation();
if (connectingFrom && connectingFrom.taskId !== task.id) {
onCompleteConnection(task.id);
}
},
[task.id, connectingFrom, onCompleteConnection],
);
const isConnectionTarget =
connectingFrom !== null && connectingFrom.taskId !== task.id;
const borderColor = isSelected const borderColor = isSelected
? "border-blue-500 ring-2 ring-blue-200" ? "border-blue-500 ring-2 ring-blue-200"
@@ -195,6 +185,12 @@ function TaskNodeInner({
const hasAction = task.action && task.action.length > 0; const hasAction = task.action && task.action.length > 0;
const summary = transitionSummary(task); const summary = transitionSummary(task);
// A stop node has no outgoing transitions to other tasks
const isStopNode =
!task.next ||
task.next.length === 0 ||
task.next.every((t) => !t.do || t.do.length === 0);
// Count custom transitions (those not matching any preset) // Count custom transitions (those not matching any preset)
const customTransitionCount = (task.next || []).filter((t) => { const customTransitionCount = (task.next || []).filter((t) => {
const ct = classifyTransitionWhen(t.when); const ct = classifyTransitionWhen(t.when);
@@ -212,55 +208,35 @@ function TaskNodeInner({
}} }}
onMouseDown={handleMouseDown} onMouseDown={handleMouseDown}
onClick={handleClick} onClick={handleClick}
onMouseUp={(e) => {
if (connectingFrom) {
e.stopPropagation();
onCompleteConnection(task.id);
}
}}
> >
{/* Input handle (top center) — drop target */}
<div
data-handle
className="absolute left-1/2 -translate-x-1/2 -top-[7px] z-20"
onMouseUp={handleInputHandleMouseUp}
onMouseEnter={() => setIsInputHandleHovered(true)}
onMouseLeave={() => setIsInputHandleHovered(false)}
>
<div
className="transition-all duration-150 rounded-full border-2 border-white shadow-sm"
style={{
width:
isConnectionTarget && isInputHandleHovered
? 16
: isConnectionTarget
? 14
: 10,
height:
isConnectionTarget && isInputHandleHovered
? 16
: isConnectionTarget
? 14
: 10,
backgroundColor:
isConnectionTarget && isInputHandleHovered
? "#8b5cf6"
: isConnectionTarget
? "#a78bfa"
: "#9ca3af",
boxShadow:
isConnectionTarget && isInputHandleHovered
? "0 0 0 4px rgba(139, 92, 246, 0.3), 0 1px 3px rgba(0,0,0,0.2)"
: isConnectionTarget
? "0 0 0 3px rgba(167, 139, 250, 0.3), 0 1px 2px rgba(0,0,0,0.15)"
: "0 1px 2px rgba(0,0,0,0.1)",
cursor: isConnectionTarget ? "pointer" : "default",
}}
/>
</div>
<div <div
className={`bg-white rounded-lg border-2 shadow-sm transition-colors ${borderColor}`} className={`bg-white rounded-lg border-2 shadow-sm transition-colors ${borderColor}`}
> >
{/* Header */} {/* Header */}
<div className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-t-md bg-blue-500 bg-opacity-10 border-b border-gray-100"> <div
<GripVertical className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" /> className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-t-md border-b ${
isStartNode
? "bg-green-500 bg-opacity-15 border-green-200"
: "bg-blue-500 bg-opacity-10 border-gray-100"
}`}
>
{isStartNode ? (
<Play className="w-3.5 h-3.5 text-green-600 flex-shrink-0" />
) : (
<GripVertical className="w-3.5 h-3.5 text-gray-400 flex-shrink-0" />
)}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-semibold text-xs text-gray-900 truncate"> <div
className={`font-semibold text-xs truncate ${
isStartNode ? "text-green-900" : "text-gray-900"
}`}
>
{task.name} {task.name}
</div> </div>
</div> </div>
@@ -322,35 +298,40 @@ function TaskNodeInner({
</div> </div>
{/* Footer actions */} {/* Footer actions */}
<div className="flex items-center justify-end px-2 py-1.5 border-t border-gray-100 bg-gray-50 rounded-b-md"> <div
<div className="flex gap-1"> className={`flex items-center px-2 py-1.5 border-t rounded-b-md ${
<button isStopNode
data-action-button ? "border-red-200 bg-red-50"
onClick={(e) => { : "border-gray-100 bg-gray-50"
e.stopPropagation(); } ${isStopNode ? "justify-between" : "justify-end"}`}
onSelect(task.id); >
}} {isStopNode && (
className="p-1 rounded hover:bg-blue-100 text-gray-400 hover:text-blue-600 transition-colors" <div className="flex items-center gap-1">
title="Configure task" <Octagon
> className="w-3.5 h-3.5 text-red-500"
<Settings className="w-3 h-3" /> fill="currentColor"
</button> strokeWidth={0}
<button />
data-action-button <span className="text-[10px] font-medium text-red-600">Stop</span>
onClick={handleDelete} </div>
className="p-1 rounded hover:bg-red-100 text-gray-400 hover:text-red-600 transition-colors" )}
title="Delete task" <button
> data-action-button
<Trash2 className="w-3 h-3" /> onClick={handleDelete}
</button> className="p-1 rounded hover:bg-red-100 text-gray-400 hover:text-red-600 transition-colors"
</div> title="Delete task"
>
<Trash2 className="w-3 h-3" />
</button>
</div> </div>
{/* Connection target overlay */} {/* Connection target overlay */}
{isConnectionTarget && ( {isConnectionTarget && (
<div className="absolute inset-0 rounded-lg bg-purple-100 bg-opacity-20 pointer-events-none flex items-center justify-center"> <div className="absolute inset-0 rounded-lg bg-purple-100 bg-opacity-20 pointer-events-none flex items-center justify-center">
<div className="text-xs font-medium text-purple-600 bg-white px-2 py-1 rounded shadow-sm"> <div className="text-xs font-medium text-purple-600 bg-white px-2 py-1 rounded shadow-sm">
Drop to connect {connectingFrom?.taskId === task.id
? "Drop to self-loop"
: "Drop to connect"}
</div> </div>
</div> </div>
)} )}

View File

@@ -3,15 +3,12 @@ import TaskNode from "./TaskNode";
import type { TransitionPreset } from "./TaskNode"; import type { TransitionPreset } from "./TaskNode";
import WorkflowEdges from "./WorkflowEdges"; import WorkflowEdges from "./WorkflowEdges";
import type { EdgeHoverInfo } from "./WorkflowEdges"; import type { EdgeHoverInfo } from "./WorkflowEdges";
import type { import type { WorkflowTask, WorkflowEdge } from "@/types/workflow";
WorkflowTask,
PaletteAction,
WorkflowEdge,
} from "@/types/workflow";
import { import {
deriveEdges, deriveEdges,
generateUniqueTaskName, generateUniqueTaskName,
generateTaskId, generateTaskId,
findStartingTaskIds,
PRESET_LABELS, PRESET_LABELS,
} from "@/types/workflow"; } from "@/types/workflow";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
@@ -19,7 +16,6 @@ import { Plus } from "lucide-react";
interface WorkflowCanvasProps { interface WorkflowCanvasProps {
tasks: WorkflowTask[]; tasks: WorkflowTask[];
selectedTaskId: string | null; selectedTaskId: string | null;
availableActions: PaletteAction[];
onSelectTask: (taskId: string | null) => void; onSelectTask: (taskId: string | null) => void;
onUpdateTask: (taskId: string, updates: Partial<WorkflowTask>) => void; onUpdateTask: (taskId: string, updates: Partial<WorkflowTask>) => void;
onDeleteTask: (taskId: string) => void; onDeleteTask: (taskId: string) => void;
@@ -29,7 +25,7 @@ interface WorkflowCanvasProps {
preset: TransitionPreset, preset: TransitionPreset,
toTaskName: string, toTaskName: string,
) => void; ) => void;
onEdgeHover?: (info: EdgeHoverInfo | null) => void; onEdgeClick?: (info: EdgeHoverInfo | null) => void;
} }
/** Label color mapping for the connecting banner */ /** Label color mapping for the connecting banner */
@@ -47,7 +43,7 @@ export default function WorkflowCanvas({
onDeleteTask, onDeleteTask,
onAddTask, onAddTask,
onSetConnection, onSetConnection,
onEdgeHover, onEdgeClick,
}: WorkflowCanvasProps) { }: WorkflowCanvasProps) {
const canvasRef = useRef<HTMLDivElement>(null); const canvasRef = useRef<HTMLDivElement>(null);
const [connectingFrom, setConnectingFrom] = useState<{ const [connectingFrom, setConnectingFrom] = useState<{
@@ -63,6 +59,8 @@ export default function WorkflowCanvas({
const edges: WorkflowEdge[] = useMemo(() => deriveEdges(tasks), [tasks]); const edges: WorkflowEdge[] = useMemo(() => deriveEdges(tasks), [tasks]);
const startingTaskIds = useMemo(() => findStartingTaskIds(tasks), [tasks]);
const handleCanvasClick = useCallback( const handleCanvasClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
// Only deselect if clicking the canvas background // Only deselect if clicking the canvas background
@@ -213,7 +211,7 @@ export default function WorkflowCanvas({
tasks={tasks} tasks={tasks}
connectingFrom={connectingFrom} connectingFrom={connectingFrom}
mousePosition={mousePosition} mousePosition={mousePosition}
onEdgeHover={onEdgeHover} onEdgeClick={onEdgeClick}
/> />
{/* Task nodes */} {/* Task nodes */}
@@ -222,6 +220,7 @@ export default function WorkflowCanvas({
key={task.id} key={task.id}
task={task} task={task}
isSelected={task.id === selectedTaskId} isSelected={task.id === selectedTaskId}
isStartNode={startingTaskIds.has(task.id)}
allTaskNames={allTaskNames} allTaskNames={allTaskNames}
onSelect={onSelectTask} onSelect={onSelectTask}
onDelete={onDeleteTask} onDelete={onDeleteTask}

View File

@@ -1,5 +1,6 @@
import { memo, useMemo } from "react"; import { memo, useMemo } from "react";
import type { WorkflowEdge, WorkflowTask, EdgeType } from "@/types/workflow"; import type { WorkflowEdge, WorkflowTask, EdgeType } from "@/types/workflow";
import { PRESET_COLORS } from "@/types/workflow";
import type { TransitionPreset } from "./TaskNode"; import type { TransitionPreset } from "./TaskNode";
export interface EdgeHoverInfo { export interface EdgeHoverInfo {
@@ -18,8 +19,8 @@ interface WorkflowEdgesProps {
connectingFrom?: { taskId: string; preset: TransitionPreset } | null; connectingFrom?: { taskId: string; preset: TransitionPreset } | null;
/** Mouse position for drawing the preview connection line */ /** Mouse position for drawing the preview connection line */
mousePosition?: { x: number; y: number } | null; mousePosition?: { x: number; y: number } | null;
/** Called when the mouse enters/leaves an edge hit area */ /** Called when an edge is clicked */
onEdgeHover?: (info: EdgeHoverInfo | null) => void; onEdgeClick?: (info: EdgeHoverInfo | null) => void;
} }
const NODE_WIDTH = 240; const NODE_WIDTH = 240;
@@ -40,13 +41,6 @@ const EDGE_DASH: Record<EdgeType, string> = {
custom: "8,4,2,4", custom: "8,4,2,4",
}; };
/** Map presets to edge colors for the preview line */
const PRESET_COLORS: Record<TransitionPreset, string> = {
succeeded: EDGE_COLORS.success,
failed: EDGE_COLORS.failure,
always: EDGE_COLORS.complete,
};
/** Calculate the center-bottom of a task node */ /** Calculate the center-bottom of a task node */
function getNodeBottomCenter( function getNodeBottomCenter(
task: WorkflowTask, task: WorkflowTask,
@@ -89,14 +83,31 @@ function getNodeRightCenter(
/** /**
* Determine the best connection points between two nodes. * Determine the best connection points between two nodes.
* Returns the start and end points for the edge. * Returns the start and end points for the edge, plus whether this is a
* self-loop (from === to).
*/ */
function getBestConnectionPoints( function getBestConnectionPoints(
fromTask: WorkflowTask, fromTask: WorkflowTask,
toTask: WorkflowTask, toTask: WorkflowTask,
nodeWidth: number, nodeWidth: number,
nodeHeight: number, nodeHeight: number,
): { start: { x: number; y: number }; end: { x: number; y: number } } { ): {
start: { x: number; y: number };
end: { x: number; y: number };
selfLoop?: boolean;
} {
// Self-loop: start from right side, end at top-right area
if (fromTask.id === toTask.id) {
return {
start: getNodeRightCenter(fromTask, nodeWidth, nodeHeight),
end: {
x: fromTask.position.x + nodeWidth * 0.75,
y: fromTask.position.y,
},
selfLoop: true,
};
}
const fromCenter = { const fromCenter = {
x: fromTask.position.x + nodeWidth / 2, x: fromTask.position.x + nodeWidth / 2,
y: fromTask.position.y + nodeHeight / 2, y: fromTask.position.y + nodeHeight / 2,
@@ -117,10 +128,15 @@ function getBestConnectionPoints(
}; };
} }
// If the target is mostly above the source, use top→bottom // If the target is mostly above the source, use a side edge (never top).
// Pick the side closer to the target, and arrive at the target's bottom.
if (dy < 0 && Math.abs(dy) > Math.abs(dx) * 0.5) { if (dy < 0 && Math.abs(dy) > Math.abs(dx) * 0.5) {
const start =
dx >= 0
? getNodeRightCenter(fromTask, nodeWidth, nodeHeight)
: getNodeLeftCenter(fromTask, nodeHeight);
return { return {
start: getNodeTopCenter(fromTask, nodeWidth), start,
end: getNodeBottomCenter(toTask, nodeWidth, nodeHeight), end: getNodeBottomCenter(toTask, nodeWidth, nodeHeight),
}; };
} }
@@ -140,6 +156,21 @@ function getBestConnectionPoints(
}; };
} }
/**
* Build an SVG path for a self-loop: exits from the right side, arcs out
* to the right and upward, then curves back to the top of the node.
*/
function buildSelfLoopPath(
start: { x: number; y: number },
end: { x: number; y: number },
): string {
const loopOffset = 50;
// Control points: push out to the right and then curve up and back
const cp1 = { x: start.x + loopOffset, y: start.y - 20 };
const cp2 = { x: end.x + loopOffset, y: end.y - 40 };
return `M ${start.x} ${start.y} C ${cp1.x} ${cp1.y}, ${cp2.x} ${cp2.y}, ${end.x} ${end.y}`;
}
/** /**
* Build an SVG path string for a curved edge between two points. * Build an SVG path string for a curved edge between two points.
* Uses a cubic bezier curve. * Uses a cubic bezier curve.
@@ -179,7 +210,7 @@ function WorkflowEdgesInner({
nodeHeight = NODE_HEIGHT, nodeHeight = NODE_HEIGHT,
connectingFrom, connectingFrom,
mousePosition, mousePosition,
onEdgeHover, onEdgeClick,
}: WorkflowEdgesProps) { }: WorkflowEdgesProps) {
const taskMap = useMemo(() => { const taskMap = useMemo(() => {
const map = new Map<string, WorkflowTask>(); const map = new Map<string, WorkflowTask>();
@@ -211,21 +242,25 @@ function WorkflowEdgesInner({
const toTask = taskMap.get(edge.to); const toTask = taskMap.get(edge.to);
if (!fromTask || !toTask) return null; if (!fromTask || !toTask) return null;
const { start, end } = getBestConnectionPoints( const { start, end, selfLoop } = getBestConnectionPoints(
fromTask, fromTask,
toTask, toTask,
nodeWidth, nodeWidth,
nodeHeight, nodeHeight,
); );
const pathD = buildCurvePath(start, end); const pathD = selfLoop
? buildSelfLoopPath(start, end)
: buildCurvePath(start, end);
const color = const color =
edge.color || EDGE_COLORS[edge.type] || EDGE_COLORS.complete; edge.color || EDGE_COLORS[edge.type] || EDGE_COLORS.complete;
const dash = EDGE_DASH[edge.type] || ""; const dash = EDGE_DASH[edge.type] || "";
// Calculate label position (midpoint of curve) // Calculate label position — offset to the right for self-loops
const labelX = (start.x + end.x) / 2; const labelX = selfLoop ? start.x + 50 : (start.x + end.x) / 2;
const labelY = (start.y + end.y) / 2 - 8; const labelY = selfLoop
? (start.y + end.y) / 2 - 20
: (start.y + end.y) / 2 - 8;
// Measure approximate label width // Measure approximate label width
const labelText = edge.label || ""; const labelText = edge.label || "";
@@ -254,13 +289,12 @@ function WorkflowEdgesInner({
stroke="transparent" stroke="transparent"
strokeWidth={12} strokeWidth={12}
className="cursor-pointer" className="cursor-pointer"
onMouseEnter={() => onClick={() =>
onEdgeHover?.({ onEdgeClick?.({
taskId: edge.from, taskId: edge.from,
transitionIndex: edge.transitionIndex, transitionIndex: edge.transitionIndex,
}) })
} }
onMouseLeave={() => onEdgeHover?.(null)}
/> />
{/* Label */} {/* Label */}
{edge.label && ( {edge.label && (
@@ -295,7 +329,7 @@ function WorkflowEdgesInner({
); );
}) })
.filter(Boolean); .filter(Boolean);
}, [edges, taskMap, nodeWidth, nodeHeight, onEdgeHover]); }, [edges, taskMap, nodeWidth, nodeHeight, onEdgeClick]);
// Preview line when connecting // Preview line when connecting
const previewLine = useMemo(() => { const previewLine = useMemo(() => {

View File

@@ -1,5 +1,6 @@
import { useState, useCallback, useMemo, useRef } from "react"; import { useState, useCallback, useMemo, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useQueries } from "@tanstack/react-query";
import { import {
ArrowLeft, ArrowLeft,
Save, Save,
@@ -7,6 +8,7 @@ import {
FileCode, FileCode,
Code, Code,
LayoutDashboard, LayoutDashboard,
X,
} from "lucide-react"; } from "lucide-react";
import yaml from "js-yaml"; import yaml from "js-yaml";
import type { WorkflowYamlDefinition } from "@/types/workflow"; import type { WorkflowYamlDefinition } from "@/types/workflow";
@@ -15,6 +17,7 @@ import WorkflowCanvas from "@/components/workflows/WorkflowCanvas";
import type { EdgeHoverInfo } from "@/components/workflows/WorkflowEdges"; import type { EdgeHoverInfo } from "@/components/workflows/WorkflowEdges";
import TaskInspector from "@/components/workflows/TaskInspector"; import TaskInspector from "@/components/workflows/TaskInspector";
import { useActions } from "@/hooks/useActions"; import { useActions } from "@/hooks/useActions";
import { ActionsService } from "@/api";
import { usePacks } from "@/hooks/usePacks"; import { usePacks } from "@/hooks/usePacks";
import { useWorkflow } from "@/hooks/useWorkflows"; import { useWorkflow } from "@/hooks/useWorkflows";
import { import {
@@ -35,6 +38,8 @@ import {
validateWorkflow, validateWorkflow,
addTransitionTarget, addTransitionTarget,
removeTaskFromTransitions, removeTaskFromTransitions,
renameTaskInTransitions,
findStartingTaskIds,
} from "@/types/workflow"; } from "@/types/workflow";
const INITIAL_STATE: WorkflowBuilderState = { const INITIAL_STATE: WorkflowBuilderState = {
@@ -82,28 +87,20 @@ export default function WorkflowBuilderPage() {
taskId: string; taskId: string;
transitionIndex: number; transitionIndex: number;
} | null>(null); } | null>(null);
const highlightTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const handleEdgeHover = useCallback( // Start-node warning toast state
const [startWarningVisible, setStartWarningVisible] = useState(false);
const [startWarningDismissed, setStartWarningDismissed] = useState(false);
const [showSaveConfirm, setShowSaveConfirm] = useState(false);
const [prevWarningKey, setPrevWarningKey] = useState<string | null>(null);
const [justInitialized, setJustInitialized] = useState(false);
const handleEdgeClick = useCallback(
(info: EdgeHoverInfo | null) => { (info: EdgeHoverInfo | null) => {
// Clear any pending auto-clear timeout
if (highlightTimeoutRef.current) {
clearTimeout(highlightTimeoutRef.current);
highlightTimeoutRef.current = null;
}
if (info) { if (info) {
// Select the source task so TaskInspector opens for it // Select the source task so TaskInspector opens for it
setSelectedTaskId(info.taskId); setSelectedTaskId(info.taskId);
setHighlightedTransition(info); setHighlightedTransition(info);
// Auto-clear highlight after 2 seconds so the flash animation plays once
highlightTimeoutRef.current = setTimeout(() => {
setHighlightedTransition(null);
highlightTimeoutRef.current = null;
}, 2000);
} else { } else {
setHighlightedTransition(null); setHighlightedTransition(null);
} }
@@ -138,6 +135,7 @@ export default function WorkflowBuilderPage() {
); );
setState(builderState); setState(builderState);
setInitialized(true); setInitialized(true);
setJustInitialized(true);
} }
} }
@@ -149,8 +147,6 @@ export default function WorkflowBuilderPage() {
label: string; label: string;
description?: string; description?: string;
pack_ref: string; pack_ref: string;
param_schema?: Record<string, unknown> | null;
out_schema?: Record<string, unknown> | null;
}>; }>;
return actions.map((a) => ({ return actions.map((a) => ({
id: a.id, id: a.id,
@@ -158,19 +154,42 @@ export default function WorkflowBuilderPage() {
label: a.label, label: a.label,
description: a.description || "", description: a.description || "",
pack_ref: a.pack_ref, pack_ref: a.pack_ref,
param_schema: a.param_schema || null,
out_schema: a.out_schema || null,
})); }));
}, [actionsData]); }, [actionsData]);
// Build action schema map for stripping defaults during serialization // Fetch full action details for every unique action ref used in the workflow.
// React Query caches each response, so repeated refs don't cause extra requests.
const uniqueActionRefs = useMemo(() => {
const refs = new Set<string>();
for (const task of state.tasks) {
if (task.action) refs.add(task.action);
}
return [...refs];
}, [state.tasks]);
const actionDetailQueries = useQueries({
queries: uniqueActionRefs.map((ref) => ({
queryKey: ["actions", ref],
queryFn: () => ActionsService.getAction({ ref }),
staleTime: 30_000,
enabled: !!ref,
})),
});
// Build action schema map from individually-fetched action details
const actionSchemaMap = useMemo(() => { const actionSchemaMap = useMemo(() => {
const map = new Map<string, Record<string, unknown> | null>(); const map = new Map<string, Record<string, unknown> | null>();
for (const action of paletteActions) { for (let i = 0; i < uniqueActionRefs.length; i++) {
map.set(action.ref, action.param_schema); const query = actionDetailQueries[i];
if (query.data?.data) {
map.set(
uniqueActionRefs[i],
(query.data.data.param_schema as Record<string, unknown>) || null,
);
}
} }
return map; return map;
}, [paletteActions]); }, [uniqueActionRefs, actionDetailQueries]);
const packs = useMemo(() => { const packs = useMemo(() => {
return (packsData?.data || []) as Array<{ return (packsData?.data || []) as Array<{
@@ -190,6 +209,65 @@ export default function WorkflowBuilderPage() {
[state.tasks], [state.tasks],
); );
const startingTaskIds = useMemo(
() => findStartingTaskIds(state.tasks),
[state.tasks],
);
const startNodeWarning = useMemo(() => {
if (state.tasks.length === 0) return null;
const count = startingTaskIds.size;
if (count === 0)
return {
level: "error" as const,
message:
"No starting tasks found — every task is a target of another transition, so the workflow has no entry point.",
};
if (count > 1)
return {
level: "warn" as const,
message: `${count} starting tasks found (${state.tasks
.filter((t) => startingTaskIds.has(t.id))
.map((t) => `"${t.name}"`)
.join(", ")}). Workflows typically have a single entry point.`,
};
return null;
}, [state.tasks, startingTaskIds]);
// Render-phase state adjustment: detect warning key changes for immediate
// show/hide without refs or synchronous setState inside effects.
const warningKey = startNodeWarning
? `${startNodeWarning.level}:${startingTaskIds.size}`
: null;
if (warningKey !== prevWarningKey) {
setPrevWarningKey(warningKey);
if (!warningKey) {
// Condition resolved → immediately hide and allow future warnings
if (startWarningVisible) setStartWarningVisible(false);
if (startWarningDismissed) setStartWarningDismissed(false);
} else if (justInitialized) {
// Loaded from persistent storage with a problem → show immediately
if (!startWarningVisible) setStartWarningVisible(true);
setJustInitialized(false);
}
}
// Debounce timer: starts a 15-second countdown for non-initial warning
// appearances. The timer callback is async so it satisfies React's rules.
useEffect(() => {
if (!startNodeWarning || startWarningVisible || startWarningDismissed) {
return;
}
const timer = setTimeout(() => {
setStartWarningVisible(true);
}, 15_000);
return () => clearTimeout(timer);
}, [startNodeWarning, startWarningVisible, startWarningDismissed]);
// State updaters // State updaters
const updateMetadata = useCallback( const updateMetadata = useCallback(
(updates: Partial<WorkflowBuilderState>) => { (updates: Partial<WorkflowBuilderState>) => {
@@ -214,20 +292,11 @@ export default function WorkflowBuilderPage() {
} }
} }
// Pre-populate input from action's param_schema
const input: Record<string, unknown> = {};
if (action.param_schema && typeof action.param_schema === "object") {
for (const [key, param] of Object.entries(action.param_schema)) {
const meta = param as { default?: unknown };
input[key] = meta?.default !== undefined ? meta.default : "";
}
}
const newTask: WorkflowTask = { const newTask: WorkflowTask = {
id: generateTaskId(), id: generateTaskId(),
name, name,
action: action.ref, action: action.ref,
input, input: {},
position: { position: {
x: 300, x: 300,
y: state.tasks.length === 0 ? 60 : maxY + 160, y: state.tasks.length === 0 ? 60 : maxY + 160,
@@ -254,12 +323,49 @@ export default function WorkflowBuilderPage() {
const handleUpdateTask = useCallback( const handleUpdateTask = useCallback(
(taskId: string, updates: Partial<WorkflowTask>) => { (taskId: string, updates: Partial<WorkflowTask>) => {
setState((prev) => ({ setState((prev) => {
...prev, // Detect a name change so we can propagate it to transitions
tasks: prev.tasks.map((t) => const oldName =
t.id === taskId ? { ...t, ...updates } : t, updates.name !== undefined
), ? prev.tasks.find((t) => t.id === taskId)?.name
})); : undefined;
const newName = updates.name;
const isRename =
oldName !== undefined && newName !== undefined && oldName !== newName;
return {
...prev,
tasks: prev.tasks.map((t) => {
if (t.id === taskId) {
// Apply the updates, then also fix self-referencing transitions
const merged = { ...t, ...updates };
if (isRename) {
const updatedNext = renameTaskInTransitions(
merged.next,
oldName,
newName,
);
if (updatedNext !== merged.next) {
return { ...merged, next: updatedNext };
}
}
return merged;
}
// Update transition `do` lists that reference the old name
if (isRename) {
const updatedNext = renameTaskInTransitions(
t.next,
oldName,
newName,
);
if (updatedNext !== t.next) {
return { ...t, next: updatedNext };
}
}
return t;
}),
};
});
setSaveSuccess(false); setSaveSuccess(false);
}, },
[], [],
@@ -310,7 +416,7 @@ export default function WorkflowBuilderPage() {
[], [],
); );
const handleSave = useCallback(async () => { const doSave = useCallback(async () => {
// Validate // Validate
const errors = validateWorkflow(state); const errors = validateWorkflow(state);
setValidationErrors(errors); setValidationErrors(errors);
@@ -381,6 +487,18 @@ export default function WorkflowBuilderPage() {
actionSchemaMap, actionSchemaMap,
]); ]);
const handleSave = useCallback(() => {
// If there's a start-node problem, show the toast immediately and
// require confirmation before saving
if (startNodeWarning) {
setStartWarningVisible(true);
setStartWarningDismissed(false);
setShowSaveConfirm(true);
return;
}
doSave();
}, [startNodeWarning, doSave]);
// YAML preview — generate proper YAML from builder state // YAML preview — generate proper YAML from builder state
const yamlPreview = useMemo(() => { const yamlPreview = useMemo(() => {
if (!showYamlPreview) return ""; if (!showYamlPreview) return "";
@@ -640,13 +758,12 @@ export default function WorkflowBuilderPage() {
<WorkflowCanvas <WorkflowCanvas
tasks={state.tasks} tasks={state.tasks}
selectedTaskId={selectedTaskId} selectedTaskId={selectedTaskId}
availableActions={paletteActions}
onSelectTask={setSelectedTaskId} onSelectTask={setSelectedTaskId}
onUpdateTask={handleUpdateTask} onUpdateTask={handleUpdateTask}
onDeleteTask={handleDeleteTask} onDeleteTask={handleDeleteTask}
onAddTask={handleAddTask} onAddTask={handleAddTask}
onSetConnection={handleSetConnection} onSetConnection={handleSetConnection}
onEdgeHover={handleEdgeHover} onEdgeClick={handleEdgeClick}
/> />
{/* Right: Task Inspector */} {/* Right: Task Inspector */}
@@ -667,6 +784,122 @@ export default function WorkflowBuilderPage() {
</> </>
)} )}
</div> </div>
{/* Floating start-node warning toast */}
{startNodeWarning && startWarningVisible && (
<div
className="fixed top-20 left-1/2 -translate-x-1/2 z-50 animate-fade-in"
style={{
animation: "fadeInDown 0.25s ease-out both",
}}
>
<div
className={`flex items-center gap-2.5 px-4 py-2.5 rounded-lg shadow-lg border ${
startNodeWarning.level === "error"
? "bg-red-50 border-red-300 text-red-800"
: "bg-amber-50 border-amber-300 text-amber-800"
}`}
style={{ maxWidth: 520 }}
>
<AlertTriangle
className={`w-4 h-4 flex-shrink-0 ${
startNodeWarning.level === "error"
? "text-red-500"
: "text-amber-500"
}`}
/>
<p className="text-xs font-medium flex-1">
{startNodeWarning.message}
</p>
<button
onClick={() => {
setStartWarningVisible(false);
setStartWarningDismissed(true);
}}
className={`p-0.5 rounded hover:bg-black/5 flex-shrink-0 ${
startNodeWarning.level === "error"
? "text-red-400 hover:text-red-600"
: "text-amber-400 hover:text-amber-600"
}`}
>
<X className="w-3.5 h-3.5" />
</button>
</div>
</div>
)}
{/* Confirmation modal for saving with start-node warnings */}
{showSaveConfirm && startNodeWarning && (
<div className="fixed inset-0 z-[60] flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/40"
onClick={() => setShowSaveConfirm(false)}
/>
{/* Modal */}
<div className="relative bg-white rounded-lg shadow-xl border border-gray-200 p-6 max-w-md w-full mx-4">
<div className="flex items-start gap-3">
<div
className={`p-2 rounded-full flex-shrink-0 ${
startNodeWarning.level === "error"
? "bg-red-100"
: "bg-amber-100"
}`}
>
<AlertTriangle
className={`w-5 h-5 ${
startNodeWarning.level === "error"
? "text-red-600"
: "text-amber-600"
}`}
/>
</div>
<div className="flex-1">
<h3 className="text-sm font-semibold text-gray-900 mb-1">
{startNodeWarning.level === "error"
? "No starting tasks"
: "Multiple starting tasks"}
</h3>
<p className="text-xs text-gray-600 mb-4">
{startNodeWarning.message} Are you sure you want to save this
workflow?
</p>
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setShowSaveConfirm(false)}
className="px-3 py-1.5 text-sm font-medium text-gray-700 bg-gray-100 rounded hover:bg-gray-200 transition-colors"
>
Cancel
</button>
<button
onClick={() => {
setShowSaveConfirm(false);
doSave();
}}
className="px-3 py-1.5 text-sm font-medium text-white bg-blue-600 rounded hover:bg-blue-700 transition-colors"
>
Save Anyway
</button>
</div>
</div>
</div>
</div>
</div>
)}
{/* Inline style for fade-in animation */}
<style>{`
@keyframes fadeInDown {
from {
opacity: 0;
transform: translate(-50%, -8px);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}
`}</style>
</div> </div>
); );
} }

View File

@@ -105,6 +105,13 @@ export const PRESET_LABELS: Record<TransitionPreset, string> = {
always: "Always", always: "Always",
}; };
/** Default edge colors for each preset */
export const PRESET_COLORS: Record<TransitionPreset, string> = {
succeeded: "#22c55e", // green-500
failed: "#ef4444", // red-500
always: "#6b7280", // gray-500
};
/** /**
* Classify a `when` expression into an edge visual type. * Classify a `when` expression into an edge visual type.
* Used for edge coloring and labeling. * Used for edge coloring and labeling.
@@ -197,15 +204,27 @@ export interface WorkflowYamlDefinition {
tags?: string[]; tags?: string[];
} }
/** Chart-only metadata for a transition edge (not consumed by the backend) */
export interface TransitionChartMeta {
/** Custom display label for the transition */
label?: string;
/** Custom color for the transition edge (CSS color string) */
color?: string;
}
/** Transition as represented in YAML format */ /** Transition as represented in YAML format */
export interface WorkflowYamlTransition { export interface WorkflowYamlTransition {
when?: string; when?: string;
publish?: PublishDirective[]; publish?: PublishDirective[];
do?: string[]; do?: string[];
/** Custom display label for the transition */ /** Visual metadata (label, color) — ignored by backend */
label?: string; __chart_meta__?: TransitionChartMeta;
/** Custom color for the transition edge */ }
color?: string;
/** Chart-only metadata for a task node (not consumed by the backend) */
export interface TaskChartMeta {
/** Visual position on the canvas */
position?: NodePosition;
} }
/** Task as represented in YAML format */ /** Task as represented in YAML format */
@@ -221,6 +240,8 @@ export interface WorkflowYamlTask {
timeout?: number; timeout?: number;
next?: WorkflowYamlTransition[]; next?: WorkflowYamlTransition[];
join?: number; join?: number;
/** Visual metadata (position) — ignored by backend */
__chart_meta__?: TaskChartMeta;
} }
/** Request to save a workflow file to disk and sync to DB */ /** Request to save a workflow file to disk and sync to DB */
@@ -254,8 +275,6 @@ export interface PaletteAction {
label: string; label: string;
description: string; description: string;
pack_ref: string; pack_ref: string;
param_schema: Record<string, unknown> | null;
out_schema: Record<string, unknown> | null;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -352,6 +371,11 @@ export function builderStateToDefinition(
if (task.timeout) yamlTask.timeout = task.timeout; if (task.timeout) yamlTask.timeout = task.timeout;
if (task.join) yamlTask.join = task.join; if (task.join) yamlTask.join = task.join;
// Persist canvas position in __chart_meta__ so layout is restored on reload
yamlTask.__chart_meta__ = {
position: { x: task.position.x, y: task.position.y },
};
// Serialize transitions as `next` array // Serialize transitions as `next` array
if (task.next && task.next.length > 0) { if (task.next && task.next.length > 0) {
yamlTask.next = task.next.map((t) => { yamlTask.next = task.next.map((t) => {
@@ -359,8 +383,12 @@ export function builderStateToDefinition(
if (t.when) yt.when = t.when; if (t.when) yt.when = t.when;
if (t.publish && t.publish.length > 0) yt.publish = t.publish; if (t.publish && t.publish.length > 0) yt.publish = t.publish;
if (t.do && t.do.length > 0) yt.do = t.do; if (t.do && t.do.length > 0) yt.do = t.do;
if (t.label) yt.label = t.label; // Store label/color in __chart_meta__ to avoid polluting the transition namespace
if (t.color) yt.color = t.color; if (t.label || t.color) {
yt.__chart_meta__ = {};
if (t.label) yt.__chart_meta__.label = t.label;
if (t.color) yt.__chart_meta__.color = t.color;
}
return yt; return yt;
}); });
} }
@@ -499,8 +527,8 @@ export function definitionToBuilderState(
when: t.when, when: t.when,
publish: t.publish, publish: t.publish,
do: t.do, do: t.do,
label: t.label, label: t.__chart_meta__?.label,
color: t.color, color: t.__chart_meta__?.color,
})); }));
} else { } else {
const converted = legacyTransitionsToNext(task); const converted = legacyTransitionsToNext(task);
@@ -520,7 +548,7 @@ export function definitionToBuilderState(
batch_size: task.batch_size, batch_size: task.batch_size,
concurrency: task.concurrency, concurrency: task.concurrency,
join: task.join, join: task.join,
position: { position: task.__chart_meta__?.position ?? {
x: 300, x: 300,
y: 80 + index * 160, y: 80 + index * 160,
}, },
@@ -623,8 +651,11 @@ export function findOrCreateTransition(
return { next, index: existingIndex }; return { next, index: existingIndex };
} }
// Create new transition // Create new transition with default label and color for the preset
const newTransition: TaskTransition = {}; const newTransition: TaskTransition = {
label: PRESET_LABELS[preset],
color: PRESET_COLORS[preset],
};
if (whenExpr) newTransition.when = whenExpr; if (whenExpr) newTransition.when = whenExpr;
next.push(newTransition); next.push(newTransition);
return { next, index: next.length - 1 }; return { next, index: next.length - 1 };
@@ -677,6 +708,60 @@ export function removeTaskFromTransitions(
return cleaned.length > 0 ? cleaned : undefined; return cleaned.length > 0 ? cleaned : undefined;
} }
/**
* Rename a task in all transition `do` lists.
* Returns a new array (or undefined) only when something changed;
* otherwise returns the original reference so callers can cheaply
* detect a no-op via `===`.
*/
export function renameTaskInTransitions(
next: TaskTransition[] | undefined,
oldName: string,
newName: string,
): TaskTransition[] | undefined {
if (!next) return undefined;
let changed = false;
const updated = next.map((t) => {
if (!t.do || !t.do.includes(oldName)) return t;
changed = true;
return {
...t,
do: t.do.map((name) => (name === oldName ? newName : name)),
};
});
return changed ? updated : next;
}
/**
* Find "starting" tasks — those whose name does not appear in any
* transition `do` list (i.e. no other task transitions into them).
* Returns a Set of task IDs.
*/
export function findStartingTaskIds(tasks: WorkflowTask[]): Set<string> {
// Collect every task name that is referenced as a transition target
const targeted = new Set<string>();
for (const task of tasks) {
if (!task.next) continue;
for (const t of task.next) {
if (t.do) {
for (const name of t.do) {
targeted.add(name);
}
}
}
}
const startIds = new Set<string>();
for (const task of tasks) {
if (!targeted.has(task.name)) {
startIds.add(task.id);
}
}
return startIds;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Utility functions // Utility functions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------