Conversation BranchesNew
Editing a sent message creates a new branch instead of overwriting it, with a ‹ 2 / 3 › switcher that swaps the message and its reply together.
Hover the message and edit it — try “Suggest a short French name for my bakery”.
Suggest a name for my bakery
How about “Rise & Shine”? It's warm, easy to remember and hints at fresh morning bread.
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronLeft, ChevronRight, Pencil } from "lucide-react";
import { cn } from "@/lib/utils";
export interface Branch {
id: string;
prompt: string;
/** Undefined while the reply for this branch is still being generated. */
response?: React.ReactNode;
}
export interface ConversationBranchesProps {
branches: Branch[];
/** Index of the branch on screen. */
active: number;
onActiveChange: (index: number) => void;
/** Called with the edited prompt; the host appends a new branch and makes it active. */
onEdit: (prompt: string) => void;
className?: string;
}
export function ConversationBranches({ branches, active, onActiveChange, onEdit, className }: ConversationBranchesProps) {
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState("");
const [direction, setDirection] = React.useState(1);
const reduceMotion = useReducedMotion();
const branch = branches[active];
const pending = branch?.response === undefined;
function go(index: number) {
if (index < 0 || index >= branches.length || index === active) return;
setDirection(index > active ? 1 : -1);
onActiveChange(index);
}
function startEdit() {
setDraft(branch.prompt);
setEditing(true);
}
function save() {
const text = draft.trim();
setEditing(false);
if (!text || text === branch.prompt) return;
setDirection(1);
onEdit(text);
}
if (!branch) return null;
const slide = reduceMotion ? 0 : 16;
return (
<div className={cn("flex w-full flex-col gap-3", className)}>
{/* The user's turn: the part that branches. */}
<div className="group flex flex-col items-end gap-1">
{editing ? (
<form
className="flex w-full max-w-[85%] flex-col gap-2 rounded-2xl border bg-card p-2"
onSubmit={(e) => {
e.preventDefault();
save();
}}
>
<textarea
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
save();
}
if (e.key === "Escape") setEditing(false);
}}
aria-label="Edit message"
rows={2}
className="w-full resize-none bg-transparent px-1.5 py-1 text-sm outline-none"
/>
<div className="flex items-center justify-between gap-2">
<span className="px-1.5 text-[11px] text-muted-foreground">Sends as a new branch — the original is kept.</span>
<div className="flex gap-1.5">
<button
type="button"
onClick={() => setEditing(false)}
className="rounded-lg px-2.5 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground"
>
Cancel
</button>
<button type="submit" className="rounded-lg bg-foreground px-2.5 py-1 text-xs font-medium text-background">
Send
</button>
</div>
</div>
</form>
) : (
<AnimatePresence mode="popLayout" initial={false} custom={direction}>
<motion.div
key={branch.id}
initial={{ opacity: 0, x: direction * slide }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -direction * slide }}
transition={{ duration: 0.18, ease: "easeOut" }}
className="max-w-[85%] rounded-2xl bg-muted px-3.5 py-2 text-sm text-pretty whitespace-pre-wrap"
>
{branch.prompt}
</motion.div>
</AnimatePresence>
)}
{!editing && (
<div className="flex items-center gap-1 text-muted-foreground">
<button
type="button"
onClick={startEdit}
aria-label="Edit message"
title="Edit message"
className="flex size-7 items-center justify-center rounded-md transition-opacity hover:bg-accent hover:text-foreground focus-visible:opacity-100 [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover:opacity-100"
>
<Pencil className="size-3.5" aria-hidden />
</button>
{branches.length > 1 && (
<nav aria-label="Message versions" className="flex items-center">
<button
type="button"
onClick={() => go(active - 1)}
disabled={active === 0}
aria-label="Previous version"
className="flex size-7 items-center justify-center rounded-md hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
>
<ChevronLeft className="size-4" aria-hidden />
</button>
<span className="min-w-8 text-center text-xs tabular-nums" aria-live="polite" aria-atomic="true">
<span className="sr-only">Version </span>
{active + 1}
<span aria-hidden> / </span>
<span className="sr-only"> of </span>
{branches.length}
</span>
<button
type="button"
onClick={() => go(active + 1)}
disabled={active === branches.length - 1}
aria-label="Next version"
className="flex size-7 items-center justify-center rounded-md hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-30"
>
<ChevronRight className="size-4" aria-hidden />
</button>
</nav>
)}
</div>
)}
</div>
{/* The assistant's reply follows whichever branch is active. */}
<div className="relative min-h-16" aria-busy={pending}>
<AnimatePresence mode="popLayout" initial={false} custom={direction}>
<motion.div
key={branch.id}
initial={{ opacity: 0, x: direction * slide }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -direction * slide }}
transition={{ duration: 0.2, ease: "easeOut" }}
className="text-sm leading-relaxed text-pretty"
>
{pending ? <PendingLines /> : branch.response}
</motion.div>
</AnimatePresence>
</div>
</div>
);
}
function PendingLines() {
return (
<div className="flex flex-col gap-2 pt-1" aria-label="Generating reply">
{[92, 78, 54].map((w, i) => (
<motion.div
key={w}
className="h-3 rounded-full bg-muted"
style={{ width: `${w}%` }}
animate={{ opacity: [0.5, 1, 0.5] }}
transition={{ duration: 1.2, repeat: Infinity, delay: i * 0.15 }}
/>
))}
</div>
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Conversation Branches
## Summary
Editing a message that has already been sent creates a new branch of the conversation instead of overwriting it. A small ‹ 2 / 3 › switcher under the message moves between versions, and the assistant's reply follows whichever version is shown. People can explore a different phrasing without losing an answer they liked.
## When to use
- Chat assistants where people often rephrase a question to get a better answer.
- Exploratory work — naming, copy, ideas — where earlier answers stay useful after trying another angle.
- Any product that lets people edit sent messages: branching makes the edit safe to try.
## When not to use
- Comparing two answers to the same prompt side by side — use Response Compare.
- Small adjustments to an answer (shorter, more formal) — use Refinement Controls, which don't change the prompt.
- Linear records where history must not fork, such as support tickets or audit trails.
## Anatomy
- User message with an edit button (shown on hover, always visible on touch).
- Inline editor: the message text, a note that the original is kept, Cancel and Send.
- Version switcher: previous, "2 / 3", next — shown once there is more than one version.
- Reply area: the assistant's reply for the active version, with a placeholder while it is generated.
## Behavior
- Editing opens the message in place; Enter or Send creates a new branch and switches to it, Escape or Cancel leaves everything as it was. An unchanged or empty edit does nothing.
- The new branch shows a loading placeholder until its reply arrives; earlier branches keep their replies.
- Previous and next slide the message and reply together in the direction of travel, so it's clear the whole turn changed.
- Retry and Regenerate add a version the same way, with the same prompt and a new reply.
- Everything after the edited message belongs to its branch: switching versions swaps the rest of the conversation too.
- The host keeps the active version for each message, and new messages continue from the version on screen.
## Content guidelines
- Tell people the original is kept ("Sends as a new branch — the original is kept"), so editing doesn't feel destructive.
- Keep the counter terse: "2 / 3". Don't name versions.
- Label the edit action "Edit message", not "Regenerate" — it changes the question, not just the answer.
## Accessibility
- Edit, previous and next are buttons with accessible names; previous and next are disabled at the ends.
- The counter reads as "Version 2 of 3" to screen readers and is announced when it changes.
- The reply area is `aria-busy` while a branch is generating.
- The slide is skipped when the user prefers reduced motion; the counter still marks the change.
## Related patterns
- **Response Compare** — Use it instead to see two answers to the same prompt side by side.
- **Refinement Controls** — Use it instead to tweak an answer without rewriting the question.
- **Chat Bubble with Actions** — Use it as well; its action row hosts the edit button and this version switcher next to copy and feedback.
- **Prompt Enhancer** — Use it as well to rewrite the prompt before sending it as a new branch.
Do’s and don’ts
Do use it for
- Chat assistants where people often rephrase a question to get a better answer.
- Exploratory work — naming, copy, ideas — where earlier answers stay useful after trying another angle.
- Any product that lets people edit sent messages: branching makes the edit safe to try.
Don’t use it for
- Comparing two answers to the same prompt side by side — use Response Compare.
- Small adjustments to an answer (shorter, more formal) — use Refinement Controls, which don't change the prompt.
- Linear records where history must not fork, such as support tickets or audit trails.
Related patterns
Response Compare
Use it instead to see two answers to the same prompt side by side.
Refinement Controls
Use it instead to tweak an answer without rewriting the question.
Chat Bubble with Actions
Use it as well; its action row hosts the edit button and this version switcher next to copy and feedback.
Prompt Enhancer
Use it as well to rewrite the prompt before sending it as a new branch.