95 / 122 · Thread
Search in conversation
Find inside a long thread, with every hit marked down the scrollbar.
1/3
The composer reads the draft from a per-thread slot.
Installation
npx shadcn@latest add "@assistant-ui/elements-conversation-search"
Usage
import { ConversationSearch } from "@/components/elements/conversation-search";
<ConversationSearch
query={query}
hits={hits}
activeIndex={0}
onQueryChange={setQuery}
onStep={step}
/>Props
ConversationSearch
query*stringCurrent search text.
hits*SearchHit[]Matches in document order. Each carries its own surrounding text so no lookup is needed to render the preview.
activeIndex*numberWhich hit is current, in both the counter and the rail. Clamped to the current hit set, so narrowing the results cannot strand it out of range.
onQueryChange(query: string) => voidCalled as the search is typed.
onStep(delta: number) => voidCalled with -1 or 1 to walk the matches. Wrap the index yourself.
classNamestringExtra classes merged onto the root.
SearchHit
id*stringStable identity for the rail mark.
before*stringText preceding the match, for the preview.
after*stringText following the match.
position*numberWhere the hit sits in the thread, as a percentage. Drives the scrollbar marks.
match*stringThe matched run, highlighted between before and after.
Source
conversation-search.tsx"use client";
import { ChevronDownIcon, ChevronUpIcon, SearchIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { field, ghostButton, mono, paper } from "./surfaces";
export interface SearchHit {
id: string;
before: string;
match: string;
after: string;
position: number;
}
export function ConversationSearch({
query,
hits,
activeIndex,
onQueryChange,
onStep,
className,
}: {
query: string;
hits: readonly SearchHit[];
activeIndex: number;
onQueryChange?: (query: string) => void;
onStep?: (delta: number) => void;
className?: string;
}) {
const index =
hits.length === 0
? -1
: Math.min(Math.max(activeIndex, 0), hits.length - 1);
const active = index === -1 ? undefined : hits[index];
return (
<div className={cn("flex w-full max-w-sm gap-2", className)}>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div
className={cn(
paper,
"flex items-center gap-2 rounded-full py-1.5 pr-1.5 pl-3",
)}
>
<SearchIcon className="text-foreground/30 size-3.5 shrink-0" />
<input
value={query}
onChange={(event) => onQueryChange?.(event.target.value)}
placeholder="Find in conversation"
aria-label="Find in conversation"
className="text-foreground/85 placeholder:text-foreground/30 min-w-0 flex-1 bg-transparent text-[13px] outline-none"
/>
<span
className={cn(mono, "text-foreground/30 shrink-0 tabular-nums")}
>
{hits.length === 0 ? "0" : `${index + 1}/${hits.length}`}
</span>
<button
type="button"
aria-label="Previous match"
onClick={() => onStep?.(-1)}
className={cn(ghostButton, "size-6 shrink-0")}
>
<ChevronUpIcon className="size-3.5" />
</button>
<button
type="button"
aria-label="Next match"
onClick={() => onStep?.(1)}
className={cn(ghostButton, "size-6 shrink-0")}
>
<ChevronDownIcon className="size-3.5" />
</button>
</div>
{active && (
<div
className={cn(
field,
"fade-in animate-in rounded-xl px-3 py-2 text-xs leading-relaxed duration-200",
)}
>
<span className="text-foreground/45">{active.before}</span>
<span className="text-foreground/95 rounded bg-amber-400/35 px-0.5">
{active.match}
</span>
<span className="text-foreground/45">{active.after}</span>
</div>
)}
</div>
<div className="bg-foreground/[0.04] relative w-1.5 shrink-0 rounded-full">
{hits.map((hit, i) => (
<span
key={hit.id}
aria-hidden
className={cn(
"absolute inset-x-0 h-1 rounded-full transition-colors duration-200",
i === index ? "bg-amber-500" : "bg-amber-500/35",
)}
style={{ top: `${hit.position}%` }}
/>
))}
</div>
</div>
);
}