diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot-2/components/ChatContainer/ChatContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot-2/components/ChatContainer/ChatContainer.tsx new file mode 100644 index 0000000000..7e88696c4a --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot-2/components/ChatContainer/ChatContainer.tsx @@ -0,0 +1,71 @@ +"use client" +import { UIDataTypes, UITools, UIMessage } from "ai"; +import { ChatMessagesContainer } from "../ChatMessagesContainer/ChatMessagesContainer" +import { EmptySession } from "../EmptySession/EmptySession"; +import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput"; +import { postV2CreateSession } from "@/app/api/__generated__/endpoints/chat/chat"; +import { useState } from "react"; +import { parseAsString, useQueryState } from "nuqs"; + +export interface ChatContainerProps { + messages: UIMessage[]; + status: string; + error: Error | undefined; + input: string; + setInput: (input: string) => void; + handleMessageSubmit: (e: React.FormEvent) => void; + onSend: (message: string) => void; +} +export const ChatContainer = ({messages, status, error, input, setInput, handleMessageSubmit, onSend}: ChatContainerProps) => { + const [isCreating, setIsCreating] = useState(false); + const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString); + + async function createSession(e: React.FormEvent) { + e.preventDefault(); + if (isCreating) return; + setIsCreating(true); + try { + const response = await postV2CreateSession({ + body: JSON.stringify({}), + }); + if (response.status === 200 && response.data?.id) { + setSessionId(response.data.id); + } + } finally { + setIsCreating(false); + } + } + + + return ( +
+
+ {sessionId ? ( + + ) : ( + + )} +
+
+ {}} + placeholder="You can search or just ask" + /> +
+
+
+ ) +} \ No newline at end of file diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot-2/components/ChatSidebar/ChatSidebar.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot-2/components/ChatSidebar/ChatSidebar.tsx index f7304b19c2..a1f7dad17e 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot-2/components/ChatSidebar/ChatSidebar.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot-2/components/ChatSidebar/ChatSidebar.tsx @@ -1,49 +1,68 @@ -import { useState } from "react"; -import { postV2CreateSession } from "@/app/api/__generated__/endpoints/chat/chat"; -import { parseAsString, useQueryState } from "nuqs"; +"use client"; +import { Sidebar, SidebarHeader, SidebarContent, SidebarFooter, SidebarTrigger, useSidebar } from "@/components/ui/sidebar"; +import { cn } from "@/lib/utils"; +import { SparkleIcon, PlusIcon } from "@phosphor-icons/react"; +import { motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; -export const ChatSidebar = ({ - isCreating, - setIsCreating, -}: { - isCreating: boolean; - setIsCreating: (isCreating: boolean) => void; -}) => { - const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString); +export function ChatSidebar() { + const { state } = useSidebar(); + const isCollapsed = state === "collapsed"; - async function createSession(): Promise { - setIsCreating(true); - try { - const response = await postV2CreateSession({ - body: JSON.stringify({}), - }); - if (response.status === 200 && response.data?.id) { - return response.data.id; - } - return null; - } catch (error) { - return null; - } finally { - setIsCreating(false); - } - } - - async function handleNewSession() { - const newSessionId = await createSession(); - if (newSessionId) { - setSessionId(newSessionId); - } + function handleNewChat() { + // TODO: Implement new chat creation } return ( -
- -
+ + + {!isCollapsed && ( + + Acme + + )} + + + + + + + + + + + + ); -}; +} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot-2/page.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot-2/page.tsx index 7ce6d7874a..66961b541c 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot-2/page.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot-2/page.tsx @@ -9,11 +9,14 @@ import { EmptySession } from "./components/EmptySession/EmptySession"; import { ChatMessagesContainer } from "./components/ChatMessagesContainer/ChatMessagesContainer"; import { postV2CreateSession } from "@/app/api/__generated__/endpoints/chat/chat"; import { ChatInput } from "@/components/contextual/Chat/components/ChatInput/ChatInput"; +import { useSearchParams } from "next/navigation"; +import { ChatContainer } from "./components/ChatContainer/ChatContainer"; +import { SidebarProvider, SidebarInset } from "@/components/ui/sidebar"; export default function Page() { - const [sessionId, setSessionId] = useQueryState("sessionId", parseAsString); - const [isCreating, setIsCreating] = useState(false); const [input, setInput] = useState(""); + const searchParams = useSearchParams(); + const sessionId = searchParams.get("sessionId") ?? undefined; const transport = useMemo(() => { if (!sessionId) return null; @@ -39,21 +42,6 @@ export default function Page() { transport: transport ?? undefined, }); - async function createSession(e: React.FormEvent) { - e.preventDefault(); - if (isCreating) return; - setIsCreating(true); - try { - const response = await postV2CreateSession({ - body: JSON.stringify({}), - }); - if (response.status === 200 && response.data?.id) { - setSessionId(response.data.id); - } - } finally { - setIsCreating(false); - } - } function handleMessageSubmit(e: React.FormEvent) { e.preventDefault(); @@ -68,38 +56,19 @@ export default function Page() { } return ( -
- - -
-
- {sessionId ? ( - - ) : ( - - )} -
-
- + + + {}} - placeholder="You can search or just ask" /> -
-
-
-
+ + ); } diff --git a/autogpt_platform/frontend/src/app/globals.css b/autogpt_platform/frontend/src/app/globals.css index fb1578c499..dd1d17cde7 100644 --- a/autogpt_platform/frontend/src/app/globals.css +++ b/autogpt_platform/frontend/src/app/globals.css @@ -30,6 +30,14 @@ --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; } .dark { @@ -57,6 +65,14 @@ --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; } * { diff --git a/autogpt_platform/frontend/src/components/ui/input.tsx b/autogpt_platform/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000000..d9245ddbd1 --- /dev/null +++ b/autogpt_platform/frontend/src/components/ui/input.tsx @@ -0,0 +1,22 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Input = React.forwardRef>( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/autogpt_platform/frontend/src/components/ui/sheet.tsx b/autogpt_platform/frontend/src/components/ui/sheet.tsx new file mode 100644 index 0000000000..ac70cc7532 --- /dev/null +++ b/autogpt_platform/frontend/src/components/ui/sheet.tsx @@ -0,0 +1,140 @@ +"use client" + +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Sheet = SheetPrimitive.Root + +const SheetTrigger = SheetPrimitive.Trigger + +const SheetClose = SheetPrimitive.Close + +const SheetPortal = SheetPrimitive.Portal + +const SheetOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName + +const sheetVariants = cva( + "fixed z-50 gap-4 bg-white p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out dark:bg-neutral-950", + { + variants: { + side: { + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top", + bottom: + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom", + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm", + right: + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm", + }, + }, + defaultVariants: { + side: "right", + }, + } +) + +interface SheetContentProps + extends React.ComponentPropsWithoutRef, + VariantProps {} + +const SheetContent = React.forwardRef< + React.ElementRef, + SheetContentProps +>(({ side = "right", className, children, ...props }, ref) => ( + + + + + + Close + + {children} + + +)) +SheetContent.displayName = SheetPrimitive.Content.displayName + +const SheetHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetHeader.displayName = "SheetHeader" + +const SheetFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +SheetFooter.displayName = "SheetFooter" + +const SheetTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetTitle.displayName = SheetPrimitive.Title.displayName + +const SheetDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SheetDescription.displayName = SheetPrimitive.Description.displayName + +export { + Sheet, + SheetPortal, + SheetOverlay, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/autogpt_platform/frontend/src/components/ui/sidebar.tsx b/autogpt_platform/frontend/src/components/ui/sidebar.tsx new file mode 100644 index 0000000000..002544c7aa --- /dev/null +++ b/autogpt_platform/frontend/src/components/ui/sidebar.tsx @@ -0,0 +1,773 @@ +"use client" + +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" +import { PanelLeft } from "lucide-react" + +import { useIsMobile } from "@/hooks/use-mobile" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Separator } from "@/components/ui/separator" +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Skeleton } from "@/components/ui/skeleton" +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip" + +const SIDEBAR_COOKIE_NAME = "sidebar_state" +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7 +const SIDEBAR_WIDTH = "16rem" +const SIDEBAR_WIDTH_MOBILE = "18rem" +const SIDEBAR_WIDTH_ICON = "3rem" +const SIDEBAR_KEYBOARD_SHORTCUT = "b" + +type SidebarContextProps = { + state: "expanded" | "collapsed" + open: boolean + setOpen: (open: boolean) => void + openMobile: boolean + setOpenMobile: (open: boolean) => void + isMobile: boolean + toggleSidebar: () => void +} + +const SidebarContext = React.createContext(null) + +function useSidebar() { + const context = React.useContext(SidebarContext) + if (!context) { + throw new Error("useSidebar must be used within a SidebarProvider.") + } + + return context +} + +const SidebarProvider = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + defaultOpen?: boolean + open?: boolean + onOpenChange?: (open: boolean) => void + } +>( + ( + { + defaultOpen = true, + open: openProp, + onOpenChange: setOpenProp, + className, + style, + children, + ...props + }, + ref + ) => { + const isMobile = useIsMobile() + const [openMobile, setOpenMobile] = React.useState(false) + + // This is the internal state of the sidebar. + // We use openProp and setOpenProp for control from outside the component. + const [_open, _setOpen] = React.useState(defaultOpen) + const open = openProp ?? _open + const setOpen = React.useCallback( + (value: boolean | ((value: boolean) => boolean)) => { + const openState = typeof value === "function" ? value(open) : value + if (setOpenProp) { + setOpenProp(openState) + } else { + _setOpen(openState) + } + + // This sets the cookie to keep the sidebar state. + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}` + }, + [setOpenProp, open] + ) + + // Helper to toggle the sidebar. + const toggleSidebar = React.useCallback(() => { + return isMobile + ? setOpenMobile((open) => !open) + : setOpen((open) => !open) + }, [isMobile, setOpen, setOpenMobile]) + + // Adds a keyboard shortcut to toggle the sidebar. + React.useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.key === SIDEBAR_KEYBOARD_SHORTCUT && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault() + toggleSidebar() + } + } + + window.addEventListener("keydown", handleKeyDown) + return () => window.removeEventListener("keydown", handleKeyDown) + }, [toggleSidebar]) + + // We add a state so that we can do data-state="expanded" or "collapsed". + // This makes it easier to style the sidebar with Tailwind classes. + const state = open ? "expanded" : "collapsed" + + const contextValue = React.useMemo( + () => ({ + state, + open, + setOpen, + isMobile, + openMobile, + setOpenMobile, + toggleSidebar, + }), + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar] + ) + + return ( + + +
+ {children} +
+
+
+ ) + } +) +SidebarProvider.displayName = "SidebarProvider" + +const Sidebar = React.forwardRef< + HTMLDivElement, + React.ComponentProps<"div"> & { + side?: "left" | "right" + variant?: "sidebar" | "floating" | "inset" + collapsible?: "offcanvas" | "icon" | "none" + } +>( + ( + { + side = "left", + variant = "sidebar", + collapsible = "offcanvas", + className, + children, + ...props + }, + ref + ) => { + const { isMobile, state, openMobile, setOpenMobile } = useSidebar() + + if (collapsible === "none") { + return ( +
+ {children} +
+ ) + } + + if (isMobile) { + return ( + + + + Sidebar + Displays the mobile sidebar. + +
{children}
+
+
+ ) + } + + return ( +
+ {/* This is what handles the sidebar gap on desktop */} +
+ +
+ ) + } +) +Sidebar.displayName = "Sidebar" + +const SidebarTrigger = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ className, onClick, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( + + ) +}) +SidebarTrigger.displayName = "SidebarTrigger" + +const SidebarRail = React.forwardRef< + HTMLButtonElement, + React.ComponentProps<"button"> +>(({ className, ...props }, ref) => { + const { toggleSidebar } = useSidebar() + + return ( +