Thread search
History you can actually get back into: pinned first, then grouped by when.
Open thread: Converter drops empty parts
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 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-search"Props-driven: no runtime or provider required.
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
"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.
Standalone, filtering, pinning, and grouping are computed for you from whatever threads array you pass; only the state (the query and which thread is active) is yours to hold.
Hold the search state
"use client";
import { useState } from "react";
import { ThreadSearch } from "@/components/assistant-ui/elements/thread-search";
const threads = [
{ id: "1", title: "Migration plan", group: "Today", preview: "Three tables, one rollback path", pinned: true },
{ id: "2", title: "Release notes draft", group: "Today", preview: "Bumped the changelog for 4.2" },
{ id: "3", title: "Onboarding copy", group: "Yesterday", preview: "First run, three moves" },
];
export function History() {
const [query, setQuery] = useState("");
const [activeId, setActiveId] = useState(threads[0]!.id);
const [openId, setOpenId] = useState(threads[0]!.id);
const openThread = threads.find((thread) => thread.id === openId);
return (
<>
<ThreadSearch
threads={threads}
query={query}
activeId={activeId}
onQueryChange={setQuery}
onActiveChange={setActiveId}
onSelect={setOpenId}
/>
<p>Open thread: {openThread?.title}</p>
</>
);
}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"] });
}Standalone, group and pinned are fields on the objects you pass, so pinning is whatever setThreads call you write when a pin control fires. The element does not inspect how those threads were produced.
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
| Selector | Type | Description |
|---|---|---|
s.threads.threadItems | readonly ThreadListItemState[] | Every known thread list item, each with id, title, lastMessageAt, status, custom, and isRunning. |
s.threads.mainThreadId | string | Id 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> | undefined | Replaces the item's free-form metadata bag, such as a pinned flag. |
ThreadSearch
| Prop | Type | Default | Description |
|---|---|---|---|
threads | readonly SearchableThread[] | required | The full list to search and group. |
query | string | required | The current search text. |
activeId | string | required | Id of the highlighted thread. |
onQueryChange | (query: string) => void | Called as the user types. | |
onActiveChange | (id: string) => void | required with onSelect | Called when Arrow Down or Arrow Up moves the highlight, and before onSelect when a row is clicked. Without it, Arrow Down and Arrow Up do not move the highlight. |
onSelect | (id: string) => void | Called when Enter activates the highlighted result or a row is clicked. Without it, rows render as non-interactive results and Enter does nothing. | |
className | string | Merged onto the root. |
All other div props are forwarded to the root.
When updating an existing copied element that uses onSelect, add onActiveChange and store the highlighted id separately from the open thread id. Arrow keys now report highlight movement only through onActiveChange; Enter and row clicks report activation through onSelect, and a row click reports its highlight through onActiveChange first.
SearchableThread
| Field | Type | Description |
|---|---|---|
id | string | Unique identifier, also the value passed to onActiveChange and onSelect. |
title | string | Row title. |
group | string | Section label for unpinned rows. Free text; the element does not interpret it. |
preview | string | Secondary line shown under the title. |
pinned | boolean | Optional. When true, the row renders in the pinned block instead of its group. |