chore: ux improvements and space selector (#684)

This commit is contained in:
MaheshtheDev 2026-01-19 22:37:03 +00:00
parent 87b361c26b
commit 43cb9feecb
7 changed files with 238 additions and 193 deletions

View file

@ -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)}

View file

@ -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"

View file

@ -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 }}

View file

@ -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!">

View file

@ -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>

View file

@ -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>

View file

@ -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}
/>
</>
)
}