diff --git a/apps/web/src/components/molecules/sidebar.tsx b/apps/web/src/components/molecules/sidebar.tsx index 191315b..89c1654 100644 --- a/apps/web/src/components/molecules/sidebar.tsx +++ b/apps/web/src/components/molecules/sidebar.tsx @@ -1,249 +1,255 @@ "use client" + import ThemeSwitch from "@/src/components/molecules/theme-switch" import ChatSidebar from "@/src/components/organisms/chat-sidebar" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/src/components/ui/alert-dialog" import { Avatar, AvatarFallback, AvatarImage } from "@/src/components/ui/avatar" -// COMPONENTS import { Sidebar as RootSidebar, SidebarBody, SidebarLink, useSidebar } from "@/src/components/ui/sidebar" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/src/components/ui/tooltip" import { useSession } from "@/src/lib/auth-client" -// STORE +import { cn } from "@/src/lib/utils" import { useSidebarStore } from "@/src/stores/sidebar-store" -// LINKS import { getSidebarItems } from "@/src/utils/sidebar-items" -// ICONS -import { IconChevronRight, IconMenu2 } from "@tabler/icons-react" -// MOTION +import { IconChevronLeft, IconChevronRight, IconLogout } from "@tabler/icons-react" import { AnimatePresence, motion } from "motion/react" -// NEXT import Link from "next/link" import { usePathname } from "next/navigation" -// HOOKS -import { useCallback, useEffect, useRef, useState } from "react" - -// Inner component — rendered inside SidebarProvider so useSidebar() works -const SidebarContent = ({ - collapsed, - togglecollapsed, -}: { +import { useEffect, useState } from "react" + +// --------------------------------------------------------------------------- +// SidebarContent +// --------------------------------------------------------------------------- + +interface SidebarContentProps { collapsed: boolean - togglecollapsed: () => void -}) => { - // hovered is just for the expand arrow hint — NOT for auto-expanding - const { hovered } = useSidebar() + threadpanelopen: boolean + setthreadpanelopen: (open: boolean) => void +} +const SidebarContent = ({ collapsed, threadpanelopen, setthreadpanelopen }: SidebarContentProps) => { + const { open } = useSidebar() const { data } = useSession() const { user } = data ?? {} const { name, image } = user ?? {} const pathname = usePathname() const sidebarItems = getSidebarItems() + const [signoutopen, setsignoutopen] = useState(false) - // ── Chat flyout state with 300ms open debounce ── - const [chatflyoutopen, setChatflyoutopen] = useState(false) - const flyoutopentimeout = useRef | null>(null) - const flyoutclosetimeout = useRef | null>(null) - const chatitemref = useRef(null) - const flyoutref = useRef(null) - - const openflyout = useCallback(() => { - // Cancel any pending close - if (flyoutclosetimeout.current) clearTimeout(flyoutclosetimeout.current) - // Open after 300ms debounce - if (!chatflyoutopen) { - flyoutopentimeout.current = setTimeout(() => { - setChatflyoutopen(true) - }, 300) - } - }, [chatflyoutopen]) - - const closeflyout = useCallback(() => { - // Cancel any pending open - if (flyoutopentimeout.current) clearTimeout(flyoutopentimeout.current) - // Close after short delay so user can move to the flyout panel - flyoutclosetimeout.current = setTimeout(() => { - setChatflyoutopen(false) - }, 150) - }, []) - - // Cleanup on unmount + // Close thread panel when navigating away from chat useEffect(() => { - return () => { - if (flyoutopentimeout.current) clearTimeout(flyoutopentimeout.current) - if (flyoutclosetimeout.current) clearTimeout(flyoutclosetimeout.current) + if (!pathname.includes("/chat")) { + setthreadpanelopen(false) } - }, []) - - // Close flyout on route change - // biome-ignore lint/correctness/useExhaustiveDependencies: stable reference - useEffect(() => { - setChatflyoutopen(false) - }, [pathname]) + }, [pathname, setthreadpanelopen]) const ischatactive = pathname.includes("/chat") - const isexpanded = !collapsed + const isvisiblyexpanded = open return ( -
-
- {/* ── Header: Logo + Toggle ── */} -
- {isexpanded ? ( - - ) : ( -
- )} - {/* Burger icon when expanded, chevron arrow when collapsed+hovered */} - {isexpanded ? ( - - ) : ( - - {hovered ? ( - - - - ) : null} - - )} -
- - {/* ── Spacer (org switcher removed) ── */} -
- - {/* ── Nav items ── */} -
- {sidebarItems.map(({ id, hasflyout, ...item }) => { - if (id === "chat" && hasflyout) { - return ( -
- - - - - {/* Flyout panel */} - - {chatflyoutopen && ( - - setChatflyoutopen(false)} /> - - )} - -
- ) - } - - // Logout must NOT be a — prefetch would trigger the sign-out API - if (id === "logout") { - return ( - - ) - } - - return - })} -
-
- - {/* ── Footer ── */} -
-
- + + {threadpanelopen ? ( + // ── Thread panel ── + - - - {name?.charAt(0)} - - {isexpanded && ( - setthreadpanelopen(false)} /> + + ) : ( + // ── Normal nav ── + +
+ {/* Header: Logo + Collapse (expanded) or Expand button (collapsed) */} +
+ {isvisiblyexpanded ? ( + <> + + + + ) : ( + + )} +
+ +
+ + {/* Nav items — logout is handled in the footer */} +
+ {sidebarItems + .filter(({ id }) => id !== "logout") + .map(({ id, hasflyout, ...item }) => { + if (id === "chat" && hasflyout) { + return ( +
setthreadpanelopen(true)} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") setthreadpanelopen(true) + }} + className="cursor-pointer" + aria-label="Open threads" + > + + + +
+ ) + } + + return + })} +
+
+ + {/* Footer: avatar + theme toggle + sign out */} +
+ - {name} - - )} - - {isexpanded && } -
-
-
+ + + {name?.charAt(0)} + + {isvisiblyexpanded && ( + + {name} + + )} + + + {isvisiblyexpanded && ( +
+ + + + + + + Sign out + + +
+ )} +
+ + )} + + + {/* Sign-out confirmation */} + + + + Sign out? + You will be redirected to the login page. + + + Cancel + { + window.location.href = "/api/auth/sign-out" + }} + > + Sign out + + + + + ) } +// --------------------------------------------------------------------------- +// Sidebar +// --------------------------------------------------------------------------- + const Sidebar = () => { - const { collapsed, togglecollapsed } = useSidebarStore() - const open = !collapsed + const { collapsed } = useSidebarStore() + const [threadpanelopen, setthreadpanelopen] = useState(false) + + const open = threadpanelopen ? true : !collapsed + const setOpen = (val: boolean | ((prev: boolean) => boolean)) => { - const newval = typeof val === "function" ? val(open) : val + if (threadpanelopen) return + const newval = typeof val === "function" ? val(!collapsed) : val useSidebarStore.getState().setcollapsed(!newval) } return ( - - + + ) } +// --------------------------------------------------------------------------- +// Logo +// --------------------------------------------------------------------------- + export const Logo = () => { return ( - -
+ OpenZosma diff --git a/apps/web/src/components/organisms/chat-sidebar.tsx b/apps/web/src/components/organisms/chat-sidebar.tsx index 276423c..38a6c6f 100644 --- a/apps/web/src/components/organisms/chat-sidebar.tsx +++ b/apps/web/src/components/organisms/chat-sidebar.tsx @@ -10,275 +10,306 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/src/components/ui/alert-dialog" -import { Badge } from "@/src/components/ui/badge" import { Button } from "@/src/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/src/components/ui/dropdown-menu" import { Input } from "@/src/components/ui/input" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/src/components/ui/select" +import { ScrollArea } from "@/src/components/ui/scroll-area" import { Skeleton } from "@/src/components/ui/skeleton" import useDeleteConversation from "@/src/hooks/chat/use-delete-conversation" import useGetConversations from "@/src/hooks/chat/use-get-conversations" import { cn } from "@/src/lib/utils" -import { IconMessageCircle, IconPlus, IconRobot, IconSearch, IconTrash } from "@tabler/icons-react" +import type { ConversationSummary } from "@/src/services/chat.services" +import { IconChevronLeft, IconDotsVertical, IconMessageCircle, IconSearch, IconTrash, IconX } from "@tabler/icons-react" import Link from "next/link" import { usePathname, useRouter } from "next/navigation" import { useMemo, useState } from "react" import { toast } from "sonner" -type DateFilter = "all" | "today" | "week" | "month" +// --------------------------------------------------------------------------- +// Date grouping +// --------------------------------------------------------------------------- -const iswithinrange = (datestr: string, filter: DateFilter): boolean => { - if (filter === "all") return true - const date = new Date(datestr) - const now = new Date() - const startoftoday = new Date(now.getFullYear(), now.getMonth(), now.getDate()) - if (filter === "today") return date >= startoftoday - if (filter === "week") { - const weekago = new Date(startoftoday) - weekago.setDate(weekago.getDate() - 6) - return date >= weekago - } - if (filter === "month") { - const monthago = new Date(startoftoday) - monthago.setDate(monthago.getDate() - 29) - return date >= monthago - } - return true +interface DateGroup { + label: string + items: ConversationSummary[] } -const formattime = (datestr: string): string => { - const date = new Date(datestr) +const groupByDate = (conversations: ConversationSummary[]): DateGroup[] => { const now = new Date() - const diffms = now.getTime() - date.getTime() - const diffhrs = diffms / (1000 * 60 * 60) + const sod = (offsetDays = 0): Date => { + const d = new Date(now.getFullYear(), now.getMonth(), now.getDate()) + d.setDate(d.getDate() + offsetDays) + return d + } + + const groups: DateGroup[] = [ + { label: "Today", items: [] }, + { label: "Yesterday", items: [] }, + { label: "Previous 7 days", items: [] }, + { label: "Previous 30 days", items: [] }, + { label: "Older", items: [] }, + ] - if (diffhrs < 1) return "Just now" - if (diffhrs < 24) return `${Math.floor(diffhrs)}h ago` - if (diffhrs < 48) return "Yesterday" - return date.toLocaleDateString() + const [today, yesterday, week, month] = [sod(0), sod(-1), sod(-6), sod(-29)] + + for (const conv of conversations) { + const d = new Date(conv.updatedat) + if (d >= today) groups[0].items.push(conv) + else if (d >= yesterday) groups[1].items.push(conv) + else if (d >= week) groups[2].items.push(conv) + else if (d >= month) groups[3].items.push(conv) + else groups[4].items.push(conv) + } + + return groups.filter((g) => g.items.length > 0) } +// --------------------------------------------------------------------------- +// Skeleton +// --------------------------------------------------------------------------- + const ConversationSkeleton = () => ( -
- {Array.from({ length: 5 }).map((_, i) => ( -
- - - +
+ {[70, 50, 80, 55, 65, 45, 75].map((w, i) => ( +
+ +
))}
) -const ChatSidebar = ({ onNavigate }: { onNavigate?: () => void }) => { +// --------------------------------------------------------------------------- +// Thread item +// --------------------------------------------------------------------------- + +interface ThreadItemProps { + conv: ConversationSummary + isactive: boolean + onRequestDelete: (id: string) => void +} + +const ThreadItem = ({ conv, isactive, onRequestDelete }: ThreadItemProps) => ( +
+ {/* + * min-w-0 on the link allows it to shrink below content width in flex. + * pr-7 (28px) reserves space for the 24px options button + 4px gap. + * Without pr-7 the button overlaps the text. + */} + +

+ {conv.title} +

+ {conv.lastmessage && ( +

+ {conv.lastmessage} +

+ )} + + + {/* Options button — absolute so it doesn't affect link width */} +
+ + + + + + onRequestDelete(conv.id)} + > + + Delete + + + +
+
+) + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +interface ChatSidebarProps { + onClose: () => void +} + +const ChatSidebar = ({ onClose }: ChatSidebarProps) => { const router = useRouter() const pathname = usePathname() const [search, setSearch] = useState("") - const [datefilter, setDatefilter] = useState("all") - const [agentfilter, setAgentfilter] = useState("all") const [pendingdeleteid, setPendingdeleteid] = useState(null) - const activeconversationid = pathname.split("/chat/")[1] || null + const activeconversationid = pathname.split("/chat/")[1] ?? null const { data: conversations = [], isLoading: loading } = useGetConversations() const deleteConversation = useDeleteConversation() - const agentnames = useMemo(() => { - const names = new Set() - for (const c of conversations) { - if (c.agentname) names.add(c.agentname) - } - return Array.from(names).sort() - }, [conversations]) + const filtered = useMemo(() => { + const q = search.trim().toLowerCase() + if (!q) return conversations + return conversations.filter( + (c) => + c.title.toLowerCase().includes(q) || + (c.lastmessage?.toLowerCase().includes(q) ?? false) || + (c.agentname?.toLowerCase().includes(q) ?? false), + ) + }, [conversations, search]) - const filteredconversations = useMemo( - () => - conversations.filter((c) => { - const matchessearch = - c.title.toLowerCase().includes(search.toLowerCase()) || - (c.agentname?.toLowerCase().includes(search.toLowerCase()) ?? false) || - (c.lastmessage?.toLowerCase().includes(search.toLowerCase()) ?? false) - const matchesdate = iswithinrange(c.updatedat, datefilter) - const matchesagent = agentfilter === "all" || c.agentname === agentfilter - return matchessearch && matchesdate && matchesagent - }), - [conversations, search, datefilter, agentfilter], - ) + const groups = useMemo(() => groupByDate(filtered), [filtered]) - const handledeleteconfirm = async () => { + const handleDeleteConfirm = async () => { if (!pendingdeleteid) return - const idtodelete = pendingdeleteid + const id = pendingdeleteid setPendingdeleteid(null) try { - await deleteConversation.mutateAsync(idtodelete) + await deleteConversation.mutateAsync(id) toast.success("Conversation deleted") - if (activeconversationid === idtodelete) { - router.push("/chat") - } + if (activeconversationid === id) router.push("/chat") } catch { toast.error("Failed to delete conversation") } } - const hasactivefilters = search !== "" || datefilter !== "all" || agentfilter !== "all" - return ( <> + {/* + * h-full fills the motion.div wrapper in sidebar.tsx (which itself + * fills the DesktopSidebar container). flex flex-col lets ScrollArea + * take the remaining height via flex-1. + */}
- {/* Header */} -
-

Conversations

- + {/* ── Header ── */} +
+ + Threads
- {/* Search + Filters */} -
+ {/* ── Search ── */} +
- + setSearch(e.target.value)} - className="pl-8 h-8 text-sm" + className="pl-8 pr-7 h-8 text-sm bg-accent/30 border-transparent focus-visible:border-input focus-visible:bg-background" /> -
-
- - {agentnames.length > 0 && ( - + {search && ( + )}
- {/* Conversation List */} -
+ {/* ── Thread list ── */} + {loading ? ( - ) : filteredconversations.length === 0 ? ( -
- -

- {hasactivefilters ? "No matching conversations" : "No conversations yet"} -

- {!hasactivefilters && ( - )}
) : ( -
- {filteredconversations.map((conv) => ( -
{ - router.push(`/chat/${conv.id}`) - onNavigate?.() - }} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - router.push(`/chat/${conv.id}`) - onNavigate?.() - } - }} - className={cn( - "group flex flex-col gap-1 rounded-lg p-3 text-left transition-colors cursor-pointer", - activeconversationid === conv.id ? "bg-accent text-accent-foreground" : "hover:bg-accent/50", - )} - > - {/* Title + delete */} -
- - {conv.title} - - -
- - {/* Last message preview */} - - {conv.lastmessage ?? "No messages yet"} - - - {/* Agent + timestamp */} -
- {conv.agentname ? ( - - - {conv.agentname} - - ) : ( - - )} - - {formattime(conv.updatedat)} - +
+ {groups.map((group) => ( +
+

+ {group.label} +

+
+ {group.items.map((conv) => ( + + ))}
))}
)} -
+
- {/* Delete confirmation dialog */} + {/* ── Delete confirmation ── */} !open && setPendingdeleteid(null)}> - Delete conversation? + Delete thread? - This will permanently delete the conversation and all its messages. This action cannot be undone. + This will permanently delete the conversation and all its messages. This cannot be undone. Cancel Delete diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 2f43188..acc6e87 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -73,16 +73,11 @@ export const Sidebar = ({ } export const SidebarBody = (props: React.ComponentProps) => { - return ( - <> - - )} /> - - ) + return } export const DesktopSidebar = ({ className, children, ...props }: React.ComponentProps) => { - const { open, animate, setHovered } = useSidebar() + const { open, animate } = useSidebar() return ( { - if (!open) setHovered(true) - }} - onMouseLeave={() => { - if (!open) setHovered(false) - }} {...props} > {children}