Improvements for sidebar

This commit is contained in:
Utkarsh-Patel-13 2025-08-02 21:20:36 -07:00
parent d98dfd40b5
commit 8bc369cd94
10 changed files with 560 additions and 373 deletions

View file

@ -6,7 +6,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
from app.db import Chat, SearchSpace, User, get_async_session from app.db import Chat, SearchSpace, User, get_async_session
from app.schemas import AISDKChatRequest, ChatCreate, ChatRead, ChatUpdate from app.schemas import (
AISDKChatRequest,
ChatCreate,
ChatRead,
ChatReadWithoutMessages,
ChatUpdate,
)
from app.tasks.stream_connector_search_results import stream_connector_search_results from app.tasks.stream_connector_search_results import stream_connector_search_results
from app.users import current_active_user from app.users import current_active_user
from app.utils.check_ownership import check_ownership from app.utils.check_ownership import check_ownership
@ -112,7 +118,7 @@ async def create_chat(
) from None ) from None
@router.get("/chats/", response_model=list[ChatRead]) @router.get("/chats/", response_model=list[ChatReadWithoutMessages])
async def read_chats( async def read_chats(
skip: int = 0, skip: int = 0,
limit: int = 100, limit: int = 100,
@ -121,14 +127,26 @@ async def read_chats(
user: User = Depends(current_active_user), user: User = Depends(current_active_user),
): ):
try: try:
query = select(Chat).join(SearchSpace).filter(SearchSpace.user_id == user.id) # Select specific fields excluding messages
query = (
select(
Chat.id,
Chat.type,
Chat.title,
Chat.initial_connectors,
Chat.search_space_id,
Chat.created_at,
)
.join(SearchSpace)
.filter(SearchSpace.user_id == user.id)
)
# Filter by search_space_id if provided # Filter by search_space_id if provided
if search_space_id is not None: if search_space_id is not None:
query = query.filter(Chat.search_space_id == search_space_id) query = query.filter(Chat.search_space_id == search_space_id)
result = await session.execute(query.offset(skip).limit(limit)) result = await session.execute(query.offset(skip).limit(limit))
return result.scalars().all() return result.all()
except OperationalError: except OperationalError:
raise HTTPException( raise HTTPException(
status_code=503, detail="Database operation failed. Please try again later." status_code=503, detail="Database operation failed. Please try again later."

View file

@ -1,5 +1,12 @@
from .base import IDModel, TimestampModel from .base import IDModel, TimestampModel
from .chats import AISDKChatRequest, ChatBase, ChatCreate, ChatRead, ChatUpdate from .chats import (
AISDKChatRequest,
ChatBase,
ChatCreate,
ChatRead,
ChatReadWithoutMessages,
ChatUpdate,
)
from .chunks import ChunkBase, ChunkCreate, ChunkRead, ChunkUpdate from .chunks import ChunkBase, ChunkCreate, ChunkRead, ChunkUpdate
from .documents import ( from .documents import (
DocumentBase, DocumentBase,
@ -37,6 +44,7 @@ __all__ = [
"ChatBase", "ChatBase",
"ChatCreate", "ChatCreate",
"ChatRead", "ChatRead",
"ChatReadWithoutMessages",
"ChatUpdate", "ChatUpdate",
"ChunkBase", "ChunkBase",
"ChunkCreate", "ChunkCreate",

View file

@ -15,6 +15,12 @@ class ChatBase(BaseModel):
search_space_id: int search_space_id: int
class ChatBaseWithoutMessages(BaseModel):
type: ChatType
title: str
search_space_id: int
class ClientAttachment(BaseModel): class ClientAttachment(BaseModel):
name: str name: str
content_type: str content_type: str
@ -50,3 +56,7 @@ class ChatUpdate(ChatBase):
class ChatRead(ChatBase, IDModel, TimestampModel): class ChatRead(ChatBase, IDModel, TimestampModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
class ChatReadWithoutMessages(ChatBaseWithoutMessages, IDModel, TimestampModel):
model_config = ConfigDict(from_attributes=True)

View file

@ -19,14 +19,7 @@ import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -61,24 +54,16 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { cn } from "@/lib/utils";
interface Chat { interface Chat {
created_at: string; created_at: string;
id: number; id: number;
type: string; type: string;
title: string; title: string;
messages: ChatMessage[];
search_space_id: number; search_space_id: number;
} }
interface ChatMessage {
id: string;
createdAt: string;
role: string;
content: string;
parts?: any;
}
interface ChatsPageClientProps { interface ChatsPageClientProps {
searchSpaceId: string; searchSpaceId: string;
} }
@ -580,12 +565,12 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
animate="animate" animate="animate"
exit="exit" exit="exit"
transition={{ duration: 0.2, delay: index * 0.05 }} transition={{ duration: 0.2, delay: index * 0.05 }}
className={`overflow-hidden hover:shadow-md transition-shadow className={cn(
${ "overflow-hidden hover:shadow-md transition-shadow",
selectionMode && selectedChats.includes(chat.id) selectionMode && selectedChats.includes(chat.id)
? "ring-2 ring-primary ring-offset-2" ? "ring-2 ring-primary ring-offset-2"
: "" : ""
}`} )}
onClick={(e) => { onClick={(e) => {
if (!selectionMode) return; if (!selectionMode) return;
// Ignore clicks coming from interactive elements // Ignore clicks coming from interactive elements
@ -672,24 +657,21 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps)
)} )}
</div> </div>
</CardHeader> </CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground line-clamp-3"> <CardFooter className="flex items-center justify-between gap-2 w-full">
{chat.messages && chat.messages.length > 0
? typeof chat.messages[0] === "string"
? chat.messages[0]
: chat.messages[0]?.content || "No message content"
: "No messages in this chat."}
</div>
</CardContent>
<CardFooter className="flex justify-between pt-2">
<div className="flex items-center text-xs text-muted-foreground">
<MessageCircleMore className="mr-1 h-3.5 w-3.5" />
<span>{chat.messages?.length || 0} messages</span>
</div>
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
<Tag className="mr-1 h-3 w-3" /> <Tag className="mr-1 h-3 w-3" />
{chat.type || "Unknown"} {chat.type || "Unknown"}
</Badge> </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> </CardFooter>
</MotionCard> </MotionCard>
))} ))}

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { Trash2 } from "lucide-react"; import { AlertCircle, Trash2 } from "lucide-react";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
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";
import { import {
@ -12,7 +12,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { apiClient } from "@/lib/api"; // Import the API client import { apiClient } from "@/lib/api";
interface Chat { interface Chat {
created_at: string; created_at: string;
@ -50,6 +50,26 @@ interface AppSidebarProviderProps {
}[]; }[];
} }
// Loading skeleton component
const LoadingSkeleton = () => (
<div className="space-y-2 p-2">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-8 bg-muted animate-pulse rounded-md" />
))}
</div>
);
// Error state component
const ErrorState = ({ error, onRetry }: { error: string; onRetry: () => void }) => (
<div className="p-4 text-center">
<AlertCircle className="h-8 w-8 text-destructive mx-auto mb-2" />
<p className="text-sm text-muted-foreground mb-2">{error}</p>
<Button variant="outline" size="sm" onClick={onRetry}>
Retry
</Button>
</div>
);
export function AppSidebarProvider({ export function AppSidebarProvider({
searchSpaceId, searchSpaceId,
navSecondary, navSecondary,
@ -80,66 +100,82 @@ export function AppSidebarProvider({
setIsClient(true); setIsClient(true);
}, []); }, []);
// Memoized fetch function for chats
const fetchRecentChats = useCallback(async () => {
try {
// Only run on client-side
if (typeof window === "undefined") return;
const chats: Chat[] = await apiClient.get<Chat[]>(
`api/v1/chats/?limit=5&skip=0&search_space_id=${searchSpaceId}`
);
// Sort chats by created_at in descending order (newest first)
const sortedChats = chats.sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
// Transform API response to the format expected by AppSidebar
const formattedChats = sortedChats.map((chat) => ({
name: chat.title || `Chat ${chat.id}`,
url: `/dashboard/${chat.search_space_id}/researcher/${chat.id}`,
icon: "MessageCircleMore",
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);
},
},
],
}));
setRecentChats(formattedChats);
setChatError(null);
} catch (error) {
console.error("Error fetching chats:", error);
setChatError(error instanceof Error ? error.message : "Unknown error occurred");
setRecentChats([]);
} finally {
setIsLoadingChats(false);
}
}, [searchSpaceId]);
// Memoized fetch function for search space
const fetchSearchSpace = useCallback(async () => {
try {
// Only run on client-side
if (typeof window === "undefined") return;
const data: SearchSpace = await apiClient.get<SearchSpace>(
`api/v1/searchspaces/${searchSpaceId}`
);
setSearchSpace(data);
setSearchSpaceError(null);
} catch (error) {
console.error("Error fetching search space:", error);
setSearchSpaceError(error instanceof Error ? error.message : "Unknown error occurred");
} finally {
setIsLoadingSearchSpace(false);
}
}, [searchSpaceId]);
// Retry function
const retryFetch = useCallback(() => {
setChatError(null);
setSearchSpaceError(null);
setIsLoadingChats(true);
setIsLoadingSearchSpace(true);
fetchRecentChats();
fetchSearchSpace();
}, [fetchRecentChats, fetchSearchSpace]);
// Fetch recent chats // Fetch recent chats
useEffect(() => { useEffect(() => {
const fetchRecentChats = async () => {
try {
// Only run on client-side
if (typeof window === "undefined") return;
try {
// Use the API client instead of direct fetch - filter by current search space ID
const chats: Chat[] = await apiClient.get<Chat[]>(
`api/v1/chats/?limit=5&skip=0&search_space_id=${searchSpaceId}`
);
// Sort chats by created_at in descending order (newest first)
const sortedChats = chats.sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
// console.log("sortedChats", sortedChats);
// Transform API response to the format expected by AppSidebar
const formattedChats = sortedChats.map((chat) => ({
name: chat.title || `Chat ${chat.id}`, // Fallback if title is empty
url: `/dashboard/${chat.search_space_id}/researcher/${chat.id}`,
icon: "MessageCircleMore",
id: chat.id,
search_space_id: chat.search_space_id,
actions: [
{
name: "View Details",
icon: "ExternalLink",
onClick: () => {
window.location.href = `/dashboard/${chat.search_space_id}/researcher/${chat.id}`;
},
},
{
name: "Delete",
icon: "Trash2",
onClick: () => {
setChatToDelete({ id: chat.id, name: chat.title || `Chat ${chat.id}` });
setShowDeleteDialog(true);
},
},
],
}));
setRecentChats(formattedChats);
setChatError(null);
} catch (error) {
console.error("Error fetching chats:", error);
setChatError(error instanceof Error ? error.message : "Unknown error occurred");
// Provide empty array to ensure UI still renders
setRecentChats([]);
} finally {
setIsLoadingChats(false);
}
} catch (error) {
console.error("Error in fetchRecentChats:", error);
setIsLoadingChats(false);
}
};
fetchRecentChats(); fetchRecentChats();
// Set up a refresh interval (every 5 minutes) // Set up a refresh interval (every 5 minutes)
@ -147,144 +183,144 @@ export function AppSidebarProvider({
// Clean up interval on component unmount // Clean up interval on component unmount
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, [searchSpaceId]); }, [fetchRecentChats]);
// Handle delete chat // Fetch search space details
const handleDeleteChat = async () => { useEffect(() => {
fetchSearchSpace();
}, [fetchSearchSpace]);
// Handle delete chat with better error handling
const handleDeleteChat = useCallback(async () => {
if (!chatToDelete) return; if (!chatToDelete) return;
try { try {
setIsDeleting(true); setIsDeleting(true);
// Use the API client instead of direct fetch
await apiClient.delete(`api/v1/chats/${chatToDelete.id}`); await apiClient.delete(`api/v1/chats/${chatToDelete.id}`);
// Close dialog and refresh chats // Update local state
setRecentChats(recentChats.filter((chat) => chat.id !== chatToDelete.id)); setRecentChats((prev) => prev.filter((chat) => chat.id !== chatToDelete.id));
} catch (error) { } catch (error) {
console.error("Error deleting chat:", error); console.error("Error deleting chat:", error);
// You could show a toast notification here
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
setShowDeleteDialog(false); setShowDeleteDialog(false);
setChatToDelete(null); setChatToDelete(null);
} }
}; }, [chatToDelete]);
// Fetch search space details // Memoized fallback chats
useEffect(() => { const fallbackChats = useMemo(() => {
const fetchSearchSpace = async () => { if (chatError) {
try { return [
// Only run on client-side {
if (typeof window === "undefined") return; name: "Error loading chats",
url: "#",
icon: "AlertCircle",
id: 0,
search_space_id: Number(searchSpaceId),
actions: [
{
name: "Retry",
icon: "RefreshCw",
onClick: retryFetch,
},
],
},
];
}
try { if (!isLoadingChats && recentChats.length === 0) {
// Use the API client instead of direct fetch return [
const data: SearchSpace = await apiClient.get<SearchSpace>( {
`api/v1/searchspaces/${searchSpaceId}` name: "No recent chats",
); url: "#",
setSearchSpace(data); icon: "MessageCircleMore",
setSearchSpaceError(null); id: 0,
} catch (error) { search_space_id: Number(searchSpaceId),
console.error("Error fetching search space:", error); actions: [],
setSearchSpaceError(error instanceof Error ? error.message : "Unknown error occurred"); },
} finally { ];
setIsLoadingSearchSpace(false); }
}
} catch (error) {
console.error("Error in fetchSearchSpace:", error);
setIsLoadingSearchSpace(false);
}
};
fetchSearchSpace(); return [];
}, [searchSpaceId]); }, [chatError, isLoadingChats, recentChats.length, searchSpaceId, retryFetch]);
// Create a fallback chat if there's an error or no chats
const fallbackChats =
chatError || (!isLoadingChats && recentChats.length === 0)
? [
{
name: chatError ? "Error loading chats" : "No recent chats",
url: "#",
icon: chatError ? "AlertCircle" : "MessageCircleMore",
id: 0,
search_space_id: Number(searchSpaceId),
actions: [],
},
]
: [];
// Use fallback chats if there's an error or no chats // Use fallback chats if there's an error or no chats
const displayChats = recentChats.length > 0 ? recentChats : fallbackChats; const displayChats = recentChats.length > 0 ? recentChats : fallbackChats;
// Update the first item in navSecondary to show the search space name // Memoized updated navSecondary
const updatedNavSecondary = [...navSecondary]; const updatedNavSecondary = useMemo(() => {
if (updatedNavSecondary.length > 0 && isClient) { const updated = [...navSecondary];
updatedNavSecondary[0] = { if (updated.length > 0 && isClient) {
...updatedNavSecondary[0], updated[0] = {
title: ...updated[0],
searchSpace?.name || title:
(isLoadingSearchSpace searchSpace?.name ||
? "Loading..." (isLoadingSearchSpace
: searchSpaceError ? "Loading..."
? "Error loading search space" : searchSpaceError
: "Unknown Search Space"), ? "Error loading search space"
}; : "Unknown Search Space"),
};
}
return updated;
}, [navSecondary, isClient, searchSpace?.name, isLoadingSearchSpace, searchSpaceError]);
// Show loading state if not client-side
if (!isClient) {
return <AppSidebar navSecondary={navSecondary} navMain={navMain} RecentChats={[]} />;
} }
return ( return (
<> <>
<AppSidebar <AppSidebar navSecondary={updatedNavSecondary} navMain={navMain} RecentChats={displayChats} />
navSecondary={updatedNavSecondary}
navMain={navMain}
RecentChats={isClient ? displayChats : []}
/>
{/* Delete Confirmation Dialog - Only render on client */} {/* Delete Confirmation Dialog */}
{isClient && ( <Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}> <DialogContent className="sm:max-w-md">
<DialogContent className="sm:max-w-md"> <DialogHeader>
<DialogHeader> <DialogTitle className="flex items-center gap-2">
<DialogTitle className="flex items-center gap-2"> <Trash2 className="h-5 w-5 text-destructive" />
<Trash2 className="h-5 w-5 text-destructive" /> <span>Delete Chat</span>
<span>Delete Chat</span> </DialogTitle>
</DialogTitle> <DialogDescription>
<DialogDescription> Are you sure you want to delete{" "}
Are you sure you want to delete{" "} <span className="font-medium">{chatToDelete?.name}</span>? This action cannot be
<span className="font-medium">{chatToDelete?.name}</span>? This action cannot be undone.
undone. </DialogDescription>
</DialogDescription> </DialogHeader>
</DialogHeader> <DialogFooter className="flex gap-2 sm:justify-end">
<DialogFooter className="flex gap-2 sm:justify-end"> <Button
<Button variant="outline"
variant="outline" onClick={() => setShowDeleteDialog(false)}
onClick={() => setShowDeleteDialog(false)} disabled={isDeleting}
disabled={isDeleting} >
> Cancel
Cancel </Button>
</Button> <Button
<Button variant="destructive"
variant="destructive" onClick={handleDeleteChat}
onClick={handleDeleteChat} disabled={isDeleting}
disabled={isDeleting} className="gap-2"
className="gap-2" >
> {isDeleting ? (
{isDeleting ? ( <>
<> <span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" /> Deleting...
Deleting... </>
</> ) : (
) : ( <>
<> <Trash2 className="h-4 w-4" />
<Trash2 className="h-4 w-4" /> Delete
Delete </>
</> )}
)} </Button>
</Button> </DialogFooter>
</DialogFooter> </DialogContent>
</DialogContent> </Dialog>
</Dialog>
)}
</> </>
); );
} }

View file

@ -17,15 +17,17 @@ import {
Trash2, Trash2,
Undo2, Undo2,
} from "lucide-react"; } from "lucide-react";
import { useMemo } from "react"; import { memo, useMemo } from "react";
import { Logo } from "@/components/Logo"; import { Logo } from "@/components/Logo";
import { NavMain } from "@/components/sidebar/nav-main"; import { NavMain } from "@/components/sidebar/nav-main";
import { NavProjects } from "@/components/sidebar/nav-projects"; import { NavProjects } from "@/components/sidebar/nav-projects";
import { NavSecondary } from "@/components/sidebar/nav-secondary"; import { NavSecondary } from "@/components/sidebar/nav-secondary";
import { NavUser } from "@/components/sidebar/nav-user";
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
SidebarFooter,
SidebarHeader, SidebarHeader,
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
@ -64,7 +66,6 @@ const defaultData = {
isActive: true, isActive: true,
items: [], items: [],
}, },
{ {
title: "Documents", title: "Documents",
url: "#", url: "#",
@ -154,12 +155,12 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
navSecondary?: { navSecondary?: {
title: string; title: string;
url: string; url: string;
icon: string; // Changed to string (icon name) icon: string;
}[]; }[];
RecentChats?: { RecentChats?: {
name: string; name: string;
url: string; url: string;
icon: string; // Changed to string (icon name) icon: string;
id?: number; id?: number;
search_space_id?: number; search_space_id?: number;
actions?: { actions?: {
@ -168,19 +169,26 @@ interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
onClick: () => void; onClick: () => void;
}[]; }[];
}[]; }[];
user?: {
name: string;
email: string;
avatar: string;
};
} }
export function AppSidebar({ // Memoized AppSidebar component for better performance
export const AppSidebar = memo(function AppSidebar({
navMain = defaultData.navMain, navMain = defaultData.navMain,
navSecondary = defaultData.navSecondary, navSecondary = defaultData.navSecondary,
RecentChats = defaultData.RecentChats, RecentChats = defaultData.RecentChats,
user = defaultData.user,
...props ...props
}: AppSidebarProps) { }: AppSidebarProps) {
// Process navMain to resolve icon names to components // Process navMain to resolve icon names to components
const processedNavMain = useMemo(() => { const processedNavMain = useMemo(() => {
return navMain.map((item) => ({ return navMain.map((item) => ({
...item, ...item,
icon: iconMap[item.icon] || SquareTerminal, // Fallback to SquareTerminal if icon not found icon: iconMap[item.icon] || SquareTerminal,
})); }));
}, [navMain]); }, [navMain]);
@ -188,7 +196,7 @@ export function AppSidebar({
const processedNavSecondary = useMemo(() => { const processedNavSecondary = useMemo(() => {
return navSecondary.map((item) => ({ return navSecondary.map((item) => ({
...item, ...item,
icon: iconMap[item.icon] || Undo2, // Fallback to Undo2 if icon not found icon: iconMap[item.icon] || Undo2,
})); }));
}, [navSecondary]); }, [navSecondary]);
@ -197,17 +205,17 @@ export function AppSidebar({
return ( return (
RecentChats?.map((item) => ({ RecentChats?.map((item) => ({
...item, ...item,
icon: iconMap[item.icon] || MessageCircleMore, // Fallback to MessageCircleMore if icon not found icon: iconMap[item.icon] || MessageCircleMore,
})) || [] })) || []
); );
}, [RecentChats]); }, [RecentChats]);
return ( return (
<Sidebar variant="inset" {...props}> <Sidebar variant="inset" collapsible="icon" aria-label="Main navigation" {...props}>
<SidebarHeader> <SidebarHeader>
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton size="lg" asChild> <SidebarMenuButton size="lg" asChild aria-label="Go to home page">
<div> <div>
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"> <div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg">
<Logo className="rounded-lg" /> <Logo className="rounded-lg" />
@ -221,11 +229,22 @@ export function AppSidebar({
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarContent>
<SidebarContent className="space-y-6">
<NavMain items={processedNavMain} /> <NavMain items={processedNavMain} />
{processedRecentChats.length > 0 && <NavProjects chats={processedRecentChats} />}
<NavSecondary items={processedNavSecondary} className="mt-auto" /> {processedRecentChats.length > 0 && (
<div className="space-y-2">
<NavProjects chats={processedRecentChats} />
</div>
)}
</SidebarContent> </SidebarContent>
<SidebarFooter>
<NavSecondary items={processedNavSecondary} className="mt-auto" />
{/* User Profile Section */}
<NavUser user={user} />
</SidebarFooter>
</Sidebar> </Sidebar>
); );
} });

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import { ChevronRight, type LucideIcon } from "lucide-react"; import { ChevronRight, type LucideIcon } from "lucide-react";
import { useMemo } from "react";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { import {
@ -15,46 +16,56 @@ import {
SidebarMenuSubItem, SidebarMenuSubItem,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
export function NavMain({ interface NavItem {
items, title: string;
}: { url: string;
items: { icon: LucideIcon;
isActive?: boolean;
items?: {
title: string; title: string;
url: string; url: string;
icon: LucideIcon;
isActive?: boolean;
items?: {
title: string;
url: string;
}[];
}[]; }[];
}) { }
export function NavMain({ items }: { items: NavItem[] }) {
// Memoize items to prevent unnecessary re-renders
const memoizedItems = useMemo(() => items, [items]);
return ( return (
<SidebarGroup> <SidebarGroup>
<SidebarGroupLabel>Platform</SidebarGroupLabel> <SidebarGroupLabel>Platform</SidebarGroupLabel>
<SidebarMenu> <SidebarMenu>
{items.map((item, index) => ( {memoizedItems.map((item, index) => (
<Collapsible key={`${item.title}-${index}`} asChild defaultOpen={item.isActive}> <Collapsible key={`${item.title}-${index}`} asChild defaultOpen={item.isActive}>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton asChild tooltip={item.title}> <SidebarMenuButton
asChild
tooltip={item.title}
isActive={item.isActive}
aria-label={`${item.title}${item.items?.length ? " with submenu" : ""}`}
>
<a href={item.url}> <a href={item.url}>
<item.icon /> <item.icon />
<span>{item.title}</span> <span>{item.title}</span>
</a> </a>
</SidebarMenuButton> </SidebarMenuButton>
{item.items?.length ? ( {item.items?.length ? (
<> <>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<SidebarMenuAction className="data-[state=open]:rotate-90"> <SidebarMenuAction
className="data-[state=open]:rotate-90 transition-transform duration-200"
aria-label={`Toggle ${item.title} submenu`}
>
<ChevronRight /> <ChevronRight />
<span className="sr-only">Toggle</span> <span className="sr-only">Toggle submenu</span>
</SidebarMenuAction> </SidebarMenuAction>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 duration-200">
<SidebarMenuSub> <SidebarMenuSub>
{item.items?.map((subItem, subIndex) => ( {item.items?.map((subItem, subIndex) => (
<SidebarMenuSubItem key={`${subItem.title}-${subIndex}`}> <SidebarMenuSubItem key={`${subItem.title}-${subIndex}`}>
<SidebarMenuSubButton asChild> <SidebarMenuSubButton asChild aria-label={subItem.title}>
<a href={subItem.url}> <a href={subItem.url}>
<span>{subItem.title}</span> <span>{subItem.title}</span>
</a> </a>

View file

@ -1,17 +1,27 @@
"use client"; "use client";
import { ExternalLink, Folder, type LucideIcon, MoreHorizontal, Share, Trash2 } from "lucide-react"; import {
ExternalLink,
Folder,
type LucideIcon,
MoreHorizontal,
RefreshCw,
Search,
Share,
Trash2,
} from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useCallback, useMemo, useState } from "react";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import {
SidebarGroup, SidebarGroup,
SidebarGroupLabel, SidebarGroupLabel,
SidebarInput,
SidebarMenu, SidebarMenu,
SidebarMenuAction, SidebarMenuAction,
SidebarMenuButton, SidebarMenuButton,
@ -26,6 +36,8 @@ const actionIconMap: Record<string, LucideIcon> = {
Share, Share,
Trash2, Trash2,
MoreHorizontal, MoreHorizontal,
Search,
RefreshCw,
}; };
interface ChatAction { interface ChatAction {
@ -34,33 +46,57 @@ interface ChatAction {
onClick: () => void; onClick: () => void;
} }
export function NavProjects({ interface ChatItem {
chats, name: string;
}: { url: string;
chats: { icon: LucideIcon;
name: string; id?: number;
url: string; search_space_id?: number;
icon: LucideIcon; actions?: ChatAction[];
id?: number; }
search_space_id?: number;
actions?: ChatAction[]; export function NavProjects({ chats }: { chats: ChatItem[] }) {
}[];
}) {
const { isMobile } = useSidebar(); const { isMobile } = useSidebar();
const router = useRouter(); const router = useRouter();
const [searchQuery, setSearchQuery] = useState("");
const [isDeleting, setIsDeleting] = useState<number | null>(null);
const searchSpaceId = chats[0]?.search_space_id || ""; const searchSpaceId = chats[0]?.search_space_id || "";
return ( // Memoized filtered chats
<SidebarGroup className="group-data-[collapsible=icon]:hidden"> const filteredChats = useMemo(() => {
<SidebarGroupLabel>Recent Chats</SidebarGroupLabel> if (!searchQuery.trim()) return chats;
<SidebarMenu>
{chats.map((item, index) => ( return chats.filter((chat) => chat.name.toLowerCase().includes(searchQuery.toLowerCase()));
<SidebarMenuItem key={item.id ? `chat-${item.id}` : `chat-${item.name}-${index}`}> }, [chats, searchQuery]);
<SidebarMenuButton>
<item.icon /> // Handle chat deletion with loading state
<span>{item.name}</span> const handleDeleteChat = useCallback(async (chatId: number, deleteAction: () => void) => {
</SidebarMenuButton> 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> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuAction showOnHover> <SidebarMenuAction showOnHover>
@ -73,44 +109,79 @@ export function NavProjects({
side={isMobile ? "bottom" : "right"} side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"} align={isMobile ? "end" : "start"}
> >
{item.actions ? ( {chat.actions.map((action, actionIndex) => {
// Use the actions provided by the item const ActionIcon = actionIconMap[action.icon] || Folder;
item.actions.map((action, actionIndex) => { const isDeleteAction = action.name.toLowerCase().includes("delete");
const ActionIcon = actionIconMap[action.icon] || Folder;
return ( return (
<DropdownMenuItem <DropdownMenuItem
key={`${action.name}-${actionIndex}`} key={`${action.name}-${actionIndex}`}
onClick={action.onClick} onClick={() => {
> if (isDeleteAction) {
<ActionIcon className="text-muted-foreground" /> handleDeleteChat(chat.id || 0, action.onClick);
<span>{action.name}</span> } else {
</DropdownMenuItem> action.onClick();
); }
}) }}
) : ( disabled={isDeletingChat}
// Default actions if none provided className={isDeleteAction ? "text-destructive" : ""}
<> >
<DropdownMenuItem> <ActionIcon className="text-muted-foreground" />
<Folder className="text-muted-foreground" /> <span>{isDeletingChat && isDeleteAction ? "Deleting..." : action.name}</span>
<span>View Chat</span>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator /> );
<DropdownMenuItem> })}
<Trash2 className="text-muted-foreground" />
<span>Delete Chat</span>
</DropdownMenuItem>
</>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</SidebarMenuItem> )}
))}
<SidebarMenuItem>
<SidebarMenuButton onClick={() => router.push(`/dashboard/${searchSpaceId}/chats`)}>
<MoreHorizontal />
<span>View All Chats</span>
</SidebarMenuButton>
</SidebarMenuItem> </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>Recent Chats</SidebarGroupLabel>
{/* Search Input */}
{showSearch && (
<div className="px-2 pb-2">
<SidebarInput
placeholder="Search chats..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-8"
/>
</div>
)}
<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 ? "No chats found" : "No recent chats"}</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
{/* View All Chats */}
{chats.length > 0 && (
<SidebarMenuItem>
<SidebarMenuButton onClick={() => router.push(`/dashboard/${searchSpaceId}/chats`)}>
<MoreHorizontal />
<span>View All Chats</span>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
); );

View file

@ -2,6 +2,7 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import type * as React from "react"; import type * as React from "react";
import { useMemo } from "react";
import { import {
SidebarGroup, SidebarGroup,
@ -11,23 +12,28 @@ import {
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
interface NavSecondaryItem {
title: string;
url: string;
icon: LucideIcon;
}
export function NavSecondary({ export function NavSecondary({
items, items,
...props ...props
}: { }: {
items: { items: NavSecondaryItem[];
title: string;
url: string;
icon: LucideIcon;
}[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) { } & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
// Memoize items to prevent unnecessary re-renders
const memoizedItems = useMemo(() => items, [items]);
return ( return (
<SidebarGroup {...props}> <SidebarGroup {...props}>
<SidebarGroupLabel>SearchSpace</SidebarGroupLabel> <SidebarGroupLabel>SearchSpace</SidebarGroupLabel>
<SidebarMenu> <SidebarMenu>
{items.map((item, index) => ( {memoizedItems.map((item, index) => (
<SidebarMenuItem key={`${item.title}-${index}`}> <SidebarMenuItem key={`${item.title}-${index}`}>
<SidebarMenuButton asChild size="sm"> <SidebarMenuButton asChild size="sm" aria-label={item.title}>
<a href={item.url}> <a href={item.url}>
<item.icon /> <item.icon />
<span>{item.title}</span> <span>{item.title}</span>

View file

@ -1,7 +1,8 @@
"use client"; "use client";
import { BadgeCheck, ChevronsUpDown, LogOut, Settings } from "lucide-react"; import { BadgeCheck, ChevronsUpDown, LogOut, Settings, User } from "lucide-react";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { memo, useCallback } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { import {
DropdownMenu, DropdownMenu,
@ -13,90 +14,115 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { import {
SidebarGroup,
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
export function NavUser({ interface UserData {
user, name: string;
}: { email: string;
user: { avatar: string;
name: string; }
email: string;
avatar: string; // Memoized NavUser component for better performance
}; export const NavUser = memo(function NavUser({ user }: { user: UserData }) {
}) {
const { isMobile } = useSidebar(); const { isMobile } = useSidebar();
const router = useRouter(); const router = useRouter();
const { search_space_id } = useParams(); const { search_space_id } = useParams();
const handleLogout = () => { // Memoized logout handler
const handleLogout = useCallback(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
localStorage.removeItem("surfsense_bearer_token"); localStorage.removeItem("surfsense_bearer_token");
router.push("/"); router.push("/");
} }
}; }, [router]);
// Get user initials for avatar fallback
const userInitials = user.name
.split(" ")
.map((n) => n[0])
.join("")
.toUpperCase()
.slice(0, 2);
return ( return (
<SidebarMenu> <SidebarGroup className="mt-auto">
<SidebarMenuItem> <SidebarMenu>
<DropdownMenu> <SidebarMenuItem>
<DropdownMenuTrigger asChild> <DropdownMenu>
<SidebarMenuButton <DropdownMenuTrigger asChild>
size="lg" <SidebarMenuButton
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" size="lg"
> className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
<Avatar className="h-8 w-8 rounded-lg"> aria-label="User menu"
<AvatarImage src={user.avatar} alt={user.name} /> >
<AvatarFallback className="rounded-lg">CN</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg"> <Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} /> <AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">CN</AvatarFallback> <AvatarFallback className="rounded-lg">
{userInitials || <User className="h-4 w-4" />}
</AvatarFallback>
</Avatar> </Avatar>
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span> <span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs">{user.email}</span> <span className="truncate text-xs text-muted-foreground">{user.email}</span>
</div> </div>
</div> <ChevronsUpDown className="ml-auto size-4" />
</DropdownMenuLabel> </SidebarMenuButton>
<DropdownMenuSeparator /> </DropdownMenuTrigger>
<DropdownMenuGroup> <DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side={isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 rounded-lg">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback className="rounded-lg">
{userInitials || <User className="h-4 w-4" />}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.name}</span>
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => router.push(`/dashboard/${search_space_id}/api-key`)}
aria-label="Manage API key"
>
<BadgeCheck className="h-4 w-4" />
API Key
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => router.push(`/dashboard/${search_space_id}/api-key`)} onClick={() => router.push(`/settings`)}
aria-label="Go to settings"
> >
<BadgeCheck /> <Settings className="h-4 w-4" />
API Key Settings
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuGroup> <DropdownMenuItem
<DropdownMenuSeparator /> onClick={handleLogout}
<DropdownMenuItem onClick={() => router.push(`/settings`)}> aria-label="Sign out"
<Settings /> className="text-destructive focus:text-destructive"
Settings >
</DropdownMenuItem> <LogOut className="h-4 w-4" />
<DropdownMenuItem onClick={handleLogout}> Sign out
<LogOut /> </DropdownMenuItem>
Log out </DropdownMenuContent>
</DropdownMenuItem> </DropdownMenu>
</DropdownMenuContent> </SidebarMenuItem>
</DropdownMenu> </SidebarMenu>
</SidebarMenuItem> </SidebarGroup>
</SidebarMenu>
); );
} });