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';
|
||||
|
||||
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<ThreadWithProject[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadingThreadId, setLoadingThreadId] = useState<string | null>(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();
|
||||
|
||||
// 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 [isMultiSelectActive, setIsMultiSelectActive] = useState(false);
|
||||
const [selectedThreads, setSelectedThreads] = useState<Set<string>>(new Set());
|
||||
const [deleteProgress, setDeleteProgress] = useState(0);
|
||||
const [totalToDelete, setTotalToDelete] = useState(0);
|
||||
|
||||
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<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(() => {
|
||||
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<HTMLAnchorElement>, 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);
|
||||
// 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));
|
||||
|
||||
// Update the thread list
|
||||
setThreads(prev => prev.filter(t => t.threadId !== threadId));
|
||||
// Show initial toast
|
||||
toast.info(`Deleting ${threadIdsToDelete.length} conversations...`);
|
||||
|
||||
// Show success message
|
||||
toast.success('Conversation deleted successfully');
|
||||
},
|
||||
// Completion callback to reset local state
|
||||
() => {
|
||||
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 (
|
||||
<SidebarGroup>
|
||||
<div className="flex justify-between items-center">
|
||||
<SidebarGroupLabel>Agents</SidebarGroupLabel>
|
||||
{state !== 'collapsed' ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href="/dashboard"
|
||||
className="text-muted-foreground hover:text-foreground h-8 w-8 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 className="flex items-center space-x-1">
|
||||
{isMultiSelectActive ? (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={deselectAllThreads}
|
||||
disabled={selectedThreads.size === 0}
|
||||
className="h-7 w-7"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</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}
|
||||
</div>
|
||||
|
||||
|
@ -321,13 +454,14 @@ export function NavAgents() {
|
|||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))
|
||||
) : 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 (
|
||||
<SidebarMenuItem key={`thread-${thread.threadId}`}>
|
||||
|
@ -337,7 +471,8 @@ export function NavAgents() {
|
|||
<SidebarMenuButton
|
||||
asChild
|
||||
className={
|
||||
isActive ? 'bg-accent text-accent-foreground' : ''
|
||||
isActive ? 'bg-accent text-accent-foreground' :
|
||||
isSelected ? 'bg-primary/10' : ''
|
||||
}
|
||||
>
|
||||
<Link
|
||||
|
@ -346,7 +481,12 @@ export function NavAgents() {
|
|||
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" />
|
||||
) : (
|
||||
<MessagesSquare className="h-4 w-4" />
|
||||
|
@ -363,6 +503,8 @@ export function NavAgents() {
|
|||
className={
|
||||
isActive
|
||||
? '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 ? (
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
className="h-4 w-4 mr-2"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleThreadSelection(thread.threadId);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{isThreadLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
|
@ -381,7 +535,7 @@ export function NavAgents() {
|
|||
</Link>
|
||||
</SidebarMenuButton>
|
||||
)}
|
||||
{state !== 'collapsed' && (
|
||||
{state !== 'collapsed' && !isMultiSelectActive && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction showOnHover>
|
||||
|
@ -440,6 +594,22 @@ export function NavAgents() {
|
|||
</SidebarMenuItem>
|
||||
)}
|
||||
</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
|
||||
isOpen={showShareModal}
|
||||
onClose={() => setShowShareModal(false)}
|
||||
|
@ -453,7 +623,7 @@ export function NavAgents() {
|
|||
onClose={() => setIsDeleteDialogOpen(false)}
|
||||
onConfirm={confirmDelete}
|
||||
threadName={threadToDelete.name}
|
||||
isDeleting={isDeleting}
|
||||
isDeleting={isDeletingSingle || isDeletingMultiple}
|
||||
/>
|
||||
)}
|
||||
</SidebarGroup>
|
||||
|
|
|
@ -282,7 +282,7 @@ export function NavUserWithTeams({
|
|||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href="/settings/billing">
|
||||
<CreditCard className="mr-2 h-4 w-4" />
|
||||
<CreditCard className="h-4 w-4" />
|
||||
Billing
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
@ -296,15 +296,15 @@ export function NavUserWithTeams({
|
|||
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
|
||||
>
|
||||
<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" />
|
||||
<Moon className="absolute mr-2 h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<Sun className="h-4 w-4 rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-4 w-4 rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span>Theme</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleLogout}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
<DropdownMenuItem className='text-destructive focus:text-destructive focus:bg-destructive/10' onClick={handleLogout}>
|
||||
<LogOut className="h-4 w-4 text-destructive" />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</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">
|
||||
<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
|
||||
files={uploadedFiles || []}
|
||||
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