feat(frontend): add app-level left sidebar with nav links and dynamic content

- Create AppSidebar component with logo, collapsible nav links (Home, Workflow, Explore, Builder, Settings), and dynamic content slot
- Move navigation links and logo from Navbar into the sidebar
- Add ChatSessionList extracted from ChatSidebar for copilot route
- Add route-based SidebarDynamicContent (chat list on copilot, placeholder on other pages)
- Simplify Navbar to only show search input with dynamic placeholder, nav arrows, and right-side items (activity, wallet, account)
- Remove SidebarProvider and ChatSidebar from CopilotPage (now handled at layout level)
- Wrap platform layout in SidebarProvider with AppSidebar
This commit is contained in:
abhi1992002
2026-03-17 21:39:19 +05:30
parent 68f5d2ad08
commit 6ac4dc174c
5 changed files with 587 additions and 53 deletions

View File

@@ -5,12 +5,10 @@ import { useGetV2GetCopilotUsage } from "@/app/api/__generated__/endpoints/chat/
import { toast } from "@/components/molecules/Toast/use-toast";
import useCredits from "@/hooks/useCredits";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import { SidebarProvider } from "@/components/ui/sidebar";
import { cn } from "@/lib/utils";
import { UploadSimple } from "@phosphor-icons/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ChatContainer } from "./components/ChatContainer/ChatContainer";
import { ChatSidebar } from "./components/ChatSidebar/ChatSidebar";
import { DeleteChatDialog } from "./components/DeleteChatDialog/DeleteChatDialog";
import { MobileDrawer } from "./components/MobileDrawer/MobileDrawer";
import { MobileHeader } from "./components/MobileHeader/MobileHeader";
@@ -90,7 +88,7 @@ export function CopilotPage() {
handleDrawerOpenChange,
handleSelectSession,
handleNewChat,
// Delete functionality (available via ChatSidebar context menu on all viewports)
// Delete functionality
sessionToDelete,
isDeleting,
handleConfirmDelete,
@@ -143,51 +141,45 @@ export function CopilotPage() {
}
return (
<SidebarProvider
defaultOpen={true}
className="h-[calc(100vh-72px)] min-h-0"
<div
className="relative flex h-full w-full flex-col overflow-hidden bg-[#f8f8f9] px-0"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
{!isMobile && <ChatSidebar />}
{isMobile && <MobileHeader onOpenDrawer={handleOpenDrawer} />}
<NotificationBanner />
{/* Drop overlay */}
<div
className="relative flex h-full w-full flex-col overflow-hidden bg-[#f8f8f9] px-0"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"pointer-events-none absolute inset-0 z-50 flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-violet-400 bg-violet-500/10 transition-opacity duration-150",
isDragging ? "opacity-100" : "opacity-0",
)}
>
{isMobile && <MobileHeader onOpenDrawer={handleOpenDrawer} />}
<NotificationBanner />
{/* Drop overlay */}
<div
className={cn(
"pointer-events-none absolute inset-0 z-50 flex flex-col items-center justify-center gap-3 rounded-lg border-2 border-dashed border-violet-400 bg-violet-500/10 transition-opacity duration-150",
isDragging ? "opacity-100" : "opacity-0",
)}
>
<UploadSimple className="h-10 w-10 text-violet-500" weight="bold" />
<span className="text-lg font-medium text-violet-600">
Drop files here
</span>
</div>
<div className="flex-1 overflow-hidden">
<ChatContainer
messages={messages}
status={status}
error={error}
sessionId={sessionId}
isLoadingSession={isLoadingSession}
isSessionError={isSessionError}
isCreatingSession={isCreatingSession}
isReconnecting={isReconnecting}
isSyncing={isSyncing}
onCreateSession={createSession}
onSend={onSend}
onStop={stop}
isUploadingFiles={isUploadingFiles}
droppedFiles={droppedFiles}
onDroppedFilesConsumed={handleDroppedFilesConsumed}
/>
</div>
<UploadSimple className="h-10 w-10 text-violet-500" weight="bold" />
<span className="text-lg font-medium text-violet-600">
Drop files here
</span>
</div>
<div className="flex-1 overflow-hidden">
<ChatContainer
messages={messages}
status={status}
error={error}
sessionId={sessionId}
isLoadingSession={isLoadingSession}
isSessionError={isSessionError}
isCreatingSession={isCreatingSession}
isReconnecting={isReconnecting}
isSyncing={isSyncing}
onCreateSession={createSession}
onSend={onSend}
onStop={stop}
isUploadingFiles={isUploadingFiles}
droppedFiles={droppedFiles}
onDroppedFilesConsumed={handleDroppedFilesConsumed}
/>
</div>
{isMobile && (
<MobileDrawer
@@ -201,7 +193,6 @@ export function CopilotPage() {
onOpenChange={handleDrawerOpenChange}
/>
)}
{/* Delete confirmation dialog - rendered at top level for proper z-index on mobile */}
{isMobile && (
<DeleteChatDialog
session={sessionToDelete}
@@ -225,6 +216,6 @@ export function CopilotPage() {
isBillingEnabled={isBillingEnabled}
onCreditChange={fetchCredits}
/>
</SidebarProvider>
</div>
);
}

View File

@@ -1,15 +1,23 @@
"use client";
import { AppSidebar } from "@/components/layout/AppSidebar/AppSidebar";
import { SidebarDynamicContent } from "@/components/layout/AppSidebar/SidebarDynamicContent";
import { Navbar } from "@/components/layout/Navbar/Navbar";
import { SidebarProvider } from "@/components/ui/sidebar";
import { NetworkStatusMonitor } from "@/services/network-status/NetworkStatusMonitor";
import { ReactNode } from "react";
import { AdminImpersonationBanner } from "./admin/components/AdminImpersonationBanner";
export default function PlatformLayout({ children }: { children: ReactNode }) {
return (
<main className="flex h-screen w-full flex-col">
<NetworkStatusMonitor />
<Navbar />
<AdminImpersonationBanner />
<section className="flex-1">{children}</section>
</main>
<SidebarProvider defaultOpen={true}>
<AppSidebar dynamicContent={<SidebarDynamicContent />} />
<main className="flex h-screen w-full flex-col">
<NetworkStatusMonitor />
<Navbar />
<AdminImpersonationBanner />
<section className="flex-1 overflow-hidden">{children}</section>
</main>
</SidebarProvider>
);
}

View File

@@ -0,0 +1,144 @@
"use client";
import { IconAutoGPTLogo } from "@/components/__legacy__/ui/icons";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarSeparator,
SidebarTrigger,
useSidebar,
} from "@/components/ui/sidebar";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { Flag, useGetFlag } from "@/services/feature-flags/use-get-flag";
import {
House,
TreeStructure,
Compass,
Wrench,
GearSix,
} from "@phosphor-icons/react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ReactNode } from "react";
interface Props {
dynamicContent?: ReactNode;
}
export function AppSidebar({ dynamicContent }: Props) {
const { state } = useSidebar();
const isCollapsed = state === "collapsed";
const pathname = usePathname();
const isChatEnabled = useGetFlag(Flag.CHAT);
const { isLoggedIn } = useSupabase();
const homeHref = isChatEnabled === true ? "/copilot" : "/library";
const navLinks = [
{ name: "Home", href: homeHref, icon: House },
...(isChatEnabled === true
? [{ name: "Workflow", href: "/library", icon: TreeStructure }]
: []),
{ name: "Explore", href: "/marketplace", icon: Compass },
{ name: "Builder", href: "/build", icon: Wrench },
{ name: "Settings", href: "/profile/settings", icon: GearSix },
];
function isActive(href: string) {
if (href === homeHref) {
return pathname === "/" || pathname.startsWith(homeHref);
}
return pathname.startsWith(href);
}
if (!isLoggedIn) return null;
return (
<Sidebar
variant="sidebar"
collapsible="icon"
className="border-r border-zinc-100"
>
<SidebarHeader className="px-3 py-4">
<div
className={cn(
"flex items-center",
isCollapsed ? "justify-center" : "justify-between",
)}
>
<Link href={homeHref}>
<IconAutoGPTLogo
className={cn(isCollapsed ? "h-8 w-8" : "h-8 w-24")}
/>
</Link>
{!isCollapsed && (
<Tooltip>
<TooltipTrigger asChild>
<SidebarTrigger />
</TooltipTrigger>
<TooltipContent side="right">Close sidebar</TooltipContent>
</Tooltip>
)}
</div>
{isCollapsed && (
<div className="mt-2 flex justify-center">
<Tooltip>
<TooltipTrigger asChild>
<SidebarTrigger />
</TooltipTrigger>
<TooltipContent side="right">Open sidebar</TooltipContent>
</Tooltip>
</div>
)}
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupContent>
<SidebarMenu className={cn(isCollapsed && "gap-3")}>
{navLinks.map((link) => (
<SidebarMenuItem key={link.name}>
<SidebarMenuButton
asChild
isActive={isActive(link.href)}
tooltip={link.name}
className="py-5"
>
<Link href={link.href}>
<link.icon className="!size-5" weight="regular" />
<span>{link.name}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<SidebarSeparator className="mx-0" />
{dynamicContent && (
<SidebarGroup className="flex-1 overflow-hidden">
{!isCollapsed && (
<SidebarGroupContent className="h-full overflow-y-auto [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{dynamicContent}
</SidebarGroupContent>
)}
</SidebarGroup>
)}
</SidebarContent>
</Sidebar>
);
}

View File

@@ -0,0 +1,354 @@
"use client";
import {
getGetV2ListSessionsQueryKey,
useDeleteV2DeleteSession,
useGetV2ListSessions,
usePatchV2UpdateSessionTitle,
} from "@/app/api/__generated__/endpoints/chat/chat";
import { Button } from "@/components/atoms/Button/Button";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { Text } from "@/components/atoms/Text/Text";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/molecules/DropdownMenu/DropdownMenu";
import { toast } from "@/components/molecules/Toast/use-toast";
import { cn } from "@/lib/utils";
import { CheckCircle, DotsThree, PlusIcon } from "@phosphor-icons/react";
import { useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "framer-motion";
import { parseAsString, useQueryState } from "nuqs";
import { useEffect, useRef, useState } from "react";
import { useCopilotUIStore } from "@/app/(platform)/copilot/store";
import { DeleteChatDialog } from "@/app/(platform)/copilot/components/DeleteChatDialog/DeleteChatDialog";
import { PulseLoader } from "@/app/(platform)/copilot/components/PulseLoader/PulseLoader";
import { UsageLimits } from "@/app/(platform)/copilot/components/UsageLimits/UsageLimits";
import { NotificationToggle } from "@/app/(platform)/copilot/components/ChatSidebar/components/NotificationToggle/NotificationToggle";
export function ChatSessionList() {
const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString);
const {
sessionToDelete,
setSessionToDelete,
completedSessionIDs,
clearCompletedSession,
} = useCopilotUIStore();
const queryClient = useQueryClient();
const { data: sessionsResponse, isLoading: isLoadingSessions } =
useGetV2ListSessions({ limit: 50 }, { query: { refetchInterval: 10_000 } });
const { mutate: deleteSession, isPending: isDeleting } =
useDeleteV2DeleteSession({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: getGetV2ListSessionsQueryKey(),
});
if (sessionToDelete?.id === sessionId) {
setSessionId(null);
}
setSessionToDelete(null);
},
onError: (error: unknown) => {
toast({
title: "Failed to delete chat",
description:
error instanceof Error ? error.message : "An error occurred",
variant: "destructive",
});
setSessionToDelete(null);
},
},
});
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState("");
const renameInputRef = useRef<HTMLInputElement>(null);
const renameCancelledRef = useRef(false);
const { mutate: renameSession } = usePatchV2UpdateSessionTitle({
mutation: {
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: getGetV2ListSessionsQueryKey(),
});
setEditingSessionId(null);
},
onError: (error: unknown) => {
toast({
title: "Failed to rename chat",
description:
error instanceof Error ? error.message : "An error occurred",
variant: "destructive",
});
setEditingSessionId(null);
},
},
});
useEffect(() => {
if (editingSessionId && renameInputRef.current) {
renameInputRef.current.focus();
renameInputRef.current.select();
}
}, [editingSessionId]);
useEffect(() => {
queryClient.invalidateQueries({
queryKey: getGetV2ListSessionsQueryKey(),
});
}, [sessionId, queryClient]);
useEffect(() => {
if (!sessionId || !completedSessionIDs.has(sessionId)) return;
clearCompletedSession(sessionId);
const remaining = completedSessionIDs.size - 1;
document.title =
remaining > 0 ? `(${remaining}) Otto is ready - AutoGPT` : "AutoGPT";
}, [sessionId, completedSessionIDs, clearCompletedSession]);
const sessions =
sessionsResponse?.status === 200 ? sessionsResponse.data.sessions : [];
function handleNewChat() {
setSessionId(null);
}
function handleSelectSession(id: string) {
setSessionId(id);
}
function handleRenameClick(
e: React.MouseEvent,
id: string,
title: string | null | undefined,
) {
e.stopPropagation();
renameCancelledRef.current = false;
setEditingSessionId(id);
setEditingTitle(title || "");
}
function handleRenameSubmit(id: string) {
const trimmed = editingTitle.trim();
if (trimmed) {
renameSession({ sessionId: id, data: { title: trimmed } });
} else {
setEditingSessionId(null);
}
}
function handleDeleteClick(
e: React.MouseEvent,
id: string,
title: string | null | undefined,
) {
e.stopPropagation();
if (isDeleting) return;
setSessionToDelete({ id, title });
}
function handleConfirmDelete() {
if (sessionToDelete) {
deleteSession({ sessionId: sessionToDelete.id });
}
}
function handleCancelDelete() {
if (!isDeleting) {
setSessionToDelete(null);
}
}
function formatDate(dateString: string) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) return "Today";
if (diffDays === 1) return "Yesterday";
if (diffDays < 7) return `${diffDays} days ago`;
const day = date.getDate();
const ordinal =
day % 10 === 1 && day !== 11
? "st"
: day % 10 === 2 && day !== 12
? "nd"
: day % 10 === 3 && day !== 13
? "rd"
: "th";
const month = date.toLocaleDateString("en-US", { month: "short" });
const year = date.getFullYear();
return `${day}${ordinal} ${month} ${year}`;
}
return (
<>
<div className="flex flex-col gap-3 px-3 pb-3">
<div className="flex items-center justify-between">
<Text variant="h3" size="body-medium">
Your chats
</Text>
<div className="flex items-center">
<UsageLimits />
<NotificationToggle />
</div>
</div>
{sessionId ? (
<Button
variant="primary"
size="small"
onClick={handleNewChat}
className="w-full"
leftIcon={<PlusIcon className="h-4 w-4" weight="bold" />}
>
New Chat
</Button>
) : null}
</div>
<div className="flex flex-col gap-1">
{isLoadingSessions ? (
<div className="flex min-h-[30rem] items-center justify-center py-4">
<LoadingSpinner size="small" className="text-neutral-600" />
</div>
) : sessions.length === 0 ? (
<p className="py-4 text-center text-sm text-neutral-500">
No conversations yet
</p>
) : (
sessions.map((session) => (
<div
key={session.id}
className={cn(
"group relative w-full rounded-lg transition-colors",
session.id === sessionId ? "bg-zinc-100" : "hover:bg-zinc-50",
)}
>
{editingSessionId === session.id ? (
<div className="px-3 py-2.5">
<input
ref={renameInputRef}
type="text"
aria-label="Rename chat"
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.currentTarget.blur();
} else if (e.key === "Escape") {
renameCancelledRef.current = true;
setEditingSessionId(null);
}
}}
onBlur={() => {
if (renameCancelledRef.current) {
renameCancelledRef.current = false;
return;
}
handleRenameSubmit(session.id);
}}
className="w-full rounded border border-zinc-300 bg-white px-2 py-1 text-sm text-zinc-800 outline-none focus:border-purple-500 focus:ring-1 focus:ring-purple-500"
/>
</div>
) : (
<button
onClick={() => handleSelectSession(session.id)}
className="w-full px-3 py-2.5 pr-10 text-left"
>
<div className="flex min-w-0 max-w-full items-center gap-2">
<div className="min-w-0 flex-1">
<Text
variant="body"
className={cn(
"truncate font-normal",
session.id === sessionId
? "text-zinc-600"
: "text-zinc-800",
)}
>
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={session.title || "untitled"}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.2 }}
className="block truncate"
>
{session.title || "Untitled chat"}
</motion.span>
</AnimatePresence>
</Text>
<Text variant="small" className="text-neutral-400">
{formatDate(session.updated_at)}
</Text>
</div>
{session.is_processing &&
session.id !== sessionId &&
!completedSessionIDs.has(session.id) && (
<PulseLoader size={16} className="shrink-0" />
)}
{completedSessionIDs.has(session.id) &&
session.id !== sessionId && (
<CheckCircle
className="h-4 w-4 shrink-0 text-green-500"
weight="fill"
/>
)}
</div>
</button>
)}
{editingSessionId !== session.id && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
onClick={(e) => e.stopPropagation()}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full p-1.5 text-zinc-600 transition-all hover:bg-neutral-100"
aria-label="More actions"
>
<DotsThree className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={(e) =>
handleRenameClick(e, session.id, session.title)
}
>
Rename
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) =>
handleDeleteClick(e, session.id, session.title)
}
disabled={isDeleting}
className="text-red-600 focus:bg-red-50 focus:text-red-600"
>
Delete chat
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
))
)}
</div>
<DeleteChatDialog
session={sessionToDelete}
isDeleting={isDeleting}
onConfirm={handleConfirmDelete}
onCancel={handleCancelDelete}
/>
</>
);
}

View File

@@ -0,0 +1,37 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
import { usePathname } from "next/navigation";
import { ChatSessionList } from "./ChatSessionList";
export function SidebarDynamicContent() {
const pathname = usePathname();
if (pathname.startsWith("/copilot")) {
return <ChatSessionList />;
}
if (pathname.startsWith("/library")) {
return <GenericSidebarContent label="Library" />;
}
if (pathname.startsWith("/marketplace")) {
return <GenericSidebarContent label="Marketplace" />;
}
if (pathname.startsWith("/build")) {
return <GenericSidebarContent label="Builder" />;
}
return null;
}
function GenericSidebarContent({ label }: { label: string }) {
return (
<div className="px-3 py-2">
<Text variant="h3" size="body-medium">
{label}
</Text>
</div>
);
}