{"slug":"chat-bubble","category":"messages","job":"trust","added":"2026-09-11T22:23Z","title":"Chat Bubble with Actions","description":"A message bubble with feedback thumbs, edit-and-resubmit, retry/regenerate, and a version stepper.","url":"https://ai-patterns.app/patterns/messages/chat-bubble","markdown":"https://ai-patterns.app/patterns/messages/chat-bubble.md","json":"https://ai-patterns.app/api/patterns/messages/chat-bubble","seenIn":[{"team":"chatgpt","source":"https://help.openai.com/en/articles/8142208-chatgpt-ios-and-android-app-faq"},{"team":"Claude","source":"https://support.claude.com/en/articles/8525154-claude-is-providing-incorrect-or-misleading-responses-what-s-going-on"}],"spec":"# Chat Bubble with Actions\n\n## Summary\nA conversational message bubble — user or assistant — with an inline action row that reveals on hover/focus: Copy, thumbs up/down feedback, and Regenerate on assistant replies; Retry, Edit (and resubmit), and Copy on the user's own messages. When a message has more than one version — from an edit or a retry/regenerate — the row ends in a version stepper; how those versions behave is defined by Conversation Branches.\n\n## When to use\n- Any chat-style conversation UI where actions belong to a single message, not a global toolbar.\n- Assistant replies that benefit from an explicit quality signal (thumbs) or a one-click retry when the first answer misses.\n- User messages the person may want to correct or refine after seeing the reply (a typo, an added detail, a different phrasing) without retyping the whole conversation.\n\n## When not to use\n- Read-only transcripts (chat history exports, shared/public views) where no one should be able to edit or regenerate.\n- Broadcast or group messages, where \"edit and resubmit\" would silently change what other participants already saw — reserve it for single-user assistant conversations, where resubmitting is understood to fork the conversation rather than rewrite history for everyone.\n- Very short-lived, ephemeral UI (toasts, system notices) — the action row adds visual weight that isn't earned there.\n\n## Anatomy\n- Message bubble: role-differentiated style (e.g. user bubble filled/right-aligned, assistant bubble muted/left-aligned).\n- Action row, below the bubble, revealed on hover/focus:\n  - User: Retry, Edit, Copy.\n  - Assistant: Copy, thumbs up, thumbs down, Regenerate.\n  - Version stepper (optional, trailing): a vertical divider, then the previous / counter / next switcher from Conversation Branches.\n- Edit mode: the bubble becomes an editable textarea in place, with Cancel and \"Save & submit\" controls.\n\n## Behavior\n- The action row stays hidden until the bubble (or an action inside it) has hover or keyboard focus — it shouldn't compete with the message content at rest.\n- Once feedback is given, the corresponding thumb stays visibly active (a toggle, not a one-shot click) so the state reads even after the row is no longer hovered; clicking the same thumb again clears it.\n- Retry (user) and Regenerate (assistant) both resend the current user message and produce a new reply. Wire them to the same re-send handler — retrying from the user bubble is equivalent to regenerating from the assistant bubble that follows it.\n- Retry, Regenerate and \"Save & submit\" never overwrite history: each adds a version and shows it. Versioning — what a version contains, how switching moves the reply and the rest of the thread with it, the counter and its disabled ends — follows Conversation Branches.\n- Editing a user message swaps the bubble for a textarea pre-filled with the current text. Enter (without Shift) or \"Save & submit\" commits it; Escape or Cancel discards the edit and restores the original text.\n- This component doesn't generate replies itself: wire edit-submit, Retry and Regenerate to a re-send that creates the new version.\n- Only one bubble is in edit mode at a time.\n- The stepper is only rendered when there's more than one version (`versionCount > 1`) — a single version shows no stepper, not a disabled \"1/1\".\n\n## Content guidelines\n- Label the commit action \"Save & submit\" (or equivalent), not just \"Save\" — it should read as re-sending the message, a bigger consequence than saving a draft.\n- Keep the action icons unlabeled visually but always give each a real accessible name — icon-only rows save space but must not go nameless.\n\n## Accessibility\n- Every action button needs a descriptive `aria-label` (\"Good response\", \"Bad response\", \"Regenerate\", \"Retry\", \"Edit message\", \"Copy\"); the stepper's names come from Conversation Branches since the icon alone carries no accessible name.\n- Thumbs up/down are toggle buttons — expose state with `aria-pressed`, not color alone.\n- A user/assistant pair shares one version, so only one of its two steppers announces version changes — the user bubble's by default — or screen readers hear every change twice.\n- The action row must be reachable by keyboard, not only `:hover` — use `:focus-within` on the bubble so Tab reveals it.\n- The edit textarea should receive focus automatically when edit mode opens, and focus should land somewhere sensible (the bubble, or the next actionable element) when it closes.\n\n## Related patterns\n- **Streaming Text** — Use it as well while the assistant reply is still generating, then show this bubble's actions once it's done.\n- **Live Transcript** — Use it before this one for a voice-composed message, and hand the final text into a user bubble when the turn ends.\n- **Conversation Branches** — Use it as well; it defines how the versions behind this bubble's stepper work, and the stepper follows it.\n- **Selection Actions** — Use it instead when the action applies to part of a message rather than the whole thing.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { Check, ChevronLeft, ChevronRight, Copy, Pencil, RotateCcw, ThumbsDown, ThumbsUp } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type ChatRole = \"user\" | \"assistant\";\nexport type ChatFeedback = \"up\" | \"down\" | null;\n\nexport interface ChatBubbleProps {\n  role: ChatRole;\n  content: string;\n  onEditSubmit?: (content: string) => void;\n  onRegenerate?: () => void;\n  onFeedback?: (feedback: ChatFeedback) => void;\n  /** 0-based index of the version currently shown. Omit (with versionCount) to hide the stepper. */\n  versionIndex?: number;\n  /** Total number of versions (edits/regenerations) available for this message. */\n  versionCount?: number;\n  onVersionChange?: (index: number) => void;\n  /**\n   * Announce version changes to screen readers. A user/assistant pair shares\n   * one version, so only one stepper should announce it — the user bubble by\n   * default. Set it on the assistant bubble if only replies show a stepper.\n   */\n  announceVersion?: boolean;\n  className?: string;\n}\n\nexport function ChatBubble({\n  role,\n  content,\n  onEditSubmit,\n  onRegenerate,\n  onFeedback,\n  versionIndex,\n  versionCount,\n  onVersionChange,\n  announceVersion = role === \"user\",\n  className,\n}: ChatBubbleProps) {\n  const [editing, setEditing] = React.useState(false);\n  const [draft, setDraft] = React.useState(content);\n  const [feedback, setFeedback] = React.useState<ChatFeedback>(null);\n  const [copied, setCopied] = React.useState(false);\n  const isUser = role === \"user\";\n  const hasVersions = versionCount != null && versionCount > 1 && versionIndex != null && onVersionChange;\n\n  function startEdit() {\n    setDraft(content);\n    setEditing(true);\n  }\n\n  function handleFeedback(next: ChatFeedback) {\n    const value = feedback === next ? null : next;\n    setFeedback(value);\n    onFeedback?.(value);\n  }\n\n  async function handleCopy() {\n    await navigator.clipboard.writeText(content);\n    setCopied(true);\n    window.setTimeout(() => setCopied(false), 1500);\n  }\n\n  function commitEdit() {\n    const trimmed = draft.trim();\n    setEditing(false);\n    if (!trimmed || trimmed === content) {\n      setDraft(content);\n      return;\n    }\n    onEditSubmit?.(trimmed);\n  }\n\n  function cancelEdit() {\n    setDraft(content);\n    setEditing(false);\n  }\n\n  return (\n    <div className={cn(\"group flex flex-col gap-1.5\", isUser ? \"items-end\" : \"items-start\", className)}>\n      {editing ? (\n        <div className=\"w-full max-w-md space-y-2\">\n          <textarea\n            autoFocus\n            value={draft}\n            onChange={(e) => setDraft(e.target.value)}\n            onKeyDown={(e) => {\n              if (e.key === \"Enter\" && !e.shiftKey) {\n                e.preventDefault();\n                commitEdit();\n              }\n              if (e.key === \"Escape\") cancelEdit();\n            }}\n            rows={Math.min(6, Math.max(2, draft.split(\"\\n\").length))}\n            className=\"w-full resize-none rounded-2xl border bg-background px-4 py-2.5 text-sm leading-relaxed outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n          />\n          <div className=\"flex justify-end gap-2\">\n            <button\n              type=\"button\"\n              onClick={cancelEdit}\n              className=\"rounded-full px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground\"\n            >\n              Cancel\n            </button>\n            <button\n              type=\"button\"\n              onClick={commitEdit}\n              className=\"rounded-full bg-primary px-3 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90\"\n            >\n              Save & submit\n            </button>\n          </div>\n        </div>\n      ) : (\n        <div\n          className={cn(\n            \"max-w-md rounded-2xl px-4 py-2.5 text-sm leading-relaxed\",\n            isUser ? \"bg-primary text-primary-foreground\" : \"bg-muted text-foreground\"\n          )}\n        >\n          {content}\n        </div>\n      )}\n\n      {!editing && (\n        <div\n          className={cn(\n            \"flex items-center gap-0.5 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100\",\n            (copied || feedback) && \"opacity-100\"\n          )}\n        >\n          {isUser ? (\n            <>\n              {onRegenerate && (\n                <IconButton label=\"Retry\" onClick={onRegenerate}>\n                  <RotateCcw className=\"size-3.5\" />\n                </IconButton>\n              )}\n              <IconButton label=\"Edit message\" onClick={startEdit}>\n                <Pencil className=\"size-3.5\" />\n              </IconButton>\n              <IconButton label=\"Copy\" onClick={handleCopy}>\n                {copied ? <Check className=\"size-3.5\" /> : <Copy className=\"size-3.5\" />}\n              </IconButton>\n            </>\n          ) : (\n            <>\n              <IconButton label=\"Copy\" onClick={handleCopy}>\n                {copied ? <Check className=\"size-3.5\" /> : <Copy className=\"size-3.5\" />}\n              </IconButton>\n              <IconButton label=\"Good response\" active={feedback === \"up\"} onClick={() => handleFeedback(\"up\")}>\n                <ThumbsUp className=\"size-3.5\" />\n              </IconButton>\n              <IconButton label=\"Bad response\" active={feedback === \"down\"} onClick={() => handleFeedback(\"down\")}>\n                <ThumbsDown className=\"size-3.5\" />\n              </IconButton>\n              <IconButton label=\"Regenerate\" onClick={onRegenerate}>\n                <RotateCcw className=\"size-3.5\" />\n              </IconButton>\n            </>\n          )}\n\n          {hasVersions && (\n            <VersionNav\n              index={versionIndex}\n              count={versionCount}\n              onChange={onVersionChange}\n              announce={announceVersion}\n            />\n          )}\n        </div>\n      )}\n    </div>\n  );\n}\n\nfunction VersionNav({\n  index,\n  count,\n  onChange,\n  announce,\n}: {\n  index: number;\n  count: number;\n  onChange: (index: number) => void;\n  announce: boolean;\n}) {\n  return (\n    // Same switcher as Conversation Branches: a labeled nav, and a counter\n    // that reads \"Version 2 of 3\" and is announced when it changes.\n    <nav aria-label=\"Message versions\" className=\"ml-0.5 flex items-center gap-0.5 border-l pl-1\">\n      <IconButton label=\"Previous version\" onClick={() => onChange(index - 1)} disabled={index <= 0}>\n        <ChevronLeft className=\"size-3.5\" aria-hidden />\n      </IconButton>\n      <span\n        className=\"min-w-8 text-center text-xs tabular-nums text-muted-foreground\"\n        aria-live={announce ? \"polite\" : \"off\"}\n        aria-atomic=\"true\"\n      >\n        <span className=\"sr-only\">Version </span>\n        {index + 1}\n        <span aria-hidden> / </span>\n        <span className=\"sr-only\"> of </span>\n        {count}\n      </span>\n      <IconButton label=\"Next version\" onClick={() => onChange(index + 1)} disabled={index >= count - 1}>\n        <ChevronRight className=\"size-3.5\" aria-hidden />\n      </IconButton>\n    </nav>\n  );\n}\n\nfunction IconButton({\n  label,\n  active,\n  disabled,\n  onClick,\n  children,\n}: {\n  label: string;\n  active?: boolean;\n  disabled?: boolean;\n  onClick?: () => void;\n  children: React.ReactNode;\n}) {\n  return (\n    <button\n      type=\"button\"\n      onClick={onClick}\n      disabled={disabled}\n      aria-label={label}\n      aria-pressed={active}\n      className={cn(\n        \"rounded-md p-1.5 transition-colors hover:bg-accent hover:text-foreground\",\n        active && \"bg-accent text-foreground\",\n        disabled && \"pointer-events-none opacity-40\"\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n","dependencies":["lucide-react"],"localImports":["@/lib/utils"]}