messages
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;
className?: string;
}
export function ChatBubble({
role,
content,
onEditSubmit,
onRegenerate,
onFeedback,
versionIndex,
versionCount,
onVersionChange,
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} />
)}
</div>
)}
</div>
);
}
function VersionNav({
index,
count,
onChange,
}: {
index: number;
count: number;
onChange: (index: number) => void;
}) {
return (
<div 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" />
</IconButton>
<span className="min-w-[2.5ch] text-center text-xs tabular-nums text-muted-foreground">
{index + 1}/{count}
</span>
<IconButton label="Next version" onClick={() => onChange(index + 1)} disabled={index >= count - 1}>
<ChevronRight className="size-3.5" />
</IconButton>
</div>
);
}
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 — a version stepper ("2/2" with prev/next arrows) appears at the end of the row so people can page through history without losing it.
## 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.
- Conversations where regenerating or editing produces multiple candidate replies/prompts worth keeping around instead of discarding — the version stepper lets people compare them without duplicating bubbles on screen.
## 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, a previous-version chevron, an "n/total" counter, a next-version chevron — shown only when the message has more than one version.
- 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.
- Regenerating/retrying never overwrites history in place — it adds a new version and moves the stepper to it, so earlier attempts stay reachable via the prev arrow.
- 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.
- Submitting an edited user message is expected to invalidate and regenerate the assistant reply that followed it — this pattern doesn't perform that regeneration itself, but callers should wire the edit-submit handler to a re-send that also creates a new version.
- Only one bubble is in edit mode at a time.
- The version stepper is only rendered when there's more than one version (`versionCount > 1`) — a message with a single version shows no stepper at all, not a disabled "1/1".
- Navigating the stepper on either the user or assistant bubble in a pair should move both in lockstep, since a version represents one full turn (the prompt and the reply it produced), not two independently versioned halves.
- The prev/next arrows disable at the ends of the version range instead of wrapping.
## 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.
- Keep the version counter numeric and terse ("2/2"); it's a position indicator, not a label — don't spell out "Version 2 of 2" inline.
## Accessibility
- Every action button needs a descriptive `aria-label` ("Good response", "Bad response", "Regenerate", "Retry", "Edit message", "Copy", "Previous version", "Next version") since the icon alone carries no accessible name.
- Thumbs up/down are toggle buttons — expose state with `aria-pressed`, not color alone.
- The action row must be reachable by keyboard, not only `:hover` — use `:focus-within` on the bubble so Tab reveals it.
- Disabled stepper arrows (at the first/last version) must be real `disabled` buttons, not just dimmed, so assistive tech and keyboard users don't land on a dead control.
- 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
- Pairs with Streaming Text for the assistant reply while it's still generating; this pattern's action row is the equivalent post-stream toolbar for a persisted chat message rather than a one-off streamed answer.
- For a voice-composed message, show a Live Transcript while the person is still speaking, then hand the finalized text off into a user bubble once the turn ends — don't run the transcript's interim/final styling inside the bubble itself.