{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message-branches",
  "title": "Message Branches",
  "description": "The 2 / 3 switcher on a message that was edited or regenerated, with an overview of every place the conversation forked and which path is on screen.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/message-branches/message-branches.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronLeft, ChevronRight, GitFork } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\nexport type BranchNode = {\n  id: string\n  parentId: string | null\n  /** A short line of the message, shown in the branch overview. */\n  label?: string\n}\n\nexport type MessageBranchesProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\" | \"onSelect\"\n> & {\n  nodes: readonly BranchNode[]\n  /** Message ids from the first message to the one at the bottom of the view. */\n  activePath: readonly string[]\n  /** The message this control sits on. */\n  messageId: string\n  onSelect: (branchId: string) => void\n  overview?: boolean\n  label?: string\n}\n\ntype Segment = {\n  start: BranchNode\n  ids: string[]\n  children: Segment[]\n}\n\n/**\n * A run of messages with no fork in it is one row. Only the places where the\n * conversation actually split get their own level, so a long thread that was\n * edited once stays two rows deep.\n */\nfunction segmentsFrom(\n  starts: readonly BranchNode[],\n  childrenOf: Map<string | null, BranchNode[]>\n): Segment[] {\n  return starts.map((start) => {\n    const ids = [start.id]\n    let kids = childrenOf.get(start.id) ?? []\n\n    while (kids.length === 1) {\n      ids.push(kids[0]!.id)\n      kids = childrenOf.get(kids[0]!.id) ?? []\n    }\n\n    return { start, ids, children: segmentsFrom(kids, childrenOf) }\n  })\n}\n\nconst iconButton =\n  \"text-muted-foreground hover:text-foreground hover:bg-muted focus-visible:ring-ring relative inline-flex size-7 items-center justify-center rounded-md transition-colors duration-150 after:absolute after:-inset-1.5 after:content-[''] focus-visible:ring-2 focus-visible:outline-none aria-disabled:pointer-events-none aria-disabled:opacity-40 motion-reduce:transition-none\"\n\nexport function MessageBranches({\n  nodes,\n  activePath,\n  messageId,\n  onSelect,\n  overview = true,\n  label = \"Versions of this message\",\n  className,\n  ...rootProps\n}: MessageBranchesProps) {\n  const rootRef = React.useRef<HTMLDivElement>(null)\n  const panelRef = React.useRef<HTMLDivElement>(null)\n  const triggerRef = React.useRef<HTMLButtonElement>(null)\n  const panelId = React.useId()\n\n  const [open, setOpen] = React.useState(false)\n  const [direction, setDirection] = React.useState<\"back\" | \"forward\" | null>(\n    null\n  )\n\n  const { childrenOf, roots, siblings } = React.useMemo(() => {\n    const known = new Set(nodes.map((node) => node.id))\n    const childrenOf = new Map<string | null, BranchNode[]>()\n\n    for (const node of nodes) {\n      const parent =\n        node.parentId != null && known.has(node.parentId) ? node.parentId : null\n      childrenOf.set(parent, [...(childrenOf.get(parent) ?? []), node])\n    }\n\n    const self = nodes.find((node) => node.id === messageId)\n    const parent =\n      self?.parentId != null && known.has(self.parentId) ? self.parentId : null\n\n    return {\n      childrenOf,\n      roots: childrenOf.get(null) ?? [],\n      siblings: self ? (childrenOf.get(parent) ?? []) : [],\n    }\n  }, [nodes, messageId])\n\n  const segments = React.useMemo(\n    () => (open ? segmentsFrom(roots, childrenOf) : []),\n    [open, roots, childrenOf]\n  )\n\n  const onPath = new Set(activePath)\n  const deepest = activePath[activePath.length - 1]\n  const index = siblings.findIndex((node) => node.id === messageId)\n  const count = siblings.length\n\n  React.useEffect(() => {\n    if (!open) return\n\n    const close = (event: PointerEvent) => {\n      if (!rootRef.current?.contains(event.target as Node)) setOpen(false)\n    }\n\n    document.addEventListener(\"pointerdown\", close)\n    return () => document.removeEventListener(\"pointerdown\", close)\n  }, [open])\n\n  React.useLayoutEffect(() => {\n    const panel = panelRef.current\n    if (!open || !panel) return\n\n    // Open toward whichever side has room, so a control under a message on\n    // the right edge of a phone does not push its panel off screen.\n    panel.style.left = \"0px\"\n    panel.style.right = \"auto\"\n    if (panel.getBoundingClientRect().right > window.innerWidth - 8) {\n      panel.style.left = \"auto\"\n      panel.style.right = \"0px\"\n    }\n\n    const current =\n      panel.querySelector<HTMLElement>('[aria-current=\"true\"]') ??\n      panel.querySelector<HTMLElement>(\"button\")\n    current?.focus()\n  }, [open])\n\n  if (count < 2) return null\n\n  const step = (by: -1 | 1) => {\n    const next = siblings[index + by]\n    if (!next) return\n    setDirection(by < 0 ? \"back\" : \"forward\")\n    onSelect(next.id)\n  }\n\n  const close = () => {\n    setOpen(false)\n    triggerRef.current?.focus()\n  }\n\n  const choose = (id: string) => {\n    setDirection(null)\n    close()\n    onSelect(id)\n  }\n\n  const moveFocus = (event: React.KeyboardEvent<HTMLDivElement>) => {\n    const rows = Array.from(\n      panelRef.current?.querySelectorAll<HTMLButtonElement>(\n        '[data-slot=\"message-branches-row\"]'\n      ) ?? []\n    )\n    const at = rows.indexOf(document.activeElement as HTMLButtonElement)\n\n    const target =\n      event.key === \"ArrowDown\"\n        ? rows[Math.min(at + 1, rows.length - 1)]\n        : event.key === \"ArrowUp\"\n          ? rows[Math.max(at - 1, 0)]\n          : event.key === \"Home\"\n            ? rows[0]\n            : event.key === \"End\"\n              ? rows[rows.length - 1]\n              : null\n\n    if (event.key === \"Escape\") {\n      event.preventDefault()\n      event.stopPropagation()\n      close()\n    } else if (target) {\n      event.preventDefault()\n      target.focus()\n    }\n  }\n\n  const renderSegments = (list: Segment[], depth: number): React.ReactNode => (\n    <ul className={cn(depth > 0 && \"border-border ms-3 border-s ps-1.5\")}>\n      {list.map((segment) => {\n        const active = segment.ids.some((id) => onPath.has(id))\n        const here = deepest != null && segment.ids.includes(deepest)\n        const holds = segment.ids.includes(messageId)\n        const messages = segment.ids.length\n        const name = [\n          segment.start.label ?? \"Message\",\n          messages === 1 ? \"1 message\" : `${messages} messages`,\n          holds ? \"contains this message\" : \"\",\n          active ? \"shown\" : \"\",\n        ]\n          .filter(Boolean)\n          .join(\", \")\n\n        return (\n          <li key={segment.start.id}>\n            <button\n              type=\"button\"\n              data-slot=\"message-branches-row\"\n              data-active={active ? \"\" : undefined}\n              aria-label={name}\n              aria-current={here ? \"true\" : undefined}\n              onClick={() => choose(segment.start.id)}\n              className={cn(\n                \"hover:bg-muted focus-visible:ring-ring flex min-h-9 w-full items-center gap-2 rounded-md px-2 py-1.5 text-start text-sm transition-colors duration-150 focus-visible:ring-2 focus-visible:outline-none motion-reduce:transition-none\",\n                active ? \"text-foreground\" : \"text-muted-foreground\"\n              )}\n            >\n              <span\n                aria-hidden=\"true\"\n                className={cn(\n                  \"size-1.5 shrink-0 rounded-full\",\n                  active ? \"bg-primary\" : \"bg-border\"\n                )}\n              />\n              <span className=\"min-w-0 flex-1 truncate\">\n                {segment.start.label ?? \"Message\"}\n              </span>\n              <span className=\"text-muted-foreground shrink-0 text-xs tabular-nums\">\n                {messages}\n              </span>\n            </button>\n            {segment.children.length > 0\n              ? renderSegments(segment.children, depth + 1)\n              : null}\n          </li>\n        )\n      })}\n    </ul>\n  )\n\n  return (\n    <div\n      ref={rootRef}\n      role=\"group\"\n      aria-label={label}\n      data-slot=\"message-branches\"\n      data-state={open ? \"open\" : \"closed\"}\n      className={cn(\n        \"text-muted-foreground relative inline-flex items-center gap-0.5 text-xs\",\n        className\n      )}\n      {...rootProps}\n    >\n      <button\n        type=\"button\"\n        aria-label=\"Previous version\"\n        aria-disabled={index <= 0 || undefined}\n        onClick={() => step(-1)}\n        className={iconButton}\n      >\n        <ChevronLeft aria-hidden=\"true\" className=\"size-4\" />\n      </button>\n\n      <span className=\"min-w-[4ch] overflow-hidden text-center tabular-nums\">\n        <span\n          key={index}\n          aria-hidden=\"true\"\n          className={cn(\n            \"inline-block transition-[translate,opacity] duration-200 ease-out motion-reduce:transition-none\",\n            direction === \"forward\" && \"starting:translate-y-1.5\",\n            direction === \"back\" && \"starting:-translate-y-1.5\",\n            direction && \"starting:opacity-0\"\n          )}\n        >\n          {index + 1} / {count}\n        </span>\n        <span className=\"sr-only\">\n          Version {index + 1} of {count}\n        </span>\n      </span>\n\n      <button\n        type=\"button\"\n        aria-label=\"Next version\"\n        aria-disabled={index >= count - 1 || undefined}\n        onClick={() => step(1)}\n        className={iconButton}\n      >\n        <ChevronRight aria-hidden=\"true\" className=\"size-4\" />\n      </button>\n\n      {overview ? (\n        <>\n          <button\n            ref={triggerRef}\n            type=\"button\"\n            aria-label=\"Show conversation branches\"\n            aria-expanded={open}\n            aria-controls={open ? panelId : undefined}\n            onClick={() => setOpen((value) => !value)}\n            className={cn(iconButton, \"ms-0.5\", open && \"bg-muted\")}\n          >\n            <GitFork aria-hidden=\"true\" className=\"size-3.5\" />\n          </button>\n\n          {open ? (\n            <div\n              ref={panelRef}\n              id={panelId}\n              role=\"dialog\"\n              aria-label=\"Conversation branches\"\n              onKeyDown={moveFocus}\n              className=\"border-border bg-popover text-popover-foreground absolute top-full z-50 mt-1.5 w-72 max-w-[calc(100vw-2rem)] rounded-[calc(var(--radius)+0.25rem)] border p-1.5 shadow-lg transition-[opacity,translate] duration-150 ease-out motion-reduce:transition-none starting:-translate-y-1 starting:opacity-0\"\n            >\n              <p className=\"text-muted-foreground px-2 pt-1 pb-1.5 text-xs\">\n                Each row is a run of messages. Indented rows began as an edit or\n                a regenerated reply.\n              </p>\n              <nav aria-label=\"Branches\">{renderSegments(segments, 0)}</nav>\n            </div>\n          ) : null}\n        </>\n      ) : null}\n\n      <span aria-live=\"polite\" className=\"sr-only\">\n        {direction ? `Version ${index + 1} of ${count}` : \"\"}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "ai",
    "chat",
    "navigation",
    "tree",
    "agent"
  ],
  "type": "registry:ui"
}