Skip to content
Elements

Thread search

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

Open thread: Converter drops empty parts

fig. 01

Installation

Install manually
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 { useLayoutEffect, 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 openId = useAuiState((s) => s.threads.mainThreadId);
  const [query, setQuery] = useState("");
  const [activeId, setActiveId] = useState(openId);

  useLayoutEffect(() => setActiveId(openId), [openId]);

  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}
      onActiveChange={setActiveId}
      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
      role="combobox"
      aria-label="Search threads"
      aria-expanded="true"
      aria-controls="thread-list"
      aria-activedescendant="active-thread"
      placeholder="Search threads"
    />
  </div>
  <div id="thread-list" role="listbox" aria-label="Threads">
    {/* pinned matches, if any, under a "pinned" label */}
    <div role="group" aria-label="Pinned">
      <button id="active-thread" role="option" aria-selected="true" />
    </div>
    {/* remaining matches, one group per label, in first-seen order */}
    <div role="group" aria-label="Today">
      <button role="option" aria-selected="false" />
    </div>
  </div>
  {/* shown instead of options when nothing matches */}
  <span role="status">{/* 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 highlight through pinned-then-grouped order, report it through onActiveChange, and wrap around at both ends. Enter activates the highlighted result, while clicking a row highlights it through onActiveChange and then activates it through onSelect. Focus stays in the search input while aria-activedescendant identifies the highlighted option; 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 uses the same Today, Yesterday, and Earlier labels as the installed thread list; build your own buckets (by project, by tag) the same way, from whatever field your app tracks. Store the pinned flag in custom, 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.