Conversation Title
An auto-generated chat title — a sparkle provenance hint, click-to-edit rename with a length counter, and a regenerate button that shimmer-cycles candidate names before settling.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/conversation-title.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ConversationTitle" component: the name a
model gave a chat thread, plus the two ways a user changes it — type a new one,
or ask the model for another one. Icons from lucide-react; a shadcn Button and
Tooltip; cn() from your utils.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
"title" and "children"; the rest of the props spread onto the root.
- title: string — controlled. The component never owns the name, it only ever
proposes the next one.
- origin?: "auto" | "user" (default "auto") — "auto" shows the sparkle
provenance hint, "user" hides it: a name the user typed needs no explanation.
- status?: "ready" | "naming" | "error" (default "ready") — the lifecycle of the
FIRST automatic name, owned by the app. "naming" = still being produced,
"error" = that attempt failed.
- onRename?: (next: string, source: "auto" | "user") => void | Promise<void> —
the single commit boundary. Typing a name and settling on a generated one both
land here, tagged with their source, so the app has one place to persist and
one place to flip `origin`. Return a Promise for a pending state; reject to
roll back with the reason.
- onRegenerate?: () => string | string[] | Promise<string | string[]> — ask for
another name. Resolve with the final title, or with the candidates the model
considered: the LAST entry is the one that sticks, the earlier ones are cycled
through on the way there.
- labels?: Partial<Record<LabelKey, string>> — every string the component can say
lives in one LABELS map (placeholder, naming, namingFailed, regenerate,
regenerateFailed, regenerateEmpty, renameFailed, empty, auto, autoHint, edit,
save, cancel, hint, busy, saving, retry). Translation is one prop, not a hunt
through JSX.
- maxLength?: number (default 60), saveOnBlur?: boolean (default true),
spinInterval?: number (default 220), maxSpins?: number (default 4),
headingLevel?: 1..6, size?: "sm" | "md" | "lg" (default "md"),
showAutoLabel?: boolean (default true), disabled?: boolean.
- Affordances are gated by handlers, never by decoration: no onRename means the
title is plain text with no hover, no focus stop, no pencil; no onRegenerate
means the regenerate button is not rendered at all.
Behavior — rename
- Click (or Enter on) the title to open an <input> seeded with the current name,
focused and fully selected, so typing replaces it and an arrow key does not.
- Enter commits, Escape cancels. Escape also stopPropagation()s: the editor owns
that keypress, otherwise the dialog or sheet the title sits in closes with it.
- Ignore keys while event.nativeEvent.isComposing — an IME candidate window
swallows Enter and committing there cuts a half-composed word.
- saveOnBlur (default) commits on blur; a single settling flag guards the second
commit when Enter is immediately followed by the input's own blur.
- A trimmed-empty name is never written, but the two ways of arriving at one are
not the same gesture: an explicit save (Enter, the check button) is refused
with the editor left open and the reason under it, while a blur discards and
closes. Refusing on blur means pulling the caret back into the field, and then
every Tab and every click outside bounces the user in again.
- Re-typing the same name is not a write: close the editor, fire no callback,
announce nothing.
- The save / cancel icon buttons call event.preventDefault() on mousedown so
focus never leaves the input; without it the blur fires first, commits, and
unmounts the button before its click can land.
- Focus discipline: keyboard exits (Enter, Escape, the icon buttons) return
focus to the title button through a ref flag; a blur that landed somewhere the
user chose does not steal it back.
Behavior — regenerate (the candidate roulette)
- One-shot lock: a click while a name is in flight is ignored, not queued — a
second request racing the first is how two names fight over one title.
- Run ids for both the rename and the regenerate path: a response whose run id
is stale (the user started editing, or clicked again) is dropped instead of
overwriting a newer title. Same for a response arriving after unmount.
- Normalise the result to string[], trim, drop empties. Empty list = a failure
with a retry, not a blank title.
- The last entry settles; the earlier ones become spin frames, newest first,
capped at maxSpins (maxSpins <= 0 disables the roulette entirely — careful,
slice(-0) returns the whole array).
- One setTimeout per frame keyed on the spin state, cleared in the effect's
cleanup. Reference the commit function through a latest-ref, NOT through the
effect deps: an inline onRename from the parent would restart the timer on
every parent render and the cycle would never advance.
- When the last frame lands, clear the spin and commit it through the same
onRename path as a manual rename, tagged source "auto".
- Under prefers-reduced-motion skip the roulette completely: the name that
matters lands at once, with no flicker of titles nobody chose.
- Starting an edit abandons an in-flight request (bump the run id) but lets a
roulette already on screen settle first, so the editor opens on a real name
rather than a frame in passing.
Behavior — optimistic write and rollback
- Committing sets an optimistic `preview` string that renders immediately.
- If the parent's `title` prop changes, the preview is dropped during render
(compare against a stored previous title): the app's value always wins,
whether it matches the optimistic one or comes from another device.
- If onRename rejects, drop the preview — the title snaps back to what the app
still holds — and show err.message (falling back to a generic line) in a
role="alert" paragraph.
- status="error" renders the same paragraph with a "Try again" button wired to
onRegenerate; a local failure from the regenerate path gets the same retry, a
failed rename does not (the user retries by clicking the title).
- That retry button unmounts the instant the request starts, so hand focus to
the regenerate button — which survives the whole round trip — before calling
the handler; otherwise the keyboard lands on <body> mid-request.
Rendering & styling
- Semantic tokens only: text-foreground, text-muted-foreground, text-destructive,
bg-background, bg-muted/60, border, border-input, border-destructive, ring.
The shimmer gradient is built from var(--muted-foreground) and var(--foreground)
through color stops — no hex, no rgb(), no oklch().
- One row: heading/editor (flex-1, min-w-0, truncate) · provenance chip ·
actions. The resting button and the input carry the same padding, border width
and negative margin, so swapping modes moves nothing in the header.
- The title renders inside <h1..h6> when headingLevel is set — but never while
editing: an <input> standing in for a heading leaves a heading with no text in
the accessibility tree.
- Shimmer = a gradient swept across the glyphs themselves (bg-clip-text +
text-transparent + animated background-position), not a bar drawn over them:
the text stays selectable and stays on one line. Ship the keyframes with the
component in a React 19 hoisted <style href precedence>. Under reduced motion
the animation is off and background-position rests at 0%, which parks the
muted end of the gradient over the text — still painted, still readable.
- Each spin frame is keyed by index so it re-enters with a short fade/slide
(motion-reduce:animate-none), instead of the text swapping in place.
- The provenance chip is a focusable span inside a Tooltip: a focus stop with no
action is the only way a keyboard reaches a hint a pointer gets by hovering.
showAutoLabel={false} shrinks it to the sparkle and keeps the name sr-only. It
is hidden while status="naming" (no name to explain yet) and while editing.
- Accessibility: the title button's accessible name follows the SETTLED title,
not the frame on screen (a name that changes five times a second is noise),
and carries aria-busy while spinning. The editor is labelled, aria-invalid on
refusal, and aria-describedby the counter and the error. The counter shows
"12/60" with an sr-only expansion and turns destructive at or over the cap.
- One sr-only role="status" aria-live="polite" region: silent through the
roulette, then "Conversation renamed to X" once; "Suggesting a new name" /
"Saving the new name" while those run; the cap message once while editing.
Intermediate candidates are frames, not answers — they are never announced.
- The regenerate button is never `disabled` while busy: disabling the element
that has focus drops the caret to <body>. Use aria-disabled + aria-busy and
refuse the click in the handler.
Customization levers
- Roulette feel: spinInterval (220ms) is the frame rate — 300ms+ reads as
deliberate, 120ms as a slot machine; maxSpins (4) caps how many rejected
candidates a user watches; maxSpins={0} keeps the request and drops the show.
- Commit policy: saveOnBlur={false} makes clicking away discard, for headers
where an accidental blur must never write.
- Length budget: maxLength drives both the input cap and the counter; drop the
counter row entirely if your titles are never near a limit.
- Provenance: swap the Sparkles glyph, set showAutoLabel={false} for a chip that
is only an icon, or hide the chip by passing origin="user" if your product
does not distinguish machine names from typed ones.
- Density and hierarchy: size sm/md/lg drives the title type scale, the icon
button size and the chip; headingLevel decides whether the title is a landmark
or just text.
- Copy and i18n: pass `labels` — the same map covers button names, the hint line
and every failure sentence.
- Wiring: onRename is the only write; point it at your mutation and flip
`origin` from its `source` argument. onRegenerate is the only read; return
one string if your endpoint gives one name, or the candidate list if it gives
several.Concepts
- One commit boundary — typing a name and settling on a generated one funnel through the same
onRename(next, source); the app gets one place to persist and one signal to flip the title's provenance from machine to human. - Candidate roulette — a regeneration that returns several names shows the rejected ones as timed frames before the chosen one lands, so the wait reads as the model thinking rather than as a stall; under reduced motion the frames are skipped and only the answer appears.
- One-shot lock + run ids — a second regenerate click while one is in flight is refused, and any response whose run id is stale (the user started typing, clicked again, or the component unmounted) is discarded instead of overwriting a newer title.
- Optimistic preview with rollback — the new name paints immediately and is held only until the app confirms it, replaces it, or rejects it; a rejection drops the preview so the title snaps back to the value the app still holds, with the server's reason underneath.
- Provenance hint — the sparkle chip is the difference between "the model called it this" and "you called it this"; it is a focus stop with no action so a keyboard can reach the sentence a pointer gets by hovering, and it disappears the moment the user's own words replace the machine's.
- Quiet live region — intermediate candidates are never announced because they are frames, not answers; the region stays silent through the spin and speaks once when the name settles, plus the length-cap message while editing.
Queue Position
A generation-queue waiting room — a position dial that rolls down through every place it passes, a drain bar measured from where you joined, a self-correcting ETA, an optimistic notify-me switch and an arm-before-you-lose-it leave action.
AI Selection Menu
An Ask-AI row that floats over a text selection — action shortcuts, submenus, a free-form question field, one captured excerpt and a keyboard path in.