SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
21.8 KB · 372 lines tsx
Raw Blame History
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 &amp; 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