Elements

96 / 122 · Thread

Thread search

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

pinned
Today
Yesterday
Earlier

Installation

npx shadcn@latest add "@assistant-ui/elements-thread-search"

Usage

import { ThreadSearch } from "@/components/elements/thread-search";

<ThreadSearch
  threads={threads}
  query={query}
  activeId={activeId}
  onQueryChange={setQuery}
  onSelect={setActiveId}
/>

Props

ThreadSearch

threads*SearchableThread[]All threads. Filtering matches title and preview together.
query*stringCurrent search text.
activeId*stringWhich thread is open.
onQueryChange(query: string) => voidCalled as the search is typed.
onSelect(id: string) => voidCalled when a thread is opened.
classNamestringExtra classes merged onto the root.

SearchableThread

id*stringStable identity, compared against activeId.
title*stringThread title. Filtering matches this and preview together.
preview*stringSecond line under the title.
group*stringDate bucket the thread falls into: Today, Yesterday, Earlier.
pinnedbooleanLifts the thread out of its date group into its own pinned section.

Source

thread-search.tsx
"use client";

import { PinIcon, SearchIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { field, mono, paper } from "./surfaces";

export interface SearchableThread {
  id: string;
  title: string;
  group: string;
  preview: string;
  pinned?: boolean;
}

export function ThreadSearch({
  threads,
  query,
  activeId,
  onQueryChange,
  onSelect,
  className,
}: {
  threads: readonly SearchableThread[];
  query: string;
  activeId: string;
  onQueryChange?: (query: string) => void;
  onSelect?: (id: string) => void;
  className?: string;
}) {
  const matches = threads.filter((thread) =>
    `${thread.title} ${thread.preview}`
      .toLowerCase()
      .includes(query.toLowerCase()),
  );
  const pinned = matches.filter((thread) => thread.pinned);
  const groups = [
    ...new Set(matches.filter((t) => !t.pinned).map((t) => t.group)),
  ];

  const row = (thread: SearchableThread) => (
    <button
      key={thread.id}
      type="button"
      onClick={() => onSelect?.(thread.id)}
      className={cn(
        "flex flex-col gap-0.5 rounded-xl px-2 py-1 text-start transition-colors",
        thread.id === activeId
          ? "bg-foreground/[0.05]"
          : "hover:bg-foreground/[0.03]",
      )}
    >
      <span className="flex items-center gap-1.5">
        {thread.pinned && (
          <PinIcon className="text-foreground/30 size-2.5 shrink-0" />
        )}
        <span className="min-w-0 flex-1 truncate text-[13px]">
          {thread.title}
        </span>
      </span>
      <span className="text-foreground/35 truncate text-xs">
        {thread.preview}
      </span>
    </button>
  );

  return (
    <div
      className={cn(
        paper,
        "flex w-full max-w-sm flex-col gap-1.5 rounded-2xl p-3",
        className,
      )}
    >
      <div
        className={cn(
          field,
          "flex items-center gap-2 rounded-xl px-2.5 py-1.5",
        )}
      >
        <SearchIcon className="text-foreground/30 size-3.5 shrink-0" />
        <input
          value={query}
          onChange={(event) => onQueryChange?.(event.target.value)}
          placeholder="Search threads"
          aria-label="Search threads"
          className="text-foreground/85 placeholder:text-foreground/30 min-w-0 flex-1 bg-transparent text-[13px] outline-none"
        />
      </div>

      {pinned.length > 0 && (
        <div className="flex flex-col">
          <span className={cn(mono, "text-foreground/25 px-2 pb-1")}>
            pinned
          </span>
          {pinned.map(row)}
        </div>
      )}

      {groups.map((group) => (
        <div key={group} className="flex flex-col">
          <span className={cn(mono, "text-foreground/25 px-2 pb-1")}>
            {group}
          </span>
          {matches
            .filter((thread) => !thread.pinned && thread.group === group)
            .map(row)}
        </div>
      ))}

      {matches.length === 0 && (
        <span className="text-foreground/30 px-2 py-4 text-center text-xs">
          No thread matches “{query}”
        </span>
      )}
    </div>
  );
}