From 0d9dc1fcf8014934f7598674f3c1ac7410758873 Mon Sep 17 00:00:00 2001 From: Abhishek Kumar Date: Thu, 6 Nov 2025 12:48:12 +0530 Subject: [PATCH] Improve workflow UX --- ui/package-lock.json | 91 +++- ui/package.json | 6 +- ui/src/app/workflow/WorkflowLayout.tsx | 8 +- .../workflow/[workflowId]/RenderWorkflow.tsx | 43 +- .../components/WorkflowExecutions.tsx | 348 ++++++++++++++++ .../components/WorkflowHeader.tsx | 106 +++-- .../[workflowId]/components/WorkflowTabs.tsx | 28 +- .../[workflowId]/hooks/useWorkflowState.ts | 329 +++++++-------- ui/src/app/workflow/[workflowId]/page.tsx | 61 ++- .../app/workflow/[workflowId]/runs/page.tsx | 347 +--------------- ui/src/components/flow/edges/CustomEdge.tsx | 75 ++-- ui/src/components/flow/nodes/AgentNode.tsx | 3 +- ui/src/components/flow/nodes/BaseNode.tsx | 11 +- ui/src/components/flow/nodes/EndCall.tsx | 3 +- ui/src/components/flow/nodes/GlobalNode.tsx | 3 +- ui/src/components/flow/nodes/StartCall.tsx | 3 +- .../flow/nodes/common/NodeContent.tsx | 12 +- ui/src/components/flow/types.ts | 3 +- ui/src/lib/utils.ts | 9 + ui/src/stores/workflowStore.ts | 390 ++++++++++++++++++ 20 files changed, 1229 insertions(+), 650 deletions(-) create mode 100644 ui/src/app/workflow/[workflowId]/components/WorkflowExecutions.tsx create mode 100644 ui/src/stores/workflowStore.ts diff --git a/ui/package-lock.json b/ui/package-lock.json index ffa06e58..90b7ffc5 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -26,7 +26,7 @@ "@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", @@ -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" } @@ -17894,21 +17926,37 @@ "type-fest": "^2.19.0" } }, - "node_modules/zustand": { - "version": "4.5.6", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.6.tgz", - "integrity": "sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ==", + "node_modules/zundo": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/zundo/-/zundo-2.3.0.tgz", + "integrity": "sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==", "license": "MIT", - "dependencies": { - "use-sync-external-store": "^1.2.2" - }, - "engines": { - "node": ">=12.7.0" + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/charkour" }, "peerDependencies": { - "@types/react": ">=16.8", + "zustand": "^4.3.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "zustand": { + "optional": false + } + } + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", "immer": ">=9.0.6", - "react": ">=16.8" + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" }, "peerDependenciesMeta": { "@types/react": { @@ -17919,6 +17967,9 @@ }, "react": { "optional": true + }, + "use-sync-external-store": { + "optional": true } } } diff --git a/ui/package.json b/ui/package.json index f738c8ba..dac8d88a 100644 --- a/ui/package.json +++ b/ui/package.json @@ -29,7 +29,7 @@ "@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", @@ -52,7 +52,9 @@ "sonner": "^2.0.5", "tailwind-merge": "^3.2.0", "tailwindcss-animate": "^1.0.7", - "tw-animate-css": "^1.2.5" + "tw-animate-css": "^1.2.5", + "zundo": "^2.3.0", + "zustand": "^5.0.8" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/ui/src/app/workflow/WorkflowLayout.tsx b/ui/src/app/workflow/WorkflowLayout.tsx index cd08a2ef..ca80ea65 100644 --- a/ui/src/app/workflow/WorkflowLayout.tsx +++ b/ui/src/app/workflow/WorkflowLayout.tsx @@ -15,9 +15,11 @@ const WorkflowLayout: React.FC = ({ children, headerActions <> {stickyTabs && ( -
-
- {stickyTabs} +
+
+
+ {stickyTabs} +
)} diff --git a/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx b/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx index a4835410..0e156294 100644 --- a/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx +++ b/ui/src/app/workflow/[workflowId]/RenderWorkflow.tsx @@ -8,7 +8,7 @@ import { ReactFlow, } from "@xyflow/react"; import { BrushCleaning, Maximize2, Minus, Plus, Settings, Variable } from 'lucide-react'; -import { useMemo, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import WorkflowLayout from '@/app/workflow/WorkflowLayout'; import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types"; @@ -69,9 +69,11 @@ interface RenderWorkflowProps { }; initialTemplateContextVariables?: Record; initialWorkflowConfigurations?: WorkflowConfigurations; + user: { id: string; email?: string }; + getAccessToken: () => Promise; } -function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations }: RenderWorkflowProps) { +function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, user, getAccessToken }: RenderWorkflowProps) { const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false); const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false); @@ -95,7 +97,15 @@ 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(() => ({ @@ -112,15 +122,20 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT onRun={onRun} workflowId={workflowId} saveWorkflow={saveWorkflow} + user={user} + getAccessToken={getAccessToken} /> ); const stickyTabs = ; + // Memoize the context value to prevent unnecessary re-renders + const workflowContextValue = useMemo(() => ({ saveWorkflow }), [saveWorkflow]); + return ( - + -
+
{ rfInstance.current = instance; + // Center the workflow on load + setTimeout(() => { + instance.fitView({ padding: 0.2, duration: 200, maxZoom: 0.75 }); + }, 0); }} defaultEdgeOptions={defaultEdgeOptions} + defaultViewport={initialFlow?.viewport} > { + // Only re-render if these specific props change + return ( + prevProps.workflowId === nextProps.workflowId && + prevProps.initialWorkflowName === nextProps.initialWorkflowName && + prevProps.user.id === nextProps.user.id && + prevProps.getAccessToken === nextProps.getAccessToken + // Note: We intentionally don't compare initialFlow, initialTemplateContextVariables, + // or initialWorkflowConfigurations because they're only used for initialization + ); +}); diff --git a/ui/src/app/workflow/[workflowId]/components/WorkflowExecutions.tsx b/ui/src/app/workflow/[workflowId]/components/WorkflowExecutions.tsx new file mode 100644 index 00000000..ff21926e --- /dev/null +++ b/ui/src/app/workflow/[workflowId]/components/WorkflowExecutions.tsx @@ -0,0 +1,348 @@ +"use client"; + +import { ChevronLeft, ChevronRight, Download, ExternalLink } from "lucide-react"; +import { useRouter } from "next/navigation"; +import { useCallback, useEffect, useState } from "react"; + +import { getWorkflowApiV1WorkflowFetchWorkflowIdGet, getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet } from "@/client/sdk.gen"; +import { WorkflowRunResponseSchema } from "@/client/types.gen"; +import { FilterBuilder } from "@/components/filters/FilterBuilder"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { DISPOSITION_CODES } from "@/constants/dispositionCodes"; +import { useUserConfig } from '@/context/UserConfigContext'; +import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant'; +import { downloadFile } from "@/lib/files"; +import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters"; +import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters"; + +interface WorkflowExecutionsProps { + workflowId: number; + searchParams: URLSearchParams; +} + +export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecutionsProps) { + const router = useRouter(); + const [workflowRuns, setWorkflowRuns] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [currentPage, setCurrentPage] = useState(() => { + const pageParam = searchParams.get('page'); + return pageParam ? parseInt(pageParam, 10) : 1; + }); + const [totalPages, setTotalPages] = useState(1); + const [totalCount, setTotalCount] = useState(0); + const [isExecutingFilters, setIsExecutingFilters] = useState(false); + const [configuredAttributes, setConfiguredAttributes] = useState(availableAttributes); + + const { accessToken } = useUserConfig(); + + // Initialize filters from URL + const [activeFilters, setActiveFilters] = useState(() => { + return decodeFiltersFromURL(searchParams, availableAttributes); + }); + + const formatDate = (dateString: string) => new Date(dateString).toLocaleString(); + + // Load disposition codes from workflow configuration + useEffect(() => { + const loadDispositionCodes = async () => { + if (!accessToken) return; + try { + const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({ + path: { workflow_id: Number(workflowId) }, + headers: { 'Authorization': `Bearer ${accessToken}` } + }); + + const workflow = response.data; + if (workflow?.call_disposition_codes) { + // Update the disposition code attribute with actual options + const updatedAttributes = configuredAttributes.map(attr => { + if (attr.id === 'dispositionCode') { + return { + ...attr, + config: { + ...attr.config, + options: Object.keys(workflow.call_disposition_codes || {}).length > 0 + ? Object.keys(workflow.call_disposition_codes || {}) + : [...DISPOSITION_CODES] + } + }; + } + return attr; + }); + setConfiguredAttributes(updatedAttributes); + } + } catch (err) { + console.error("Failed to load disposition codes:", err); + } + }; + + loadDispositionCodes(); + }, [workflowId, accessToken]); + + const fetchWorkflowRuns = useCallback(async (page: number, filters?: ActiveFilter[]) => { + if (!accessToken) return; + try { + setLoading(true); + // Prepare filter data for API + let filterParam = undefined; + if (filters && filters.length > 0) { + const filterData = filters.map(filter => ({ + attribute: filter.attribute.id, + type: filter.attribute.type, + value: filter.value + })); + filterParam = JSON.stringify(filterData); + } + + const response = await getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet({ + path: { workflow_id: Number(workflowId) }, + query: { + page: page, + limit: 50, + ...(filterParam && { filters: filterParam }) + }, + headers: { + 'Authorization': `Bearer ${accessToken}`, + } + }); + + if (response.error) { + throw new Error("Failed to fetch workflow runs"); + } + + if (response.data) { + setWorkflowRuns(response.data.runs || []); + setTotalPages(response.data.total_pages || 1); + setTotalCount(response.data.total_count || 0); + setCurrentPage(response.data.page || 1); + } + setError(null); + } catch (err) { + console.error("Error fetching workflow runs:", err); + setError("Failed to load workflow runs"); + } finally { + setLoading(false); + } + }, [workflowId, accessToken]); + + const updatePageInUrl = useCallback((page: number, filters?: ActiveFilter[]) => { + const params = new URLSearchParams(); + params.set('tab', 'executions'); + params.set('page', page.toString()); + + // Add filters to URL if present + if (filters && filters.length > 0) { + const filterString = encodeFiltersToURL(filters); + if (filterString) { + const filterParams = new URLSearchParams(filterString); + filterParams.forEach((value, key) => params.set(key, value)); + } + } + + router.push(`/workflow/${workflowId}?${params.toString()}`, { scroll: false }); + }, [router, workflowId]); + + useEffect(() => { + fetchWorkflowRuns(currentPage, activeFilters); + }, [currentPage, activeFilters, fetchWorkflowRuns]); + + const handleApplyFilters = useCallback(async () => { + setIsExecutingFilters(true); + setCurrentPage(1); // Reset to first page when applying filters + updatePageInUrl(1, activeFilters); + await fetchWorkflowRuns(1, activeFilters); + setIsExecutingFilters(false); + }, [activeFilters, fetchWorkflowRuns, updatePageInUrl]); + + const handleFiltersChange = useCallback((filters: ActiveFilter[]) => { + setActiveFilters(filters); + }, []); + + const handleClearFilters = useCallback(async () => { + setIsExecutingFilters(true); + setCurrentPage(1); + updatePageInUrl(1, []); // Clear filters from URL + await fetchWorkflowRuns(1, []); // Fetch all workflows without filters + setIsExecutingFilters(false); + }, [fetchWorkflowRuns, updatePageInUrl]); + + return ( +
+
+

Workflow Run History

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

No workflow runs found

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

+ Page {currentPage} of {totalPages} +

+
+ + +
+
+ )} +
+
+ )} +
+ ); +} diff --git a/ui/src/app/workflow/[workflowId]/components/WorkflowHeader.tsx b/ui/src/app/workflow/[workflowId]/components/WorkflowHeader.tsx index 949d9d24..1ef18758 100644 --- a/ui/src/app/workflow/[workflowId]/components/WorkflowHeader.tsx +++ b/ui/src/app/workflow/[workflowId]/components/WorkflowHeader.tsx @@ -16,7 +16,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp import { WORKFLOW_RUN_MODES } from '@/constants/workflowRunModes'; import { useOnboarding } from '@/context/OnboardingContext'; import { useUserConfig } from "@/context/UserConfigContext"; -import { useAuth } from '@/lib/auth'; interface WorkflowHeaderProps { isDirty: boolean; @@ -26,6 +25,8 @@ interface WorkflowHeaderProps { workflowId: number; workflowValidationErrors: WorkflowError[]; saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise; + user: { id: string; email?: string }; + getAccessToken: () => Promise; } const handleExport = (workflow_name: string, workflow_definition: ReactFlowJsonObject | undefined) => { @@ -57,7 +58,7 @@ const handleExport = (workflow_name: string, workflow_definition: ReactFlowJsonO URL.revokeObjectURL(url); }; -const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, workflowValidationErrors, saveWorkflow }: WorkflowHeaderProps) => { +const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, workflowValidationErrors, saveWorkflow, user, getAccessToken }: WorkflowHeaderProps) => { const router = useRouter(); const { userConfig, saveUserConfig } = useUserConfig(); const { hasSeenTooltip, markTooltipSeen } = useOnboarding(); @@ -70,7 +71,6 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, const [phoneChanged, setPhoneChanged] = useState(false); const [validationDialogOpen, setValidationDialogOpen] = useState(false); const [configureDialogOpen, setConfigureDialogOpen] = useState(false); - const { user, getAccessToken } = useAuth(); const webCallButtonRef = useRef(null); const hasValidationErrors = workflowValidationErrors.length > 0; @@ -211,39 +211,73 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, - - - + + + + + + + {(isDirty || hasValidationErrors) && ( + + {isDirty ? 'Save the workflow before exporting' : 'Fix validation errors before exporting'} + + )} + + + + + + + + {(isDirty || hasValidationErrors) && ( + + {isDirty ? 'Save the workflow before testing' : 'Fix validation errors before testing'} + + )} + + + + + + + + {(isDirty || hasValidationErrors) && ( + + {isDirty ? 'Save the workflow before making a call' : 'Fix validation errors before making a call'} + + )} + {isDirty ? (
); diff --git a/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts b/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts index 519920a6..e4baea29 100644 --- a/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts +++ b/ui/src/app/workflow/[workflowId]/hooks/useWorkflowState.ts @@ -5,12 +5,9 @@ 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 { createWorkflowRunApiV1WorkflowWorkflowIdRunsPost, @@ -19,10 +16,10 @@ 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 { useWorkflowStore } from "@/stores/workflowStore"; +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,57 @@ interface UseWorkflowStateProps { }; initialTemplateContextVariables?: Record; initialWorkflowConfigurations?: WorkflowConfigurations; + user: { id: string; email?: string }; // Minimal user type needed + getAccessToken: () => Promise; } -export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations }: UseWorkflowStateProps) => { - const rfInstance = useRef | null>(null); +export const useWorkflowState = ({ + initialWorkflowName, + workflowId, + initialFlow, + initialTemplateContextVariables, + initialWorkflowConfigurations, + user, + getAccessToken +}: UseWorkflowStateProps) => { const router = useRouter(); - const { user, getAccessToken } = useAuth(); - const [nodes, setNodes] = useNodesState( - initialFlow?.nodes?.length + const rfInstance = useRef | null>(null); + + // Get state and actions from the store + const { + nodes, + edges, + workflowName, + isDirty, + isAddNodePanelOpen, + isEditingName, + 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 +140,74 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, : getDefaultAllowInterrupt(node.type), } })) - : defaultNodes - ); - const [edges, setEdges] = useEdgesState(initialFlow?.edges ?? []); - const [isAddNodePanelOpen, setIsAddNodePanelOpen] = useState(false); - const [workflowName, setWorkflowName] = useState(initialWorkflowName); - const [isEditingName, setIsEditingName] = useState(false); - const [isDirty, setIsDirty] = useState(false); - const [workflowValidationErrors, setWorkflowValidationErrors] = useState([]); - const [templateContextVariables, setTemplateContextVariables] = useState>( - initialTemplateContextVariables || {} - ); - const [workflowConfigurations, setWorkflowConfigurations] = useState( - initialWorkflowConfigurations || DEFAULT_WORKFLOW_CONFIGURATIONS - ); + : defaultNodes; + + initializeWorkflow( + workflowId, + initialWorkflowName, + initialNodes, + initialFlow?.edges ?? [], + initialTemplateContextVariables, + initialWorkflowConfigurations + ); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + // Set up keyboard shortcuts for undo/redo + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Check if we're in an input field + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { + return; + } + + // Undo: Cmd/Ctrl + Z + if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) { + e.preventDefault(); + if (canUndo) { + undo(); + } + } + // Redo: Cmd/Ctrl + Shift + Z or Cmd/Ctrl + Y + else if ( + ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'z') || + ((e.metaKey || e.ctrlKey) && e.key === 'y') + ) { + e.preventDefault(); + if (canRedo) { + redo(); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [undo, redo, canUndo, canRedo]); const handleNodeSelect = useCallback((nodeType: string) => { - /* - Used to add new node to the workflow. Receives nodeType as param. - Example: nodeType can be agentNode/ startNode etc. as defined by NodeType in - types.ts + if (!rfInstance.current) return; - We then pass nodeTypes which contais the NodeType keyword and the component. - Those components then contain all the component speecific functioanlity like edit - button etc. + const position = rfInstance.current.screenToFlowPosition({ + x: window.innerWidth / 2, + y: window.innerHeight / 2, + }); - */ - const newNode = getNewNode(nodeType, { x: 150, y: 150 }); - setNodes((nds) => [...nds, newNode]); + const newNode = { + ...getNewNode(nodeType, position, nodes), + selected: true, // Mark the new node as selected + }; + + // Use addNodes from ReactFlow instance + rfInstance.current.addNodes([newNode]); setIsAddNodePanelOpen(false); - }, [setNodes, setIsAddNodePanelOpen]); + }, [nodes, setIsAddNodePanelOpen]); const handleNameChange = (e: React.ChangeEvent) => { setWorkflowName(e.target.value); setIsDirty(true); }; - // Validate workflow function (without saving) + // Validate workflow function const validateWorkflow = useCallback(async () => { if (!user) return; try { @@ -150,106 +221,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 +271,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,56 +293,44 @@ 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); - console.log("in onEdgesChange", changes, eds, newEdges); - return newEdges; - }), + (changes) => { + setEdges((eds) => { + const newEdges = applyEdgeChanges(changes, eds) as FlowEdge[]; + return newEdges; + }); + }, [setEdges], ); const onNodesChange: OnNodesChange = useCallback( - (changes) => setNodes((nds) => { - const newNodes = applyNodeChanges(changes, nds) as FlowNode[]; - setIsDirty(true); - return newNodes; - }), + (changes) => { + setNodes((nds) => { + const newNodes = applyNodeChanges(changes, nds) as FlowNode[]; + return newNodes; + }); + }, [setNodes], ); @@ -355,7 +353,7 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, router.push(`/workflow/${workflowId}/run/${response.data?.id}`); }; - // Save template context variables function + // Save template context variables const saveTemplateContextVariables = useCallback(async (variables: Record) => { if (!user) return; const accessToken = await getAccessToken(); @@ -379,9 +377,9 @@ 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 + // Save workflow configurations const saveWorkflowConfigurations = useCallback(async (configurations: WorkflowConfigurations) => { if (!user) return; const accessToken = await getAccessToken(); @@ -405,15 +403,20 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, logger.error(`Error saving workflow configurations: ${error}`); throw error; } - }, [workflowId, workflowName, user, getAccessToken]); + }, [workflowId, workflowName, user, getAccessToken, setWorkflowConfigurations]); + + // 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, @@ -426,10 +429,7 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, templateContextVariables, workflowConfigurations, setNodes, - setEdges, setIsAddNodePanelOpen, - setWorkflowName, - setIsEditingName, handleNodeSelect, handleNameChange, saveWorkflow, @@ -438,6 +438,11 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, onNodesChange, onRun, saveTemplateContextVariables, - saveWorkflowConfigurations + saveWorkflowConfigurations, + // Export undo/redo state + undo, + redo, + canUndo, + canRedo, }; }; diff --git a/ui/src/app/workflow/[workflowId]/page.tsx b/ui/src/app/workflow/[workflowId]/page.tsx index a764befa..3f24ca36 100644 --- a/ui/src/app/workflow/[workflowId]/page.tsx +++ b/ui/src/app/workflow/[workflowId]/page.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useParams } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { useParams, useSearchParams } from 'next/navigation'; +import { useEffect, useMemo, useState } from 'react'; import RenderWorkflow from '@/app/workflow/[workflowId]/RenderWorkflow'; import { getWorkflowApiV1WorkflowFetchWorkflowIdGet } from '@/client/sdk.gen'; @@ -13,14 +13,20 @@ import logger from '@/lib/logger'; import { DEFAULT_WORKFLOW_CONFIGURATIONS,WorkflowConfigurations } from '@/types/workflow-configurations'; import WorkflowLayout from '../WorkflowLayout'; +import { WorkflowExecutions } from './components/WorkflowExecutions'; +import { WorkflowTabs } from './components/WorkflowTabs'; export default function WorkflowDetailPage() { const params = useParams(); + const searchParams = useSearchParams(); const [workflow, setWorkflow] = useState(undefined); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const { user, getAccessToken, redirectToLogin, loading: authLoading } = useAuth(); + // Get current tab from URL, default to 'editor' + const currentTab = (searchParams.get('tab') as 'editor' | 'executions') || 'editor'; + // Redirect if not authenticated useEffect(() => { if (!authLoading && !user) { @@ -56,16 +62,22 @@ export default function WorkflowDetailPage() { } }, [params.workflowId, user, getAccessToken]); + const stickyTabs = workflow ? : null; + + // Memoize user and getAccessToken to prevent unnecessary re-renders + const stableUser = useMemo(() => user, [user?.id]); + const stableGetAccessToken = useMemo(() => getAccessToken, [getAccessToken]); + if (loading) { return ( - + ); } else if (error || !workflow) { return ( - +
{error || 'Workflow not found'}
@@ -73,19 +85,36 @@ export default function WorkflowDetailPage() { ); } else { + // Render both views but hide the inactive one using absolute positioning + // This preserves state when switching tabs return ( - // We are sending custom header actions to WorkflowLayout from RenderWorkflow component - || {}} - initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS} - /> + <> + {/* Editor view */} +
+ {stableUser && ( + || {}} + initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS} + user={stableUser} + getAccessToken={stableGetAccessToken} + /> + )} +
+ + {/* Executions view */} +
+ + + +
+ ); } } diff --git a/ui/src/app/workflow/[workflowId]/runs/page.tsx b/ui/src/app/workflow/[workflowId]/runs/page.tsx index 187cba9c..14ec82e9 100644 --- a/ui/src/app/workflow/[workflowId]/runs/page.tsx +++ b/ui/src/app/workflow/[workflowId]/runs/page.tsx @@ -1,352 +1,19 @@ "use client"; -import { ChevronLeft, ChevronRight, Download, ExternalLink } from "lucide-react"; 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 { WorkflowTabs } from '../components/WorkflowTabs'; +import { useEffect } from "react"; export default function WorkflowRunsPage() { const { workflowId } = useParams(); const router = useRouter(); const searchParams = useSearchParams(); - const [workflowRuns, setWorkflowRuns] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [currentPage, setCurrentPage] = useState(() => { - const pageParam = searchParams.get('page'); - return pageParam ? parseInt(pageParam, 10) : 1; - }); - const [totalPages, setTotalPages] = useState(1); - const [totalCount, setTotalCount] = useState(0); - const [isExecutingFilters, setIsExecutingFilters] = useState(false); - const [configuredAttributes, setConfiguredAttributes] = useState(availableAttributes); - const { accessToken } = useUserConfig(); - - // Initialize filters from URL - const [activeFilters, setActiveFilters] = useState(() => { - return decodeFiltersFromURL(searchParams, availableAttributes); - }); - - const formatDate = (dateString: string) => new Date(dateString).toLocaleString(); - - - // Load disposition codes from workflow configuration + // Redirect to main workflow page with executions tab useEffect(() => { - const loadDispositionCodes = async () => { - if (!accessToken) return; - try { - const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({ - path: { workflow_id: Number(workflowId) }, - headers: { 'Authorization': `Bearer ${accessToken}` } - }); + const params = new URLSearchParams(searchParams.toString()); + params.set('tab', 'executions'); + router.replace(`/workflow/${workflowId}?${params.toString()}`); + }, [workflowId, router, searchParams]); - const workflow = response.data; - if (workflow?.call_disposition_codes) { - // Update the disposition code attribute with actual options - const updatedAttributes = configuredAttributes.map(attr => { - if (attr.id === 'dispositionCode') { - return { - ...attr, - config: { - ...attr.config, - options: Object.keys(workflow.call_disposition_codes || {}).length > 0 - ? Object.keys(workflow.call_disposition_codes || {}) - : [...DISPOSITION_CODES] - } - }; - } - return attr; - }); - setConfiguredAttributes(updatedAttributes); - } - } catch (err) { - console.error("Failed to load disposition codes:", err); - } - }; - - loadDispositionCodes(); - }, [workflowId, accessToken]); - - 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 stickyTabs = ; - - return ( - -
-
-

Workflow Run History

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

No workflow runs found

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

- Page {currentPage} of {totalPages} -

-
- - -
-
- )} -
-
- )} -
-
- ); + return null; } diff --git a/ui/src/components/flow/edges/CustomEdge.tsx b/ui/src/components/flow/edges/CustomEdge.tsx index 56b7ec98..f3b8c901 100644 --- a/ui/src/components/flow/edges/CustomEdge.tsx +++ b/ui/src/components/flow/edges/CustomEdge.tsx @@ -92,11 +92,6 @@ export default function CustomEdge(props: CustomEdgeProps) { const [open, setOpen] = useState(false); const [isHovered, setIsHovered] = useState(false); - // Edge is highlighted when either selected or hovered - const isHighlighted = selected || isHovered; - - console.log("in CustomEdge", id, selected, isHovered, isHighlighted); - const parallel = getEdges().filter( (e) => (e.source === source && e.target === target) || @@ -131,34 +126,34 @@ export default function CustomEdge(props: CustomEdgeProps) { targetPosition, }); - // Highlight connected nodes when edge is highlighted (selected or hovered) + // Update connected nodes when edge is selected or hovered useEffect(() => { - if (isHighlighted) { - setNodes((nodes) => - nodes.map((node) => { - if (node.id === source || node.id === target) { + 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, highlighted: true } + data: { + ...node.data, + selected_through_edge: shouldSelectThroughEdge, + hovered_through_edge: shouldHoverThroughEdge + } }; } - return node; - }) - ); - } else { - setNodes((nodes) => - nodes.map((node) => { - if (node.id === source || node.id === target) { - return { - ...node, - data: { ...node.data, highlighted: false } - }; - } - return node; - }) - ); - } - }, [isHighlighted, source, target, setNodes, selected]); + } + return node; + }); + }); + }, [selected, isHovered, source, target, setNodes]); const handleSaveEdgeData = useCallback(async (updatedData: FlowEdgeData) => { // Update the node data in the ReactFlow nodes state @@ -182,24 +177,31 @@ export default function CustomEdge(props: CustomEdgeProps) { setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} + onDoubleClick={() => setOpen(true)} > - {/* Show label when highlighted (selected or hovered), positioned at edge center */} - {isHighlighted && ( + {/* Show label when selected or hovered, positioned at edge center */} + {(selected || isHovered) && (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} + onDoubleClick={() => setOpen(true)} >
- Condition + Condition - EdgeID: {id}