diff --git a/ui/package-lock.json b/ui/package-lock.json index 460f9b0..ffa06e5 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -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" diff --git a/ui/package.json b/ui/package.json index 15ef26d..f738c8b 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/app/workflow/WorkflowLayout.tsx b/ui/src/app/workflow/WorkflowLayout.tsx index 7d063f8..cd08a2e 100644 --- a/ui/src/app/workflow/WorkflowLayout.tsx +++ b/ui/src/app/workflow/WorkflowLayout.tsx @@ -6,13 +6,21 @@ interface WorkflowLayoutProps { children: ReactNode, headerActions?: ReactNode, backButton?: ReactNode, - showFeaturesNav?: boolean + showFeaturesNav?: boolean, + stickyTabs?: ReactNode } -const WorkflowLayout: React.FC = ({ children, headerActions, backButton, showFeaturesNav = true }) => { +const WorkflowLayout: React.FC = ({ children, headerActions, backButton, showFeaturesNav = true, stickyTabs }) => { return ( <> + {stickyTabs && ( +
+
+ {stickyTabs} +
+
+ )} {children} ) diff --git a/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx b/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx index ca00f3c..a483541 100644 --- a/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx +++ b/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx @@ -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 = ( ); + const stickyTabs = ; + return ( - -
+ +
{ rfInstance.current = instance; }} - defaultEdgeOptions={{ animated: true, type: "custom" }} + defaultEdgeOptions={defaultEdgeOptions} > - - - + + + + {/* Top-right controls - vertical layout */} + + +
+ + + + + +

Add node

+
+
+ + + + + + +

Configurations

+
+
+ + + + + + +

Template Context Variables

+
+
+
+
+ + {/* Bottom-left controls - horizontal layout with custom buttons */} +
+ + {/* Zoom In */} + + + + + +

Zoom in

+
+
+ + {/* Zoom Out */} + + + + + +

Zoom out

+
+
+ + {/* Fit View */} + + + + + +

Fit view

+
+
+ + {/* Tidy/Arrange Nodes */} + + + + + +

Tidy Up

+
+
+
+
setIsAddNodePanelOpen(false)} /> + + + +
); diff --git a/ui/src/app/workflow/[workflowId]/components/TemplateContextVariablesDialog.tsx b/ui/src/app/workflow/[workflowId]/components/TemplateContextVariablesDialog.tsx index b8dc912..226bc4b 100644 --- a/ui/src/app/workflow/[workflowId]/components/TemplateContextVariablesDialog.tsx +++ b/ui/src/app/workflow/[workflowId]/components/TemplateContextVariablesDialog.tsx @@ -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 = ({ Template Context Variables + + 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}}`}. +
{/* Existing Variables */} diff --git a/ui/src/app/workflow/[workflowId]/components/WorkflowTabs.tsx b/ui/src/app/workflow/[workflowId]/components/WorkflowTabs.tsx new file mode 100644 index 0000000..52b1b05 --- /dev/null +++ b/ui/src/app/workflow/[workflowId]/components/WorkflowTabs.tsx @@ -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 ( +
+ + +
+ ); +}; diff --git a/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts b/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts index 7753140..519920a 100644 --- a/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts +++ b/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts @@ -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) => { diff --git a/ui/src/app/workflow/[workflowId]/runs/page.tsx b/ui/src/app/workflow/[workflowId]/runs/page.tsx index 877d667..187cba9 100644 --- a/ui/src/app/workflow/[workflowId]/runs/page.tsx +++ b/ui/src/app/workflow/[workflowId]/runs/page.tsx @@ -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 = ( - - - - ); + const stickyTabs = ; return ( - +

Workflow Run History

diff --git a/ui/src/components/flow/edges/CustomEdge.tsx b/ui/src/components/flow/edges/CustomEdge.tsx index 6a77a0f..56b7ec9 100644 --- a/ui/src/components/flow/edges/CustomEdge.tsx +++ b/ui/src/components/flow/edges/CustomEdge.tsx @@ -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(); + const { getEdges, setEdges, setNodes } = useReactFlow(); 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 ( <> - - -
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + -
-
- {data?.label || data?.condition || 'Set Condition'} - + interactionWidth={20} + /> + + {/* Show label when highlighted (selected or hovered), positioned at edge center */} + {isHighlighted && ( + +
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > +
+ {/* Header with label */} +
+ + Condition + + +
+ {/* Content */} +
+
+ {data?.label || data?.condition || 'Click to set condition'} +
+
-
-
- + + )} { } bgColor="bg-blue-300" hasSourceHandle={true} hasTargetHandle={true} + onDoubleClick={() => setOpen(true)} + nodeId={id} >
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt} diff --git a/ui/src/components/flow/nodes/BaseHandle.tsx b/ui/src/components/flow/nodes/BaseHandle.tsx index 1c89d07..e098c53 100644 --- a/ui/src/components/flow/nodes/BaseHandle.tsx +++ b/ui/src/components/flow/nodes/BaseHandle.tsx @@ -6,16 +6,28 @@ import { cn } from "@/lib/utils"; export type BaseHandleProps = HandleProps; export const BaseHandle = forwardRef( - ({ className, children, ...props }, ref) => { + ({ className, children, type, ...props }, ref) => { + const isSource = type === 'source'; + const isTarget = type === 'target'; + return ( {children} diff --git a/ui/src/components/flow/nodes/BaseNode.tsx b/ui/src/components/flow/nodes/BaseNode.tsx index 2e3a5f6..6d39f05 100644 --- a/ui/src/components/flow/nodes/BaseNode.tsx +++ b/ui/src/components/flow/nodes/BaseNode.tsx @@ -7,8 +7,9 @@ export const BaseNode = forwardRef< HTMLAttributes & { selected?: boolean; invalid?: boolean; + highlighted?: boolean; } ->(({ className, selected, invalid, ...props }, ref) => ( +>(({ className, selected, invalid, highlighted, ...props }, ref) => (
{ } bgColor="bg-red-300" hasTargetHandle={true} + onDoubleClick={() => setOpen(true)} + nodeId={id} >
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt} diff --git a/ui/src/components/flow/nodes/GlobalNode.tsx b/ui/src/components/flow/nodes/GlobalNode.tsx index 21548e3..51fee73 100644 --- a/ui/src/components/flow/nodes/GlobalNode.tsx +++ b/ui/src/components/flow/nodes/GlobalNode.tsx @@ -61,9 +61,12 @@ export const GlobalNode = memo(({ data, selected, id }: GlobalNodeProps) => { } bgColor="bg-orange-300" + onDoubleClick={() => setOpen(true)} + nodeId={id} >
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt} diff --git a/ui/src/components/flow/nodes/StartCall.tsx b/ui/src/components/flow/nodes/StartCall.tsx index b09d207..748a5ff 100644 --- a/ui/src/components/flow/nodes/StartCall.tsx +++ b/ui/src/components/flow/nodes/StartCall.tsx @@ -100,10 +100,13 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => { } bgColor="bg-green-300" hasSourceHandle={true} + onDoubleClick={() => setOpen(true)} + nodeId={id} >
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt} diff --git a/ui/src/components/flow/nodes/common/NodeContent.tsx b/ui/src/components/flow/nodes/common/NodeContent.tsx index e4f8340..7a191f2 100644 --- a/ui/src/components/flow/nodes/common/NodeContent.tsx +++ b/ui/src/components/flow/nodes/common/NodeContent.tsx @@ -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 ( - + {hasTargetHandle && } {icon} {title} +

{nodeId}

{children} diff --git a/ui/src/components/flow/types.ts b/ui/src/components/flow/types.ts index 93668ba..fa14ee4 100644 --- a/ui/src/components/flow/types.ts +++ b/ui/src/components/flow/types.ts @@ -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;