{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "response",
  "title": "Message Response",
  "author": "Jamie Thompson <jamie@jamiethompson.design> (https://jamiethompson.design)",
  "description": "Markdown renderer for streamed AI assistant output. MessageResponse wraps Streamdown with the cjk, code (Shiki), math (KaTeX) and mermaid plugins, tolerates incomplete markdown while tokens arrive, and is memoized on children/isAnimating so a chat list does not re-render every block on each token. Use it as the body of an assistant message; accepts every Streamdown prop.",
  "dependencies": [
    "cn",
    "katex@^0.16",
    "streamdown@^2.6",
    "@streamdown/cjk@^1",
    "@streamdown/code@^1",
    "@streamdown/math@^1",
    "@streamdown/mermaid@^1"
  ],
  "registryDependencies": [],
  "files": [
    {
      "path": "registry/ai/response.tsx",
      "content": "// Derived from Vercel AI Elements message.tsx (Apache-2.0, Copyright 2023 Vercel, Inc.).\n// Modified for uifiles: ported from Radix UI to Base UI; dependencies point at @uifiles.\n\"use client\"\n\nimport { cjk } from \"@streamdown/cjk\"\nimport { code } from \"@streamdown/code\"\nimport { math } from \"@streamdown/math\"\nimport { mermaid } from \"@streamdown/mermaid\"\nimport { cn } from \"cn\"\nimport type { ComponentProps } from \"react\"\nimport { memo, useEffect, useId, useMemo, useRef, useState } from \"react\"\nimport { createPortal } from \"react-dom\"\nimport type {\n  LinkSafetyConfig,\n  LinkSafetyModalProps,\n  StreamdownTranslations,\n} from \"streamdown\"\nimport { defaultTranslations, Streamdown } from \"streamdown\"\n\nexport type MessageResponseProps = ComponentProps<typeof Streamdown>\n\nconst streamdownPlugins = { cjk, code, math, mermaid }\n// uifiles: GitHub's default light theme fails AA (orange tokens at 3.48:1); the\n// high-contrast pair keeps every token readable in both schemes.\nconst shikiThemes: NonNullable<\n  ComponentProps<typeof Streamdown>[\"shikiTheme\"]\n> = [\"github-light-high-contrast\", \"github-dark-high-contrast\"]\n\n// uifiles: Streamdown's code-block bodies and table wrappers scroll\n// (horizontally, and vertically past codeBlockMaxHeight / tableMaxHeight)\n// without being focusable, so keyboard users cannot reach the overflow (axe\n// scrollable-region-focusable). Mark the ones that actually overflow as named\n// tab stops; re-check as streamed content arrives and on resize.\nconst CODE_BLOCK_BODY = '[data-streamdown=\"code-block-body\"]'\n// The wrapper holds the table's controls; the div between it and the table\n// is the one that scrolls.\nconst TABLE = '[data-streamdown=\"table-wrapper\"] [data-streamdown=\"table\"]'\n// Streamdown portals the fullscreen table view to document.body.\nconst FULLSCREEN = '[data-streamdown=\"table-fullscreen\"]'\nconst FULLSCREEN_TABLE = `${FULLSCREEN} [data-streamdown=\"table\"]`\n// KaTeX's display formula; it scrolls once the stylesheet gives it\n// `overflow: auto hidden` (see the item's docs).\nconst MATH_DISPLAY = \".katex-display\"\n\ninterface Scroller {\n  /** The accessible name it carries while it overflows. */\n  label: string\n  overflows: (element: HTMLElement) => boolean\n}\n\nconst overflowsEitherAxis = (element: HTMLElement) =>\n  element.scrollWidth > element.clientWidth ||\n  element.scrollHeight > element.clientHeight\n\nconst CODE: Scroller = { label: \"Code\", overflows: overflowsEitherAxis }\nconst TABLE_SCROLLER: Scroller = {\n  label: \"Table\",\n  overflows: overflowsEitherAxis,\n}\n// A formula is one line clipped vertically, so only a sideways overflow can\n// be scrolled; without the stylesheet rule the page scrolls instead and\n// there is nothing to focus.\nconst MATH: Scroller = {\n  label: \"Math\",\n  overflows: (element) => {\n    const { overflowX } = getComputedStyle(element)\n    return (\n      (overflowX === \"auto\" || overflowX === \"scroll\") &&\n      element.scrollWidth > element.clientWidth\n    )\n  },\n}\n\n/** Every element Streamdown lets scroll, with what it is. */\nfunction findScrollers(root: HTMLElement): Map<HTMLElement, Scroller> {\n  const scrollers = new Map<HTMLElement, Scroller>()\n  for (const body of root.querySelectorAll<HTMLElement>(CODE_BLOCK_BODY)) {\n    scrollers.set(body, CODE)\n  }\n  const tables = [\n    ...root.querySelectorAll(TABLE),\n    ...document.querySelectorAll(FULLSCREEN_TABLE),\n  ]\n  for (const table of tables) {\n    const scroller = table.parentElement\n    if (scroller instanceof HTMLElement) scrollers.set(scroller, TABLE_SCROLLER)\n  }\n  for (const display of root.querySelectorAll<HTMLElement>(MATH_DISPLAY)) {\n    scrollers.set(display, MATH)\n  }\n  return scrollers\n}\n\n// React tags every host node it has hydrated or created with a\n// `__reactFiber$…` property. Streamdown lazy-loads its highlighted code body\n// inside a Suspense boundary, and until that boundary hydrates its server\n// DOM carries no tag; an attribute added there is reported as a hydration\n// mismatch once React reaches it.\nconst isClaimedByReact = (element: Element) =>\n  Object.keys(element).some((key) => key.startsWith(\"__reactFiber$\"))\n\nfunction markScrollable(element: HTMLElement, scroller: Scroller) {\n  if (scroller.overflows(element)) {\n    element.tabIndex = 0\n    // A named group rather than a region: a transcript with several\n    // overflowing blocks would otherwise list identical landmarks.\n    element.setAttribute(\"role\", \"group\")\n    element.setAttribute(\"aria-label\", scroller.label)\n  } else {\n    element.removeAttribute(\"tabindex\")\n    element.removeAttribute(\"role\")\n    element.removeAttribute(\"aria-label\")\n  }\n}\n\nconst isFullscreenView = (node: Node) =>\n  node instanceof Element && node.matches(FULLSCREEN)\n\ntype LinkSafetyDialogProps = LinkSafetyModalProps & {\n  translations: StreamdownTranslations\n}\n\n// uifiles: Streamdown's own link-safety modal is a role=\"button\" backdrop\n// wrapped around the dialog's buttons (axe nested-interactive) that never\n// moves focus into itself, and a long URL scrolls in a box the keyboard\n// cannot reach. A native modal <dialog> makes the page inert, takes focus,\n// closes on Escape and gives focus back to the link.\nfunction LinkSafetyDialog({ isOpen, ...props }: LinkSafetyDialogProps) {\n  if (!isOpen || typeof document === \"undefined\") return null\n  return createPortal(<OpenLinkDialog {...props} />, document.body)\n}\n\nconst dialogButton =\n  \"inline-flex flex-1 items-center justify-center rounded-md px-4 py-2 font-medium text-sm transition-colors outline-none focus-visible:ring-3 focus-visible:ring-ring/50\"\n\nfunction OpenLinkDialog({\n  onClose,\n  onConfirm,\n  translations,\n  url,\n}: Omit<LinkSafetyDialogProps, \"isOpen\">) {\n  const ref = useRef<HTMLDialogElement>(null)\n  const titleId = useId()\n  const descriptionId = useId()\n  const [copied, setCopied] = useState(false)\n\n  useEffect(() => {\n    const dialog = ref.current\n    // Closing hands focus back to what had it before (the link). StrictMode\n    // replays this effect on the dialog it already opened, which older\n    // engines refuse with an InvalidStateError.\n    if (dialog && !dialog.open) dialog.showModal()\n  }, [])\n\n  useEffect(() => {\n    if (!copied) return\n    const timer = setTimeout(() => setCopied(false), 2000)\n    return () => clearTimeout(timer)\n  }, [copied])\n\n  const close = () => ref.current?.close()\n\n  const copy = async () => {\n    try {\n      await navigator.clipboard.writeText(url)\n      setCopied(true)\n    } catch {\n      // Clipboard access denied: the URL stays visible to copy by hand.\n    }\n  }\n\n  return (\n    // biome-ignore lint/a11y/useKeyWithClickEvents: the backdrop click is the pointer's way out; Escape (the dialog's native cancel) is the keyboard's\n    <dialog\n      aria-describedby={descriptionId}\n      aria-labelledby={titleId}\n      className=\"m-auto max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-md overflow-y-auto rounded-xl border bg-background p-0 text-foreground shadow-lg backdrop:bg-background/50 backdrop:backdrop-blur-sm\"\n      data-slot=\"link-safety-dialog\"\n      // A click on the backdrop lands on the dialog itself; the panel\n      // inside catches every click on the content.\n      onClick={(event) => {\n        if (event.target === event.currentTarget) close()\n      }}\n      // Escape, a button or the backdrop closed it.\n      onClose={onClose}\n      ref={ref}\n    >\n      <div className=\"relative flex flex-col gap-4 p-6\">\n        <button\n          aria-label={translations.close}\n          className=\"absolute top-4 right-4 inline-flex size-7 items-center justify-center rounded-md text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50\"\n          onClick={close}\n          title={translations.close}\n          type=\"button\"\n        >\n          <svg\n            aria-hidden=\"true\"\n            className=\"size-4\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            strokeLinecap=\"round\"\n            strokeLinejoin=\"round\"\n            strokeWidth=\"2\"\n            viewBox=\"0 0 24 24\"\n          >\n            <path d=\"M18 6 6 18M6 6l12 12\" />\n          </svg>\n        </button>\n        <div className=\"flex flex-col gap-2 pr-8\">\n          <h2 className=\"text-lg font-semibold\" id={titleId}>\n            {translations.openExternalLink}\n          </h2>\n          <p className=\"text-sm text-muted-foreground\" id={descriptionId}>\n            {translations.externalLinkWarning}\n          </p>\n        </div>\n        <p className=\"rounded-md bg-muted p-3 font-mono text-sm break-all\">\n          {url}\n        </p>\n        <div className=\"flex gap-2\">\n          <button\n            className={cn(dialogButton, \"border bg-background hover:bg-muted\")}\n            onClick={copy}\n            type=\"button\"\n          >\n            {copied ? translations.copied : translations.copyLink}\n          </button>\n          <button\n            className={cn(\n              dialogButton,\n              \"bg-primary text-primary-foreground hover:bg-primary/90\"\n            )}\n            onClick={() => {\n              onConfirm()\n              close()\n            }}\n            type=\"button\"\n          >\n            {translations.openLink}\n          </button>\n        </div>\n      </div>\n    </dialog>\n  )\n}\n\nexport const MessageResponse = memo(\n  ({ className, ...props }: MessageResponseProps) => {\n    const ref = useRef<HTMLDivElement>(null)\n    const { linkSafety, translations } = props\n\n    // Streamdown's default (link safety on) with the accessible dialog; a\n    // consumer's renderModal or enabled: false still wins.\n    const safety = useMemo<LinkSafetyConfig>(() => {\n      const strings = { ...defaultTranslations, ...translations }\n      return {\n        enabled: true,\n        renderModal: (modal) => (\n          <LinkSafetyDialog {...modal} translations={strings} />\n        ),\n        ...linkSafety,\n      }\n    }, [linkSafety, translations])\n\n    useEffect(() => {\n      const root = ref.current\n      if (!root) return\n      const observed = new Map<HTMLElement, Scroller>()\n      const resizeObserver = new ResizeObserver((entries) => {\n        for (const entry of entries) {\n          if (!(entry.target instanceof HTMLElement)) continue\n          const scroller = observed.get(entry.target)\n          if (scroller) markScrollable(entry.target, scroller)\n        }\n      })\n      let frame = 0\n      const sync = () => {\n        cancelAnimationFrame(frame)\n        const scrollers = findScrollers(root)\n        for (const element of observed.keys()) {\n          if (!scrollers.has(element)) {\n            resizeObserver.unobserve(element)\n            observed.delete(element)\n          }\n        }\n        let dehydrated = false\n        for (const [element, scroller] of scrollers) {\n          if (!isClaimedByReact(element)) {\n            dehydrated = true\n            continue\n          }\n          markScrollable(element, scroller)\n          if (!observed.has(element)) {\n            resizeObserver.observe(element)\n            observed.set(element, scroller)\n          }\n        }\n        // Hydration does not mutate the DOM, so poll until React owns every scroller.\n        if (dehydrated) frame = requestAnimationFrame(sync)\n      }\n      sync()\n      const mutationObserver = new MutationObserver(sync)\n      mutationObserver.observe(root, {\n        characterData: true,\n        childList: true,\n        subtree: true,\n      })\n      // Only the fullscreen view's arrival and departure matter out there.\n      const portalObserver = new MutationObserver((records) => {\n        if (\n          records.some(\n            (record) =>\n              [...record.addedNodes].some(isFullscreenView) ||\n              [...record.removedNodes].some(isFullscreenView)\n          )\n        ) {\n          sync()\n        }\n      })\n      portalObserver.observe(document.body, { childList: true })\n      return () => {\n        cancelAnimationFrame(frame)\n        mutationObserver.disconnect()\n        portalObserver.disconnect()\n        resizeObserver.disconnect()\n      }\n    }, [])\n\n    return (\n      <div className=\"contents\" data-slot=\"message-response\" ref={ref}>\n        <Streamdown\n          className={cn(\n            \"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0\",\n            className\n          )}\n          plugins={streamdownPlugins}\n          shikiTheme={shikiThemes}\n          {...props}\n          linkSafety={safety}\n        />\n      </div>\n    )\n  },\n  (prevProps, nextProps) =>\n    prevProps.children === nextProps.children &&\n    nextProps.isAnimating === prevProps.isAnimating\n)\n\nMessageResponse.displayName = \"MessageResponse\"\n",
      "type": "registry:component",
      "target": "components/ai/response.tsx"
    }
  ],
  "css": {
    "@import \"streamdown/styles.css\"": {},
    "@import \"katex/dist/katex.min.css\"": {},
    "@layer base": {
      ".katex-display": {
        "overflow": "auto hidden",
        "padding-block": "0.25em"
      }
    }
  },
  "docs": "Derived from Vercel AI Elements (Apache-2.0, Copyright 2023 Vercel, Inc.); ported to Base UI and maintained by uifiles. Built for the Base UI styles (base-nova); the file imports no style wrappers, so nothing in it is style-specific. Extracted from AI Elements message.tsx (MessageResponse only; shadcn's own message replaces the rest). Export name and props identical (ComponentProps<typeof Streamdown>). No Radix code in this component. MessageResponse passes shikiTheme={[\"github-light-high-contrast\", \"github-dark-high-contrast\"]} to Streamdown because Streamdown's default GitHub light theme fails AA on orange tokens; your own shikiTheme prop overrides it. Code-block bodies, tables (inline, and in the fullscreen table view Streamdown portals to document.body) and display formulas that overflow (horizontally, or vertically past codeBlockMaxHeight / tableMaxHeight; formulas sideways only, and only once the `.katex-display` rule below lets them scroll) become keyboard-focusable scroll regions on the client: tabindex=0 plus role=\"group\" and an aria-label (\"Code\", \"Table\" or \"Math\"; a group, not a region landmark, so several blocks on one page do not collide), re-checked as tokens stream in and on resize, removed again when the content fits, and applied only after React has hydrated them, so server-rendered blocks never cause a hydration mismatch. Links: Streamdown's link safety stays on by default, but its confirmation modal (a role=\"button\" backdrop around the dialog's buttons, which fails axe nested-interactive and never takes focus) is replaced through linkSafety.renderModal by a native modal <dialog>: named by its \"Open external link?\" heading, it makes the page inert, takes focus, shows the whole URL, copies it, closes on Escape, its close button or a backdrop click, returns focus to the link, and uses Streamdown's `translations`. `linkSafety={{ enabled: false }}` renders plain anchors, and your own linkSafety.renderModal or onLinkCheck wins (Streamdown renders renderModal's output inside the link's paragraph, so return phrasing content or a portal). CSS: installing this item adds three things to your globals.css through its `css` field. `@import \"streamdown/styles.css\"` provides the `[data-sd-animate]` fade/blur/slide keyframes and the list-marker fade; without it streamed blocks appear instantly instead of animating in. `@import \"katex/dist/katex.min.css\"` is KaTeX's stylesheet (this item installs katex, which @streamdown/math uses); without it every formula renders twice, KaTeX's HTML plus the MathML fallback the stylesheet hides. The CLI places each import after your last `@import` (or at the top of the file) and skips it when an identical `@import` is already there. `@layer base { .katex-display { overflow: auto hidden; padding-block: 0.25em; } }`: KaTeX gives a display formula no overflow container, so one wider than the column would widen the whole page on a phone (WCAG 1.4.10); with the rule the formula scrolls sideways in its own box and the padding keeps its tall parts (limits, over- and underbraces) from being clipped. Add one line yourself: `@source \"../node_modules/streamdown/dist/*.js\"` next to your `@import \"tailwindcss\"`, with the path relative to your CSS file, because Streamdown's own Tailwind classes only compile if your stylesheet can see them (the `@streamdown/*` plugins ship no classes). If you copy the file by hand, add all four lines. Inline `$...$` math is off by default in @streamdown/math (singleDollarTextMath: false); `$$` blocks work.",
  "categories": [
    "ai",
    "chat"
  ],
  "type": "registry:component"
}