Merge pull request #215 from Gauchosr/thread-deletion-final

Add thread deletion with confirmation dialog and improved UI interaction
This commit is contained in:
Marko Kraemer 2025-05-03 17:50:27 -07:00 committed by GitHub
commit 1741a338ba
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 468 additions and 24 deletions

View File

@ -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 (
<SidebarProvider>
<SidebarLeft />
<SidebarInset>
<div className="bg-background">
{children}
</div>
</SidebarInset>
{/* <PricingAlert
open={showPricingAlert}
onOpenChange={setShowPricingAlert}
closeable={false}
accountId={personalAccount?.account_id}
/> */}
<MaintenanceAlert
open={showMaintenanceAlert}
onOpenChange={setShowMaintenanceAlert}
closeable={true}
/>
</SidebarProvider>
<DeleteOperationProvider>
<SidebarProvider>
<SidebarLeft />
<SidebarInset>
<div className="bg-background">
{children}
</div>
</SidebarInset>
{/* <PricingAlert
open={showPricingAlert}
onOpenChange={setShowPricingAlert}
closeable={false}
accountId={personalAccount?.account_id}
/> */}
<MaintenanceAlert
open={showMaintenanceAlert}
onOpenChange={setShowMaintenanceAlert}
closeable={true}
/>
{/* Status overlay for deletion operations */}
<StatusOverlay />
</SidebarProvider>
</DeleteOperationProvider>
)
}

View File

@ -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<string | null>(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<HTMLAnchorElement>, 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 (
<SidebarGroup>
@ -293,7 +374,7 @@ export function NavAgents() {
</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownMenuItem onClick={() => handleDeleteThread(thread.threadId, thread.projectName)}>
<Trash2 className="text-muted-foreground" />
<span>Delete</span>
</DropdownMenuItem>
@ -314,6 +395,16 @@ export function NavAgents() {
</SidebarMenuItem>
)}
</SidebarMenu>
{threadToDelete && (
<DeleteConfirmationDialog
isOpen={isDeleteDialogOpen}
onClose={() => setIsDeleteDialogOpen(false)}
onConfirm={confirmDelete}
threadName={threadToDelete.name}
isDeleting={isDeleting}
/>
)}
</SidebarGroup>
)
}

View File

@ -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 (
<AlertDialog open={isOpen} onOpenChange={onClose}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete conversation</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the conversation{" "}
<span className="font-semibold">"{threadName}"</span>?
<br />
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault()
onConfirm()
}}
disabled={isDeleting}
className="bg-destructive text-white hover:bg-destructive/90"
>
{isDeleting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Deleting...
</>
) : (
"Delete"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)
}

View File

@ -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 (
<div className="fixed bottom-4 right-4 z-50 flex items-center gap-2 bg-background/90 backdrop-blur p-3 rounded-lg shadow-lg border border-border">
{state.operation === 'pending' && (
<>
<Loader2 className="h-5 w-5 text-muted-foreground animate-spin" />
<span className="text-sm">Processing...</span>
</>
)}
{state.operation === 'success' && (
<>
<CheckCircle className="h-5 w-5 text-green-500" />
<span className="text-sm">Completed</span>
</>
)}
{state.operation === 'error' && (
<>
<AlertCircle className="h-5 w-5 text-destructive" />
<span className="text-sm">Failed</span>
</>
)}
</div>
);
}

View File

@ -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<DeleteAction>;
performDelete: (
id: string,
isActive: boolean,
deleteFunction: () => Promise<void>,
onComplete?: () => void
) => Promise<void>;
isOperationInProgress: React.MutableRefObject<boolean>;
};
const DeleteOperationContext = createContext<DeleteOperationContextType | undefined>(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<void>,
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 (
<DeleteOperationContext.Provider value={{
state,
dispatch,
performDelete,
isOperationInProgress
}}>
{children}
</DeleteOperationContext.Provider>
);
}
export function useDeleteOperation() {
const context = useContext(DeleteOperationContext);
if (context === undefined) {
throw new Error('useDeleteOperation must be used within a DeleteOperationProvider');
}
return context;
}

View File

@ -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<void> => {
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<Project[]> => {
try {