WebberUI

AI Branch Switcher (React)

A React version switcher for a single AI reply: a ‹ 2 / 3 › pager slides the content directionally, regenerate opens a new branch with a typing skeleton, an optional mini branch tree sits above the bubble, ←/→ switch versions while the bubble has focus, plus copy and model/time captions.

This keeps every version of one reply inside a single assistant bubble: the bottom toolbar holds a "‹ 2 / 3 ›" pager, regenerate and copy buttons, and a model/time caption on the right. Switching slides the content with AnimatePresence in the direction you travel — going forward the new version enters from the right while the old one exits left, going back does the reverse. Pressing regenerate calls onRegenerate; set isGenerating to true while you stream and the content area swaps to a typing skeleton with a pending slot reserved in the pager and branch tree, then push the new version into versions and it slides in from the right. Turn on showTree to draw a main line above the bubble with one small dot per version — the current one highlighted and each clickable. With focus inside the bubble, ←/→ (and Home/End) switch versions too. The index works controlled or uncontrolled.

Loading preview…
npx shadcn@latest add https://webberui.com/r/ai-branch-switcher.json

Playground

Tune the props live — the code snippet updates as you go, so you can dial in the look you want before copying it.

<AiBranchSwitcher />

Installation

npx shadcn@latest add https://webberui.com/r/ai-branch-switcher.json

Or, once registries are configured in components.json, install it as @webberui/ai-branch-switcher.

Usage

import { AiBranchSwitcher, type BranchVersion } from "@/components/ui/ai-branch-switcher";

// Content and model names are fictional
const [versions, setVersions] = React.useState<BranchVersion[]>([
  { id: "v1", model: "Aurora-7", createdAt: "09:12", content: "【專業版】……" },
  { id: "v2", model: "Aurora-7", createdAt: "09:13", content: "【輕鬆版】……" },
]);
const [index, setIndex] = React.useState(1);
const [generating, setGenerating] = React.useState(false);

<AiBranchSwitcher
  versions={versions}
  index={index}
  onIndexChange={setIndex}
  isGenerating={generating}
  showTree
  onRegenerate={async () => {
    setGenerating(true);
    const content = await regenerate(); // your stream / request
    // One batch: push the new version, jump the index to the end, stop generating
    setVersions((prev) => [...prev, { id: crypto.randomUUID(), content, model: "Aurora-7" }]);
    setIndex(versions.length);
    setGenerating(false);
  }}
/>

Props

PropTypeDefaultDescription
versionsBranchVersion[]Every version (branch) of the same reply, in generation order
indexnumberControlled current index (0-based); when provided the parent owns it
defaultIndexnumber0Initial index in uncontrolled mode (0-based)
onIndexChange(index: number) => voidCalled when the index changes (pager, branch tree, keyboard, and when uncontrolled mode auto-follows a newly appended version)
onRegenerate() => voidCalled when "Regenerate" is pressed; the button is hidden when omitted
isGeneratingbooleanfalseWhether a new branch not yet in versions is being generated: the content area shows a typing skeleton, the pager and branch tree reserve a pending slot, and the toolbar is disabled
showTreebooleanfalseShow the mini branch tree above the bubble (one main line with a small dot per version, each clickable)
compactbooleanfalseCompact mode: tighter padding, smaller text and toolbar
renderContent(version: BranchVersion) => React.ReactNodeCustom content renderer (e.g. Markdown); by default plain text is split into paragraphs on blank lines
regenerateLabelstring"Regenerate" button text; a shortcut for labels.regenerate and takes precedence when both are given
labelsPartial<AiBranchSwitcherLabels>UI copy; override any subset
classNamestringForwarded to the outermost container

BranchVersion

FieldTypeDefaultDescription
idstringUnique id, used as the React key and the boundary of the slide animation
contentstringReply content; rendered as plain-text paragraphs (split on blank lines) by default, or via renderContent
createdAtstringDisplay text for the generation time (e.g. "14:32"); the component does no timezone conversion and shows it as-is
modelstringName of the model that produced this version, shown as a small caption on the right of the toolbar

AiBranchSwitcherLabels

FieldTypeDefaultDescription
regeneratestring"重新生成" (Regenerate)"Regenerate" button text
previousstring"上一個版本" (Previous version)Accessible label of the "previous" button
nextstring"下一個版本" (Next version)Accessible label of the "next" button
copystring"複製這個版本" (Copy this version)Accessible label of the copy button
copiedstring"已複製" (Copied)Text shown briefly after a successful copy
generatingstring"正在產生新的版本" (Generating a new version)Generating hint (under the skeleton and in the aria-live announcement)
treestring"回覆版本分支" (Reply version branches)Accessible label of the branch tree region
emptystring"尚無回覆版本" (No versions yet)Text shown when there are no versions
position(current: number, total: number) => string第 ${current} 個版本,共 ${total} 個 (Version 2 of 3)Position text for the pager and the aria-live announcement

clampBranchIndex(index, count) (clamps an index into the valid range) and renderPlainParagraphs(content) (the default plain-text paragraph renderer) are also named exports, so a custom renderContent can reuse the default paragraph styling.

How it works

  • Direction: the slide direction is derived during render from the previous index, so controlled, uncontrolled, and external index changes all animate correctly — new versions enter from the right and old ones exit left, reversed when going back. Starting a new generation always counts as "forward", so the skeleton and the outgoing content never enter and exit on the same side after you had just paged backwards.
  • Regenerate data flow: the component makes no requests — the button only calls onRegenerate. Set isGenerating to true while the request runs (the content area becomes a skeleton and the pager shows a reserved "4 / 4" slot), then in one batch push into versions, point index at the last item, and set isGenerating back to false; the new version slides in from the right.
  • Uncontrolled auto-follow: without an index prop, whenever versions grows the component switches to the newest version and fires onIndexChange; in controlled mode the parent decides whether to jump.
  • Content height: switching uses AnimatePresence in popLayout mode, so the old and new versions coexist and the exiting one is clipped by overflow-hidden; the bubble height updates directly to the new content without a height animation, so versions of very different length never slow the switch down.
  • Time text: createdAt is a display string — the component neither parses nor timezone-shifts it. Whether you show "14:32" or "yesterday 09:10" is up to you, and server and client output stay identical.
  • Copy: uses navigator.clipboard.writeText and fails silently on non-HTTPS pages or when permission is denied (the content can still be selected by hand); on success the icon becomes a check mark for 1.6 seconds.

Accessibility

  • The bubble is a role="group" with aria-roledescription="回覆版本" and an aria-label of the current position ("Version 2 of 3"); the whole bubble is focusable (tabIndex=0) with a focus-visible ring, and while focus is inside it ←/→ move to the previous/next version and Home/End jump to the first/last
  • Both pager buttons are native buttons (type="button") with aria-labels (localize via labels.previous/labels.next) and are disabled at the ends; the pager group itself also carries the position text, while the visible "2 / 3" is hidden from assistive tech to avoid double reading
  • Every branch-tree dot is a button whose aria-label is that version's position text, with aria-current="true" on the current one; the whole tree is disabled while generating
  • A hidden role="status", aria-live="polite" region announces the new position on every switch and labels.generating while a new branch is generating; the bubble gets aria-busy while generating
  • When the user has "reduce motion" enabled at the system level, the horizontal slide, blur, digit roll, branch-tree ring movement, and skeleton shimmer are disabled and switching keeps only a fade

On this page