AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Check, Loader2, Pencil, RefreshCw, Sparkles, X } from "lucide-react"

import { Button } from "@/components/ui/button"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Shimmer
 *
 * A machine-written name is provisional until it settles, and the shimmer is
 * what says so. It is a gradient swept across the glyphs themselves

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/conversation-title.json

Prompt

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.

On This Page