mirror of https://github.com/kortix-ai/suna.git
Merge branch 'sidebar' into feat/ux
This commit is contained in:
commit
60b12a75d1
|
@ -1,6 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useRef } from 'react';
|
import { useEffect, useState, useRef } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
Link as LinkIcon,
|
Link as LinkIcon,
|
||||||
|
@ -9,7 +10,9 @@ import {
|
||||||
Plus,
|
Plus,
|
||||||
MessagesSquare,
|
MessagesSquare,
|
||||||
Loader2,
|
Loader2,
|
||||||
Share2
|
Share2,
|
||||||
|
X,
|
||||||
|
Check
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
import { usePathname, useRouter } from "next/navigation"
|
import { usePathname, useRouter } from "next/navigation"
|
||||||
|
@ -35,25 +38,18 @@ import {
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger
|
TooltipTrigger
|
||||||
} from "@/components/ui/tooltip"
|
} from "@/components/ui/tooltip"
|
||||||
import { getProjects, getThreads, Project, deleteThread } from "@/lib/api"
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { ShareModal } from "./share-modal"
|
import { ShareModal } from "./share-modal"
|
||||||
import { DeleteConfirmationDialog } from "@/components/thread/DeleteConfirmationDialog"
|
import { DeleteConfirmationDialog } from "@/components/thread/DeleteConfirmationDialog"
|
||||||
import { useDeleteOperation } from '@/contexts/DeleteOperationContext'
|
import { useDeleteOperation } from '@/contexts/DeleteOperationContext'
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
// Thread with associated project info for display in sidebar
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
type ThreadWithProject = {
|
import { ThreadWithProject } from '@/hooks/react-query/sidebar/use-sidebar';
|
||||||
threadId: string;
|
import { processThreadsWithProjects, useDeleteMultipleThreads, useDeleteThread, useProjects, useThreads } from '@/hooks/react-query/sidebar/use-sidebar';
|
||||||
projectId: string;
|
import { projectKeys, threadKeys } from '@/hooks/react-query/sidebar/keys';
|
||||||
projectName: string;
|
|
||||||
url: string;
|
|
||||||
updatedAt: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function NavAgents() {
|
export function NavAgents() {
|
||||||
const { isMobile, state } = useSidebar()
|
const { isMobile, state } = useSidebar()
|
||||||
const [threads, setThreads] = useState<ThreadWithProject[]>([])
|
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
|
||||||
const [loadingThreadId, setLoadingThreadId] = useState<string | null>(null)
|
const [loadingThreadId, setLoadingThreadId] = useState<string | null>(null)
|
||||||
const [showShareModal, setShowShareModal] = useState(false)
|
const [showShareModal, setShowShareModal] = useState(false)
|
||||||
const [selectedItem, setSelectedItem] = useState<{ threadId: string, projectId: string } | null>(null)
|
const [selectedItem, setSelectedItem] = useState<{ threadId: string, projectId: string } | null>(null)
|
||||||
|
@ -61,119 +57,50 @@ export function NavAgents() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||||
const [threadToDelete, setThreadToDelete] = useState<{ id: string; name: string } | null>(null)
|
const [threadToDelete, setThreadToDelete] = useState<{ id: string; name: string } | null>(null)
|
||||||
const [isDeleting, setIsDeleting] = useState(false)
|
|
||||||
const isNavigatingRef = useRef(false)
|
const isNavigatingRef = useRef(false)
|
||||||
const { performDelete, isOperationInProgress } = useDeleteOperation();
|
const { performDelete } = useDeleteOperation();
|
||||||
const isPerformingActionRef = useRef(false);
|
const isPerformingActionRef = useRef(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const [isMultiSelectActive, setIsMultiSelectActive] = useState(false);
|
||||||
|
const [selectedThreads, setSelectedThreads] = useState<Set<string>>(new Set());
|
||||||
|
const [deleteProgress, setDeleteProgress] = useState(0);
|
||||||
|
const [totalToDelete, setTotalToDelete] = useState(0);
|
||||||
|
|
||||||
// Helper to sort threads by updated_at (most recent first)
|
const {
|
||||||
const sortThreads = (
|
data: projects = [],
|
||||||
threadsList: ThreadWithProject[],
|
isLoading: isProjectsLoading,
|
||||||
): ThreadWithProject[] => {
|
error: projectsError
|
||||||
return [...threadsList].sort((a, b) => {
|
} = useProjects();
|
||||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
|
||||||
});
|
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<string, Project>();
|
|
||||||
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(() => {
|
useEffect(() => {
|
||||||
const handleProjectUpdate = (event: Event) => {
|
const handleProjectUpdate = (event: Event) => {
|
||||||
const customEvent = event as CustomEvent;
|
const customEvent = event as CustomEvent;
|
||||||
if (customEvent.detail) {
|
if (customEvent.detail) {
|
||||||
const { projectId, updatedData } = customEvent.detail;
|
const { projectId, updatedData } = customEvent.detail;
|
||||||
|
queryClient.invalidateQueries({ queryKey: projectKeys.detail(projectId) });
|
||||||
// Update just the name for the threads with the matching project ID
|
queryClient.invalidateQueries({ queryKey: projectKeys.lists() });
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -187,7 +114,7 @@ export function NavAgents() {
|
||||||
handleProjectUpdate as EventListener,
|
handleProjectUpdate as EventListener,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, []);
|
}, [queryClient]);
|
||||||
|
|
||||||
// Reset loading state when navigation completes (pathname changes)
|
// Reset loading state when navigation completes (pathname changes)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -219,17 +146,77 @@ export function NavAgents() {
|
||||||
|
|
||||||
// Function to handle thread click with loading state
|
// Function to handle thread click with loading state
|
||||||
const handleThreadClick = (e: React.MouseEvent<HTMLAnchorElement>, threadId: string, url: string) => {
|
const handleThreadClick = (e: React.MouseEvent<HTMLAnchorElement>, threadId: string, url: string) => {
|
||||||
|
// If multi-select is active, prevent navigation and toggle selection
|
||||||
|
if (isMultiSelectActive) {
|
||||||
|
e.preventDefault();
|
||||||
|
toggleThreadSelection(threadId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setLoadingThreadId(threadId)
|
setLoadingThreadId(threadId)
|
||||||
router.push(url)
|
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
|
// Function to handle thread deletion
|
||||||
const handleDeleteThread = async (threadId: string, threadName: string) => {
|
const handleDeleteThread = async (threadId: string, threadName: string) => {
|
||||||
setThreadToDelete({ id: threadId, name: threadName });
|
setThreadToDelete({ id: threadId, name: threadName });
|
||||||
setIsDeleteDialogOpen(true);
|
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 () => {
|
const confirmDelete = async () => {
|
||||||
if (!threadToDelete || isPerformingActionRef.current) return;
|
if (!threadToDelete || isPerformingActionRef.current) return;
|
||||||
|
|
||||||
|
@ -239,58 +226,204 @@ export function NavAgents() {
|
||||||
// Close dialog first for immediate feedback
|
// Close dialog first for immediate feedback
|
||||||
setIsDeleteDialogOpen(false);
|
setIsDeleteDialogOpen(false);
|
||||||
|
|
||||||
const threadId = threadToDelete.id;
|
// Check if it's a single thread or multiple threads
|
||||||
const isActive = pathname?.includes(threadId);
|
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
|
// Store threadToDelete in a local variable since it might be cleared
|
||||||
const deletedThread = { ...threadToDelete };
|
const deletedThread = { ...threadToDelete };
|
||||||
|
|
||||||
// Log operation start
|
// Log operation start
|
||||||
console.log('DELETION - Starting thread deletion process', {
|
console.log('DELETION - Starting thread deletion process', {
|
||||||
threadId: deletedThread.id,
|
threadId: deletedThread.id,
|
||||||
isCurrentThread: isActive,
|
isCurrentThread: isActive,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Use the centralized deletion system with completion callback
|
// Use the centralized deletion system with completion callback
|
||||||
await performDelete(
|
await performDelete(
|
||||||
threadId,
|
threadId,
|
||||||
isActive,
|
isActive,
|
||||||
async () => {
|
async () => {
|
||||||
// Delete the thread
|
// Delete the thread using the mutation
|
||||||
await deleteThread(threadId);
|
deleteThreadMutation(
|
||||||
|
{ threadId },
|
||||||
// Update the thread list
|
{
|
||||||
setThreads(prev => prev.filter(t => t.threadId !== threadId));
|
onSuccess: () => {
|
||||||
|
// Invalidate queries to refresh the list
|
||||||
// Show success message
|
queryClient.invalidateQueries({ queryKey: threadKeys.lists() });
|
||||||
toast.success('Conversation deleted successfully');
|
toast.success('Conversation deleted successfully');
|
||||||
},
|
},
|
||||||
// Completion callback to reset local state
|
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);
|
setThreadToDelete(null);
|
||||||
setIsDeleting(false);
|
|
||||||
isPerformingActionRef.current = 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 (
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<SidebarGroupLabel>Agents</SidebarGroupLabel>
|
<SidebarGroupLabel>Agents</SidebarGroupLabel>
|
||||||
{state !== 'collapsed' ? (
|
{state !== 'collapsed' ? (
|
||||||
<Tooltip>
|
<div className="flex items-center space-x-1">
|
||||||
<TooltipTrigger asChild>
|
{isMultiSelectActive ? (
|
||||||
<Link
|
<>
|
||||||
href="/dashboard"
|
<Button
|
||||||
className="text-muted-foreground hover:text-foreground h-8 w-8 flex items-center justify-center rounded-md"
|
variant="ghost"
|
||||||
>
|
size="icon"
|
||||||
<Plus className="h-4 w-4" />
|
onClick={deselectAllThreads}
|
||||||
<span className="sr-only">New Agent</span>
|
disabled={selectedThreads.size === 0}
|
||||||
</Link>
|
className="h-7 w-7"
|
||||||
</TooltipTrigger>
|
>
|
||||||
<TooltipContent>New Agent</TooltipContent>
|
<X className="h-4 w-4" />
|
||||||
</Tooltip>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={selectAllThreads}
|
||||||
|
disabled={selectedThreads.size === combinedThreads.length}
|
||||||
|
className="h-7 w-7"
|
||||||
|
>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleMultiDelete}
|
||||||
|
disabled={selectedThreads.size === 0}
|
||||||
|
className="h-7 w-7 text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={toggleMultiSelect}
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={toggleMultiSelect}
|
||||||
|
className="h-7 w-7"
|
||||||
|
disabled={combinedThreads.length === 0}
|
||||||
|
>
|
||||||
|
<Checkbox className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Select Multiple</span>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>Select Multiple</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Link
|
||||||
|
href="/dashboard"
|
||||||
|
className="text-muted-foreground hover:text-foreground h-7 w-7 flex items-center justify-center rounded-md"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
<span className="sr-only">New Agent</span>
|
||||||
|
</Link>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>New Agent</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -321,13 +454,14 @@ export function NavAgents() {
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
))
|
))
|
||||||
) : threads.length > 0 ? (
|
) : combinedThreads.length > 0 ? (
|
||||||
// Show all threads with project info
|
// Show all threads with project info
|
||||||
<>
|
<>
|
||||||
{threads.map((thread) => {
|
{combinedThreads.map((thread) => {
|
||||||
// Check if this thread is currently active
|
// Check if this thread is currently active
|
||||||
const isActive = pathname?.includes(thread.threadId) || false;
|
const isActive = pathname?.includes(thread.threadId) || false;
|
||||||
const isThreadLoading = loadingThreadId === thread.threadId;
|
const isThreadLoading = loadingThreadId === thread.threadId;
|
||||||
|
const isSelected = selectedThreads.has(thread.threadId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenuItem key={`thread-${thread.threadId}`}>
|
<SidebarMenuItem key={`thread-${thread.threadId}`}>
|
||||||
|
@ -337,7 +471,8 @@ export function NavAgents() {
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
asChild
|
asChild
|
||||||
className={
|
className={
|
||||||
isActive ? 'bg-accent text-accent-foreground' : ''
|
isActive ? 'bg-accent text-accent-foreground' :
|
||||||
|
isSelected ? 'bg-primary/10' : ''
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
|
@ -346,7 +481,12 @@ export function NavAgents() {
|
||||||
handleThreadClick(e, thread.threadId, thread.url)
|
handleThreadClick(e, thread.threadId, thread.url)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{isThreadLoading ? (
|
{isMultiSelectActive ? (
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
) : isThreadLoading ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<MessagesSquare className="h-4 w-4" />
|
<MessagesSquare className="h-4 w-4" />
|
||||||
|
@ -362,7 +502,9 @@ export function NavAgents() {
|
||||||
asChild
|
asChild
|
||||||
className={
|
className={
|
||||||
isActive
|
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) =>
|
onClick={(e) =>
|
||||||
handleThreadClick(e, thread.threadId, thread.url)
|
handleThreadClick(e, thread.threadId, thread.url)
|
||||||
}
|
}
|
||||||
|
className="flex items-center"
|
||||||
>
|
>
|
||||||
|
{isMultiSelectActive ? (
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
className="h-4 w-4 mr-2"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleThreadSelection(thread.threadId);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
{isThreadLoading ? (
|
{isThreadLoading ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
|
@ -381,7 +535,7 @@ export function NavAgents() {
|
||||||
</Link>
|
</Link>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
)}
|
)}
|
||||||
{state !== 'collapsed' && (
|
{state !== 'collapsed' && !isMultiSelectActive && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<SidebarMenuAction showOnHover>
|
<SidebarMenuAction showOnHover>
|
||||||
|
@ -440,6 +594,22 @@ export function NavAgents() {
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
)}
|
)}
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
|
|
||||||
|
{/* Bulk delete progress indicator */}
|
||||||
|
{(isDeletingSingle || isDeletingMultiple) && totalToDelete > 0 && (
|
||||||
|
<div className="mt-2 px-2">
|
||||||
|
<div className="text-xs text-muted-foreground mb-1">
|
||||||
|
Deleting {deleteProgress > 0 ? `(${Math.floor(deleteProgress)}%)` : '...'}
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-secondary h-1 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-primary h-1 transition-all duration-300 ease-in-out"
|
||||||
|
style={{ width: `${deleteProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ShareModal
|
<ShareModal
|
||||||
isOpen={showShareModal}
|
isOpen={showShareModal}
|
||||||
onClose={() => setShowShareModal(false)}
|
onClose={() => setShowShareModal(false)}
|
||||||
|
@ -453,9 +623,9 @@ export function NavAgents() {
|
||||||
onClose={() => setIsDeleteDialogOpen(false)}
|
onClose={() => setIsDeleteDialogOpen(false)}
|
||||||
onConfirm={confirmDelete}
|
onConfirm={confirmDelete}
|
||||||
threadName={threadToDelete.name}
|
threadName={threadToDelete.name}
|
||||||
isDeleting={isDeleting}
|
isDeleting={isDeletingSingle || isDeletingMultiple}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
|
@ -282,7 +282,7 @@ export function NavUserWithTeams({
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/settings/billing">
|
<Link href="/settings/billing">
|
||||||
<CreditCard className="mr-2 h-4 w-4" />
|
<CreditCard className="h-4 w-4" />
|
||||||
Billing
|
Billing
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
@ -296,15 +296,15 @@ export function NavUserWithTeams({
|
||||||
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
|
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sun className="mr-2 h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||||
<Moon className="absolute mr-2 h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||||
<span>Theme</span>
|
<span>Theme</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
<DropdownMenuItem className='text-destructive focus:text-destructive focus:bg-destructive/10' onClick={handleLogout}>
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
<LogOut className="h-4 w-4 text-destructive" />
|
||||||
Log out
|
Log out
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|
|
@ -199,7 +199,7 @@ export const ChatInput = forwardRef<ChatInputHandles, ChatInputProps>(
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-full text-sm flex flex-col justify-between items-start rounded-lg">
|
<div className="w-full text-sm flex flex-col justify-between items-start rounded-lg">
|
||||||
<CardContent className="w-full p-1.5 pb-2 pt-3 bg-sidebar rounded-2xl border">
|
<CardContent className="w-full p-1.5 pb-2 bg-sidebar rounded-2xl border">
|
||||||
<AttachmentGroup
|
<AttachmentGroup
|
||||||
files={uploadedFiles || []}
|
files={uploadedFiles || []}
|
||||||
sandboxId={sandboxId}
|
sandboxId={sandboxId}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { createQueryKeys } from "@/hooks/use-query";
|
||||||
|
|
||||||
|
export const threadKeys = createQueryKeys({
|
||||||
|
all: ['threads'] as const,
|
||||||
|
lists: () => [...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,
|
||||||
|
});
|
|
@ -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<string, Project>();
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
};
|
Loading…
Reference in New Issue