Chat

Chat History Sidebar

Conversation history sidebar with a New conversation button, a search field and a list whose rows rename inline or delete from a dropdown menu. Shows an empty state when nothing matches.

Free, MIT licenseOpen in Browse
"use client"

import { useState } from "react"
import {
  DotsThreeIcon,
  PencilSimpleIcon,
  PlusIcon,
  TrashIcon,
} from "@phosphor-icons/react/dist/ssr"

import {
  Section,
  SectionContent,
} from "@/components/editorial/section"
import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
  Empty,
  EmptyDescription,
  EmptyHeader,
  EmptyTitle,
} from "@/components/ui/empty"
import {
  Sidebar,
  SidebarContent,
  SidebarGroup,
  SidebarGroupContent,
  SidebarHeader,
  SidebarInput,
  SidebarMenu,
  SidebarMenuAction,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
} from "@/components/ui/sidebar"

// The copy: change text here, not in the markup.
const COPY = {
  create: "New conversation",
  search: "Search conversations",
  actions: "More",
  rename: "Rename",
  remove: "Delete",
  untitled: "Untitled",
  emptyTitle: "No conversations yet",
  emptyBody: "Start one and it will show up here.",
} as const

// when is a string the app already formatted; the list never tells time.
const CONVERSATIONS = [
  { id: "c1", title: "Which hero for a product page", when: "an hour ago" },
  { id: "c2", title: "Type scale questions", when: "yesterday" },
  { id: "c3", title: "Composable vs ready-made", when: "3 days ago" },
]

export default function ChatHistory01() {
  const [conversations, setConversations] = useState(CONVERSATIONS)
  const [currentId, setCurrentId] = useState("c1")
  const [query, setQuery] = useState("")
  const [editingId, setEditingId] = useState<string | null>(null)

  const shown = conversations.filter(
    (c) => !query || c.title.toLowerCase().includes(query.toLowerCase())
  )
  const rename = (id: string, title: string) => {
    setConversations((prev) =>
      prev.map((c) =>
        c.id === id ? { ...c, title: title.trim() || c.title } : c
      )
    )
    setEditingId(null)
  }

  return (
    <Section>
      <SectionContent>
        <SidebarProvider className="min-h-0">
          <Sidebar
            collapsible="none"
            className="h-104 max-w-xs overflow-hidden rounded-xl ring-1 ring-border"
          >
            <SidebarHeader>
              <Button
                onClick={() =>
                  setConversations((prev) => [
                    {
                      id: `c-${Date.now()}`,
                      title: COPY.untitled,
                      when: "now",
                    },
                    ...prev,
                  ])
                }
              >
                <PlusIcon weight="light" data-icon="inline-start" />
                {COPY.create}
              </Button>
              <SidebarInput
                value={query}
                placeholder={COPY.search}
                aria-label={COPY.search}
                onChange={(event) => setQuery(event.target.value)}
              />
            </SidebarHeader>
            <SidebarContent>
              <SidebarGroup>
                <SidebarGroupContent>
                  {shown.length === 0 ? (
                    <Empty>
                      <EmptyHeader>
                        <EmptyTitle>{COPY.emptyTitle}</EmptyTitle>
                        <EmptyDescription>{COPY.emptyBody}</EmptyDescription>
                      </EmptyHeader>
                    </Empty>
                  ) : (
                    <SidebarMenu>
                      {shown.map((conversation) => (
                        <SidebarMenuItem key={conversation.id}>
                          {editingId === conversation.id ? (
                            <SidebarInput
                              autoFocus
                              defaultValue={conversation.title}
                              aria-label={COPY.rename}
                              onBlur={(event) =>
                                rename(conversation.id, event.target.value)
                              }
                              onKeyDown={(event) => {
                                if (event.key === "Enter")
                                  rename(
                                    conversation.id,
                                    event.currentTarget.value
                                  )
                                if (event.key === "Escape") setEditingId(null)
                              }}
                            />
                          ) : (
                            <>
                              <SidebarMenuButton
                                size="lg"
                                isActive={conversation.id === currentId}
                                onClick={() => setCurrentId(conversation.id)}
                              >
                                <span className="grid">
                                  <span className="truncate">
                                    {conversation.title}
                                  </span>
                                  <span className="text-xs text-muted-foreground">
                                    {conversation.when}
                                  </span>
                                </span>
                              </SidebarMenuButton>
                              <DropdownMenu>
                                <DropdownMenuTrigger
                                  render={<SidebarMenuAction showOnHover />}
                                  aria-label={COPY.actions}
                                >
                                  <DotsThreeIcon weight="bold" />
                                </DropdownMenuTrigger>
                                <DropdownMenuContent align="end">
                                  <DropdownMenuGroup>
                                    <DropdownMenuItem
                                      onClick={() =>
                                        setEditingId(conversation.id)
                                      }
                                    >
                                      <PencilSimpleIcon weight="light" />
                                      {COPY.rename}
                                    </DropdownMenuItem>
                                    <DropdownMenuItem
                                      variant="destructive"
                                      onClick={() =>
                                        setConversations((prev) =>
                                          prev.filter(
                                            (c) => c.id !== conversation.id
                                          )
                                        )
                                      }
                                    >
                                      <TrashIcon weight="light" />
                                      {COPY.remove}
                                    </DropdownMenuItem>
                                  </DropdownMenuGroup>
                                </DropdownMenuContent>
                              </DropdownMenu>
                            </>
                          )}
                        </SidebarMenuItem>
                      ))}
                    </SidebarMenu>
                  )}
                </SidebarGroupContent>
              </SidebarGroup>
            </SidebarContent>
          </Sidebar>
        </SidebarProvider>
      </SectionContent>
    </Section>
  )
}

Installation

pnpm dlx shadcn add @01orbital/chat-history-01
npx shadcn add @01orbital/chat-history-01
yarn shadcn add @01orbital/chat-history-01
bunx --bun shadcn add @01orbital/chat-history-01

Free under the MIT license. Add the @01orbital registry to components.json once (install guide), then run the command.

It lands at components/blocks/chat/chat-history-01/index.tsx, as source you own and edit.

Usage

import ChatHistory01 from "@/components/blocks/chat/chat-history-01"

export default function Example() {
  return <ChatHistory01 />
}

Dependencies

npm: @phosphor-icons/react. The CLI installs them for you.

Registry pieces it installs with it:

  • button
  • dropdown-menu
  • empty
  • section
  • sidebar