Merge branch 'sidebar' into feat/ux

This commit is contained in:
Soumyadas15 2025-05-15 23:56:00 +05:30
commit 60b12a75d1
5 changed files with 484 additions and 172 deletions

View File

@ -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>
); );
} }

View File

@ -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>

View File

@ -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}

View File

@ -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,
});

View File

@ -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();
});
};