Thread list
Where past chats live: a dated sidebar, chats filed into projects, a search palette that matches inside messages, a full page for managing chats in bulk, and a slim rail that opens on hover.
Loading preview…
Install
- 1
Run this command in your Tailwind CSS project. It adds
components/ai/thread-list-default.tsxand installs its dependencies.$ npx shadcn@latest add https://gradientdeck.com/ai-components/r/thread-list-default.json
Usage & API
Example
import { ThreadList } from "@/components/ai/thread-list-default"
<ThreadList
threads={[]}
activeId={null}
onSelect={() => {}}
onNew={() => {}}
onRename={() => {}}
onDelete={() => {}}
/>Props ThreadList
| Prop | Type & description | Required |
|---|---|---|
threads | Thread[] | Required |
activeId | string | null | Required |
onSelect | (id: string) => void | Required |
onNew | () => void | Required |
onRename | (id: string, title: string) => void | Required |
onDelete | (id: string) => void | Required |
Dependencies
- React
- Tailwind CSS
- motion
- lucide-react
Implementation notes
Sidebar exports ThreadList with threads, activeId, onSelect, onNew, onRename, and onDelete. A thread has an id, a title, and a group. The nav is labelled Chats. New chat calls onNew. The search box filters titles in lower case and has a clear button. Each non-empty group gets a heading: Today, Yesterday, or Previous 7 days. The active row sets aria-current page. Rename opens an input labelled Chat title. Enter or blur saves a non-empty trim through onRename. Escape cancels. Delete asks Delete this chat? in the row, with Cancel and Delete, and Escape cancels. The confirm button then calls onDelete. Double-clicking the title starts a rename.
Example
import { ProjectSidebar } from "@/components/ai/thread-list-projects"
<ProjectSidebar
projects={[]}
chats={[]}
activeId={null}
onSelect={() => {}}
onMove={() => {}}
onTogglePin={() => {}}
onNew={() => {}}
/>Props ProjectSidebar
| Prop | Type & description | Required |
|---|---|---|
projects | Project[] | Required |
chats | ProjectChat[] | Required |
activeId | string | null | Required |
onSelect | (id: string) => void | Required |
onMove | (chatId: string, projectId: string | null) => voidMove a chat into a project, or out of all projects with `null`. | Required |
onTogglePin | (chatId: string) => void | Required |
onNew | () => void | Required |
defaultExpanded | string[]Projects open on first render. | Optional |
Dependencies
- React
- Tailwind CSS
- motion
- lucide-react
Implementation notes
Projects exports ProjectSidebar with projects, chats, activeId, onSelect, onMove, onTogglePin, onNew, and defaultExpanded. A project has id, name, and color. A chat has id, title, projectId, and an optional pinned flag. Pinned chats form their own block. Chats with no project and no pin are Recent. A project section expands, and defaultExpanded is the ids open on first render. Dragging a chat and dropping it on a project calls onMove with the chat id and the project id. Moving out of every project passes null. The row menu can pin or unpin through onTogglePin, and can move without dragging. The menu is positioned inside the sidebar and closed on Escape.
Example
import { Highlight } from "@/components/ai/thread-list-search"
<Highlight
text="…"
query="…"
/>Props Highlight
| Prop | Type & description | Required |
|---|---|---|
text | string | Required |
query | string | Required |
Dependencies
- React
- Tailwind CSS
- motion
- lucide-react
Implementation notes
Search palette exports SearchPalette and Highlight. Props are open, onClose, chats, projects, onOpen, and defaultQuery. A chat has messages, a display date, a thisWeek flag, and a projectId. With an empty query, the list is chats, and onOpen is called with message index -1. With a query, every message that contains it becomes a row, and onOpen receives that message index. Highlight wraps the matches in mark. A snippet shows the match with some words on either side. Chips filter to this week and to one project. Arrow keys move the active row. The input focuses when open flips from false to true, not when the palette is mounted already open. The palette does not change your route. You do that in onOpen.
Example
import { ChatLibrary } from "@/components/ai/thread-list-library"
<ChatLibrary
chats={[]}
projects={[]}
onMove={() => {}}
onArchive={() => {}}
onDelete={() => {}}
/>Props ChatLibrary
| Prop | Type & description | Required |
|---|---|---|
chats | LibraryChat[] | Required |
projects | LibraryProject[] | Required |
onMove | (ids: string[], projectId: string | null) => void | Required |
onArchive | (ids: string[], archived: boolean) => void | Required |
onDelete | (ids: string[]) => void | Required |
onOpen | (id: string) => void | Optional |
Dependencies
- React
- Tailwind CSS
- motion
- lucide-react
Implementation notes
All chats page exports ChatLibrary with chats, projects, onMove, onArchive, onDelete, and onOpen. A library chat has title, projectId, lastMessage, age as a number of minutes for sorting, updated as a display string, and an optional archived flag. The page title is All chats. Tabs split the list, and a search box filters it. Sort offers Last updated, Title, and Project. The table columns are Title, Project, Last message, and Updated. Checking rows shows a bar labelled Selected chats, with Move to project, Archive or Restore, and Delete. Move calls onMove with the ids and a project id, or null. Archive calls onArchive with the ids and a boolean. A row can still open through onOpen.
Example
import { ChatRail } from "@/components/ai/thread-list-rail"
<ChatRail
chats={[]}
activeId={null}
onSelect={() => {}}
onNew={() => {}}
pinned={true}
onPinnedChange={() => {}}
/>Props ChatRail
| Prop | Type & description | Required |
|---|---|---|
chats | RailChat[] | Required |
activeId | string | null | Required |
onSelect | (id: string) => void | Required |
onNew | () => void | Required |
onSearch | () => void | Optional |
pinned | booleanPinned keeps the panel open and pushes the page over. | Required |
onPinnedChange | (pinned: boolean) => void | Required |
recentCount | numberHow many chats show as icons when collapsed. | Optional |
Dependencies
- React
- Tailwind CSS
- motion
- lucide-react
Implementation notes
Collapsible rail exports ChatRail with chats, activeId, onSelect, onNew, onSearch, pinned, onPinnedChange, and recentCount, default 5. A rail chat has a title, a group of Today, Yesterday, or Previous 7 days, and a tint. Collapsed width is 56px. The panel is 260px. Hover opens it after 140ms. pinned keeps it open and the preview pushes the page. recentCount is how many chats stay visible as icons when collapsed. Those icons are initials from the title. The active row sets aria-current page. New chat and Search are rows in the same list. Pin calls onPinnedChange. The component does not persist pinned. You pass it back.
Choosing a layout
Use the sidebar when there is one list and the useful groups are dates. Double-click a title, or use the pencil, to rename. Use Projects when chats belong to named work and people will drag them. A chat with no project and no pin falls into Recent. Use the search palette when a person remembers a phrase from inside a message. The result passes you the chat id and the message index. Use the library when the job is bulk management, not switching chats while writing. Use the rail when the list should get out of the way and come back on hover, with recentCount icons still visible when it is closed.
Do not use the sidebar search as a full-text search. It filters titles only, and the empty copy is No chats match, plus the query. The palette is the one that walks messages. Do not use the library as a narrow sidebar. It is a page: tabs, a sort menu, and a table with Title, Project, Last message, and Updated. Do not invent groups other than the ones each variant has. The sidebar's group field is today, yesterday, or week. The rail's group is the display string Today, Yesterday, or Previous 7 days.
Why does sidebar search miss a phrase I know is in the chat?
ThreadList compares the query with the title only. Message text is not on the Thread type. SearchPalette is the variant that stores messages and matches inside them. It also tells you which message, through the index passed to onOpen. Index -1 means the person opened a chat with no query. The sidebar's empty state prints the query in No chats match. Clearing the field is a button, not a side effect of selecting a chat.
What is the difference between pinning a chat and pinning the rail?
On ProjectSidebar, onTogglePin pins one chat into the pinned block, and the chat's pinned flag is yours to store. On ChatRail, pinned is the panel: true keeps the 260px list open after the pointer leaves, and false lets it collapse back to 56px. recentCount still limits how many icons show in the collapsed rail. The header of a single chat can also pin that chat, but that control lives on Thread header, not here. None of these write to storage.