mirror of https://github.com/kortix-ai/suna.git
Merge pull request #215 from Gauchosr/thread-deletion-final
Add thread deletion with confirmation dialog and improved UI interaction
This commit is contained in:
commit
1741a338ba
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 {
|
||||
|
|
Loading…
Reference in New Issue