feat: improve workflow builder UX (#41)

* chore: improve ux of workflow editor

* Improve workflow UX

* Add option to edit workflow name

* Fix undo/ redo for node editing
This commit is contained in:
Abhishek 2025-11-06 18:26:15 +05:30 committed by Sabiha Khan
parent 6ab23d4066
commit 1a0a18a435
27 changed files with 1749 additions and 879 deletions

View file

@ -1,8 +1,9 @@
import { BaseEdge, type Edge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath, useReactFlow } from '@xyflow/react';
import { BaseEdge, type Edge, EdgeLabelRenderer, type EdgeProps, getBezierPath, useReactFlow } from '@xyflow/react';
import { AlertCircle, Pencil } from 'lucide-react';
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { useWorkflowStore } from "@/app/workflow/[workflowId]/stores/workflowStore";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
@ -25,6 +26,14 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
const [condition, setCondition] = useState(data?.condition ?? '');
const [label, setLabel] = useState(data?.label ?? '');
// Update form state when data changes (e.g., from undo/redo)
useEffect(() => {
if (open) {
setCondition(data?.condition ?? '');
setLabel(data?.label ?? '');
}
}, [data, open]);
const handleSave = () => {
onSave({ condition: condition, label: label });
onOpenChange(false);
@ -85,10 +94,14 @@ interface CustomEdgeProps extends EdgeProps {
}
export default function CustomEdge(props: CustomEdgeProps) {
const { id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data } = props;
const { id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, style, selected } = props;
const { getEdges, setEdges } = useReactFlow<FlowNode, FlowEdge>();
const { getEdges, setNodes } = useReactFlow<FlowNode, FlowEdge>();
const { saveWorkflow } = useWorkflow();
const updateEdge = useWorkflowStore((state) => state.updateEdge);
const [open, setOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const parallel = getEdges().filter(
(e) =>
(e.source === source && e.target === target) ||
@ -113,8 +126,8 @@ export default function CustomEdge(props: CustomEdgeProps) {
}
}
// 3) draw the straight path + get label coords
const [edgePath, labelX, labelY] = getSmoothStepPath({
// 3) draw the bezier path + get label coords
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
@ -123,31 +136,74 @@ export default function CustomEdge(props: CustomEdgeProps) {
targetPosition,
});
const [open, setOpen] = useState(false);
// Update connected nodes when edge is selected or hovered
useEffect(() => {
setNodes((nodes) => {
return nodes.map((node) => {
if (node.id === source || node.id === target) {
// Update both properties based on edge state
const shouldSelectThroughEdge = selected || false;
const shouldHoverThroughEdge = isHovered || false;
// Only update if state actually changed
if (
node.data.selected_through_edge !== shouldSelectThroughEdge ||
node.data.hovered_through_edge !== shouldHoverThroughEdge
) {
return {
...node,
data: {
...node.data,
selected_through_edge: shouldSelectThroughEdge,
hovered_through_edge: shouldHoverThroughEdge
}
};
}
}
return node;
});
});
}, [selected, isHovered, source, target, setNodes]);
const handleSaveEdgeData = useCallback(async (updatedData: FlowEdgeData) => {
// Update the node data in the ReactFlow nodes state
setEdges((edges) => {
const updatedEdges = edges.map((edge) =>
edge.id === id
? { ...edge, data: updatedData }
: edge
)
return updatedEdges;
}
);
// Use the workflow store's updateEdge method to properly track history
updateEdge(id, { data: updatedData });
// Save the workflow after updating edge data with a small delay to ensure state is updated
setTimeout(async () => {
await saveWorkflow();
}, 100);
}, [id, setEdges, saveWorkflow]);
}, [id, updateEdge, saveWorkflow]);
return (
<>
<BaseEdge
id={id}
path={edgePath}
/>
<g
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onDoubleClick={() => setOpen(true)}
>
<BaseEdge
id={id}
path={edgePath}
style={{
...style,
stroke: selected
? '#3B82F6' // blue-500 when selected
: isHovered
? '#60A5FA' // blue-400 when hovered
: data?.invalid ? '#EF4444' : '#94A3B8',
strokeWidth: selected ? 4 : isHovered ? 3 : 2.5,
filter: selected
? 'drop-shadow(0 0 8px rgba(59, 130, 246, 0.6))'
: isHovered
? 'drop-shadow(0 0 6px rgba(96, 165, 250, 0.4))'
: 'none',
transition: 'all 0.2s ease',
}}
interactionWidth={20}
/>
</g>
{/* Always show label, expand on select/hover */}
<EdgeLabelRenderer>
<div
style={{
@ -155,26 +211,55 @@ export default function CustomEdge(props: CustomEdgeProps) {
pointerEvents: 'all',
transformOrigin: 'center',
transform: `translate(-50%, -50%) translate(${labelX + offsetX}px, ${labelY + offsetY}px)`,
zIndex: 1000,
}}
className="nodrag nopan"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onDoubleClick={() => setOpen(true)}
>
<div className={cn(
"flex items-center gap-2 bg-white pl-3 pr-1 py-1 rounded-md border shadow-sm",
data?.invalid ? "border-red-500/30 shadow-[0_0_10px_rgba(239,68,68,0.5)]" : "border-gray-200"
)}>
<div className="flex flex-col">
<span className="text-sm">{data?.label || data?.condition || 'Set Condition'}</span>
{/* Show full EdgeLabel when selected or hovered, otherwise show simple label */}
{(selected || isHovered) ? (
<div className={cn(
"flex flex-col gap-2 bg-white rounded-lg border-2 shadow-xl min-w-[200px]",
"animate-in fade-in zoom-in duration-200",
data?.invalid ? "border-red-500 shadow-[0_0_15px_rgba(239,68,68,0.5)]" : "border-gray-300"
)}>
{/* Header with label */}
<div className={cn(
"flex items-center justify-between px-3 py-2 border-b",
data?.invalid ? "bg-red-50 border-red-200" : "bg-gray-50 border-gray-200"
)}>
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wide">
Condition - EdgeID: {id}
</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0 hover:bg-gray-200"
onClick={() => setOpen(true)}
>
<Pencil className="h-3 w-3" />
</Button>
</div>
{/* Content */}
<div className="px-3 pb-3">
<div className="text-sm font-medium text-gray-900 break-words">
{data?.label || data?.condition || 'Click to set condition'}
</div>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 p-0"
onClick={() => setOpen(true)}
>
<Pencil className="h-4 w-4" />
</Button>
</div>
) : (
/* Simple label shown by default */
<div className={cn(
"px-2 py-1 bg-white rounded border shadow-sm",
data?.invalid ? "border-red-400 text-red-600" : "border-gray-300 text-gray-700"
)}>
<div className="text-xs font-medium">
{data?.label || data?.condition || 'No condition'}
</div>
</div>
)}
</div>
</EdgeLabelRenderer>
<EdgeDetailsDialog

View file

@ -1,6 +1,6 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, Headset, PlusIcon,Trash2Icon } from "lucide-react";
import { memo, useState } from "react";
import { memo, useEffect, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { ExtractionVariable,FlowNodeData } from "@/components/flow/types";
@ -83,16 +83,33 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
setOpen(newOpen);
};
// Update form state when data changes (e.g., from undo/redo)
useEffect(() => {
if (open) {
setPrompt(data.prompt);
setName(data.name);
setAllowInterrupt(data.allow_interrupt ?? true);
setExtractionEnabled(data.extraction_enabled ?? false);
setExtractionPrompt(data.extraction_prompt ?? "");
setVariables(data.extraction_variables ?? []);
setAddGlobalPrompt(data.add_global_prompt ?? true);
}
}, [data, open]);
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
selected_through_edge={data.selected_through_edge}
hovered_through_edge={data.hovered_through_edge}
title={data.name || 'Agent'}
icon={<Headset />}
bgColor="bg-blue-300"
hasSourceHandle={true}
hasTargetHandle={true}
onDoubleClick={() => setOpen(true)}
nodeId={id}
>
<div className="text-sm text-muted-foreground">
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}

View file

@ -6,16 +6,28 @@ import { cn } from "@/lib/utils";
export type BaseHandleProps = HandleProps;
export const BaseHandle = forwardRef<HTMLDivElement, BaseHandleProps>(
({ className, children, ...props }, ref) => {
({ className, children, type, ...props }, ref) => {
const isSource = type === 'source';
const isTarget = type === 'target';
return (
<Handle
ref={ref}
type={type}
{...props}
className={cn(
"h-[11px] w-[11px] rounded-full border border-slate-300 bg-slate-100 transition dark:border-secondary dark:bg-secondary",
"transition-all hover:!bg-blue-500",
// Source (outgoing) has larger visible handle for easier connection
isSource && "!h-[16px] !w-[16px] rounded-full",
// Target (incoming) smaller rectangle
isTarget && "!h-[10px] !w-[14px] rounded-sm",
className,
)}
{...props}
style={{
border: 'none',
background: '#94A3B8', // slate-400
...props.style,
}}
>
{children}
</Handle>

View file

@ -7,8 +7,10 @@ export const BaseNode = forwardRef<
HTMLAttributes<HTMLDivElement> & {
selected?: boolean;
invalid?: boolean;
selected_through_edge?: boolean;
hovered_through_edge?: boolean;
}
>(({ className, selected, invalid, ...props }, ref) => (
>(({ className, selected, invalid, selected_through_edge, hovered_through_edge, ...props }, ref) => (
<div
ref={ref}
className={cn(
@ -16,7 +18,10 @@ export const BaseNode = forwardRef<
className,
selected ? "border-muted-foreground shadow-lg" : "",
invalid ? "border-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]" : "",
"hover:ring-1",
// Hovered through edge takes precedence over selected through edge
hovered_through_edge ? "ring-2 ring-blue-400 shadow-[0_0_12px_rgba(96,165,250,0.5)]" : "",
!hovered_through_edge && selected_through_edge ? "ring-1 ring-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.4)]" : "",
!selected_through_edge && !hovered_through_edge && "hover:ring-1 hover:ring-gray-300",
)}
tabIndex={0}
{...props}

View file

@ -1,6 +1,6 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, OctagonX, PlusIcon, Trash2Icon } from "lucide-react";
import { memo, useState } from "react";
import { memo, useEffect, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { ExtractionVariable, FlowNodeData } from "@/components/flow/types";
@ -87,15 +87,32 @@ export const EndCall = memo(({ data, selected, id }: EndCallNodeProps) => {
setOpen(newOpen);
};
// Update form state when data changes (e.g., from undo/redo)
useEffect(() => {
if (open) {
setPrompt(data.prompt);
setIsStatic(data.is_static ?? true);
setName(data.name);
setExtractionEnabled(data.extraction_enabled ?? false);
setExtractionPrompt(data.extraction_prompt ?? "");
setVariables(data.extraction_variables ?? []);
setAddGlobalPrompt(data.add_global_prompt ?? true);
}
}, [data, open]);
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
selected_through_edge={data.selected_through_edge}
hovered_through_edge={data.hovered_through_edge}
title="End Call"
icon={<OctagonX />}
bgColor="bg-red-300"
hasTargetHandle={true}
onDoubleClick={() => setOpen(true)}
nodeId={id}
>
<div className="text-sm text-muted-foreground">
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}

View file

@ -1,6 +1,6 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, Headset, Trash2Icon } from "lucide-react";
import { memo, useState } from "react";
import { memo, useEffect, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { FlowNodeData } from "@/components/flow/types";
@ -56,14 +56,26 @@ export const GlobalNode = memo(({ data, selected, id }: GlobalNodeProps) => {
setOpen(newOpen);
};
// Update form state when data changes (e.g., from undo/redo)
useEffect(() => {
if (open) {
setPrompt(data.prompt);
setName(data.name);
}
}, [data, open]);
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
selected_through_edge={data.selected_through_edge}
hovered_through_edge={data.hovered_through_edge}
title={data.name || 'Global'}
icon={<Headset />}
bgColor="bg-orange-300"
onDoubleClick={() => setOpen(true)}
nodeId={id}
>
<div className="text-sm text-muted-foreground">
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}

View file

@ -1,6 +1,6 @@
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
import { Edit, Play } from "lucide-react";
import { memo, useState } from "react";
import { memo, useEffect, useState } from "react";
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
import { FlowNodeData } from "@/components/flow/types";
@ -95,15 +95,34 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
setOpen(newOpen);
};
// Update form state when data changes (e.g., from undo/redo)
useEffect(() => {
if (open) {
setPrompt(data.prompt ?? "");
setIsStatic(data.is_static ?? true);
setName(data.name);
setAllowInterrupt(data.allow_interrupt ?? true);
setAddGlobalPrompt(data.add_global_prompt ?? true);
setWaitForUserResponse(data.wait_for_user_response ?? false);
setDetectVoicemail(data.detect_voicemail ?? true);
setDelayedStart(data.delayed_start ?? false);
setDelayedStartDuration(data.delayed_start_duration ?? 3);
}
}, [data, open]);
return (
<>
<NodeContent
selected={selected}
invalid={data.invalid}
selected_through_edge={data.selected_through_edge}
hovered_through_edge={data.hovered_through_edge}
title="Start Call"
icon={<Play />}
bgColor="bg-green-300"
hasSourceHandle={true}
onDoubleClick={() => setOpen(true)}
nodeId={id}
>
<div className="text-sm text-muted-foreground">
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}

View file

@ -8,6 +8,8 @@ import { NodeHeader, NodeHeaderIcon, NodeHeaderTitle } from "@/components/flow/n
interface NodeContentProps {
selected: boolean;
invalid?: boolean;
selected_through_edge?: boolean;
hovered_through_edge?: boolean;
title: string;
icon: ReactNode;
bgColor: string;
@ -15,11 +17,15 @@ interface NodeContentProps {
hasTargetHandle?: boolean;
children?: ReactNode;
className?: string;
onDoubleClick?: () => void;
nodeId?: string;
}
export const NodeContent = ({
selected,
invalid,
selected_through_edge,
hovered_through_edge,
title,
icon,
bgColor,
@ -27,13 +33,22 @@ export const NodeContent = ({
hasTargetHandle = false,
children,
className = "",
onDoubleClick,
nodeId,
}: NodeContentProps) => {
return (
<BaseNode selected={selected} invalid={invalid} className={`p-0 overflow-hidden ${className}`}>
<BaseNode
selected={selected}
invalid={invalid}
selected_through_edge={selected_through_edge}
hovered_through_edge={hovered_through_edge}
className={`p-0 overflow-hidden ${className}`}
onDoubleClick={onDoubleClick}
>
{hasTargetHandle && <BaseHandle type="target" position={Position.Top} />}
<NodeHeader className={`px-3 py-2 border-b ${bgColor}`}>
<NodeHeaderIcon>{icon}</NodeHeaderIcon>
<NodeHeaderTitle>{title}</NodeHeaderTitle>
<NodeHeaderTitle>{title} - NodeID: {nodeId}</NodeHeaderTitle>
</NodeHeader>
<div className="p-3">
{children}

View file

@ -1,7 +1,7 @@
import { useReactFlow } from "@xyflow/react";
import { useCallback, useState } from "react";
import { FlowEdge, FlowNode, FlowNodeData } from "@/components/flow/types";
import { useWorkflowStore } from "@/app/workflow/[workflowId]/stores/workflowStore";
import { FlowNodeData } from "@/components/flow/types";
interface UseNodeHandlersProps {
id: string;
@ -10,25 +10,26 @@ interface UseNodeHandlersProps {
export const useNodeHandlers = ({ id, additionalData = {} }: UseNodeHandlersProps) => {
const [open, setOpen] = useState(false);
const { setNodes } = useReactFlow<FlowNode, FlowEdge>();
const updateNode = useWorkflowStore((state) => state.updateNode);
const deleteNode = useWorkflowStore((state) => state.deleteNode);
const nodes = useWorkflowStore((state) => state.nodes);
const handleSaveNodeData = useCallback(
(updatedData: FlowNodeData) => {
setNodes((nodes) => {
const updatedNodes = nodes.map((node) =>
node.id === id
? { ...node, data: { ...node.data, ...updatedData, ...additionalData } }
: node
);
return updatedNodes;
});
// Find the current node to merge data properly
const currentNode = nodes.find(node => node.id === id);
if (currentNode) {
updateNode(id, {
data: { ...currentNode.data, ...updatedData, ...additionalData }
});
}
},
[id, setNodes, additionalData]
[id, updateNode, additionalData, nodes]
);
const handleDeleteNode = useCallback(() => {
setNodes((nodes) => nodes.filter((node) => node.id !== id));
}, [id, setNodes]);
deleteNode(id);
}, [id, deleteNode]);
return {
open,

View file

@ -13,6 +13,8 @@ export type FlowNodeData = {
is_end?: boolean;
invalid?: boolean;
validationMessage?: string | null;
selected_through_edge?: boolean;
hovered_through_edge?: boolean;
allow_interrupt?: boolean;
extraction_enabled?: boolean;
extraction_prompt?: string;