ThreadList

Multi-thread management for listing, creating, switching, archiving, and deleting conversations.

The ThreadList primitive manages multiple conversations by listing threads, creating new ones, switching between them, and archiving or deleting old ones. It's composed from three primitive namespaces: ThreadListPrimitive, ThreadListItemPrimitive, and ThreadListItemMorePrimitive.

Tabchange focus
cycle list
focus, open/close menu

Quick Start

Minimal example:

import {
  ThreadListPrimitive,
  ThreadListItemPrimitive,
} from "@assistant-ui/react";

<ThreadListPrimitive.Root>
  <ThreadListPrimitive.New>New Thread</ThreadListPrimitive.New>
  <ThreadListPrimitive.Items>
    {() => (
      <ThreadListItemPrimitive.Root>
        <ThreadListItemPrimitive.Trigger>
          <ThreadListItemPrimitive.Title fallback="New Chat" />
        </ThreadListItemPrimitive.Trigger>
      </ThreadListItemPrimitive.Root>
    )}
  </ThreadListPrimitive.Items>
</ThreadListPrimitive.Root>

Root renders a <div>, New renders a <button> that creates a new thread, and Items iterates over the thread list. Each item is composed from ThreadListItemPrimitive parts.

Runtime setup: primitives require runtime context. Wrap your UI in AssistantRuntimeProvider with a runtime (for example useLocalRuntime(...)). See Pick a Runtime.

Core Concepts

Three Namespaces

ThreadList is split across three namespaces to keep each level of abstraction composable:

  • ThreadListPrimitive: The outer container. Root wraps the list, New creates threads, and Items iterates over them.
  • ThreadListItemPrimitive: An individual thread row. Root provides context, Trigger switches to the thread, Title shows the thread name, and action buttons (Archive, Unarchive, Delete) manage the thread lifecycle.
  • ThreadListItemMorePrimitive: An overflow dropdown menu (built on Radix DropdownMenu) for thread actions. Compose action buttons with menu items using asChild.

Active State

Both ThreadListPrimitive.New and ThreadListItemPrimitive.Root get a data-active attribute when they represent the current thread. Use this for styling:

<ThreadListItemPrimitive.Root className="hover:bg-muted data-active:bg-muted">
  {/* ... */}
</ThreadListItemPrimitive.Root>

The New button gets data-active when the user is on a fresh, unsaved thread.

Keyboard Navigation

Every item stays a native Tab stop, and arrow keys are layered on top. Up/Down move between items and Right focuses an item's More button (Left/Right are mirrored in RTL). This is built into the primitives — no prop is required.

To fold the More menu into the same navigation, opt it in with sharedFocusGroup:

<ThreadListItemMorePrimitive.Root sharedFocusGroup>
  {/* Right opens the menu from the trigger; Left/Escape close it and
      return focus to the trigger, keeping the highlight continuous. */}
</ThreadListItemMorePrimitive.Root>

sharedFocusGroup joins the menu to the list's focus group, so its trigger and the open menu act as one keyboard-navigable unit: Right opens it and Left/Escape close it, synchronously restoring focus to the trigger so a group-has-focus-visible highlight never flickers. Enabling it forces the menu non-modal, because a focus trap and a shared focus group are mutually exclusive — the trap keeps focus inside the menu, the shared group lets it move out across the boundary. Without sharedFocusGroup the menu keeps its standard Radix DropdownMenu keyboard behavior (modal by default), so menus you already render are unchanged. Our assistant-ui thread-list registry component sets sharedFocusGroup for you.

Items Iterator

ThreadListPrimitive.Items now prefers a children render function, similar to ThreadPrimitive.Messages:

<ThreadListPrimitive.Items>
  {({ threadListItem }) => (
    <MyThreadItem threadId={threadListItem.id} />
  )}
</ThreadListPrimitive.Items>

components is deprecated. Use the children render function instead.

Archived Threads

Pass archived to Items to render archived threads separately:

<ThreadListPrimitive.Root>
  <ThreadListPrimitive.New>New Thread</ThreadListPrimitive.New>
  <ThreadListPrimitive.Items>
    {() => <ThreadListItem />}
  </ThreadListPrimitive.Items>

  <h3>Archived</h3>
  <ThreadListPrimitive.Items archived>
    {() => <ArchivedThreadItem />}
  </ThreadListPrimitive.Items>
</ThreadListPrimitive.Root>

Thread Actions

Archive, Unarchive, and Delete buttons disable automatically when the runtime capability is unavailable.

Composing Actions with Menu Items

The canonical pattern composes ThreadListItemPrimitive.Archive asChild with ThreadListItemMorePrimitive.Item to get both the action behavior AND the menu item styling:

<ThreadListItemPrimitive.Archive asChild>
  <ThreadListItemMorePrimitive.Item>
    <ArchiveIcon className="size-4" /> Archive
  </ThreadListItemMorePrimitive.Item>
</ThreadListItemPrimitive.Archive>

Archive provides the click handler and disabled logic. Item provides the menu item behavior and styling. asChild merges them into a single element.

Accessibility

The thread list leans on native button semantics and Radix's DropdownMenu; the arrow-key navigation is layered on top as a convenience, not a requirement.

  • Each ThreadListItemPrimitive.Trigger is a native <button> and its own Tab stop, so the whole list is reachable with Tab alone. The arrow keys (see Keyboard Navigation) speed traversal up, but nothing depends on them.
  • The item representing the current thread sets aria-current="true" (alongside data-active) on ThreadListItemPrimitive.Root, so assistive tech announces which conversation is open.
  • The More trigger exposes aria-haspopup="menu", and its panel is a Radix role="menu" with role="menuitem" children, so screen readers announce it as a menu and the arrow keys cycle its items.
  • With sharedFocusGroup, the More menu is non-modal and joins the list's focus group: opening and closing it move focus synchronously between the trigger and the menu, so keyboard focus is never dropped and the group-has-focus-visible highlight never flickers. Without it, the menu keeps Radix's default modal focus trap.

Parts

ThreadListPrimitive

Root

Container for the full thread list. Renders a <div> element unless asChild is set.

<ThreadListPrimitive.Root className="flex flex-col gap-1">
  <ThreadListPrimitive.Items>
    {() => <MyThreadItem />}
  </ThreadListPrimitive.Items>
</ThreadListPrimitive.Root>

New

Button that creates a new thread. Renders a <button> element unless asChild is set.

<ThreadListPrimitive.New className="rounded-lg border px-3 py-2 text-sm">
  New Thread
</ThreadListPrimitive.New>

Items

Renders a component per thread.

<ThreadListPrimitive.Items>
  {() => <MyThreadItem />}
</ThreadListPrimitive.Items>

Prop

Type

ItemByIndex

Renders a single thread at a specific index.

<ThreadListPrimitive.ItemByIndex
  index={0}
  components={{ ThreadListItem: MyThreadItem }}
/>

Prop

Type

index
number
components
ThreadListItemsComponentConfig

LoadMore

Button that appends the next page of threads. Renders a <button> element unless asChild is set, and is automatically disabled when the runtime is loading or when the adapter's last list() did not return a nextCursor. See Threads concepts for the adapter contract.

<ThreadListPrimitive.LoadMore className="rounded-lg border px-3 py-2 text-sm">
  Load more
</ThreadListPrimitive.LoadMore>

For scroll-driven loading, wrap aui.threads.loadMore() in your own IntersectionObserver at the application layer; assistant-ui ships the explicit button to keep the primitive surface predictable.

import { useAui, useAuiState } from "@assistant-ui/react";
import { useEffect, useRef } from "react";

function LoadMoreSentinel() {
  const aui = useAui();
  const disabled = useAuiState(
    (s) =>
      !s.threads.hasMore || s.threads.isLoading || s.threads.isLoadingMore,
  );
  const ref = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el || disabled) return;
    const observer = new IntersectionObserver(([entry]) => {
      if (entry?.isIntersecting) aui.threads.loadMore();
    });
    observer.observe(el);
    return () => observer.disconnect();
  }, [aui, disabled]);

  return disabled ? null : <div ref={ref} className="h-9" />;
}

ThreadListItemPrimitive

Root

Container for one thread list item. Renders a <div> element unless asChild is set.

<ThreadListItemPrimitive.Root className="flex items-center gap-2 rounded-lg px-2 py-1.5">
  <ThreadListItemPrimitive.Trigger className="flex-1 text-left">
    <ThreadListItemPrimitive.Title fallback="New Chat" />
  </ThreadListItemPrimitive.Trigger>
</ThreadListItemPrimitive.Root>

Trigger

Interactive button that selects the thread. Renders a <button> element unless asChild is set.

<ThreadListItemPrimitive.Trigger className="flex-1 text-left">
  <ThreadListItemPrimitive.Title fallback="New Chat" />
</ThreadListItemPrimitive.Trigger>

Title

Displays the thread title with a fallback prop. Renders a React fragment (no wrapper element). Use a <span> wrapper if you need to style it.

<ThreadListItemPrimitive.Title fallback="New Chat" />

Prop

Type

fallback?
ReactNode

Archive

Button that archives the current thread item. Renders a <button> element unless asChild is set.

<ThreadListItemPrimitive.Archive>Archive</ThreadListItemPrimitive.Archive>

Unarchive

Button that restores the current thread item from the archive. Renders a <button> element unless asChild is set.

<ThreadListItemPrimitive.Unarchive>Unarchive</ThreadListItemPrimitive.Unarchive>

Delete

Button that deletes the current thread item. Renders a <button> element unless asChild is set.

<ThreadListItemPrimitive.Delete>Delete</ThreadListItemPrimitive.Delete>

ThreadListItemMorePrimitive

Root

Root container for the overflow menu primitives. Pass sharedFocusGroup to fold the menu into the list's arrow-key navigation (see Keyboard Navigation); doing so forces it non-modal. Without it the menu honors the modal prop, defaulting to modal like Radix's DropdownMenu.

<ThreadListItemMorePrimitive.Root sharedFocusGroup>
  <ThreadListItemMorePrimitive.Trigger>More</ThreadListItemMorePrimitive.Trigger>
</ThreadListItemMorePrimitive.Root>

Trigger

Button that opens the overflow menu. Renders a <button> element unless asChild is set.

<ThreadListItemMorePrimitive.Trigger className="rounded-md p-1 hover:bg-muted">
  More
</ThreadListItemMorePrimitive.Trigger>

Content

The dropdown panel. Renders a <div> via a portal (positioned relative to the trigger).

<ThreadListItemMorePrimitive.Content className="rounded-md border bg-popover p-1 shadow-md">
  <ThreadListItemPrimitive.Archive asChild>
    <ThreadListItemMorePrimitive.Item>Archive</ThreadListItemMorePrimitive.Item>
  </ThreadListItemPrimitive.Archive>
</ThreadListItemMorePrimitive.Content>

Prop

Type

Item

Menu item slot inside the overflow menu. Renders a <div> element unless asChild is set.

<ThreadListItemMorePrimitive.Item>Archive</ThreadListItemMorePrimitive.Item>

Separator

Visual separator between groups of menu items. Renders a <div> element unless asChild is set.

<ThreadListItemMorePrimitive.Separator className="my-1 h-px bg-border" />

Patterns

Basic Thread List with New Button

<ThreadListPrimitive.Root className="flex flex-col gap-1">
  <ThreadListPrimitive.New className="flex items-center gap-2 rounded-lg border px-3 py-2 text-sm">
    <PlusIcon className="size-4" /> New Thread
  </ThreadListPrimitive.New>
  <ThreadListPrimitive.Items>
    {() => <ThreadListItem />}
  </ThreadListPrimitive.Items>
</ThreadListPrimitive.Root>

Thread Item with More Menu

function ThreadListItem() {
  return (
    <ThreadListItemPrimitive.Root className="group flex items-center rounded-lg hover:bg-muted data-active:bg-muted">
      <ThreadListItemPrimitive.Trigger className="flex-1 truncate px-3 py-2 text-sm">
        <ThreadListItemPrimitive.Title fallback="New Chat" />
      </ThreadListItemPrimitive.Trigger>
      <ThreadListItemMorePrimitive.Root>
        <ThreadListItemMorePrimitive.Trigger className="opacity-0 group-hover:opacity-100">
          <MoreHorizontalIcon className="size-4" />
        </ThreadListItemMorePrimitive.Trigger>
        <ThreadListItemMorePrimitive.Content>
          <ThreadListItemPrimitive.Archive asChild>
            <ThreadListItemMorePrimitive.Item>Archive</ThreadListItemMorePrimitive.Item>
          </ThreadListItemPrimitive.Archive>
          <ThreadListItemMorePrimitive.Separator />
          <ThreadListItemPrimitive.Delete asChild>
            <ThreadListItemMorePrimitive.Item>Delete</ThreadListItemMorePrimitive.Item>
          </ThreadListItemPrimitive.Delete>
        </ThreadListItemMorePrimitive.Content>
      </ThreadListItemMorePrimitive.Root>
    </ThreadListItemPrimitive.Root>
  );
}

Archived Threads Section

<ThreadListPrimitive.Root>
  <ThreadListPrimitive.New>New Thread</ThreadListPrimitive.New>

  <h3 className="px-3 pt-4 text-xs font-medium text-muted-foreground">Recent</h3>
  <ThreadListPrimitive.Items>
    {() => <ThreadListItem />}
  </ThreadListPrimitive.Items>

  <h3 className="px-3 pt-4 text-xs font-medium text-muted-foreground">Archived</h3>
  <ThreadListPrimitive.Items archived>
    {() => <ArchivedItem />}
  </ThreadListPrimitive.Items>
</ThreadListPrimitive.Root>

In the ArchivedItem component, use ThreadListItemPrimitive.Unarchive instead of Archive to let users restore threads.

Relationship to Components

The shadcn ThreadList component wraps these primitives with Tailwind styling in a sidebar layout, including a skeleton loading state and a prebuilt more menu with archive and delete actions. Start there for a default thread list implementation. Reach for the primitives directly when you need a custom layout, different actions, or integration with your own sidebar or navigation system.

API Reference

For full prop details on every part, see the API reference for each namespace: