Chat Sidebar

チャットアプリ向けのサイドバーです。上部にロゴ、検索、New chat、その下に独自のリンク、ピン留めされたチャット、チャットを表示するために展開するプロジェクト、日ごとにグループ化された最近のチャットを配置します。行にはホバーメニューと右クリックメニュー、インラインでの名前変更、取り消しできる削除、リアルタイムの返信状態があります。

ChatGPT、Claude、Perplexity、Grokはどれも同じサイドバーに落ち着きました。上部からチャットを始めるか検索し、いくつかのチャットをピン留めし、作業をプロジェクトにまとめ、それ以外は日ごとにさかのぼってスクロールします。Chat Sidebarはそのサイドバーで、HextaUIのSidebarのinsetバリアントの上に作られているため、会話はフレームの内側にある角丸の独立したパネルに収まります。デスクトップではアイコンレールに折りたたまれ、スマートフォンでは画面いっぱいにスライドインし、チャットを選ぶとすぐにスライドして消えます。

ヘッダーには、ロゴ、検索ボタン、折りたたみボタンが並びます。その下にNew chatがあり、ショートカットが常に表示され、続いて渡したリンク(Images、Library、Pluginsなど)が並びます。プロジェクトは折りたたみ可能なフォルダーで、それぞれ独自のアイコンを持ち、そのチャットは履歴ではなくフォルダー内にインデントして表示されます。最近のチャットは、Today、Yesterday、Previous 7 days、Previous 30 days、その後は月ごとにグループ化され、スクロールに合わせてページ単位で描画されるため、数千件のチャットでも即座に開きます。

すべての行には、ホバーで表示されるメニューと、右クリックで開く同じメニューがあります。ピン留め、名前の変更、プロジェクトへの移動、削除です。名前の変更はダブルクリックまたはF2でその場で行います。削除はチャットをすぐに非表示にし、onDeleteを呼ぶ前の5秒間Undoを提示します。返信中のチャットにはスピナーが、別の場所で完了したチャットにはドットが表示され、どちらもホバー時にはメニューボタンに場所を譲ります。アカウントメニューは上方向に開き、渡した項目の上に名前とメールアドレスが表示されます。

  1. Proレジストリをcomponents.jsonに追加する

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  2. トークンを追加する

    アカウントページでトークンを作成し、.env.local に HEXTAUI_PRO_TOKEN として設定してください。

  3. ブロックを追加する

    pnpm dlx shadcn@latest add @hextaui-pro/chat-sidebar

自分の状態に接続する

チャットと、対応するハンドラーを渡してください。各メニュー項目は対応するハンドラーが設定されている場合にだけ表示されるため、読み取り専用の履歴ならonSelectとonNewChatだけで済みます。

"use client"

import * as React from "react"

import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"

import { ChatSidebar } from "@/components/blocks/chat-sidebar/chat-sidebar"
import type { SidebarChat } from "@/components/blocks/chat-sidebar/chats"

export function App({ initialChats }: { initialChats: SidebarChat[] }) {
  const [chats, setChats] = React.useState(initialChats)
  const [activeId, setActiveId] = React.useState<string | null>(null)

  const update = (id: string, change: Partial<SidebarChat>) =>
    setChats((current) => current.map((chat) => (chat.id === id ? { ...chat, ...change } : chat)))

  return (
    <SidebarProvider>
      <ChatSidebar
        chats={chats}
        activeId={activeId}
        onSelect={setActiveId}
        onNewChat={() => setActiveId(null)}
        onRename={(id, title) => update(id, { title })}
        onPinnedChange={(id, pinned) => update(id, { pinned })}
        onDelete={(id) => setChats((current) => current.filter((chat) => chat.id !== id))}
        user={{ name: "Mia Chen", plan: "Pro plan" }}
      />
      <SidebarInset />
    </SidebarProvider>
  )
}

プロジェクトとルーティング

各プロジェクトは、独自のアイコンを持つ折りたたみ可能なフォルダーです。そのチャットは、日付別の履歴ではなく、フォルダーの中にインデントして表示されます。onMoveを指定すると、すべての行にMove to projectが追加されます。

"use client"

import { useRouter } from "next/navigation"

import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"

import { ChatSidebar } from "@/components/blocks/chat-sidebar/chat-sidebar"
import type { SidebarChat, SidebarProject } from "@/components/blocks/chat-sidebar/chats"

export function Workspace({
  chats,
  projects,
  activeId,
  moveChat,
  children,
}: {
  chats: SidebarChat[]
  projects: SidebarProject[]
  activeId: string | null
  moveChat: (id: string, projectId: string | null) => void
  children: React.ReactNode
}) {
  const router = useRouter()

  return (
    <SidebarProvider>
      <ChatSidebar
        chats={chats}
        projects={projects}
        activeId={activeId}
        onSelect={(id) => router.push(`/c/${id}`)}
        onNewChat={() => router.push("/")}
        defaultOpenProjects={projects.slice(0, 1).map((project) => project.id)}
        onMove={moveChat}
      />
      <SidebarInset>{children}</SidebarInset>
    </SidebarProvider>
  )
}

構造

外側から内側へ組み合わせるパーツ。

パーツ説明
ChatSidebarサイドバー全体。ヘッダー、New chatとリンク、ピン留めされたチャット、プロジェクト、日付別の履歴、アカウントメニュー。
groupChatsチャットを新しい順に並べ、サイドバーに表示する日付別のグループに分けます。
searchChats検索ダイアログが使う、単語ベースのタイトル検索。クエリが空の場合は、最新の8件のチャットを返します。
プロパティ型デフォルト
chatsすべてのチャットを、任意の順序で。それぞれがid、title、updatedAt(ms)と、任意のprojectId、pinned、status("streaming" または "unread")を持ちます。
SidebarChat[]–
projectsid、name、任意のiconを持つプロジェクト。それぞれが、チャットを収める折りたたみ可能なフォルダーとして描画されます。空の場合、セクションは非表示になります。
SidebarProject[][]
activeId開いているチャット。現在のページとして示されます。
string | null–
onSelectリスト、検索、アイコンレール内の最近のカードからチャットが選ばれたときに呼ばれます。
(id: string) => void–
onNewChatNew chatボタンと⌘⇧Oから呼ばれます。
() => void–
linksNew chatの下に追加する行で、Images、Library、Pluginsなどです。レールではツールチップ付きのアイコンのままになります。
{ label, icon, href?, active?, onSelect? }[][]
defaultOpenProjects展開された状態で始まるプロジェクト。
string[][]
onProjectOpenChangeプロジェクトが展開または折りたたまれたときに呼ばれるので、状態を記憶できます。
(id: string, open: boolean) => void–
onRenameRename、ダブルクリック、F2を有効にします。タイトルが実際に変更されたときだけ呼ばれます。
(id: string, title: string) => void–
onPinnedChangePinとUnpinを有効にします。
(id: string, pinned: boolean) => void–
onMoveMove to projectを有効にします。nullはチャットをプロジェクトから外します。
(id: string, projectId: string | null) => void–
onDeleteDeleteを有効にします。5秒間のUndoの猶予が過ぎてから呼ばれます。
(id: string) => void–
brand検索ボタンと折りたたみボタンの隣、上部に表示される自分のロゴ。
ReactNode–
userフッターにアカウントボタンを表示します。
{ name, email?, image?, plan? }–
accountMenu名前とメールアドレスの下に表示される、Settings、Helpサブメニュー、Log outなどのDropdownMenu項目。
ReactNode–
pageSize一度に描画する履歴の行数。リストの末尾に近づくと、さらに読み込まれます。
number60
classNameSidebarルートのクラス。
string–
キーアクション
⌘Kチャット検索を開閉します。ヘッダーの検索ボタンも同じ動作です。
⌘⇧O新しいチャットを開始します。
⌘Bアイコンレールに折りたたむか、展開します。スマートフォンでは、全画面のサイドバーを開閉します。
F2フォーカスのあるチャットの名前を変更します。ダブルクリックも同じ動作です。
Enterフォーカスのあるプロジェクトを展開または折りたたみます。名前の変更中は新しいタイトルを保存し、フィールドを離れても保存されます。
Esc名前の変更をキャンセルするか、メニューや検索を閉じます。
  • 開いているチャットにはaria-current="page"が付き、各行のメニューボタンには「Options for Pricing page copy」のようにチャットにちなんだ名前が付きます。返信の状態は、スピナーやドットで示されるだけでなく、「Replying」と「Unread reply」として読み上げられます。
  • メニューボタンはマウスユーザーにはホバーで表示されますが、Tabでも到達でき、すべての行の操作がそのメニューにあるため、右クリックやダブルクリックに依存するものはありません。プロジェクトはaria-expandedを報告し、一度開いたプロジェクトのチャットはブラウザーの検索で見つけられます。
  • タイトルのタイピング表示と、スマートフォンでの全画面スライドは、モーション軽減の設定を尊重します。Undoはタイムアウトするまでトーストに残り、チャットが削除されてもフォーカスは元の場所に留まります。

使用技術

Chat Sidebar を構成する無料のHextaUIコンポーネントです。それぞれ単独でインストールできます。

コード

3 個のファイルを components/blocks/chat-sidebar に追加しました。