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.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/message-search.jsonPrompt
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.
Pinned Messages
A pinned-messages strip for a conversation — one chip per pin, as many as genuinely fit measured against the real container, the rest collapsed into a +N more panel, unpin behind a confirmation that fires once, and real loading/empty/error states.
Edit Message
In-place editor for a message that was already sent — it states how many replies the fork will drop, guards the draft on cancel, keeps it on a rejected resubmit, and never forks for unchanged text.