diff --git a/frontend/src/components/sidebar/nav-agents.tsx b/frontend/src/components/sidebar/nav-agents.tsx index 9d94be51..9496a494 100644 --- a/frontend/src/components/sidebar/nav-agents.tsx +++ b/frontend/src/components/sidebar/nav-agents.tsx @@ -1,6 +1,7 @@ 'use client'; import { useEffect, useState, useRef } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; import { ArrowUpRight, Link as LinkIcon, @@ -9,7 +10,9 @@ import { Plus, MessagesSquare, Loader2, - Share2 + Share2, + X, + Check } from "lucide-react" import { toast } from "sonner" import { usePathname, useRouter } from "next/navigation" @@ -35,25 +38,18 @@ import { TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" -import { getProjects, getThreads, Project, deleteThread } from "@/lib/api" import Link from "next/link" import { ShareModal } from "./share-modal" import { DeleteConfirmationDialog } from "@/components/thread/DeleteConfirmationDialog" import { useDeleteOperation } from '@/contexts/DeleteOperationContext' - -// Thread with associated project info for display in sidebar -type ThreadWithProject = { - threadId: string; - projectId: string; - projectName: string; - url: string; - updatedAt: string; -}; +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { ThreadWithProject } from '@/hooks/react-query/sidebar/use-sidebar'; +import { processThreadsWithProjects, useDeleteMultipleThreads, useDeleteThread, useProjects, useThreads } from '@/hooks/react-query/sidebar/use-sidebar'; +import { projectKeys, threadKeys } from '@/hooks/react-query/sidebar/keys'; export function NavAgents() { const { isMobile, state } = useSidebar() - const [threads, setThreads] = useState([]) - const [isLoading, setIsLoading] = useState(true) const [loadingThreadId, setLoadingThreadId] = useState(null) const [showShareModal, setShowShareModal] = useState(false) const [selectedItem, setSelectedItem] = useState<{ threadId: string, projectId: string } | null>(null) @@ -61,119 +57,50 @@ export function NavAgents() { 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 { performDelete } = useDeleteOperation(); const isPerformingActionRef = useRef(false); + const queryClient = useQueryClient(); + + const [isMultiSelectActive, setIsMultiSelectActive] = useState(false); + const [selectedThreads, setSelectedThreads] = useState>(new Set()); + const [deleteProgress, setDeleteProgress] = useState(0); + const [totalToDelete, setTotalToDelete] = useState(0); - // Helper to sort threads by updated_at (most recent first) - const sortThreads = ( - threadsList: ThreadWithProject[], - ): ThreadWithProject[] => { - return [...threadsList].sort((a, b) => { - return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); - }); + const { + data: projects = [], + isLoading: isProjectsLoading, + error: projectsError + } = useProjects(); + + const { + data: threads = [], + isLoading: isThreadsLoading, + error: threadsError + } = useThreads(); + + const { mutate: deleteThreadMutation, isPending: isDeletingSingle } = useDeleteThread(); + const { + mutate: deleteMultipleThreadsMutation, + isPending: isDeletingMultiple + } = useDeleteMultipleThreads(); + + const combinedThreads: ThreadWithProject[] = + !isProjectsLoading && !isThreadsLoading ? + processThreadsWithProjects(threads, projects) : []; + + const handleDeletionProgress = (completed: number, total: number) => { + const percentage = (completed / total) * 100; + setDeleteProgress(percentage); }; - - // Function to load threads data with associated projects - const loadThreadsWithProjects = async (showLoading = true) => { - try { - if (showLoading) { - setIsLoading(true); - } - - // Get all projects - const projects = await getProjects() as Project[] - console.log("Projects loaded:", projects.length, projects.map(p => ({ id: p.id, name: p.name }))); - - // If no projects are found, the user might not be logged in - if (projects.length === 0) { - setThreads([]); - return; - } - - // Create a map of projects by ID for faster lookups - const projectsById = new Map(); - projects.forEach((project) => { - projectsById.set(project.id, project); - }); - - // Get all threads at once - const allThreads = await getThreads() - console.log("Threads loaded:", allThreads.length, allThreads.map(t => ({ thread_id: t.thread_id, project_id: t.project_id }))); - - // Create display objects for threads with their project info - const threadsWithProjects: ThreadWithProject[] = []; - - for (const thread of allThreads) { - const projectId = thread.project_id; - // Skip threads without a project ID - if (!projectId) continue; - - // Get the associated project - const project = projectsById.get(projectId); - if (!project) { - console.log( - `❌ Thread ${thread.thread_id} has project_id=${projectId} but no matching project found`, - ); - continue; - } - - console.log(`✅ Thread ${thread.thread_id} matched with project "${project.name}" (${projectId})`); - - // Add to our list - threadsWithProjects.push({ - threadId: thread.thread_id, - projectId: projectId, - projectName: project.name || 'Unnamed Project', - url: `/agents/${thread.thread_id}`, - updatedAt: - thread.updated_at || project.updated_at || new Date().toISOString(), - }); - } - - // Set threads, ensuring consistent sort order - setThreads(sortThreads(threadsWithProjects)); - } catch (err) { - console.error('Error loading threads with projects:', err); - // Set empty threads array on error - setThreads([]); - } finally { - if (showLoading) { - setIsLoading(false); - } - } - }; - - // Load threads dynamically from the API on initial load - useEffect(() => { - loadThreadsWithProjects(true); - }, []); - - // Listen for project-updated events to update the sidebar without full reload + useEffect(() => { const handleProjectUpdate = (event: Event) => { const customEvent = event as CustomEvent; if (customEvent.detail) { const { projectId, updatedData } = customEvent.detail; - - // Update just the name for the threads with the matching project ID - setThreads(prevThreads => { - const updatedThreads = prevThreads.map(thread => - thread.projectId === projectId - ? { - ...thread, - projectName: updatedData.name, - } - : thread - ); - - // Return the threads without re-sorting immediately - return updatedThreads; - }); - - // Silently refresh in background to fetch updated timestamp and re-sort - setTimeout(() => loadThreadsWithProjects(false), 1000); + queryClient.invalidateQueries({ queryKey: projectKeys.detail(projectId) }); + queryClient.invalidateQueries({ queryKey: projectKeys.lists() }); } }; @@ -187,7 +114,7 @@ export function NavAgents() { handleProjectUpdate as EventListener, ); }; - }, []); + }, [queryClient]); // Reset loading state when navigation completes (pathname changes) useEffect(() => { @@ -219,17 +146,77 @@ export function NavAgents() { // Function to handle thread click with loading state const handleThreadClick = (e: React.MouseEvent, threadId: string, url: string) => { + // If multi-select is active, prevent navigation and toggle selection + if (isMultiSelectActive) { + e.preventDefault(); + toggleThreadSelection(threadId); + return; + } + e.preventDefault() setLoadingThreadId(threadId) router.push(url) } + // Toggle thread selection for multi-select + const toggleThreadSelection = (threadId: string) => { + setSelectedThreads(prev => { + const newSelection = new Set(prev); + if (newSelection.has(threadId)) { + newSelection.delete(threadId); + } else { + newSelection.add(threadId); + } + return newSelection; + }); + }; + + // Toggle multi-select mode + const toggleMultiSelect = () => { + setIsMultiSelectActive(!isMultiSelectActive); + // Clear selections when toggling off + if (isMultiSelectActive) { + setSelectedThreads(new Set()); + } + }; + + // Select all threads + const selectAllThreads = () => { + const allThreadIds = combinedThreads.map(thread => thread.threadId); + setSelectedThreads(new Set(allThreadIds)); + }; + + // Deselect all threads + const deselectAllThreads = () => { + setSelectedThreads(new Set()); + }; + // Function to handle thread deletion const handleDeleteThread = async (threadId: string, threadName: string) => { setThreadToDelete({ id: threadId, name: threadName }); setIsDeleteDialogOpen(true); }; + // Function to handle multi-delete + const handleMultiDelete = () => { + if (selectedThreads.size === 0) return; + + // Get thread names for confirmation dialog + const threadsToDelete = combinedThreads.filter(t => selectedThreads.has(t.threadId)); + const threadNames = threadsToDelete.map(t => t.projectName).join(", "); + + setThreadToDelete({ + id: "multiple", + name: selectedThreads.size > 3 + ? `${selectedThreads.size} conversations` + : threadNames + }); + + setTotalToDelete(selectedThreads.size); + setDeleteProgress(0); + setIsDeleteDialogOpen(true); + }; + const confirmDelete = async () => { if (!threadToDelete || isPerformingActionRef.current) return; @@ -239,58 +226,204 @@ export function NavAgents() { // Close dialog first for immediate feedback setIsDeleteDialogOpen(false); - const threadId = threadToDelete.id; - const isActive = pathname?.includes(threadId); + // Check if it's a single thread or multiple threads + if (threadToDelete.id !== "multiple") { + // Single thread deletion + const threadId = threadToDelete.id; + const isActive = pathname?.includes(threadId); - // Store threadToDelete in a local variable since it might be cleared - const deletedThread = { ...threadToDelete }; + // 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, - }); + // 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 - () => { + // Use the centralized deletion system with completion callback + await performDelete( + threadId, + isActive, + async () => { + // Delete the thread using the mutation + deleteThreadMutation( + { threadId }, + { + onSuccess: () => { + // Invalidate queries to refresh the list + queryClient.invalidateQueries({ queryKey: threadKeys.lists() }); + toast.success('Conversation deleted successfully'); + }, + onSettled: () => { + setThreadToDelete(null); + isPerformingActionRef.current = false; + } + } + ); + }, + // Completion callback to reset local state + () => { + setThreadToDelete(null); + isPerformingActionRef.current = false; + }, + ); + } else { + // Multi-thread deletion + const threadIdsToDelete = Array.from(selectedThreads); + const isActiveThreadIncluded = threadIdsToDelete.some(id => pathname?.includes(id)); + + // Show initial toast + toast.info(`Deleting ${threadIdsToDelete.length} conversations...`); + + try { + // If the active thread is included, handle navigation first + if (isActiveThreadIncluded) { + // Navigate to dashboard before deleting + isNavigatingRef.current = true; + document.body.style.pointerEvents = 'none'; + router.push('/dashboard'); + + // Wait a moment for navigation to start + await new Promise(resolve => setTimeout(resolve, 100)); + } + + // Use the mutation for bulk deletion + deleteMultipleThreadsMutation( + { + threadIds: threadIdsToDelete, + onProgress: handleDeletionProgress + }, + { + onSuccess: (data) => { + // Invalidate queries to refresh the list + queryClient.invalidateQueries({ queryKey: threadKeys.lists() }); + + // Show success message + toast.success(`Successfully deleted ${data.successful.length} conversations`); + + // If some deletions failed, show warning + if (data.failed.length > 0) { + toast.warning(`Failed to delete ${data.failed.length} conversations`); + } + + // Reset states + setSelectedThreads(new Set()); + setIsMultiSelectActive(false); + setDeleteProgress(0); + setTotalToDelete(0); + }, + onError: (error) => { + console.error('Error in bulk deletion:', error); + toast.error('Error deleting conversations'); + }, + onSettled: () => { + setThreadToDelete(null); + isPerformingActionRef.current = false; + setDeleteProgress(0); + setTotalToDelete(0); + } + } + ); + } catch (err) { + console.error('Error initiating bulk deletion:', err); + toast.error('Error initiating deletion process'); + + // Reset states + setSelectedThreads(new Set()); + setIsMultiSelectActive(false); setThreadToDelete(null); - setIsDeleting(false); isPerformingActionRef.current = false; - }, - ); + setDeleteProgress(0); + setTotalToDelete(0); + } + } }; + // Loading state or error handling + const isLoading = isProjectsLoading || isThreadsLoading; + const hasError = projectsError || threadsError; + + if (hasError) { + console.error('Error loading data:', { projectsError, threadsError }); + } + return (
Agents {state !== 'collapsed' ? ( - - - - - New Agent - - - New Agent - +
+ {isMultiSelectActive ? ( + <> + + + + + + ) : ( + <> + + + + + Select Multiple + + + + + + New Agent + + + New Agent + + + )} +
) : null}
@@ -321,13 +454,14 @@ export function NavAgents() { )) - ) : threads.length > 0 ? ( + ) : combinedThreads.length > 0 ? ( // Show all threads with project info <> - {threads.map((thread) => { + {combinedThreads.map((thread) => { // Check if this thread is currently active const isActive = pathname?.includes(thread.threadId) || false; const isThreadLoading = loadingThreadId === thread.threadId; + const isSelected = selectedThreads.has(thread.threadId); return ( @@ -337,7 +471,8 @@ export function NavAgents() { - {isThreadLoading ? ( + {isMultiSelectActive ? ( + + ) : isThreadLoading ? ( ) : ( @@ -362,7 +502,9 @@ export function NavAgents() { asChild className={ isActive - ? 'bg-accent text-accent-foreground font-medium' + ? 'bg-accent text-accent-foreground font-medium' + : isSelected + ? 'bg-primary/10' : '' } > @@ -371,7 +513,19 @@ export function NavAgents() { onClick={(e) => handleThreadClick(e, thread.threadId, thread.url) } + className="flex items-center" > + {isMultiSelectActive ? ( + { + e.preventDefault(); + e.stopPropagation(); + toggleThreadSelection(thread.threadId); + }} + /> + ) : null} {isThreadLoading ? ( ) : ( @@ -381,7 +535,7 @@ export function NavAgents() { )} - {state !== 'collapsed' && ( + {state !== 'collapsed' && !isMultiSelectActive && ( @@ -440,6 +594,22 @@ export function NavAgents() { )} + + {/* Bulk delete progress indicator */} + {(isDeletingSingle || isDeletingMultiple) && totalToDelete > 0 && ( +
+
+ Deleting {deleteProgress > 0 ? `(${Math.floor(deleteProgress)}%)` : '...'} +
+
+
+
+
+ )} + setShowShareModal(false)} @@ -453,9 +623,9 @@ export function NavAgents() { onClose={() => setIsDeleteDialogOpen(false)} onConfirm={confirmDelete} threadName={threadToDelete.name} - isDeleting={isDeleting} + isDeleting={isDeletingSingle || isDeletingMultiple} /> )} ); -} +} \ No newline at end of file diff --git a/frontend/src/components/sidebar/nav-user-with-teams.tsx b/frontend/src/components/sidebar/nav-user-with-teams.tsx index ad4eede2..29be7a41 100644 --- a/frontend/src/components/sidebar/nav-user-with-teams.tsx +++ b/frontend/src/components/sidebar/nav-user-with-teams.tsx @@ -282,7 +282,7 @@ export function NavUserWithTeams({ - + Billing @@ -296,15 +296,15 @@ export function NavUserWithTeams({ onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')} >
- - + + Theme
- - + + Log out diff --git a/frontend/src/components/thread/chat-input/chat-input.tsx b/frontend/src/components/thread/chat-input/chat-input.tsx index 26127aed..54c4229e 100644 --- a/frontend/src/components/thread/chat-input/chat-input.tsx +++ b/frontend/src/components/thread/chat-input/chat-input.tsx @@ -199,7 +199,7 @@ export const ChatInput = forwardRef( }} >
- + [...threadKeys.all, 'list'] as const, + detail: (id: string) => [...threadKeys.all, 'detail', id] as const, +}); + +export const projectKeys = createQueryKeys({ + all: ['projects'] as const, + lists: () => [...projectKeys.all, 'list'] as const, + detail: (id: string) => [...projectKeys.all, 'detail', id] as const, +}); \ No newline at end of file diff --git a/frontend/src/hooks/react-query/sidebar/use-sidebar.ts b/frontend/src/hooks/react-query/sidebar/use-sidebar.ts new file mode 100644 index 00000000..a8e34c7a --- /dev/null +++ b/frontend/src/hooks/react-query/sidebar/use-sidebar.ts @@ -0,0 +1,129 @@ +'use client'; + +import { createMutationHook, createQueryKeys } from "@/hooks/use-query"; +import { getProjects, getThreads, Project, Thread, deleteThread } from "@/lib/api"; +import { createQueryHook } from '@/hooks/use-query'; +import { threadKeys } from "./keys"; +import { projectKeys } from "./keys"; + +export const useProjects = createQueryHook( + projectKeys.lists(), + async () => { + const data = await getProjects(); + return data as Project[]; + }, + { + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + } +); + +export const useThreads = createQueryHook( + threadKeys.lists(), + async () => { + const data = await getThreads(); + return data as Thread[]; + }, + { + staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, + } +); + +interface DeleteThreadVariables { + threadId: string; + isNavigateAway?: boolean; +} + +export const useDeleteThread = createMutationHook( + async ({ threadId }: DeleteThreadVariables) => { + return await deleteThread(threadId); + }, + { + onSuccess: () => { + }, + } +); + +interface DeleteMultipleThreadsVariables { + threadIds: string[]; + onProgress?: (completed: number, total: number) => void; +} + +export const useDeleteMultipleThreads = createMutationHook( + async ({ threadIds, onProgress }: DeleteMultipleThreadsVariables) => { + let completedCount = 0; + const results = await Promise.all( + threadIds.map(async (threadId) => { + try { + const result = await deleteThread(threadId); + completedCount++; + onProgress?.(completedCount, threadIds.length); + return { success: true, threadId }; + } catch (error) { + return { success: false, threadId, error }; + } + }) + ); + + return { + successful: results.filter(r => r.success).map(r => r.threadId), + failed: results.filter(r => !r.success).map(r => r.threadId), + }; + }, + { + onSuccess: () => { + }, + } +); + +export type ThreadWithProject = { + threadId: string; + projectId: string; + projectName: string; + url: string; + updatedAt: string; +}; + +export const processThreadsWithProjects = ( + threads: Thread[], + projects: Project[] +): ThreadWithProject[] => { + const projectsById = new Map(); + projects.forEach((project) => { + projectsById.set(project.id, project); + }); + + const threadsWithProjects: ThreadWithProject[] = []; + + for (const thread of threads) { + const projectId = thread.project_id; + if (!projectId) continue; + + const project = projectsById.get(projectId); + if (!project) { + console.log( + `❌ Thread ${thread.thread_id} has project_id=${projectId} but no matching project found`, + ); + continue; + } + threadsWithProjects.push({ + threadId: thread.thread_id, + projectId: projectId, + projectName: project.name || 'Unnamed Project', + url: `/agents/${thread.thread_id}`, + updatedAt: + thread.updated_at || project.updated_at || new Date().toISOString(), + }); + } + + return sortThreads(threadsWithProjects); +}; + +export const sortThreads = ( + threadsList: ThreadWithProject[], +): ThreadWithProject[] => { + return [...threadsList].sort((a, b) => { + return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(); + }); +}; \ No newline at end of file