{"slug":"conversation-branches","category":"messages","job":"steer","added":"2026-11-07T23:13Z","title":"Conversation Branches","description":"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.","url":"https://ai-patterns.app/patterns/messages/conversation-branches","markdown":"https://ai-patterns.app/patterns/messages/conversation-branches.md","json":"https://ai-patterns.app/api/patterns/messages/conversation-branches","seenIn":[{"team":"chatgpt","source":"https://help.openai.com/en/articles/6825453-chatgpt-release-notes"}],"spec":"# Conversation Branches\n\n## Summary\nEditing 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.\n\n## When to use\n- Chat assistants where people often rephrase a question to get a better answer.\n- Exploratory work — naming, copy, ideas — where earlier answers stay useful after trying another angle.\n- Any product that lets people edit sent messages: branching makes the edit safe to try.\n\n## When not to use\n- Comparing two answers to the same prompt side by side — use Response Compare.\n- Small adjustments to an answer (shorter, more formal) — use Refinement Controls, which don't change the prompt.\n- Linear records where history must not fork, such as support tickets or audit trails.\n\n## Anatomy\n- User message with an edit button (shown on hover, always visible on touch).\n- Inline editor: the message text, a note that the original is kept, Cancel and Send.\n- Version switcher: previous, \"2 / 3\", next — shown once there is more than one version.\n- Reply area: the assistant's reply for the active version, with a placeholder while it is generated.\n\n## Behavior\n- 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.\n- The new branch shows a loading placeholder until its reply arrives; earlier branches keep their replies.\n- Previous and next slide the message and reply together in the direction of travel, so it's clear the whole turn changed.\n- Retry and Regenerate add a version the same way, with the same prompt and a new reply.\n- Everything after the edited message belongs to its branch: switching versions swaps the rest of the conversation too.\n- The host keeps the active version for each message, and new messages continue from the version on screen.\n\n## Content guidelines\n- Tell people the original is kept (\"Sends as a new branch — the original is kept\"), so editing doesn't feel destructive.\n- Keep the counter terse: \"2 / 3\". Don't name versions.\n- Label the edit action \"Edit message\", not \"Regenerate\" — it changes the question, not just the answer.\n\n## Accessibility\n- Edit, previous and next are buttons with accessible names; previous and next are disabled at the ends.\n- The counter reads as \"Version 2 of 3\" to screen readers and is announced when it changes.\n- The reply area is `aria-busy` while a branch is generating.\n- The slide is skipped when the user prefers reduced motion; the counter still marks the change.\n\n## Related patterns\n- **Response Compare** — Use it instead to see two answers to the same prompt side by side.\n- **Refinement Controls** — Use it instead to tweak an answer without rewriting the question.\n- **Chat Bubble with Actions** — Use it as well; its action row hosts the edit button and this version switcher next to copy and feedback.\n- **Prompt Enhancer** — Use it as well to rewrite the prompt before sending it as a new branch.\n","code":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ChevronLeft, ChevronRight, Pencil } from \"lucide-react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport interface Branch {\n  id: string;\n  prompt: string;\n  /** Undefined while the reply for this branch is still being generated. */\n  response?: React.ReactNode;\n}\n\nexport interface ConversationBranchesProps {\n  branches: Branch[];\n  /** Index of the branch on screen. */\n  active: number;\n  onActiveChange: (index: number) => void;\n  /** Called with the edited prompt; the host appends a new branch and makes it active. */\n  onEdit: (prompt: string) => void;\n  className?: string;\n}\n\nexport function ConversationBranches({ branches, active, onActiveChange, onEdit, className }: ConversationBranchesProps) {\n  const [editing, setEditing] = React.useState(false);\n  const [draft, setDraft] = React.useState(\"\");\n  const [direction, setDirection] = React.useState(1);\n  const reduceMotion = useReducedMotion();\n  const branch = branches[active];\n  const pending = branch?.response === undefined;\n\n  function go(index: number) {\n    if (index < 0 || index >= branches.length || index === active) return;\n    setDirection(index > active ? 1 : -1);\n    onActiveChange(index);\n  }\n\n  function startEdit() {\n    setDraft(branch.prompt);\n    setEditing(true);\n  }\n\n  function save() {\n    const text = draft.trim();\n    setEditing(false);\n    if (!text || text === branch.prompt) return;\n    setDirection(1);\n    onEdit(text);\n  }\n\n  if (!branch) return null;\n\n  const slide = reduceMotion ? 0 : 16;\n\n  return (\n    <div className={cn(\"flex w-full flex-col gap-3\", className)}>\n      {/* The user's turn: the part that branches. */}\n      <div className=\"group flex flex-col items-end gap-1\">\n        {editing ? (\n          <form\n            className=\"flex w-full max-w-[85%] flex-col gap-2 rounded-2xl border bg-card p-2\"\n            onSubmit={(e) => {\n              e.preventDefault();\n              save();\n            }}\n          >\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                  save();\n                }\n                if (e.key === \"Escape\") setEditing(false);\n              }}\n              aria-label=\"Edit message\"\n              rows={2}\n              className=\"w-full resize-none bg-transparent px-1.5 py-1 text-sm outline-none\"\n            />\n            <div className=\"flex items-center justify-between gap-2\">\n              <span className=\"px-1.5 text-[11px] text-muted-foreground\">Sends as a new branch — the original is kept.</span>\n              <div className=\"flex gap-1.5\">\n                <button\n                  type=\"button\"\n                  onClick={() => setEditing(false)}\n                  className=\"rounded-lg px-2.5 py-1 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground\"\n                >\n                  Cancel\n                </button>\n                <button type=\"submit\" className=\"rounded-lg bg-foreground px-2.5 py-1 text-xs font-medium text-background\">\n                  Send\n                </button>\n              </div>\n            </div>\n          </form>\n        ) : (\n          <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n            <motion.div\n              key={branch.id}\n              initial={{ opacity: 0, x: direction * slide }}\n              animate={{ opacity: 1, x: 0 }}\n              exit={{ opacity: 0, x: -direction * slide }}\n              transition={{ duration: 0.18, ease: \"easeOut\" }}\n              className=\"max-w-[85%] rounded-2xl bg-muted px-3.5 py-2 text-sm text-pretty whitespace-pre-wrap\"\n            >\n              {branch.prompt}\n            </motion.div>\n          </AnimatePresence>\n        )}\n\n        {!editing && (\n          <div className=\"flex items-center gap-1 text-muted-foreground\">\n            <button\n              type=\"button\"\n              onClick={startEdit}\n              aria-label=\"Edit message\"\n              title=\"Edit message\"\n              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\"\n            >\n              <Pencil className=\"size-3.5\" aria-hidden />\n            </button>\n            {branches.length > 1 && (\n              <nav aria-label=\"Message versions\" className=\"flex items-center\">\n                <button\n                  type=\"button\"\n                  onClick={() => go(active - 1)}\n                  disabled={active === 0}\n                  aria-label=\"Previous version\"\n                  className=\"flex size-7 items-center justify-center rounded-md hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-30\"\n                >\n                  <ChevronLeft className=\"size-4\" aria-hidden />\n                </button>\n                <span className=\"min-w-8 text-center text-xs tabular-nums\" aria-live=\"polite\" aria-atomic=\"true\">\n                  <span className=\"sr-only\">Version </span>\n                  {active + 1}\n                  <span aria-hidden> / </span>\n                  <span className=\"sr-only\"> of </span>\n                  {branches.length}\n                </span>\n                <button\n                  type=\"button\"\n                  onClick={() => go(active + 1)}\n                  disabled={active === branches.length - 1}\n                  aria-label=\"Next version\"\n                  className=\"flex size-7 items-center justify-center rounded-md hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-30\"\n                >\n                  <ChevronRight className=\"size-4\" aria-hidden />\n                </button>\n              </nav>\n            )}\n          </div>\n        )}\n      </div>\n\n      {/* The assistant's reply follows whichever branch is active. */}\n      <div className=\"relative min-h-16\" aria-busy={pending}>\n        <AnimatePresence mode=\"popLayout\" initial={false} custom={direction}>\n          <motion.div\n            key={branch.id}\n            initial={{ opacity: 0, x: direction * slide }}\n            animate={{ opacity: 1, x: 0 }}\n            exit={{ opacity: 0, x: -direction * slide }}\n            transition={{ duration: 0.2, ease: \"easeOut\" }}\n            className=\"text-sm leading-relaxed text-pretty\"\n          >\n            {pending ? <PendingLines /> : branch.response}\n          </motion.div>\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n\nfunction PendingLines() {\n  return (\n    <div className=\"flex flex-col gap-2 pt-1\" aria-label=\"Generating reply\">\n      {[92, 78, 54].map((w, i) => (\n        <motion.div\n          key={w}\n          className=\"h-3 rounded-full bg-muted\"\n          style={{ width: `${w}%` }}\n          animate={{ opacity: [0.5, 1, 0.5] }}\n          transition={{ duration: 1.2, repeat: Infinity, delay: i * 0.15 }}\n        />\n      ))}\n    </div>\n  );\n}\n","dependencies":["lucide-react","motion"],"localImports":["@/lib/utils"]}