diff options
| author | MaheshtheDev <[email protected]> | 2026-01-19 22:37:03 +0000 |
|---|---|---|
| committer | MaheshtheDev <[email protected]> | 2026-01-19 22:37:03 +0000 |
| commit | 43cb9feecbaf4d83517696154073cc75b7c9c5c4 (patch) | |
| tree | f51b57d20d915e0c249180b6972598899d15623f /apps | |
| parent | docs changes (#678) (diff) | |
| download | supermemory-43cb9feecbaf4d83517696154073cc75b7c9c5c4.tar.xz supermemory-43cb9feecbaf4d83517696154073cc75b7c9c5c4.zip | |
chore: ux improvements and space selector (#684)01-19-chore_ux_improvements_and_space_selector
Diffstat (limited to 'apps')
| -rw-r--r-- | apps/web/app/new/page.tsx | 30 | ||||
| -rw-r--r-- | apps/web/components/new/add-document/index.tsx | 115 | ||||
| -rw-r--r-- | apps/web/components/new/chat/index.tsx | 24 | ||||
| -rw-r--r-- | apps/web/components/new/header.tsx | 68 | ||||
| -rw-r--r-- | apps/web/components/new/mcp-modal/index.tsx | 17 | ||||
| -rw-r--r-- | apps/web/components/new/memories-grid.tsx | 8 | ||||
| -rw-r--r-- | apps/web/components/new/space-selector.tsx | 169 |
7 files changed, 238 insertions, 193 deletions
diff --git a/apps/web/app/new/page.tsx b/apps/web/app/new/page.tsx index 3d85a427..aad09a28 100644 --- a/apps/web/app/new/page.tsx +++ b/apps/web/app/new/page.tsx @@ -19,7 +19,7 @@ export default function NewPage() { return ( <HotkeysProvider> - <div className="h-screen overflow-hidden bg-black"> + <div className="bg-black"> <AnimatedGradientBackground topPosition="15%" animateFromBottom={false} @@ -28,21 +28,23 @@ export default function NewPage() { onAddMemory={() => setIsAddDocumentOpen(true)} onOpenMCP={() => setIsMCPModalOpen(true)} /> - <main className="relative"> - <div key={`main-container-${isChatOpen}`} className="relative z-10"> - <div className="flex flex-row h-[calc(100vh-90px)] relative"> - <div className="flex-1 flex flex-col justify-start p-6 pr-0"> - <MemoriesGrid isChatOpen={isChatOpen} /> - </div> - <AnimatePresence mode="popLayout"> - <ChatSidebar - isChatOpen={isChatOpen} - setIsChatOpen={setIsChatOpen} - /> - </AnimatePresence> - </div> + <main + key={`main-container-${isChatOpen}`} + className="z-10 flex flex-row relative" + > + <div className="flex-1 p-6 pr-0"> + <MemoriesGrid isChatOpen={isChatOpen} /> + </div> + <div className="sticky top-0 h-screen"> + <AnimatePresence mode="popLayout"> + <ChatSidebar + isChatOpen={isChatOpen} + setIsChatOpen={setIsChatOpen} + /> + </AnimatePresence> </div> </main> + <AddDocumentModal isOpen={isAddDocumentOpen} onClose={() => setIsAddDocumentOpen(false)} diff --git a/apps/web/components/new/add-document/index.tsx b/apps/web/components/new/add-document/index.tsx index a99505bd..282150e4 100644 --- a/apps/web/components/new/add-document/index.tsx +++ b/apps/web/components/new/add-document/index.tsx @@ -1,38 +1,21 @@ "use client" -import { useState, useEffect, useMemo, useCallback } from "react" +import { useState, useEffect, useCallback } from "react" import { Dialog, DialogContent, DialogTitle } from "@repo/ui/components/dialog" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" -import { - FileTextIcon, - GlobeIcon, - ZapIcon, - ChevronsUpDownIcon, - FolderIcon, - Loader2, -} from "lucide-react" +import { FileTextIcon, GlobeIcon, ZapIcon, Loader2 } from "lucide-react" import { Button } from "@ui/components/button" import { ConnectContent } from "./connections" import { NoteContent } from "./note" import { LinkContent, type LinkData } from "./link" import { FileContent, type FileData } from "./file" import { useProject } from "@/stores" -import { $fetch } from "@lib/api" -import { DEFAULT_PROJECT_ID } from "@repo/lib/constants" -import type { Project } from "@repo/lib/types" -import { useQuery } from "@tanstack/react-query" -import { motion } from "motion/react" -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@repo/ui/components/dropdown-menu" import { toast } from "sonner" import { useDocumentMutations } from "../../../hooks/use-document-mutations" import { useCustomer } from "autumn-js/react" import { useMemoriesUsage } from "@/hooks/use-memories-usage" +import { SpaceSelector } from "../space-selector" type TabType = "note" | "link" | "file" | "connect" @@ -115,7 +98,6 @@ export function AddDocument({ const [localSelectedProject, setLocalSelectedProject] = useState<string>( globalSelectedProject, ) - const [isProjectSelectorOpen, setIsProjectSelectorOpen] = useState(false) // Form data state for button click handling const [noteContent, setNoteContent] = useState("") @@ -147,33 +129,6 @@ export function AddDocument({ setLocalSelectedProject(globalSelectedProject) }, [globalSelectedProject]) - const { data: projects = [], isLoading: isLoadingProjects } = useQuery({ - queryKey: ["projects"], - queryFn: async () => { - const response = await $fetch("@get/projects") - - if (response.error) { - throw new Error(response.error?.message || "Failed to load projects") - } - - return response.data?.projects || [] - }, - staleTime: 30 * 1000, - }) - - const projectName = useMemo(() => { - if (localSelectedProject === DEFAULT_PROJECT_ID) return "Default Project" - const found = projects.find( - (p: Project) => p.containerTag === localSelectedProject, - ) - return found?.name ?? localSelectedProject - }, [projects, localSelectedProject]) - - const handleProjectSelect = (containerTag: string) => { - setLocalSelectedProject(containerTag) - setIsProjectSelectorOpen(false) - } - useEffect(() => { if (defaultTab) { setActiveTab(defaultTab) @@ -334,65 +289,11 @@ export function AddDocument({ <ConnectContent selectedProject={localSelectedProject} /> )} <div className="flex justify-between"> - <DropdownMenu - open={isProjectSelectorOpen} - onOpenChange={setIsProjectSelectorOpen} - > - <DropdownMenuTrigger asChild> - <Button - variant="insideOut" - className="gap-2" - disabled={isSubmitting} - > - <FolderIcon className="size-4" /> - <span className="max-w-[120px] truncate"> - {isLoadingProjects ? "..." : projectName} - </span> - <motion.div - animate={{ rotate: isProjectSelectorOpen ? 180 : 0 }} - transition={{ duration: 0.2 }} - > - <ChevronsUpDownIcon className="size-4" color="#737373" /> - </motion.div> - </Button> - </DropdownMenuTrigger> - <DropdownMenuContent - align="start" - className="w-56 bg-[#1B1F24] border border-[#2A2E35] rounded-[12px] p-1.5 max-h-64 overflow-y-auto" - > - <DropdownMenuItem - onClick={() => handleProjectSelect(DEFAULT_PROJECT_ID)} - className={cn( - "flex items-center gap-2 px-3 py-2 rounded-[8px] cursor-pointer", - localSelectedProject === DEFAULT_PROJECT_ID - ? "bg-[#4BA0FA]/20 text-white" - : "text-[#737373] hover:bg-[#14161A] hover:text-white", - )} - > - <FolderIcon className="h-4 w-4" /> - <span className="text-sm font-medium">Default Project</span> - </DropdownMenuItem> - {projects - .filter((p: Project) => p.containerTag !== DEFAULT_PROJECT_ID) - .map((project: Project) => ( - <DropdownMenuItem - key={project.id} - onClick={() => handleProjectSelect(project.containerTag)} - className={cn( - "flex items-center gap-2 px-3 py-2 rounded-[8px] cursor-pointer", - localSelectedProject === project.containerTag - ? "bg-[#4BA0FA]/20 text-white" - : "text-[#737373] hover:bg-[#14161A] hover:text-white", - )} - > - <FolderIcon className="h-4 w-4" /> - <span className="text-sm font-medium truncate"> - {project.name} - </span> - </DropdownMenuItem> - ))} - </DropdownMenuContent> - </DropdownMenu> + <SpaceSelector + value={localSelectedProject} + onValueChange={setLocalSelectedProject} + variant="insideOut" + /> <div className="flex items-center gap-2"> <Button variant="ghost" diff --git a/apps/web/components/new/chat/index.tsx b/apps/web/components/new/chat/index.tsx index 08f4e2ef..634c0bb1 100644 --- a/apps/web/components/new/chat/index.tsx +++ b/apps/web/components/new/chat/index.tsx @@ -94,11 +94,28 @@ export function ChatSidebar({ >({}) const [isInputExpanded, setIsInputExpanded] = useState(false) const [isScrolledToBottom, setIsScrolledToBottom] = useState(true) + const [heightOffset, setHeightOffset] = useState(95) const pendingFollowUpGenerations = useRef<Set<string>>(new Set()) const messagesContainerRef = useRef<HTMLDivElement>(null) const { selectedProject } = useProject() const { setCurrentChatId } = usePersistentChat() + // Adjust chat height based on scroll position + useEffect(() => { + const handleWindowScroll = () => { + const scrollThreshold = 80 + const scrollY = window.scrollY + const progress = Math.min(scrollY / scrollThreshold, 1) + const newOffset = 95 - progress * (95 - 15) + setHeightOffset(newOffset) + } + + window.addEventListener("scroll", handleWindowScroll, { passive: true }) + handleWindowScroll() + + return () => window.removeEventListener("scroll", handleWindowScroll) + }, []) + const { messages, sendMessage, status, setMessages, stop } = useChat({ transport: new DefaultChatTransport({ api: `${process.env.NEXT_PUBLIC_BACKEND_URL}/chat/v2`, @@ -364,7 +381,7 @@ export function ChatSidebar({ > <motion.button onClick={toggleChat} - className="flex items-center gap-2 rounded-full px-3 py-1.5 text-xs font-medium border border-[#17181A] text-white cursor-pointer" + className="flex items-center gap-3 rounded-full px-3 py-1.5 text-sm font-medium border border-[#17181A] text-white cursor-pointer whitespace-nowrap" style={{ background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)", }} @@ -377,9 +394,12 @@ export function ChatSidebar({ <motion.div key="open" className={cn( - "w-[450px] h-[calc(100vh-95px)] bg-[#05070A] backdrop-blur-md flex flex-col rounded-2xl m-4 mt-2 border border-[#17181AB2] relative pt-4", + "w-[450px] bg-[#05070A] backdrop-blur-md flex flex-col rounded-2xl m-4 mt-2 border border-[#17181AB2] relative pt-4", dmSansClassName(), )} + style={{ + height: `calc(100vh - ${heightOffset}px)`, + }} initial={{ x: "100px", opacity: 0 }} animate={{ x: 0, opacity: 1 }} exit={{ x: "100px", opacity: 0 }} diff --git a/apps/web/components/new/header.tsx b/apps/web/components/new/header.tsx index 3bb2d115..77362dec 100644 --- a/apps/web/components/new/header.tsx +++ b/apps/web/components/new/header.tsx @@ -5,11 +5,9 @@ import { Avatar, AvatarFallback, AvatarImage } from "@ui/components/avatar" import { useAuth } from "@lib/auth-context" import { useEffect, useState } from "react" import { - ChevronsLeftRight, LayoutGridIcon, Plus, SearchIcon, - FolderIcon, LogOut, Settings, Home, @@ -20,22 +18,18 @@ import { Button } from "@ui/components/button" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" import { Tabs, TabsList, TabsTrigger } from "@ui/components/tabs" -import { useProjectName } from "@/hooks/use-project-name" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@ui/components/dropdown-menu" -import { useQuery } from "@tanstack/react-query" -import { $fetch } from "@repo/lib/api" import { authClient } from "@lib/auth" -import { DEFAULT_PROJECT_ID } from "@repo/lib/constants" import { useProjectMutations } from "@/hooks/use-project-mutations" import { useProject } from "@/stores" import { useRouter } from "next/navigation" import Link from "next/link" -import type { Project } from "@repo/lib/types" +import { SpaceSelector } from "./space-selector" interface HeaderProps { onAddMemory?: () => void @@ -45,23 +39,9 @@ interface HeaderProps { export function Header({ onAddMemory, onOpenMCP }: HeaderProps) { const { user } = useAuth() const [name, setName] = useState<string>("") - const projectName = useProjectName() const { selectedProject } = useProject() const { switchProject } = useProjectMutations() const router = useRouter() - const { data: projects = [] } = useQuery({ - queryKey: ["projects"], - queryFn: async () => { - const response = await $fetch("@get/projects") - - if (response.error) { - throw new Error(response.error?.message || "Failed to load projects") - } - - return response.data?.projects || [] - }, - staleTime: 30 * 1000, - }) useEffect(() => { const storedName = @@ -130,47 +110,11 @@ export function Header({ onAddMemory, onOpenMCP }: HeaderProps) { </DropdownMenuContent> </DropdownMenu> <div className="self-stretch w-px bg-[#FFFFFF33]" /> - <div className="flex items-center gap-2"> - <p>📁 {projectName}</p> - <DropdownMenu> - <DropdownMenuTrigger asChild> - <button - type="button" - className="cursor-pointer hover:opacity-70 transition-opacity" - aria-label="Change project" - > - <ChevronsLeftRight className="size-4 rotate-90" /> - </button> - </DropdownMenuTrigger> - <DropdownMenuContent align="start" className="w-56"> - <DropdownMenuItem - onClick={() => switchProject(DEFAULT_PROJECT_ID)} - className={cn( - "cursor-pointer", - selectedProject === DEFAULT_PROJECT_ID && "bg-accent", - )} - > - <FolderIcon className="h-3.5 w-3.5 mr-2" /> - <span className="text-sm">Default Project</span> - </DropdownMenuItem> - {projects - .filter((p: Project) => p.containerTag !== DEFAULT_PROJECT_ID) - .map((project: Project) => ( - <DropdownMenuItem - key={project.id} - onClick={() => switchProject(project.containerTag)} - className={cn( - "cursor-pointer", - selectedProject === project.containerTag && "bg-accent", - )} - > - <FolderIcon className="h-3.5 w-3.5 mr-2 opacity-70" /> - <span className="text-sm truncate">{project.name}</span> - </DropdownMenuItem> - ))} - </DropdownMenuContent> - </DropdownMenu> - </div> + <SpaceSelector + value={selectedProject} + onValueChange={switchProject} + showChevron + /> </div> <Tabs defaultValue="grid"> <TabsList className="rounded-full border border-[#161F2C] h-11! z-10!"> diff --git a/apps/web/components/new/mcp-modal/index.tsx b/apps/web/components/new/mcp-modal/index.tsx index 6816c21e..4a5cc0b1 100644 --- a/apps/web/components/new/mcp-modal/index.tsx +++ b/apps/web/components/new/mcp-modal/index.tsx @@ -2,9 +2,12 @@ import { dmSans125ClassName, dmSansClassName } from "@/lib/fonts" import { Dialog, DialogContent, DialogFooter } from "@repo/ui/components/dialog" import { cn } from "@lib/utils" import * as DialogPrimitive from "@radix-ui/react-dialog" -import { ChevronsUpDownIcon, XIcon } from "lucide-react" +import { XIcon } from "lucide-react" import { Button } from "@ui/components/button" import { MCPSteps } from "./mcp-detail-view" +import { SpaceSelector } from "../space-selector" +import { useProject } from "@/stores" +import { useProjectMutations } from "@/hooks/use-project-mutations" export function MCPModal({ isOpen, @@ -13,6 +16,8 @@ export function MCPModal({ isOpen: boolean onClose: () => void }) { + const { selectedProject } = useProject() + const { switchProject } = useProjectMutations() return ( <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}> <DialogContent @@ -50,9 +55,11 @@ export function MCPModal({ </div> <DialogFooter className="justify-between!"> <div className="flex items-center gap-2"> - <Button variant="insideOut"> - My Space <ChevronsUpDownIcon className="size-4" color="#737373" /> - </Button> + <SpaceSelector + value={selectedProject} + onValueChange={switchProject} + variant="insideOut" + /> <Button variant="ghost" className="text-[#737373] cursor-pointer rounded-full" @@ -60,7 +67,7 @@ export function MCPModal({ Migrate from MCP v1 </Button> </div> - <Button variant="insideOut" className="px-6 py-[10px]"> + <Button variant="insideOut" className="px-6 py-[10px]" onClick={onClose}> Done </Button> </DialogFooter> diff --git a/apps/web/components/new/memories-grid.tsx b/apps/web/components/new/memories-grid.tsx index e6095645..dd4a4e9b 100644 --- a/apps/web/components/new/memories-grid.tsx +++ b/apps/web/components/new/memories-grid.tsx @@ -54,7 +54,7 @@ export function MemoriesGrid({ isChatOpen }: { isChatOpen: boolean }) { const response = await $fetch("@post/documents/documents", { body: { page: pageParam as number, - limit: (pageParam as number) === 1 ? (IS_DEV ? 500 : 500) : PAGE_SIZE, + limit: PAGE_SIZE, sort: "createdAt", order: "desc", containerTags: selectedProject ? [selectedProject] : undefined, @@ -91,6 +91,8 @@ export function MemoriesGrid({ isChatOpen }: { isChatOpen: boolean }) { ) }, [data]) + const isLoadingMore = isFetchingNextPage + const loadMoreDocuments = useCallback(async (): Promise<void> => { if (hasNextPage && !isFetchingNextPage) { await fetchNextPage() @@ -148,7 +150,7 @@ export function MemoriesGrid({ isChatOpen }: { isChatOpen: boolean }) { } return ( - <div className="h-full"> + <div className="relative"> <Button className={cn( dmSansClassName(), @@ -189,7 +191,7 @@ export function MemoriesGrid({ isChatOpen }: { isChatOpen: boolean }) { onRender={maybeLoadMore} /> - {isFetchingNextPage && ( + {isLoadingMore && ( <div className="py-8 flex items-center justify-center"> <SuperLoader /> </div> diff --git a/apps/web/components/new/space-selector.tsx b/apps/web/components/new/space-selector.tsx new file mode 100644 index 00000000..68c615a5 --- /dev/null +++ b/apps/web/components/new/space-selector.tsx @@ -0,0 +1,169 @@ +"use client" + +import { useState, useMemo } from "react" +import { cn } from "@lib/utils" +import { dmSansClassName } from "@/lib/fonts" +import { $fetch } from "@repo/lib/api" +import { DEFAULT_PROJECT_ID } from "@repo/lib/constants" +import { useQuery } from "@tanstack/react-query" +import { ChevronsLeftRight, Plus } from "lucide-react" +import type { Project } from "@repo/lib/types" +import { CreateProjectDialog } from "@/components/create-project-dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@ui/components/dropdown-menu" + +export interface SpaceSelectorProps { + value: string + onValueChange: (containerTag: string) => void + variant?: "default" | "insideOut" + showChevron?: boolean + triggerClassName?: string + contentClassName?: string + showNewSpace?: boolean +} + +const triggerVariants = { + default: "px-3 py-2 rounded-md hover:bg-white/5", + insideOut: "px-3 py-2 rounded-full bg-[#0D121A] shadow-inside-out", +} + +export function SpaceSelector({ + value, + onValueChange, + variant = "default", + showChevron = false, + triggerClassName, + contentClassName, + showNewSpace = true, +}: SpaceSelectorProps) { + const [isOpen, setIsOpen] = useState(false) + const [showCreateDialog, setShowCreateDialog] = useState(false) + + const { data: projects = [], isLoading } = useQuery({ + queryKey: ["projects"], + queryFn: async () => { + const response = await $fetch("@get/projects") + + if (response.error) { + throw new Error(response.error?.message || "Failed to load projects") + } + + return response.data?.projects || [] + }, + staleTime: 30 * 1000, + }) + + const selectedProjectName = useMemo(() => { + if (value === DEFAULT_PROJECT_ID) return "My Space" + const found = projects.find((p: Project) => p.containerTag === value) + return found?.name ?? value + }, [projects, value]) + + const handleSelect = (containerTag: string) => { + onValueChange(containerTag) + setIsOpen(false) + } + + const handleNewSpace = () => { + setIsOpen(false) + setShowCreateDialog(true) + } + + return ( + <> + <DropdownMenu open={isOpen} onOpenChange={setIsOpen}> + <DropdownMenuTrigger asChild> + <button + type="button" + className={cn( + "flex items-center gap-2 cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50", + triggerVariants[variant], + dmSansClassName(), + triggerClassName, + )} + > + <span className="text-sm font-bold tracking-[-0.98px]">📁</span> + <span className="text-sm font-medium text-white"> + {isLoading ? "..." : selectedProjectName} + </span> + {showChevron && ( + <ChevronsLeftRight className="size-4 rotate-90 text-white/70" /> + )} + </button> + </DropdownMenuTrigger> + <DropdownMenuContent + align="start" + className={cn( + "min-w-[200px] p-3 rounded-[11px] border border-[#2E3033] shadow-[0px_1.5px_20px_0px_rgba(0,0,0,0.65)]", + dmSansClassName(), + contentClassName, + )} + style={{ + background: "linear-gradient(180deg, #0A0E14 0%, #05070A 100%)", + }} + > + <div className="flex flex-col gap-3"> + <div className="flex flex-col"> + {/* Default Project */} + <DropdownMenuItem + onClick={() => handleSelect(DEFAULT_PROJECT_ID)} + className={cn( + "flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer text-white text-sm font-medium", + value === DEFAULT_PROJECT_ID + ? "bg-[#161E2B] border border-[rgba(115,115,115,0.1)]" + : "opacity-50 hover:opacity-100 hover:bg-[#161E2B]/50", + )} + > + <span className="font-bold tracking-[-0.98px]">📁</span> + <span>My Space</span> + </DropdownMenuItem> + + {/* User Projects */} + {projects + .filter((p: Project) => p.containerTag !== DEFAULT_PROJECT_ID) + .map((project: Project) => ( + <DropdownMenuItem + key={project.id} + onClick={() => handleSelect(project.containerTag)} + className={cn( + "flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer text-white text-sm font-medium", + value === project.containerTag + ? "bg-[#161E2B] border border-[rgba(115,115,115,0.1)]" + : "opacity-50 hover:opacity-100 hover:bg-[#161E2B]/50", + )} + > + <span className="font-bold tracking-[-0.98px]">📁</span> + <span className="truncate">{project.name}</span> + </DropdownMenuItem> + ))} + </div> + + {showNewSpace && ( + <button + type="button" + onClick={handleNewSpace} + className="flex items-center justify-center gap-2 px-3 py-2 rounded-md cursor-pointer text-white text-sm font-medium border border-[#161F2C] hover:bg-[#0D121A]/80 transition-colors" + style={{ + background: + "linear-gradient(180deg, #0D121A 0%, #000000 100%)", + }} + > + <Plus className="size-4" /> + <span>New Space</span> + </button> + )} + </div> + </DropdownMenuContent> + </DropdownMenu> + + <CreateProjectDialog + open={showCreateDialog} + onOpenChange={setShowCreateDialog} + /> + </> + ) +} |