From 1a0a18a435279f57ad1095c27c205913a504fa8a Mon Sep 17 00:00:00 2001 From: Abhishek Date: Thu, 6 Nov 2025 18:26:15 +0530 Subject: [PATCH] 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 --- ui/package-lock.json | 99 +++-- ui/package.json | 8 +- ui/src/app/workflow/WorkflowLayout.tsx | 14 +- .../workflow/[workflowId]/RenderWorkflow.tsx | 259 ++++++++++-- .../components/ConfigurationsDialog.tsx | 42 +- .../TemplateContextVariablesDialog.tsx | 6 +- .../components/WorkflowControls.tsx | 178 -------- .../components/WorkflowExecutions.tsx | 348 +++++++++++++++ .../components/WorkflowHeader.tsx | 106 +++-- .../[workflowId]/components/WorkflowTabs.tsx | 45 ++ .../workflow/[workflowId]/components/index.ts | 1 - .../[workflowId]/hooks/useWorkflowState.ts | 339 ++++++++------- ui/src/app/workflow/[workflowId]/page.tsx | 61 ++- .../app/workflow/[workflowId]/runs/page.tsx | 354 +--------------- .../[workflowId]/stores/workflowStore.ts | 399 ++++++++++++++++++ .../[workflowId]/utils/layoutNodes.ts | 49 +++ ui/src/components/flow/edges/CustomEdge.tsx | 161 +++++-- ui/src/components/flow/nodes/AgentNode.tsx | 19 +- ui/src/components/flow/nodes/BaseHandle.tsx | 18 +- ui/src/components/flow/nodes/BaseNode.tsx | 9 +- ui/src/components/flow/nodes/EndCall.tsx | 19 +- ui/src/components/flow/nodes/GlobalNode.tsx | 14 +- ui/src/components/flow/nodes/StartCall.tsx | 21 +- .../flow/nodes/common/NodeContent.tsx | 19 +- .../flow/nodes/common/useNodeHandlers.ts | 29 +- ui/src/components/flow/types.ts | 2 + ui/src/lib/utils.ts | 9 + 27 files changed, 1749 insertions(+), 879 deletions(-) delete mode 100644 ui/src/app/workflow/[workflowId]/components/WorkflowControls.tsx create mode 100644 ui/src/app/workflow/[workflowId]/components/WorkflowExecutions.tsx create mode 100644 ui/src/app/workflow/[workflowId]/components/WorkflowTabs.tsx create mode 100644 ui/src/app/workflow/[workflowId]/stores/workflowStore.ts create mode 100644 ui/src/app/workflow/[workflowId]/utils/layoutNodes.ts diff --git a/ui/package-lock.json b/ui/package-lock.json index 460f9b0..90b7ffc 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -26,12 +26,12 @@ "@radix-ui/react-tooltip": "^1.2.0", "@sentry/nextjs": "^9.28.1", "@stackframe/stack": "^2.8.28", - "@xyflow/react": "^12.5.5", + "@xyflow/react": "^12.9.2", "class-variance-authority": "^0.7.1", "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", @@ -49,7 +49,9 @@ "sonner": "^2.0.5", "tailwind-merge": "^3.2.0", "tailwindcss-animate": "^1.0.7", - "tw-animate-css": "^1.2.5" + "tw-animate-css": "^1.2.5", + "zundo": "^2.3.0", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -10107,12 +10109,12 @@ "peer": true }, "node_modules/@xyflow/react": { - "version": "12.5.5", - "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.5.5.tgz", - "integrity": "sha512-mAtHuS4ktYBL1ph5AJt7X/VmpzzlmQBN3+OXxyT/1PzxwrVto6AKc3caerfxzwBsg3cA4J8lB63F3WLAuPMmHw==", + "version": "12.9.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.2.tgz", + "integrity": "sha512-Xr+LFcysHCCoc5KRHaw+FwbqbWYxp9tWtk1mshNcqy25OAPuaKzXSdqIMNOA82TIXF/gFKo0Wgpa6PU7wUUVqw==", "license": "MIT", "dependencies": { - "@xyflow/system": "0.0.55", + "@xyflow/system": "0.0.72", "classcat": "^5.0.3", "zustand": "^4.4.0" }, @@ -10121,17 +10123,47 @@ "react-dom": ">=17" } }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/@xyflow/system": { - "version": "0.0.55", - "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.55.tgz", - "integrity": "sha512-6cngWlE4oMXm+zrsbJxerP3wUNUFJcv/cE5kDfu0qO55OWK3fAeSOLW9td3xEVQlomjIW5knds1MzeMnBeCfqw==", + "version": "0.0.72", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz", + "integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==", "license": "MIT", "dependencies": { "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } @@ -14231,9 +14263,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" @@ -17894,21 +17926,37 @@ "type-fest": "^2.19.0" } }, - "node_modules/zustand": { - "version": "4.5.6", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.6.tgz", - "integrity": "sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ==", + "node_modules/zundo": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/zundo/-/zundo-2.3.0.tgz", + "integrity": "sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==", "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/charkour" }, "peerDependencies": { - "@types/react": ">=16.8", + "zustand": "^4.3.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "zustand": { + "optional": false + } + } + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", "immer": ">=9.0.6", - "react": ">=16.8" + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" }, "peerDependenciesMeta": { "@types/react": { @@ -17919,6 +17967,9 @@ }, "react": { "optional": true + }, + "use-sync-external-store": { + "optional": true } } } diff --git a/ui/package.json b/ui/package.json index daa1e06..18a36f3 100644 --- a/ui/package.json +++ b/ui/package.json @@ -29,12 +29,12 @@ "@radix-ui/react-tooltip": "^1.2.0", "@sentry/nextjs": "^9.28.1", "@stackframe/stack": "^2.8.28", - "@xyflow/react": "^12.5.5", + "@xyflow/react": "^12.9.2", "class-variance-authority": "^0.7.1", "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", @@ -52,7 +52,9 @@ "sonner": "^2.0.5", "tailwind-merge": "^3.2.0", "tailwindcss-animate": "^1.0.7", - "tw-animate-css": "^1.2.5" + "tw-animate-css": "^1.2.5", + "zundo": "^2.3.0", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/ui/src/app/workflow/WorkflowLayout.tsx b/ui/src/app/workflow/WorkflowLayout.tsx index 7d063f8..ca80ea6 100644 --- a/ui/src/app/workflow/WorkflowLayout.tsx +++ b/ui/src/app/workflow/WorkflowLayout.tsx @@ -6,13 +6,23 @@ 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..ea00b9d 100644 --- a/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx +++ b/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx @@ -2,21 +2,30 @@ 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 React, { 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 WorkflowHeader from "./components/WorkflowHeader"; +import { WorkflowTabs } from './components/WorkflowTabs'; import { WorkflowProvider } from "./contexts/WorkflowContext"; import { useWorkflowState } from "./hooks/useWorkflowState"; +import { layoutNodes } from './utils/layoutNodes'; // Define the node types dynamically based on the onSave prop const nodeTypes = { @@ -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; @@ -44,25 +69,27 @@ interface RenderWorkflowProps { }; initialTemplateContextVariables?: Record; initialWorkflowConfigurations?: WorkflowConfigurations; + user: { id: string; email?: string }; + getAccessToken: () => Promise; } -function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations }: RenderWorkflowProps) { +function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, user, getAccessToken }: 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, @@ -70,7 +97,21 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT onRun, saveTemplateContextVariables, saveWorkflowConfigurations - } = useWorkflowState({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations }); + } = useWorkflowState({ + initialWorkflowName, + workflowId, + initialFlow, + initialTemplateContextVariables, + initialWorkflowConfigurations, + user, + getAccessToken + }); + + // Memoize defaultEdgeOptions to prevent unnecessary re-renders + const defaultEdgeOptions = useMemo(() => ({ + animated: true, + type: "custom" + }), []); const headerActions = ( ); + const stickyTabs = ; + + // Memoize the context value to prevent unnecessary re-renders + const workflowContextValue = useMemo(() => ({ saveWorkflow }), [saveWorkflow]); + return ( - - -
+ + +
{ rfInstance.current = instance; + // Center the workflow on load + setTimeout(() => { + instance.fitView({ padding: 0.2, duration: 200, maxZoom: 0.75 }); + }, 0); }} - defaultEdgeOptions={{ animated: true, type: "custom" }} + defaultEdgeOptions={defaultEdgeOptions} + defaultViewport={initialFlow?.viewport} > - - - + + + + {/* 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)} /> + + + +
); } -export default RenderWorkflow; +// Memoize the component to prevent unnecessary re-renders when parent re-renders +export default React.memo(RenderWorkflow, (prevProps, nextProps) => { + // Only re-render if these specific props change + return ( + prevProps.workflowId === nextProps.workflowId && + prevProps.initialWorkflowName === nextProps.initialWorkflowName && + prevProps.user.id === nextProps.user.id && + prevProps.getAccessToken === nextProps.getAccessToken + // Note: We intentionally don't compare initialFlow, initialTemplateContextVariables, + // or initialWorkflowConfigurations because they're only used for initialization + ); +}); diff --git a/ui/src/app/workflow/[workflowId]/components/ConfigurationsDialog.tsx b/ui/src/app/workflow/[workflowId]/components/ConfigurationsDialog.tsx index 0017568..003c164 100644 --- a/ui/src/app/workflow/[workflowId]/components/ConfigurationsDialog.tsx +++ b/ui/src/app/workflow/[workflowId]/components/ConfigurationsDialog.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; @@ -11,7 +11,8 @@ interface ConfigurationsDialogProps { open: boolean; onOpenChange: (open: boolean) => void; workflowConfigurations: WorkflowConfigurations | null; - onSave: (configurations: WorkflowConfigurations) => Promise; + workflowName: string; + onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise; } const DEFAULT_VAD_CONFIG: VADConfiguration = { @@ -30,8 +31,10 @@ export const ConfigurationsDialog = ({ open, onOpenChange, workflowConfigurations, + workflowName, onSave }: ConfigurationsDialogProps) => { + const [name, setName] = useState(workflowName); const [vadConfig, setVadConfig] = useState( workflowConfigurations?.vad_configuration || DEFAULT_VAD_CONFIG ); @@ -54,7 +57,7 @@ export const ConfigurationsDialog = ({ ambient_noise_configuration: ambientNoiseConfig, max_call_duration: maxCallDuration, max_user_idle_timeout: maxUserIdleTimeout - }); + }, name); onOpenChange(false); } catch (error) { console.error("Failed to save configurations:", error); @@ -63,15 +66,16 @@ export const ConfigurationsDialog = ({ } }; - const handleDialogOpenChange = (isOpen: boolean) => { - onOpenChange(isOpen); - if (isOpen) { + // Sync state with props when dialog opens + useEffect(() => { + if (open) { + setName(workflowName); setVadConfig(workflowConfigurations?.vad_configuration || DEFAULT_VAD_CONFIG); setAmbientNoiseConfig(workflowConfigurations?.ambient_noise_configuration || DEFAULT_AMBIENT_NOISE_CONFIG); setMaxCallDuration(workflowConfigurations?.max_call_duration || 600); setMaxUserIdleTimeout(workflowConfigurations?.max_user_idle_timeout || 10); } - }; + }, [open, workflowName, workflowConfigurations]); const handleVadChange = (field: keyof VADConfiguration, value: string) => { const numValue = parseFloat(value); @@ -84,13 +88,35 @@ export const ConfigurationsDialog = ({ }; return ( - + Configurations
+ {/* Workflow Name Section */} +
+
+

Workflow Name

+

+ The name of your workflow +

+
+
+ + setName(e.target.value)} + placeholder="Enter workflow name" + /> +
+
+ {/* Voice Activity Detection Section */}
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/WorkflowControls.tsx b/ui/src/app/workflow/[workflowId]/components/WorkflowControls.tsx deleted file mode 100644 index 0ad6812..0000000 --- a/ui/src/app/workflow/[workflowId]/components/WorkflowControls.tsx +++ /dev/null @@ -1,178 +0,0 @@ -import dagre from '@dagrejs/dagre'; -import { ReactFlowInstance } from "@xyflow/react"; -import { Check, Pencil } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useState } from "react"; - -import { FlowEdge, FlowNode } from "@/components/flow/types"; -import { Button } from "@/components/ui/button"; -import { WorkflowConfigurations } from "@/types/workflow-configurations"; - -import { ConfigurationsDialog } from "./ConfigurationsDialog"; -import { TemplateContextVariablesDialog } from "./TemplateContextVariablesDialog"; - -interface WorkflowControlsProps { - workflowId: number; - workflowName: string; - isEditingName: boolean; - setIsEditingName: (isEditing: boolean) => void; - handleNameChange: (e: React.ChangeEvent) => void; - setIsAddNodePanelOpen: (isOpen: boolean) => void; - saveWorkflow: (updateWorkflowDefinition: boolean) => Promise; - nodes: FlowNode[]; - edges: FlowEdge[]; - setNodes: (nodes: FlowNode[] | ((nds: FlowNode[]) => FlowNode[])) => void; - rfInstance: React.RefObject | null>; - templateContextVariables?: Record; - saveTemplateContextVariables: (variables: Record) => Promise; - workflowConfigurations: WorkflowConfigurations | null; - saveWorkflowConfigurations: (configurations: WorkflowConfigurations) => Promise; -} - -export const layoutNodes = ( - nodes: FlowNode[], - edges: FlowEdge[], - rankdir: 'TB' | 'LR', - rfInstance: React.RefObject | null>, - saveWorkflow: (updateWorkflowDefinition: boolean) => Promise -) => { - const g = new dagre.graphlib.Graph(); - g.setGraph({ rankdir, nodesep: 250, ranksep: 250 }); - g.setDefaultEdgeLabel(() => ({})); - - // Sort nodes so startCall nodes come first and endCall nodes come last - const sortedNodes = [...nodes].sort((a, b) => { - if (a.type === 'startCall') return -1; - if (b.type === 'startCall') return 1; - if (a.type === 'endCall') return 1; - if (b.type === 'endCall') return -1; - return 0; - }); - - sortedNodes.forEach((node) => { - g.setNode(node.id, { width: 180, height: 60 }); - }); - - edges.forEach((edge) => { - g.setEdge(edge.source, edge.target); - }); - - dagre.layout(g); - - const newNodes = sortedNodes.map((node) => { - const nodeWithPosition = g.node(node.id); - return { - ...node, - position: { x: nodeWithPosition.x, y: nodeWithPosition.y } - }; - }); - - // Fit view to the new layout and save the viewport position - setTimeout(() => { - rfInstance.current?.fitView(); - saveWorkflow(true); - }, 0); - - return newNodes; -}; - -const WorkflowControls = ({ - workflowId, - workflowName, - isEditingName, - setIsEditingName, - handleNameChange, - setIsAddNodePanelOpen, - saveWorkflow, - nodes, - edges, - setNodes, - rfInstance, - templateContextVariables = {}, - saveTemplateContextVariables, - workflowConfigurations, - saveWorkflowConfigurations -}: WorkflowControlsProps) => { - const router = useRouter(); - const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false); - const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false); - - return ( -
-
-
- {isEditingName ? ( - e.key === 'Enter' && (setIsEditingName(false), saveWorkflow(false))} - /> - ) : ( -

{workflowName}

- )} - -
-
-
- - - - - - -
- - - - -
- ); -}; - -export default WorkflowControls; diff --git a/ui/src/app/workflow/[workflowId]/components/WorkflowExecutions.tsx b/ui/src/app/workflow/[workflowId]/components/WorkflowExecutions.tsx new file mode 100644 index 0000000..ff21926 --- /dev/null +++ b/ui/src/app/workflow/[workflowId]/components/WorkflowExecutions.tsx @@ -0,0 +1,348 @@ +"use client"; + +import { ChevronLeft, ChevronRight, Download, ExternalLink } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +import { getWorkflowApiV1WorkflowFetchWorkflowIdGet, getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet } from "@/client/sdk.gen"; +import { WorkflowRunResponseSchema } from "@/client/types.gen"; +import { FilterBuilder } from "@/components/filters/FilterBuilder"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { DISPOSITION_CODES } from "@/constants/dispositionCodes"; +import { useUserConfig } from '@/context/UserConfigContext'; +import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant'; +import { downloadFile } from "@/lib/files"; +import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters"; +import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters"; + +interface WorkflowExecutionsProps { + workflowId: number; + searchParams: URLSearchParams; +} + +export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecutionsProps) { + const router = useRouter(); + const [workflowRuns, setWorkflowRuns] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(() => { + const pageParam = searchParams.get('page'); + return pageParam ? parseInt(pageParam, 10) : 1; + }); + const [totalPages, setTotalPages] = useState(1); + const [totalCount, setTotalCount] = useState(0); + const [isExecutingFilters, setIsExecutingFilters] = useState(false); + const [configuredAttributes, setConfiguredAttributes] = useState(availableAttributes); + + const { accessToken } = useUserConfig(); + + // Initialize filters from URL + const [activeFilters, setActiveFilters] = useState(() => { + return decodeFiltersFromURL(searchParams, availableAttributes); + }); + + const formatDate = (dateString: string) => new Date(dateString).toLocaleString(); + + // Load disposition codes from workflow configuration + useEffect(() => { + const loadDispositionCodes = async () => { + if (!accessToken) return; + try { + const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({ + path: { workflow_id: Number(workflowId) }, + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + + const workflow = response.data; + if (workflow?.call_disposition_codes) { + // Update the disposition code attribute with actual options + const updatedAttributes = configuredAttributes.map(attr => { + if (attr.id === 'dispositionCode') { + return { + ...attr, + config: { + ...attr.config, + options: Object.keys(workflow.call_disposition_codes || {}).length > 0 + ? Object.keys(workflow.call_disposition_codes || {}) + : [...DISPOSITION_CODES] + } + }; + } + return attr; + }); + setConfiguredAttributes(updatedAttributes); + } + } catch (err) { + console.error("Failed to load disposition codes:", err); + } + }; + + loadDispositionCodes(); + }, [workflowId, accessToken]); + + const fetchWorkflowRuns = useCallback(async (page: number, filters?: ActiveFilter[]) => { + if (!accessToken) return; + try { + setLoading(true); + // Prepare filter data for API + let filterParam = undefined; + if (filters && filters.length > 0) { + const filterData = filters.map(filter => ({ + attribute: filter.attribute.id, + type: filter.attribute.type, + value: filter.value + })); + filterParam = JSON.stringify(filterData); + } + + const response = await getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet({ + path: { workflow_id: Number(workflowId) }, + query: { + page: page, + limit: 50, + ...(filterParam && { filters: filterParam }) + }, + headers: { + 'Authorization': `Bearer ${accessToken}`, + } + }); + + if (response.error) { + throw new Error("Failed to fetch workflow runs"); + } + + if (response.data) { + setWorkflowRuns(response.data.runs || []); + setTotalPages(response.data.total_pages || 1); + setTotalCount(response.data.total_count || 0); + setCurrentPage(response.data.page || 1); + } + setError(null); + } catch (err) { + console.error("Error fetching workflow runs:", err); + setError("Failed to load workflow runs"); + } finally { + setLoading(false); + } + }, [workflowId, accessToken]); + + const updatePageInUrl = useCallback((page: number, filters?: ActiveFilter[]) => { + const params = new URLSearchParams(); + params.set('tab', 'executions'); + params.set('page', page.toString()); + + // Add filters to URL if present + if (filters && filters.length > 0) { + const filterString = encodeFiltersToURL(filters); + if (filterString) { + const filterParams = new URLSearchParams(filterString); + filterParams.forEach((value, key) => params.set(key, value)); + } + } + + router.push(`/workflow/${workflowId}?${params.toString()}`, { scroll: false }); + }, [router, workflowId]); + + useEffect(() => { + fetchWorkflowRuns(currentPage, activeFilters); + }, [currentPage, activeFilters, fetchWorkflowRuns]); + + const handleApplyFilters = useCallback(async () => { + setIsExecutingFilters(true); + setCurrentPage(1); // Reset to first page when applying filters + updatePageInUrl(1, activeFilters); + await fetchWorkflowRuns(1, activeFilters); + setIsExecutingFilters(false); + }, [activeFilters, fetchWorkflowRuns, updatePageInUrl]); + + const handleFiltersChange = useCallback((filters: ActiveFilter[]) => { + setActiveFilters(filters); + }, []); + + const handleClearFilters = useCallback(async () => { + setIsExecutingFilters(true); + setCurrentPage(1); + updatePageInUrl(1, []); // Clear filters from URL + await fetchWorkflowRuns(1, []); // Fetch all workflows without filters + setIsExecutingFilters(false); + }, [fetchWorkflowRuns, updatePageInUrl]); + + return ( +
+
+

Workflow Run History

+ +
+ {loading ? ( +
+
Loading workflow runs...
+
+ ) : error ? ( +
+ {error} +
+ ) : workflowRuns.length === 0 ? ( +
+

No workflow runs found

+
+ ) : ( + + + Workflow Runs + + Showing {workflowRuns.length} of {totalCount} total runs + + + +
+ + + + ID + Status + Created At + Duration + Disposition + Dograh Token + Actions + + + + {workflowRuns.map((run) => ( + window.open(`/workflow/${workflowId}/run/${run.id}`, '_blank')} + > + #{run.id} + + + {run.is_completed ? "Completed" : "In Progress"} + + + {formatDate(run.created_at)} + + {typeof run.cost_info?.call_duration_seconds === 'number' + ? `${run.cost_info.call_duration_seconds.toFixed(1)}s` + : "-"} + + + {run.gathered_context?.mapped_call_disposition ? ( + + {run.gathered_context.mapped_call_disposition as string} + + ) : ( + - + )} + + + {typeof run.cost_info?.dograh_token_usage === 'number' + ? `${run.cost_info.dograh_token_usage.toFixed(2)}` + : "-"} + + +
+ {run.transcript_url && ( + + )} + {run.recording_url && ( + + )} + +
+
+
+ ))} +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {currentPage} of {totalPages} +

+
+ + +
+
+ )} +
+
+ )} +
+ ); +} diff --git a/ui/src/app/workflow/[workflowId]/components/WorkflowHeader.tsx b/ui/src/app/workflow/[workflowId]/components/WorkflowHeader.tsx index 949d9d2..1ef1875 100644 --- a/ui/src/app/workflow/[workflowId]/components/WorkflowHeader.tsx +++ b/ui/src/app/workflow/[workflowId]/components/WorkflowHeader.tsx @@ -16,7 +16,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { WORKFLOW_RUN_MODES } from '@/constants/workflowRunModes'; import { useOnboarding } from '@/context/OnboardingContext'; import { useUserConfig } from "@/context/UserConfigContext"; -import { useAuth } from '@/lib/auth'; interface WorkflowHeaderProps { isDirty: boolean; @@ -26,6 +25,8 @@ interface WorkflowHeaderProps { workflowId: number; workflowValidationErrors: WorkflowError[]; saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise; + user: { id: string; email?: string }; + getAccessToken: () => Promise; } const handleExport = (workflow_name: string, workflow_definition: ReactFlowJsonObject | undefined) => { @@ -57,7 +58,7 @@ const handleExport = (workflow_name: string, workflow_definition: ReactFlowJsonO URL.revokeObjectURL(url); }; -const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, workflowValidationErrors, saveWorkflow }: WorkflowHeaderProps) => { +const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, workflowValidationErrors, saveWorkflow, user, getAccessToken }: WorkflowHeaderProps) => { const router = useRouter(); const { userConfig, saveUserConfig } = useUserConfig(); const { hasSeenTooltip, markTooltipSeen } = useOnboarding(); @@ -70,7 +71,6 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, const [phoneChanged, setPhoneChanged] = useState(false); const [validationDialogOpen, setValidationDialogOpen] = useState(false); const [configureDialogOpen, setConfigureDialogOpen] = useState(false); - const { user, getAccessToken } = useAuth(); const webCallButtonRef = useRef(null); const hasValidationErrors = workflowValidationErrors.length > 0; @@ -211,39 +211,73 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, - - - + + + + + + + {(isDirty || hasValidationErrors) && ( + + {isDirty ? 'Save the workflow before exporting' : 'Fix validation errors before exporting'} + + )} + + + + + + + + {(isDirty || hasValidationErrors) && ( + + {isDirty ? 'Save the workflow before testing' : 'Fix validation errors before testing'} + + )} + + + + + + + + {(isDirty || hasValidationErrors) && ( + + {isDirty ? 'Save the workflow before making a call' : 'Fix validation errors before making a call'} + + )} + {isDirty ? ( + +
+ ); +}; diff --git a/ui/src/app/workflow/[workflowId]/components/index.ts b/ui/src/app/workflow/[workflowId]/components/index.ts index 6b387b2..b2a311f 100644 --- a/ui/src/app/workflow/[workflowId]/components/index.ts +++ b/ui/src/app/workflow/[workflowId]/components/index.ts @@ -1,2 +1 @@ -export * from './WorkflowControls'; export * from './WorkflowHeader'; diff --git a/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts b/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts index 7753140..1ba279e 100644 --- a/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts +++ b/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts @@ -5,13 +5,11 @@ import { OnEdgesChange, OnNodesChange, ReactFlowInstance, - useEdgesState, - useNodesState } from "@xyflow/react"; -import { addEdge } from "@xyflow/react"; import { useRouter } from "next/navigation"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; +import { useWorkflowStore } from "@/app/workflow/[workflowId]/stores/workflowStore"; import { createWorkflowRunApiV1WorkflowWorkflowIdRunsPost, updateWorkflowApiV1WorkflowWorkflowIdPut, @@ -19,10 +17,9 @@ import { } from "@/client"; import { WorkflowError } from "@/client/types.gen"; import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types"; -import { useAuth } from '@/lib/auth'; import logger from '@/lib/logger'; -import { getRandomId } from "@/lib/utils"; -import { DEFAULT_WORKFLOW_CONFIGURATIONS,WorkflowConfigurations } from "@/types/workflow-configurations"; +import { getNextNodeId, getRandomId } from "@/lib/utils"; +import { WorkflowConfigurations } from "@/types/workflow-configurations"; export function getDefaultAllowInterrupt(type: string = NodeType.START_CALL): boolean { switch (type) { @@ -49,9 +46,9 @@ const defaultNodes: FlowNode[] = [ }, ]; -const getNewNode = (type: string, position: { x: number, y: number }) => { +const getNewNode = (type: string, position: { x: number, y: number }, existingNodes: FlowNode[]) => { return { - id: `${getRandomId()}`, + id: getNextNodeId(existingNodes), type, position, data: { @@ -82,14 +79,56 @@ interface UseWorkflowStateProps { }; initialTemplateContextVariables?: Record; initialWorkflowConfigurations?: WorkflowConfigurations; + user: { id: string; email?: string }; // Minimal user type needed + getAccessToken: () => Promise; } -export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations }: UseWorkflowStateProps) => { - const rfInstance = useRef | null>(null); +export const useWorkflowState = ({ + initialWorkflowName, + workflowId, + initialFlow, + initialTemplateContextVariables, + initialWorkflowConfigurations, + user, + getAccessToken +}: UseWorkflowStateProps) => { const router = useRouter(); - const { user, getAccessToken } = useAuth(); - const [nodes, setNodes] = useNodesState( - initialFlow?.nodes?.length + const rfInstance = useRef | null>(null); + + // Get state and actions from the store + const { + nodes, + edges, + workflowName, + isDirty, + isAddNodePanelOpen, + workflowValidationErrors, + templateContextVariables, + workflowConfigurations, + initializeWorkflow, + setNodes, + setEdges, + setWorkflowName, + setIsDirty, + setIsAddNodePanelOpen, + setWorkflowValidationErrors, + setTemplateContextVariables, + setWorkflowConfigurations, + clearValidationErrors, + markNodeAsInvalid, + markEdgeAsInvalid, + setRfInstance, + } = useWorkflowStore(); + + // Get undo/redo functions from the store + const undo = useWorkflowStore((state) => state.undo); + const redo = useWorkflowStore((state) => state.redo); + const canUndo = useWorkflowStore((state) => state.canUndo()); + const canRedo = useWorkflowStore((state) => state.canRedo()); + + // Initialize workflow on mount + useEffect(() => { + const initialNodes = initialFlow?.nodes?.length ? initialFlow.nodes.map(node => ({ ...node, data: { @@ -100,43 +139,74 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, : getDefaultAllowInterrupt(node.type), } })) - : defaultNodes - ); - const [edges, setEdges] = useEdgesState(initialFlow?.edges ?? []); - const [isAddNodePanelOpen, setIsAddNodePanelOpen] = useState(false); - const [workflowName, setWorkflowName] = useState(initialWorkflowName); - const [isEditingName, setIsEditingName] = useState(false); - const [isDirty, setIsDirty] = useState(false); - const [workflowValidationErrors, setWorkflowValidationErrors] = useState([]); - const [templateContextVariables, setTemplateContextVariables] = useState>( - initialTemplateContextVariables || {} - ); - const [workflowConfigurations, setWorkflowConfigurations] = useState( - initialWorkflowConfigurations || DEFAULT_WORKFLOW_CONFIGURATIONS - ); + : defaultNodes; + + initializeWorkflow( + workflowId, + initialWorkflowName, + initialNodes, + initialFlow?.edges ?? [], + initialTemplateContextVariables, + initialWorkflowConfigurations + ); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Set up keyboard shortcuts for undo/redo + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Check if we're in an input field + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { + return; + } + + // Undo: Cmd/Ctrl + Z + if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + if (canUndo) { + undo(); + } + } + // Redo: Cmd/Ctrl + Shift + Z or Cmd/Ctrl + Y + else if ( + ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'z') || + ((e.metaKey || e.ctrlKey) && e.key === 'y') + ) { + e.preventDefault(); + if (canRedo) { + redo(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [undo, redo, canUndo, canRedo]); const handleNodeSelect = useCallback((nodeType: string) => { - /* - Used to add new node to the workflow. Receives nodeType as param. - Example: nodeType can be agentNode/ startNode etc. as defined by NodeType in - types.ts + if (!rfInstance.current) return; - We then pass nodeTypes which contais the NodeType keyword and the component. - Those components then contain all the component speecific functioanlity like edit - button etc. + const position = rfInstance.current.screenToFlowPosition({ + x: window.innerWidth / 2, + y: window.innerHeight / 2, + }); - */ - const newNode = getNewNode(nodeType, { x: 150, y: 150 }); - setNodes((nds) => [...nds, newNode]); + const newNode = { + ...getNewNode(nodeType, position, nodes), + selected: true, // Mark the new node as selected + }; + + // Use addNodes from ReactFlow instance + rfInstance.current.addNodes([newNode]); setIsAddNodePanelOpen(false); - }, [setNodes, setIsAddNodePanelOpen]); + }, [nodes, setIsAddNodePanelOpen]); const handleNameChange = (e: React.ChangeEvent) => { setWorkflowName(e.target.value); setIsDirty(true); }; - // Validate workflow function (without saving) + // Validate workflow function const validateWorkflow = useCallback(async () => { if (!user) return; try { @@ -150,106 +220,48 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, }, }); - // Reset validation state for all nodes and edges - setNodes((nds) => nds.map(node => ({ ...node, data: { ...node.data, invalid: false, validationMessage: null } }))); - setEdges((eds) => eds.map(edge => ({ ...edge, data: { ...edge.data, invalid: false, validationMessage: null } }))); - setWorkflowValidationErrors([]); + // Clear validation errors first + clearValidationErrors(); - // Check if we have a 422 error with validation errors + // Check if we have validation errors if (response.error) { - // The error could be in different formats depending on the status code let errors: WorkflowError[] = []; - - // Type assertion for validation response structure const errorResponse = response.error as { is_valid?: boolean; errors?: WorkflowError[]; detail?: { errors: WorkflowError[] }; }; - // For 422 responses, the error contains the validation response if (errorResponse.is_valid === false && errorResponse.errors) { errors = errorResponse.errors; - } - // Also check for detail.errors format - else if (errorResponse.detail?.errors) { + } else if (errorResponse.detail?.errors) { errors = errorResponse.detail.errors; } if (errors.length > 0) { // Update nodes with validation state - setNodes((nds) => nds.map(node => { - const nodeErrors = errors.filter((err) => err.kind === 'node' && err.id === node.id); - if (nodeErrors.length > 0) { - return { - ...node, - data: { - ...node.data, - invalid: true, - validationMessage: nodeErrors.map(err => err.message).join(', ') - } - }; + errors.forEach((error) => { + if (error.kind === 'node' && error.id) { + markNodeAsInvalid(error.id, error.message); + } else if (error.kind === 'edge' && error.id) { + markEdgeAsInvalid(error.id, error.message); } - return node; - })); + }); - // Update edges with validation state - setEdges((eds) => eds.map(edge => { - const edgeErrors = errors.filter((err) => err.kind === 'edge' && err.id === edge.id); - if (edgeErrors.length > 0) { - return { - ...edge, - data: { - ...edge.data, - invalid: true, - validationMessage: edgeErrors.map(err => err.message).join(', ') - } - }; - } - return edge; - })); - - // Set workflow validation errors (all types of errors) setWorkflowValidationErrors(errors); } } else if (response.data) { - // If we get a 200 response with data, check if it's valid if (response.data.is_valid === false && response.data.errors) { const errors = response.data.errors; - // Update nodes with validation state - setNodes((nds) => nds.map(node => { - const nodeErrors = errors.filter((err) => err.kind === 'node' && err.id === node.id); - if (nodeErrors.length > 0) { - return { - ...node, - data: { - ...node.data, - invalid: true, - validationMessage: nodeErrors.map((err) => err.message).join(', ') - } - }; + errors.forEach((error) => { + if (error.kind === 'node' && error.id) { + markNodeAsInvalid(error.id, error.message); + } else if (error.kind === 'edge' && error.id) { + markEdgeAsInvalid(error.id, error.message); } - return node; - })); + }); - // Update edges with validation state - setEdges((eds) => eds.map(edge => { - const edgeErrors = errors.filter((err) => err.kind === 'edge' && err.id === edge.id); - if (edgeErrors.length > 0) { - return { - ...edge, - data: { - ...edge.data, - invalid: true, - validationMessage: edgeErrors.map((err) => err.message).join(', ') - } - }; - } - return edge; - })); - - // Set workflow validation errors (all types of errors) setWorkflowValidationErrors(errors); } else { logger.info('Workflow is valid'); @@ -258,13 +270,10 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, } catch (error) { logger.error(`Unexpected validation error: ${error}`); } - }, [workflowId, user, getAccessToken, setNodes, setEdges]); + }, [workflowId, user, getAccessToken, clearValidationErrors, markNodeAsInvalid, markEdgeAsInvalid, setWorkflowValidationErrors]); - // Save function + // Save workflow function const saveWorkflow = useCallback(async (updateWorkflowDefinition: boolean = true) => { - /* - validates and saves workflow - */ if (!user || !rfInstance.current) return; const flow = rfInstance.current.toObject(); const accessToken = await getAccessToken(); @@ -283,60 +292,43 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, }); setIsDirty(false); } catch (error) { - logger.error(`Error auto-saving workflow: ${error}`); + logger.error(`Error saving workflow: ${error}`); } // Validate after saving await validateWorkflow(); - }, [workflowId, workflowName, setIsDirty, user, getAccessToken, rfInstance, validateWorkflow]); - - // Handle debounced save - REMOVED AUTOSAVE FUNCTIONALITY - // const debouncedSave = useCallback(() => { - // // Clear any existing timeout - // if (saveTimeoutRef.current) { - // clearTimeout(saveTimeoutRef.current); - // } - - // // Set a new timeout - // saveTimeoutRef.current = setTimeout(() => { - // saveWorkflow(); - // saveTimeoutRef.current = null; - // }, 2000); - // }, [saveWorkflow]); + }, [workflowId, workflowName, setIsDirty, user, getAccessToken, validateWorkflow]); const onConnect: OnConnect = useCallback((connection) => { - setEdges((eds) => addEdge({ + if (!rfInstance.current) return; + + // Use addEdges from ReactFlow instance + rfInstance.current.addEdges([{ ...connection, + id: `${connection.source}-${connection.target}`, data: { label: '', condition: '' } - }, eds)); - setIsDirty(true); - // Trigger validation after connection - setTimeout(() => validateWorkflow(), 100); - }, [setEdges, validateWorkflow]); + }]); + }, []); const onEdgesChange: OnEdgesChange = useCallback( - (changes) => setEdges((eds) => { - const newEdges = applyEdgeChanges(changes, eds) as FlowEdge[]; - setIsDirty(true); - // Trigger validation after edge changes - setTimeout(() => validateWorkflow(), 100); - return newEdges; - }), - [setEdges, validateWorkflow], + (changes) => { + const currentEdges = useWorkflowStore.getState().edges; + const newEdges = applyEdgeChanges(changes, currentEdges) as FlowEdge[]; + setEdges(newEdges, changes); + }, + [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], + (changes) => { + const currentNodes = useWorkflowStore.getState().nodes; + const newNodes = applyNodeChanges(changes, currentNodes) as FlowNode[]; + setNodes(newNodes, changes); + }, + [setNodes], ); const onRun = async (mode: string) => { @@ -358,7 +350,7 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, router.push(`/workflow/${workflowId}/run/${response.data?.id}`); }; - // Save template context variables function + // Save template context variables const saveTemplateContextVariables = useCallback(async (variables: Record) => { if (!user) return; const accessToken = await getAccessToken(); @@ -382,10 +374,10 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, logger.error(`Error saving template context variables: ${error}`); throw error; } - }, [workflowId, workflowName, user, getAccessToken]); + }, [workflowId, workflowName, user, getAccessToken, setTemplateContextVariables]); - // Save workflow configurations function - const saveWorkflowConfigurations = useCallback(async (configurations: WorkflowConfigurations) => { + // Save workflow configurations + const saveWorkflowConfigurations = useCallback(async (configurations: WorkflowConfigurations, newWorkflowName: string) => { if (!user) return; const accessToken = await getAccessToken(); try { @@ -394,7 +386,7 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, workflow_id: workflowId, }, body: { - name: workflowName, + name: newWorkflowName, workflow_definition: null, workflow_configurations: configurations as Record, }, @@ -403,36 +395,38 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, }, }); setWorkflowConfigurations(configurations); + setWorkflowName(newWorkflowName); logger.info('Workflow configurations saved successfully'); } catch (error) { logger.error(`Error saving workflow configurations: ${error}`); throw error; } - }, [workflowId, workflowName, user, getAccessToken]); + }, [workflowId, user, getAccessToken, setWorkflowConfigurations, setWorkflowName]); + + // Update rfInstance when it changes + useEffect(() => { + if (rfInstance.current) { + setRfInstance(rfInstance.current); + } + }, [setRfInstance]); // Validate workflow on mount useEffect(() => { validateWorkflow(); }, []); // eslint-disable-line react-hooks/exhaustive-deps - // Removed useEffect for clearing auto-save timeout as autosave is disabled - return { rfInstance, nodes, edges, isAddNodePanelOpen, workflowName, - isEditingName, isDirty, workflowValidationErrors, templateContextVariables, workflowConfigurations, setNodes, - setEdges, setIsAddNodePanelOpen, - setWorkflowName, - setIsEditingName, handleNodeSelect, handleNameChange, saveWorkflow, @@ -441,6 +435,11 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, onNodesChange, onRun, saveTemplateContextVariables, - saveWorkflowConfigurations + saveWorkflowConfigurations, + // Export undo/redo state + undo, + redo, + canUndo, + canRedo, }; }; diff --git a/ui/src/app/workflow/[workflowId]/page.tsx b/ui/src/app/workflow/[workflowId]/page.tsx index a764bef..3f24ca3 100644 --- a/ui/src/app/workflow/[workflowId]/page.tsx +++ b/ui/src/app/workflow/[workflowId]/page.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useParams } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { useParams, useSearchParams } from 'next/navigation'; +import { useEffect, useMemo, useState } from 'react'; import RenderWorkflow from '@/app/workflow/[workflowId]/RenderWorkflow'; import { getWorkflowApiV1WorkflowFetchWorkflowIdGet } from '@/client/sdk.gen'; @@ -13,14 +13,20 @@ import logger from '@/lib/logger'; import { DEFAULT_WORKFLOW_CONFIGURATIONS,WorkflowConfigurations } from '@/types/workflow-configurations'; import WorkflowLayout from '../WorkflowLayout'; +import { WorkflowExecutions } from './components/WorkflowExecutions'; +import { WorkflowTabs } from './components/WorkflowTabs'; export default function WorkflowDetailPage() { const params = useParams(); + const searchParams = useSearchParams(); const [workflow, setWorkflow] = useState(undefined); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const { user, getAccessToken, redirectToLogin, loading: authLoading } = useAuth(); + // Get current tab from URL, default to 'editor' + const currentTab = (searchParams.get('tab') as 'editor' | 'executions') || 'editor'; + // Redirect if not authenticated useEffect(() => { if (!authLoading && !user) { @@ -56,16 +62,22 @@ export default function WorkflowDetailPage() { } }, [params.workflowId, user, getAccessToken]); + const stickyTabs = workflow ? : null; + + // Memoize user and getAccessToken to prevent unnecessary re-renders + const stableUser = useMemo(() => user, [user?.id]); + const stableGetAccessToken = useMemo(() => getAccessToken, [getAccessToken]); + if (loading) { return ( - + ); } else if (error || !workflow) { return ( - +
{error || 'Workflow not found'}
@@ -73,19 +85,36 @@ export default function WorkflowDetailPage() { ); } else { + // Render both views but hide the inactive one using absolute positioning + // This preserves state when switching tabs return ( - // We are sending custom header actions to WorkflowLayout from RenderWorkflow component - || {}} - initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS} - /> + <> + {/* Editor view */} +
+ {stableUser && ( + || {}} + initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS} + user={stableUser} + getAccessToken={stableGetAccessToken} + /> + )} +
+ + {/* Executions view */} +
+ + + +
+ ); } } diff --git a/ui/src/app/workflow/[workflowId]/runs/page.tsx b/ui/src/app/workflow/[workflowId]/runs/page.tsx index 877d667..14ec82e 100644 --- a/ui/src/app/workflow/[workflowId]/runs/page.tsx +++ b/ui/src/app/workflow/[workflowId]/runs/page.tsx @@ -1,359 +1,19 @@ "use client"; -import { ArrowLeft, ChevronLeft, ChevronRight, Download, ExternalLink } from "lucide-react"; -import Link from "next/link"; import { useParams, useRouter, useSearchParams } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; - -import { getWorkflowApiV1WorkflowFetchWorkflowIdGet,getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet } from "@/client/sdk.gen"; -import { WorkflowRunResponseSchema } from "@/client/types.gen"; -import { FilterBuilder } from "@/components/filters/FilterBuilder"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { DISPOSITION_CODES } from "@/constants/dispositionCodes"; -import { useUserConfig } from '@/context/UserConfigContext'; -import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant'; -import { downloadFile } from "@/lib/files"; -import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters"; -import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters"; - -import WorkflowLayout from '../../WorkflowLayout'; +import { useEffect } from "react"; export default function WorkflowRunsPage() { const { workflowId } = useParams(); const router = useRouter(); const searchParams = useSearchParams(); - const [workflowRuns, setWorkflowRuns] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [currentPage, setCurrentPage] = useState(() => { - const pageParam = searchParams.get('page'); - return pageParam ? parseInt(pageParam, 10) : 1; - }); - const [totalPages, setTotalPages] = useState(1); - const [totalCount, setTotalCount] = useState(0); - const [isExecutingFilters, setIsExecutingFilters] = useState(false); - const [configuredAttributes, setConfiguredAttributes] = useState(availableAttributes); - const { accessToken } = useUserConfig(); - - // Initialize filters from URL - const [activeFilters, setActiveFilters] = useState(() => { - return decodeFiltersFromURL(searchParams, availableAttributes); - }); - - const formatDate = (dateString: string) => new Date(dateString).toLocaleString(); - - - // Load disposition codes from workflow configuration + // Redirect to main workflow page with executions tab useEffect(() => { - const loadDispositionCodes = async () => { - if (!accessToken) return; - try { - const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({ - path: { workflow_id: Number(workflowId) }, - headers: { 'Authorization': `Bearer ${accessToken}` } - }); + const params = new URLSearchParams(searchParams.toString()); + params.set('tab', 'executions'); + router.replace(`/workflow/${workflowId}?${params.toString()}`); + }, [workflowId, router, searchParams]); - const workflow = response.data; - if (workflow?.call_disposition_codes) { - // Update the disposition code attribute with actual options - const updatedAttributes = configuredAttributes.map(attr => { - if (attr.id === 'dispositionCode') { - return { - ...attr, - config: { - ...attr.config, - options: Object.keys(workflow.call_disposition_codes || {}).length > 0 - ? Object.keys(workflow.call_disposition_codes || {}) - : [...DISPOSITION_CODES] - } - }; - } - return attr; - }); - setConfiguredAttributes(updatedAttributes); - } - } catch (err) { - console.error("Failed to load disposition codes:", err); - } - }; - - loadDispositionCodes(); - }, [workflowId, accessToken, configuredAttributes]); - - const fetchWorkflowRuns = useCallback(async (page: number, filters?: ActiveFilter[]) => { - if (!accessToken) return; - try { - setLoading(true); - // Prepare filter data for API - let filterParam = undefined; - if (filters && filters.length > 0) { - const filterData = filters.map(filter => ({ - attribute: filter.attribute.id, - type: filter.attribute.type, - value: filter.value - })); - filterParam = JSON.stringify(filterData); - } - - const response = await getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet({ - path: { workflow_id: Number(workflowId) }, - query: { - page: page, - limit: 50, - ...(filterParam && { filters: filterParam }) - }, - headers: { - 'Authorization': `Bearer ${accessToken}`, - } - }); - - if (response.error) { - throw new Error("Failed to fetch workflow runs"); - } - - if (response.data) { - setWorkflowRuns(response.data.runs || []); - setTotalPages(response.data.total_pages || 1); - setTotalCount(response.data.total_count || 0); - setCurrentPage(response.data.page || 1); - } - setError(null); - } catch (err) { - console.error("Error fetching workflow runs:", err); - setError("Failed to load workflow runs"); - } finally { - setLoading(false); - } - }, [workflowId, accessToken]); - - const updatePageInUrl = useCallback((page: number, filters?: ActiveFilter[]) => { - const params = new URLSearchParams(); - params.set('page', page.toString()); - - // Add filters to URL if present - if (filters && filters.length > 0) { - const filterString = encodeFiltersToURL(filters); - if (filterString) { - const filterParams = new URLSearchParams(filterString); - filterParams.forEach((value, key) => params.set(key, value)); - } - } - - router.push(`/workflow/${workflowId}/runs?${params.toString()}`); - }, [router, workflowId]); - - useEffect(() => { - fetchWorkflowRuns(currentPage, activeFilters); - }, [currentPage, activeFilters, fetchWorkflowRuns]); - - const handleApplyFilters = useCallback(async () => { - setIsExecutingFilters(true); - setCurrentPage(1); // Reset to first page when applying filters - updatePageInUrl(1, activeFilters); - await fetchWorkflowRuns(1, activeFilters); - setIsExecutingFilters(false); - }, [activeFilters, fetchWorkflowRuns, updatePageInUrl]); - - const handleFiltersChange = useCallback((filters: ActiveFilter[]) => { - setActiveFilters(filters); - }, []); - - const handleClearFilters = useCallback(async () => { - setIsExecutingFilters(true); - setCurrentPage(1); - updatePageInUrl(1, []); // Clear filters from URL - await fetchWorkflowRuns(1, []); // Fetch all workflows without filters - setIsExecutingFilters(false); - }, [fetchWorkflowRuns, updatePageInUrl]); - - const backButton = ( - - - - ); - - return ( - -
-
-

Workflow Run History

- -
- {loading ? ( -
-
Loading workflow runs...
-
- ) : error ? ( -
- {error} -
- ) : workflowRuns.length === 0 ? ( -
-

No workflow runs found

-
- ) : ( - - - Workflow Runs - - Showing {workflowRuns.length} of {totalCount} total runs - - - -
- - - - ID - Status - Created At - Duration - Disposition - Dograh Token - Actions - - - - {workflowRuns.map((run) => ( - router.push(`/workflow/${workflowId}/run/${run.id}`)} - > - #{run.id} - - - {run.is_completed ? "Completed" : "In Progress"} - - - {formatDate(run.created_at)} - - {typeof run.cost_info?.call_duration_seconds === 'number' - ? `${run.cost_info.call_duration_seconds.toFixed(1)}s` - : "-"} - - - {run.gathered_context?.mapped_call_disposition ? ( - - {run.gathered_context.mapped_call_disposition as string} - - ) : ( - - - )} - - - {typeof run.cost_info?.dograh_token_usage === 'number' - ? `${run.cost_info.dograh_token_usage.toFixed(2)}` - : "-"} - - -
- {run.transcript_url && ( - - )} - {run.recording_url && ( - - )} - -
-
-
- ))} -
-
-
- - {/* Pagination */} - {totalPages > 1 && ( -
-

- Page {currentPage} of {totalPages} -

-
- - -
-
- )} -
-
- )} -
-
- ); + return null; } diff --git a/ui/src/app/workflow/[workflowId]/stores/workflowStore.ts b/ui/src/app/workflow/[workflowId]/stores/workflowStore.ts new file mode 100644 index 0000000..caa57ff --- /dev/null +++ b/ui/src/app/workflow/[workflowId]/stores/workflowStore.ts @@ -0,0 +1,399 @@ +import { ReactFlowInstance } from '@xyflow/react'; +import { EdgeChange,NodeChange } from '@xyflow/system'; +import { create } from 'zustand'; + +import { WorkflowError } from '@/client/types.gen'; +import { FlowEdge, FlowNode } from '@/components/flow/types'; +import { DEFAULT_WORKFLOW_CONFIGURATIONS, WorkflowConfigurations } from '@/types/workflow-configurations'; + +interface HistoryState { + nodes: FlowNode[]; + edges: FlowEdge[]; + workflowName: string; +} + +interface WorkflowState { + // Workflow identification + workflowId: number | null; + workflowName: string; + + // Flow state + nodes: FlowNode[]; + edges: FlowEdge[]; + + // History for undo/redo + history: HistoryState[]; + historyIndex: number; + + // UI state (not tracked in history) + isDirty: boolean; + isAddNodePanelOpen: boolean; + + // Validation state + workflowValidationErrors: WorkflowError[]; + + // Configuration + templateContextVariables: Record; + workflowConfigurations: WorkflowConfigurations | null; + + // ReactFlow instance reference + rfInstance: ReactFlowInstance | null; +} + +interface WorkflowActions { + // Initialization + initializeWorkflow: ( + workflowId: number, + workflowName: string, + nodes: FlowNode[], + edges: FlowEdge[], + templateContextVariables?: Record, + workflowConfigurations?: WorkflowConfigurations | null + ) => void; + + // History management + pushToHistory: () => void; + undo: () => void; + redo: () => void; + canUndo: () => boolean; + canRedo: () => boolean; + + // Node operations + setNodes: (nodes: FlowNode[], changes?: NodeChange[]) => void; + addNode: (node: FlowNode) => void; + updateNode: (nodeId: string, updates: Partial) => void; + deleteNode: (nodeId: string) => void; + + // Edge operations + setEdges: (edges: FlowEdge[], changes?: EdgeChange[]) => void; + addEdge: (edge: FlowEdge) => void; + updateEdge: (edgeId: string, updates: Partial) => void; + deleteEdge: (edgeId: string) => void; + + // Workflow metadata + setWorkflowName: (name: string) => void; + setTemplateContextVariables: (variables: Record) => void; + setWorkflowConfigurations: (configurations: WorkflowConfigurations) => void; + + // UI state + setIsDirty: (isDirty: boolean) => void; + setIsAddNodePanelOpen: (isOpen: boolean) => void; + + // Validation + setWorkflowValidationErrors: (errors: WorkflowError[]) => void; + markNodeAsInvalid: (nodeId: string, message: string) => void; + markEdgeAsInvalid: (edgeId: string, message: string) => void; + clearValidationErrors: () => void; + + // ReactFlow instance + setRfInstance: (instance: ReactFlowInstance | null) => void; + + // Clear store (for cleanup) + clearStore: () => void; +} + +type WorkflowStore = WorkflowState & WorkflowActions; + +const MAX_HISTORY_SIZE = 50; + +// Create the store +export const useWorkflowStore = create((set, get) => ({ + // Initial state + workflowId: null, + workflowName: '', + nodes: [], + edges: [], + history: [], + historyIndex: -1, + isDirty: false, + isAddNodePanelOpen: false, + workflowValidationErrors: [], + templateContextVariables: {}, + workflowConfigurations: DEFAULT_WORKFLOW_CONFIGURATIONS, + rfInstance: null, + + // Actions + initializeWorkflow: (workflowId, workflowName, nodes, edges, templateContextVariables = {}, workflowConfigurations = DEFAULT_WORKFLOW_CONFIGURATIONS) => { + const initialHistory: HistoryState = { nodes, edges, workflowName }; + set({ + workflowId, + workflowName, + nodes, + edges, + templateContextVariables, + workflowConfigurations, + isDirty: false, + workflowValidationErrors: [], + history: [initialHistory], + historyIndex: 0, + }); + }, + + pushToHistory: () => { + const state = get(); + const currentState: HistoryState = { + nodes: state.nodes, + edges: state.edges, + workflowName: state.workflowName, + }; + + // Remove any forward history if we're not at the end + const newHistory = state.history.slice(0, state.historyIndex + 1); + newHistory.push(currentState); + + // Limit history size + if (newHistory.length > MAX_HISTORY_SIZE) { + newHistory.shift(); + } + + set({ + history: newHistory, + historyIndex: newHistory.length - 1, + }); + }, + + undo: () => { + const state = get(); + if (state.historyIndex > 0) { + const newIndex = state.historyIndex - 1; + const historicState = state.history[newIndex]; + set({ + nodes: historicState.nodes, + edges: historicState.edges, + workflowName: historicState.workflowName, + historyIndex: newIndex, + isDirty: true, + }); + } + }, + + redo: () => { + const state = get(); + if (state.historyIndex < state.history.length - 1) { + const newIndex = state.historyIndex + 1; + const historicState = state.history[newIndex]; + set({ + nodes: historicState.nodes, + edges: historicState.edges, + workflowName: historicState.workflowName, + historyIndex: newIndex, + isDirty: true, + }); + } + }, + + canUndo: () => { + const state = get(); + return state.historyIndex > 0; + }, + + canRedo: () => { + const state = get(); + return state.historyIndex < state.history.length - 1; + }, + + setNodes: (nodes, changes) => { + // Determine whether to push to history and set isDirty based on change types + if (changes && changes.length > 0) { + // Check if any changes are user-initiated (not just selections or dimensions) + const hasDirtyChanges = changes.some(change => + change.type === 'add' || + change.type === 'remove' || + (change.type === 'position' && change.dragging) + ); + + if (hasDirtyChanges) { + get().pushToHistory(); + set({ nodes, isDirty: true }); + } else { + // For selection changes or dimension updates, don't push to history + set({ nodes }); + } + } else { + // No changes provided, just update nodes without history + set({ nodes }); + } + }, + + addNode: (node) => { + const state = get(); + get().pushToHistory(); + set({ + nodes: [...state.nodes, node], + isDirty: true + }); + }, + + updateNode: (nodeId, updates) => { + const state = get(); + get().pushToHistory(); + set({ + nodes: state.nodes.map((node) => + node.id === nodeId ? { ...node, ...updates } : node + ), + isDirty: true, + }); + }, + + deleteNode: (nodeId) => { + const state = get(); + get().pushToHistory(); + set({ + nodes: state.nodes.filter((node) => node.id !== nodeId), + edges: state.edges.filter( + (edge) => edge.source !== nodeId && edge.target !== nodeId + ), + isDirty: true, + }); + }, + + setEdges: (edges, changes) => { + // Determine whether to push to history and set isDirty based on change types + if (changes && changes.length > 0) { + // Check if any changes are user-initiated (not just selections) + const hasDirtyChanges = changes.some(change => + change.type === 'add' || + change.type === 'remove' || + change.type === 'replace' + ); + + if (hasDirtyChanges) { + get().pushToHistory(); + set({ edges, isDirty: true }); + } else { + // For selection changes, don't push to history + set({ edges }); + } + } else { + // No changes provided, just update edges without history + set({ edges }); + } + }, + + addEdge: (edge) => { + const state = get(); + get().pushToHistory(); + set({ + edges: [...state.edges, edge], + isDirty: true + }); + }, + + updateEdge: (edgeId, updates) => { + const state = get(); + get().pushToHistory(); + set({ + edges: state.edges.map((edge) => + edge.id === edgeId ? { ...edge, ...updates } : edge + ), + isDirty: true, + }); + }, + + deleteEdge: (edgeId) => { + const state = get(); + get().pushToHistory(); + set({ + edges: state.edges.filter((edge) => edge.id !== edgeId), + isDirty: true, + }); + }, + + setWorkflowName: (workflowName) => { + get().pushToHistory(); + set({ workflowName, isDirty: true }); + }, + + setTemplateContextVariables: (templateContextVariables) => { + set({ templateContextVariables }); + }, + + setWorkflowConfigurations: (workflowConfigurations) => { + set({ workflowConfigurations }); + }, + + setIsDirty: (isDirty) => { + set({ isDirty }); + }, + + setIsAddNodePanelOpen: (isAddNodePanelOpen) => { + set({ isAddNodePanelOpen }); + }, + + setWorkflowValidationErrors: (workflowValidationErrors) => { + set({ workflowValidationErrors }); + }, + + markNodeAsInvalid: (nodeId, message) => { + set((state) => ({ + nodes: state.nodes.map((node) => + node.id === nodeId + ? { ...node, data: { ...node.data, invalid: true, validationMessage: message } } + : node + ), + })); + }, + + markEdgeAsInvalid: (edgeId, message) => { + set((state) => ({ + edges: state.edges.map((edge) => + edge.id === edgeId + ? { ...edge, data: { ...edge.data, invalid: true, validationMessage: message } } + : edge + ), + })); + }, + + clearValidationErrors: () => { + set((state) => ({ + nodes: state.nodes.map((node) => ({ + ...node, + data: { ...node.data, invalid: false, validationMessage: null }, + })), + edges: state.edges.map((edge) => ({ + ...edge, + data: { ...edge.data, invalid: false, validationMessage: null }, + })), + workflowValidationErrors: [], + })); + }, + + setRfInstance: (rfInstance) => { + set({ rfInstance }); + }, + + clearStore: () => { + set({ + workflowId: null, + workflowName: '', + nodes: [], + edges: [], + history: [], + historyIndex: -1, + isDirty: false, + isAddNodePanelOpen: false, + workflowValidationErrors: [], + templateContextVariables: {}, + workflowConfigurations: DEFAULT_WORKFLOW_CONFIGURATIONS, + rfInstance: null, + }); + }, +})); + +// Selectors for common use cases +export const useWorkflowNodes = () => useWorkflowStore((state) => state.nodes); +export const useWorkflowEdges = () => useWorkflowStore((state) => state.edges); +export const useWorkflowName = () => useWorkflowStore((state) => state.workflowName); +export const useWorkflowId = () => useWorkflowStore((state) => state.workflowId); +export const useWorkflowDirtyState = () => useWorkflowStore((state) => state.isDirty); +export const useWorkflowValidationErrors = () => useWorkflowStore((state) => state.workflowValidationErrors); + +// Selector for undo/redo state +export const useUndoRedo = () => { + const undo = useWorkflowStore((state) => state.undo); + const redo = useWorkflowStore((state) => state.redo); + const canUndo = useWorkflowStore((state) => state.canUndo()); + const canRedo = useWorkflowStore((state) => state.canRedo()); + + return { undo, redo, canUndo, canRedo }; +}; diff --git a/ui/src/app/workflow/[workflowId]/utils/layoutNodes.ts b/ui/src/app/workflow/[workflowId]/utils/layoutNodes.ts new file mode 100644 index 0000000..868e7a3 --- /dev/null +++ b/ui/src/app/workflow/[workflowId]/utils/layoutNodes.ts @@ -0,0 +1,49 @@ +import dagre from '@dagrejs/dagre'; +import { ReactFlowInstance } from "@xyflow/react"; + +import { FlowEdge, FlowNode } from "@/components/flow/types"; + +export const layoutNodes = ( + nodes: FlowNode[], + edges: FlowEdge[], + rankdir: 'TB' | 'LR', + rfInstance: React.RefObject | null> +) => { + const g = new dagre.graphlib.Graph(); + g.setGraph({ rankdir, nodesep: 250, ranksep: 250 }); + g.setDefaultEdgeLabel(() => ({})); + + // Sort nodes so startCall nodes come first and endCall nodes come last + const sortedNodes = [...nodes].sort((a, b) => { + if (a.type === 'startCall') return -1; + if (b.type === 'startCall') return 1; + if (a.type === 'endCall') return 1; + if (b.type === 'endCall') return -1; + return 0; + }); + + sortedNodes.forEach((node) => { + g.setNode(node.id, { width: 180, height: 60 }); + }); + + edges.forEach((edge) => { + g.setEdge(edge.source, edge.target); + }); + + dagre.layout(g); + + const newNodes = sortedNodes.map((node) => { + const nodeWithPosition = g.node(node.id); + return { + ...node, + position: { x: nodeWithPosition.x, y: nodeWithPosition.y } + }; + }); + + // Fit view to the new layout and save the viewport position + setTimeout(() => { + rfInstance.current?.fitView({ padding: 0.2, duration: 200, maxZoom: 0.75 }); + }, 0); + + return newNodes; +}; diff --git a/ui/src/components/flow/edges/CustomEdge.tsx b/ui/src/components/flow/edges/CustomEdge.tsx index 6a77a0f..fb24ab9 100644 --- a/ui/src/components/flow/edges/CustomEdge.tsx +++ b/ui/src/components/flow/edges/CustomEdge.tsx @@ -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(); + const { getEdges, setNodes } = useReactFlow(); 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 ( <> - + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onDoubleClick={() => setOpen(true)} + > + + + {/* Always show label, expand on select/hover */}
setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + onDoubleClick={() => setOpen(true)} > -
-
- {data?.label || data?.condition || 'Set Condition'} - + {/* Show full EdgeLabel when selected or hovered, otherwise show simple label */} + {(selected || isHovered) ? ( +
+ {/* Header with label */} +
+ + Condition - EdgeID: {id} + + +
+ {/* Content */} +
+
+ {data?.label || data?.condition || 'Click to set condition'} +
+
- -
+ ) : ( + /* Simple label shown by default */ +
+
+ {data?.label || data?.condition || 'No condition'} +
+
+ )}
{ 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 ( <> } 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..bf629b3 100644 --- a/ui/src/components/flow/nodes/BaseNode.tsx +++ b/ui/src/components/flow/nodes/BaseNode.tsx @@ -7,8 +7,10 @@ export const BaseNode = forwardRef< HTMLAttributes & { 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) => (
{ 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 ( <> } 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..9df37c9 100644 --- a/ui/src/components/flow/nodes/GlobalNode.tsx +++ b/ui/src/components/flow/nodes/GlobalNode.tsx @@ -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 ( <> } 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..f9ca488 100644 --- a/ui/src/components/flow/nodes/StartCall.tsx +++ b/ui/src/components/flow/nodes/StartCall.tsx @@ -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 ( <> } 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..378b982 100644 --- a/ui/src/components/flow/nodes/common/NodeContent.tsx +++ b/ui/src/components/flow/nodes/common/NodeContent.tsx @@ -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 ( - + {hasTargetHandle && } {icon} - {title} + {title} - NodeID: {nodeId}
{children} diff --git a/ui/src/components/flow/nodes/common/useNodeHandlers.ts b/ui/src/components/flow/nodes/common/useNodeHandlers.ts index 29e8cc7..122bf3c 100644 --- a/ui/src/components/flow/nodes/common/useNodeHandlers.ts +++ b/ui/src/components/flow/nodes/common/useNodeHandlers.ts @@ -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(); + 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, diff --git a/ui/src/components/flow/types.ts b/ui/src/components/flow/types.ts index 93668ba..5f21085 100644 --- a/ui/src/components/flow/types.ts +++ b/ui/src/components/flow/types.ts @@ -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; diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 1aa670f..e45c071 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -13,6 +13,15 @@ export function getRandomId() { return Math.floor(Math.random() * 10_000); } +export function getNextNodeId(existingNodes: { id: string }[]): string { + const numericIds = existingNodes + .map(node => parseInt(node.id, 10)) + .filter(id => !isNaN(id)); + + const maxId = numericIds.length > 0 ? Math.max(...numericIds) : 0; + return String(maxId + 1); +} + export function debounce unknown>(func: T, wait: number): (...args: Parameters) => void { let timeout: NodeJS.Timeout | null = null;