Thread list
Runtime-backed conversation switching with search, active selection, and thread actions.
Installation
Install manually
npx shadcn@latest add "@assistant-ui/thread-list"First time? Set up a runtime
Runtime components read their state from an assistant-ui runtime. Add one to an existing project:
npx assistant-ui@latest initThen wrap your app in a runtime provider:
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";
export default function App() {
const runtime = useChatRuntime({
transport: new AssistantChatTransport({ api: "/api/chat" }),
});
return (
<AssistantRuntimeProvider runtime={runtime}>
{/* your components */}
</AssistantRuntimeProvider>
);
}The installation guide covers new projects, templates, and API routes.
npx shadcn@latest add "@assistant-ui/elements-thread-list"Props-driven: no runtime or provider required.
Thread list is the conversation switcher: a new-thread button, search once there is something to search, and every past thread grouped by day with rename, archive, and delete. With a runtime it reads and writes the thread list directly; standalone you pass in the rows and report which one is active.
Getting started
ThreadList takes no props: mount it anywhere inside a runtime provider and it reads and writes the thread list on its own.
Use it directly
import { ThreadList } from "@/components/assistant-ui/elements/thread-list.aui";
export function Sidebar() {
return (
<aside className="w-64 border-e p-2">
<ThreadList />
</aside>
);
}Thread list sidebar already pairs this with a full collapsible sidebar shell, if that is what you need instead of a bare panel.
Standalone, the element is a controlled row list: you hold the threads and the active index, it renders rows, and it reports selection, rename, and delete clicks. There is no built-in new-thread button or search; both stay outside the element.
Hold the thread state
"use client";
import { useState } from "react";
import {
ThreadList,
type ThreadItem,
} from "@/components/assistant-ui/elements/thread-list";
const threads: ThreadItem[] = [
{ id: "rename", title: "Renaming a component", time: "2m", unread: true },
{ id: "deploy", title: "Deploy checklist", time: "1h" },
];
export function Sidebar() {
const [activeIndex, setActiveIndex] = useState(0);
return (
<ThreadList
threads={threads}
activeIndex={activeIndex}
onActiveIndexChange={setActiveIndex}
/>
);
}Anatomy
<div data-slot="aui_thread-list-root">
<button data-slot="aui_thread-list-new">New Thread</button>
<div data-slot="aui_thread-list-search" /> {/* runtime only, once a thread exists */}
<div data-slot="aui_thread-list-items">
<div data-slot="aui_thread-list-group-label">Today</div>
<div data-slot="aui_thread-list-item">
<button data-slot="aui_thread-list-item-trigger">
{/* spinner while running, then the title */}
</button>
<button data-slot="aui_thread-list-item-more" aria-label="More options" />
</div>
</div>
</div>Runtime, the search box only renders once at least one thread exists, and it filters case-insensitively against each thread's title, falling back to matching "New Chat" for untitled ones; no match shows "No threads found" instead of the list. Rows group under Today, Yesterday, and Earlier by lastMessageAt, but only when at least one visible thread carries a timestamp, otherwise the list renders flat with no group labels. The active thread carries data-active and aria-current. Rename swaps a row's trigger for an inline input in place (Enter commits, Escape cancels, blur commits); it never navigates away from the list.
Standalone, the element renders a single static "Today" label above every row regardless of the actual dates, since it has no timestamps to group by, and it has no new-thread control or search of its own. Rename and delete are controlled actions: each button renders only when its matching callback is provided.
Examples
Search
Search is built in and needs no wiring; it appears once the thread list is non-empty and narrows to matching titles as you type.
There is no search input in the element itself. Filter the array you already hold before passing it in:
<ThreadList
threads={threads.filter((t) =>
t.title.toLowerCase().includes(query.toLowerCase()),
)}
activeIndex={activeIndex}
onActiveIndexChange={setActiveIndex}
/>Renaming
The more menu's Rename item is already wired. To rename without going through the menu, call the same method it uses:
const aui = useAui();
aui.threads.item({ id: threadId }).rename("Q3 planning");Use onRename to open your own inline editor or dialog. The callback receives the row index, as selection and deletion do:
<ThreadList
threads={threads}
activeIndex={activeIndex}
onActiveIndexChange={setActiveIndex}
onRename={(index) => setRenamingIndex(index)}
onDelete={(index) => {
setThreads((current) => current.filter((_, i) => i !== index));
setActiveIndex((current) =>
Math.max(0, current - Number(index <= current)),
);
}}
/>Restyle
Standalone, ThreadList takes className on the root; the runtime ThreadList takes no props, so size it from a wrapper element. The standalone active row's fill and trailing timestamp use the shared field and mono tokens from surfaces.tsx, so restyling those two tokens restyles every element that uses them.
<ThreadList className="max-w-[280px]" /* ... */ />API reference
ThreadListPrimitive
| Part | Renders | Notes |
|---|---|---|
Root | div | Provides the thread list's keyboard focus group. |
New | button | Switches to the runtime's placeholder new thread. Carries data-active while already on it. |
ItemByIndex | no fixed element | Renders one item at index through a components.ThreadListItem you supply. ThreadList groups these by day itself rather than using the simpler Items primitive, which renders every item ungrouped for a list with no day headers. |
ThreadListItemPrimitive and ThreadListItemMorePrimitive
| Part | Renders | Notes |
|---|---|---|
ThreadListItemPrimitive.Root | div | Carries data-active / aria-current for the main thread. |
ThreadListItemPrimitive.Trigger | button | Switches to this thread. |
ThreadListItemPrimitive.Title | text | The thread's title; fallback renders when it has none yet. |
ThreadListItemPrimitive.Archive | button | Archives the thread. Accepts asChild. |
ThreadListItemPrimitive.Delete | button | Deletes the thread. Accepts asChild. |
ThreadListItemMorePrimitive.Root | no fixed element | sharedFocusGroup folds the menu into the list's own keyboard navigation instead of a standalone modal dropdown. |
ThreadListItemMorePrimitive.Trigger / .Content / .Item | button / menu / menu item | The overflow menu shell. |
Thread list state
| Selector | Type | Description |
|---|---|---|
s.threads.threadIds | readonly string[] | Ids in display order. |
s.threads.threadItems | readonly ThreadListItemState[] | One state object per id, including title and lastMessageAt. |
s.threads.isLoading | boolean | True while the list itself is still loading. |
s.threadListItem.id | string | Id of the item in scope, inside ItemByIndex. |
s.threadListItem.title | string | undefined | Undefined until the runtime names the thread. |
s.threadListItem.isRunning | boolean | Whether this thread has a run in progress, even if it is not the open one. |
aui.threads.item({ id }).rename(title) | (title: string) => void | Renames a thread by id without going through the more menu. |
ThreadList
| Prop | Type | Default | Description |
|---|---|---|---|
threads | readonly ThreadItem[] | required | The rows to render, in order. |
activeIndex | number | required | Index of the highlighted row. |
onActiveIndexChange | (index: number) => void | Called with the clicked row's index. Without it, thread rows render as non-interactive list items. | |
onRename | (index: number) => void | Renders a labelled rename button and reports the clicked row. | |
onDelete | (index: number) => void | Renders a labelled delete button and reports the clicked row. | |
className | string | Merged onto the root. |
All other div props are forwarded to the root.
ThreadItem
| Field | Type | Description |
|---|---|---|
id | string | Stable row identifier used to preserve identity when threads change. |
title | string | Row label. |
time | string | Trailing timestamp text, already formatted. |
unread | boolean | undefined | Shows a dot before the timestamp when true and the row is not active. |