{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "message",
  "type": "registry:ui",
  "title": "Message",
  "description": "A chat between a person and an agent. The agent's words sit on the page with no bubble, and the person's turn is a raised bubble on the right. MessageText styles what a markdown renderer produces, with no library: paragraphs, lists, accent links, inline code, a quiet pre and quotes with no rail. MessageActions puts Copy and Retry under a finished answer, and a turn that stopped says so with a Retry button. The conversation scrolls on its own in a column up to 48rem, follows the newest line while an answer streams, and offers Jump to latest once you scroll up.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://littmd.vercel.app/r/litt.json",
    "https://littmd.vercel.app/r/button.json",
    "https://littmd.vercel.app/r/icons.json",
    "https://littmd.vercel.app/r/motion.json",
    "https://littmd.vercel.app/r/tooltip.json"
  ],
  "files": [
    {
      "path": "registry/litt/ui/message.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n// @litt/message 2026.11.06. What changed: https://littmd.vercel.app/changelog.md\n\n/**\n * Conversation, Message, MessageText and MessageActions: a chat between a person and an agent, read top to\n * bottom with the newest turn at the foot (components.md, \"Conversation and message\").\n *\n * - The agent's turn has no bubble. Its words sit on the page at the body size in --litt-fg-muted, in a measure of\n *   about 68ch, because the answer is the content and a box around long prose cramps it. The parts of a turn\n *   (thinking, a plan, tool calls, the answer, its actions) are children of one Message and sit --litt-space-3 apart.\n * - Flat: the agent's answer (MessageText) is a grey bubble at the surface radius and the person's turn an ink bubble\n *   with --litt-canvas text and no hairline (flat-pill.md, rules 3 and 4).\n * - The person's turn is a bubble: right-aligned, `.litt-raised` at the surface radius, padding --litt-space-3 and\n *   --litt-space-4, text in --litt-fg, at most 80% of the column (85% on compact screens) and 32rem. No tail and no accent\n *   fill: the accent stays on Send. Files the person sent sit above it.\n * - Who is speaking: the side says it. Each turn is an `article` named by a heading that is visually hidden (\"You\",\n *   or the agent's name). A `name` shows that heading instead, at the small size in --litt-fg (never colored), with an\n *   optional sm Avatar beside it, for a thread with more than one agent or person.\n * - MessageText styles what a markdown renderer puts inside a turn, with no markdown library: paragraphs --litt-space-3\n *   apart, headings at the body size and the title weight in --litt-fg, lists with the marker in --litt-fg-subtle, links in --litt-accent-text, inline\n *   code in the mono on fg 6% at the control radius, a `pre` as a quiet mono block on --litt-surface-2, a blockquote\n *   with no rail, and strong text in --litt-fg at the title weight (R8). It never bolds and never italicises.\n * - MessageActions: sm ghost icon-only buttons with tooltips (Copy swaps to a check and a polite live region says\n *   \"Copied\", Retry, and the caller's own). They always show under the latest turn, on hover and focus-within under\n *   older ones, and always on a coarse pointer, where they grow to 44px. A streaming turn has none.\n * - Error: a turn that stopped says so on a line under its text: a 16px alert icon in --litt-danger, the words in\n *   --litt-fg-muted and a Retry sm secondary button. Status color is on the icon only. It has no actions row.\n * - Conversation: a labelled region that scrolls on its own and holds the turns in a column up to 48rem wide,\n *   centered, so the composer under it can share the column. It keeps the latest line in view while content grows\n *   (a ResizeObserver) unless the reader has scrolled up. Then a \"Jump to latest\" sm secondary button with an\n *   ArrowDown icon floats --litt-space-4 above the bottom, centered, and jumps without a smooth scroll. It is aria-busy\n *   while the agent answers, and a polite live region says when a turn starts and ends, never the words.\n * - Motion (responsive tier, hooks only): a turn added after the Conversation mounted fades in and travels half of\n *   --litt-travel upward, once. The turns that were there at the start, and a re-render, never animate. Nothing moves\n *   while text streams: there is no caret, no fade per word and no shimmer. The Jump button fades on the same\n *   tier. The actions' reveal is a color-family fade on the instant tier.\n * - Phones: the bubble is 85% wide on compact screens, the actions, the Retry and the Jump button are 44px on\n *   coarse pointers, and the actions are always visible there. Both signals come from useDevice, so\n *   <DeviceProvider mode=\"phone\"> previews them with a mouse.\n */\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useMemo,\n  useRef,\n  useState,\n  type HTMLAttributes,\n  type ReactNode,\n  type Ref,\n} from \"react\";\nimport { AnimatePresence, motion } from \"motion/react\";\nimport { Button } from \"./button\";\nimport { AlertIcon, ArrowDownIcon, CheckIcon, CopyIcon, RetryIcon } from \"./icons\";\nimport { Tooltip } from \"./tooltip\";\nimport { useDevice, useExit, useTransition, useTravel } from \"../../lib/litt/motion\";\n\n/* ----------------------------------------------------------------- conversation */\n\ntype ConversationContextValue = {\n  /** The agent's name: the hidden heading of an agent turn, and the live region's words. */\n  agentName: string;\n  /** False until the Conversation has mounted, so the turns that were already there never animate. */\n  mounted: { current: boolean };\n  /** Scrolls to the foot and keeps following, as when the person sends a message. */\n  follow: () => void;\n  /** A turn reported that it stopped, so the live region says so instead of \"answered\". */\n  noteError: () => void;\n};\n\nconst ConversationContext = createContext<ConversationContextValue | null>(null);\n\nexport type ConversationProps = {\n  /** What the region is called for screen readers, such as \"Conversation with Litt\". Required, because it is a landmark. */\n  label: string;\n  /** True while the agent answers. It sets aria-busy on the region and makes the live region say the turn started and ended. */\n  busy?: boolean;\n  /** The agent's name, used in the live region (\"Litt is answering\", \"Litt answered\") and as the hidden heading of its turns. */\n  agentName?: string;\n  /** Extra classes for the whole conversation. Give it a height (h-96, or flex-1 min-h-0 in a column), because the region scrolls inside it. */\n  className?: string;\n  /** The turns: Message elements, in order. A first-use state, such as an Empty with suggestions, goes here too. */\n  children?: ReactNode;\n  /** The scrolling region, for a caller that needs to move it. */\n  ref?: Ref<HTMLDivElement>;\n};\n\n/** How far from the foot still counts as the foot, in px. Fractional scroll positions land a pixel short. */\nconst NEAR_FOOT = 2;\n\n/**\n * A chat's scrolling region and its centered column. It keeps the newest line in view while the answer grows, until the\n * reader scrolls up, and then offers \"Jump to latest\". Put Message elements inside it, and mark it busy while the agent answers.\n */\nexport function Conversation({ label, busy = false, agentName = \"Litt\", className = \"\", children, ref }: ConversationProps) {\n  const scroller = useRef<HTMLDivElement | null>(null);\n  const column = useRef<HTMLDivElement>(null);\n  const stuck = useRef(true);\n  const lastTop = useRef(0);\n  const mounted = useRef(false);\n  const errored = useRef(false);\n  const wasBusy = useRef<boolean | null>(null);\n  const [away, setAway] = useState(false);\n  const [said, setSaid] = useState(\"\");\n\n  const { coarse } = useDevice();\n  const enter = useTransition(\"responsive\");\n  const exit = useExit(\"responsive\");\n\n  const setScroller = useCallback(\n    (el: HTMLDivElement | null) => {\n      scroller.current = el;\n      if (typeof ref === \"function\") ref(el);\n      else if (ref) ref.current = el;\n    },\n    [ref],\n  );\n\n  /* Start at the latest line, then follow the column and the frame as either one changes size. */\n  useLayoutEffect(() => {\n    const el = scroller.current;\n    const col = column.current;\n    if (!el || !col) return;\n    mounted.current = true;\n    el.scrollTop = el.scrollHeight;\n    lastTop.current = el.scrollTop;\n    const follow = () => {\n      if (stuck.current) el.scrollTop = el.scrollHeight;\n    };\n    const watch = new ResizeObserver(follow);\n    watch.observe(col);\n    watch.observe(el);\n    return () => watch.disconnect();\n  }, []);\n\n  /* At the foot means following. A step up, past the tolerance, means the reader has taken over. */\n  const onScroll = () => {\n    const el = scroller.current;\n    if (!el) return;\n    const gap = el.scrollHeight - el.scrollTop - el.clientHeight;\n    if (gap < NEAR_FOOT) stuck.current = true;\n    else if (el.scrollTop < lastTop.current) stuck.current = false;\n    lastTop.current = el.scrollTop;\n    setAway(!stuck.current);\n  };\n\n  const follow = useCallback(() => {\n    const el = scroller.current;\n    if (!el) return;\n    stuck.current = true;\n    el.scrollTop = el.scrollHeight;\n    lastTop.current = el.scrollTop;\n    setAway(false);\n  }, []);\n\n  const jump = () => {\n    follow();\n    /* The button is about to leave. Keep a keyboard where it was reading. */\n    scroller.current?.focus({ preventScroll: true });\n  };\n\n  /* The start and the end of a turn, once each. The words never reach the live region. */\n  useEffect(() => {\n    if (busy) {\n      errored.current = false;\n      setSaid(`${agentName} is answering`);\n    } else if (wasBusy.current) {\n      setSaid(errored.current ? `${agentName} stopped answering` : `${agentName} answered`);\n    }\n    wasBusy.current = busy;\n    /* The name is read when the turn starts or ends, and a change of name is no reason to say it again. */\n  }, [busy]);\n\n  const value = useMemo<ConversationContextValue>(\n    () => ({ agentName, mounted, follow, noteError: () => void (errored.current = true) }),\n    [agentName, follow],\n  );\n\n  return (\n    <ConversationContext.Provider value={value}>\n      <div className={`relative flex min-h-0 flex-col ${className}`}>\n        <div\n          ref={setScroller}\n          role=\"region\"\n          aria-label={label}\n          aria-busy={busy || undefined}\n          tabIndex={0}\n          onScroll={onScroll}\n          className={\n            \"min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 [scroll-behavior:auto] \" +\n            \"focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-litt-focus\"\n          }\n        >\n          <div ref={column} className=\"mx-auto flex w-full max-w-[48rem] flex-col gap-8 py-6\">\n            {children}\n          </div>\n        </div>\n\n        <AnimatePresence>\n          {away && (\n            <motion.div\n              key=\"jump\"\n              className=\"pointer-events-none absolute inset-x-0 bottom-4 flex justify-center\"\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1, transition: enter }}\n              exit={{ opacity: 0, transition: exit }}\n            >\n              <Button\n                size=\"sm\"\n                variant=\"secondary\"\n                icon={<ArrowDownIcon />}\n                onClick={jump}\n                data-coarse={coarse ? \"\" : undefined}\n                className=\"pointer-events-auto pointer-coarse:h-litt-control-md data-coarse:h-litt-control-md\"\n              >\n                Jump to latest\n              </Button>\n            </motion.div>\n          )}\n        </AnimatePresence>\n\n        <p role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n          {said}\n        </p>\n      </div>\n    </ConversationContext.Provider>\n  );\n}\n\n/* ----------------------------------------------------------------- message */\n\nexport type MessageProps = Omit<HTMLAttributes<HTMLElement>, \"children\"> & {\n  /** Who is speaking. The person's turn is a right-aligned bubble. The agent's turn has no bubble. */\n  from?: \"user\" | \"agent\";\n  /** A name shown above the turn, for a thread with more than one agent or person. It becomes the visible heading. Without it the heading is hidden: \"You\", or the Conversation's agentName. */\n  name?: ReactNode;\n  /** An sm Avatar beside the name. Pass it decorative, because the name is written next to it. */\n  avatar?: ReactNode;\n  /** Files the person sent, shown above their bubble, such as the Composer's file chips. */\n  attachments?: ReactNode;\n  /** The row under a finished agent turn, usually a MessageActions. It is left out while the turn streams or has stopped. */\n  actions?: ReactNode;\n  /** True while this turn's text is still arriving. The actions wait until it ends. */\n  streaming?: boolean;\n  /** Says the turn stopped, on a line under its text with a Retry button. Pass true for \"The answer stopped.\", or the reason as words. */\n  error?: ReactNode;\n  /** Called by the error line's Retry button. Without it the line has no button. */\n  onRetry?: () => void;\n  /** Marks the newest turn, so its actions always show. It is needed only when something other than a Message follows it in the column. */\n  latest?: boolean;\n  /** The level of the turn's heading. Three suits a page with a title and sections. */\n  headingLevel?: 2 | 3 | 4 | 5 | 6;\n  /** The parts of the turn, in order: thinking, a plan, tool calls, then a MessageText. For the person's turn, the text they wrote. */\n  children?: ReactNode;\n  /** Extra classes for the article. */\n  className?: string;\n};\n\n/**\n * One turn: an article named by a heading. The agent's words sit bare on the page, the person's in a raised bubble on the right.\n * A turn added after the Conversation mounted fades in once; the person's own turn also scrolls the Conversation to the foot.\n */\nexport function Message({\n  from = \"agent\",\n  name,\n  avatar,\n  attachments,\n  actions,\n  streaming = false,\n  error,\n  onRetry,\n  latest,\n  headingLevel = 3,\n  className = \"\",\n  children,\n  ...rest\n}: MessageProps) {\n  const conversation = useContext(ConversationContext);\n  const headingId = useId();\n  const { compact, coarse } = useDevice();\n  const enter = useTransition(\"responsive\");\n  const travel = useTravel();\n\n  /* A turn that mounts with the Conversation is history. One that arrives later enters, once. The travel is 0 under\n     reduced motion, so there it simply appears, with no frame at zero opacity. */\n  const [fresh] = useState(() => conversation?.mounted.current ?? false);\n  const follow = conversation?.follow;\n  useLayoutEffect(() => {\n    if (fresh && from === \"user\") follow?.();\n    /* Only when the turn arrives. */\n  }, []);\n\n  const failed = error !== undefined && error !== null && error !== false;\n  const noteError = conversation?.noteError;\n  useEffect(() => {\n    if (failed) noteError?.();\n  }, [failed, noteError]);\n\n  const person = from === \"user\";\n  const Heading = `h${headingLevel}` as \"h3\";\n  const hidden = person ? \"You\" : (conversation?.agentName ?? \"Litt\");\n  const words = error === true ? \"The answer stopped.\" : error;\n\n  const header =\n    name !== undefined && name !== null && name !== false ? (\n      <div className={`flex items-center gap-2 ${person ? \"justify-end\" : \"\"}`}>\n        {avatar}\n        <Heading id={headingId} className=\"text-litt-small font-litt-title text-litt-fg\">\n          {name}\n        </Heading>\n      </div>\n    ) : (\n      <Heading id={headingId} className=\"sr-only\">\n        {hidden}\n      </Heading>\n    );\n\n  return (\n    <article\n      {...rest}\n      aria-labelledby={headingId}\n      data-from={from}\n      data-latest={latest ? \"\" : undefined}\n      data-coarse={coarse ? \"\" : undefined}\n      className={`group/turn ${className}`}\n    >\n      <motion.div\n        initial={fresh && travel > 0 ? { opacity: 0, y: travel / 2 } : false}\n        animate={{ opacity: 1, y: 0, transition: enter }}\n        className={person ? \"flex flex-col items-end gap-3\" : \"flex flex-col gap-3 text-litt-body text-litt-fg-muted\"}\n      >\n        {header}\n\n        {person ? (\n          <>\n            {attachments && <div className=\"flex max-w-full flex-wrap justify-end gap-2\">{attachments}</div>}\n            <div\n              className={\n                \"litt-raised px-4 py-3 text-litt-body text-litt-fg whitespace-pre-wrap [overflow-wrap:anywhere] \" +\n                \"in-[[data-material=flat]]:bg-litt-fg in-[[data-material=flat]]:text-litt-canvas in-[[data-material=flat]]:shadow-none \" +\n                \"in-[[data-material=flat]]:forced-colors:border \" +\n                (compact ? \"max-w-[min(85%,32rem)]\" : \"max-w-[min(80%,32rem)]\")\n              }\n            >\n              {children}\n            </div>\n          </>\n        ) : (\n          <>\n            {children}\n\n            {failed && (\n              <div className=\"flex flex-wrap items-center gap-x-3 gap-y-2\">\n                <p className=\"flex min-w-0 items-start gap-2 text-litt-small text-litt-fg-muted\">\n                  <span className=\"flex h-[1lh] shrink-0 items-center text-litt-danger\">\n                    <AlertIcon />\n                  </span>\n                  <span className=\"min-w-0\">{words}</span>\n                </p>\n                {onRetry && (\n                  <Button\n                    size=\"sm\"\n                    variant=\"secondary\"\n                    onClick={onRetry}\n                    data-coarse={coarse ? \"\" : undefined}\n                    className=\"pointer-coarse:h-litt-control-md data-coarse:h-litt-control-md\"\n                  >\n                    Retry\n                  </Button>\n                )}\n              </div>\n            )}\n\n            {actions && !streaming && !failed && (\n              <div\n                className={\n                  /* The first icon lines up with the text's edge, so the hover fill hangs out into the gutter. */\n                  \"-ms-2 pointer-coarse:-ms-3.5 group-data-coarse/turn:-ms-3.5 \" +\n                  \"opacity-0 transition-[opacity] duration-(--litt-dur-instant) ease-litt \" +\n                  \"group-hover/turn:opacity-100 group-focus-within/turn:opacity-100 group-last/turn:opacity-100 \" +\n                  \"group-data-latest/turn:opacity-100 group-data-coarse/turn:opacity-100 pointer-coarse:opacity-100\"\n                }\n              >\n                {actions}\n              </div>\n            )}\n          </>\n        )}\n      </motion.div>\n    </article>\n  );\n}\n\n/* ----------------------------------------------------------------- message text */\n\n/* What a markdown renderer puts inside an answer. Every rule is a descendant selector, so any renderer works, and each\n   color, size and radius is a litt token. Weights are set here by role (R8), so an app without the base styles gets them too. */\nconst PROSE = [\n  \"flex min-w-0 max-w-(--litt-measure) flex-col gap-3 text-litt-body [&>*]:min-w-0 [&_p]:text-pretty\",\n  /* Headings: the body size at the title weight in --litt-fg. Weight and ink are the hierarchy, as everywhere (R8). */\n  \"[&_:is(h1,h2,h3,h4)]:text-litt-body [&_:is(h1,h2,h3,h4)]:font-litt-title [&_:is(h1,h2,h3,h4)]:text-litt-fg [&>:is(h1,h2,h3,h4):not(:first-child)]:mt-2\",\n  /* Lists: the marker steps back to --litt-fg-subtle. A nested list tucks under its item. */\n  \"[&_ul]:list-disc [&_ol]:list-decimal [&_:is(ul,ol)]:ps-6 [&_li+li]:mt-1 [&_li::marker]:text-litt-fg-subtle [&_li_:is(ul,ol)]:mt-1\",\n  /* Links: accent text, always underlined, so color is never the only mark. */\n  \"[&_a]:text-litt-accent-text [&_a]:underline [&_a]:decoration-[color-mix(in_oklab,var(--litt-accent-text)_40%,transparent)] [&_a]:underline-offset-2 [&_a:hover]:decoration-litt-accent-text\",\n  /* Strong steps to the title weight in --litt-fg, never bold, and emphasis steps to --litt-fg. No italic (R8). */\n  \"[&_:is(strong,b)]:font-litt-title [&_:is(strong,b)]:text-litt-fg [&_:is(em,i)]:not-italic [&_:is(em,i)]:text-litt-fg\",\n  /* Inline code: the mono on fg 6% at the control radius. A pre resets it below. */\n  \"[&_code]:rounded-litt-control [&_code]:bg-[color-mix(in_oklab,var(--litt-fg)_6%,transparent)] [&_code]:px-1 [&_code]:py-0.5 [&_code]:font-litt-mono [&_code]:font-normal [&_code]:text-litt-small [&_code]:text-litt-fg [&_code]:box-decoration-clone [&_code]:[overflow-wrap:anywhere]\",\n  /* A pre: a quiet mono block on --litt-surface-2. It wraps, so it never needs a scrollbar. CodeBlock is for code to read or copy. */\n  \"[&_pre]:whitespace-pre-wrap [&_pre]:rounded-litt-surface [&_pre]:bg-litt-surface-2 [&_pre]:p-4 [&_pre]:font-litt-mono [&_pre]:text-litt-small [&_pre]:text-litt-fg [&_pre]:shadow-[inset_0_0_0_1px_var(--litt-line)] [&_pre]:[overflow-wrap:anywhere] [&_pre]:[tab-size:2]\",\n  \"[&_pre_code]:rounded-none [&_pre_code]:bg-transparent [&_pre_code]:p-0\",\n  /* A blockquote: indented and a step quieter. No rail and no tint. */\n  \"[&_blockquote]:grid [&_blockquote]:gap-3 [&_blockquote]:ps-4 [&_blockquote]:text-litt-fg-subtle\",\n  \"[&_hr]:my-1 [&_hr]:border-litt-line\",\n  \"[&_img]:h-auto [&_img]:max-w-full [&_img]:rounded-litt-surface\",\n  /* Flat: the answer is a grey bubble (the person's is the ink one), and inline code lifts to a white pill inside it. */\n  \"in-[[data-material=flat]]:w-fit in-[[data-material=flat]]:self-start in-[[data-material=flat]]:rounded-litt-surface in-[[data-material=flat]]:bg-litt-surface-3 in-[[data-material=flat]]:px-4 in-[[data-material=flat]]:py-4 in-[[data-material=flat]]:text-litt-fg\",\n  \"in-[[data-material=flat]]:[&_code]:bg-litt-surface-1 in-[[data-material=flat]]:[&_code]:rounded-[calc(var(--litt-radius-surface)-4px)] in-[[data-material=flat]]:[&_pre_code]:rounded-none in-[[data-material=flat]]:[&_pre]:bg-litt-surface-1 in-[[data-material=flat]]:[&_pre]:shadow-none in-[[data-material=flat]]:[&_tr]:border-0 in-[[data-material=flat]]:[&_hr]:border-0\",\n  /* A table: hairlines between rows, headings in --litt-fg, cells that wrap instead of scrolling. */\n  \"[&_table]:w-full [&_table]:border-collapse [&_table]:text-litt-small [&_tr]:border-b [&_tr]:border-litt-line\",\n  \"[&_th]:py-2 [&_th]:pe-4 [&_th]:text-start [&_th]:text-litt-caption [&_th]:text-litt-fg [&_td]:py-2 [&_td]:pe-4 [&_td]:align-top [&_:is(th,td)]:[overflow-wrap:anywhere]\",\n].join(\" \");\n\nexport type MessageTextProps = {\n  /** What a markdown renderer produced (p, h1 to h4, ul, ol, li, a, code, pre, blockquote, strong, em, hr, table), or a plain string, which becomes one paragraph. */\n  children?: ReactNode;\n  /** Extra classes for the text block. */\n  className?: string;\n};\n\n/**\n * The prose of an answer: a column of at most 68ch with the markdown elements styled in litt's tokens. Give it your renderer's\n * output, or a string for one paragraph.\n */\nexport function MessageText({ children, className = \"\" }: MessageTextProps) {\n  return <div className={`${PROSE} ${className}`}>{typeof children === \"string\" ? <p>{children}</p> : children}</div>;\n}\n\n/* ----------------------------------------------------------------- message actions */\n\n/** Puts text on the clipboard. The async API needs a secure context and a permission, so a hidden field and execCommand stand behind it. */\nasync function writeClipboard(text: string): Promise<boolean> {\n  try {\n    await navigator.clipboard.writeText(text);\n    return true;\n  } catch {\n    /* fall through to the older route */\n  }\n  const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n  try {\n    const field = document.createElement(\"textarea\");\n    field.value = text;\n    field.setAttribute(\"readonly\", \"\");\n    field.setAttribute(\"aria-hidden\", \"true\");\n    field.style.position = \"fixed\";\n    field.style.opacity = \"0\";\n    document.body.append(field);\n    field.select();\n    const ok = document.execCommand(\"copy\");\n    field.remove();\n    return ok;\n  } catch {\n    return false;\n  } finally {\n    focused?.focus({ preventScroll: true });\n  }\n}\n\nexport type MessageActionsProps = {\n  /** The text the Copy button puts on the clipboard. Without it there is no Copy button. */\n  copyText?: string;\n  /** Called by the Retry button. Without it there is no Retry button. */\n  onRetry?: () => void;\n  /** The group's name for screen readers. */\n  label?: string;\n  /** Your own buttons, after Copy and Retry: sm ghost icon-only Buttons, each in a Tooltip and with an aria-label. */\n  children?: ReactNode;\n  /** Extra classes for the row. */\n  className?: string;\n};\n\n/** How long the check stays in place of the copy icon, in ms. An interaction delay, not an animation. */\nconst COPIED_FOR = 2000;\n\n/**\n * The row under a finished agent turn: Copy (it swaps to a check and says \"Copied\" politely), Retry, and your own buttons.\n * Pass it to a Message's actions, which shows it under the latest turn, on hover and focus under older ones, and always on touch.\n */\nexport function MessageActions({ copyText, onRetry, label = \"Message actions\", children, className = \"\" }: MessageActionsProps) {\n  const { coarse } = useDevice();\n  const [copied, setCopied] = useState(false);\n  const [said, setSaid] = useState(\"\");\n  const timer = useRef<number | undefined>(undefined);\n\n  useEffect(() => () => window.clearTimeout(timer.current), []);\n\n  async function copy() {\n    if (copyText === undefined) return;\n    const ok = await writeClipboard(copyText);\n    window.clearTimeout(timer.current);\n    setCopied(ok);\n    setSaid(ok ? \"Copied\" : \"Could not copy\");\n    timer.current = window.setTimeout(() => {\n      setCopied(false);\n      setSaid(\"\");\n    }, COPIED_FOR);\n  }\n\n  return (\n    <div\n      role=\"group\"\n      aria-label={label}\n      data-coarse={coarse ? \"\" : undefined}\n      className={\n        \"flex flex-wrap items-center gap-1 \" +\n        \"pointer-coarse:[&_button]:h-litt-control-md pointer-coarse:[&_button]:w-litt-control-md \" +\n        \"data-coarse:[&_button]:h-litt-control-md data-coarse:[&_button]:w-litt-control-md \" +\n        className\n      }\n    >\n      {copyText !== undefined && (\n        <Tooltip content={copied ? \"Copied\" : \"Copy\"}>\n          <Button size=\"sm\" variant=\"ghost\" iconOnly aria-label=\"Copy\" onClick={copy}>\n            {copied ? <CheckIcon /> : <CopyIcon />}\n          </Button>\n        </Tooltip>\n      )}\n      {onRetry && (\n        <Tooltip content=\"Retry\">\n          <Button size=\"sm\" variant=\"ghost\" iconOnly aria-label=\"Retry\" onClick={onRetry}>\n            <RetryIcon />\n          </Button>\n        </Tooltip>\n      )}\n      {children}\n      <span role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {said}\n      </span>\n    </div>\n  );\n}\n",
      "target": "components/litt/message.tsx"
    }
  ],
  "docs": "Import Conversation, Message, MessageText and MessageActions from components/litt/message. Give the Conversation a label and a height, and mark it busy while the agent answers. Inside it put a Message with from=\"user\" for the person's turn, and a Message for the agent's, holding a MessageText (your markdown renderer's output goes inside it) and actions={<MessageActions copyText onRetry />}. Pass streaming while the text arrives, and error with onRetry for a turn that stopped.",
  "meta": {
    "version": "2026.11.06",
    "released": "2026-11-06",
    "changelog": "https://littmd.vercel.app/changelog.md"
  }
}
