{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"conversation-sidebar","type":"registry:ui","title":"Conversation Sidebar","description":"The chat-history sidebar every AI app rebuilds — search, inline rename, pin, and delete with undo, grouped by date, with loading and empty states.","author":"Scrim UI (https://scrimui.dev)","categories":["conversation"],"docs":"https://scrimui.dev/components/conversation-sidebar","dependencies":[],"registryDependencies":[],"files":[{"path":"src/showcase/conversation-sidebar/conversation-sidebar.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\n\n/**\n * The chat-history sidebar every AI product rebuilds.\n *\n * A list of conversations is a ten-minute component. What makes this one\n * worth its own file is everything around the list:\n *\n * **Delete is undo-first, never confirm-first.** A confirm dialog makes the\n * reader answer a question before anything has happened; an undo bar lets\n * the action land instantly and be taken back while it is still visible.\n * `onDelete` fires immediately; if `onRestore` is provided, a bar offers to\n * bring the conversation back for a few seconds.\n *\n * **Rename is inline.** The title becomes an input where it sits — Enter\n * commits, Escape cancels. A modal for editing twelve characters is a whole\n * screen change for a two-second task.\n *\n * **Grouping is the caller's job.** \"Today\" / \"Yesterday\" / date math is\n * application policy, and timezone-sensitive. This component renders the\n * groups it is given; search filters within them.\n *\n * **Row actions reveal on hover AND focus.** The action buttons live inside\n * each row, so keyboard users reach them by tabbing — `focus-within` keeps\n * them visible while focus is anywhere in the row.\n */\n\nexport type Conversation = {\n  id: string;\n  title: string;\n  updatedAt?: string;\n  pinned?: boolean;\n};\n\nexport type ConversationGroup = {\n  id: string;\n  label: string;\n  conversations: Conversation[];\n};\n\nexport type ConversationSidebarProps = {\n  groups: ConversationGroup[];\n  activeId?: string;\n  /** Skeleton rows instead of the list, for first load. */\n  loading?: boolean;\n  /** Seeds the search box. The query itself is internal state after that. */\n  defaultQuery?: string;\n  newChatLabel?: string;\n  searchPlaceholder?: string;\n  emptyText?: string;\n  onNewChat?: () => void;\n  onSelect?: (id: string) => void;\n  onRename?: (id: string, title: string) => void;\n  onTogglePin?: (id: string) => void;\n  onDelete?: (id: string) => void;\n  /** Enables the undo bar after a delete. Re-insert the conversation. */\n  onRestore?: (conversation: Conversation) => void;\n  className?: string;\n};\n\n/* ------------------------------------------------------------------ */\n/* Icons                                                               */\n/* ------------------------------------------------------------------ */\n\nfunction PlusIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"13\" height=\"13\" {...props}>\n      <path d=\"M12 5v14M5 12h14\" />\n    </svg>\n  );\n}\n\nfunction SearchIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"13\" height=\"13\" {...props}>\n      <circle cx=\"11\" cy=\"11\" r=\"8\" />\n      <path d=\"m21 21-4.3-4.3\" />\n    </svg>\n  );\n}\n\nfunction PinIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"12\" height=\"12\" {...props}>\n      <path d=\"M12 17v5\" />\n      <path d=\"M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3z\" />\n    </svg>\n  );\n}\n\nfunction PencilIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"12\" height=\"12\" {...props}>\n      <path d=\"M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z\" />\n      <path d=\"m15 5 4 4\" />\n    </svg>\n  );\n}\n\nfunction TrashIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"12\" height=\"12\" {...props}>\n      <path d=\"M3 6h18\" />\n      <path d=\"M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6\" />\n      <path d=\"M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2\" />\n    </svg>\n  );\n}\n\nfunction CheckIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"3\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"11\" height=\"11\" {...props}>\n      <path d=\"M20 6 9 17l-5-5\" />\n    </svg>\n  );\n}\n\nfunction XIcon(props: React.SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"2.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" width=\"11\" height=\"11\" {...props}>\n      <path d=\"M18 6 6 18M6 6l12 12\" />\n    </svg>\n  );\n}\n\n/* ------------------------------------------------------------------ */\n/* ConversationSidebar                                                 */\n/* ------------------------------------------------------------------ */\n\nconst UNDO_MS = 6000;\n\nexport function ConversationSidebar({\n  groups,\n  activeId,\n  loading = false,\n  defaultQuery = \"\",\n  newChatLabel = \"New chat\",\n  searchPlaceholder = \"Search chats…\",\n  emptyText = \"No conversations yet. Start a new chat to see it here.\",\n  onNewChat,\n  onSelect,\n  onRename,\n  onTogglePin,\n  onDelete,\n  onRestore,\n  className = \"\",\n}: ConversationSidebarProps) {\n  const [query, setQuery] = React.useState(defaultQuery);\n  const [renamingId, setRenamingId] = React.useState<string | null>(null);\n  const [draft, setDraft] = React.useState(\"\");\n  const [deleted, setDeleted] = React.useState<Conversation | null>(null);\n  const undoTimer = React.useRef<number | undefined>(undefined);\n\n  /* The undo bar dismisses itself; a new delete restarts the clock. The\n     timeout callback is the only place this state changes on a timer, so\n     there is nothing to sync in an effect. */\n  React.useEffect(() => () => window.clearTimeout(undoTimer.current), []);\n\n  const q = query.trim().toLowerCase();\n  const visible = q\n    ? groups\n        .map((g) => ({\n          ...g,\n          conversations: g.conversations.filter((c) => c.title.toLowerCase().includes(q)),\n        }))\n        .filter((g) => g.conversations.length > 0)\n    : groups;\n  const total = groups.reduce((n, g) => n + g.conversations.length, 0);\n\n  function startRename(conv: Conversation) {\n    setRenamingId(conv.id);\n    setDraft(conv.title);\n  }\n\n  function commitRename() {\n    if (renamingId) {\n      const title = draft.trim();\n      if (title) onRename?.(renamingId, title);\n    }\n    setRenamingId(null);\n  }\n\n  function handleDelete(conv: Conversation) {\n    onDelete?.(conv.id);\n    if (!onRestore) return;\n    window.clearTimeout(undoTimer.current);\n    setDeleted(conv);\n    undoTimer.current = window.setTimeout(() => setDeleted(null), UNDO_MS);\n  }\n\n  function handleUndo() {\n    window.clearTimeout(undoTimer.current);\n    if (deleted) onRestore?.(deleted);\n    setDeleted(null);\n  }\n\n  const actionBtn =\n    \"rounded-md p-1.5 text-zinc-400 transition-colors hover:bg-zinc-200 hover:text-zinc-700 dark:text-zinc-500 dark:hover:bg-zinc-700 dark:hover:text-zinc-200\";\n\n  return (\n    <div className={`flex h-full flex-col bg-zinc-50 dark:bg-zinc-950 ${className}`}>\n      {/* New chat */}\n      <div className=\"p-3 pb-2\">\n        <button\n          type=\"button\"\n          onClick={onNewChat}\n          disabled={loading}\n          className=\"flex w-full items-center justify-center gap-1.5 rounded-lg border border-zinc-200 bg-white py-2 text-[13px] font-medium text-zinc-700 transition-colors hover:bg-zinc-100 disabled:opacity-50 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700\"\n        >\n          <PlusIcon />\n          {newChatLabel}\n        </button>\n      </div>\n\n      {/* Search */}\n      <div className=\"px-3 pb-2\">\n        <div className=\"relative\">\n          <SearchIcon className=\"pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-400 dark:text-zinc-500\" />\n          <input\n            value={query}\n            onChange={(e) => setQuery(e.target.value)}\n            placeholder={searchPlaceholder}\n            aria-label=\"Search conversations\"\n            className=\"w-full rounded-lg border border-zinc-200 bg-white py-1.5 pl-8 pr-3 text-[13px] text-zinc-700 placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-zinc-400/40 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-200 dark:placeholder:text-zinc-500\"\n          />\n        </div>\n      </div>\n\n      {/* List */}\n      <nav aria-label=\"Conversations\" className=\"min-h-0 flex-1 overflow-y-auto px-3 pb-2\">\n        {loading ? (\n          <div className=\"space-y-2.5 pt-2\" aria-label=\"Loading conversations\">\n            {[70, 88, 55, 80, 64, 92, 48].map((w, i) => (\n              <div\n                key={i}\n                className=\"h-4 animate-pulse rounded bg-zinc-200 dark:bg-zinc-800\"\n                style={{ width: `${w}%` }}\n              />\n            ))}\n          </div>\n        ) : total === 0 ? (\n          <p className=\"px-2 pt-8 text-center text-xs leading-5 text-zinc-500 dark:text-zinc-400\">{emptyText}</p>\n        ) : visible.length === 0 ? (\n          <div className=\"px-2 pt-8 text-center\">\n            <p className=\"text-xs text-zinc-500 dark:text-zinc-400\">No chats match &ldquo;{query.trim()}&rdquo;.</p>\n            <button\n              type=\"button\"\n              onClick={() => setQuery(\"\")}\n              className=\"mt-1 text-xs font-medium text-zinc-700 underline underline-offset-2 hover:text-zinc-900 dark:text-zinc-300 dark:hover:text-zinc-100\"\n            >\n              Clear search\n            </button>\n          </div>\n        ) : (\n          visible.map((group) => (\n            <div key={group.id} className=\"pt-3 first:pt-1\">\n              <p className=\"px-2 pb-1 text-[11px] font-medium tracking-wide text-zinc-400 dark:text-zinc-500\">\n                {group.label}\n              </p>\n              <ul className=\"space-y-0.5\">\n                {group.conversations.map((conv) => (\n                  <li key={conv.id}>\n                    {renamingId === conv.id ? (\n                      <form\n                        onSubmit={(e) => {\n                          e.preventDefault();\n                          commitRename();\n                        }}\n                        className=\"flex items-center gap-1 py-0.5\"\n                      >\n                        <input\n                          autoFocus\n                          value={draft}\n                          onChange={(e) => setDraft(e.target.value)}\n                          onBlur={commitRename}\n                          onKeyDown={(e) => {\n                            if (e.key === \"Escape\") setRenamingId(null);\n                          }}\n                          aria-label=\"Rename conversation\"\n                          className=\"min-w-0 flex-1 rounded-md border border-zinc-300 bg-white px-2 py-1.5 text-[13px] text-zinc-800 focus:outline-none focus:ring-2 focus:ring-zinc-400/40 dark:border-zinc-600 dark:bg-zinc-900 dark:text-zinc-100\"\n                        />\n                        <button type=\"submit\" aria-label=\"Save name\" className={actionBtn}>\n                          <CheckIcon />\n                        </button>\n                        <button\n                          type=\"button\"\n                          aria-label=\"Cancel rename\"\n                          onClick={() => setRenamingId(null)}\n                          className={actionBtn}\n                        >\n                          <XIcon />\n                        </button>\n                      </form>\n                    ) : (\n                      <div className=\"group/row relative\">\n                        <button\n                          type=\"button\"\n                          aria-current={conv.id === activeId ? \"true\" : undefined}\n                          onClick={() => onSelect?.(conv.id)}\n                          className={`flex w-full items-center gap-1.5 rounded-lg px-2.5 py-2 text-left text-[13px] transition-colors ${\n                            conv.id === activeId\n                              ? \"bg-zinc-200/70 font-medium text-zinc-900 dark:bg-zinc-800 dark:text-zinc-100\"\n                              : \"text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800/60 dark:hover:text-zinc-200\"\n                          }`}\n                        >\n                          <span className=\"truncate\">{conv.title}</span>\n                          {conv.pinned && (\n                            <PinIcon className=\"shrink-0 text-zinc-400 dark:text-zinc-500\" aria-label=\"Pinned\" />\n                          )}\n                        </button>\n                        {/* Row actions — invisible until hover or keyboard\n                            focus lands anywhere in the row, so the list stays\n                            quiet but every action stays reachable. */}\n                        <span className=\"absolute right-1 top-1/2 flex -translate-y-1/2 items-center rounded-md bg-zinc-50 opacity-0 shadow-sm ring-1 ring-zinc-200 transition-opacity group-hover/row:opacity-100 group-focus-within/row:opacity-100 dark:bg-zinc-900 dark:ring-zinc-700\">\n                          {onTogglePin && (\n                            <button\n                              type=\"button\"\n                              aria-pressed={!!conv.pinned}\n                              aria-label={conv.pinned ? `Unpin: ${conv.title}` : `Pin: ${conv.title}`}\n                              onClick={() => onTogglePin(conv.id)}\n                              className={actionBtn}\n                            >\n                              <PinIcon />\n                            </button>\n                          )}\n                          {onRename && (\n                            <button\n                              type=\"button\"\n                              aria-label={`Rename: ${conv.title}`}\n                              onClick={() => startRename(conv)}\n                              className={actionBtn}\n                            >\n                              <PencilIcon />\n                            </button>\n                          )}\n                          {onDelete && (\n                            <button\n                              type=\"button\"\n                              aria-label={`Delete: ${conv.title}`}\n                              onClick={() => handleDelete(conv)}\n                              className={`${actionBtn} hover:text-red-600 dark:hover:text-red-400`}\n                            >\n                              <TrashIcon />\n                            </button>\n                          )}\n                        </span>\n                      </div>\n                    )}\n                  </li>\n                ))}\n              </ul>\n            </div>\n          ))\n        )}\n      </nav>\n\n      {/* Undo bar */}\n      {deleted && (\n        <div\n          role=\"status\"\n          className=\"flex items-center justify-between gap-2 border-t border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-950\"\n        >\n          <span className=\"truncate text-xs text-zinc-500 dark:text-zinc-400\">\n            Deleted &ldquo;{deleted.title}&rdquo;\n          </span>\n          <button\n            type=\"button\"\n            onClick={handleUndo}\n            className=\"shrink-0 rounded-md px-2 py-1 text-xs font-medium text-zinc-800 underline underline-offset-2 hover:text-zinc-950 dark:text-zinc-200 dark:hover:text-white\"\n          >\n            Undo\n          </button>\n        </div>\n      )}\n    </div>\n  );\n}\n","type":"registry:ui","target":"components/ui/conversation-sidebar.tsx"}]}