chore: improve ux of workflow editor

This commit is contained in:
Abhishek Kumar 2025-11-05 15:22:46 +05:30
parent 5c1fe2c6af
commit be1699aafc
17 changed files with 417 additions and 93 deletions

8
ui/package-lock.json generated
View file

@ -31,7 +31,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"livekit-client": "^2.9.9",
"lucide-react": "^0.487.0",
"lucide-react": "^0.505.0",
"next": "^15.3.3",
"next-themes": "^0.4.6",
"pino": "^9.9.2",
@ -14231,9 +14231,9 @@
"license": "ISC"
},
"node_modules/lucide-react": {
"version": "0.487.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.487.0.tgz",
"integrity": "sha512-aKqhOQ+YmFnwq8dWgGjOuLc8V1R9/c/yOd+zDY4+ohsR2Jo05lSGc3WsstYPIzcTpeosN7LoCkLReUUITvaIvw==",
"version": "0.505.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.505.0.tgz",
"integrity": "sha512-CblOqNBI1aIJqTIBx42CbBf7omVukYtYEy43eZLkm0CTrOO1tgumeuL/RrjwzXRaWonlcJYYTtBE70STDH3pvg==",
"license": "ISC",
"peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"

View file

@ -34,7 +34,7 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"livekit-client": "^2.9.9",
"lucide-react": "^0.487.0",
"lucide-react": "^0.505.0",
"next": "^15.3.3",
"next-themes": "^0.4.6",
"pino": "^9.9.2",

View file

@ -6,13 +6,21 @@ interface WorkflowLayoutProps {
children: ReactNode,
headerActions?: ReactNode,
backButton?: ReactNode,
showFeaturesNav?: boolean
showFeaturesNav?: boolean,
stickyTabs?: ReactNode
}
const WorkflowLayout: React.FC<WorkflowLayoutProps> = ({ children, headerActions, backButton, showFeaturesNav = true }) => {
const WorkflowLayout: React.FC<WorkflowLayoutProps> = ({ children, headerActions, backButton, showFeaturesNav = true, stickyTabs }) => {
return (
<>
<BaseHeader headerActions={headerActions} backButton={backButton} showFeaturesNav={showFeaturesNav} />
{stickyTabs && (
<div className="sticky top-0 z-50 bg-white border-b">
<div className="flex justify-center relative">
{stickyTabs}
</div>
</div>
)}
{children}
</>
)

View file

@ -2,19 +2,28 @@ import '@xyflow/react/dist/style.css';
import {
Background,
BackgroundVariant,
MiniMap,
Panel,
ReactFlow,
} from "@xyflow/react";
import { BrushCleaning, Maximize2, Minus, Plus, Settings, Variable } from 'lucide-react';
import { useMemo, useState } from 'react';
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { WorkflowConfigurations } from '@/types/workflow-configurations';
import AddNodePanel from "../../../components/flow/AddNodePanel";
import CustomEdge from "../../../components/flow/edges/CustomEdge";
import { AgentNode, EndCall, GlobalNode, StartCall } from "../../../components/flow/nodes";
import WorkflowControls from "./components/WorkflowControls";
import { ConfigurationsDialog } from './components/ConfigurationsDialog';
import { TemplateContextVariablesDialog } from './components/TemplateContextVariablesDialog';
import { layoutNodes } from './components/WorkflowControls';
import WorkflowHeader from "./components/WorkflowHeader";
import { WorkflowTabs } from './components/WorkflowTabs';
import { WorkflowProvider } from "./contexts/WorkflowContext";
import { useWorkflowState } from "./hooks/useWorkflowState";
@ -30,6 +39,22 @@ const edgeTypes = {
custom: CustomEdge,
};
// Helper function for MiniMap node colors
const getNodeColor = (node: FlowNode) => {
switch (node.type) {
case NodeType.START_CALL:
return '#10B981'; // green-500
case NodeType.AGENT_NODE:
return '#3B82F6'; // blue-500
case NodeType.END_CALL:
return '#EF4444'; // red-500
case NodeType.GLOBAL_NODE:
return '#F59E0B'; // orange-500
default:
return '#6B7280'; // gray-500
}
};
interface RenderWorkflowProps {
initialWorkflowName: string;
workflowId: number;
@ -47,22 +72,22 @@ interface RenderWorkflowProps {
}
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations }: RenderWorkflowProps) {
const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false);
const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false);
const {
rfInstance,
nodes,
edges,
isAddNodePanelOpen,
workflowName,
isEditingName,
isDirty,
workflowValidationErrors,
templateContextVariables,
workflowConfigurations,
setNodes,
setIsAddNodePanelOpen,
setIsEditingName,
handleNodeSelect,
handleNameChange,
saveWorkflow,
onConnect,
onEdgesChange,
@ -72,6 +97,12 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
saveWorkflowConfigurations
} = useWorkflowState({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations });
// Memoize defaultEdgeOptions to prevent unnecessary re-renders
const defaultEdgeOptions = useMemo(() => ({
animated: true,
type: "custom"
}), []);
const headerActions = (
<WorkflowHeader
workflowValidationErrors={workflowValidationErrors}
@ -84,10 +115,12 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
/>
);
const stickyTabs = <WorkflowTabs workflowId={workflowId} currentTab="editor" />;
return (
<WorkflowProvider value={{ saveWorkflow }}>
<WorkflowLayout headerActions={headerActions} showFeaturesNav={false}>
<div className="h-[calc(100vh-80px)]">
<WorkflowLayout headerActions={headerActions} showFeaturesNav={false} stickyTabs={stickyTabs}>
<div className="h-[calc(100vh-80px)] relative">
<ReactFlow
nodes={nodes}
edges={edges}
@ -99,29 +132,149 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
onInit={(instance) => {
rfInstance.current = instance;
}}
defaultEdgeOptions={{ animated: true, type: "custom" }}
defaultEdgeOptions={defaultEdgeOptions}
>
<Background />
<Panel position="top-left">
<WorkflowControls
workflowId={workflowId}
workflowName={workflowName}
isEditingName={isEditingName}
setIsEditingName={setIsEditingName}
handleNameChange={handleNameChange}
setIsAddNodePanelOpen={setIsAddNodePanelOpen}
saveWorkflow={saveWorkflow}
nodes={nodes}
edges={edges}
setNodes={setNodes}
rfInstance={rfInstance}
templateContextVariables={templateContextVariables}
saveTemplateContextVariables={saveTemplateContextVariables}
workflowConfigurations={workflowConfigurations}
saveWorkflowConfigurations={saveWorkflowConfigurations}
/>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
color="#94a3b8"
/>
<MiniMap
nodeColor={getNodeColor}
position="bottom-right"
className="bg-white/90 border rounded shadow-lg"
maskColor="rgb(0, 0, 0, 0.1)"
/>
{/* Top-right controls - vertical layout */}
<Panel position="top-right">
<TooltipProvider>
<div className="flex flex-col gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="icon"
onClick={() => setIsAddNodePanelOpen(true)}
className="shadow-md hover:shadow-lg"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Add node</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setIsConfigurationsDialogOpen(true)}
className="bg-white shadow-sm hover:shadow-md"
>
<Settings className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Configurations</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setIsContextVarsDialogOpen(true)}
className="bg-white shadow-sm hover:shadow-md"
>
<Variable className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Template Context Variables</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</Panel>
</ReactFlow>
{/* Bottom-left controls - horizontal layout with custom buttons */}
<div className="absolute bottom-12 left-8 z-[1000] flex gap-2">
<TooltipProvider>
{/* Zoom In */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.zoomIn()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Zoom in</p>
</TooltipContent>
</Tooltip>
{/* Zoom Out */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.zoomOut()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Minus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Zoom out</p>
</TooltipContent>
</Tooltip>
{/* Fit View */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.fitView()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Maximize2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Fit view</p>
</TooltipContent>
</Tooltip>
{/* Tidy/Arrange Nodes */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setNodes(layoutNodes(nodes, edges, 'LR', rfInstance, saveWorkflow))}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<BrushCleaning className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Tidy Up</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<AddNodePanel
@ -129,6 +282,20 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
onNodeSelect={handleNodeSelect}
onClose={() => setIsAddNodePanelOpen(false)}
/>
<ConfigurationsDialog
open={isConfigurationsDialogOpen}
onOpenChange={setIsConfigurationsDialogOpen}
workflowConfigurations={workflowConfigurations}
onSave={saveWorkflowConfigurations}
/>
<TemplateContextVariablesDialog
open={isContextVarsDialogOpen}
onOpenChange={setIsContextVarsDialogOpen}
templateContextVariables={templateContextVariables}
onSave={saveTemplateContextVariables}
/>
</WorkflowLayout>
</WorkflowProvider>
);

View file

@ -2,7 +2,7 @@ import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
@ -63,6 +63,10 @@ export const TemplateContextVariablesDialog = ({
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Template Context Variables</DialogTitle>
<DialogDescription>
Add or remove template context variables that will be available to your workflow. You can use
these variables within your workflow nodes within double curly braces. Example: {`{{variable_name}}`}.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Existing Variables */}

View file

@ -0,0 +1,47 @@
"use client";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
interface WorkflowTabsProps {
workflowId: number;
currentTab: 'editor' | 'executions';
}
export const WorkflowTabs = ({ workflowId, currentTab }: WorkflowTabsProps) => {
const router = useRouter();
return (
<div className="flex gap-1">
<button
onClick={() => router.push(`/workflow/${workflowId}`)}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors relative cursor-pointer",
currentTab === 'editor'
? "text-gray-900"
: "text-gray-500 hover:text-gray-700"
)}
>
Editor
{currentTab === 'editor' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600" />
)}
</button>
<button
onClick={() => router.push(`/workflow/${workflowId}/runs`)}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors relative cursor-pointer",
currentTab === 'executions'
? "text-gray-900"
: "text-gray-500 hover:text-gray-700"
)}
>
Executions
{currentTab === 'executions' && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600" />
)}
</button>
</div>
);
};

View file

@ -321,22 +321,19 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow,
(changes) => setEdges((eds) => {
const newEdges = applyEdgeChanges(changes, eds) as FlowEdge[];
setIsDirty(true);
// Trigger validation after edge changes
setTimeout(() => validateWorkflow(), 100);
console.log("in onEdgesChange", changes, eds, newEdges);
return newEdges;
}),
[setEdges, validateWorkflow],
[setEdges],
);
const onNodesChange: OnNodesChange = useCallback(
(changes) => setNodes((nds) => {
const newNodes = applyNodeChanges(changes, nds) as FlowNode[];
setIsDirty(true);
// Trigger validation after node changes
setTimeout(() => validateWorkflow(), 100);
return newNodes;
}),
[setNodes, validateWorkflow],
[setNodes],
);
const onRun = async (mode: string) => {

View file

@ -1,7 +1,6 @@
"use client";
import { ArrowLeft, ChevronLeft, ChevronRight, Download, ExternalLink } from "lucide-react";
import Link from "next/link";
import { ChevronLeft, ChevronRight, Download, ExternalLink } from "lucide-react";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
@ -27,6 +26,7 @@ import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters";
import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters";
import WorkflowLayout from '../../WorkflowLayout';
import { WorkflowTabs } from '../components/WorkflowTabs';
export default function WorkflowRunsPage() {
const { workflowId } = useParams();
@ -89,7 +89,7 @@ export default function WorkflowRunsPage() {
};
loadDispositionCodes();
}, [workflowId, accessToken, configuredAttributes]);
}, [workflowId, accessToken]);
const fetchWorkflowRuns = useCallback(async (page: number, filters?: ActiveFilter[]) => {
if (!accessToken) return;
@ -177,17 +177,10 @@ export default function WorkflowRunsPage() {
setIsExecutingFilters(false);
}, [fetchWorkflowRuns, updatePageInUrl]);
const backButton = (
<Link href={`/workflow/${workflowId}`}>
<Button variant="outline" size="sm" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
Workflow
</Button>
</Link>
);
const stickyTabs = <WorkflowTabs workflowId={Number(workflowId)} currentTab="executions" />;
return (
<WorkflowLayout backButton={backButton}>
<WorkflowLayout stickyTabs={stickyTabs} showFeaturesNav={false}>
<div className="container mx-auto py-8">
<div className="mb-6">
<h1 className="text-2xl font-bold mb-4">Workflow Run History</h1>

View file

@ -1,6 +1,6 @@
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 { Button } from "@/components/ui/button";
@ -85,10 +85,18 @@ 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, setEdges, setNodes } = useReactFlow<FlowNode, FlowEdge>();
const { saveWorkflow } = useWorkflow();
const [open, setOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
// Edge is highlighted when either selected or hovered
const isHighlighted = selected || isHovered;
console.log("in CustomEdge", id, selected, isHovered, isHighlighted);
const parallel = getEdges().filter(
(e) =>
(e.source === source && e.target === target) ||
@ -113,8 +121,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,7 +131,34 @@ export default function CustomEdge(props: CustomEdgeProps) {
targetPosition,
});
const [open, setOpen] = useState(false);
// Highlight connected nodes when edge is highlighted (selected or hovered)
useEffect(() => {
if (isHighlighted) {
setNodes((nodes) =>
nodes.map((node) => {
if (node.id === source || node.id === target) {
return {
...node,
data: { ...node.data, highlighted: true }
};
}
return node;
})
);
} else {
setNodes((nodes) =>
nodes.map((node) => {
if (node.id === source || node.id === target) {
return {
...node,
data: { ...node.data, highlighted: false }
};
}
return node;
})
);
}
}, [isHighlighted, source, target, setNodes, selected]);
const handleSaveEdgeData = useCallback(async (updatedData: FlowEdgeData) => {
// Update the node data in the ReactFlow nodes state
@ -144,39 +179,72 @@ export default function CustomEdge(props: CustomEdgeProps) {
return (
<>
<BaseEdge
id={id}
path={edgePath}
/>
<EdgeLabelRenderer>
<div
<g
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<BaseEdge
id={id}
path={edgePath}
style={{
position: 'absolute',
pointerEvents: 'all',
transformOrigin: 'center',
transform: `translate(-50%, -50%) translate(${labelX + offsetX}px, ${labelY + offsetY}px)`,
...style,
stroke: isHighlighted
? '#3B82F6' // blue-500 when highlighted (selected or hovered)
: data?.invalid ? '#EF4444' : '#94A3B8',
strokeWidth: isHighlighted ? 4 : 2.5,
filter: isHighlighted ? 'drop-shadow(0 0 8px rgba(59, 130, 246, 0.6))' : 'none',
transition: 'all 0.2s ease',
}}
className="nodrag nopan"
>
<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>
interactionWidth={20}
/>
</g>
{/* Show label when highlighted (selected or hovered), positioned at edge center */}
{isHighlighted && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
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)}
>
<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
</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>
</div>
</EdgeLabelRenderer>
</EdgeLabelRenderer>
)}
<EdgeDetailsDialog
open={open}
onOpenChange={setOpen}

View file

@ -88,11 +88,14 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
<NodeContent
selected={selected}
invalid={data.invalid}
highlighted={data.highlighted}
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,9 @@ export const BaseNode = forwardRef<
HTMLAttributes<HTMLDivElement> & {
selected?: boolean;
invalid?: boolean;
highlighted?: boolean;
}
>(({ className, selected, invalid, ...props }, ref) => (
>(({ className, selected, invalid, highlighted, ...props }, ref) => (
<div
ref={ref}
className={cn(
@ -16,6 +17,7 @@ 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)]" : "",
highlighted ? "ring-2 ring-blue-400 shadow-[0_0_15px_rgba(59,130,246,0.5)]" : "",
"hover:ring-1",
)}
tabIndex={0}

View file

@ -92,10 +92,13 @@ export const EndCall = memo(({ data, selected, id }: EndCallNodeProps) => {
<NodeContent
selected={selected}
invalid={data.invalid}
highlighted={data.highlighted}
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

@ -61,9 +61,12 @@ export const GlobalNode = memo(({ data, selected, id }: GlobalNodeProps) => {
<NodeContent
selected={selected}
invalid={data.invalid}
highlighted={data.highlighted}
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

@ -100,10 +100,13 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
<NodeContent
selected={selected}
invalid={data.invalid}
highlighted={data.highlighted}
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,7 @@ import { NodeHeader, NodeHeaderIcon, NodeHeaderTitle } from "@/components/flow/n
interface NodeContentProps {
selected: boolean;
invalid?: boolean;
highlighted?: boolean;
title: string;
icon: ReactNode;
bgColor: string;
@ -15,11 +16,14 @@ interface NodeContentProps {
hasTargetHandle?: boolean;
children?: ReactNode;
className?: string;
onDoubleClick?: () => void;
nodeId?: string;
}
export const NodeContent = ({
selected,
invalid,
highlighted,
title,
icon,
bgColor,
@ -27,13 +31,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}
highlighted={highlighted}
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>
<p>{nodeId}</p>
</NodeHeader>
<div className="p-3">
{children}

View file

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