Spaces:
Running
Running
| "use client"; | |
| import { useState, useEffect } from "react"; | |
| import { useRouter, usePathname } from "next/navigation"; | |
| import { MessageSquare, PlusCircle, Trash2, ServerIcon, Settings, Sparkles, ChevronsUpDown, Copy, Pencil, Github, Key } from "lucide-react"; | |
| import { | |
| Sidebar, | |
| SidebarContent, | |
| SidebarFooter, | |
| SidebarGroup, | |
| SidebarGroupContent, | |
| SidebarGroupLabel, | |
| SidebarHeader, | |
| SidebarMenu, | |
| SidebarMenuButton, | |
| SidebarMenuItem, | |
| SidebarMenuBadge, | |
| useSidebar | |
| } from "@/components/ui/sidebar"; | |
| import { Separator } from "@/components/ui/separator"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { toast } from "sonner"; | |
| import Image from "next/image"; | |
| import { MCPServerManager } from "./mcp-server-manager"; | |
| import { ApiKeyManager } from "./api-key-manager"; | |
| import { ThemeToggle } from "./theme-toggle"; | |
| import { getUserId, updateUserId } from "@/lib/user-id"; | |
| import { useChats } from "@/lib/hooks/use-chats"; | |
| import { cn } from "@/lib/utils"; | |
| import Link from "next/link"; | |
| import { | |
| DropdownMenu, | |
| DropdownMenuContent, | |
| DropdownMenuGroup, | |
| DropdownMenuItem, | |
| DropdownMenuLabel, | |
| DropdownMenuSeparator, | |
| DropdownMenuTrigger, | |
| } from "@/components/ui/dropdown-menu"; | |
| import { Avatar, AvatarFallback } from "@/components/ui/avatar"; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogDescription, | |
| DialogFooter, | |
| DialogHeader, | |
| DialogTitle, | |
| } from "@/components/ui/dialog"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Label } from "@/components/ui/label"; | |
| import { useMCP } from "@/lib/context/mcp-context"; | |
| import { Skeleton } from "@/components/ui/skeleton"; | |
| import { AnimatePresence, motion } from "motion/react"; | |
| export function ChatSidebar() { | |
| const router = useRouter(); | |
| const pathname = usePathname(); | |
| const [userId, setUserId] = useState<string>(''); | |
| const [mcpSettingsOpen, setMcpSettingsOpen] = useState(false); | |
| const [apiKeySettingsOpen, setApiKeySettingsOpen] = useState(false); | |
| const { state } = useSidebar(); | |
| const isCollapsed = state === "collapsed"; | |
| const [editUserIdOpen, setEditUserIdOpen] = useState(false); | |
| const [newUserId, setNewUserId] = useState(''); | |
| // Get MCP server data from context | |
| const { mcpServers, setMcpServers, selectedMcpServers, setSelectedMcpServers } = useMCP(); | |
| // Initialize userId | |
| useEffect(() => { | |
| setUserId(getUserId()); | |
| }, []); | |
| // Use TanStack Query to fetch chats | |
| const { chats, isLoading, deleteChat, refreshChats } = useChats(userId); | |
| // Start a new chat | |
| const handleNewChat = () => { | |
| router.push('/'); | |
| }; | |
| // Delete a chat | |
| const handleDeleteChat = async (chatId: string, e: React.MouseEvent) => { | |
| e.stopPropagation(); | |
| e.preventDefault(); | |
| deleteChat(chatId); | |
| // If we're currently on the deleted chat's page, navigate to home | |
| if (pathname === `/chat/${chatId}`) { | |
| router.push('/'); | |
| } | |
| }; | |
| // Get active MCP servers status | |
| const activeServersCount = selectedMcpServers.length; | |
| // Handle user ID update | |
| const handleUpdateUserId = () => { | |
| if (!newUserId.trim()) { | |
| toast.error("User ID cannot be empty"); | |
| return; | |
| } | |
| updateUserId(newUserId.trim()); | |
| setUserId(newUserId.trim()); | |
| setEditUserIdOpen(false); | |
| toast.success("User ID updated successfully"); | |
| // Refresh the page to reload chats with new user ID | |
| window.location.reload(); | |
| }; | |
| // Show loading state if user ID is not yet initialized | |
| if (!userId) { | |
| return null; // Or a loading spinner | |
| } | |
| // Create chat loading skeletons | |
| const renderChatSkeletons = () => { | |
| return Array(3).fill(0).map((_, index) => ( | |
| <SidebarMenuItem key={`skeleton-${index}`}> | |
| <div className={`flex items-center gap-2 px-3 py-2 ${isCollapsed ? "justify-center" : ""}`}> | |
| <Skeleton className="h-4 w-4 rounded-full" /> | |
| {!isCollapsed && ( | |
| <> | |
| <Skeleton className="h-4 w-full max-w-[180px]" /> | |
| <Skeleton className="h-5 w-5 ml-auto rounded-md flex-shrink-0" /> | |
| </> | |
| )} | |
| </div> | |
| </SidebarMenuItem> | |
| )); | |
| }; | |
| return ( | |
| <Sidebar className="shadow-sm bg-background/80 dark:bg-background/40 backdrop-blur-md" collapsible="icon"> | |
| <SidebarHeader className="p-4 border-b border-border/40"> | |
| <div className="flex items-center justify-start"> | |
| <div className={`flex items-center gap-2 ${isCollapsed ? "justify-center w-full" : ""}`}> | |
| <div className={`relative rounded-full bg-primary/70 flex items-center justify-center ${isCollapsed ? "size-5 p-3" : "size-6"}`}> | |
| <Image src="/scira.png" alt="Scira Logo" width={24} height={24} className="absolute transform scale-75" unoptimized quality={100} /> | |
| </div> | |
| {!isCollapsed && ( | |
| <div className="font-semibold text-lg text-foreground/90">MCP</div> | |
| )} | |
| </div> | |
| </div> | |
| </SidebarHeader> | |
| <SidebarContent className="flex flex-col h-[calc(100vh-8rem)]"> | |
| <SidebarGroup className="flex-1 min-h-0"> | |
| <SidebarGroupLabel className={cn( | |
| "px-4 text-xs font-medium text-muted-foreground/80 uppercase tracking-wider", | |
| isCollapsed ? "sr-only" : "" | |
| )}> | |
| Chats | |
| </SidebarGroupLabel> | |
| <SidebarGroupContent className={cn( | |
| "overflow-y-auto pt-1", | |
| isCollapsed ? "overflow-x-hidden" : "" | |
| )}> | |
| <SidebarMenu> | |
| {isLoading ? ( | |
| renderChatSkeletons() | |
| ) : chats.length === 0 ? ( | |
| <div className={`flex items-center justify-center py-3 ${isCollapsed ? "" : "px-4"}`}> | |
| {isCollapsed ? ( | |
| <div className="flex h-6 w-6 items-center justify-center rounded-md border border-border/50 bg-background/50"> | |
| <MessageSquare className="h-3 w-3 text-muted-foreground" /> | |
| </div> | |
| ) : ( | |
| <div className="flex items-center gap-3 w-full px-3 py-2 rounded-md border border-dashed border-border/50 bg-background/50"> | |
| <MessageSquare className="h-4 w-4 text-muted-foreground" /> | |
| <span className="text-xs text-muted-foreground font-normal">No conversations yet</span> | |
| </div> | |
| )} | |
| </div> | |
| ) : ( | |
| <AnimatePresence initial={false}> | |
| {chats.map((chat) => ( | |
| <motion.div | |
| key={chat.id} | |
| initial={{ opacity: 0, height: 0, y: -10 }} | |
| animate={{ opacity: 1, height: "auto", y: 0 }} | |
| exit={{ opacity: 0, height: 0 }} | |
| transition={{ duration: 0.2 }} | |
| > | |
| <SidebarMenuItem> | |
| <SidebarMenuButton | |
| asChild | |
| tooltip={isCollapsed ? chat.title : undefined} | |
| data-active={pathname === `/chat/${chat.id}`} | |
| className={cn( | |
| "transition-all hover:bg-primary/10 active:bg-primary/15", | |
| pathname === `/chat/${chat.id}` ? "bg-secondary/60 hover:bg-secondary/60" : "" | |
| )} | |
| > | |
| <Link | |
| href={`/chat/${chat.id}`} | |
| className="flex items-center justify-between w-full gap-1" | |
| > | |
| <div className="flex items-center min-w-0 overflow-hidden flex-1 pr-2"> | |
| <MessageSquare className={cn( | |
| "h-4 w-4 flex-shrink-0", | |
| pathname === `/chat/${chat.id}` ? "text-foreground" : "text-muted-foreground" | |
| )} /> | |
| {!isCollapsed && ( | |
| <span className={cn( | |
| "ml-2 truncate text-sm", | |
| pathname === `/chat/${chat.id}` ? "text-foreground font-medium" : "text-foreground/80" | |
| )} title={chat.title}> | |
| {chat.title.length > 18 ? `${chat.title.slice(0, 18)}...` : chat.title} | |
| </span> | |
| )} | |
| </div> | |
| {!isCollapsed && ( | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="h-6 w-6 text-muted-foreground hover:text-foreground flex-shrink-0" | |
| onClick={(e) => handleDeleteChat(chat.id, e)} | |
| title="Delete chat" | |
| > | |
| <Trash2 className="h-3.5 w-3.5" /> | |
| </Button> | |
| )} | |
| </Link> | |
| </SidebarMenuButton> | |
| </SidebarMenuItem> | |
| </motion.div> | |
| ))} | |
| </AnimatePresence> | |
| )} | |
| </SidebarMenu> | |
| </SidebarGroupContent> | |
| </SidebarGroup> | |
| <div className="relative my-0"> | |
| <div className="absolute inset-x-0"> | |
| <Separator className="w-full h-px bg-border/40" /> | |
| </div> | |
| </div> | |
| <SidebarGroup className="flex-shrink-0 invisible"> | |
| <SidebarGroupLabel className={cn( | |
| "px-2 pt-0 text-xs font-medium text-muted-foreground/80 uppercase tracking-wider", | |
| isCollapsed ? "sr-only" : "" | |
| )}> | |
| MCP Servers | |
| </SidebarGroupLabel> | |
| <SidebarGroupContent> | |
| <SidebarMenu> | |
| <SidebarMenuItem> | |
| <SidebarMenuButton | |
| onClick={() => setMcpSettingsOpen(true)} | |
| className={cn( | |
| "w-full flex items-center gap-2 transition-all", | |
| "hover:bg-secondary/50 active:bg-secondary/70" | |
| )} | |
| tooltip={isCollapsed ? "MCP Servers" : undefined} | |
| > | |
| <ServerIcon className={cn( | |
| "h-4 w-4 flex-shrink-0", | |
| activeServersCount > 0 ? "text-primary" : "text-muted-foreground" | |
| )} /> | |
| {!isCollapsed && ( | |
| <span className="flex-grow text-sm text-foreground/80">MCP Servers</span> | |
| )} | |
| {activeServersCount > 0 && !isCollapsed ? ( | |
| <Badge | |
| variant="secondary" | |
| className="ml-auto text-[10px] px-1.5 py-0 h-5 bg-secondary/80" | |
| > | |
| {activeServersCount} | |
| </Badge> | |
| ) : activeServersCount > 0 && isCollapsed ? ( | |
| <SidebarMenuBadge className="bg-secondary/80 text-secondary-foreground"> | |
| {activeServersCount} | |
| </SidebarMenuBadge> | |
| ) : null} | |
| </SidebarMenuButton> | |
| </SidebarMenuItem> | |
| </SidebarMenu> | |
| </SidebarGroupContent> | |
| </SidebarGroup> | |
| </SidebarContent> | |
| <SidebarFooter className="p-4 border-t border-border/40 mt-auto"> | |
| <div className={`flex flex-col ${isCollapsed ? "items-center" : ""} gap-3`}> | |
| <motion.div | |
| whileHover={{ scale: 1.02 }} | |
| whileTap={{ scale: 0.98 }} | |
| > | |
| <Button | |
| variant="default" | |
| className={cn( | |
| "w-full bg-primary text-primary-foreground hover:bg-primary/90", | |
| isCollapsed ? "w-8 h-8 p-0" : "" | |
| )} | |
| onClick={handleNewChat} | |
| title={isCollapsed ? "New Chat" : undefined} | |
| > | |
| <PlusCircle className={`${isCollapsed ? "" : "mr-2"} h-4 w-4`} /> | |
| {!isCollapsed && <span>New Chat</span>} | |
| </Button> | |
| </motion.div> | |
| <DropdownMenu modal={false}> | |
| <DropdownMenuTrigger asChild> | |
| {isCollapsed ? ( | |
| <Button | |
| variant="ghost" | |
| className="w-8 h-8 p-0 flex items-center justify-center" | |
| > | |
| <Avatar className="h-6 w-6 rounded-lg bg-secondary/60"> | |
| <AvatarFallback className="rounded-lg text-xs font-medium text-secondary-foreground"> | |
| {userId.substring(0, 2).toUpperCase()} | |
| </AvatarFallback> | |
| </Avatar> | |
| </Button> | |
| ) : ( | |
| <Button | |
| variant="outline" | |
| className="w-full justify-between font-normal bg-transparent border border-border/60 shadow-none px-2 h-10 hover:bg-secondary/50" | |
| > | |
| <div className="flex items-center gap-2"> | |
| <Avatar className="h-7 w-7 rounded-lg bg-secondary/60"> | |
| <AvatarFallback className="rounded-lg text-sm font-medium text-secondary-foreground"> | |
| {userId.substring(0, 2).toUpperCase()} | |
| </AvatarFallback> | |
| </Avatar> | |
| <div className="grid text-left text-sm leading-tight"> | |
| <span className="truncate font-medium text-foreground/90">User ID</span> | |
| <span className="truncate text-xs text-muted-foreground">{userId.substring(0, 16)}...</span> | |
| </div> | |
| </div> | |
| <ChevronsUpDown className="h-4 w-4 text-muted-foreground" /> | |
| </Button> | |
| )} | |
| </DropdownMenuTrigger> | |
| <DropdownMenuContent | |
| className="w-56 rounded-lg" | |
| side={isCollapsed ? "top" : "top"} | |
| align={isCollapsed ? "start" : "end"} | |
| sideOffset={8} | |
| > | |
| <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 bg-secondary/60"> | |
| <AvatarFallback className="rounded-lg text-sm font-medium text-secondary-foreground"> | |
| {userId.substring(0, 2).toUpperCase()} | |
| </AvatarFallback> | |
| </Avatar> | |
| <div className="grid flex-1 text-left text-sm leading-tight"> | |
| <span className="truncate font-semibold text-foreground/90">User ID</span> | |
| <span className="truncate text-xs text-muted-foreground">{userId}</span> | |
| </div> | |
| </div> | |
| </DropdownMenuLabel> | |
| <DropdownMenuSeparator /> | |
| <DropdownMenuGroup> | |
| <DropdownMenuItem onSelect={(e) => { | |
| e.preventDefault(); | |
| navigator.clipboard.writeText(userId); | |
| toast.success("User ID copied to clipboard"); | |
| }}> | |
| <Copy className="mr-2 h-4 w-4 hover:text-sidebar-accent" /> | |
| Copy User ID | |
| </DropdownMenuItem> | |
| <DropdownMenuItem onSelect={(e) => { | |
| e.preventDefault(); | |
| setEditUserIdOpen(true); | |
| }}> | |
| <Pencil className="mr-2 h-4 w-4 hover:text-sidebar-accent" /> | |
| Edit User ID | |
| </DropdownMenuItem> | |
| </DropdownMenuGroup> | |
| <DropdownMenuSeparator /> | |
| <DropdownMenuGroup> | |
| <DropdownMenuItem onSelect={(e) => { | |
| e.preventDefault(); | |
| setMcpSettingsOpen(true); | |
| }}> | |
| <Settings className="mr-2 h-4 w-4 hover:text-sidebar-accent" /> | |
| MCP Settings | |
| </DropdownMenuItem> | |
| <DropdownMenuItem onSelect={(e) => { | |
| e.preventDefault(); | |
| setApiKeySettingsOpen(true); | |
| }}> | |
| <Key className="mr-2 h-4 w-4 hover:text-sidebar-accent" /> | |
| API Keys | |
| </DropdownMenuItem> | |
| <DropdownMenuItem onSelect={(e) => { | |
| e.preventDefault(); | |
| window.open("https://git.new/s-mcp", "_blank"); | |
| }}> | |
| <Github className="mr-2 h-4 w-4 hover:text-sidebar-accent" /> | |
| GitHub | |
| </DropdownMenuItem> | |
| <DropdownMenuItem onSelect={(e) => e.preventDefault()}> | |
| <div className="flex items-center justify-between w-full"> | |
| <div className="flex items-center"> | |
| <Sparkles className="mr-2 h-4 w-4 hover:text-sidebar-accent" /> | |
| Theme | |
| </div> | |
| <ThemeToggle className="h-6 w-6" /> | |
| </div> | |
| </DropdownMenuItem> | |
| </DropdownMenuGroup> | |
| </DropdownMenuContent> | |
| </DropdownMenu> | |
| </div> | |
| <MCPServerManager | |
| servers={mcpServers} | |
| onServersChange={setMcpServers} | |
| selectedServers={selectedMcpServers} | |
| onSelectedServersChange={setSelectedMcpServers} | |
| open={mcpSettingsOpen} | |
| onOpenChange={setMcpSettingsOpen} | |
| /> | |
| <ApiKeyManager | |
| open={apiKeySettingsOpen} | |
| onOpenChange={setApiKeySettingsOpen} | |
| /> | |
| </SidebarFooter> | |
| <Dialog open={editUserIdOpen} onOpenChange={(open) => { | |
| setEditUserIdOpen(open); | |
| if (open) { | |
| setNewUserId(userId); | |
| } | |
| }}> | |
| <DialogContent className="sm:max-w-[400px]"> | |
| <DialogHeader> | |
| <DialogTitle>Edit User ID</DialogTitle> | |
| <DialogDescription> | |
| Update your user ID for chat synchronization. This will affect which chats are visible to you. | |
| </DialogDescription> | |
| </DialogHeader> | |
| <div className="grid gap-4 py-4"> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="userId">User ID</Label> | |
| <Input | |
| id="userId" | |
| value={newUserId} | |
| onChange={(e) => setNewUserId(e.target.value)} | |
| placeholder="Enter your user ID" | |
| /> | |
| </div> | |
| </div> | |
| <DialogFooter> | |
| <Button | |
| variant="outline" | |
| onClick={() => setEditUserIdOpen(false)} | |
| > | |
| Cancel | |
| </Button> | |
| <Button onClick={handleUpdateUserId}> | |
| Save Changes | |
| </Button> | |
| </DialogFooter> | |
| </DialogContent> | |
| </Dialog> | |
| </Sidebar> | |
| ); | |
| } |