feat: improve workflow builder UX (#41)

* chore: improve ux of workflow editor

* Improve workflow UX

* Add option to edit workflow name

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

99
ui/package-lock.json generated
View file

@ -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
}
}
}

View file

@ -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",

View file

@ -6,13 +6,23 @@ interface WorkflowLayoutProps {
children: ReactNode,
headerActions?: ReactNode,
backButton?: ReactNode,
showFeaturesNav?: boolean
showFeaturesNav?: boolean,
stickyTabs?: ReactNode
}
const WorkflowLayout: React.FC<WorkflowLayoutProps> = ({ children, headerActions, backButton, showFeaturesNav = true }) => {
const WorkflowLayout: React.FC<WorkflowLayoutProps> = ({ children, headerActions, backButton, showFeaturesNav = true, stickyTabs }) => {
return (
<>
<BaseHeader headerActions={headerActions} backButton={backButton} showFeaturesNav={showFeaturesNav} />
{stickyTabs && (
<div className="sticky top-[73px] z-40 bg-[#2a2e39] border-b border-gray-700">
<div className="container mx-auto px-4">
<div className="flex items-center justify-center py-2">
{stickyTabs}
</div>
</div>
</div>
)}
{children}
</>
)

View file

@ -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<string, string>;
initialWorkflowConfigurations?: WorkflowConfigurations;
user: { id: string; email?: string };
getAccessToken: () => Promise<string>;
}
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 = (
<WorkflowHeader
@ -81,13 +122,20 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
onRun={onRun}
workflowId={workflowId}
saveWorkflow={saveWorkflow}
user={user}
getAccessToken={getAccessToken}
/>
);
const stickyTabs = <WorkflowTabs workflowId={workflowId} currentTab="editor" />;
// Memoize the context value to prevent unnecessary re-renders
const workflowContextValue = useMemo(() => ({ saveWorkflow }), [saveWorkflow]);
return (
<WorkflowProvider value={{ saveWorkflow }}>
<WorkflowLayout headerActions={headerActions} showFeaturesNav={false}>
<div className="h-[calc(100vh-80px)]">
<WorkflowProvider value={workflowContextValue}>
<WorkflowLayout headerActions={headerActions} showFeaturesNav={false} stickyTabs={stickyTabs}>
<div className="h-[calc(100vh-128px)] relative">
<ReactFlow
nodes={nodes}
edges={edges}
@ -98,30 +146,155 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
onConnect={onConnect}
onInit={(instance) => {
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}
>
<Background />
<Panel position="top-left">
<WorkflowControls
workflowId={workflowId}
workflowName={workflowName}
isEditingName={isEditingName}
setIsEditingName={setIsEditingName}
handleNameChange={handleNameChange}
setIsAddNodePanelOpen={setIsAddNodePanelOpen}
saveWorkflow={saveWorkflow}
nodes={nodes}
edges={edges}
setNodes={setNodes}
rfInstance={rfInstance}
templateContextVariables={templateContextVariables}
saveTemplateContextVariables={saveTemplateContextVariables}
workflowConfigurations={workflowConfigurations}
saveWorkflowConfigurations={saveWorkflowConfigurations}
/>
<Background
variant={BackgroundVariant.Dots}
gap={16}
size={1}
color="#94a3b8"
/>
<MiniMap
nodeColor={getNodeColor}
position="bottom-right"
className="bg-white/90 border rounded shadow-lg"
maskColor="rgb(0, 0, 0, 0.1)"
/>
{/* Top-right controls - vertical layout */}
<Panel position="top-right">
<TooltipProvider>
<div className="flex flex-col gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="default"
size="icon"
onClick={() => setIsAddNodePanelOpen(true)}
className="shadow-md hover:shadow-lg"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Add node</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setIsConfigurationsDialogOpen(true)}
className="bg-white shadow-sm hover:shadow-md"
>
<Settings className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Configurations</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setIsContextVarsDialogOpen(true)}
className="bg-white shadow-sm hover:shadow-md"
>
<Variable className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Template Context Variables</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</Panel>
</ReactFlow>
{/* Bottom-left controls - horizontal layout with custom buttons */}
<div className="absolute bottom-12 left-8 z-[1000] flex gap-2">
<TooltipProvider>
{/* Zoom In */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.zoomIn()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Plus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Zoom in</p>
</TooltipContent>
</Tooltip>
{/* Zoom Out */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.zoomOut()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Minus className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Zoom out</p>
</TooltipContent>
</Tooltip>
{/* Fit View */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => rfInstance.current?.fitView()}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<Maximize2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Fit view</p>
</TooltipContent>
</Tooltip>
{/* Tidy/Arrange Nodes */}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
onClick={() => setNodes(layoutNodes(nodes, edges, 'LR', rfInstance, saveWorkflow))}
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
>
<BrushCleaning className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>Tidy Up</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>
<AddNodePanel
@ -129,9 +302,35 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
onNodeSelect={handleNodeSelect}
onClose={() => setIsAddNodePanelOpen(false)}
/>
<ConfigurationsDialog
open={isConfigurationsDialogOpen}
onOpenChange={setIsConfigurationsDialogOpen}
workflowConfigurations={workflowConfigurations}
workflowName={workflowName}
onSave={saveWorkflowConfigurations}
/>
<TemplateContextVariablesDialog
open={isContextVarsDialogOpen}
onOpenChange={setIsContextVarsDialogOpen}
templateContextVariables={templateContextVariables}
onSave={saveTemplateContextVariables}
/>
</WorkflowLayout>
</WorkflowProvider>
);
}
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
);
});

View file

@ -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<void>;
workflowName: string;
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
}
const DEFAULT_VAD_CONFIG: VADConfiguration = {
@ -30,8 +31,10 @@ export const ConfigurationsDialog = ({
open,
onOpenChange,
workflowConfigurations,
workflowName,
onSave
}: ConfigurationsDialogProps) => {
const [name, setName] = useState<string>(workflowName);
const [vadConfig, setVadConfig] = useState<VADConfiguration>(
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 (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Configurations</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* Workflow Name Section */}
<div className="space-y-4">
<div>
<h3 className="text-sm font-semibold mb-1">Workflow Name</h3>
<p className="text-xs text-gray-500">
The name of your workflow
</p>
</div>
<div className="space-y-2">
<Label htmlFor="workflow_name" className="text-xs">
Name
</Label>
<Input
id="workflow_name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter workflow name"
/>
</div>
</div>
{/* Voice Activity Detection Section */}
<div className="space-y-4">
<div>

View file

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

View file

@ -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<HTMLInputElement>) => void;
setIsAddNodePanelOpen: (isOpen: boolean) => void;
saveWorkflow: (updateWorkflowDefinition: boolean) => Promise<void>;
nodes: FlowNode[];
edges: FlowEdge[];
setNodes: (nodes: FlowNode[] | ((nds: FlowNode[]) => FlowNode[])) => void;
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>;
templateContextVariables?: Record<string, string>;
saveTemplateContextVariables: (variables: Record<string, string>) => Promise<void>;
workflowConfigurations: WorkflowConfigurations | null;
saveWorkflowConfigurations: (configurations: WorkflowConfigurations) => Promise<void>;
}
export const layoutNodes = (
nodes: FlowNode[],
edges: FlowEdge[],
rankdir: 'TB' | 'LR',
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>,
saveWorkflow: (updateWorkflowDefinition: boolean) => Promise<void>
) => {
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 (
<div>
<div className="mb-2">
<div className="flex items-center relative bg-white border border-gray-200 rounded-md px-3 py-1 shadow-sm group hover:border-gray-300 transition-colors w-45">
{isEditingName ? (
<input
type="text"
value={workflowName}
onChange={handleNameChange}
className="pr-8 bg-transparent focus:outline-none w-full text-lg"
autoFocus
onKeyDown={(e) => e.key === 'Enter' && (setIsEditingName(false), saveWorkflow(false))}
/>
) : (
<h1 className="text-lg font-medium pr-8 truncate">{workflowName}</h1>
)}
<Button
size="icon"
variant="ghost"
onClick={() => {
if (isEditingName) {
setIsEditingName(false);
saveWorkflow(false);
} else {
setIsEditingName(true);
}
}}
className="h-7 w-7 absolute right-2 top-1/2 transform -translate-y-1/2"
>
{isEditingName ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Pencil className="h-4 w-4 opacity-50 group-hover:opacity-100 transition-opacity" />
)}
</Button>
</div>
</div>
<div className="flex flex-col gap-2">
<Button onClick={() => setIsAddNodePanelOpen(true)}>Add New Node</Button>
<Button onClick={() => setNodes(layoutNodes(nodes, edges, 'TB', rfInstance, saveWorkflow))}>Vertical Layout</Button>
<Button onClick={() => setNodes(layoutNodes(nodes, edges, 'LR', rfInstance, saveWorkflow))}>Horizontal Layout</Button>
<Button
onClick={() => setIsConfigurationsDialogOpen(true)}
className="flex items-center gap-2"
>
Configurations
</Button>
<Button
onClick={() => setIsContextVarsDialogOpen(true)}
className="flex items-center gap-2"
>
Template Context Variables
</Button>
<Button
onClick={() => router.push(`/workflow/${workflowId}/runs`)}
className="flex items-center gap-1"
>
View Run History
</Button>
</div>
<ConfigurationsDialog
open={isConfigurationsDialogOpen}
onOpenChange={setIsConfigurationsDialogOpen}
workflowConfigurations={workflowConfigurations}
onSave={saveWorkflowConfigurations}
/>
<TemplateContextVariablesDialog
open={isContextVarsDialogOpen}
onOpenChange={setIsContextVarsDialogOpen}
templateContextVariables={templateContextVariables}
onSave={saveTemplateContextVariables}
/>
</div>
);
};
export default WorkflowControls;

View file

@ -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<WorkflowRunResponseSchema[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<FilterAttribute[]>(availableAttributes);
const { accessToken } = useUserConfig();
// Initialize filters from URL
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
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 (
<div className="container mx-auto py-8">
<div className="mb-6">
<h1 className="text-2xl font-bold mb-4">Workflow Run History</h1>
<FilterBuilder
availableAttributes={configuredAttributes}
activeFilters={activeFilters}
onFiltersChange={handleFiltersChange}
onApplyFilters={handleApplyFilters}
onClearFilters={handleClearFilters}
isExecuting={isExecutingFilters}
/>
</div>
{loading ? (
<div className="flex justify-center">
<div className="animate-pulse">Loading workflow runs...</div>
</div>
) : error ? (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
) : workflowRuns.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500">No workflow runs found</p>
</div>
) : (
<Card>
<CardHeader>
<CardTitle>Workflow Runs</CardTitle>
<CardDescription>
Showing {workflowRuns.length} of {totalCount} total runs
</CardDescription>
</CardHeader>
<CardContent>
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="font-semibold">ID</TableHead>
<TableHead className="font-semibold">Status</TableHead>
<TableHead className="font-semibold">Created At</TableHead>
<TableHead className="font-semibold">Duration</TableHead>
<TableHead className="font-semibold">Disposition</TableHead>
<TableHead className="font-semibold">Dograh Token</TableHead>
<TableHead className="font-semibold">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workflowRuns.map((run) => (
<TableRow
key={run.id}
className="cursor-pointer"
onClick={() => window.open(`/workflow/${workflowId}/run/${run.id}`, '_blank')}
>
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
<TableCell>
<Badge variant={run.is_completed ? "default" : "secondary"}>
{run.is_completed ? "Completed" : "In Progress"}
</Badge>
</TableCell>
<TableCell className="text-sm">{formatDate(run.created_at)}</TableCell>
<TableCell className="text-sm">
{typeof run.cost_info?.call_duration_seconds === 'number'
? `${run.cost_info.call_duration_seconds.toFixed(1)}s`
: "-"}
</TableCell>
<TableCell>
{run.gathered_context?.mapped_call_disposition ? (
<Badge variant={getDispositionBadgeVariant(run.gathered_context.mapped_call_disposition as string)}>
{run.gathered_context.mapped_call_disposition as string}
</Badge>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-sm">
{typeof run.cost_info?.dograh_token_usage === 'number'
? `${run.cost_info.dograh_token_usage.toFixed(2)}`
: "-"}
</TableCell>
<TableCell>
<div className="flex space-x-2">
{run.transcript_url && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
if (accessToken) downloadFile(run.transcript_url, accessToken);
}}
>
<Download className="h-3 w-3 mr-1" />
Transcript
</Button>
)}
{run.recording_url && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
if (accessToken) downloadFile(run.recording_url, accessToken);
}}
>
<Download className="h-3 w-3 mr-1" />
Recording
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
window.open(`/workflow/${workflowId}/run/${run.id}`, '_blank');
}}
>
<ExternalLink className="h-3 w-3 mr-1" />
View
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-gray-600">
Page {currentPage} of {totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
const newPage = currentPage - 1;
setCurrentPage(newPage);
updatePageInUrl(newPage, activeFilters);
}}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const newPage = currentPage + 1;
setCurrentPage(newPage);
updatePageInUrl(newPage, activeFilters);
}}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
);
}

View file

@ -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<void>;
user: { id: string; email?: string };
getAccessToken: () => Promise<string>;
}
const handleExport = (workflow_name: string, workflow_definition: ReactFlowJsonObject<FlowNode, FlowEdge> | 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<HTMLButtonElement>(null);
const hasValidationErrors = workflowValidationErrors.length > 0;
@ -211,39 +211,73 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
size="sm"
onClick={() => handleExport(workflowName, rfInstance.current?.toObject())}
>
<Download className="mr-2 h-4 w-4" />
Export Pathway
</Button>
<Button
ref={webCallButtonRef}
variant="outline"
size="sm"
onClick={() => {
// Mark the tooltip as seen when the button is clicked
if (!hasSeenTooltip('web_call')) {
markTooltipSeen('web_call');
}
onRun(WORKFLOW_RUN_MODES.SMALL_WEBRTC);
}}
disabled={hasValidationErrors}
>
<Phone className="mr-2 h-4 w-4" />
Web Call
</Button>
<Button
variant="outline"
size="sm"
onClick={handlePhoneCallClick}
disabled={hasValidationErrors}
>
<Phone className="mr-2 h-4 w-4" />
Phone Call
</Button>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="outline"
size="sm"
onClick={() => handleExport(workflowName, rfInstance.current?.toObject())}
disabled={isDirty || hasValidationErrors}
>
<Download className="mr-2 h-4 w-4" />
Export Pathway
</Button>
</span>
</TooltipTrigger>
{(isDirty || hasValidationErrors) && (
<TooltipContent>
{isDirty ? 'Save the workflow before exporting' : 'Fix validation errors before exporting'}
</TooltipContent>
)}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
ref={webCallButtonRef}
variant="outline"
size="sm"
onClick={() => {
// Mark the tooltip as seen when the button is clicked
if (!hasSeenTooltip('web_call')) {
markTooltipSeen('web_call');
}
onRun(WORKFLOW_RUN_MODES.SMALL_WEBRTC);
}}
disabled={isDirty || hasValidationErrors}
>
<Phone className="mr-2 h-4 w-4" />
Web Call
</Button>
</span>
</TooltipTrigger>
{(isDirty || hasValidationErrors) && (
<TooltipContent>
{isDirty ? 'Save the workflow before testing' : 'Fix validation errors before testing'}
</TooltipContent>
)}
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="outline"
size="sm"
onClick={handlePhoneCallClick}
disabled={isDirty || hasValidationErrors}
>
<Phone className="mr-2 h-4 w-4" />
Phone Call
</Button>
</span>
</TooltipTrigger>
{(isDirty || hasValidationErrors) && (
<TooltipContent>
{isDirty ? 'Save the workflow before making a call' : 'Fix validation errors before making a call'}
</TooltipContent>
)}
</Tooltip>
{isDirty ? (
<Button

View file

@ -0,0 +1,45 @@
"use client";
import { useRouter } from "next/navigation";
import { cn } from "@/lib/utils";
interface WorkflowTabsProps {
workflowId: number;
currentTab: 'editor' | 'executions';
}
export const WorkflowTabs = ({ workflowId, currentTab }: WorkflowTabsProps) => {
const router = useRouter();
const handleTabChange = (tab: 'editor' | 'executions') => {
router.push(`/workflow/${workflowId}?tab=${tab}`, { scroll: false });
};
return (
<div className="flex gap-2">
<button
onClick={() => handleTabChange('editor')}
className={cn(
"px-6 py-2.5 text-sm font-medium transition-all relative cursor-pointer rounded-md",
currentTab === 'editor'
? "text-white bg-[#3d4451]"
: "text-gray-300 hover:text-white hover:bg-[#343842]"
)}
>
Editor
</button>
<button
onClick={() => handleTabChange('executions')}
className={cn(
"px-6 py-2.5 text-sm font-medium transition-all relative cursor-pointer rounded-md",
currentTab === 'executions'
? "text-white bg-[#3d4451]"
: "text-gray-300 hover:text-white hover:bg-[#343842]"
)}
>
Executions
</button>
</div>
);
};

View file

@ -1,2 +1 @@
export * from './WorkflowControls';
export * from './WorkflowHeader';

View file

@ -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<string, string>;
initialWorkflowConfigurations?: WorkflowConfigurations;
user: { id: string; email?: string }; // Minimal user type needed
getAccessToken: () => Promise<string>;
}
export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations }: UseWorkflowStateProps) => {
const rfInstance = useRef<ReactFlowInstance<FlowNode, FlowEdge> | 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<ReactFlowInstance<FlowNode, FlowEdge> | 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<WorkflowError[]>([]);
const [templateContextVariables, setTemplateContextVariables] = useState<Record<string, string>>(
initialTemplateContextVariables || {}
);
const [workflowConfigurations, setWorkflowConfigurations] = useState<WorkflowConfigurations | null>(
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<HTMLInputElement>) => {
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<string, string>) => {
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<string, unknown>,
},
@ -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,
};
};

View file

@ -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<WorkflowResponse | undefined>(undefined);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 ? <WorkflowTabs workflowId={workflow.id} currentTab={currentTab} /> : null;
// Memoize user and getAccessToken to prevent unnecessary re-renders
const stableUser = useMemo(() => user, [user?.id]);
const stableGetAccessToken = useMemo(() => getAccessToken, [getAccessToken]);
if (loading) {
return (
<WorkflowLayout>
<WorkflowLayout stickyTabs={stickyTabs}>
<SpinLoader />
</WorkflowLayout>
);
}
else if (error || !workflow) {
return (
<WorkflowLayout showFeaturesNav={false}>
<WorkflowLayout showFeaturesNav={false} stickyTabs={stickyTabs}>
<div className="flex items-center justify-center min-h-screen">
<div className="text-lg text-red-500">{error || 'Workflow not found'}</div>
</div>
@ -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
<RenderWorkflow
initialWorkflowName={workflow.name}
workflowId={workflow.id}
initialFlow={{
nodes: workflow.workflow_definition.nodes as FlowNode[],
edges: workflow.workflow_definition.edges as FlowEdge[],
viewport: { x: 0, y: 0, zoom: 1 }
}}
initialTemplateContextVariables={workflow.template_context_variables as Record<string, string> || {}}
initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS}
/>
<>
{/* Editor view */}
<div className={currentTab === 'editor' ? 'block' : 'hidden'} aria-hidden={currentTab !== 'editor'}>
{stableUser && (
<RenderWorkflow
initialWorkflowName={workflow.name}
workflowId={workflow.id}
initialFlow={{
nodes: workflow.workflow_definition.nodes as FlowNode[],
edges: workflow.workflow_definition.edges as FlowEdge[],
viewport: { x: 0, y: 0, zoom: 0 }
}}
initialTemplateContextVariables={workflow.template_context_variables as Record<string, string> || {}}
initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS}
user={stableUser}
getAccessToken={stableGetAccessToken}
/>
)}
</div>
{/* Executions view */}
<div className={currentTab === 'executions' ? 'block' : 'hidden'} aria-hidden={currentTab !== 'executions'}>
<WorkflowLayout stickyTabs={stickyTabs} showFeaturesNav={false}>
<WorkflowExecutions workflowId={workflow.id} searchParams={searchParams} />
</WorkflowLayout>
</div>
</>
);
}
}

View file

@ -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<WorkflowRunResponseSchema[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<FilterAttribute[]>(availableAttributes);
const { accessToken } = useUserConfig();
// Initialize filters from URL
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
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 = (
<Link href={`/workflow/${workflowId}`}>
<Button variant="outline" size="sm" className="flex items-center gap-1">
<ArrowLeft className="h-4 w-4" />
Workflow
</Button>
</Link>
);
return (
<WorkflowLayout backButton={backButton}>
<div className="container mx-auto py-8">
<div className="mb-6">
<h1 className="text-2xl font-bold mb-4">Workflow Run History</h1>
<FilterBuilder
availableAttributes={configuredAttributes}
activeFilters={activeFilters}
onFiltersChange={handleFiltersChange}
onApplyFilters={handleApplyFilters}
onClearFilters={handleClearFilters}
isExecuting={isExecutingFilters}
/>
</div>
{loading ? (
<div className="flex justify-center">
<div className="animate-pulse">Loading workflow runs...</div>
</div>
) : error ? (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
) : workflowRuns.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500">No workflow runs found</p>
</div>
) : (
<Card>
<CardHeader>
<CardTitle>Workflow Runs</CardTitle>
<CardDescription>
Showing {workflowRuns.length} of {totalCount} total runs
</CardDescription>
</CardHeader>
<CardContent>
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
<Table>
<TableHeader>
<TableRow className="bg-gray-50">
<TableHead className="font-semibold">ID</TableHead>
<TableHead className="font-semibold">Status</TableHead>
<TableHead className="font-semibold">Created At</TableHead>
<TableHead className="font-semibold">Duration</TableHead>
<TableHead className="font-semibold">Disposition</TableHead>
<TableHead className="font-semibold">Dograh Token</TableHead>
<TableHead className="font-semibold">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{workflowRuns.map((run) => (
<TableRow
key={run.id}
className="cursor-pointer"
onClick={() => router.push(`/workflow/${workflowId}/run/${run.id}`)}
>
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
<TableCell>
<Badge variant={run.is_completed ? "default" : "secondary"}>
{run.is_completed ? "Completed" : "In Progress"}
</Badge>
</TableCell>
<TableCell className="text-sm">{formatDate(run.created_at)}</TableCell>
<TableCell className="text-sm">
{typeof run.cost_info?.call_duration_seconds === 'number'
? `${run.cost_info.call_duration_seconds.toFixed(1)}s`
: "-"}
</TableCell>
<TableCell>
{run.gathered_context?.mapped_call_disposition ? (
<Badge variant={getDispositionBadgeVariant(run.gathered_context.mapped_call_disposition as string)}>
{run.gathered_context.mapped_call_disposition as string}
</Badge>
) : (
<span className="text-sm text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-sm">
{typeof run.cost_info?.dograh_token_usage === 'number'
? `${run.cost_info.dograh_token_usage.toFixed(2)}`
: "-"}
</TableCell>
<TableCell>
<div className="flex space-x-2">
{run.transcript_url && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
if (accessToken) downloadFile(run.transcript_url, accessToken);
}}
>
<Download className="h-3 w-3 mr-1" />
Transcript
</Button>
)}
{run.recording_url && (
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
if (accessToken) downloadFile(run.recording_url, accessToken);
}}
>
<Download className="h-3 w-3 mr-1" />
Recording
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
router.push(`/workflow/${workflowId}/run/${run.id}`);
}}
>
<ExternalLink className="h-3 w-3 mr-1" />
View
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-gray-600">
Page {currentPage} of {totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
const newPage = currentPage - 1;
setCurrentPage(newPage);
updatePageInUrl(newPage, activeFilters);
}}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
const newPage = currentPage + 1;
setCurrentPage(newPage);
updatePageInUrl(newPage, activeFilters);
}}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
</WorkflowLayout>
);
return null;
}

View file

@ -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<string, string>;
workflowConfigurations: WorkflowConfigurations | null;
// ReactFlow instance reference
rfInstance: ReactFlowInstance<FlowNode, FlowEdge> | null;
}
interface WorkflowActions {
// Initialization
initializeWorkflow: (
workflowId: number,
workflowName: string,
nodes: FlowNode[],
edges: FlowEdge[],
templateContextVariables?: Record<string, string>,
workflowConfigurations?: WorkflowConfigurations | null
) => void;
// History management
pushToHistory: () => void;
undo: () => void;
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
// Node operations
setNodes: (nodes: FlowNode[], changes?: NodeChange<FlowNode>[]) => void;
addNode: (node: FlowNode) => void;
updateNode: (nodeId: string, updates: Partial<FlowNode>) => void;
deleteNode: (nodeId: string) => void;
// Edge operations
setEdges: (edges: FlowEdge[], changes?: EdgeChange<FlowEdge>[]) => void;
addEdge: (edge: FlowEdge) => void;
updateEdge: (edgeId: string, updates: Partial<FlowEdge>) => void;
deleteEdge: (edgeId: string) => void;
// Workflow metadata
setWorkflowName: (name: string) => void;
setTemplateContextVariables: (variables: Record<string, string>) => 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<FlowNode, FlowEdge> | null) => void;
// Clear store (for cleanup)
clearStore: () => void;
}
type WorkflowStore = WorkflowState & WorkflowActions;
const MAX_HISTORY_SIZE = 50;
// Create the store
export const useWorkflowStore = create<WorkflowStore>((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 };
};

View file

@ -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<ReactFlowInstance<FlowNode, FlowEdge> | 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;
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<T extends (...args: unknown[]) => unknown>(func: T, wait: number): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;