mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-15 18:25:18 +02:00
Merge pull request #604 from MODSetter/dev
refactor(ux): remove old chat components and implement new sidebar structure
This commit is contained in:
commit
48ea41a8d2
22 changed files with 945 additions and 780 deletions
|
|
@ -319,15 +319,19 @@ async def read_chats(
|
||||||
"You don't have permission to read chats in this search space",
|
"You don't have permission to read chats in this search space",
|
||||||
)
|
)
|
||||||
# Select specific fields excluding messages
|
# Select specific fields excluding messages
|
||||||
query = select(
|
query = (
|
||||||
Chat.id,
|
select(
|
||||||
Chat.type,
|
Chat.id,
|
||||||
Chat.title,
|
Chat.type,
|
||||||
Chat.initial_connectors,
|
Chat.title,
|
||||||
Chat.search_space_id,
|
Chat.initial_connectors,
|
||||||
Chat.created_at,
|
Chat.search_space_id,
|
||||||
Chat.state_version,
|
Chat.created_at,
|
||||||
).filter(Chat.search_space_id == search_space_id)
|
Chat.state_version,
|
||||||
|
)
|
||||||
|
.filter(Chat.search_space_id == search_space_id)
|
||||||
|
.order_by(Chat.created_at.desc())
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Get chats from all search spaces user has membership in
|
# Get chats from all search spaces user has membership in
|
||||||
query = (
|
query = (
|
||||||
|
|
@ -343,6 +347,7 @@ async def read_chats(
|
||||||
.join(SearchSpace)
|
.join(SearchSpace)
|
||||||
.join(SearchSpaceMembership)
|
.join(SearchSpaceMembership)
|
||||||
.filter(SearchSpaceMembership.user_id == user.id)
|
.filter(SearchSpaceMembership.user_id == user.id)
|
||||||
|
.order_by(Chat.created_at.desc())
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await session.execute(query.offset(skip).limit(limit))
|
result = await session.execute(query.offset(skip).limit(limit))
|
||||||
|
|
|
||||||
|
|
@ -1,458 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { format } from "date-fns";
|
|
||||||
import { useAtom, useAtomValue } from "jotai";
|
|
||||||
import {
|
|
||||||
Calendar,
|
|
||||||
ExternalLink,
|
|
||||||
MessageCircleMore,
|
|
||||||
MoreHorizontal,
|
|
||||||
Search,
|
|
||||||
Tag,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { AnimatePresence, motion, type Variants } from "motion/react";
|
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms";
|
|
||||||
import { chatsAtom } from "@/atoms/chats/chat-query.atoms";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import {
|
|
||||||
Pagination,
|
|
||||||
PaginationContent,
|
|
||||||
PaginationItem,
|
|
||||||
PaginationLink,
|
|
||||||
PaginationNext,
|
|
||||||
PaginationPrevious,
|
|
||||||
} from "@/components/ui/pagination";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectGroup,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from "@/components/ui/select";
|
|
||||||
|
|
||||||
export interface Chat {
|
|
||||||
created_at: string;
|
|
||||||
id: number;
|
|
||||||
type: "QNA";
|
|
||||||
title: string;
|
|
||||||
search_space_id: number;
|
|
||||||
state_version: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ChatDetails {
|
|
||||||
type: "QNA";
|
|
||||||
title: string;
|
|
||||||
initial_connectors: string[];
|
|
||||||
messages: any[];
|
|
||||||
created_at: string;
|
|
||||||
id: number;
|
|
||||||
search_space_id: number;
|
|
||||||
state_version: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatsPageClientProps {
|
|
||||||
searchSpaceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pageVariants: Variants = {
|
|
||||||
initial: { opacity: 0 },
|
|
||||||
enter: { opacity: 1, transition: { duration: 0.3, ease: "easeInOut" } },
|
|
||||||
exit: { opacity: 0, transition: { duration: 0.3, ease: "easeInOut" } },
|
|
||||||
};
|
|
||||||
|
|
||||||
const chatCardVariants: Variants = {
|
|
||||||
initial: { y: 20, opacity: 0 },
|
|
||||||
animate: { y: 0, opacity: 1 },
|
|
||||||
exit: { y: -20, opacity: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
const MotionCard = motion(Card);
|
|
||||||
|
|
||||||
export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [filteredChats, setFilteredChats] = useState<Chat[]>([]);
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
|
||||||
const [selectedType, setSelectedType] = useState<string>("all");
|
|
||||||
const [sortOrder, setSortOrder] = useState<string>("newest");
|
|
||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
|
||||||
const [chatToDelete, setChatToDelete] = useState<{
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
} | null>(null);
|
|
||||||
const { isFetching: isFetchingChats, data: chats, error: fetchError } = useAtomValue(chatsAtom);
|
|
||||||
const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] =
|
|
||||||
useAtom(deleteChatMutationAtom);
|
|
||||||
|
|
||||||
const chatsPerPage = 9;
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
|
|
||||||
// Get initial page from URL params if it exists
|
|
||||||
useEffect(() => {
|
|
||||||
const pageParam = searchParams.get("page");
|
|
||||||
if (pageParam) {
|
|
||||||
const pageNumber = parseInt(pageParam, 10);
|
|
||||||
if (!Number.isNaN(pageNumber) && pageNumber > 0) {
|
|
||||||
setCurrentPage(pageNumber);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [searchParams]);
|
|
||||||
|
|
||||||
// Filter and sort chats based on search query, type, and sort order
|
|
||||||
useEffect(() => {
|
|
||||||
let result = [...(chats || [])];
|
|
||||||
|
|
||||||
// Filter by search term
|
|
||||||
if (searchQuery) {
|
|
||||||
const query = searchQuery.toLowerCase();
|
|
||||||
result = result.filter((chat) => chat.title.toLowerCase().includes(query));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter by type
|
|
||||||
if (selectedType !== "all") {
|
|
||||||
result = result.filter((chat) => chat.type === selectedType);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort chats
|
|
||||||
result.sort((a, b) => {
|
|
||||||
const dateA = new Date(a.created_at).getTime();
|
|
||||||
const dateB = new Date(b.created_at).getTime();
|
|
||||||
|
|
||||||
return sortOrder === "newest" ? dateB - dateA : dateA - dateB;
|
|
||||||
});
|
|
||||||
|
|
||||||
setFilteredChats(result);
|
|
||||||
setTotalPages(Math.max(1, Math.ceil(result.length / chatsPerPage)));
|
|
||||||
|
|
||||||
// Reset to first page when filters change
|
|
||||||
if (currentPage !== 1 && (searchQuery || selectedType !== "all" || sortOrder !== "newest")) {
|
|
||||||
setCurrentPage(1);
|
|
||||||
}
|
|
||||||
}, [chats, searchQuery, selectedType, sortOrder, currentPage]);
|
|
||||||
|
|
||||||
// Function to handle chat deletion
|
|
||||||
const handleDeleteChat = async () => {
|
|
||||||
if (!chatToDelete) return;
|
|
||||||
|
|
||||||
await deleteChat({ id: chatToDelete.id });
|
|
||||||
|
|
||||||
setDeleteDialogOpen(false);
|
|
||||||
setChatToDelete(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate pagination
|
|
||||||
const indexOfLastChat = currentPage * chatsPerPage; // Index of last chat in the current page
|
|
||||||
const indexOfFirstChat = indexOfLastChat - chatsPerPage; // Index of first chat in the current page
|
|
||||||
const currentChats = filteredChats.slice(indexOfFirstChat, indexOfLastChat);
|
|
||||||
|
|
||||||
// Get unique chat types for filter dropdown
|
|
||||||
const chatTypes = chats ? ["all", ...Array.from(new Set(chats.map((chat) => chat.type)))] : [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<motion.div
|
|
||||||
className="container p-6 mx-auto"
|
|
||||||
initial="initial"
|
|
||||||
animate="enter"
|
|
||||||
exit="exit"
|
|
||||||
variants={pageVariants}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col space-y-4 md:space-y-6">
|
|
||||||
<div className="flex flex-col space-y-2">
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">All Chats</h1>
|
|
||||||
<p className="text-muted-foreground">View, search, and manage all your chats.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filter and Search Bar */}
|
|
||||||
<div className="flex flex-col space-y-4 md:flex-row md:items-center md:justify-between md:space-y-0">
|
|
||||||
<div className="flex flex-1 items-center gap-2">
|
|
||||||
<div className="relative w-full md:w-80">
|
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
placeholder="Search chats..."
|
|
||||||
className="pl-8"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Select value={selectedType} onValueChange={setSelectedType}>
|
|
||||||
<SelectTrigger className="w-full md:w-40">
|
|
||||||
<SelectValue placeholder="Filter by type" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{chatTypes.map((type) => (
|
|
||||||
<SelectItem key={type} value={type}>
|
|
||||||
{type === "all" ? "All Types" : type.charAt(0).toUpperCase() + type.slice(1)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Select value={sortOrder} onValueChange={setSortOrder}>
|
|
||||||
<SelectTrigger className="w-40">
|
|
||||||
<SelectValue placeholder="Sort order" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectItem value="newest">Newest First</SelectItem>
|
|
||||||
<SelectItem value="oldest">Oldest First</SelectItem>
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status Messages */}
|
|
||||||
{isFetchingChats && (
|
|
||||||
<div className="flex items-center justify-center h-40">
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
|
||||||
<p className="text-sm text-muted-foreground">Loading chats...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{fetchError && !isFetchingChats && (
|
|
||||||
<div className="border border-destructive/50 text-destructive p-4 rounded-md">
|
|
||||||
<h3 className="font-medium">Error loading chats</h3>
|
|
||||||
<p className="text-sm">{fetchError.message}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!isFetchingChats && !fetchError && filteredChats.length === 0 && (
|
|
||||||
<div className="flex flex-col items-center justify-center h-40 gap-2 text-center">
|
|
||||||
<MessageCircleMore className="h-8 w-8 text-muted-foreground" />
|
|
||||||
<h3 className="font-medium">No chats found</h3>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{searchQuery || selectedType !== "all"
|
|
||||||
? "Try adjusting your search filters"
|
|
||||||
: "Start a new chat to get started"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Chat Grid */}
|
|
||||||
{!isFetchingChats && !fetchError && filteredChats.length > 0 && (
|
|
||||||
<AnimatePresence mode="wait">
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
||||||
{currentChats.map((chat, index) => (
|
|
||||||
<MotionCard
|
|
||||||
key={chat.id}
|
|
||||||
variants={chatCardVariants}
|
|
||||||
initial="initial"
|
|
||||||
animate="animate"
|
|
||||||
exit="exit"
|
|
||||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
|
||||||
className="overflow-hidden hover:shadow-md transition-shadow"
|
|
||||||
>
|
|
||||||
<CardHeader className="pb-3">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<CardTitle className="line-clamp-1">
|
|
||||||
{chat.title || `Chat ${chat.id}`}
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<Calendar className="h-3.5 w-3.5" />
|
|
||||||
<span>{format(new Date(chat.created_at), "MMM d, yyyy")}</span>
|
|
||||||
</span>
|
|
||||||
</CardDescription>
|
|
||||||
</div>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
<span className="sr-only">Open menu</span>
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() =>
|
|
||||||
router.push(
|
|
||||||
`/dashboard/${chat.search_space_id}/researcher/${chat.id}`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ExternalLink className="mr-2 h-4 w-4" />
|
|
||||||
<span>View Chat</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setChatToDelete({
|
|
||||||
id: chat.id,
|
|
||||||
title: chat.title || `Chat ${chat.id}`,
|
|
||||||
});
|
|
||||||
setDeleteDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
|
||||||
<span>Delete Chat</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardFooter className="flex items-center justify-between gap-2 w-full">
|
|
||||||
<Badge variant="secondary" className="text-xs">
|
|
||||||
<Tag className="mr-1 h-3 w-3" />
|
|
||||||
{chat.type || "Unknown"}
|
|
||||||
</Badge>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
router.push(`/dashboard/${chat.search_space_id}/researcher/${chat.id}`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MessageCircleMore className="h-4 w-4" />
|
|
||||||
<span>View Chat</span>
|
|
||||||
</Button>
|
|
||||||
</CardFooter>
|
|
||||||
</MotionCard>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</AnimatePresence>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{!isFetchingChats && !fetchError && totalPages > 1 && (
|
|
||||||
<Pagination className="mt-8">
|
|
||||||
<PaginationContent>
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationPrevious
|
|
||||||
href={`?page=${Math.max(1, currentPage - 1)}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (currentPage > 1) setCurrentPage(currentPage - 1);
|
|
||||||
}}
|
|
||||||
className={currentPage <= 1 ? "pointer-events-none opacity-50" : ""}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
|
||||||
|
|
||||||
{Array.from({ length: totalPages }).map((_, index) => {
|
|
||||||
const pageNumber = index + 1;
|
|
||||||
const isVisible =
|
|
||||||
pageNumber === 1 ||
|
|
||||||
pageNumber === totalPages ||
|
|
||||||
(pageNumber >= currentPage - 1 && pageNumber <= currentPage + 1);
|
|
||||||
|
|
||||||
if (!isVisible) {
|
|
||||||
// Show ellipsis at appropriate positions
|
|
||||||
if (pageNumber === 2 || pageNumber === totalPages - 1) {
|
|
||||||
return (
|
|
||||||
<PaginationItem key={pageNumber}>
|
|
||||||
<span className="flex h-9 w-9 items-center justify-center">...</span>
|
|
||||||
</PaginationItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PaginationItem key={pageNumber}>
|
|
||||||
<PaginationLink
|
|
||||||
href={`?page=${pageNumber}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setCurrentPage(pageNumber);
|
|
||||||
}}
|
|
||||||
isActive={pageNumber === currentPage}
|
|
||||||
>
|
|
||||||
{pageNumber}
|
|
||||||
</PaginationLink>
|
|
||||||
</PaginationItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<PaginationItem>
|
|
||||||
<PaginationNext
|
|
||||||
href={`?page=${Math.min(totalPages, currentPage + 1)}`}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (currentPage < totalPages) setCurrentPage(currentPage + 1);
|
|
||||||
}}
|
|
||||||
className={currentPage >= totalPages ? "pointer-events-none opacity-50" : ""}
|
|
||||||
/>
|
|
||||||
</PaginationItem>
|
|
||||||
</PaginationContent>
|
|
||||||
</Pagination>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
|
||||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<Trash2 className="h-5 w-5 text-destructive" />
|
|
||||||
<span>Delete Chat</span>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Are you sure you want to delete{" "}
|
|
||||||
<span className="font-medium">{chatToDelete?.title}</span>? This action cannot be
|
|
||||||
undone.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<DialogFooter className="flex gap-2 sm:justify-end">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setDeleteDialogOpen(false)}
|
|
||||||
disabled={isDeletingChat}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
onClick={handleDeleteChat}
|
|
||||||
disabled={isDeletingChat}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
{isDeletingChat ? (
|
|
||||||
<>
|
|
||||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
||||||
Deleting...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
Delete
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
import { Suspense } from "react";
|
|
||||||
import ChatsPageClient from "./chats-client";
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: {
|
|
||||||
search_space_id: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function ChatsPage({ params }: PageProps) {
|
|
||||||
// Get search space ID from the route parameter
|
|
||||||
const { search_space_id: searchSpaceId } = await Promise.resolve(params);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<div className="flex items-center justify-center h-[60vh]">
|
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ChatsPageClient searchSpaceId={searchSpaceId} />
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
import { AlertCircle, ArrowLeft, FileText, Loader2, Save } from "lucide-react";
|
import { AlertCircle, ArrowLeft, FileText, Loader2, Save } from "lucide-react";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
|
||||||
import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor";
|
import { BlockNoteEditor } from "@/components/DynamicBlockNoteEditor";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|
@ -76,6 +78,46 @@ export default function EditorPage() {
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||||
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
|
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
|
||||||
|
|
||||||
|
// Global state for cross-component communication
|
||||||
|
const [, setGlobalHasUnsavedChanges] = useAtom(hasUnsavedEditorChangesAtom);
|
||||||
|
const [pendingNavigation, setPendingNavigation] = useAtom(pendingEditorNavigationAtom);
|
||||||
|
|
||||||
|
// Sync local unsaved changes state with global atom
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalHasUnsavedChanges(hasUnsavedChanges);
|
||||||
|
}, [hasUnsavedChanges, setGlobalHasUnsavedChanges]);
|
||||||
|
|
||||||
|
// Cleanup global state when component unmounts
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
setGlobalHasUnsavedChanges(false);
|
||||||
|
setPendingNavigation(null);
|
||||||
|
};
|
||||||
|
}, [setGlobalHasUnsavedChanges, setPendingNavigation]);
|
||||||
|
|
||||||
|
// Handle pending navigation from sidebar (e.g., when user clicks "+" to create new note)
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingNavigation) {
|
||||||
|
if (hasUnsavedChanges) {
|
||||||
|
// Show dialog to confirm navigation
|
||||||
|
setShowUnsavedDialog(true);
|
||||||
|
} else {
|
||||||
|
// No unsaved changes, navigate immediately
|
||||||
|
router.push(pendingNavigation);
|
||||||
|
setPendingNavigation(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [pendingNavigation, hasUnsavedChanges, router, setPendingNavigation]);
|
||||||
|
|
||||||
|
// Reset state when documentId changes (e.g., navigating from existing note to new note)
|
||||||
|
useEffect(() => {
|
||||||
|
setDocument(null);
|
||||||
|
setEditorContent(null);
|
||||||
|
setError(null);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
setLoading(true);
|
||||||
|
}, [documentId]);
|
||||||
|
|
||||||
// Fetch document content - DIRECT CALL TO FASTAPI
|
// Fetch document content - DIRECT CALL TO FASTAPI
|
||||||
// Skip fetching if this is a new note
|
// Skip fetching if this is a new note
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -287,7 +329,23 @@ export default function EditorPage() {
|
||||||
|
|
||||||
const handleConfirmLeave = () => {
|
const handleConfirmLeave = () => {
|
||||||
setShowUnsavedDialog(false);
|
setShowUnsavedDialog(false);
|
||||||
router.push(`/dashboard/${searchSpaceId}/researcher`);
|
// Clear global unsaved state
|
||||||
|
setGlobalHasUnsavedChanges(false);
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
|
|
||||||
|
// If there's a pending navigation (from sidebar), use that; otherwise go back to researcher
|
||||||
|
if (pendingNavigation) {
|
||||||
|
router.push(pendingNavigation);
|
||||||
|
setPendingNavigation(null);
|
||||||
|
} else {
|
||||||
|
router.push(`/dashboard/${searchSpaceId}/researcher`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelLeave = () => {
|
||||||
|
setShowUnsavedDialog(false);
|
||||||
|
// Clear pending navigation if user cancels
|
||||||
|
setPendingNavigation(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -402,6 +460,7 @@ export default function EditorPage() {
|
||||||
)}
|
)}
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
<BlockNoteEditor
|
<BlockNoteEditor
|
||||||
|
key={documentId} // Force re-mount when document changes
|
||||||
initialContent={isNewNote ? undefined : editorContent}
|
initialContent={isNewNote ? undefined : editorContent}
|
||||||
onChange={setEditorContent}
|
onChange={setEditorContent}
|
||||||
useTitleBlock={isNote}
|
useTitleBlock={isNote}
|
||||||
|
|
@ -411,7 +470,12 @@ export default function EditorPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Unsaved Changes Dialog */}
|
{/* Unsaved Changes Dialog */}
|
||||||
<AlertDialog open={showUnsavedDialog} onOpenChange={setShowUnsavedDialog}>
|
<AlertDialog
|
||||||
|
open={showUnsavedDialog}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) handleCancelLeave();
|
||||||
|
}}
|
||||||
|
>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
|
<AlertDialogTitle>Unsaved Changes</AlertDialogTitle>
|
||||||
|
|
@ -420,7 +484,7 @@ export default function EditorPage() {
|
||||||
</AlertDialogDescription>
|
</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
<AlertDialogCancel onClick={handleCancelLeave}>Cancel</AlertDialogCancel>
|
||||||
<AlertDialogAction onClick={handleConfirmLeave}>OK</AlertDialogAction>
|
<AlertDialogAction onClick={handleConfirmLeave}>OK</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
|
|
|
||||||
|
|
@ -134,9 +134,13 @@ export default function ResearcherPage() {
|
||||||
message: Message | CreateMessage,
|
message: Message | CreateMessage,
|
||||||
chatRequestOptions?: { data?: any }
|
chatRequestOptions?: { data?: any }
|
||||||
) => {
|
) => {
|
||||||
|
// Use the first message content as the chat title (truncated to 100 chars)
|
||||||
|
const messageContent = typeof message.content === "string" ? message.content : "";
|
||||||
|
const chatTitle = messageContent.slice(0, 100) || "Untitled Chat";
|
||||||
|
|
||||||
const newChat = await createChat({
|
const newChat = await createChat({
|
||||||
type: researchMode,
|
type: researchMode,
|
||||||
title: "Untitled Chat",
|
title: chatTitle,
|
||||||
initial_connectors: selectedConnectors,
|
initial_connectors: selectedConnectors,
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { atomWithMutation } from "jotai-tanstack-query";
|
import { atomWithMutation } from "jotai-tanstack-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import type { Chat } from "@/app/dashboard/[search_space_id]/chats/chats-client";
|
|
||||||
import type {
|
import type {
|
||||||
|
ChatSummary,
|
||||||
CreateChatRequest,
|
CreateChatRequest,
|
||||||
DeleteChatRequest,
|
DeleteChatRequest,
|
||||||
UpdateChatRequest,
|
UpdateChatRequest,
|
||||||
|
|
@ -27,12 +27,21 @@ export const deleteChatMutationAtom = atomWithMutation((get) => {
|
||||||
|
|
||||||
onSuccess: (_, request: DeleteChatRequest) => {
|
onSuccess: (_, request: DeleteChatRequest) => {
|
||||||
toast.success("Chat deleted successfully");
|
toast.success("Chat deleted successfully");
|
||||||
|
// Optimistically update the current query
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
cacheKeys.chats.globalQueryParams(chatsQueryParams),
|
cacheKeys.chats.globalQueryParams(chatsQueryParams),
|
||||||
(oldData: Chat[]) => {
|
(oldData: ChatSummary[]) => {
|
||||||
return oldData.filter((chat) => chat.id !== request.id);
|
return oldData?.filter((chat) => chat.id !== request.id) ?? [];
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
// Invalidate all chat queries to ensure consistency across components
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["chats"],
|
||||||
|
});
|
||||||
|
// Also invalidate the "all-chats" query used by AllChatsSidebar
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["all-chats"],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
@ -50,8 +59,14 @@ export const createChatMutationAtom = atomWithMutation((get) => {
|
||||||
},
|
},
|
||||||
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
// Invalidate ALL chat queries to ensure sidebar and other components refresh
|
||||||
|
// Using a partial key match to avoid stale closure issues with specific query params
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: cacheKeys.chats.globalQueryParams(chatsQueryParams),
|
queryKey: ["chats"],
|
||||||
|
});
|
||||||
|
// Also invalidate the "all-chats" query used by AllChatsSidebar
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["all-chats"],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
27
surfsense_web/atoms/editor/ui.atoms.ts
Normal file
27
surfsense_web/atoms/editor/ui.atoms.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
interface EditorUIState {
|
||||||
|
hasUnsavedChanges: boolean;
|
||||||
|
pendingNavigation: string | null; // URL to navigate to after user confirms
|
||||||
|
}
|
||||||
|
|
||||||
|
export const editorUIAtom = atom<EditorUIState>({
|
||||||
|
hasUnsavedChanges: false,
|
||||||
|
pendingNavigation: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Derived atom for just the unsaved changes state
|
||||||
|
export const hasUnsavedEditorChangesAtom = atom(
|
||||||
|
(get) => get(editorUIAtom).hasUnsavedChanges,
|
||||||
|
(get, set, value: boolean) => {
|
||||||
|
set(editorUIAtom, { ...get(editorUIAtom), hasUnsavedChanges: value });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Derived atom for pending navigation
|
||||||
|
export const pendingEditorNavigationAtom = atom(
|
||||||
|
(get) => get(editorUIAtom).pendingNavigation,
|
||||||
|
(get, set, value: string | null) => {
|
||||||
|
set(editorUIAtom, { ...get(editorUIAtom), pendingNavigation: value });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
@ -9,6 +9,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms";
|
import { deleteChatMutationAtom } from "@/atoms/chats/chat-mutation.atoms";
|
||||||
import { chatsAtom } from "@/atoms/chats/chat-query.atoms";
|
import { chatsAtom } from "@/atoms/chats/chat-query.atoms";
|
||||||
import { globalChatsQueryParamsAtom } from "@/atoms/chats/ui.atoms";
|
import { globalChatsQueryParamsAtom } from "@/atoms/chats/ui.atoms";
|
||||||
|
import { hasUnsavedEditorChangesAtom, pendingEditorNavigationAtom } from "@/atoms/editor/ui.atoms";
|
||||||
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
|
||||||
import { AppSidebar } from "@/components/sidebar/app-sidebar";
|
import { AppSidebar } from "@/components/sidebar/app-sidebar";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
@ -56,9 +57,13 @@ export function AppSidebarProvider({
|
||||||
const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] =
|
const [{ isPending: isDeletingChat, mutateAsync: deleteChat, error: deleteError }] =
|
||||||
useAtom(deleteChatMutationAtom);
|
useAtom(deleteChatMutationAtom);
|
||||||
|
|
||||||
|
// Editor state for handling unsaved changes
|
||||||
|
const hasUnsavedEditorChanges = useAtomValue(hasUnsavedEditorChangesAtom);
|
||||||
|
const setPendingNavigation = useSetAtom(pendingEditorNavigationAtom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setChatsQueryParams((prev) => ({ ...prev, search_space_id: searchSpaceId, skip: 0, limit: 5 }));
|
setChatsQueryParams((prev) => ({ ...prev, search_space_id: searchSpaceId, skip: 0, limit: 4 }));
|
||||||
}, [searchSpaceId]);
|
}, [searchSpaceId, setChatsQueryParams]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: searchSpace,
|
data: searchSpace,
|
||||||
|
|
@ -84,13 +89,20 @@ export function AppSidebarProvider({
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
notesApiService.getNotes({
|
notesApiService.getNotes({
|
||||||
search_space_id: Number(searchSpaceId),
|
search_space_id: Number(searchSpaceId),
|
||||||
page_size: 5, // Get 5 notes (changed from 10)
|
page_size: 4, // Get 4 notes for compact sidebar
|
||||||
}),
|
}),
|
||||||
enabled: !!searchSpaceId,
|
enabled: !!searchSpaceId,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
|
const [chatToDelete, setChatToDelete] = useState<{ id: number; name: string } | null>(null);
|
||||||
|
const [showDeleteNoteDialog, setShowDeleteNoteDialog] = useState(false);
|
||||||
|
const [noteToDelete, setNoteToDelete] = useState<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
search_space_id: number;
|
||||||
|
} | null>(null);
|
||||||
|
const [isDeletingNote, setIsDeletingNote] = useState(false);
|
||||||
const [isClient, setIsClient] = useState(false);
|
const [isClient, setIsClient] = useState(false);
|
||||||
|
|
||||||
// Set isClient to true when component mounts on the client
|
// Set isClient to true when component mounts on the client
|
||||||
|
|
@ -105,25 +117,32 @@ export function AppSidebarProvider({
|
||||||
|
|
||||||
// Transform API response to the format expected by AppSidebar
|
// Transform API response to the format expected by AppSidebar
|
||||||
const recentChats = useMemo(() => {
|
const recentChats = useMemo(() => {
|
||||||
return chats
|
if (!chats) return [];
|
||||||
? chats.map((chat) => ({
|
|
||||||
name: chat.title || `Chat ${chat.id}`,
|
// Sort chats by created_at (most recent first)
|
||||||
url: `/dashboard/${chat.search_space_id}/researcher/${chat.id}`,
|
const sortedChats = [...chats].sort((a, b) => {
|
||||||
icon: "MessageCircleMore",
|
const dateA = new Date(a.created_at).getTime();
|
||||||
id: chat.id,
|
const dateB = new Date(b.created_at).getTime();
|
||||||
search_space_id: chat.search_space_id,
|
return dateB - dateA; // Descending order (most recent first)
|
||||||
actions: [
|
});
|
||||||
{
|
|
||||||
name: "Delete",
|
return sortedChats.map((chat) => ({
|
||||||
icon: "Trash2",
|
name: chat.title || `Chat ${chat.id}`,
|
||||||
onClick: () => {
|
url: `/dashboard/${chat.search_space_id}/researcher/${chat.id}`,
|
||||||
setChatToDelete({ id: chat.id, name: chat.title || `Chat ${chat.id}` });
|
icon: "MessageCircleMore",
|
||||||
setShowDeleteDialog(true);
|
id: chat.id,
|
||||||
},
|
search_space_id: chat.search_space_id,
|
||||||
},
|
actions: [
|
||||||
],
|
{
|
||||||
}))
|
name: "Delete",
|
||||||
: [];
|
icon: "Trash2",
|
||||||
|
onClick: () => {
|
||||||
|
setChatToDelete({ id: chat.id, name: chat.title || `Chat ${chat.id}` });
|
||||||
|
setShowDeleteDialog(true);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
}, [chats]);
|
}, [chats]);
|
||||||
|
|
||||||
// Handle delete chat with better error handling
|
// Handle delete chat with better error handling
|
||||||
|
|
@ -141,6 +160,26 @@ export function AppSidebarProvider({
|
||||||
}
|
}
|
||||||
}, [chatToDelete, deleteChat]);
|
}, [chatToDelete, deleteChat]);
|
||||||
|
|
||||||
|
// Handle delete note with confirmation
|
||||||
|
const handleDeleteNote = useCallback(async () => {
|
||||||
|
if (!noteToDelete) return;
|
||||||
|
|
||||||
|
setIsDeletingNote(true);
|
||||||
|
try {
|
||||||
|
await notesApiService.deleteNote({
|
||||||
|
search_space_id: noteToDelete.search_space_id,
|
||||||
|
note_id: noteToDelete.id,
|
||||||
|
});
|
||||||
|
refetchNotes();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting note:", error);
|
||||||
|
} finally {
|
||||||
|
setIsDeletingNote(false);
|
||||||
|
setShowDeleteNoteDialog(false);
|
||||||
|
setNoteToDelete(null);
|
||||||
|
}
|
||||||
|
}, [noteToDelete, refetchNotes]);
|
||||||
|
|
||||||
// Memoized fallback chats
|
// Memoized fallback chats
|
||||||
const fallbackChats = useMemo(() => {
|
const fallbackChats = useMemo(() => {
|
||||||
if (chatError) {
|
if (chatError) {
|
||||||
|
|
@ -162,19 +201,6 @@ export function AppSidebarProvider({
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLoadingChats && recentChats.length === 0) {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: t("no_recent_chats"),
|
|
||||||
url: "#",
|
|
||||||
icon: "MessageCircleMore",
|
|
||||||
id: 0,
|
|
||||||
search_space_id: Number(searchSpaceId),
|
|
||||||
actions: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
}, [chatError, isLoadingChats, recentChats.length, searchSpaceId, retryFetch, t, tCommon]);
|
}, [chatError, isLoadingChats, recentChats.length, searchSpaceId, retryFetch, t, tCommon]);
|
||||||
|
|
||||||
|
|
@ -196,8 +222,8 @@ export function AppSidebarProvider({
|
||||||
return dateB - dateA; // Descending order (most recent first)
|
return dateB - dateA; // Descending order (most recent first)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Limit to 5 notes
|
// Limit to 4 notes for compact sidebar
|
||||||
return sortedNotes.slice(0, 5).map((note) => ({
|
return sortedNotes.slice(0, 4).map((note) => ({
|
||||||
name: note.title,
|
name: note.title,
|
||||||
url: `/dashboard/${note.search_space_id}/editor/${note.id}`,
|
url: `/dashboard/${note.search_space_id}/editor/${note.id}`,
|
||||||
icon: "FileText",
|
icon: "FileText",
|
||||||
|
|
@ -207,26 +233,31 @@ export function AppSidebarProvider({
|
||||||
{
|
{
|
||||||
name: "Delete",
|
name: "Delete",
|
||||||
icon: "Trash2",
|
icon: "Trash2",
|
||||||
onClick: async () => {
|
onClick: () => {
|
||||||
try {
|
setNoteToDelete({
|
||||||
await notesApiService.deleteNote({
|
id: note.id,
|
||||||
search_space_id: note.search_space_id,
|
name: note.title,
|
||||||
note_id: note.id,
|
search_space_id: note.search_space_id,
|
||||||
});
|
});
|
||||||
refetchNotes();
|
setShowDeleteNoteDialog(true);
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting note:", error);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
}, [notesData, refetchNotes]);
|
}, [notesData]);
|
||||||
|
|
||||||
// Handle add note
|
// Handle add note - check for unsaved changes first
|
||||||
const handleAddNote = useCallback(() => {
|
const handleAddNote = useCallback(() => {
|
||||||
router.push(`/dashboard/${searchSpaceId}/editor/new`);
|
const newNoteUrl = `/dashboard/${searchSpaceId}/editor/new`;
|
||||||
}, [router, searchSpaceId]);
|
|
||||||
|
if (hasUnsavedEditorChanges) {
|
||||||
|
// Set pending navigation - the editor will show the unsaved changes dialog
|
||||||
|
setPendingNavigation(newNoteUrl);
|
||||||
|
} else {
|
||||||
|
// No unsaved changes, navigate directly
|
||||||
|
router.push(newNoteUrl);
|
||||||
|
}
|
||||||
|
}, [router, searchSpaceId, hasUnsavedEditorChanges, setPendingNavigation]);
|
||||||
|
|
||||||
// Memoized updated navSecondary
|
// Memoized updated navSecondary
|
||||||
const updatedNavSecondary = useMemo(() => {
|
const updatedNavSecondary = useMemo(() => {
|
||||||
|
|
@ -271,6 +302,7 @@ export function AppSidebarProvider({
|
||||||
navMain={navMain}
|
navMain={navMain}
|
||||||
RecentChats={[]}
|
RecentChats={[]}
|
||||||
RecentNotes={[]}
|
RecentNotes={[]}
|
||||||
|
onAddNote={handleAddNote}
|
||||||
pageUsage={pageUsage}
|
pageUsage={pageUsage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -330,6 +362,49 @@ export function AppSidebarProvider({
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete Note Confirmation Dialog */}
|
||||||
|
<Dialog open={showDeleteNoteDialog} onOpenChange={setShowDeleteNoteDialog}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Trash2 className="h-5 w-5 text-destructive" />
|
||||||
|
<span>{t("delete_note")}</span>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("delete_note_confirm")} <span className="font-medium">{noteToDelete?.name}</span>?{" "}
|
||||||
|
{t("action_cannot_undone")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="flex gap-2 sm:justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setShowDeleteNoteDialog(false)}
|
||||||
|
disabled={isDeletingNote}
|
||||||
|
>
|
||||||
|
{tCommon("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDeleteNote}
|
||||||
|
disabled={isDeletingNote}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{isDeletingNote ? (
|
||||||
|
<>
|
||||||
|
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||||
|
{t("deleting")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
{tCommon("delete")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
253
surfsense_web/components/sidebar/all-chats-sidebar.tsx
Normal file
253
surfsense_web/components/sidebar/all-chats-sidebar.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { Loader2, MessageCircleMore, MoreHorizontal, Search, Trash2, X } from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
Sheet,
|
||||||
|
SheetContent,
|
||||||
|
SheetDescription,
|
||||||
|
SheetHeader,
|
||||||
|
SheetTitle,
|
||||||
|
} from "@/components/ui/sheet";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
|
import { chatsApiService } from "@/lib/apis/chats-api.service";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface AllChatsSidebarProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
searchSpaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AllChatsSidebar({ open, onOpenChange, searchSpaceId }: AllChatsSidebarProps) {
|
||||||
|
const t = useTranslations("sidebar");
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [deletingChatId, setDeletingChatId] = useState<number | null>(null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300);
|
||||||
|
|
||||||
|
// Fetch all chats
|
||||||
|
const {
|
||||||
|
data: chatsData,
|
||||||
|
error: chatsError,
|
||||||
|
isLoading: isLoadingChats,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["all-chats", searchSpaceId],
|
||||||
|
queryFn: () =>
|
||||||
|
chatsApiService.getChats({
|
||||||
|
queryParams: {
|
||||||
|
search_space_id: Number(searchSpaceId),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
enabled: !!searchSpaceId && open,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle chat navigation
|
||||||
|
const handleChatClick = useCallback(
|
||||||
|
(chatId: number, chatSearchSpaceId: number) => {
|
||||||
|
router.push(`/dashboard/${chatSearchSpaceId}/researcher/${chatId}`);
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
[router, onOpenChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle chat deletion
|
||||||
|
const handleDeleteChat = useCallback(
|
||||||
|
async (chatId: number) => {
|
||||||
|
setDeletingChatId(chatId);
|
||||||
|
try {
|
||||||
|
await chatsApiService.deleteChat({ id: chatId });
|
||||||
|
toast.success(t("chat_deleted") || "Chat deleted successfully");
|
||||||
|
// Invalidate queries to refresh the list
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["all-chats", searchSpaceId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["chats"] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error deleting chat:", error);
|
||||||
|
toast.error(t("error_deleting_chat") || "Failed to delete chat");
|
||||||
|
} finally {
|
||||||
|
setDeletingChatId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[queryClient, searchSpaceId, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear search
|
||||||
|
const handleClearSearch = useCallback(() => {
|
||||||
|
setSearchQuery("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filter and sort chats based on search query (client-side filtering)
|
||||||
|
const chats = useMemo(() => {
|
||||||
|
const allChats = chatsData ?? [];
|
||||||
|
|
||||||
|
// Sort chats by created_at (most recent first)
|
||||||
|
const sortedChats = [...allChats].sort((a, b) => {
|
||||||
|
const dateA = new Date(a.created_at).getTime();
|
||||||
|
const dateB = new Date(b.created_at).getTime();
|
||||||
|
return dateB - dateA; // Descending order (most recent first)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!debouncedSearchQuery) {
|
||||||
|
return sortedChats;
|
||||||
|
}
|
||||||
|
const query = debouncedSearchQuery.toLowerCase();
|
||||||
|
return sortedChats.filter((chat) => chat.title.toLowerCase().includes(query));
|
||||||
|
}, [chatsData, debouncedSearchQuery]);
|
||||||
|
|
||||||
|
const isSearchMode = !!debouncedSearchQuery;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
|
<SheetContent side="left" className="w-80 p-0 flex flex-col">
|
||||||
|
<SheetHeader className="mx-3 px-4 py-4 border-b space-y-3">
|
||||||
|
<SheetTitle>{t("all_chats") || "All Chats"}</SheetTitle>
|
||||||
|
<SheetDescription className="sr-only">
|
||||||
|
{t("all_chats_description") || "Browse and manage all your chats"}
|
||||||
|
</SheetDescription>
|
||||||
|
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("search_chats") || "Search chats..."}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-9 pr-8 h-9"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-6 w-6"
|
||||||
|
onClick={handleClearSearch}
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SheetHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-2">
|
||||||
|
{isLoadingChats ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : chatsError ? (
|
||||||
|
<div className="text-center py-8 text-sm text-destructive">
|
||||||
|
{t("error_loading_chats") || "Error loading chats"}
|
||||||
|
</div>
|
||||||
|
) : chats.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{chats.map((chat) => {
|
||||||
|
const isDeleting = deletingChatId === chat.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={chat.id}
|
||||||
|
className={cn(
|
||||||
|
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
|
||||||
|
"hover:bg-accent hover:text-accent-foreground",
|
||||||
|
"transition-colors cursor-pointer",
|
||||||
|
isDeleting && "opacity-50 pointer-events-none"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Main clickable area for navigation */}
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleChatClick(chat.id, chat.search_space_id)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||||||
|
>
|
||||||
|
<MessageCircleMore className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{chat.title}</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<p>
|
||||||
|
{t("created") || "Created"}:{" "}
|
||||||
|
{format(new Date(chat.created_at), "MMM d, yyyy 'at' h:mm a")}
|
||||||
|
</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Actions dropdown - separate from main click area */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-6 shrink-0",
|
||||||
|
"opacity-0 group-hover:opacity-100 focus:opacity-100",
|
||||||
|
"transition-opacity"
|
||||||
|
)}
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
{isDeleting ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleDeleteChat(chat.id)}
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t("delete") || "Delete"}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : isSearchMode ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Search className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("no_chats_found") || "No chats found"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
|
{t("try_different_search") || "Try a different search term"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<MessageCircleMore className="h-12 w-12 mx-auto text-muted-foreground/50 mb-3" />
|
||||||
|
<p className="text-sm text-muted-foreground">{t("no_chats") || "No chats yet"}</p>
|
||||||
|
<p className="text-xs text-muted-foreground/70 mt-1">
|
||||||
|
{t("start_new_chat_hint") || "Start a new chat from the researcher"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { format } from "date-fns";
|
||||||
import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react";
|
import { FileText, Loader2, MoreHorizontal, Plus, Search, Trash2, X } from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
|
@ -21,6 +22,7 @@ import {
|
||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||||
import { notesApiService } from "@/lib/apis/notes-api.service";
|
import { notesApiService } from "@/lib/apis/notes-api.service";
|
||||||
|
|
@ -121,22 +123,44 @@ export function AllNotesSidebar({
|
||||||
const isLoading = isSearchMode ? isSearching : isLoadingNotes;
|
const isLoading = isSearchMode ? isSearching : isLoadingNotes;
|
||||||
const error = isSearchMode ? searchError : notesError;
|
const error = isSearchMode ? searchError : notesError;
|
||||||
|
|
||||||
// Transform notes data - handle both regular notes and search results
|
// Transform and sort notes data - handle both regular notes and search results
|
||||||
const notes = useMemo(() => {
|
const notes = useMemo(() => {
|
||||||
|
let notesList: {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
search_space_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string | null;
|
||||||
|
}[];
|
||||||
|
|
||||||
if (isSearchMode && searchData?.items) {
|
if (isSearchMode && searchData?.items) {
|
||||||
return searchData.items.map((doc) => ({
|
notesList = searchData.items.map((doc) => ({
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
title: doc.title,
|
title: doc.title,
|
||||||
search_space_id: doc.search_space_id,
|
search_space_id: doc.search_space_id,
|
||||||
|
created_at: doc.created_at,
|
||||||
|
updated_at: doc.updated_at,
|
||||||
}));
|
}));
|
||||||
|
} else {
|
||||||
|
notesList = notesData?.items ?? [];
|
||||||
}
|
}
|
||||||
return notesData?.items ?? [];
|
|
||||||
|
// Sort notes by updated_at (most recent first), fallback to created_at
|
||||||
|
return [...notesList].sort((a, b) => {
|
||||||
|
const dateA = a.updated_at
|
||||||
|
? new Date(a.updated_at).getTime()
|
||||||
|
: new Date(a.created_at).getTime();
|
||||||
|
const dateB = b.updated_at
|
||||||
|
? new Date(b.updated_at).getTime()
|
||||||
|
: new Date(b.created_at).getTime();
|
||||||
|
return dateB - dateA; // Descending order (most recent first)
|
||||||
|
});
|
||||||
}, [isSearchMode, searchData, notesData]);
|
}, [isSearchMode, searchData, notesData]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||||
<SheetContent side="left" className="w-80 p-0 flex flex-col">
|
<SheetContent side="left" className="w-80 p-0 flex flex-col">
|
||||||
<SheetHeader className="px-4 py-4 border-b space-y-3">
|
<SheetHeader className="mx-3 px-4 py-4 border-b space-y-3">
|
||||||
<SheetTitle>{t("all_notes") || "All Notes"}</SheetTitle>
|
<SheetTitle>{t("all_notes") || "All Notes"}</SheetTitle>
|
||||||
<SheetDescription className="sr-only">
|
<SheetDescription className="sr-only">
|
||||||
{t("all_notes_description") || "Browse and manage all your notes"}
|
{t("all_notes_description") || "Browse and manage all your notes"}
|
||||||
|
|
@ -160,7 +184,7 @@ export function AllNotesSidebar({
|
||||||
onClick={handleClearSearch}
|
onClick={handleClearSearch}
|
||||||
>
|
>
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
<span className="sr-only">Clear search</span>
|
<span className="sr-only">{t("clear_search") || "Clear search"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -192,15 +216,33 @@ export function AllNotesSidebar({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Main clickable area for navigation */}
|
{/* Main clickable area for navigation */}
|
||||||
<button
|
<Tooltip>
|
||||||
type="button"
|
<TooltipTrigger asChild>
|
||||||
onClick={() => handleNoteClick(note.id, note.search_space_id)}
|
<button
|
||||||
disabled={isDeleting}
|
type="button"
|
||||||
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
onClick={() => handleNoteClick(note.id, note.search_space_id)}
|
||||||
>
|
disabled={isDeleting}
|
||||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
className="flex items-center gap-2 flex-1 min-w-0 text-left"
|
||||||
<span className="truncate">{note.title}</span>
|
>
|
||||||
</button>
|
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate">{note.title}</span>
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p>
|
||||||
|
{t("created") || "Created"}:{" "}
|
||||||
|
{format(new Date(note.created_at), "MMM d, yyyy 'at' h:mm a")}
|
||||||
|
</p>
|
||||||
|
{note.updated_at && (
|
||||||
|
<p>
|
||||||
|
{t("updated") || "Updated"}:{" "}
|
||||||
|
{format(new Date(note.updated_at), "MMM d, yyyy 'at' h:mm a")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* Actions dropdown - separate from main click area */}
|
{/* Actions dropdown - separate from main click area */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|
@ -220,7 +262,7 @@ export function AllNotesSidebar({
|
||||||
) : (
|
) : (
|
||||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
<span className="sr-only">More options</span>
|
<span className="sr-only">{t("more_options") || "More options"}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-40">
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
|
|
@ -229,7 +271,7 @@ export function AllNotesSidebar({
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:text-destructive"
|
||||||
>
|
>
|
||||||
<Trash2 className="mr-2 h-4 w-4" />
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
<span>Delete</span>
|
<span>{t("delete") || "Delete"}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
@ -273,7 +315,7 @@ export function AllNotesSidebar({
|
||||||
|
|
||||||
{/* Footer with Add Note button */}
|
{/* Footer with Add Note button */}
|
||||||
{onAddNote && notes.length > 0 && (
|
{onAddNote && notes.length > 0 && (
|
||||||
<div className="p-3 border-t">
|
<div className="mx-3 p-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAddNote();
|
onAddNote();
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
MessageCircleMore,
|
MessageCircleMore,
|
||||||
MoonIcon,
|
MoonIcon,
|
||||||
Podcast,
|
Podcast,
|
||||||
|
RefreshCw,
|
||||||
Settings2,
|
Settings2,
|
||||||
SquareLibrary,
|
SquareLibrary,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
|
|
@ -113,9 +114,9 @@ function UserAvatar({ email, size = 32 }: { email: string; size?: number }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { NavChats } from "@/components/sidebar/nav-chats";
|
||||||
import { NavMain } from "@/components/sidebar/nav-main";
|
import { NavMain } from "@/components/sidebar/nav-main";
|
||||||
import { NavNotes } from "@/components/sidebar/nav-notes";
|
import { NavNotes } from "@/components/sidebar/nav-notes";
|
||||||
import { NavProjects } from "@/components/sidebar/nav-projects";
|
|
||||||
import { NavSecondary } from "@/components/sidebar/nav-secondary";
|
import { NavSecondary } from "@/components/sidebar/nav-secondary";
|
||||||
import { PageUsageDisplay } from "@/components/sidebar/page-usage-display";
|
import { PageUsageDisplay } from "@/components/sidebar/page-usage-display";
|
||||||
import {
|
import {
|
||||||
|
|
@ -146,6 +147,7 @@ export const iconMap: Record<string, LucideIcon> = {
|
||||||
Trash2,
|
Trash2,
|
||||||
Podcast,
|
Podcast,
|
||||||
Users,
|
Users,
|
||||||
|
RefreshCw,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultData = {
|
const defaultData = {
|
||||||
|
|
@ -293,6 +295,7 @@ export const AppSidebar = memo(function AppSidebar({
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { data: user, isPending: isLoadingUser } = useAtomValue(currentUserAtom);
|
const { data: user, isPending: isLoadingUser } = useAtomValue(currentUserAtom);
|
||||||
const [isClient, setIsClient] = useState(false);
|
const [isClient, setIsClient] = useState(false);
|
||||||
|
const [isSourcesExpanded, setIsSourcesExpanded] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsClient(true);
|
setIsClient(true);
|
||||||
|
|
@ -443,22 +446,21 @@ export const AppSidebar = memo(function AppSidebar({
|
||||||
</SidebarMenu>
|
</SidebarMenu>
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
|
|
||||||
<SidebarContent className="space-y-6">
|
<SidebarContent className="gap-1">
|
||||||
<NavMain items={processedNavMain} />
|
<NavMain items={processedNavMain} onSourcesExpandedChange={setIsSourcesExpanded} />
|
||||||
|
|
||||||
{processedRecentChats.length > 0 && (
|
<NavChats
|
||||||
<div className="space-y-2">
|
chats={processedRecentChats}
|
||||||
<NavProjects chats={processedRecentChats} />
|
searchSpaceId={searchSpaceId}
|
||||||
</div>
|
isSourcesExpanded={isSourcesExpanded}
|
||||||
)}
|
/>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<NavNotes
|
||||||
<NavNotes
|
notes={processedRecentNotes}
|
||||||
notes={processedRecentNotes}
|
onAddNote={onAddNote}
|
||||||
onAddNote={onAddNote}
|
searchSpaceId={searchSpaceId}
|
||||||
searchSpaceId={searchSpaceId}
|
isSourcesExpanded={isSourcesExpanded}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
{pageUsage && (
|
{pageUsage && (
|
||||||
|
|
|
||||||
246
surfsense_web/components/sidebar/nav-chats.tsx
Normal file
246
surfsense_web/components/sidebar/nav-chats.tsx
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
ChevronRight,
|
||||||
|
FolderOpen,
|
||||||
|
Loader2,
|
||||||
|
type LucideIcon,
|
||||||
|
MessageCircleMore,
|
||||||
|
MoreHorizontal,
|
||||||
|
RefreshCw,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import {
|
||||||
|
SidebarGroup,
|
||||||
|
SidebarGroupContent,
|
||||||
|
SidebarGroupLabel,
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
} from "@/components/ui/sidebar";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { AllChatsSidebar } from "./all-chats-sidebar";
|
||||||
|
|
||||||
|
interface ChatAction {
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatItem {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
id?: number;
|
||||||
|
search_space_id?: number;
|
||||||
|
actions?: ChatAction[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavChatsProps {
|
||||||
|
chats: ChatItem[];
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
searchSpaceId?: string;
|
||||||
|
isSourcesExpanded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map of icon names to their components
|
||||||
|
const actionIconMap: Record<string, LucideIcon> = {
|
||||||
|
MessageCircleMore,
|
||||||
|
Trash2,
|
||||||
|
MoreHorizontal,
|
||||||
|
RefreshCw,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function NavChats({
|
||||||
|
chats,
|
||||||
|
defaultOpen = true,
|
||||||
|
searchSpaceId,
|
||||||
|
isSourcesExpanded = false,
|
||||||
|
}: NavChatsProps) {
|
||||||
|
const t = useTranslations("sidebar");
|
||||||
|
const router = useRouter();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [isDeleting, setIsDeleting] = useState<number | null>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||||
|
const [isAllChatsSidebarOpen, setIsAllChatsSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
// Auto-collapse on smaller screens when Sources is expanded
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSourcesExpanded && isMobile) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}, [isSourcesExpanded, isMobile]);
|
||||||
|
|
||||||
|
// Handle chat deletion with loading state
|
||||||
|
const handleDeleteChat = useCallback(async (chatId: number, deleteAction: () => void) => {
|
||||||
|
setIsDeleting(chatId);
|
||||||
|
try {
|
||||||
|
await deleteAction();
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(null);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle chat navigation
|
||||||
|
const handleChatClick = useCallback(
|
||||||
|
(url: string) => {
|
||||||
|
router.push(url);
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
||||||
|
<Collapsible open={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
<div className="flex items-center group/header">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<SidebarGroupLabel className="cursor-pointer rounded-md px-2 py-1.5 -mx-2 transition-colors flex items-center gap-1.5 flex-1">
|
||||||
|
<ChevronRight
|
||||||
|
className={cn(
|
||||||
|
"h-3.5 w-3.5 text-muted-foreground transition-all duration-200 shrink-0",
|
||||||
|
isOpen && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span>{t("recent_chats") || "Recent Chats"}</span>
|
||||||
|
</SidebarGroupLabel>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
{/* Action buttons - always visible on hover */}
|
||||||
|
<div className="flex items-center gap-0.5 opacity-0 group-hover/header:opacity-100 transition-opacity pr-1">
|
||||||
|
{searchSpaceId && chats.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-5 w-5"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsAllChatsSidebarOpen(true);
|
||||||
|
}}
|
||||||
|
aria-label={t("view_all_chats") || "View all chats"}
|
||||||
|
>
|
||||||
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CollapsibleContent>
|
||||||
|
{chats.length > 0 ? (
|
||||||
|
<SidebarGroupContent>
|
||||||
|
<SidebarMenu>
|
||||||
|
{chats.map((chat) => {
|
||||||
|
const isDeletingChat = isDeleting === chat.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenuItem key={chat.id || chat.name} className="group/chat">
|
||||||
|
{/* Main navigation button */}
|
||||||
|
<SidebarMenuButton
|
||||||
|
onClick={() => handleChatClick(chat.url)}
|
||||||
|
disabled={isDeletingChat}
|
||||||
|
className={cn(
|
||||||
|
"pr-8", // Make room for the action button
|
||||||
|
isDeletingChat && "opacity-50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<chat.icon className="h-4 w-4 shrink-0" />
|
||||||
|
<span className="truncate">{chat.name}</span>
|
||||||
|
</SidebarMenuButton>
|
||||||
|
|
||||||
|
{/* Actions dropdown - positioned absolutely */}
|
||||||
|
{chat.actions && chat.actions.length > 0 && (
|
||||||
|
<div className="absolute right-1 top-1/2 -translate-y-1/2">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn(
|
||||||
|
"h-6 w-6",
|
||||||
|
"opacity-0 group-hover/chat:opacity-100 focus:opacity-100",
|
||||||
|
"data-[state=open]:opacity-100",
|
||||||
|
"transition-opacity"
|
||||||
|
)}
|
||||||
|
disabled={isDeletingChat}
|
||||||
|
>
|
||||||
|
{isDeletingChat ? (
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
|
)}
|
||||||
|
<span className="sr-only">
|
||||||
|
{t("more_options") || "More options"}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" side="right" className="w-40">
|
||||||
|
{chat.actions.map((action, actionIndex) => {
|
||||||
|
const ActionIcon = actionIconMap[action.icon] || MessageCircleMore;
|
||||||
|
const isDeleteAction = action.name.toLowerCase().includes("delete");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={`${action.name}-${actionIndex}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (isDeleteAction) {
|
||||||
|
handleDeleteChat(chat.id || 0, action.onClick);
|
||||||
|
} else {
|
||||||
|
action.onClick();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={isDeletingChat}
|
||||||
|
className={
|
||||||
|
isDeleteAction
|
||||||
|
? "text-destructive focus:text-destructive"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ActionIcon className="mr-2 h-4 w-4" />
|
||||||
|
<span>
|
||||||
|
{isDeletingChat && isDeleteAction
|
||||||
|
? t("deleting") || "Deleting..."
|
||||||
|
: action.name}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SidebarMenuItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SidebarMenu>
|
||||||
|
</SidebarGroupContent>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 px-2 py-1 text-muted-foreground/60 text-xs">
|
||||||
|
<MessageCircleMore className="h-3.5 w-3.5" />
|
||||||
|
<span>{t("no_recent_chats") || "No recent chats"}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
|
||||||
|
{/* All Chats Sheet */}
|
||||||
|
{searchSpaceId && (
|
||||||
|
<AllChatsSidebar
|
||||||
|
open={isAllChatsSidebarOpen}
|
||||||
|
onOpenChange={setIsAllChatsSidebarOpen}
|
||||||
|
searchSpaceId={searchSpaceId}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SidebarGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { ChevronRight, type LucideIcon } from "lucide-react";
|
import { ChevronRight, type LucideIcon } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useMemo } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import {
|
import {
|
||||||
|
|
@ -28,7 +28,12 @@ interface NavItem {
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NavMain({ items }: { items: NavItem[] }) {
|
interface NavMainProps {
|
||||||
|
items: NavItem[];
|
||||||
|
onSourcesExpandedChange?: (expanded: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavMain({ items, onSourcesExpandedChange }: NavMainProps) {
|
||||||
const t = useTranslations("nav_menu");
|
const t = useTranslations("nav_menu");
|
||||||
|
|
||||||
// Translation function that handles both exact matches and fallback to original
|
// Translation function that handles both exact matches and fallback to original
|
||||||
|
|
@ -53,6 +58,29 @@ export function NavMain({ items }: { items: NavItem[] }) {
|
||||||
// Memoize items to prevent unnecessary re-renders
|
// Memoize items to prevent unnecessary re-renders
|
||||||
const memoizedItems = useMemo(() => items, [items]);
|
const memoizedItems = useMemo(() => items, [items]);
|
||||||
|
|
||||||
|
// Track expanded state for items with sub-menus (like Sources)
|
||||||
|
const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>(() => {
|
||||||
|
const initial: Record<string, boolean> = {};
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.items?.length) {
|
||||||
|
initial[item.title] = item.isActive ?? false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return initial;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle collapsible state change
|
||||||
|
const handleOpenChange = useCallback(
|
||||||
|
(title: string, isOpen: boolean) => {
|
||||||
|
setExpandedItems((prev) => ({ ...prev, [title]: isOpen }));
|
||||||
|
// Notify parent when Sources is expanded/collapsed
|
||||||
|
if (title === "Sources" && onSourcesExpandedChange) {
|
||||||
|
onSourcesExpandedChange(isOpen);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onSourcesExpandedChange]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarGroup>
|
<SidebarGroup>
|
||||||
<SidebarGroupLabel>{translateTitle("Platform")}</SidebarGroupLabel>
|
<SidebarGroupLabel>{translateTitle("Platform")}</SidebarGroupLabel>
|
||||||
|
|
@ -60,8 +88,15 @@ export function NavMain({ items }: { items: NavItem[] }) {
|
||||||
{memoizedItems.map((item, index) => {
|
{memoizedItems.map((item, index) => {
|
||||||
const translatedTitle = translateTitle(item.title);
|
const translatedTitle = translateTitle(item.title);
|
||||||
const hasSub = !!item.items?.length;
|
const hasSub = !!item.items?.length;
|
||||||
|
const isItemOpen = expandedItems[item.title] ?? item.isActive ?? false;
|
||||||
return (
|
return (
|
||||||
<Collapsible key={`${item.title}-${index}`} asChild defaultOpen={item.isActive}>
|
<Collapsible
|
||||||
|
key={`${item.title}-${index}`}
|
||||||
|
asChild
|
||||||
|
open={hasSub ? isItemOpen : undefined}
|
||||||
|
onOpenChange={hasSub ? (open) => handleOpenChange(item.title, open) : undefined}
|
||||||
|
defaultOpen={!hasSub ? item.isActive : undefined}
|
||||||
|
>
|
||||||
<SidebarMenuItem>
|
<SidebarMenuItem>
|
||||||
{hasSub ? (
|
{hasSub ? (
|
||||||
// When the item has children, make the whole row a collapsible trigger
|
// When the item has children, make the whole row a collapsible trigger
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||||
import {
|
import {
|
||||||
|
|
@ -29,6 +29,7 @@ import {
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { AllNotesSidebar } from "./all-notes-sidebar";
|
import { AllNotesSidebar } from "./all-notes-sidebar";
|
||||||
|
|
||||||
|
|
@ -52,6 +53,7 @@ interface NavNotesProps {
|
||||||
onAddNote?: () => void;
|
onAddNote?: () => void;
|
||||||
defaultOpen?: boolean;
|
defaultOpen?: boolean;
|
||||||
searchSpaceId?: string;
|
searchSpaceId?: string;
|
||||||
|
isSourcesExpanded?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map of icon names to their components
|
// Map of icon names to their components
|
||||||
|
|
@ -61,13 +63,27 @@ const actionIconMap: Record<string, LucideIcon> = {
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
};
|
};
|
||||||
|
|
||||||
export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }: NavNotesProps) {
|
export function NavNotes({
|
||||||
|
notes,
|
||||||
|
onAddNote,
|
||||||
|
defaultOpen = true,
|
||||||
|
searchSpaceId,
|
||||||
|
isSourcesExpanded = false,
|
||||||
|
}: NavNotesProps) {
|
||||||
const t = useTranslations("sidebar");
|
const t = useTranslations("sidebar");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const [isDeleting, setIsDeleting] = useState<number | null>(null);
|
const [isDeleting, setIsDeleting] = useState<number | null>(null);
|
||||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||||
const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false);
|
const [isAllNotesSidebarOpen, setIsAllNotesSidebarOpen] = useState(false);
|
||||||
|
|
||||||
|
// Auto-collapse on smaller screens when Sources is expanded
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSourcesExpanded && isMobile) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}, [isSourcesExpanded, isMobile]);
|
||||||
|
|
||||||
// Handle note deletion with loading state
|
// Handle note deletion with loading state
|
||||||
const handleDeleteNote = useCallback(async (noteId: number, deleteAction: () => void) => {
|
const handleDeleteNote = useCallback(async (noteId: number, deleteAction: () => void) => {
|
||||||
setIsDeleting(noteId);
|
setIsDeleting(noteId);
|
||||||
|
|
@ -113,7 +129,7 @@ export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setIsAllNotesSidebarOpen(true);
|
setIsAllNotesSidebarOpen(true);
|
||||||
}}
|
}}
|
||||||
aria-label="View all notes"
|
aria-label={t("view_all_notes") || "View all notes"}
|
||||||
>
|
>
|
||||||
<FolderOpen className="h-3.5 w-3.5" />
|
<FolderOpen className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -127,7 +143,7 @@ export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onAddNote();
|
onAddNote();
|
||||||
}}
|
}}
|
||||||
aria-label="Add note"
|
aria-label={t("add_note") || "Add note"}
|
||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -178,7 +194,9 @@ export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }
|
||||||
) : (
|
) : (
|
||||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
<span className="sr-only">More options</span>
|
<span className="sr-only">
|
||||||
|
{t("more_options") || "More options"}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" side="right" className="w-40">
|
<DropdownMenuContent align="end" side="right" className="w-40">
|
||||||
|
|
@ -206,7 +224,7 @@ export function NavNotes({ notes, onAddNote, defaultOpen = true, searchSpaceId }
|
||||||
<ActionIcon className="mr-2 h-4 w-4" />
|
<ActionIcon className="mr-2 h-4 w-4" />
|
||||||
<span>
|
<span>
|
||||||
{isDeletingNote && isDeleteAction
|
{isDeletingNote && isDeleteAction
|
||||||
? "Deleting..."
|
? t("deleting") || "Deleting..."
|
||||||
: action.name}
|
: action.name}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
ExternalLink,
|
|
||||||
Folder,
|
|
||||||
type LucideIcon,
|
|
||||||
MoreHorizontal,
|
|
||||||
RefreshCw,
|
|
||||||
Search,
|
|
||||||
Share,
|
|
||||||
Trash2,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useCallback, useMemo, useState } from "react";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu";
|
|
||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarInput,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuAction,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
useSidebar,
|
|
||||||
} from "@/components/ui/sidebar";
|
|
||||||
|
|
||||||
// Map of icon names to their components
|
|
||||||
const actionIconMap: Record<string, LucideIcon> = {
|
|
||||||
ExternalLink,
|
|
||||||
Folder,
|
|
||||||
Share,
|
|
||||||
Trash2,
|
|
||||||
MoreHorizontal,
|
|
||||||
Search,
|
|
||||||
RefreshCw,
|
|
||||||
};
|
|
||||||
|
|
||||||
interface ChatAction {
|
|
||||||
name: string;
|
|
||||||
icon: string;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatItem {
|
|
||||||
name: string;
|
|
||||||
url: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
id?: number;
|
|
||||||
search_space_id?: number;
|
|
||||||
actions?: ChatAction[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NavProjects({ chats }: { chats: ChatItem[] }) {
|
|
||||||
const t = useTranslations("sidebar");
|
|
||||||
const { isMobile } = useSidebar();
|
|
||||||
const router = useRouter();
|
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
|
||||||
const [isDeleting, setIsDeleting] = useState<number | null>(null);
|
|
||||||
|
|
||||||
const searchSpaceId = chats[0]?.search_space_id || "";
|
|
||||||
|
|
||||||
// Memoized filtered chats
|
|
||||||
const filteredChats = useMemo(() => {
|
|
||||||
if (!searchQuery.trim()) return chats;
|
|
||||||
|
|
||||||
return chats.filter((chat) => chat.name.toLowerCase().includes(searchQuery.toLowerCase()));
|
|
||||||
}, [chats, searchQuery]);
|
|
||||||
|
|
||||||
// Handle chat deletion with loading state
|
|
||||||
const handleDeleteChat = useCallback(async (chatId: number, deleteAction: () => void) => {
|
|
||||||
setIsDeleting(chatId);
|
|
||||||
try {
|
|
||||||
await deleteAction();
|
|
||||||
} finally {
|
|
||||||
setIsDeleting(null);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Enhanced chat item component
|
|
||||||
const ChatItemComponent = useCallback(
|
|
||||||
({ chat }: { chat: ChatItem }) => {
|
|
||||||
const isDeletingChat = isDeleting === chat.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem key={chat.id ? `chat-${chat.id}` : `chat-${chat.name}`}>
|
|
||||||
<SidebarMenuButton
|
|
||||||
onClick={() => router.push(chat.url)}
|
|
||||||
disabled={isDeletingChat}
|
|
||||||
className={isDeletingChat ? "opacity-50" : ""}
|
|
||||||
>
|
|
||||||
<chat.icon />
|
|
||||||
<span className={isDeletingChat ? "opacity-50" : ""}>{chat.name}</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
|
|
||||||
{chat.actions && chat.actions.length > 0 && (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<SidebarMenuAction showOnHover>
|
|
||||||
<MoreHorizontal />
|
|
||||||
<span className="sr-only">More</span>
|
|
||||||
</SidebarMenuAction>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
className="w-48"
|
|
||||||
side={isMobile ? "bottom" : "right"}
|
|
||||||
align={isMobile ? "end" : "start"}
|
|
||||||
>
|
|
||||||
{chat.actions.map((action, actionIndex) => {
|
|
||||||
const ActionIcon = actionIconMap[action.icon] || Folder;
|
|
||||||
const isDeleteAction = action.name.toLowerCase().includes("delete");
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={`${action.name}-${actionIndex}`}
|
|
||||||
onClick={() => {
|
|
||||||
if (isDeleteAction) {
|
|
||||||
handleDeleteChat(chat.id || 0, action.onClick);
|
|
||||||
} else {
|
|
||||||
action.onClick();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={isDeletingChat}
|
|
||||||
className={isDeleteAction ? "text-destructive" : ""}
|
|
||||||
>
|
|
||||||
<ActionIcon className="text-muted-foreground" />
|
|
||||||
<span>{isDeletingChat && isDeleteAction ? "Deleting..." : action.name}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)}
|
|
||||||
</SidebarMenuItem>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[isDeleting, router, isMobile, handleDeleteChat]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Show search input if there are chats
|
|
||||||
const showSearch = chats.length > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarGroup className="group-data-[collapsible=icon]:hidden">
|
|
||||||
<SidebarGroupLabel>{t("recent_chats")}</SidebarGroupLabel>
|
|
||||||
<SidebarMenu>
|
|
||||||
{/* Chat Items */}
|
|
||||||
{filteredChats.length > 0 ? (
|
|
||||||
filteredChats.map((chat) => <ChatItemComponent key={chat.id || chat.name} chat={chat} />)
|
|
||||||
) : (
|
|
||||||
/* No results state */
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton disabled className="text-muted-foreground">
|
|
||||||
<Search className="h-4 w-4" />
|
|
||||||
<span>{searchQuery ? t("no_chats_found") : t("no_recent_chats")}</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* View All Chats */}
|
|
||||||
{chats.length > 0 && (
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<SidebarMenuButton onClick={() => router.push(`/dashboard/${searchSpaceId}/chats`)}>
|
|
||||||
<MoreHorizontal />
|
|
||||||
<span>{t("view_all_chats")}</span>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
)}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -365,7 +365,7 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
<div
|
<div
|
||||||
data-slot="sidebar-group"
|
data-slot="sidebar-group"
|
||||||
data-sidebar="group"
|
data-sidebar="group"
|
||||||
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
|
className={cn("relative flex w-full min-w-0 flex-col px-2 py-1", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -42,13 +42,13 @@ function TooltipContent({
|
||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance",
|
"bg-popover text-popover-foreground border shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit rounded-md px-3 py-1.5 text-xs text-balance",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
<TooltipPrimitive.Arrow className="bg-popover fill-popover z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||||
</TooltipPrimitive.Content>
|
</TooltipPrimitive.Content>
|
||||||
</TooltipPrimitive.Portal>
|
</TooltipPrimitive.Portal>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export const chatSummary = z.object({
|
||||||
});
|
});
|
||||||
|
|
||||||
export const chatDetails = chatSummary.extend({
|
export const chatDetails = chatSummary.extend({
|
||||||
initial_connectors: z.array(z.string()),
|
initial_connectors: z.array(z.string()).nullable().optional(),
|
||||||
messages: z.array(z.any()),
|
messages: z.array(z.any()),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import type { Message } from "@ai-sdk/react";
|
import { useEffect, useState } from "react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
import type { ChatDetails } from "@/app/dashboard/[search_space_id]/chats/chats-client";
|
|
||||||
import type { ResearchMode } from "@/components/chat";
|
import type { ResearchMode } from "@/components/chat";
|
||||||
import type { Document } from "@/contracts/types/document.types";
|
import type { Document } from "@/contracts/types/document.types";
|
||||||
import { getBearerToken } from "@/lib/auth-utils";
|
import { getBearerToken } from "@/lib/auth-utils";
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,14 @@ export const cacheKeys = {
|
||||||
chats: {
|
chats: {
|
||||||
activeChat: (chatId: string) => ["active-chat", chatId] as const,
|
activeChat: (chatId: string) => ["active-chat", chatId] as const,
|
||||||
globalQueryParams: (queries: GetChatsRequest["queryParams"]) =>
|
globalQueryParams: (queries: GetChatsRequest["queryParams"]) =>
|
||||||
["chats", ...(queries ? Object.values(queries) : [])] as const,
|
[
|
||||||
|
"chats",
|
||||||
|
queries?.search_space_id,
|
||||||
|
queries?.limit,
|
||||||
|
queries?.skip,
|
||||||
|
queries?.page,
|
||||||
|
queries?.page_size,
|
||||||
|
] as const,
|
||||||
},
|
},
|
||||||
podcasts: {
|
podcasts: {
|
||||||
globalQueryParams: (queries: GetPodcastsRequest["queryParams"]) =>
|
globalQueryParams: (queries: GetPodcastsRequest["queryParams"]) =>
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,8 @@
|
||||||
"unknown_search_space": "Unknown Search Space",
|
"unknown_search_space": "Unknown Search Space",
|
||||||
"delete_chat": "Delete Chat",
|
"delete_chat": "Delete Chat",
|
||||||
"delete_chat_confirm": "Are you sure you want to delete",
|
"delete_chat_confirm": "Are you sure you want to delete",
|
||||||
|
"delete_note": "Delete Note",
|
||||||
|
"delete_note_confirm": "Are you sure you want to delete",
|
||||||
"action_cannot_undone": "This action cannot be undone.",
|
"action_cannot_undone": "This action cannot be undone.",
|
||||||
"deleting": "Deleting...",
|
"deleting": "Deleting...",
|
||||||
"surfsense_dashboard": "SurfSense Dashboard",
|
"surfsense_dashboard": "SurfSense Dashboard",
|
||||||
|
|
@ -641,7 +643,14 @@
|
||||||
"search_chats": "Search chats...",
|
"search_chats": "Search chats...",
|
||||||
"no_chats_found": "No chats found",
|
"no_chats_found": "No chats found",
|
||||||
"no_recent_chats": "No recent chats",
|
"no_recent_chats": "No recent chats",
|
||||||
"view_all_chats": "View All Chats",
|
"view_all_chats": "View all chats",
|
||||||
|
"all_chats": "All Chats",
|
||||||
|
"all_chats_description": "Browse and manage all your chats",
|
||||||
|
"no_chats": "No chats yet",
|
||||||
|
"start_new_chat_hint": "Start a new chat from the researcher",
|
||||||
|
"error_loading_chats": "Error loading chats",
|
||||||
|
"chat_deleted": "Chat deleted successfully",
|
||||||
|
"error_deleting_chat": "Failed to delete chat",
|
||||||
"search_space": "Search Space",
|
"search_space": "Search Space",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"all_notes": "All Notes",
|
"all_notes": "All Notes",
|
||||||
|
|
@ -652,7 +661,15 @@
|
||||||
"no_notes": "No notes yet",
|
"no_notes": "No notes yet",
|
||||||
"create_new_note": "Create a new note",
|
"create_new_note": "Create a new note",
|
||||||
"error_loading_notes": "Error loading notes",
|
"error_loading_notes": "Error loading notes",
|
||||||
"loading": "Loading..."
|
"loading": "Loading...",
|
||||||
|
"deleting": "Deleting...",
|
||||||
|
"delete": "Delete",
|
||||||
|
"created": "Created",
|
||||||
|
"updated": "Updated",
|
||||||
|
"more_options": "More options",
|
||||||
|
"clear_search": "Clear search",
|
||||||
|
"view_all_notes": "View all notes",
|
||||||
|
"add_note": "Add note"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"something_went_wrong": "Something went wrong",
|
"something_went_wrong": "Something went wrong",
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,8 @@
|
||||||
"unknown_search_space": "未知搜索空间",
|
"unknown_search_space": "未知搜索空间",
|
||||||
"delete_chat": "删除对话",
|
"delete_chat": "删除对话",
|
||||||
"delete_chat_confirm": "您确定要删除",
|
"delete_chat_confirm": "您确定要删除",
|
||||||
|
"delete_note": "删除笔记",
|
||||||
|
"delete_note_confirm": "您确定要删除",
|
||||||
"action_cannot_undone": "此操作无法撤销。",
|
"action_cannot_undone": "此操作无法撤销。",
|
||||||
"deleting": "删除中...",
|
"deleting": "删除中...",
|
||||||
"surfsense_dashboard": "SurfSense 仪表盘",
|
"surfsense_dashboard": "SurfSense 仪表盘",
|
||||||
|
|
@ -642,6 +644,13 @@
|
||||||
"no_chats_found": "未找到对话",
|
"no_chats_found": "未找到对话",
|
||||||
"no_recent_chats": "暂无最近对话",
|
"no_recent_chats": "暂无最近对话",
|
||||||
"view_all_chats": "查看所有对话",
|
"view_all_chats": "查看所有对话",
|
||||||
|
"all_chats": "所有对话",
|
||||||
|
"all_chats_description": "浏览和管理您的所有对话",
|
||||||
|
"no_chats": "暂无对话",
|
||||||
|
"start_new_chat_hint": "从研究员开始新对话",
|
||||||
|
"error_loading_chats": "加载对话时出错",
|
||||||
|
"chat_deleted": "对话删除成功",
|
||||||
|
"error_deleting_chat": "删除对话失败",
|
||||||
"search_space": "搜索空间",
|
"search_space": "搜索空间",
|
||||||
"notes": "笔记",
|
"notes": "笔记",
|
||||||
"all_notes": "所有笔记",
|
"all_notes": "所有笔记",
|
||||||
|
|
@ -652,7 +661,15 @@
|
||||||
"no_notes": "暂无笔记",
|
"no_notes": "暂无笔记",
|
||||||
"create_new_note": "创建新笔记",
|
"create_new_note": "创建新笔记",
|
||||||
"error_loading_notes": "加载笔记时出错",
|
"error_loading_notes": "加载笔记时出错",
|
||||||
"loading": "加载中..."
|
"loading": "加载中...",
|
||||||
|
"deleting": "删除中...",
|
||||||
|
"delete": "删除",
|
||||||
|
"created": "创建时间",
|
||||||
|
"updated": "更新时间",
|
||||||
|
"more_options": "更多选项",
|
||||||
|
"clear_search": "清除搜索",
|
||||||
|
"view_all_notes": "查看所有笔记",
|
||||||
|
"add_note": "添加笔记"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"something_went_wrong": "出错了",
|
"something_went_wrong": "出错了",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue