{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "chat-branches",
  "type": "registry:component",
  "title": "Chat Branches",
  "description": "A conversation you can fork from any message, with a little branch map up top that shows every path you tried and hops between them in one click.",
  "categories": [
    "component",
    "Conversation"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/chat-branches.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { ArrowUpIcon, ChevronLeftIcon, ChevronRightIcon, GitBranchPlusIcon, SparklesIcon, XIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type FormEvent, useEffect, useId, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nfunction useReducedMotionAfterMount() {\n  const prefers = useReducedMotion();\n  const [mounted, setMounted] = useState(false);\n  useEffect(() => setMounted(true), []);\n  return mounted && !!prefers;\n}\n\nexport interface BranchMessage {\n  id: string;\n  parentId: string | null;\n  role: \"user\" | \"assistant\";\n  text: string;\n}\n\nexport interface ChatBranchesProps {\n  title?: string;\n  messages: BranchMessage[];\n  defaultLeafId?: string;\n  respond?: (text: string, history: BranchMessage[]) => string;\n  assistantName?: string;\n  className?: string;\n}\n\nconst spring = { type: \"spring\", stiffness: 480, damping: 38 } as const;\nconst STEP = 16;\nconst ROW = 26;\n\nconst cannedReplies = [\n  \"Here's a tighter take: “Work offline in Tideline 2.4. Your edits wait patiently and sync the moment you're back.”\",\n  \"Sure. I led with the speed this time: “Sync is now up to 3× faster, and it keeps going when your Wi-Fi doesn't.”\",\n  \"Done. It's under 120 characters now, so it fits the in-app banner without truncating.\",\n];\n\nfunction useTree(messages: BranchMessage[]) {\n  return useMemo(() => {\n    const byId = new Map(messages.map((m) => [m.id, m]));\n    const children = new Map<string | null, BranchMessage[]>();\n    for (const m of messages) {\n      const list = children.get(m.parentId) ?? [];\n      list.push(m);\n      children.set(m.parentId, list);\n    }\n    const leaves = messages.filter((m) => !children.has(m.id));\n    const pathTo = (id: string | null) => {\n      const out: BranchMessage[] = [];\n      let cur = id ? byId.get(id) : undefined;\n      while (cur) {\n        out.unshift(cur);\n        cur = cur.parentId ? byId.get(cur.parentId) : undefined;\n      }\n      return out;\n    };\n    const lane = new Map<string, number>();\n    const depth = new Map<string, number>();\n    leaves.forEach((leaf, i) => {\n      pathTo(leaf.id).forEach((m, d) => {\n        depth.set(m.id, d);\n        if (!lane.has(m.id)) lane.set(m.id, i);\n      });\n    });\n    const tipOf = (id: string) => {\n      for (let i = leaves.length - 1; i >= 0; i--) {\n        if (pathTo(leaves[i].id).some((m) => m.id === id)) return leaves[i].id;\n      }\n      return id;\n    };\n    return { byId, children, leaves, pathTo, lane, depth, tipOf };\n  }, [messages]);\n}\n\nexport function ChatBranches({\n  title = \"Release note, v2.4\",\n  messages: initial,\n  defaultLeafId,\n  respond,\n  assistantName = \"Lumen\",\n  className,\n}: ChatBranchesProps) {\n  const reduce = useReducedMotionAfterMount();\n  const inputId = useId();\n  const [messages, setMessages] = useState(initial);\n  const tree = useTree(messages);\n  const [leafId, setLeafId] = useState<string>(defaultLeafId ?? tree.leaves[0]?.id ?? \"\");\n  const [forkFrom, setForkFrom] = useState<string | null>(null);\n  const [draft, setDraft] = useState(\"\");\n  const [pending, setPending] = useState(false);\n  const replyCount = useRef(0);\n  const scrollRef = useRef<HTMLDivElement>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n  const timer = useRef<ReturnType<typeof setTimeout>>(undefined);\n\n  useEffect(() => () => clearTimeout(timer.current), []);\n\n  const viewTip = forkFrom ?? leafId;\n  const path = tree.pathTo(viewTip);\n  const activeIds = new Set(tree.pathTo(leafId).map((m) => m.id));\n  const activeLane = tree.lane.get(leafId) ?? 0;\n\n  useEffect(() => {\n    const el = scrollRef.current;\n    if (el) el.scrollTo({ top: el.scrollHeight, behavior: reduce ? \"auto\" : \"smooth\" });\n  }, [viewTip, pending, reduce]);\n\n  const hop = (id: string) => {\n    if (forkFrom) cancelFork();\n    setLeafId(tree.tipOf(id));\n  };\n\n  const startFork = (id: string) => {\n    const m = tree.byId.get(id);\n    if (m?.role === \"user\") setDraft(m.text);\n    else if (forkSource?.role === \"user\") setDraft(\"\");\n    setForkFrom(id);\n    requestAnimationFrame(() => {\n      inputRef.current?.focus();\n      inputRef.current?.select();\n    });\n  };\n\n  const cancelFork = () => {\n    if (forkSource?.role === \"user\") setDraft(\"\");\n    setForkFrom(null);\n  };\n\n  const send = (e: FormEvent) => {\n    e.preventDefault();\n    const text = draft.trim();\n    if (!text || pending) return;\n    const from = forkFrom ? tree.byId.get(forkFrom) : null;\n    const parentId = from ? (from.role === \"user\" ? from.parentId : from.id) : leafId;\n    const userMsg: BranchMessage = { id: `u-${Date.now()}`, parentId, role: \"user\", text };\n    const history = [...tree.pathTo(parentId), userMsg];\n    setMessages((m) => [...m, userMsg]);\n    setLeafId(userMsg.id);\n    setForkFrom(null);\n    setDraft(\"\");\n    setPending(true);\n    timer.current = setTimeout(() => {\n      const reply =\n        respond?.(text, history) ?? cannedReplies[replyCount.current++ % cannedReplies.length];\n      const botMsg: BranchMessage = { id: `a-${Date.now()}`, parentId: userMsg.id, role: \"assistant\", text: reply };\n      setMessages((m) => [...m, botMsg]);\n      setLeafId(botMsg.id);\n      setPending(false);\n    }, 1100);\n  };\n\n  const forkSource = forkFrom ? tree.byId.get(forkFrom) : null;\n\n  return (\n    <section\n      aria-label={title}\n      className={cn(\"flex w-full max-w-[440px] flex-col rounded-2xl border bg-card text-card-foreground shadow-xs/5\", className)}\n    >\n      <header className=\"flex items-center justify-between gap-3 px-4 pt-3.5 pb-3\">\n        <h3 className=\"truncate font-medium text-sm\">{title}</h3>\n        <span className=\"shrink-0 text-muted-foreground text-xs tabular-nums\">\n          {tree.leaves.length} {tree.leaves.length === 1 ? \"branch\" : \"branches\"}\n        </span>\n      </header>\n\n      <Minimap activeIds={activeIds} activeLane={activeLane} onHop={hop} reduce={reduce} tree={tree} />\n\n      <div className=\"relative mt-3 h-72 overflow-y-auto border-t px-4 py-4 [mask-image:linear-gradient(to_bottom,transparent,black_14px,black_calc(100%-14px),transparent)]\" ref={scrollRef}>\n        <ol className=\"flex flex-col gap-4\">\n          <AnimatePresence initial={false}>\n            {path.map((m) => {\n              const siblings = tree.children.get(m.parentId) ?? [];\n              const index = siblings.findIndex((s) => s.id === m.id);\n              return (\n                <motion.li\n                  animate={{ opacity: 1, y: 0 }}\n                  exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6 }}\n                  key={m.id}\n                  transition={{ ...spring, opacity: { duration: 0.18 } }}\n                >\n                  <Message\n                    assistantName={assistantName}\n                    forking={forkFrom === m.id}\n                    message={m}\n                    onFork={() => startFork(m.id)}\n                  />\n                  {siblings.length > 1 && (\n                    <div\n                      className={cn(\n                        \"mt-1 flex items-center gap-0.5 text-muted-foreground text-xs tabular-nums\",\n                        m.role === \"user\" ? \"justify-end\" : \"ps-8\",\n                      )}\n                    >\n                      <PagerButton\n                        disabled={index === 0}\n                        label=\"Previous branch\"\n                        onClick={() => hop(siblings[index - 1].id)}\n                      >\n                        <ChevronLeftIcon className=\"size-3.5\" />\n                      </PagerButton>\n                      <span className=\"min-w-7 text-center\">\n                        {index + 1}/{siblings.length}\n                      </span>\n                      <PagerButton\n                        disabled={index === siblings.length - 1}\n                        label=\"Next branch\"\n                        onClick={() => hop(siblings[index + 1].id)}\n                      >\n                        <ChevronRightIcon className=\"size-3.5\" />\n                      </PagerButton>\n                    </div>\n                  )}\n                </motion.li>\n              );\n            })}\n            {pending && (\n              <motion.li\n                animate={{ opacity: 1 }}\n                className=\"flex items-center gap-3\"\n                exit={{ opacity: 0, transition: { duration: 0.08 } }}\n                initial={{ opacity: 0 }}\n                key=\"pending\"\n              >\n                <AssistantMark />\n                <span className=\"flex gap-1\" role=\"status\">\n                  <span className=\"sr-only\">{assistantName} is replying</span>\n                  {[0, 1, 2].map((i) => (\n                    <motion.span\n                      animate={{ opacity: [0.3, 1, 0.3] }}\n                      className=\"size-1.5 rounded-full bg-muted-foreground\"\n                      key={i}\n                      transition={{ duration: 1.1, repeat: Number.POSITIVE_INFINITY, delay: i * 0.15 }}\n                    />\n                  ))}\n                </span>\n              </motion.li>\n            )}\n          </AnimatePresence>\n        </ol>\n      </div>\n\n      <form className=\"border-t p-3\" onSubmit={send}>\n        <AnimatePresence initial={false}>\n          {forkSource && (\n            <motion.div\n              animate={{ height: \"auto\", opacity: 1 }}\n              className=\"overflow-hidden\"\n              exit={{ height: 0, opacity: 0 }}\n              initial={{ height: 0, opacity: 0 }}\n              transition={reduce ? { duration: 0 } : { ...spring, opacity: { duration: 0.15 } }}\n            >\n              <div className=\"mb-2 flex items-center gap-2 rounded-lg bg-info/8 py-1 ps-2.5 pe-1 text-info-foreground text-xs\">\n                <GitBranchPlusIcon className=\"size-3.5 shrink-0\" />\n                <span className=\"min-w-0 flex-1 truncate\">\n                  {forkSource.role === \"user\" ? \"New branch instead of \" : \"New branch after \"}\n                  <span className=\"font-medium\">“{forkSource.text}”</span>\n                </span>\n                <button\n                  aria-label=\"Cancel branch\"\n                  className=\"flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-md outline-none transition-colors hover:bg-info/12 focus-visible:ring-2 focus-visible:ring-ring\"\n                  onClick={cancelFork}\n                  type=\"button\"\n                >\n                  <XIcon className=\"size-3.5\" />\n                </button>\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n        <label className=\"sr-only\" htmlFor={inputId}>\n          Message\n        </label>\n        <div className=\"flex items-center gap-2 rounded-xl border bg-background ps-3 pe-1 transition-shadow focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/24 dark:bg-input/32\">\n          <input\n            autoComplete=\"off\"\n            className=\"h-9 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground/72\"\n            id={inputId}\n            onChange={(e) => setDraft(e.target.value)}\n            onKeyDown={(e) => e.key === \"Escape\" && forkFrom && cancelFork()}\n            placeholder={forkFrom ? \"Take it somewhere else…\" : \"Reply, or branch from any message\"}\n            ref={inputRef}\n            value={draft}\n          />\n          <button\n            aria-label=\"Send\"\n            className=\"flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-lg bg-primary text-primary-foreground outline-none transition-[opacity,scale] duration-150 focus-visible:ring-2 focus-visible:ring-ring active:scale-[0.97] disabled:cursor-default disabled:opacity-40\"\n            disabled={!draft.trim() || pending}\n            type=\"submit\"\n          >\n            <ArrowUpIcon className=\"size-4\" />\n          </button>\n        </div>\n      </form>\n    </section>\n  );\n}\n\nfunction PagerButton({\n  label,\n  disabled,\n  onClick,\n  children,\n}: {\n  label: string;\n  disabled?: boolean;\n  onClick: () => void;\n  children: React.ReactNode;\n}) {\n  return (\n    <button\n      aria-label={label}\n      className=\"relative flex size-6 cursor-pointer items-center justify-center rounded-md outline-none transition-colors before:absolute before:-inset-1 hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-40\"\n      disabled={disabled}\n      onClick={onClick}\n      type=\"button\"\n    >\n      {children}\n    </button>\n  );\n}\n\nfunction AssistantMark() {\n  return (\n    <span aria-hidden className=\"flex size-5 shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground\">\n      <SparklesIcon className=\"size-3\" />\n    </span>\n  );\n}\n\nfunction Message({\n  message,\n  assistantName,\n  forking,\n  onFork,\n}: {\n  message: BranchMessage;\n  assistantName: string;\n  forking: boolean;\n  onFork: () => void;\n}) {\n  const user = message.role === \"user\";\n  const forkButton = message.parentId === null ? null : (\n    <button\n      aria-label={user ? \"Rewrite this prompt as a new branch\" : \"Branch from this reply\"}\n      className={cn(\n        \"flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-lg text-muted-foreground outline-none transition-[opacity,background-color,color] duration-150 hover:bg-accent hover:text-foreground focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring [@media(hover:hover)]:opacity-0 [@media(hover:hover)]:group-hover/msg:opacity-100\",\n        forking && \"bg-info/10 text-info-foreground opacity-100 [@media(hover:hover)]:opacity-100\",\n      )}\n      onClick={onFork}\n      title={user ? \"Rewrite as a new branch\" : \"Branch from here\"}\n      type=\"button\"\n    >\n      <GitBranchPlusIcon className=\"size-3.5\" />\n    </button>\n  );\n\n  if (user) {\n    return (\n      <div className=\"group/msg flex items-center justify-end gap-1.5\">\n        {forkButton}\n        <p\n          className={cn(\n            \"max-w-[80%] whitespace-pre-line text-pretty rounded-2xl rounded-br-md bg-muted px-3 py-2 text-sm transition-shadow\",\n            forking && \"ring-2 ring-info/40\",\n          )}\n        >\n          {message.text}\n        </p>\n      </div>\n    );\n  }\n  return (\n    <div className=\"group/msg flex items-start gap-3\">\n      <span className=\"mt-0.5\">\n        <AssistantMark />\n      </span>\n      <div className=\"min-w-0 flex-1\">\n        <span className=\"sr-only\">{assistantName}: </span>\n        <p\n          className={cn(\n            \"-mx-2 -my-1 whitespace-pre-line text-pretty rounded-lg px-2 py-1 text-sm leading-relaxed transition-colors duration-150\",\n            forking && \"bg-info/8\",\n          )}\n        >\n          {message.text}\n        </p>\n      </div>\n      {forkButton}\n    </div>\n  );\n}\n\nfunction Minimap({\n  tree,\n  activeIds,\n  activeLane,\n  onHop,\n  reduce,\n}: {\n  tree: ReturnType<typeof useTree>;\n  activeIds: Set<string>;\n  activeLane: number;\n  onHop: (id: string) => void;\n  reduce: boolean | null;\n}) {\n  const rowId = useId();\n  const nodes = [...tree.byId.values()];\n  const maxDepth = Math.max(0, ...nodes.map((n) => tree.depth.get(n.id) ?? 0));\n  const width = (maxDepth + 1) * STEP;\n  const pos = (id: string) => ({\n    x: STEP / 2 + (tree.depth.get(id) ?? 0) * STEP,\n    y: ROW / 2 + (tree.lane.get(id) ?? 0) * ROW,\n  });\n\n  const branchName = (leaf: BranchMessage, lane: number) => {\n    if (lane === 0) return \"Main\";\n    const first = tree.pathTo(leaf.id).find((m) => tree.lane.get(m.id) === lane && m.role === \"user\");\n    return first?.text ?? \"Branch\";\n  };\n\n  return (\n    <nav aria-label=\"Branches\" className=\"mx-3 rounded-xl bg-muted/50 p-1\">\n      <div className=\"relative\">\n        <ul>\n          <AnimatePresence initial={false}>\n          {tree.leaves.map((leaf, lane) => {\n            const active = lane === activeLane;\n            return (\n              <motion.li\n                animate={{ height: ROW, opacity: 1 }}\n                initial={reduce ? false : { height: 0, opacity: 0 }}\n                key={leaf.id}\n                transition={{ ...spring, opacity: { duration: 0.15 } }}\n              >\n                <button\n                  aria-current={active ? \"true\" : undefined}\n                  className={cn(\n                    \"relative flex w-full cursor-pointer items-center rounded-lg text-left text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring\",\n                    active ? \"text-foreground\" : \"text-muted-foreground hover:text-foreground\",\n                  )}\n                  onClick={() => onHop(leaf.id)}\n                  style={{ height: ROW }}\n                  type=\"button\"\n                >\n                  {active && (\n                    <motion.span\n                      className=\"absolute inset-0 rounded-lg bg-card shadow-xs/5 ring-1 ring-border\"\n                      layoutId={`${rowId}-active`}\n                      transition={reduce ? { duration: 0 } : spring}\n                    />\n                  )}\n                  <span className=\"relative min-w-0 flex-1 truncate pe-2\" style={{ paddingInlineStart: width + 10 }}>\n                    <span className={cn(active && \"font-medium\")}>{branchName(leaf, lane)}</span>\n                  </span>\n                  <span className=\"relative pe-2 text-muted-foreground tabular-nums\">\n                    {tree.pathTo(leaf.id).length}\n                    <span className=\"sr-only\"> messages</span>\n                  </span>\n                </button>\n              </motion.li>\n            );\n          })}\n          </AnimatePresence>\n        </ul>\n        <svg\n          aria-hidden\n          className=\"pointer-events-none absolute top-0 left-0 overflow-visible\"\n          height={tree.leaves.length * ROW}\n          width={width}\n        >\n          {nodes.map((n) => {\n            if (!n.parentId) return null;\n            const a = pos(n.parentId);\n            const b = pos(n.id);\n            const d =\n              a.y === b.y\n                ? `M ${a.x} ${a.y} L ${b.x} ${b.y}`\n                : `M ${a.x} ${a.y} C ${a.x + STEP * 0.7} ${a.y}, ${b.x - STEP * 0.7} ${b.y}, ${b.x} ${b.y}`;\n            const on = activeIds.has(n.id);\n            return (\n              <motion.path\n                animate={{ pathLength: 1 }}\n                className={cn(\"transition-[stroke] duration-200\", on ? \"stroke-foreground\" : \"stroke-muted-foreground/40\")}\n                d={d}\n                fill=\"none\"\n                initial={reduce ? false : { pathLength: 0 }}\n                key={`e-${n.id}`}\n                strokeLinecap=\"round\"\n                strokeWidth={1.5}\n                transition={{ duration: 0.25, ease: [0.23, 1, 0.32, 1] }}\n              />\n            );\n          })}\n          {nodes.map((n) => {\n            const p = pos(n.id);\n            const on = activeIds.has(n.id);\n            const user = n.role === \"user\";\n            return (\n              <motion.circle\n                animate={{ scale: 1, opacity: 1 }}\n                className={cn(\n                  \"transition-[fill,stroke] duration-200\",\n                  on ? \"stroke-foreground\" : \"stroke-muted-foreground/60\",\n                  user ? (on ? \"fill-foreground\" : \"fill-muted-foreground/60\") : \"fill-card\",\n                )}\n                cx={p.x}\n                cy={p.y}\n                initial={reduce ? false : { scale: 0.6, opacity: 0 }}\n                key={`n-${n.id}`}\n                r={user ? 3 : 2.75}\n                strokeWidth={1.5}\n                style={{ transformOrigin: `${p.x}px ${p.y}px` }}\n                transition={{ duration: 0.2, delay: 0.12 }}\n              />\n            );\n          })}\n        </svg>\n      </div>\n    </nav>\n  );\n}\n"
    }
  ]
}