From d826d2a18ec1d84c1ebb5368572d8b35c9fbfad6 Mon Sep 17 00:00:00 2001 From: Gauchosr Date: Sat, 3 May 2025 23:16:16 +0200 Subject: [PATCH] Add thread deletion with confirmation dialog and improved UI interaction --- frontend/src/app/(dashboard)/layout.tsx | 49 +++-- .../src/components/sidebar/nav-agents.tsx | 97 ++++++++- .../thread/DeleteConfirmationDialog.tsx | 70 +++++++ frontend/src/components/ui/status-overlay.tsx | 34 +++ .../src/contexts/DeleteOperationContext.tsx | 195 ++++++++++++++++++ frontend/src/lib/api.ts | 47 +++++ 6 files changed, 468 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/thread/DeleteConfirmationDialog.tsx create mode 100644 frontend/src/components/ui/status-overlay.tsx create mode 100644 frontend/src/contexts/DeleteOperationContext.tsx diff --git a/frontend/src/app/(dashboard)/layout.tsx b/frontend/src/app/(dashboard)/layout.tsx index e2074df9..339878b9 100644 --- a/frontend/src/app/(dashboard)/layout.tsx +++ b/frontend/src/app/(dashboard)/layout.tsx @@ -14,6 +14,8 @@ import { useRouter } from "next/navigation" import { Loader2 } from "lucide-react" import { checkApiHealth } from "@/lib/api" import { MaintenancePage } from "@/components/maintenance/maintenance-page" +import { DeleteOperationProvider } from "@/contexts/DeleteOperationContext" +import { StatusOverlay } from "@/components/ui/status-overlay" interface DashboardLayoutProps { children: React.ReactNode @@ -83,26 +85,31 @@ export default function DashboardLayout({ } return ( - - - -
- {children} -
-
- - {/* */} - - -
+ + + + +
+ {children} +
+
+ + {/* */} + + + + {/* Status overlay for deletion operations */} + +
+
) } \ No newline at end of file diff --git a/frontend/src/components/sidebar/nav-agents.tsx b/frontend/src/components/sidebar/nav-agents.tsx index 290a189e..93e32cc1 100644 --- a/frontend/src/components/sidebar/nav-agents.tsx +++ b/frontend/src/components/sidebar/nav-agents.tsx @@ -1,6 +1,6 @@ "use client" -import { useEffect, useState } from "react" +import { useEffect, useState, useRef } from "react" import { ArrowUpRight, Link as LinkIcon, @@ -34,8 +34,10 @@ import { TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import { getProjects, getThreads, Project } from "@/lib/api" +import { getProjects, getThreads, Project, deleteThread } from "@/lib/api" import Link from "next/link" +import { DeleteConfirmationDialog } from "@/components/thread/DeleteConfirmationDialog" +import { useDeleteOperation } from '@/contexts/DeleteOperationContext' // Thread with associated project info for display in sidebar type ThreadWithProject = { @@ -53,6 +55,12 @@ export function NavAgents() { const [loadingThreadId, setLoadingThreadId] = useState(null) const pathname = usePathname() const router = useRouter() + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) + const [threadToDelete, setThreadToDelete] = useState<{ id: string; name: string } | null>(null) + const [isDeleting, setIsDeleting] = useState(false) + const isNavigatingRef = useRef(false) + const { performDelete, isOperationInProgress } = useDeleteOperation(); + const isPerformingActionRef = useRef(false); // Helper to sort threads by updated_at (most recent first) const sortThreads = (threadsList: ThreadWithProject[]): ThreadWithProject[] => { @@ -174,12 +182,85 @@ export function NavAgents() { setLoadingThreadId(null) }, [pathname]) + // Add event handler for completed navigation + useEffect(() => { + const handleNavigationComplete = () => { + console.log("NAVIGATION - Navigation event completed"); + document.body.style.pointerEvents = "auto"; + isNavigatingRef.current = false; + }; + + window.addEventListener("popstate", handleNavigationComplete); + + return () => { + window.removeEventListener("popstate", handleNavigationComplete); + // Ensure we clean up any leftover styles + document.body.style.pointerEvents = "auto"; + }; + }, []); + + // Reset isNavigatingRef when pathname changes + useEffect(() => { + isNavigatingRef.current = false; + document.body.style.pointerEvents = "auto"; + }, [pathname]); + // Function to handle thread click with loading state const handleThreadClick = (e: React.MouseEvent, threadId: string, url: string) => { e.preventDefault() setLoadingThreadId(threadId) router.push(url) } + + // Function to handle thread deletion + const handleDeleteThread = async (threadId: string, threadName: string) => { + setThreadToDelete({ id: threadId, name: threadName }); + setIsDeleteDialogOpen(true); + }; + + const confirmDelete = async () => { + if (!threadToDelete || isPerformingActionRef.current) return; + + // Mark action in progress + isPerformingActionRef.current = true; + + // Close dialog first for immediate feedback + setIsDeleteDialogOpen(false); + + const threadId = threadToDelete.id; + const isActive = pathname?.includes(threadId); + + // Store threadToDelete in a local variable since it might be cleared + const deletedThread = { ...threadToDelete }; + + // Log operation start + console.log("DELETION - Starting thread deletion process", { + threadId: deletedThread.id, + isCurrentThread: isActive + }); + + // Use the centralized deletion system with completion callback + await performDelete( + threadId, + isActive, + async () => { + // Delete the thread + await deleteThread(threadId); + + // Update the thread list + setThreads(prev => prev.filter(t => t.threadId !== threadId)); + + // Show success message + toast.success("Conversation deleted successfully"); + }, + // Completion callback to reset local state + () => { + setThreadToDelete(null); + setIsDeleting(false); + isPerformingActionRef.current = false; + } + ); + }; return ( @@ -293,7 +374,7 @@ export function NavAgents() { - + handleDeleteThread(thread.threadId, thread.projectName)}> Delete @@ -314,6 +395,16 @@ export function NavAgents() { )} + + {threadToDelete && ( + setIsDeleteDialogOpen(false)} + onConfirm={confirmDelete} + threadName={threadToDelete.name} + isDeleting={isDeleting} + /> + )} ) } diff --git a/frontend/src/components/thread/DeleteConfirmationDialog.tsx b/frontend/src/components/thread/DeleteConfirmationDialog.tsx new file mode 100644 index 00000000..8118edbf --- /dev/null +++ b/frontend/src/components/thread/DeleteConfirmationDialog.tsx @@ -0,0 +1,70 @@ +"use client" + +import React from "react" +import { Loader2 } from "lucide-react" + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" + +interface DeleteConfirmationDialogProps { + isOpen: boolean + onClose: () => void + onConfirm: () => void + threadName: string + isDeleting: boolean +} + +/** + * Confirmation dialog for deleting a conversation + */ +export function DeleteConfirmationDialog({ + isOpen, + onClose, + onConfirm, + threadName, + isDeleting, +}: DeleteConfirmationDialogProps) { + return ( + + + + Delete conversation + + Are you sure you want to delete the conversation{" "} + "{threadName}"? +
+ This action cannot be undone. +
+
+ + Cancel + { + e.preventDefault() + onConfirm() + }} + disabled={isDeleting} + className="bg-destructive text-white hover:bg-destructive/90" + > + {isDeleting ? ( + <> + + Deleting... + + ) : ( + "Delete" + )} + + +
+
+ ) +} \ No newline at end of file diff --git a/frontend/src/components/ui/status-overlay.tsx b/frontend/src/components/ui/status-overlay.tsx new file mode 100644 index 00000000..defa9ef4 --- /dev/null +++ b/frontend/src/components/ui/status-overlay.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Loader2, CheckCircle, AlertCircle } from 'lucide-react'; +import { useDeleteOperation } from '@/contexts/DeleteOperationContext'; + +export function StatusOverlay() { + const { state } = useDeleteOperation(); + + if (state.operation === 'none' || !state.isDeleting) return null; + + return ( +
+ {state.operation === 'pending' && ( + <> + + Processing... + + )} + + {state.operation === 'success' && ( + <> + + Completed + + )} + + {state.operation === 'error' && ( + <> + + Failed + + )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/contexts/DeleteOperationContext.tsx b/frontend/src/contexts/DeleteOperationContext.tsx new file mode 100644 index 00000000..cde3146e --- /dev/null +++ b/frontend/src/contexts/DeleteOperationContext.tsx @@ -0,0 +1,195 @@ +import React, { createContext, useContext, useReducer, useEffect, useRef } from 'react'; + +type DeleteState = { + isDeleting: boolean; + targetId: string | null; + isActive: boolean; + operation: 'none' | 'pending' | 'success' | 'error'; +}; + +type DeleteAction = + | { type: 'START_DELETE'; id: string; isActive: boolean } + | { type: 'DELETE_SUCCESS' } + | { type: 'DELETE_ERROR' } + | { type: 'RESET' }; + +const initialState: DeleteState = { + isDeleting: false, + targetId: null, + isActive: false, + operation: 'none' +}; + +function deleteReducer(state: DeleteState, action: DeleteAction): DeleteState { + switch (action.type) { + case 'START_DELETE': + return { + ...state, + isDeleting: true, + targetId: action.id, + isActive: action.isActive, + operation: 'pending' + }; + case 'DELETE_SUCCESS': + return { + ...state, + operation: 'success' + }; + case 'DELETE_ERROR': + return { + ...state, + isDeleting: false, + operation: 'error' + }; + case 'RESET': + return initialState; + default: + return state; + } +} + +type DeleteOperationContextType = { + state: DeleteState; + dispatch: React.Dispatch; + performDelete: ( + id: string, + isActive: boolean, + deleteFunction: () => Promise, + onComplete?: () => void + ) => Promise; + isOperationInProgress: React.MutableRefObject; +}; + +const DeleteOperationContext = createContext(undefined); + +export function DeleteOperationProvider({ children }: { children: React.ReactNode }) { + const [state, dispatch] = useReducer(deleteReducer, initialState); + const isOperationInProgress = useRef(false); + + // Listen for state changes to handle navigation + useEffect(() => { + if (state.operation === 'success' && state.isActive) { + // Delay navigation to allow UI feedback + const timer = setTimeout(() => { + try { + // Use window.location for reliable navigation + window.location.pathname = '/dashboard'; + } catch (error) { + console.error("Navigation error:", error); + } + }, 500); + return () => clearTimeout(timer); + } + }, [state.operation, state.isActive]); + + // Auto-reset after operations complete + useEffect(() => { + if (state.operation === 'success' && !state.isActive) { + const timer = setTimeout(() => { + dispatch({ type: 'RESET' }); + // Ensure pointer events are restored + document.body.style.pointerEvents = "auto"; + isOperationInProgress.current = false; + + // Restore sidebar menu interactivity + const sidebarMenu = document.querySelector(".sidebar-menu"); + if (sidebarMenu) { + sidebarMenu.classList.remove("pointer-events-none"); + } + }, 1000); + return () => clearTimeout(timer); + } + + if (state.operation === 'error') { + // Reset on error immediately + document.body.style.pointerEvents = "auto"; + isOperationInProgress.current = false; + + // Restore sidebar menu interactivity + const sidebarMenu = document.querySelector(".sidebar-menu"); + if (sidebarMenu) { + sidebarMenu.classList.remove("pointer-events-none"); + } + } + }, [state.operation, state.isActive]); + + const performDelete = async ( + id: string, + isActive: boolean, + deleteFunction: () => Promise, + onComplete?: () => void + ) => { + // Prevent multiple operations + if (isOperationInProgress.current) return; + isOperationInProgress.current = true; + + // Disable pointer events during operation + document.body.style.pointerEvents = "none"; + + // Disable sidebar menu interactions + const sidebarMenu = document.querySelector(".sidebar-menu"); + if (sidebarMenu) { + sidebarMenu.classList.add("pointer-events-none"); + } + + dispatch({ type: 'START_DELETE', id, isActive }); + + try { + // Execute the delete operation + await deleteFunction(); + + // Use precise timing for UI updates + setTimeout(() => { + dispatch({ type: 'DELETE_SUCCESS' }); + + // For non-active threads, restore interaction with delay + if (!isActive) { + setTimeout(() => { + document.body.style.pointerEvents = "auto"; + + if (sidebarMenu) { + sidebarMenu.classList.remove("pointer-events-none"); + } + + // Call the completion callback + if (onComplete) onComplete(); + }, 100); + } + }, 50); + } catch (error) { + console.error("Delete operation failed:", error); + + // Reset states on error + document.body.style.pointerEvents = "auto"; + isOperationInProgress.current = false; + + if (sidebarMenu) { + sidebarMenu.classList.remove("pointer-events-none"); + } + + dispatch({ type: 'DELETE_ERROR' }); + + // Call the completion callback + if (onComplete) onComplete(); + } + }; + + return ( + + {children} + + ); +} + +export function useDeleteOperation() { + const context = useContext(DeleteOperationContext); + if (context === undefined) { + throw new Error('useDeleteOperation must be used within a DeleteOperationProvider'); + } + return context; +} \ No newline at end of file diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5e9acca3..ef6f5e21 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1051,6 +1051,53 @@ export const toggleThreadPublicStatus = async (threadId: string, isPublic: boole return updateThread(threadId, { is_public: isPublic }); }; +export const deleteThread = async (threadId: string): Promise => { + try { + const supabase = createClient(); + + // First delete all agent runs associated with this thread + console.log(`Deleting all agent runs for thread ${threadId}`); + const { error: agentRunsError } = await supabase + .from('agent_runs') + .delete() + .eq('thread_id', threadId); + + if (agentRunsError) { + console.error('Error deleting agent runs:', agentRunsError); + throw new Error(`Error deleting agent runs: ${agentRunsError.message}`); + } + + // Then delete all messages associated with the thread + console.log(`Deleting all messages for thread ${threadId}`); + const { error: messagesError } = await supabase + .from('messages') + .delete() + .eq('thread_id', threadId); + + if (messagesError) { + console.error('Error deleting messages:', messagesError); + throw new Error(`Error deleting messages: ${messagesError.message}`); + } + + // Finally, delete the thread itself + console.log(`Deleting thread ${threadId}`); + const { error: threadError } = await supabase + .from('threads') + .delete() + .eq('thread_id', threadId); + + if (threadError) { + console.error('Error deleting thread:', threadError); + throw new Error(`Error deleting thread: ${threadError.message}`); + } + + console.log(`Thread ${threadId} successfully deleted with all related items`); + } catch (error) { + console.error('Error deleting thread and related items:', error); + throw error; + } +}; + // Function to get public projects export const getPublicProjects = async (): Promise => { try {