Chat Bubble with Actions
A message bubble with feedback thumbs, edit-and-resubmit, retry/regenerate, and a version stepper.
Which loader should I use while my agent is thinking?
For a single reasoning step, the Thinking Loader keeps it simple — a shimmering label with a live elapsed-time counter.
"use client";
import * as React from "react";
import { Check, ChevronLeft, ChevronRight, Copy, Pencil, RotateCcw, ThumbsDown, ThumbsUp } from "lucide-react";
import { cn } from "@/lib/utils";
export type ChatRole = "user" | "assistant";
export type ChatFeedback = "up" | "down" | null;
export interface ChatBubbleProps {
role: ChatRole;
content: string;
onEditSubmit?: (content: string) => void;
onRegenerate?: () => void;
onFeedback?: (feedback: ChatFeedback) => void;
/** 0-based index of the version currently shown. Omit (with versionCount) to hide the stepper. */
versionIndex?: number;
/** Total number of versions (edits/regenerations) available for this message. */
versionCount?: number;
onVersionChange?: (index: number) => void;
/**
* Announce version changes to screen readers. A user/assistant pair shares
* one version, so only one stepper should announce it — the user bubble by
* default. Set it on the assistant bubble if only replies show a stepper.
*/
announceVersion?: boolean;
className?: string;
}
export function ChatBubble({
role,
content,
onEditSubmit,
onRegenerate,
onFeedback,
versionIndex,
versionCount,
onVersionChange,
announceVersion = role === "user",
className,
}: ChatBubbleProps) {
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState(content);
const [feedback, setFeedback] = React.useState<ChatFeedback>(null);
const [copied, setCopied] = React.useState(false);
const isUser = role === "user";
const hasVersions = versionCount != null && versionCount > 1 && versionIndex != null && onVersionChange;
function startEdit() {
setDraft(content);
setEditing(true);
}
function handleFeedback(next: ChatFeedback) {
const value = feedback === next ? null : next;
setFeedback(value);
onFeedback?.(value);
}
async function handleCopy() {
await navigator.clipboard.writeText(content);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
}
function commitEdit() {
const trimmed = draft.trim();
setEditing(false);
if (!trimmed || trimmed === content) {
setDraft(content);
return;
}
onEditSubmit?.(trimmed);
}
function cancelEdit() {
setDraft(content);
setEditing(false);
}
return (
<div className={cn("group flex flex-col gap-1.5", isUser ? "items-end" : "items-start", className)}>
{editing ? (
<div className="w-full max-w-md space-y-2">
<textarea
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
commitEdit();
}
if (e.key === "Escape") cancelEdit();
}}
rows={Math.min(6, Math.max(2, draft.split("\n").length))}
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"
/>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={cancelEdit}
className="rounded-full px-3 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Cancel
</button>
<button
type="button"
onClick={commitEdit}
className="rounded-full bg-primary px-3 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Save & submit
</button>
</div>
</div>
) : (
<div
className={cn(
"max-w-md rounded-2xl px-4 py-2.5 text-sm leading-relaxed",
isUser ? "bg-primary text-primary-foreground" : "bg-muted text-foreground"
)}
>
{content}
</div>
)}
{!editing && (
<div
className={cn(
"flex items-center gap-0.5 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100",
(copied || feedback) && "opacity-100"
)}
>
{isUser ? (
<>
{onRegenerate && (
<IconButton label="Retry" onClick={onRegenerate}>
<RotateCcw className="size-3.5" />
</IconButton>
)}
<IconButton label="Edit message" onClick={startEdit}>
<Pencil className="size-3.5" />
</IconButton>
<IconButton label="Copy" onClick={handleCopy}>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</IconButton>
</>
) : (
<>
<IconButton label="Copy" onClick={handleCopy}>
{copied ? <Check className="size-3.5" /> : <Copy className="size-3.5" />}
</IconButton>
<IconButton label="Good response" active={feedback === "up"} onClick={() => handleFeedback("up")}>
<ThumbsUp className="size-3.5" />
</IconButton>
<IconButton label="Bad response" active={feedback === "down"} onClick={() => handleFeedback("down")}>
<ThumbsDown className="size-3.5" />
</IconButton>
<IconButton label="Regenerate" onClick={onRegenerate}>
<RotateCcw className="size-3.5" />
</IconButton>
</>
)}
{hasVersions && (
<VersionNav
index={versionIndex}
count={versionCount}
onChange={onVersionChange}
announce={announceVersion}
/>
)}
</div>
)}
</div>
);
}
function VersionNav({
index,
count,
onChange,
announce,
}: {
index: number;
count: number;
onChange: (index: number) => void;
announce: boolean;
}) {
return (
// Same switcher as Conversation Branches: a labeled nav, and a counter
// that reads "Version 2 of 3" and is announced when it changes.
<nav aria-label="Message versions" className="ml-0.5 flex items-center gap-0.5 border-l pl-1">
<IconButton label="Previous version" onClick={() => onChange(index - 1)} disabled={index <= 0}>
<ChevronLeft className="size-3.5" aria-hidden />
</IconButton>
<span
className="min-w-8 text-center text-xs tabular-nums text-muted-foreground"
aria-live={announce ? "polite" : "off"}
aria-atomic="true"
>
<span className="sr-only">Version </span>
{index + 1}
<span aria-hidden> / </span>
<span className="sr-only"> of </span>
{count}
</span>
<IconButton label="Next version" onClick={() => onChange(index + 1)} disabled={index >= count - 1}>
<ChevronRight className="size-3.5" aria-hidden />
</IconButton>
</nav>
);
}
function IconButton({
label,
active,
disabled,
onClick,
children,
}: {
label: string;
active?: boolean;
disabled?: boolean;
onClick?: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={label}
aria-pressed={active}
className={cn(
"rounded-md p-1.5 transition-colors hover:bg-accent hover:text-foreground",
active && "bg-accent text-foreground",
disabled && "pointer-events-none opacity-40"
)}
>
{children}
</button>
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Chat Bubble with Actions
## Summary
A 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.
## When to use
- Any chat-style conversation UI where actions belong to a single message, not a global toolbar.
- Assistant replies that benefit from an explicit quality signal (thumbs) or a one-click retry when the first answer misses.
- 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.
## When not to use
- Read-only transcripts (chat history exports, shared/public views) where no one should be able to edit or regenerate.
- 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.
- Very short-lived, ephemeral UI (toasts, system notices) — the action row adds visual weight that isn't earned there.
## Anatomy
- Message bubble: role-differentiated style (e.g. user bubble filled/right-aligned, assistant bubble muted/left-aligned).
- Action row, below the bubble, revealed on hover/focus:
- User: Retry, Edit, Copy.
- Assistant: Copy, thumbs up, thumbs down, Regenerate.
- Version stepper (optional, trailing): a vertical divider, then the previous / counter / next switcher from Conversation Branches.
- Edit mode: the bubble becomes an editable textarea in place, with Cancel and "Save & submit" controls.
## Behavior
- 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.
- 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.
- 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.
- 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.
- 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.
- This component doesn't generate replies itself: wire edit-submit, Retry and Regenerate to a re-send that creates the new version.
- Only one bubble is in edit mode at a time.
- 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".
## Content guidelines
- 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.
- Keep the action icons unlabeled visually but always give each a real accessible name — icon-only rows save space but must not go nameless.
## Accessibility
- 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.
- Thumbs up/down are toggle buttons — expose state with `aria-pressed`, not color alone.
- 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.
- The action row must be reachable by keyboard, not only `:hover` — use `:focus-within` on the bubble so Tab reveals it.
- 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.
## Related patterns
- **Streaming Text** — Use it as well while the assistant reply is still generating, then show this bubble's actions once it's done.
- **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.
- **Conversation Branches** — Use it as well; it defines how the versions behind this bubble's stepper work, and the stepper follows it.
- **Selection Actions** — Use it instead when the action applies to part of a message rather than the whole thing.
Do’s and don’ts
Do use it for
- Any chat-style conversation UI where actions belong to a single message, not a global toolbar.
- Assistant replies that benefit from an explicit quality signal (thumbs) or a one-click retry when the first answer misses.
- 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.
Don’t use it for
- Read-only transcripts (chat history exports, shared/public views) where no one should be able to edit or regenerate.
- 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.
- Very short-lived, ephemeral UI (toasts, system notices) — the action row adds visual weight that isn't earned there.
Related patterns
Streaming Text
Use it as well while the assistant reply is still generating, then show this bubble's actions once it's done.
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.
Conversation Branches
Use it as well; it defines how the versions behind this bubble's stepper work, and the stepper follows it.
Selection Actions
Use it instead when the action applies to part of a message rather than the whole thing.