From 8bc369cd948b22b8c7d2b8a2cd4aa2bfa8641bdf Mon Sep 17 00:00:00 2001 From: Utkarsh-Patel-13 Date: Sat, 2 Aug 2025 21:20:36 -0700 Subject: [PATCH 1/8] Improvements for sidebar --- surfsense_backend/app/routes/chats_routes.py | 26 +- surfsense_backend/app/schemas/__init__.py | 10 +- surfsense_backend/app/schemas/chats.py | 10 + .../[search_space_id]/chats/chats-client.tsx | 56 +-- .../components/sidebar/AppSidebarProvider.tsx | 380 ++++++++++-------- .../components/sidebar/app-sidebar.tsx | 47 ++- surfsense_web/components/sidebar/nav-main.tsx | 45 ++- .../components/sidebar/nav-projects.tsx | 187 ++++++--- .../components/sidebar/nav-secondary.tsx | 20 +- surfsense_web/components/sidebar/nav-user.tsx | 152 ++++--- 10 files changed, 560 insertions(+), 373 deletions(-) diff --git a/surfsense_backend/app/routes/chats_routes.py b/surfsense_backend/app/routes/chats_routes.py index e01b85795..21af85c0f 100644 --- a/surfsense_backend/app/routes/chats_routes.py +++ b/surfsense_backend/app/routes/chats_routes.py @@ -6,7 +6,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select 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.users import current_active_user from app.utils.check_ownership import check_ownership @@ -112,7 +118,7 @@ async def create_chat( ) from None -@router.get("/chats/", response_model=list[ChatRead]) +@router.get("/chats/", response_model=list[ChatReadWithoutMessages]) async def read_chats( skip: int = 0, limit: int = 100, @@ -121,14 +127,26 @@ async def read_chats( user: User = Depends(current_active_user), ): 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 if search_space_id is not None: query = query.filter(Chat.search_space_id == search_space_id) result = await session.execute(query.offset(skip).limit(limit)) - return result.scalars().all() + return result.all() except OperationalError: raise HTTPException( status_code=503, detail="Database operation failed. Please try again later." diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py index e38d534af..c038d9cfd 100644 --- a/surfsense_backend/app/schemas/__init__.py +++ b/surfsense_backend/app/schemas/__init__.py @@ -1,5 +1,12 @@ 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 .documents import ( DocumentBase, @@ -37,6 +44,7 @@ __all__ = [ "ChatBase", "ChatCreate", "ChatRead", + "ChatReadWithoutMessages", "ChatUpdate", "ChunkBase", "ChunkCreate", diff --git a/surfsense_backend/app/schemas/chats.py b/surfsense_backend/app/schemas/chats.py index 1dfedef53..59c274dc5 100644 --- a/surfsense_backend/app/schemas/chats.py +++ b/surfsense_backend/app/schemas/chats.py @@ -15,6 +15,12 @@ class ChatBase(BaseModel): search_space_id: int +class ChatBaseWithoutMessages(BaseModel): + type: ChatType + title: str + search_space_id: int + + class ClientAttachment(BaseModel): name: str content_type: str @@ -50,3 +56,7 @@ class ChatUpdate(ChatBase): class ChatRead(ChatBase, IDModel, TimestampModel): model_config = ConfigDict(from_attributes=True) + + +class ChatReadWithoutMessages(ChatBaseWithoutMessages, IDModel, TimestampModel): + model_config = ConfigDict(from_attributes=True) diff --git a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx index dbad14eba..1222733d0 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/chats/chats-client.tsx @@ -19,14 +19,7 @@ import { useEffect, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Dialog, DialogContent, @@ -61,24 +54,16 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { cn } from "@/lib/utils"; interface Chat { created_at: string; id: number; type: string; title: string; - messages: ChatMessage[]; search_space_id: number; } -interface ChatMessage { - id: string; - createdAt: string; - role: string; - content: string; - parts?: any; -} - interface ChatsPageClientProps { searchSpaceId: string; } @@ -580,12 +565,12 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) animate="animate" exit="exit" transition={{ duration: 0.2, delay: index * 0.05 }} - className={`overflow-hidden hover:shadow-md transition-shadow - ${ - selectionMode && selectedChats.includes(chat.id) - ? "ring-2 ring-primary ring-offset-2" - : "" - }`} + className={cn( + "overflow-hidden hover:shadow-md transition-shadow", + selectionMode && selectedChats.includes(chat.id) + ? "ring-2 ring-primary ring-offset-2" + : "" + )} onClick={(e) => { if (!selectionMode) return; // Ignore clicks coming from interactive elements @@ -672,24 +657,21 @@ export default function ChatsPageClient({ searchSpaceId }: ChatsPageClientProps) )} - -
- {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."} -
-
- -
- - {chat.messages?.length || 0} messages -
+ + {chat.type || "Unknown"} + ))} diff --git a/surfsense_web/components/sidebar/AppSidebarProvider.tsx b/surfsense_web/components/sidebar/AppSidebarProvider.tsx index 1a5920aea..daff1af33 100644 --- a/surfsense_web/components/sidebar/AppSidebarProvider.tsx +++ b/surfsense_web/components/sidebar/AppSidebarProvider.tsx @@ -1,7 +1,7 @@ "use client"; -import { Trash2 } from "lucide-react"; -import { useEffect, useState } from "react"; +import { AlertCircle, Trash2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { AppSidebar } from "@/components/sidebar/app-sidebar"; import { Button } from "@/components/ui/button"; import { @@ -12,7 +12,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { apiClient } from "@/lib/api"; // Import the API client +import { apiClient } from "@/lib/api"; interface Chat { created_at: string; @@ -50,6 +50,26 @@ interface AppSidebarProviderProps { }[]; } +// Loading skeleton component +const LoadingSkeleton = () => ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+ ))} +
+); + +// Error state component +const ErrorState = ({ error, onRetry }: { error: string; onRetry: () => void }) => ( +
+ +

{error}

+ +
+); + export function AppSidebarProvider({ searchSpaceId, navSecondary, @@ -80,66 +100,82 @@ export function AppSidebarProvider({ 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( + `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( + `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 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( - `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(); // Set up a refresh interval (every 5 minutes) @@ -147,144 +183,144 @@ export function AppSidebarProvider({ // Clean up interval on component unmount return () => clearInterval(intervalId); - }, [searchSpaceId]); + }, [fetchRecentChats]); - // Handle delete chat - const handleDeleteChat = async () => { + // Fetch search space details + useEffect(() => { + fetchSearchSpace(); + }, [fetchSearchSpace]); + + // Handle delete chat with better error handling + const handleDeleteChat = useCallback(async () => { if (!chatToDelete) return; try { setIsDeleting(true); - // Use the API client instead of direct fetch await apiClient.delete(`api/v1/chats/${chatToDelete.id}`); - // Close dialog and refresh chats - setRecentChats(recentChats.filter((chat) => chat.id !== chatToDelete.id)); + // Update local state + setRecentChats((prev) => prev.filter((chat) => chat.id !== chatToDelete.id)); } catch (error) { console.error("Error deleting chat:", error); + // You could show a toast notification here } finally { setIsDeleting(false); setShowDeleteDialog(false); setChatToDelete(null); } - }; + }, [chatToDelete]); - // Fetch search space details - useEffect(() => { - const fetchSearchSpace = async () => { - try { - // Only run on client-side - if (typeof window === "undefined") return; + // Memoized fallback chats + const fallbackChats = useMemo(() => { + if (chatError) { + return [ + { + name: "Error loading chats", + url: "#", + icon: "AlertCircle", + id: 0, + search_space_id: Number(searchSpaceId), + actions: [ + { + name: "Retry", + icon: "RefreshCw", + onClick: retryFetch, + }, + ], + }, + ]; + } - try { - // Use the API client instead of direct fetch - const data: SearchSpace = await apiClient.get( - `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); - } - } catch (error) { - console.error("Error in fetchSearchSpace:", error); - setIsLoadingSearchSpace(false); - } - }; + if (!isLoadingChats && recentChats.length === 0) { + return [ + { + name: "No recent chats", + url: "#", + icon: "MessageCircleMore", + id: 0, + search_space_id: Number(searchSpaceId), + actions: [], + }, + ]; + } - fetchSearchSpace(); - }, [searchSpaceId]); - - // 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: [], - }, - ] - : []; + return []; + }, [chatError, isLoadingChats, recentChats.length, searchSpaceId, retryFetch]); // Use fallback chats if there's an error or no chats const displayChats = recentChats.length > 0 ? recentChats : fallbackChats; - // Update the first item in navSecondary to show the search space name - const updatedNavSecondary = [...navSecondary]; - if (updatedNavSecondary.length > 0 && isClient) { - updatedNavSecondary[0] = { - ...updatedNavSecondary[0], - title: - searchSpace?.name || - (isLoadingSearchSpace - ? "Loading..." - : searchSpaceError - ? "Error loading search space" - : "Unknown Search Space"), - }; + // Memoized updated navSecondary + const updatedNavSecondary = useMemo(() => { + const updated = [...navSecondary]; + if (updated.length > 0 && isClient) { + updated[0] = { + ...updated[0], + title: + searchSpace?.name || + (isLoadingSearchSpace + ? "Loading..." + : searchSpaceError + ? "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 ; } return ( <> - + - {/* Delete Confirmation Dialog - Only render on client */} - {isClient && ( - - - - - - Delete Chat - - - Are you sure you want to delete{" "} - {chatToDelete?.name}? This action cannot be - undone. - - - - - - - - - )} + {/* Delete Confirmation Dialog */} + + + + + + Delete Chat + + + Are you sure you want to delete{" "} + {chatToDelete?.name}? This action cannot be + undone. + + + + + + + + ); } diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx index 20d6736e4..5071de4b9 100644 --- a/surfsense_web/components/sidebar/app-sidebar.tsx +++ b/surfsense_web/components/sidebar/app-sidebar.tsx @@ -17,15 +17,17 @@ import { Trash2, Undo2, } from "lucide-react"; -import { useMemo } from "react"; +import { memo, useMemo } from "react"; import { Logo } from "@/components/Logo"; import { NavMain } from "@/components/sidebar/nav-main"; import { NavProjects } from "@/components/sidebar/nav-projects"; import { NavSecondary } from "@/components/sidebar/nav-secondary"; +import { NavUser } from "@/components/sidebar/nav-user"; import { Sidebar, SidebarContent, + SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, @@ -64,7 +66,6 @@ const defaultData = { isActive: true, items: [], }, - { title: "Documents", url: "#", @@ -154,12 +155,12 @@ interface AppSidebarProps extends React.ComponentProps { navSecondary?: { title: string; url: string; - icon: string; // Changed to string (icon name) + icon: string; }[]; RecentChats?: { name: string; url: string; - icon: string; // Changed to string (icon name) + icon: string; id?: number; search_space_id?: number; actions?: { @@ -168,19 +169,26 @@ interface AppSidebarProps extends React.ComponentProps { 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, navSecondary = defaultData.navSecondary, RecentChats = defaultData.RecentChats, + user = defaultData.user, ...props }: AppSidebarProps) { // Process navMain to resolve icon names to components const processedNavMain = useMemo(() => { return navMain.map((item) => ({ ...item, - icon: iconMap[item.icon] || SquareTerminal, // Fallback to SquareTerminal if icon not found + icon: iconMap[item.icon] || SquareTerminal, })); }, [navMain]); @@ -188,7 +196,7 @@ export function AppSidebar({ const processedNavSecondary = useMemo(() => { return navSecondary.map((item) => ({ ...item, - icon: iconMap[item.icon] || Undo2, // Fallback to Undo2 if icon not found + icon: iconMap[item.icon] || Undo2, })); }, [navSecondary]); @@ -197,17 +205,17 @@ export function AppSidebar({ return ( RecentChats?.map((item) => ({ ...item, - icon: iconMap[item.icon] || MessageCircleMore, // Fallback to MessageCircleMore if icon not found + icon: iconMap[item.icon] || MessageCircleMore, })) || [] ); }, [RecentChats]); return ( - + - +
@@ -221,11 +229,22 @@ export function AppSidebar({ - + + - {processedRecentChats.length > 0 && } - + + {processedRecentChats.length > 0 && ( +
+ +
+ )}
+ + + + {/* User Profile Section */} + + ); -} +}); diff --git a/surfsense_web/components/sidebar/nav-main.tsx b/surfsense_web/components/sidebar/nav-main.tsx index 41859b628..f768c7af8 100644 --- a/surfsense_web/components/sidebar/nav-main.tsx +++ b/surfsense_web/components/sidebar/nav-main.tsx @@ -1,6 +1,7 @@ "use client"; import { ChevronRight, type LucideIcon } from "lucide-react"; +import { useMemo } from "react"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { @@ -15,46 +16,56 @@ import { SidebarMenuSubItem, } from "@/components/ui/sidebar"; -export function NavMain({ - items, -}: { - items: { +interface NavItem { + title: string; + url: string; + icon: LucideIcon; + isActive?: boolean; + items?: { title: 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 ( Platform - {items.map((item, index) => ( + {memoizedItems.map((item, index) => ( - + {item.title} + {item.items?.length ? ( <> - + - Toggle + Toggle submenu - + {item.items?.map((subItem, subIndex) => ( - + {subItem.title} diff --git a/surfsense_web/components/sidebar/nav-projects.tsx b/surfsense_web/components/sidebar/nav-projects.tsx index 1ce323c24..cd0245bb5 100644 --- a/surfsense_web/components/sidebar/nav-projects.tsx +++ b/surfsense_web/components/sidebar/nav-projects.tsx @@ -1,17 +1,27 @@ "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 { useCallback, useMemo, useState } from "react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { SidebarGroup, SidebarGroupLabel, + SidebarInput, SidebarMenu, SidebarMenuAction, SidebarMenuButton, @@ -26,6 +36,8 @@ const actionIconMap: Record = { Share, Trash2, MoreHorizontal, + Search, + RefreshCw, }; interface ChatAction { @@ -34,33 +46,57 @@ interface ChatAction { onClick: () => void; } -export function NavProjects({ - chats, -}: { - chats: { - name: string; - url: string; - icon: LucideIcon; - id?: number; - search_space_id?: number; - actions?: ChatAction[]; - }[]; -}) { +interface ChatItem { + name: string; + url: string; + icon: LucideIcon; + id?: number; + search_space_id?: number; + actions?: ChatAction[]; +} + +export function NavProjects({ chats }: { chats: ChatItem[] }) { const { isMobile } = useSidebar(); const router = useRouter(); + const [searchQuery, setSearchQuery] = useState(""); + const [isDeleting, setIsDeleting] = useState(null); const searchSpaceId = chats[0]?.search_space_id || ""; - return ( - - Recent Chats - - {chats.map((item, index) => ( - - - - {item.name} - + // 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 ( + + router.push(chat.url)} + disabled={isDeletingChat} + className={isDeletingChat ? "opacity-50" : ""} + > + + {chat.name} + + + {chat.actions && chat.actions.length > 0 && ( @@ -73,44 +109,79 @@ export function NavProjects({ side={isMobile ? "bottom" : "right"} align={isMobile ? "end" : "start"} > - {item.actions ? ( - // Use the actions provided by the item - item.actions.map((action, actionIndex) => { - const ActionIcon = actionIconMap[action.icon] || Folder; - return ( - - - {action.name} - - ); - }) - ) : ( - // Default actions if none provided - <> - - - View Chat + {chat.actions.map((action, actionIndex) => { + const ActionIcon = actionIconMap[action.icon] || Folder; + const isDeleteAction = action.name.toLowerCase().includes("delete"); + + return ( + { + if (isDeleteAction) { + handleDeleteChat(chat.id || 0, action.onClick); + } else { + action.onClick(); + } + }} + disabled={isDeletingChat} + className={isDeleteAction ? "text-destructive" : ""} + > + + {isDeletingChat && isDeleteAction ? "Deleting..." : action.name} - - - - Delete Chat - - - )} + ); + })} - - ))} - - router.push(`/dashboard/${searchSpaceId}/chats`)}> - - View All Chats - + )} + ); + }, + [isDeleting, router, isMobile, handleDeleteChat] + ); + + // Show search input if there are chats + const showSearch = chats.length > 0; + + return ( + + Recent Chats + + {/* Search Input */} + {showSearch && ( +
+ setSearchQuery(e.target.value)} + className="h-8" + /> +
+ )} + + + {/* Chat Items */} + {filteredChats.length > 0 ? ( + filteredChats.map((chat) => ) + ) : ( + /* No results state */ + + + + {searchQuery ? "No chats found" : "No recent chats"} + + + )} + + {/* View All Chats */} + {chats.length > 0 && ( + + router.push(`/dashboard/${searchSpaceId}/chats`)}> + + View All Chats + + + )}
); diff --git a/surfsense_web/components/sidebar/nav-secondary.tsx b/surfsense_web/components/sidebar/nav-secondary.tsx index f292ba75b..ec2defa0e 100644 --- a/surfsense_web/components/sidebar/nav-secondary.tsx +++ b/surfsense_web/components/sidebar/nav-secondary.tsx @@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react"; import type * as React from "react"; +import { useMemo } from "react"; import { SidebarGroup, @@ -11,23 +12,28 @@ import { SidebarMenuItem, } from "@/components/ui/sidebar"; +interface NavSecondaryItem { + title: string; + url: string; + icon: LucideIcon; +} + export function NavSecondary({ items, ...props }: { - items: { - title: string; - url: string; - icon: LucideIcon; - }[]; + items: NavSecondaryItem[]; } & React.ComponentPropsWithoutRef) { + // Memoize items to prevent unnecessary re-renders + const memoizedItems = useMemo(() => items, [items]); + return ( SearchSpace - {items.map((item, index) => ( + {memoizedItems.map((item, index) => ( - + {item.title} diff --git a/surfsense_web/components/sidebar/nav-user.tsx b/surfsense_web/components/sidebar/nav-user.tsx index 62229cb74..664052dae 100644 --- a/surfsense_web/components/sidebar/nav-user.tsx +++ b/surfsense_web/components/sidebar/nav-user.tsx @@ -1,7 +1,8 @@ "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 { memo, useCallback } from "react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { DropdownMenu, @@ -13,90 +14,115 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { + SidebarGroup, SidebarMenu, SidebarMenuButton, SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar"; -export function NavUser({ - user, -}: { - user: { - name: string; - email: string; - avatar: string; - }; -}) { +interface UserData { + 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 router = useRouter(); const { search_space_id } = useParams(); - const handleLogout = () => { + // Memoized logout handler + const handleLogout = useCallback(() => { if (typeof window !== "undefined") { localStorage.removeItem("surfsense_bearer_token"); router.push("/"); } - }; + }, [router]); + + // Get user initials for avatar fallback + const userInitials = user.name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2); + return ( - - - - - - - - CN - -
- {user.name} - {user.email} -
- -
-
- - -
+ + + + + + - CN + + {userInitials || } +
{user.name} - {user.email} + {user.email}
-
-
- - + +
+ + + +
+ + + + {userInitials || } + + +
+ {user.name} + {user.email} +
+
+
+ + + router.push(`/dashboard/${search_space_id}/api-key`)} + aria-label="Manage API key" + > + + API Key + + + router.push(`/dashboard/${search_space_id}/api-key`)} + onClick={() => router.push(`/settings`)} + aria-label="Go to settings" > - - API Key + + Settings - - - router.push(`/settings`)}> - - Settings - - - - Log out - -
- -
-
+ + + Sign out + + + +
+
+
); -} +}); From a7c1fd49be44d11d12adb9dfbbde600298d1fc5c Mon Sep 17 00:00:00 2001 From: Utkarsh-Patel-13 Date: Sat, 2 Aug 2025 21:36:04 -0700 Subject: [PATCH 2/8] sticky top navigation & user data in user nav --- .../[search_space_id]/client-layout.tsx | 4 +- .../components/sidebar/app-sidebar.tsx | 3 +- surfsense_web/components/sidebar/nav-user.tsx | 74 +++++++++++++++---- 3 files changed, 64 insertions(+), 17 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index 97c7894c0..d785bdf4e 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -26,8 +26,8 @@ export function DashboardClientLayout({ navMain={navMain} /> -
-
+
+
diff --git a/surfsense_web/components/sidebar/app-sidebar.tsx b/surfsense_web/components/sidebar/app-sidebar.tsx index 5071de4b9..70eb3fb83 100644 --- a/surfsense_web/components/sidebar/app-sidebar.tsx +++ b/surfsense_web/components/sidebar/app-sidebar.tsx @@ -181,7 +181,6 @@ export const AppSidebar = memo(function AppSidebar({ navMain = defaultData.navMain, navSecondary = defaultData.navSecondary, RecentChats = defaultData.RecentChats, - user = defaultData.user, ...props }: AppSidebarProps) { // Process navMain to resolve icon names to components @@ -243,7 +242,7 @@ export const AppSidebar = memo(function AppSidebar({ {/* User Profile Section */} - + ); diff --git a/surfsense_web/components/sidebar/nav-user.tsx b/surfsense_web/components/sidebar/nav-user.tsx index 664052dae..114f42aef 100644 --- a/surfsense_web/components/sidebar/nav-user.tsx +++ b/surfsense_web/components/sidebar/nav-user.tsx @@ -1,8 +1,8 @@ "use client"; -import { BadgeCheck, ChevronsUpDown, LogOut, Settings, User } from "lucide-react"; +import { BadgeCheck, ChevronsUpDown, LogOut, Settings, User as UserIcon } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; -import { memo, useCallback } from "react"; +import { memo, useCallback, useEffect, useState } from "react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { DropdownMenu, @@ -20,6 +20,15 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar"; +import { apiClient } from "@/lib/api"; + +interface User { + id: string; + email: string; + is_active: boolean; + is_superuser: boolean; + is_verified: boolean; +} interface UserData { name: string; @@ -28,11 +37,50 @@ interface UserData { } // Memoized NavUser component for better performance -export const NavUser = memo(function NavUser({ user }: { user: UserData }) { +export const NavUser = memo(function NavUser() { const { isMobile } = useSidebar(); const router = useRouter(); const { search_space_id } = useParams(); + // User state management + const [user, setUser] = useState(null); + const [isLoadingUser, setIsLoadingUser] = useState(true); + const [userError, setUserError] = useState(null); + + // Fetch user details + useEffect(() => { + const fetchUser = async () => { + try { + if (typeof window === "undefined") return; + + try { + const userData = await apiClient.get("users/me"); + setUser(userData); + setUserError(null); + } catch (error) { + console.error("Error fetching user:", error); + setUserError(error instanceof Error ? error.message : "Unknown error occurred"); + } finally { + setIsLoadingUser(false); + } + } catch (error) { + console.error("Error in fetchUser:", error); + setIsLoadingUser(false); + } + }; + + fetchUser(); + }, []); + + // Create user object for display + const userData: UserData = { + name: user?.email ? user.email.split("@")[0] : "User", + email: + user?.email || + (isLoadingUser ? "Loading..." : userError ? "Error loading user" : "Unknown User"), + avatar: "/icon-128.png", // Default avatar + }; + // Memoized logout handler const handleLogout = useCallback(() => { if (typeof window !== "undefined") { @@ -42,9 +90,9 @@ export const NavUser = memo(function NavUser({ user }: { user: UserData }) { }, [router]); // Get user initials for avatar fallback - const userInitials = user.name + const userInitials = userData.name .split(" ") - .map((n) => n[0]) + .map((n: string) => n[0]) .join("") .toUpperCase() .slice(0, 2); @@ -61,14 +109,14 @@ export const NavUser = memo(function NavUser({ user }: { user: UserData }) { aria-label="User menu" > - + - {userInitials || } + {userInitials || }
- {user.name} - {user.email} + {userData.name} + {userData.email}
@@ -82,14 +130,14 @@ export const NavUser = memo(function NavUser({ user }: { user: UserData }) {
- + - {userInitials || } + {userInitials || }
- {user.name} - {user.email} + {userData.name} + {userData.email}
From 49a5d048bf90e0461816d0e80bb2e2087925bab5 Mon Sep 17 00:00:00 2001 From: Utkarsh-Patel-13 Date: Sat, 2 Aug 2025 22:19:33 -0700 Subject: [PATCH 3/8] Improved upload page --- .../documents/upload/page.tsx | 484 +++++++++--------- 1 file changed, 239 insertions(+), 245 deletions(-) diff --git a/surfsense_web/app/dashboard/[search_space_id]/documents/upload/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/documents/upload/page.tsx index 8631c7efd..efea74b49 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/documents/upload/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/documents/upload/page.tsx @@ -1,12 +1,17 @@ "use client"; import { AnimatePresence, motion } from "framer-motion"; -import { Calendar, CheckCircle2, FileType, Tag, Upload, X } from "lucide-react"; +import { CheckCircle2, FileType, Info, Tag, Upload, X } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useState } from "react"; import { useDropzone } from "react-dropzone"; import { toast } from "sonner"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { Separator } from "@/components/ui/separator"; // Grid pattern component inspired by Aceternity UI function GridPattern() { @@ -34,14 +39,13 @@ function GridPattern() { } export default function FileUploader() { - // Use the useParams hook to get the params const params = useParams(); const search_space_id = params.search_space_id as string; const [files, setFiles] = useState([]); const [isUploading, setIsUploading] = useState(false); + const [uploadProgress, setUploadProgress] = useState(0); const router = useRouter(); - const fileInputRef = useRef(null); // Audio files are always supported (using whisper) const audioFileTypes = { @@ -204,7 +208,6 @@ export default function FileUploader() { }; const acceptedFileTypes = getAcceptedFileTypes(); - const supportedExtensions = Array.from(new Set(Object.values(acceptedFileTypes).flat())).sort(); const onDrop = useCallback((acceptedFiles: File[]) => { @@ -215,12 +218,10 @@ export default function FileUploader() { onDrop, accept: acceptedFileTypes, maxSize: 50 * 1024 * 1024, // 50MB + noClick: false, // Ensure clicking is enabled + noKeyboard: false, // Ensure keyboard navigation is enabled }); - const handleClick = () => { - fileInputRef.current?.click(); - }; - const removeFile = (index: number) => { setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index)); }; @@ -235,6 +236,7 @@ export default function FileUploader() { const handleUpload = async () => { setIsUploading(true); + setUploadProgress(0); const formData = new FormData(); files.forEach((file) => { @@ -244,12 +246,16 @@ export default function FileUploader() { formData.append("search_space_id", search_space_id); try { - // toast("File Upload", { - // description: "Files Uploading Initiated", - // }) + // Simulate progress for better UX + const progressInterval = setInterval(() => { + setUploadProgress((prev) => { + if (prev >= 90) return prev; + return prev + Math.random() * 10; + }); + }, 200); const response = await fetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL!}/api/v1/documents/fileupload`, + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/documents/fileupload`, { method: "POST", headers: { @@ -259,6 +265,9 @@ export default function FileUploader() { } ); + clearInterval(progressInterval); + setUploadProgress(100); + if (!response.ok) { throw new Error("Upload failed"); } @@ -272,31 +281,15 @@ export default function FileUploader() { router.push(`/dashboard/${search_space_id}/documents`); } catch (error: any) { setIsUploading(false); + setUploadProgress(0); toast("Upload Error", { description: `Error uploading files: ${error.message}`, }); } }; - const mainVariant = { - initial: { - x: 0, - y: 0, - }, - animate: { - x: 20, - y: -20, - opacity: 0.9, - }, - }; - - const secondaryVariant = { - initial: { - opacity: 0, - }, - animate: { - opacity: 1, - }, + const getTotalFileSize = () => { + return files.reduce((total, file) => total + file.size, 0); }; const containerVariants = { @@ -326,251 +319,252 @@ export default function FileUploader() { return (
- - + {/* Header Card */} + + + + + + Upload Documents + + + Upload your files to make them searchable and accessible through AI-powered + conversations. + + + + + + + Maximum file size: 50MB per file. Supported formats vary based on your ETL service + configuration. + + + + + + + {/* Upload Area Card */} + + {/* Grid background pattern */} -
+
-
- {/* Dropzone area */} -
- - -

- Upload files -

-

- Drag or drop your files here or click to upload -

- -
- {!files.length && ( - - {isDragActive ? ( - - Drop it - - - ) : ( - - )} - - )} - - {!files.length && ( - - )} -
-
-
- - - {/* File list section */} - - {files.length > 0 && ( - +
-
-

Selected Files ({files.length})

-
+
+ + +
-
- - {files.map((file, index) => ( - -
- - {file.name} - + {/* File List Card */} + + {files.length > 0 && ( + + + +
+
+ Selected Files ({files.length}) + + Total size: {formatFileSize(getTotalFileSize())} + +
+ +
+
+ +
+ + {files.map((file, index) => ( + +
+
+ +
+
+

{file.name}

+
+ + {formatFileSize(file.size)} + + + {file.type || "Unknown type"} + +
+
+
- - {formatFileSize(file.size)} -
-
- -
- - - {file.type || "Unknown type"} - - - - - modified {new Date(file.lastModified).toLocaleDateString()} - -
-
- ))} -
-
- - - - -
- )} -
+ ))} + +
- {/* File type information */} - -
-
- -

Supported file types:

-
+ {isUploading && ( + + +
+
+ Uploading files... + {Math.round(uploadProgress)}% +
+ +
+
+ )} + + + + + + + + )} + + + {/* Supported File Types Card */} + + + + + + Supported File Types + + + These file types are supported based on your current ETL service configuration. + + +
{supportedExtensions.map((ext) => ( - + {ext} - + ))}
-
-
+ + From c8845027379e95db8106e9f4115de4da4dcd4a8a Mon Sep 17 00:00:00 2001 From: Utkarsh-Patel-13 Date: Sat, 2 Aug 2025 22:46:15 -0700 Subject: [PATCH 4/8] Added breadcrumbs --- .../[search_space_id]/client-layout.tsx | 8 +- .../dashboard/[search_space_id]/layout.tsx | 1 - .../app/dashboard/[search_space_id]/page.tsx | 15 ++ .../components/dashboard-breadcrumb.tsx | 169 ++++++++++++++++++ surfsense_web/components/ui/breadcrumb.tsx | 100 +++++++++++ 5 files changed, 290 insertions(+), 3 deletions(-) create mode 100644 surfsense_web/app/dashboard/[search_space_id]/page.tsx create mode 100644 surfsense_web/components/dashboard-breadcrumb.tsx create mode 100644 surfsense_web/components/ui/breadcrumb.tsx diff --git a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx index d785bdf4e..8b28c8bc6 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/client-layout.tsx @@ -1,6 +1,7 @@ "use client"; import type React from "react"; +import { DashboardBreadcrumb } from "@/components/dashboard-breadcrumb"; import { AppSidebarProvider } from "@/components/sidebar/AppSidebarProvider"; import { ThemeTogglerComponent } from "@/components/theme/theme-toggle"; import { Separator } from "@/components/ui/separator"; @@ -28,8 +29,11 @@ export function DashboardClientLayout({
- - +
+ + + +
diff --git a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx index 9dc18621b..d0e04fe68 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/layout.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/layout.tsx @@ -31,7 +31,6 @@ export default function DashboardLayout({ title: "Researcher", url: `/dashboard/${search_space_id}/researcher`, icon: "SquareTerminal", - isActive: true, items: [], }, diff --git a/surfsense_web/app/dashboard/[search_space_id]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/page.tsx new file mode 100644 index 000000000..cd697db21 --- /dev/null +++ b/surfsense_web/app/dashboard/[search_space_id]/page.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { useParams, useRouter } from "next/navigation"; +import { useEffect } from "react"; + +export default function SearchSpaceDashboardPage() { + const router = useRouter(); + const { search_space_id } = useParams(); + + useEffect(() => { + router.push(`/dashboard/${search_space_id}/chats`); + }, []); + + return <>; +} diff --git a/surfsense_web/components/dashboard-breadcrumb.tsx b/surfsense_web/components/dashboard-breadcrumb.tsx new file mode 100644 index 000000000..1bc7abd2a --- /dev/null +++ b/surfsense_web/components/dashboard-breadcrumb.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import React from "react"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; + +interface BreadcrumbItemInterface { + label: string; + href?: string; +} + +export function DashboardBreadcrumb() { + const pathname = usePathname(); + + // Parse the pathname to create breadcrumb items + const generateBreadcrumbs = (path: string): BreadcrumbItemInterface[] => { + const segments = path.split("/").filter(Boolean); + const breadcrumbs: BreadcrumbItemInterface[] = []; + + // Always start with Dashboard + breadcrumbs.push({ label: "Dashboard", href: "/dashboard" }); + + // Handle search space + if (segments[0] === "dashboard" && segments[1]) { + breadcrumbs.push({ label: `Search Space ${segments[1]}`, href: `/dashboard/${segments[1]}` }); + + // Handle specific sections + if (segments[2]) { + const section = segments[2]; + let sectionLabel = section.charAt(0).toUpperCase() + section.slice(1); + + // Map section names to more readable labels + const sectionLabels: Record = { + researcher: "Researcher", + documents: "Documents", + connectors: "Connectors", + podcasts: "Podcasts", + logs: "Logs", + chats: "Chats", + }; + + sectionLabel = sectionLabels[section] || sectionLabel; + + // Handle sub-sections + if (segments[3]) { + const subSection = segments[3]; + let subSectionLabel = subSection.charAt(0).toUpperCase() + subSection.slice(1); + + // Handle documents sub-sections + if (section === "documents") { + const documentLabels: Record = { + upload: "Upload Documents", + youtube: "Add YouTube Videos", + webpage: "Add Webpages", + }; + + const documentLabel = documentLabels[subSection] || subSectionLabel; + breadcrumbs.push({ + label: "Documents", + href: `/dashboard/${segments[1]}/documents`, + }); + breadcrumbs.push({ label: documentLabel }); + return breadcrumbs; + } + + // Handle connector sub-sections + if (section === "connectors") { + // Handle specific connector types + if (subSection === "add" && segments[4]) { + const connectorType = segments[4]; + const connectorLabels: Record = { + "github-connector": "GitHub", + "jira-connector": "Jira", + "confluence-connector": "Confluence", + "discord-connector": "Discord", + "linear-connector": "Linear", + "clickup-connector": "ClickUp", + "slack-connector": "Slack", + "notion-connector": "Notion", + "tavily-api": "Tavily API", + "serper-api": "Serper API", + "linkup-api": "LinkUp API", + }; + + const connectorLabel = connectorLabels[connectorType] || connectorType; + breadcrumbs.push({ + label: "Connectors", + href: `/dashboard/${segments[1]}/connectors`, + }); + breadcrumbs.push({ + label: "Add Connector", + href: `/dashboard/${segments[1]}/connectors/add`, + }); + breadcrumbs.push({ label: connectorLabel }); + return breadcrumbs; + } + + const connectorLabels: Record = { + add: "Add Connector", + manage: "Manage Connectors", + }; + + const connectorLabel = connectorLabels[subSection] || subSectionLabel; + breadcrumbs.push({ + label: "Connectors", + href: `/dashboard/${segments[1]}/connectors`, + }); + breadcrumbs.push({ label: connectorLabel }); + return breadcrumbs; + } + + // Handle other sub-sections + const subSectionLabels: Record = { + upload: "Upload Documents", + youtube: "Add YouTube Videos", + webpage: "Add Webpages", + add: "Add Connector", + edit: "Edit Connector", + manage: "Manage", + }; + + subSectionLabel = subSectionLabels[subSection] || subSectionLabel; + + breadcrumbs.push({ + label: sectionLabel, + href: `/dashboard/${segments[1]}/${section}`, + }); + breadcrumbs.push({ label: subSectionLabel }); + } else { + breadcrumbs.push({ label: sectionLabel }); + } + } + } + + return breadcrumbs; + }; + + const breadcrumbs = generateBreadcrumbs(pathname); + + if (breadcrumbs.length <= 1) { + return null; // Don't show breadcrumbs for root dashboard + } + + return ( + + + {breadcrumbs.map((item, index) => ( + + + {index === breadcrumbs.length - 1 ? ( + {item.label} + ) : ( + {item.label} + )} + + {index < breadcrumbs.length - 1 && } + + ))} + + + ); +} diff --git a/surfsense_web/components/ui/breadcrumb.tsx b/surfsense_web/components/ui/breadcrumb.tsx new file mode 100644 index 000000000..e2451573f --- /dev/null +++ b/surfsense_web/components/ui/breadcrumb.tsx @@ -0,0 +1,100 @@ +import { Slot } from "@radix-ui/react-slot"; +import { ChevronRight, MoreHorizontal } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { + return