Elements

Elements · Thread

Thread search

History you can actually get back into: pinned first, then grouped by when.

pinned
Today
Yesterday
Earlier
fig. 01

Installation

npx shadcn@latest add "@assistant-ui/elements-thread-search"
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.

A jump list for past conversations: type to filter, pinned threads stay first, and the rest fall into the groups you hand it. With a runtime the list comes from the thread list; standalone you supply the threads and the groups yourself.

Getting started

A runtime keeps every thread's title, id, and recency in s.threads.threadItems. Mapping that into SearchableThread[] and selecting on it is the whole integration; there is no dedicated search primitive to compose.

Map thread state into the list

components/assistant-ui/elements/thread-search-panel.tsx
"use client";

import { useMemo, useState } from "react";
import { useAui, useAuiState } from "@assistant-ui/react";
import {
  ThreadSearch,
  type SearchableThread,
} from "@/components/assistant-ui/elements/thread-search";

const DAY = 86_400_000;

function groupFor(date: Date | undefined, now: number) {
  if (!date) return "Earlier";
  if (date.getTime() >= now - DAY) return "Today";
  if (date.getTime() >= now - 2 * DAY) return "Yesterday";
  return "Earlier";
}

export function ThreadSearchPanel() {
  const aui = useAui();
  const items = useAuiState((s) => s.threads.threadItems);
  const activeId = useAuiState((s) => s.threads.mainThreadId);
  const [query, setQuery] = useState("");

  const threads = useMemo<SearchableThread[]>(() => {
    const now = Date.now();
    return items
      .filter((item) => item.status === "regular")
      .map((item) => ({
        id: item.id,
        title: item.title ?? "New chat",
        preview: item.isRunning ? "Running…" : "",
        group: groupFor(item.lastMessageAt, now),
        pinned: Boolean(item.custom?.["pinned"]),
      }));
  }, [items]);

  return (
    <ThreadSearch
      threads={threads}
      query={query}
      activeId={activeId}
      onQueryChange={setQuery}
      onSelect={(id) => aui.threads.item({ id }).switchTo()}
    />
  );
}

status === "regular" excludes archived, deleted, and the placeholder "new" entry a fresh thread starts as, so the list matches what a user would call their history.

Anatomy

<div data-slot="thread-search">
  <div>
    <input aria-label="Search threads" placeholder="Search threads" />
  </div>
  {/* pinned matches, if any, under a "pinned" label */}
  <div>{/* pinned rows */}</div>
  {/* remaining matches, one block per group, in first-seen order */}
  <div>{/* group label */}</div>
  <div>{/* group rows */}</div>
  {/* or, when nothing matches */}
  <span>{/* No thread matches "{query}" */}</span>
</div>

Matching runs ${title} ${preview} against the query, case-insensitively, so an empty query matches every thread. Pinned matches always render first as their own block; the rest are split into groups in the order their first match appears, not alphabetically or by recency. Arrow Down and Arrow Up in the search input move the active selection through pinned-then-grouped order and wrap around at both ends; IME composition is ignored so composing a query never steals the keys. There is no visible "no threads at all" state distinct from "no matches": an empty threads array renders the same empty message as a query with zero hits.

Examples

Where threads come from

group and pinned are not runtime concepts. The date-bucketed grouping above matches what the installed thread list uses internally; build your own buckets (by project, by tag) the same way, from whatever field your app tracks. Pinning has to live somewhere too, and custom is exactly the place: it is a free-form bag every thread list item carries, read with item.custom?.pinned and written with aui.threads.item({ id }).updateCustom(...). updateCustom replaces the whole bag rather than merging, so spread the existing value when flipping one field:

function togglePinned(id: string) {
  const current = aui.threads.item({ id }).getState().custom;
  aui.threads.item({ id }).updateCustom({ ...current, pinned: !current?.["pinned"] });
}

Restyle the list

Both lanes take className on the root. Row and field surfaces come from the same paper, field, and mono tokens used across the catalog, so retheming those tokens covers the search box, the row hover state, and the section labels together.

<ThreadSearch className="max-w-xs" /* ... */ />

API reference

This element has no dedicated primitive: it is fed from useAuiState selectors, as in Getting started.

Threads state

SelectorTypeDescription
s.threads.threadItemsreadonly ThreadListItemState[]Every known thread list item, each with id, title, lastMessageAt, status, custom, and isRunning.
s.threads.mainThreadIdstringId of the currently open thread.
aui.threads.item({ id }).switchTo(options){ unarchive?: boolean }Makes the given thread the open one.
aui.threads.item({ id }).updateCustom(custom)Record<string, unknown> | undefinedReplaces the item's free-form metadata bag, such as a pinned flag.