diff --git a/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx b/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx index 0e15629..ea00b9d 100644 --- a/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx +++ b/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx @@ -21,11 +21,11 @@ import CustomEdge from "../../../components/flow/edges/CustomEdge"; import { AgentNode, EndCall, GlobalNode, StartCall } from "../../../components/flow/nodes"; 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"; +import { layoutNodes } from './utils/layoutNodes'; // Define the node types dynamically based on the onSave prop const nodeTypes = { @@ -307,6 +307,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT open={isConfigurationsDialogOpen} onOpenChange={setIsConfigurationsDialogOpen} workflowConfigurations={workflowConfigurations} + workflowName={workflowName} onSave={saveWorkflowConfigurations} /> 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/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/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 e4baea2..1ba279e 100644 --- a/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts +++ b/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts @@ -9,6 +9,7 @@ import { import { useRouter } from "next/navigation"; import { useCallback, useEffect, useRef } from "react"; +import { useWorkflowStore } from "@/app/workflow/[workflowId]/stores/workflowStore"; import { createWorkflowRunApiV1WorkflowWorkflowIdRunsPost, updateWorkflowApiV1WorkflowWorkflowIdPut, @@ -18,7 +19,6 @@ import { WorkflowError } from "@/client/types.gen"; import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types"; import logger from '@/lib/logger'; import { getNextNodeId, getRandomId } from "@/lib/utils"; -import { useWorkflowStore } from "@/stores/workflowStore"; import { WorkflowConfigurations } from "@/types/workflow-configurations"; export function getDefaultAllowInterrupt(type: string = NodeType.START_CALL): boolean { @@ -102,7 +102,6 @@ export const useWorkflowState = ({ workflowName, isDirty, isAddNodePanelOpen, - isEditingName, workflowValidationErrors, templateContextVariables, workflowConfigurations, @@ -316,20 +315,18 @@ export const useWorkflowState = ({ const onEdgesChange: OnEdgesChange = useCallback( (changes) => { - setEdges((eds) => { - const newEdges = applyEdgeChanges(changes, eds) as FlowEdge[]; - return newEdges; - }); + 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[]; - return newNodes; - }); + const currentNodes = useWorkflowStore.getState().nodes; + const newNodes = applyNodeChanges(changes, currentNodes) as FlowNode[]; + setNodes(newNodes, changes); }, [setNodes], ); @@ -380,7 +377,7 @@ export const useWorkflowState = ({ }, [workflowId, workflowName, user, getAccessToken, setTemplateContextVariables]); // Save workflow configurations - const saveWorkflowConfigurations = useCallback(async (configurations: WorkflowConfigurations) => { + const saveWorkflowConfigurations = useCallback(async (configurations: WorkflowConfigurations, newWorkflowName: string) => { if (!user) return; const accessToken = await getAccessToken(); try { @@ -389,7 +386,7 @@ export const useWorkflowState = ({ workflow_id: workflowId, }, body: { - name: workflowName, + name: newWorkflowName, workflow_definition: null, workflow_configurations: configurations as Record, }, @@ -398,12 +395,13 @@ export const useWorkflowState = ({ }, }); 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, setWorkflowConfigurations]); + }, [workflowId, user, getAccessToken, setWorkflowConfigurations, setWorkflowName]); // Update rfInstance when it changes useEffect(() => { @@ -423,7 +421,6 @@ export const useWorkflowState = ({ edges, isAddNodePanelOpen, workflowName, - isEditingName, isDirty, workflowValidationErrors, templateContextVariables, diff --git a/ui/src/stores/workflowStore.ts b/ui/src/app/workflow/[workflowId]/stores/workflowStore.ts similarity index 85% rename from ui/src/stores/workflowStore.ts rename to ui/src/app/workflow/[workflowId]/stores/workflowStore.ts index e079bd4..85d0c06 100644 --- a/ui/src/stores/workflowStore.ts +++ b/ui/src/app/workflow/[workflowId]/stores/workflowStore.ts @@ -1,4 +1,5 @@ import { ReactFlowInstance } from '@xyflow/react'; +import { NodeChange, EdgeChange } from '@xyflow/system'; import { create } from 'zustand'; import { WorkflowError } from '@/client/types.gen'; @@ -27,7 +28,6 @@ interface WorkflowState { // UI state (not tracked in history) isDirty: boolean; isAddNodePanelOpen: boolean; - isEditingName: boolean; // Validation state workflowValidationErrors: WorkflowError[]; @@ -59,13 +59,13 @@ interface WorkflowActions { canRedo: () => boolean; // Node operations - setNodes: (nodes: FlowNode[] | ((nodes: FlowNode[]) => FlowNode[])) => void; + 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[] | ((edges: FlowEdge[]) => FlowEdge[])) => void; + setEdges: (edges: FlowEdge[], changes?: EdgeChange[]) => void; addEdge: (edge: FlowEdge) => void; updateEdge: (edgeId: string, updates: Partial) => void; deleteEdge: (edgeId: string) => void; @@ -78,7 +78,6 @@ interface WorkflowActions { // UI state setIsDirty: (isDirty: boolean) => void; setIsAddNodePanelOpen: (isOpen: boolean) => void; - setIsEditingName: (isEditing: boolean) => void; // Validation setWorkflowValidationErrors: (errors: WorkflowError[]) => void; @@ -108,7 +107,6 @@ export const useWorkflowStore = create((set, get) => ({ historyIndex: -1, isDirty: false, isAddNodePanelOpen: false, - isEditingName: false, workflowValidationErrors: [], templateContextVariables: {}, workflowConfigurations: DEFAULT_WORKFLOW_CONFIGURATIONS, @@ -194,19 +192,27 @@ export const useWorkflowStore = create((set, get) => ({ return state.historyIndex < state.history.length - 1; }, - setNodes: (nodes) => { - const state = get(); - let newNodes: FlowNode[]; - if (typeof nodes === 'function') { - newNodes = nodes(state.nodes); + 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 { - newNodes = nodes; + // No changes provided, just update nodes without history + set({ nodes }); } - - // Push current state to history before making changes - get().pushToHistory(); - - set({ nodes: newNodes, isDirty: true }); }, addNode: (node) => { @@ -241,19 +247,27 @@ export const useWorkflowStore = create((set, get) => ({ }); }, - setEdges: (edges) => { - const state = get(); - let newEdges: FlowEdge[]; - if (typeof edges === 'function') { - newEdges = edges(state.edges); + 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 { - newEdges = edges; + // No changes provided, just update edges without history + set({ edges }); } - - // Push current state to history before making changes - get().pushToHistory(); - - set({ edges: newEdges, isDirty: true }); }, addEdge: (edge) => { @@ -306,10 +320,6 @@ export const useWorkflowStore = create((set, get) => ({ set({ isAddNodePanelOpen }); }, - setIsEditingName: (isEditingName) => { - set({ isEditingName }); - }, - setWorkflowValidationErrors: (workflowValidationErrors) => { set({ workflowValidationErrors }); }, @@ -362,7 +372,6 @@ export const useWorkflowStore = create((set, get) => ({ historyIndex: -1, isDirty: false, isAddNodePanelOpen: false, - isEditingName: false, workflowValidationErrors: [], templateContextVariables: {}, workflowConfigurations: DEFAULT_WORKFLOW_CONFIGURATIONS, @@ -387,4 +396,4 @@ export const useUndoRedo = () => { const canRedo = useWorkflowStore((state) => state.canRedo()); return { undo, redo, canUndo, canRedo }; -}; \ No newline at end of file +}; 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..78e526f --- /dev/null +++ b/ui/src/app/workflow/[workflowId]/utils/layoutNodes.ts @@ -0,0 +1,50 @@ +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>, + 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({ padding: 0.2, duration: 200, maxZoom: 0.75 }); + }, 0); + + return newNodes; +};