AI

Message Search

In-conversation search that expands from an icon into a field — debounced dispatch, a hit counter walked with Enter and Shift+Enter, and a result list of role, time and an excerpt with the query emphasised.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown, ChevronUp, Loader2, Search, X } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Keyframes travel with the component through a React 19 hoisted <style> —
 * no Tailwind config edit, and two search bars on one page dedupe by href.
 * -------------------------------------------------------------------------- */
const KEYFRAMES = `@keyframes zg-msearch-list-in{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:none}}`

export type MessageSearchRole = "user" | "assistant" | "system" | "tool"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/message-search.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "MessageSearch" component: an
in-conversation find bar that expands out of an icon button, debounces the
query, and walks a list of hits the CONSUMER supplies. lucide-react for icons,
no other runtime dependency.

Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>
  minus children; leftover props spread onto the root.
- matches: MessageSearchMatch[] — the hits, in transcript order.
    id: string                    // stable message id, handed back untouched
    role: "user" | "assistant" | "system" | "tool"
    excerpt: string               // a window around the hit, cut upstream
    label?: string                // overrides the role chip (persona / agent name)
    timestamp?: string            // display-ready, see below
    ranges?: [number, number][]   // hit offsets INTO `excerpt`
- onQueryChange: (query: string) => void — required. Fired with the trimmed
  query. Making it required is the contract: the component never reads a message
  list, never tokenises, never builds an index. It is a view over somebody
  else's search result, so the search has to exist.
- onNavigate?: (index: number, match: MessageSearchMatch) => void
- defaultQuery?: string ("") · debounceMs?: number (200) · loading?: boolean
  (false) · showResults?: boolean (true) · placeholder?: string · label?: string
  (names the trigger, the field and the list).
- expanded? / defaultExpanded? (false) / onExpandedChange? — the usual
  controlled-or-uncontrolled disclosure triad. The active hit is deliberately
  NOT controllable: the consumer reacts to onNavigate, it does not drive it.
- `timestamp` is a string on purpose. A Date formatted inside a component that
  can render on the server puts the server's locale and time zone against the
  browser's and hydration explodes; absolute-vs-relative is the app's policy
  anyway. Format upstream.

Behavior — debounce, and a counter that cannot lie
- The field is never debounced, only the dispatch is: keep the typed value in
  local state so typing stays at input latency, and hand `onQueryChange` the
  trimmed value after `debounceMs` of quiet. debounceMs <= 0 dispatches on every
  keystroke.
- Keep the last dispatched query in state. `pending = query.trim() !== dispatched`
  is the whole staleness model: while pending, the counter shows a spinner
  instead of a number, because "3/17" belongs to the previous query and would be
  a lie about the one on screen. Same for the `loading` prop, which is the
  consumer saying their own search is still in flight.
- Emptying the field dispatches "" IMMEDIATELY, bypassing the debounce —
  otherwise the transcript keeps its highlighting for another debounceMs after
  the field is visibly empty.
- Read the callbacks out of a latest-ref inside the timer, never out of the
  effect's dependency array. `onQueryChange={q => setQuery(q)}` is a new function
  every render, so a dependency array would tear down and restart the timer every
  render and the query would never be dispatched at all.
- Clear the timer in the effect's cleanup: no dispatch may survive unmount.

Behavior — the cursor over the result set
- Derive a key from the CONTENT of `matches` (join the ids), not from array
  identity: a consumer that rebuilds the array each render must not read as
  "new results". When the key changes, reset the cursor to 0 during render (an
  adjust-state, not an effect) so a frame pointing past the end of a shrunken
  result set is never painted.
- New results for a live query hand the caller its first hit through
  onNavigate(0, matches[0]) — that is what makes the transcript scroll while the
  user types. Skip it on the first commit (mounting is not a navigation) and skip
  it when the dispatched query is empty.
- Next / previous wrap in both directions. A find bar that stops at the last hit
  makes the user retype the query to get back to the top.
- With no hits the two walkers go aria-disabled, never `disabled`: the guard is
  the early return in the walk function. The native attribute would blur the
  button the instant an upstream re-search returned nothing under the user's
  focus, dropping the caret on <body> and restarting Tab from the top of the
  page.
- Keyboard, all on the input: Enter = next, Shift+Enter = previous, ArrowDown /
  ArrowUp = next / previous, and the first arrow after a dismissal only brings
  the list back so the user sees where they are before they move.
- Escape is two-stage: the first takes back the dropdown, only the second closes
  the bar. stopPropagation on it, or the Escape that dismisses the list also
  closes the dialog or drawer the chat lives in.
- Collapsing is a full reset — query cleared, "" dispatched upstream, focus
  handed back to the trigger — so a closed bar can never leave the transcript
  highlighted.
- An outside press dismisses the LIST only. The bar itself never collapses under
  the user's hands: a half-typed query must not be lost to a stray click.

Behavior — emphasis
- If a match carries `ranges`, use them: a fuzzy / stemmed / semantic matcher
  found something the raw query does not literally contain, and only it knows
  where. Clamp them to the excerpt, drop empties, sort, merge overlaps — a
  matcher's offsets are untrusted input.
- Otherwise emphasise every case-insensitive literal occurrence of the DISPATCHED
  query (never the one still being typed, or the emphasis flickers a keystroke
  ahead of the results). Compare excerpt.length with excerpt.toLowerCase().length
  first: a few characters (Turkish dotted I, ligatures) change length when
  lowercased, which slides every later offset — when that happens, render the row
  unemphasised rather than wrong.

Behavior — disclosure and focus
- Collapsed: one icon button with aria-expanded="false" and aria-controls
  pointing at the field. Expanded: the same magnifier becomes decorative, the
  input takes focus, and a close button appears at the far end.
- Animate the opening by transitioning a grid track from 0fr to 1fr, with the
  strip inside overflow-hidden — the only way to transition to a
  content-derived width in CSS.
- Keep the strip mounted and mark it `inert` while collapsed. Clipped-but-
  focusable content is the classic way an "icon button" ends up with five hidden
  tab stops and a screen reader reading a field nobody can see.
- Skip the focus hand-off on the FIRST commit, or a bar rendered with
  defaultExpanded (several of them on a docs page) steals the page's focus on
  load and wipes its own defaultQuery.

Rendering & styling
- Semantic tokens only: border-input / bg-background / ring-ring for the field,
  bg-popover + text-popover-foreground + border + shadow for the list,
  bg-accent + text-accent-foreground for the active row, bg-muted on hover,
  text-muted-foreground for meta, text-destructive for a 0/0 counter, bg-border
  for the hairline separators. No hex, no rgb(), no oklch().
- Role chips stay apart under ANY token set by varying the treatment, not the
  hue: filled (bg-muted) for the user, tinted (bg-primary/10 + text-primary) for
  the assistant, outlined for tools, dashed for system.
- <mark> ships with a hardcoded yellow from the UA stylesheet — repaint both
  branches from tokens: bg-primary + text-primary-foreground inside the active
  row, bg-primary/15 + text-foreground elsewhere.
- The input is role="combobox" with aria-expanded / aria-controls /
  aria-activedescendant; the dropdown is role="listbox" with role="option" rows.
  With showResults={false} there is no popup, so drop the combobox role too and
  leave a plain text field.
- Rows keep focus in the field: preventDefault on mouseDown, or the click blurs
  the combobox that owns aria-activedescendant and Enter stops working.
- Scroll the active row into view by writing the list's own scrollTop, not with
  scrollIntoView — that is allowed to scroll every ancestor and yanks the page
  (and the transcript behind it) on every Enter.
- One sr-only role="status" aria-live="polite" region, and it stays EMPTY while
  the query is still being typed: announcing "searching" per keystroke turns a
  12-character query into 12 interruptions. It speaks only for a settled result
  set ("Match 3 of 17"), a real upstream loading, or a move to another hit.
- Reduced motion: the open/close transition and the list's entrance animation
  are motion-reduce:transition-none / motion-reduce:[animation:none], and the
  spinner is motion-reduce:animate-none. Everything still works, nothing moves.
- Ship the list keyframes in a React 19 hoisted <style href precedence>, so two
  search bars on a page dedupe by href and no Tailwind config edit is needed.

Customization levers
- debounceMs: 200 suits a server round trip; 0 is right for a cheap local scan
  over a few hundred messages and makes the counter feel instant.
- showResults={false} turns the whole thing into a browser-style find bar —
  counter plus walkers, no dropdown. Good for narrow headers and mobile.
- Field width (w-36 on the input) and control size (size-6 buttons) are the two
  density knobs; the list is max-h-80 and w-[min(24rem,calc(100vw-2rem))] —
  narrow it for a sidebar, widen it for a desktop-only shell.
- Excerpt clamp (line-clamp-3) trades rows-on-screen against context per row;
  the amount of context is decided upstream anyway, by how wide a window your
  matcher cuts around the hit.
- Role chips: extend the role union and add one entry to each of the label and
  class maps — both are exhaustive Records, so TypeScript names the gap.
- Drop the first-hit hand-off effect if your transcript should stay put while
  the user types and only move on an explicit Enter.
- Anchoring: the list is absolute right-0 top-full under the root. Flip it to
  left-0 for a left-aligned bar, or to bottom-full for a composer-anchored one.
- Add ⌘F by binding it outside and driving `expanded` — deliberately not built
  in, because taking the browser's own find shortcut is a decision the app owns.

Concepts

  • Debounced dispatch — the typed value and the searched value are two different pieces of state; only the second one is delayed, so the field answers every keystroke at input latency while the expensive matching runs at most once per quiet period, and an emptied field skips the delay entirely.
  • A counter that cannot lie — "3/17" describes the last query the consumer was actually told about; the moment what is on screen differs from it, the number is replaced by a spinner rather than left standing as a stale claim about text the user is still typing.
  • Consumer-owned matching — the component receives hits and never produces them, which is what lets the same bar sit on a regex scan, a Fuse index or a vector search; a matcher that finds words the query does not contain sends its own [start, end) offsets so the emphasis travels with the match.
  • First-hit hand-off — a new result set for a live query immediately reports hit #1 to the caller, which is what makes the transcript scroll and mark itself while the user types; it is keyed on the content of the set, so a consumer re-rendering with equal results cannot start a loop.
  • Wrap-around walking — next and previous are modular over the hit list in both directions, and the same cursor drives the counter, the highlighted row and the callback, so the bar and the transcript can never disagree about which hit is current.
  • Two-stage Escape — the first Escape takes back the dropdown and the second closes the bar, and neither escapes the component: the keypress is stopped, so dismissing a result list never also closes the dialog the conversation is sitting in.

On This Page