mirror of
https://github.com/MODSetter/SurfSense.git
synced 2025-09-01 18:19:08 +00:00
Improvements for sidebar
This commit is contained in:
parent
d98dfd40b5
commit
8bc369cd94
10 changed files with 560 additions and 373 deletions
|
@ -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."
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
Loading…
Add table
Reference in a new issue