{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "branch-switcher",
  "type": "registry:ui",
  "title": "Branch Switcher",
  "description": "Steps between regenerated replies; content slides the way you went, counts roll.",
  "categories": [
    "ai-chat"
  ],
  "dependencies": [
    "@number-flow/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json"
  ],
  "files": [
    {
      "path": "components/ui/branch-switcher.tsx",
      "content": "\"use client\";\nimport NumberFlow from \"@number-flow/react\";\nimport { AnimatePresence, animate, motion, useReducedMotion } from \"motion/react\";\nimport { createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ChevronLeft, ChevronRight } from \"@/lib/icons\";\nimport { ease } from \"@/lib/motion\";\n\n/* -------------------------------------------------------------------------------------------------\n * State\n * -----------------------------------------------------------------------------------------------*/\n\nexport type UseBranchSwitcherOptions = {\n  /** How many versions exist. */\n  count: number;\n  /** Controlled index, 0-based. */\n  value?: number;\n  /** Uncontrolled starting index. Defaults to the newest. */\n  defaultValue?: number;\n  onValueChange?: (index: number) => void;\n  /** When a new version is added, show it. Uncontrolled only; a controlled parent decides for itself. */\n  followNew?: boolean;\n};\n\nexport type BranchSwitcherState = {\n  index: number;\n  count: number;\n  /** 1 when the last move went to a later version, -1 when it went back. */\n  direction: 1 | -1;\n  go: (index: number) => void;\n  prev: () => void;\n  next: () => void;\n};\n\n/** The index, the direction of travel and the moves, without any UI. */\nexport function useBranchSwitcher({ count, value, defaultValue, onValueChange, followNew = true }: UseBranchSwitcherOptions): BranchSwitcherState {\n  const [inner, setInner] = useState(defaultValue ?? Math.max(0, count - 1));\n  const [seenCount, setSeenCount] = useState(count);\n  const controlled = value !== undefined;\n\n  // A regenerated reply is a new last version; take the reader to it (adjusting state during render).\n  if (count !== seenCount) {\n    setSeenCount(count);\n    if (!controlled && followNew && count > seenCount) setInner(count - 1);\n  }\n\n  const index = clamp(controlled ? value : inner, count);\n  const [track, setTrack] = useState<{ index: number; direction: 1 | -1 }>({ index, direction: 1 });\n  if (track.index !== index) setTrack({ index, direction: index > track.index ? 1 : -1 });\n\n  const go = useCallback(\n    (next: number) => {\n      const to = clamp(next, count);\n      if (to === index) return;\n      if (!controlled) setInner(to);\n      onValueChange?.(to);\n    },\n    [count, index, controlled, onValueChange],\n  );\n\n  return { index, count, direction: track.direction, go, prev: () => go(index - 1), next: () => go(index + 1) };\n}\n\nconst clamp = (i: number, count: number) => Math.min(Math.max(0, i), Math.max(0, count - 1));\n\n/* -------------------------------------------------------------------------------------------------\n * Root\n * -----------------------------------------------------------------------------------------------*/\n\ntype Ctx = BranchSwitcherState & { noun: string };\nconst BranchContext = createContext<Ctx | null>(null);\n\nfunction useBranch() {\n  const ctx = useContext(BranchContext);\n  if (!ctx) throw new Error(\"BranchSwitcher parts must be inside <BranchSwitcher>\");\n  return ctx;\n}\n\nexport type BranchSwitcherProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> &\n  UseBranchSwitcherOptions & {\n    /** What a version is called, for labels and announcements: \"response\", \"message\", \"draft\". */\n    noun?: string;\n  };\n\n/** Holds which version is showing. Put the content and the control anywhere inside. */\nexport function BranchSwitcher({ count, value, defaultValue, onValueChange, followNew, noun = \"response\", className, children, ...rest }: BranchSwitcherProps) {\n  const state = useBranchSwitcher({ count, value, defaultValue, onValueChange, followNew });\n  return (\n    <BranchContext value={{ ...state, noun }}>\n      <div className={cn(\"min-w-0\", className)} {...rest}>\n        {children}\n      </div>\n    </BranchContext>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Content: the version that's showing, sliding the way you moved\n * -----------------------------------------------------------------------------------------------*/\n\nexport type BranchSwitcherContentProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** Renders the version at an index. */\n  children: (index: number) => React.ReactNode;\n};\n\nexport function BranchSwitcherContent({ className, children, ...rest }: BranchSwitcherContentProps) {\n  const { index, direction } = useBranch();\n  const reduce = !!useReducedMotion();\n  const box = useRef<HTMLDivElement>(null);\n  const last = useRef(0);\n  const first = useRef(true);\n\n  // Remember the height as content changes on its own (a reply streaming in), without animating it.\n  useEffect(() => {\n    const el = box.current;\n    if (!el) return;\n    const ro = new ResizeObserver(() => {\n      if (!el.style.height) last.current = el.offsetHeight;\n    });\n    ro.observe(el);\n    return () => ro.disconnect();\n  }, []);\n\n  // Only a change of version animates the height: from the old version's height to the new one's,\n  // then back to auto so streaming text is never clipped by a height that's catching up.\n  useLayoutEffect(() => {\n    const el = box.current;\n    if (!el) return;\n    if (first.current) {\n      first.current = false;\n      last.current = el.offsetHeight;\n      return;\n    }\n    const from = last.current;\n    el.style.height = \"\";\n    const to = el.offsetHeight;\n    last.current = to;\n    if (reduce || from === to || !from) return;\n    el.style.height = `${from}px`;\n    const controls = animate(el, { height: [from, to] }, { duration: 0.28, ease: ease.inOut });\n    controls.then(() => (el.style.height = \"\"));\n    return () => {\n      controls.stop();\n      el.style.height = \"\";\n    };\n  }, [index, reduce]);\n\n  return (\n    <div\n      ref={box}\n      // Clip only vertically, so the height change is clean but the slide and focus rings aren't cut.\n      className={cn(\"relative [overflow-x:visible] [overflow-y:clip]\", className)}\n      aria-live=\"off\"\n      {...rest}\n    >\n      <AnimatePresence initial={false} mode=\"popLayout\" custom={direction}>\n        <motion.div\n          key={index}\n          custom={direction}\n          variants={{\n            enter: (d: number) => (reduce ? { opacity: 0 } : { opacity: 0, x: 18 * d, filter: \"blur(2px)\" }),\n            center: { opacity: 1, x: 0, filter: \"blur(0px)\" },\n            exit: (d: number) => (reduce ? { opacity: 0, transition: { duration: 0.1 } } : { opacity: 0, x: -14 * d, filter: \"blur(2px)\", transition: { duration: 0.16, ease: ease.in } }),\n          }}\n          initial=\"enter\"\n          animate=\"center\"\n          exit=\"exit\"\n          transition={{ duration: reduce ? 0.14 : 0.26, ease: ease.out }}\n          className=\"w-full\"\n        >\n          {children(index)}\n        </motion.div>\n      </AnimatePresence>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Control: ‹ 2 / 3 ›\n * -----------------------------------------------------------------------------------------------*/\n\nexport type BranchSwitcherControlProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  size?: \"sm\" | \"md\";\n  /** Keep the control on screen when there's only one version. Hidden by default. */\n  showSingle?: boolean;\n};\n\nexport function BranchSwitcherControl({ size = \"sm\", showSingle = false, className, onKeyDown, ...rest }: BranchSwitcherControlProps) {\n  const { index, count, direction, prev, next, noun } = useBranch();\n  const reduce = !!useReducedMotion();\n  const [announced, setAnnounced] = useState(\"\");\n  const moved = useRef(false);\n\n  // Announce only moves the reader made, not the first render or a new version arriving.\n  useEffect(() => {\n    if (!moved.current) return;\n    moved.current = false;\n    setAnnounced(`${cap(noun)} ${index + 1} of ${count}`);\n  }, [index, count, noun]);\n\n  if (count < 2 && !showSingle) return null;\n\n  const move = (fn: () => void) => {\n    moved.current = true;\n    fn();\n  };\n  const atStart = index === 0;\n  const atEnd = index >= count - 1;\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={`${cap(noun)} ${index + 1} of ${count}`}\n      data-size={size}\n      onKeyDown={(e) => {\n        onKeyDown?.(e);\n        if (e.defaultPrevented) return;\n        // Arrows step between versions from either button, so a keyboard user can flip back and forth in place.\n        if (e.key === \"ArrowLeft\" && !atStart) {\n          e.preventDefault();\n          move(prev);\n        } else if (e.key === \"ArrowRight\" && !atEnd) {\n          e.preventDefault();\n          move(next);\n        }\n      }}\n      className={cn(\"inline-flex select-none items-center text-fg-3\", size === \"sm\" ? \"h-7 gap-1 text-[12px]\" : \"h-8 gap-1.5 text-[12.5px]\", className)}\n      {...rest}\n    >\n      <StepButton label={`Previous ${noun}`} disabled={atStart} size={size} onPress={() => move(prev)}>\n        <ChevronLeft size={size === \"sm\" ? 14 : 16} />\n      </StepButton>\n      <span aria-hidden className=\"flex min-w-[3ch] items-center justify-center gap-[0.3em] font-medium tabular text-fg-2\">\n        <NumberFlow value={index + 1} trend={direction} animated={!reduce} className=\"tabular\" />\n        <span className=\"text-fg-4\">/</span>\n        <NumberFlow value={count} trend={1} animated={!reduce} className=\"tabular\" />\n      </span>\n      <StepButton label={`Next ${noun}`} disabled={atEnd} size={size} onPress={() => move(next)}>\n        <ChevronRight size={size === \"sm\" ? 14 : 16} />\n      </StepButton>\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {announced}\n      </span>\n    </div>\n  );\n}\n\n// aria-disabled rather than disabled: stepping to the last version must not throw focus to the body.\nfunction StepButton({ label, disabled, size, onPress, children }: { label: string; disabled: boolean; size: \"sm\" | \"md\"; onPress: () => void; children: React.ReactNode }) {\n  return (\n    <button\n      type=\"button\"\n      aria-label={label}\n      aria-disabled={disabled || undefined}\n      onClick={() => !disabled && onPress()}\n      className={cn(\n        \"relative grid shrink-0 place-items-center rounded-md text-fg-3 outline-none\",\n        \"touch-manipulation [-webkit-tap-highlight-color:transparent]\",\n        \"transition-[background-color,color,scale,opacity] duration-150 ease-out active:scale-[0.9] active:duration-75\",\n        \"hover:bg-hover hover:text-fg focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-1 focus-visible:outline-fg-3\",\n        \"aria-disabled:cursor-default aria-disabled:text-fg-4 aria-disabled:opacity-60 aria-disabled:hover:bg-transparent aria-disabled:active:scale-100\",\n        \"before:absolute before:-inset-2.5 before:content-[''] pointer-fine:before:hidden motion-reduce:active:scale-100\",\n        size === \"sm\" ? \"size-6\" : \"size-7\",\n      )}\n    >\n      {children}\n    </button>\n  );\n}\n\nconst cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/branch-switcher"
}
