Elements

Elements · AUI connected · AUI

Thread list

Runtime-backed conversation switching with search, active selection, and thread actions.

fig. 01

Installation

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 init

Then 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.

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

app/sidebar.tsx
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.

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 actually 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. Its rename and delete icons are presentational: they render on hover but carry no click handler, since this file is copied into your project specifically so you attach your own.

Examples

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.

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");

Restyle

Both lanes take className on the root. Standalone, the active row's fill and the 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

PartRendersNotes
RootdivProvides the thread list's keyboard focus group.
NewbuttonSwitches to the runtime's placeholder new thread. Carries data-active while already on it.
ItemByIndexno fixed elementRenders 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

PartRendersNotes
ThreadListItemPrimitive.RootdivCarries data-active / aria-current for the main thread.
ThreadListItemPrimitive.TriggerbuttonSwitches to this thread.
ThreadListItemPrimitive.TitletextThe thread's title; fallback renders when it has none yet.
ThreadListItemPrimitive.ArchivebuttonArchives the thread. Accepts asChild.
ThreadListItemPrimitive.DeletebuttonDeletes the thread. Accepts asChild.
ThreadListItemMorePrimitive.Rootno fixed elementsharedFocusGroup folds the menu into the list's own keyboard navigation instead of a standalone modal dropdown.
ThreadListItemMorePrimitive.Trigger / .Content / .Itembutton / menu / menu itemThe overflow menu shell.

Thread list state

SelectorTypeDescription
s.threads.threadIdsreadonly string[]Ids in display order.
s.threads.threadItemsreadonly ThreadListItemState[]One state object per id, including title and lastMessageAt.
s.threads.isLoadingbooleanTrue while the list itself is still loading.
s.threadListItem.idstringId of the item in scope, inside ItemByIndex.
s.threadListItem.titlestring | undefinedUndefined until the runtime names the thread.
s.threadListItem.isRunningbooleanWhether this thread has a run in progress, even if it is not the open one.
aui.threads.item({ id }).rename(title)(title: string) => voidRenames a thread by id without going through the more menu.