"use client"; import * as React from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { Archive, BarChart3, Boxes, ChevronDown, ChevronRight, Copy, Download, Folder as FolderIcon, FolderPlus, MessageSquare, MoreHorizontal, PanelLeftClose, Pencil, Pin, PinOff, Plus, Search, Settings, ShieldCheck, Sparkles, Swords, Trash2, WandSparkles, X, Share2, LogOut, Library, BookMarked } from "lucide-react"; import { useApp, invalidateConversations } from "./store"; import { useApi, api } from "@/lib/client/api"; import type { ConversationsResponse, PublicConversation } from "@/lib/client/types"; import { Logo } from "@/components/brand/logo"; import { ProviderIcon } from "@/components/brand/provider-icon"; import { Button } from "@/components/ui/button"; import { Kbd, Skeleton } from "@/components/ui/misc"; import { Tooltip } from "@/components/ui/tooltip"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuLabel } from "@/components/ui/dropdown-menu"; import { ActionSheet, type ActionSheetItem } from "@/components/ui/sheet"; import { PromptDialog } from "@/components/common/prompt-dialog"; import { ConfirmDialog } from "@/components/common/confirm-dialog"; import { ProjectsSidebarSection } from "@/components/projects/sidebar-section"; import { toast } from "@/components/ui/toast"; import { useIsMobile, useLongPress } from "@/lib/client/hooks"; import { cn } from "@/lib/utils"; import { signOut } from "@/lib/auth-client"; const NAV = [ { href: "/app/chat", label: "Chat", icon: MessageSquare, match: /^\/app\/chat/ }, { href: "/app/arena", label: "Arena", icon: Swords, match: /^\/app\/arena/ }, { href: "/app/models", label: "Models", icon: Boxes, match: /^\/app\/models/ }, { href: "/app/prompts", label: "Prompts", icon: WandSparkles, match: /^\/app\/prompts/ }, { href: "/app/presets", label: "Presets", icon: Sparkles, match: /^\/app\/presets/ }, { href: "/app/library", label: "Library", icon: Library, match: /^\/app\/library/ }, { href: "/app/usage", label: "Usage", icon: BarChart3, match: /^\/app\/usage/ }, ]; type Modal = { kind: "rename"; c: PublicConversation } | { kind: "delete"; c: PublicConversation } | { kind: "new-folder" } | { kind: "delete-folder"; id: string; name: string } | null; /** * Conversation sidebar. Desktop: persistent column. Phone (`mobile`): content of the slide-over drawer * with 44px rows, long-press actions (ActionSheet) and no browser `prompt()`/`confirm()` dialogs. */ export function Sidebar({ onCollapse, mobile }: { onCollapse: () => void; mobile?: boolean }) { const { user, folders, refreshFolders, setPaletteOpen, setSearchOpen } = useApp(); const pathname = usePathname(); const router = useRouter(); const isMobile = useIsMobile(); const [q, setQ] = React.useState(""); const [showArchived, setShowArchived] = React.useState(false); const [modal, setModal] = React.useState(null); const [sheetFor, setSheetFor] = React.useState(null); const key = `/api/conversations?limit=120${showArchived ? "&archived=1" : ""}${q ? `&q=${encodeURIComponent(q)}` : ""}`; const { data, isLoading, mutate } = useApi(key, { keepPreviousData: true }); const list = React.useMemo(() => data?.conversations ?? [], [data]); const activeId = pathname.match(/^\/app\/chat\/([^/]+)/)?.[1]; const groups = React.useMemo(() => groupConversations(list, folders.map((f) => ({ id: f.id, name: f.name }))), [list, folders]); const patch = async (id: string, body: Record) => { await api(`/api/conversations/${id}`, { method: "PATCH", json: body }); await mutate(); invalidateConversations(); }; const onDelete = async (c: PublicConversation) => { await api(`/api/conversations/${c.id}`, { method: "DELETE" }); await mutate(); invalidateConversations(); if (activeId === c.id) router.push("/app/chat"); toast.success("Conversation deleted"); }; const onDuplicate = async (c: PublicConversation) => { const res = await api<{ conversation: PublicConversation }>(`/api/conversations/${c.id}/actions`, { method: "POST", json: { action: "duplicate" } }); await mutate(); router.push(`/app/chat/${res.conversation.id}`); }; const onExport = async (c: PublicConversation, format: "json" | "markdown") => { const res = await fetch(`/api/conversations/${c.id}/actions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "export", format }) }); const blob = await res.blob(); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = res.headers.get("content-disposition")?.match(/filename="([^"]+)"/)?.[1] ?? `conversation.${format === "json" ? "json" : "md"}`; a.click(); URL.revokeObjectURL(a.href); }; const onShare = async (c: PublicConversation) => { const res = await api<{ id: string }>(`/api/conversations/${c.id}/actions`, { method: "POST", json: { action: "share" } }); const url = `${window.location.origin}/share/${res.id}`; await navigator.clipboard.writeText(url).catch(() => {}); toast.success("Share link copied", url); }; const rowActions = (c: PublicConversation) => ({ onRename: () => setModal({ kind: "rename", c }), onPin: () => patch(c.id, { pinned: !c.pinned }), onArchive: () => patch(c.id, { archived: !c.archived }), onDelete: () => setModal({ kind: "delete", c }), onDuplicate: () => onDuplicate(c), onExport: (f: "json" | "markdown") => onExport(c, f), onShare: () => onShare(c), onMove: (folderId: string | null) => patch(c.id, { folderId }), }); const sheetItems = (c: PublicConversation): (ActionSheetItem | "separator")[] => { const a = rowActions(c); return [ { key: "rename", label: "Rename", icon: , onSelect: a.onRename }, { key: "pin", label: c.pinned ? "Unpin" : "Pin", icon: c.pinned ? : , onSelect: a.onPin }, { key: "share", label: "Copy share link", icon: , onSelect: a.onShare }, { key: "dup", label: "Duplicate", icon: , onSelect: a.onDuplicate }, { key: "md", label: "Export Markdown", icon: , onSelect: () => a.onExport("markdown") }, ...(folders.length ? ([{ key: "nofolder", label: "Remove from folder", icon: , onSelect: () => a.onMove(null), disabled: !c.folderId }] as ActionSheetItem[]) : []), ...folders.map((f) => ({ key: `f-${f.id}`, label: `Move to ${f.name}`, icon: , onSelect: () => a.onMove(f.id), selected: c.folderId === f.id })), "separator", { key: "archive", label: c.archived ? "Unarchive" : "Archive", icon: , onSelect: a.onArchive }, { key: "delete", label: "Delete", icon: , onSelect: a.onDelete, destructive: true }, ]; }; return (
setQ(e.target.value)} placeholder="Filter chats" className={cn("w-full rounded-md border border-border bg-bg pl-8 pr-2 text-[13px] outline-none placeholder:text-fg-subtle focus:border-accent", mobile ? "h-10 text-[15px]" : "h-8")} aria-label="Filter conversations" />
{isLoading && !data ? (
{Array.from({ length: 8 }).map((_, i) => ( ))}
) : list.length === 0 ? (

{q ? "No conversations match." : showArchived ? "No archived conversations." : "No conversations yet. Start a new chat."}

) : ( groups.map((g) => ( : undefined} defaultOpen={g.key !== "older"} onDeleteFolder={g.folderId ? () => setModal({ kind: "delete-folder", id: g.folderId!, name: g.title }) : undefined}> {g.items.map((c) => ( setSheetFor(c)} {...rowActions(c)} /> ))} )) )}
router.push("/app/settings/account")}> Settings router.push("/app/settings/providers")}> Providers & keys {user.isAdmin ? ( router.push("/admin/providers")}> Admin diagnostics ) : null} { await signOut(); router.push("/login"); }} > Sign out
{/* Mobile long-press actions */} !o && setSheetFor(null)} title={sheetFor?.title} items={sheetFor ? sheetItems(sheetFor) : []} /> {/* Dialogs (replace window.prompt/confirm) */} !o && setModal(null)} title="Rename conversation" defaultValue={modal?.kind === "rename" ? modal.c.title : ""} onSubmit={async (v) => { if (modal?.kind === "rename") await patch(modal.c.id, { title: v }); }} /> !o && setModal(null)} title="New folder" placeholder="Research, Clients, Ideas…" confirmLabel="Create" onSubmit={async (v) => { await api("/api/folders", { method: "POST", json: { name: v } }); await refreshFolders(); }} /> !o && setModal(null)} title="Delete conversation?" description={modal?.kind === "delete" ? `“${modal.c.title}” and its messages will be permanently deleted.` : undefined} confirmLabel="Delete" destructive onConfirm={async () => { if (modal?.kind === "delete") await onDelete(modal.c); }} /> !o && setModal(null)} title="Delete folder?" description={modal?.kind === "delete-folder" ? `“${modal.name}” will be removed. Conversations inside are kept.` : undefined} confirmLabel="Delete folder" destructive onConfirm={async () => { if (modal?.kind !== "delete-folder") return; await api(`/api/folders?id=${modal.id}`, { method: "DELETE" }); await Promise.all([refreshFolders(), mutate()]); }} />
); } function Group({ title, count, children, icon, defaultOpen = true, onDeleteFolder }: { title: string; count: number; children: React.ReactNode; icon?: React.ReactNode; defaultOpen?: boolean; onDeleteFolder?: () => void }) { const [open, setOpen] = React.useState(defaultOpen); return (
{onDeleteFolder ? ( ) : null}
{open ?
{children}
: null}
); } function ConversationRow({ c, active, mobile, folders, onLongPress, onRename, onPin, onArchive, onDelete, onDuplicate, onExport, onShare, onMove }: { c: PublicConversation; active: boolean; mobile: boolean; folders: { id: string; name: string }[]; onLongPress: () => void; onRename: () => void; onPin: () => void; onArchive: () => void; onDelete: () => void; onDuplicate: () => void; onExport: (f: "json" | "markdown") => void; onShare: () => void; onMove: (folderId: string | null) => void }) { const router = useRouter(); const press = useLongPress({ onLongPress, onClick: () => router.push(`/app/chat/${c.id}`), disabled: !mobile }); return (
{mobile ? ( ) : ( {c.title} {c.pinned ? : null} )} {mobile ? ( ) : ( Rename {c.pinned ? : } {c.pinned ? "Unpin" : "Pin"} Move to folder Folders onMove(null)}>No folder {folders.map((f) => ( onMove(f.id)}> {f.name} ))} {folders.length === 0 ?

Create a folder from the sidebar footer.

: null}
Duplicate Share link Export onExport("markdown")}>Markdown (.md) onExport("json")}>JSON {c.archived ? "Unarchive" : "Archive"} Delete
)}
); } function groupConversations(list: PublicConversation[], folders: { id: string; name: string }[]) { const now = new Date(); const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const startOfYesterday = startOfToday - 86_400_000; const week = startOfToday - 7 * 86_400_000; const groups: { key: string; title: string; items: PublicConversation[]; folderId?: string }[] = []; const pinned = list.filter((c) => c.pinned); if (pinned.length) groups.push({ key: "pinned", title: "Pinned", items: pinned }); for (const f of folders) { const items = list.filter((c) => !c.pinned && c.folderId === f.id); if (items.length) groups.push({ key: `folder:${f.id}`, title: f.name, items, folderId: f.id }); } const rest = list.filter((c) => !c.pinned && !folders.some((f) => f.id === c.folderId)); const by = (pred: (t: number) => boolean) => rest.filter((c) => pred(new Date(c.updatedAt).getTime())); const today = by((t) => t >= startOfToday); const yesterday = by((t) => t >= startOfYesterday && t < startOfToday); const thisWeek = by((t) => t >= week && t < startOfYesterday); const older = by((t) => t < week); if (today.length) groups.push({ key: "today", title: "Today", items: today }); if (yesterday.length) groups.push({ key: "yesterday", title: "Yesterday", items: yesterday }); if (thisWeek.length) groups.push({ key: "week", title: "Previous 7 days", items: thisWeek }); if (older.length) groups.push({ key: "older", title: "Older", items: older }); return groups; }