TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import Link from "next/link";4import { usePathname, useRouter } from "next/navigation";5import { 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";6import { useApp, invalidateConversations } from "./store";7import { useApi, api } from "@/lib/client/api";8import type { ConversationsResponse, PublicConversation } from "@/lib/client/types";9import { Logo } from "@/components/brand/logo";10import { ProviderIcon } from "@/components/brand/provider-icon";11import { Button } from "@/components/ui/button";12import { Kbd, Skeleton } from "@/components/ui/misc";13import { Tooltip } from "@/components/ui/tooltip";14import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuLabel } from "@/components/ui/dropdown-menu";15import { ActionSheet, type ActionSheetItem } from "@/components/ui/sheet";16import { PromptDialog } from "@/components/common/prompt-dialog";17import { ConfirmDialog } from "@/components/common/confirm-dialog";18import { ProjectsSidebarSection } from "@/components/projects/sidebar-section";19import { toast } from "@/components/ui/toast";20import { useIsMobile, useLongPress } from "@/lib/client/hooks";21import { cn } from "@/lib/utils";22import { signOut } from "@/lib/auth-client";2324const NAV = [25 { href: "/app/chat", label: "Chat", icon: MessageSquare, match: /^\/app\/chat/ },26 { href: "/app/arena", label: "Arena", icon: Swords, match: /^\/app\/arena/ },27 { href: "/app/models", label: "Models", icon: Boxes, match: /^\/app\/models/ },28 { href: "/app/prompts", label: "Prompts", icon: WandSparkles, match: /^\/app\/prompts/ },29 { href: "/app/presets", label: "Presets", icon: Sparkles, match: /^\/app\/presets/ },30 { href: "/app/library", label: "Library", icon: Library, match: /^\/app\/library/ },31 { href: "/app/usage", label: "Usage", icon: BarChart3, match: /^\/app\/usage/ },32];3334type Modal = { kind: "rename"; c: PublicConversation } | { kind: "delete"; c: PublicConversation } | { kind: "new-folder" } | { kind: "delete-folder"; id: string; name: string } | null;3536/**37 * Conversation sidebar. Desktop: persistent column. Phone (`mobile`): content of the slide-over drawer38 * with 44px rows, long-press actions (ActionSheet) and no browser `prompt()`/`confirm()` dialogs.39 */40export function Sidebar({ onCollapse, mobile }: { onCollapse: () => void; mobile?: boolean }) {41 const { user, folders, refreshFolders, setPaletteOpen, setSearchOpen } = useApp();42 const pathname = usePathname();43 const router = useRouter();44 const isMobile = useIsMobile();45 const [q, setQ] = React.useState("");46 const [showArchived, setShowArchived] = React.useState(false);47 const [modal, setModal] = React.useState<Modal>(null);48 const [sheetFor, setSheetFor] = React.useState<PublicConversation | null>(null);49 const key = `/api/conversations?limit=120${showArchived ? "&archived=1" : ""}${q ? `&q=${encodeURIComponent(q)}` : ""}`;50 const { data, isLoading, mutate } = useApi<ConversationsResponse>(key, { keepPreviousData: true });51 const list = React.useMemo(() => data?.conversations ?? [], [data]);52 const activeId = pathname.match(/^\/app\/chat\/([^/]+)/)?.[1];53 const groups = React.useMemo(() => groupConversations(list, folders.map((f) => ({ id: f.id, name: f.name }))), [list, folders]);5455 const patch = async (id: string, body: Record<string, unknown>) => {56 await api(`/api/conversations/${id}`, { method: "PATCH", json: body });57 await mutate();58 invalidateConversations();59 };60 const onDelete = async (c: PublicConversation) => {61 await api(`/api/conversations/${c.id}`, { method: "DELETE" });62 await mutate();63 invalidateConversations();64 if (activeId === c.id) router.push("/app/chat");65 toast.success("Conversation deleted");66 };67 const onDuplicate = async (c: PublicConversation) => {68 const res = await api<{ conversation: PublicConversation }>(`/api/conversations/${c.id}/actions`, { method: "POST", json: { action: "duplicate" } });69 await mutate();70 router.push(`/app/chat/${res.conversation.id}`);71 };72 const onExport = async (c: PublicConversation, format: "json" | "markdown") => {73 const res = await fetch(`/api/conversations/${c.id}/actions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "export", format }) });74 const blob = await res.blob();75 const a = document.createElement("a");76 a.href = URL.createObjectURL(blob);77 a.download = res.headers.get("content-disposition")?.match(/filename="([^"]+)"/)?.[1] ?? `conversation.${format === "json" ? "json" : "md"}`;78 a.click();79 URL.revokeObjectURL(a.href);80 };81 const onShare = async (c: PublicConversation) => {82 const res = await api<{ id: string }>(`/api/conversations/${c.id}/actions`, { method: "POST", json: { action: "share" } });83 const url = `${window.location.origin}/share/${res.id}`;84 await navigator.clipboard.writeText(url).catch(() => {});85 toast.success("Share link copied", url);86 };8788 const rowActions = (c: PublicConversation) => ({89 onRename: () => setModal({ kind: "rename", c }),90 onPin: () => patch(c.id, { pinned: !c.pinned }),91 onArchive: () => patch(c.id, { archived: !c.archived }),92 onDelete: () => setModal({ kind: "delete", c }),93 onDuplicate: () => onDuplicate(c),94 onExport: (f: "json" | "markdown") => onExport(c, f),95 onShare: () => onShare(c),96 onMove: (folderId: string | null) => patch(c.id, { folderId }),97 });9899 const sheetItems = (c: PublicConversation): (ActionSheetItem | "separator")[] => {100 const a = rowActions(c);101 return [102 { key: "rename", label: "Rename", icon: <Pencil />, onSelect: a.onRename },103 { key: "pin", label: c.pinned ? "Unpin" : "Pin", icon: c.pinned ? <PinOff /> : <Pin />, onSelect: a.onPin },104 { key: "share", label: "Copy share link", icon: <Share2 />, onSelect: a.onShare },105 { key: "dup", label: "Duplicate", icon: <Copy />, onSelect: a.onDuplicate },106 { key: "md", label: "Export Markdown", icon: <Download />, onSelect: () => a.onExport("markdown") },107 ...(folders.length ? ([{ key: "nofolder", label: "Remove from folder", icon: <FolderIcon />, onSelect: () => a.onMove(null), disabled: !c.folderId }] as ActionSheetItem[]) : []),108 ...folders.map<ActionSheetItem>((f) => ({ key: `f-${f.id}`, label: `Move to ${f.name}`, icon: <FolderIcon />, onSelect: () => a.onMove(f.id), selected: c.folderId === f.id })),109 "separator",110 { key: "archive", label: c.archived ? "Unarchive" : "Archive", icon: <Archive />, onSelect: a.onArchive },111 { key: "delete", label: "Delete", icon: <Trash2 />, onSelect: a.onDelete, destructive: true },112 ];113 };114115 return (116 <div className="flex h-full flex-col">117 <div className="flex items-center justify-between px-3 pb-1 pt-3">118 <Link href="/app/chat" className="rounded-md px-1 py-0.5 hover:bg-bg-muted">119 <Logo size={22} />120 </Link>121 <Tooltip content={mobile ? "Close" : "Collapse sidebar ⌘B"}>122 <Button variant="ghost" size={mobile ? "icon" : "icon-sm"} onClick={onCollapse} aria-label={mobile ? "Close" : "Collapse sidebar"}>123 {mobile ? <X /> : <PanelLeftClose />}124 </Button>125 </Tooltip>126 </div>127128 <div className="flex gap-1.5 px-3 pb-2 pt-1">129 <Button asChild variant="primary" size={mobile ? "lg" : "md"} className={cn("flex-1 justify-between", mobile ? "rounded-xl text-[15px]" : "")}>130 <Link href="/app/chat">131 <span className="inline-flex items-center gap-2">132 <Plus className="size-4" /> New chat133 </span>134 {!mobile ? <Kbd className="border-border-strong/40 bg-transparent text-bg/70">⌘N</Kbd> : null}135 </Link>136 </Button>137 <Tooltip content="Search (⌘K)">138 <Button variant="outline" size={mobile ? "icon-lg" : "icon"} onClick={() => (isMobile ? setSearchOpen(true) : setPaletteOpen(true))} aria-label="Search conversations">139 <Search />140 </Button>141 </Tooltip>142 </div>143144 <nav className="px-2 pb-1">145 {NAV.map((n) => {146 const active = n.match.test(pathname);147 return (148 <Link key={n.href} href={n.href} className={cn("flex items-center gap-2.5 rounded-md px-2.5 text-[13.5px] font-medium transition-colors", mobile ? "min-h-[42px] text-[15px]" : "py-1.5", active ? "bg-bg-muted text-fg" : "text-fg-muted hover:bg-bg-muted/70 hover:text-fg")}>149 <n.icon className={cn("size-4", active ? "text-accent" : "text-fg-subtle")} />150 {n.label}151 </Link>152 );153 })}154 </nav>155156 <div className="mx-3 my-1.5">157 <div className="relative">158 <Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-fg-subtle" />159 <input value={q} onChange={(e) => 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" />160 </div>161 </div>162163 <div className="min-h-0 flex-1 overflow-y-auto px-2 pb-2 scrollbar-thin contain-scroll">164 <ProjectsSidebarSection onNavigate={mobile ? onCollapse : undefined} />165 {isLoading && !data ? (166 <div className="space-y-1.5 px-1 pt-3">167 {Array.from({ length: 8 }).map((_, i) => (168 <Skeleton key={i} className={mobile ? "h-9" : "h-7"} />169 ))}170 </div>171 ) : list.length === 0 ? (172 <div className="px-2 pt-8 text-center">173 <BookMarked className="mx-auto size-5 text-fg-subtle" />174 <p className="mt-2 text-xs text-fg-subtle">{q ? "No conversations match." : showArchived ? "No archived conversations." : "No conversations yet. Start a new chat."}</p>175 </div>176 ) : (177 groups.map((g) => (178 <Group key={g.key} title={g.title} count={g.items.length} icon={g.folderId ? <FolderIcon className="size-3.5" /> : undefined} defaultOpen={g.key !== "older"} onDeleteFolder={g.folderId ? () => setModal({ kind: "delete-folder", id: g.folderId!, name: g.title }) : undefined}>179 {g.items.map((c) => (180 <ConversationRow key={c.id} c={c} active={c.id === activeId} mobile={Boolean(mobile)} folders={folders} onLongPress={() => setSheetFor(c)} {...rowActions(c)} />181 ))}182 </Group>183 ))184 )}185 </div>186187 <div className="border-t border-border px-2 py-2">188 <div className="flex items-center justify-between px-1 pb-1">189 <button onClick={() => setShowArchived((v) => !v)} className={cn("tap inline-flex items-center gap-1.5 rounded px-1.5 py-1 text-[12px] text-fg-subtle hover:text-fg", showArchived && "text-fg")}>190 <Archive className="size-3.5" /> {showArchived ? "Hide archived" : "Archived"}191 </button>192 <Tooltip content="New folder">193 <Button variant="ghost" size="icon-sm" onClick={() => setModal({ kind: "new-folder" })} aria-label="New folder">194 <FolderPlus />195 </Button>196 </Tooltip>197 </div>198 <DropdownMenu>199 <DropdownMenuTrigger asChild>200 <button className={cn("flex w-full items-center gap-2.5 rounded-md px-2 text-left hover:bg-bg-muted", mobile ? "min-h-[48px]" : "py-1.5")}>201 <span className="flex size-7 items-center justify-center rounded-full bg-accent-soft text-[12px] font-semibold text-accent">{(user.name || user.email).slice(0, 1).toUpperCase()}</span>202 <span className="min-w-0 flex-1">203 <span className="block truncate text-[13px] font-medium">{user.name || user.email.split("@")[0]}</span>204 <span className="block truncate text-[11.5px] text-fg-subtle">{user.email}</span>205 </span>206 <MoreHorizontal className="size-4 text-fg-subtle" />207 </button>208 </DropdownMenuTrigger>209 <DropdownMenuContent side="top" align="start" className="w-60">210 <DropdownMenuItem onSelect={() => router.push("/app/settings/account")}>211 <Settings /> Settings212 </DropdownMenuItem>213 <DropdownMenuItem onSelect={() => router.push("/app/settings/providers")}>214 <ShieldCheck /> Providers & keys215 </DropdownMenuItem>216 {user.isAdmin ? (217 <DropdownMenuItem onSelect={() => router.push("/admin/providers")}>218 <BarChart3 /> Admin diagnostics219 </DropdownMenuItem>220 ) : null}221 <DropdownMenuSeparator />222 <DropdownMenuItem223 onSelect={async () => {224 await signOut();225 router.push("/login");226 }}227 >228 <LogOut /> Sign out229 </DropdownMenuItem>230 </DropdownMenuContent>231 </DropdownMenu>232 </div>233234 {/* Mobile long-press actions */}235 <ActionSheet open={Boolean(sheetFor)} onOpenChange={(o) => !o && setSheetFor(null)} title={sheetFor?.title} items={sheetFor ? sheetItems(sheetFor) : []} />236237 {/* Dialogs (replace window.prompt/confirm) */}238 <PromptDialog open={modal?.kind === "rename"} onOpenChange={(o) => !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 }); }} />239 <PromptDialog open={modal?.kind === "new-folder"} onOpenChange={(o) => !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(); }} />240 <ConfirmDialog open={modal?.kind === "delete"} onOpenChange={(o) => !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); }} />241 <ConfirmDialog open={modal?.kind === "delete-folder"} onOpenChange={(o) => !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()]); }} />242 </div>243 );244}245246function Group({ title, count, children, icon, defaultOpen = true, onDeleteFolder }: { title: string; count: number; children: React.ReactNode; icon?: React.ReactNode; defaultOpen?: boolean; onDeleteFolder?: () => void }) {247 const [open, setOpen] = React.useState(defaultOpen);248 return (249 <div className="pt-2">250 <div className="group flex items-center justify-between px-2 pb-0.5">251 <button onClick={() => setOpen((o) => !o)} className="tap inline-flex items-center gap-1 text-[11px] font-medium uppercase tracking-wide text-fg-subtle hover:text-fg" aria-expanded={open}>252 {open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}253 {icon}254 {title}255 <span className="ml-1 font-normal text-fg-subtle/70">{count}</span>256 </button>257 {onDeleteFolder ? (258 <button onClick={onDeleteFolder} className="hover-reveal tap rounded p-0.5 text-fg-subtle hover:text-danger" aria-label="Delete folder">259 <Trash2 className="size-3" />260 </button>261 ) : null}262 </div>263 {open ? <div className="space-y-px">{children}</div> : null}264 </div>265 );266}267268function 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 }) {269 const router = useRouter();270 const press = useLongPress({ onLongPress, onClick: () => router.push(`/app/chat/${c.id}`), disabled: !mobile });271 return (272 <div className={cn("group relative flex items-center rounded-md", active ? "bg-bg-muted" : "hover:bg-bg-muted/70")}>273 {mobile ? (274 <button type="button" {...press} className="flex min-h-[44px] min-w-0 flex-1 select-none items-center gap-2.5 px-2 text-left" style={{ WebkitTouchCallout: "none" }}>275 <ProviderIcon provider={c.provider} size={14} className="opacity-80" />276 <span className={cn("min-w-0 flex-1 truncate text-[15px]", active ? "text-fg" : "text-fg-muted")}>{c.title}</span>277 {c.pinned ? <Pin className="size-3 text-fg-subtle" /> : null}278 </button>279 ) : (280 <Link href={`/app/chat/${c.id}`} className="flex min-w-0 flex-1 items-center gap-2 px-2 py-1.5">281 <ProviderIcon provider={c.provider} size={13} className="opacity-80" />282 <span className={cn("min-w-0 flex-1 truncate text-[13px]", active ? "text-fg" : "text-fg-muted group-hover:text-fg")}>{c.title}</span>283 {c.pinned ? <Pin className="size-3 text-fg-subtle" /> : null}284 </Link>285 )}286 {mobile ? (287 <button onClick={onLongPress} className="tap mr-1 rounded p-2 text-fg-subtle" aria-label="Conversation actions">288 <MoreHorizontal className="size-4" />289 </button>290 ) : (291 <DropdownMenu>292 <DropdownMenuTrigger asChild>293 <button className={cn("mr-1 rounded p-1 text-fg-subtle hover:bg-border hover:text-fg", active ? "" : "invisible group-hover:visible data-[state=open]:visible")} aria-label="Conversation actions">294 <MoreHorizontal className="size-3.5" />295 </button>296 </DropdownMenuTrigger>297 <DropdownMenuContent align="start" className="w-52">298 <DropdownMenuItem onSelect={onRename}>299 <Pencil /> Rename300 </DropdownMenuItem>301 <DropdownMenuItem onSelect={onPin}>302 {c.pinned ? <PinOff /> : <Pin />} {c.pinned ? "Unpin" : "Pin"}303 </DropdownMenuItem>304 <DropdownMenuSub>305 <DropdownMenuSubTrigger>306 <FolderIcon /> Move to folder307 </DropdownMenuSubTrigger>308 <DropdownMenuSubContent>309 <DropdownMenuLabel>Folders</DropdownMenuLabel>310 <DropdownMenuItem onSelect={() => onMove(null)}>No folder</DropdownMenuItem>311 {folders.map((f) => (312 <DropdownMenuItem key={f.id} onSelect={() => onMove(f.id)}>313 {f.name}314 </DropdownMenuItem>315 ))}316 {folders.length === 0 ? <p className="px-2 py-1 text-xs text-fg-subtle">Create a folder from the sidebar footer.</p> : null}317 </DropdownMenuSubContent>318 </DropdownMenuSub>319 <DropdownMenuItem onSelect={onDuplicate}>320 <Copy /> Duplicate321 </DropdownMenuItem>322 <DropdownMenuItem onSelect={onShare}>323 <Share2 /> Share link324 </DropdownMenuItem>325 <DropdownMenuSub>326 <DropdownMenuSubTrigger>327 <Download /> Export328 </DropdownMenuSubTrigger>329 <DropdownMenuSubContent>330 <DropdownMenuItem onSelect={() => onExport("markdown")}>Markdown (.md)</DropdownMenuItem>331 <DropdownMenuItem onSelect={() => onExport("json")}>JSON</DropdownMenuItem>332 </DropdownMenuSubContent>333 </DropdownMenuSub>334 <DropdownMenuItem onSelect={onArchive}>335 <Archive /> {c.archived ? "Unarchive" : "Archive"}336 </DropdownMenuItem>337 <DropdownMenuSeparator />338 <DropdownMenuItem destructive onSelect={onDelete}>339 <Trash2 /> Delete340 </DropdownMenuItem>341 </DropdownMenuContent>342 </DropdownMenu>343 )}344 </div>345 );346}347348function groupConversations(list: PublicConversation[], folders: { id: string; name: string }[]) {349 const now = new Date();350 const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();351 const startOfYesterday = startOfToday - 86_400_000;352 const week = startOfToday - 7 * 86_400_000;353 const groups: { key: string; title: string; items: PublicConversation[]; folderId?: string }[] = [];354 const pinned = list.filter((c) => c.pinned);355 if (pinned.length) groups.push({ key: "pinned", title: "Pinned", items: pinned });356 for (const f of folders) {357 const items = list.filter((c) => !c.pinned && c.folderId === f.id);358 if (items.length) groups.push({ key: `folder:${f.id}`, title: f.name, items, folderId: f.id });359 }360 const rest = list.filter((c) => !c.pinned && !folders.some((f) => f.id === c.folderId));361 const by = (pred: (t: number) => boolean) => rest.filter((c) => pred(new Date(c.updatedAt).getTime()));362 const today = by((t) => t >= startOfToday);363 const yesterday = by((t) => t >= startOfYesterday && t < startOfToday);364 const thisWeek = by((t) => t >= week && t < startOfYesterday);365 const older = by((t) => t < week);366 if (today.length) groups.push({ key: "today", title: "Today", items: today });367 if (yesterday.length) groups.push({ key: "yesterday", title: "Yesterday", items: yesterday });368 if (thisWeek.length) groups.push({ key: "week", title: "Previous 7 days", items: thisWeek });369 if (older.length) groups.push({ key: "older", title: "Older", items: older });370 return groups;371}372