{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"message","type":"registry:ui","title":"Message","description":"One message in a conversation with an AI assistant, with a cursor while it streams and Copy, Regenerate and feedback under it.","dependencies":["lucide-react"],"registryDependencies":["https://dashral.com/r/utils.json","https://dashral.com/r/lib-locale.json","https://dashral.com/r/avatar.json","https://dashral.com/r/button.json","https://dashral.com/r/copy-button.json","https://dashral.com/r/tooltip.json"],"files":[{"path":"registry/dashral/message.tsx","type":"registry:file","target":"components/dashral/message.tsx","content":"\"use client\"\n\nimport * as React from \"react\"\nimport { RotateCcw, Sparkles, ThumbsDown, ThumbsUp } from \"lucide-react\"\n\nimport { useLocale } from \"./lib/locale\"\nimport { cn } from \"./lib/utils\"\nimport { Avatar } from \"./avatar\"\nimport { Button } from \"./button\"\nimport { CopyButton } from \"./copy-button\"\nimport { Tooltip } from \"./tooltip\"\n\ntype MessageFrom = \"user\" | \"assistant\"\n/** \"streaming\" while words are still arriving; \"error\" when the answer stopped short. */\ntype MessageStatus = \"streaming\" | \"done\" | \"error\"\ntype MessageFeedback = \"up\" | \"down\" | null\n\ntype MessageProps = Omit<React.ComponentProps<\"article\">, \"children\"> & {\n  /** Who wrote it: your own messages sit at the end in a bubble, the assistant's open flat beside its avatar. */\n  from: MessageFrom\n  /** What it says: text (line breaks kept), or your own rendering (Markdown, a table, a chart). */\n  children: React.ReactNode\n  /** The sender, for the avatar's initials and screen readers. \"You\" or \"Assistant\" by default. */\n  name?: string\n  /** An image URL, or your own mark (a logo). The assistant shows a sparkle by default, you nothing; false hides it. */\n  avatar?: string | React.ReactNode | false\n  /** When it was sent: a Date (written in the locale) or your own words (\"2 min ago\"). */\n  time?: Date | string\n  status?: MessageStatus\n  /** What Copy copies. The message's text by default. */\n  copyText?: string\n  /** Shows Regenerate (and Try again after an error). */\n  onRegenerate?: () => void\n  /** Good and bad response, controlled or not. Shown when onFeedbackChange is given. */\n  feedback?: MessageFeedback\n  defaultFeedback?: MessageFeedback\n  onFeedbackChange?: (feedback: MessageFeedback) => void\n  /** More buttons, after the built-in ones. */\n  actions?: React.ReactNode\n  /** \"hover\" shows the actions when the pointer or focus is on the message (always on touch screens). */\n  showActions?: \"hover\" | \"always\" | false\n  /** What an error says. \"Couldn't finish this answer.\" by default. */\n  error?: React.ReactNode\n  classNames?: { content?: string; avatar?: string; actions?: string; meta?: string }\n}\n\n/**\n * One message in a conversation with an assistant: yours in a bubble at the end, the assistant's flat with its avatar,\n * a cursor while it streams in, and Copy, Regenerate and feedback under it.\n */\nfunction Message({\n  from,\n  children,\n  name,\n  avatar,\n  time,\n  status = \"done\",\n  copyText,\n  onRegenerate,\n  feedback: feedbackProp,\n  defaultFeedback = null,\n  onFeedbackChange,\n  actions,\n  showActions = \"hover\",\n  error,\n  className,\n  classNames,\n  ...props\n}: MessageProps) {\n  const { t, locale } = useLocale()\n  const content = React.useRef<HTMLDivElement>(null)\n  const [ownFeedback, setOwnFeedback] = React.useState(defaultFeedback)\n  const feedback = feedbackProp ?? ownFeedback\n  const setFeedback = (next: MessageFeedback) => {\n    if (feedbackProp === undefined) setOwnFeedback(next)\n    onFeedbackChange?.(next)\n  }\n  // Copy takes the text as it reads on screen, once the message is complete.\n  const [text, setText] = React.useState(copyText ?? (typeof children === \"string\" ? children : \"\"))\n  React.useEffect(() => {\n    if (copyText !== undefined) setText(copyText)\n    else if (status !== \"streaming\") setText(content.current?.innerText.trim() ?? \"\")\n  }, [copyText, status, children])\n\n  const user = from === \"user\"\n  const sender = name ?? (user ? t(\"you\") : t(\"assistant\"))\n  const streaming = status === \"streaming\"\n  const showAvatar = avatar !== false && (avatar !== undefined || !user)\n  const when =\n    time instanceof Date ? { label: time.toLocaleTimeString(locale, { hour: \"numeric\", minute: \"2-digit\" }), iso: time.toISOString() } : time ? { label: time } : null\n\n  const buttons = !streaming && showActions !== false && (\n    <div\n      data-slot=\"message-actions\"\n      className={cn(\n        \"flex items-center gap-0.5 text-muted-foreground transition-opacity duration-(--duration-fast)\",\n        // Yours sit beside the bubble, so a hidden row doesn't open a gap under it; the assistant's go under the answer.\n        user ? \"shrink-0\" : \"-ms-1.5\",\n        showActions === \"hover\" && \"opacity-0 group-hover/message:opacity-100 group-focus-within/message:opacity-100 [@media(hover:none)]:opacity-100\",\n        classNames?.actions\n      )}\n    >\n      <CopyButton value={text} size=\"icon-sm\" disabled={!text} />\n      {!user && onRegenerate && (\n        <Tooltip content={t(\"regenerate\")}>\n          <Button variant=\"ghost\" size=\"icon-sm\" aria-label={t(\"regenerate\")} onClick={onRegenerate}>\n            <RotateCcw />\n          </Button>\n        </Tooltip>\n      )}\n      {!user && onFeedbackChange && (\n        <>\n          <Tooltip content={t(\"goodResponse\")}>\n            <Button\n              variant=\"ghost\"\n              size=\"icon-sm\"\n              aria-label={t(\"goodResponse\")}\n              aria-pressed={feedback === \"up\"}\n              onClick={() => setFeedback(feedback === \"up\" ? null : \"up\")}\n              className={cn(feedback === \"up\" && \"text-foreground\")}\n            >\n              <ThumbsUp className={cn(feedback === \"up\" && \"fill-current\")} />\n            </Button>\n          </Tooltip>\n          <Tooltip content={t(\"badResponse\")}>\n            <Button\n              variant=\"ghost\"\n              size=\"icon-sm\"\n              aria-label={t(\"badResponse\")}\n              aria-pressed={feedback === \"down\"}\n              onClick={() => setFeedback(feedback === \"down\" ? null : \"down\")}\n              className={cn(feedback === \"down\" && \"text-foreground\")}\n            >\n              <ThumbsDown className={cn(feedback === \"down\" && \"fill-current\")} />\n            </Button>\n          </Tooltip>\n        </>\n      )}\n      {actions}\n    </div>\n  )\n\n  const body = (\n    <div\n      ref={content}\n      data-slot=\"message-content\"\n      dir=\"auto\"\n      className={cn(\n        \"min-w-0 text-sm leading-relaxed [overflow-wrap:anywhere]\",\n        typeof children === \"string\" && \"whitespace-pre-wrap\",\n        user ? \"rounded-2xl rounded-ee-md bg-primary px-3.5 py-2 text-primary-foreground\" : \"pt-1\",\n        // The streaming cursor follows the last word: after the text itself, or inside the last element (a paragraph,\n        // a list item) when the content is your own rendering, so it never drops to a line of its own.\n        streaming &&\n          (typeof children === \"string\"\n            ? \"after:ms-0.5 after:inline-block after:h-[1.1em] after:w-[0.45em] after:translate-y-[0.18em] after:rounded-[1px] after:bg-foreground/70 after:content-[''] motion-safe:after:animate-pulse\"\n            : \"[&>:last-child]:after:ms-0.5 [&>:last-child]:after:inline-block [&>:last-child]:after:h-[1.1em] [&>:last-child]:after:w-[0.45em] [&>:last-child]:after:translate-y-[0.18em] [&>:last-child]:after:rounded-[1px] [&>:last-child]:after:bg-foreground/70 [&>:last-child]:after:content-[''] motion-safe:[&>:last-child]:after:animate-pulse\"),\n        classNames?.content\n      )}\n    >\n      {children}\n    </div>\n  )\n\n  return (\n    <article\n      data-slot=\"message\"\n      data-from={from}\n      data-status={status}\n      aria-label={sender}\n      // While words are still arriving, screen readers wait for the whole answer instead of reading each one.\n      aria-busy={streaming || undefined}\n      className={cn(\"group/message flex w-full gap-3\", user && \"flex-row-reverse\", className)}\n      {...props}\n    >\n      {showAvatar &&\n        (avatar === undefined ? (\n          <span aria-hidden className={cn(\"mt-0.5 grid size-8 shrink-0 place-items-center rounded-full bg-primary/10 text-primary-text\", classNames?.avatar)}>\n            <Sparkles className=\"size-4\" />\n          </span>\n        ) : typeof avatar === \"string\" ? (\n          <Avatar src={avatar} name={sender} alt=\"\" size=\"sm\" className={cn(\"mt-0.5\", classNames?.avatar)} />\n        ) : (\n          <span aria-hidden className={cn(\"mt-0.5 grid size-8 shrink-0 place-items-center\", classNames?.avatar)}>\n            {avatar}\n          </span>\n        ))}\n      <div className={cn(\"flex min-w-0 flex-col\", user ? \"max-w-[85%] items-end gap-1\" : \"flex-1 gap-1.5\")}>\n        {user ? (\n          <div className=\"flex min-w-0 items-center gap-1\">\n            {buttons}\n            {body}\n          </div>\n        ) : (\n          body\n        )}\n        {status === \"error\" && (\n          <div role=\"alert\" className=\"flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-destructive-text\">\n            {error ?? t(\"messageFailed\")}\n            {onRegenerate && (\n              <Button variant=\"link\" color=\"destructive\" size=\"inline\" onClick={onRegenerate}>\n                {t(\"tryAgain\")}\n              </Button>\n            )}\n          </div>\n        )}\n        {(when || (buttons && !user)) && (\n          <div className={cn(\"flex items-center gap-2\", classNames?.meta)}>\n            {!user && buttons}\n            {when && (\n              <time dateTime={when.iso} className=\"text-xs text-muted-foreground tabular-nums\">\n                {when.label}\n              </time>\n            )}\n          </div>\n        )}\n      </div>\n    </article>\n  )\n}\n\nexport { Message, type MessageProps, type MessageFrom, type MessageStatus, type MessageFeedback }\n"}],"cssVars":{"theme":{"color-primary-text":"var(--primary-text, var(--primary))","color-destructive-foreground":"var(--destructive-foreground)","color-destructive-text":"var(--destructive-text)","ease-standard":"var(--ease-standard)","ease-enter":"var(--ease-enter)","ease-exit":"var(--ease-exit)","ease-spring":"var(--ease-spring)"},"light":{"destructive-foreground":"oklch(0.985 0 0)","destructive-text":"#bd2800","duration-fast":"160ms","duration-standard":"240ms","duration-considered":"480ms","ease-standard":"cubic-bezier(0.2, 0, 0, 1)","ease-enter":"cubic-bezier(0, 0, 0, 1)","ease-exit":"cubic-bezier(0.3, 0, 1, 1)","ease-spring":"linear(0, 0.071 4%, 0.234 8%, 0.433 12%, 0.626 17%, 0.791 21%, 0.917 25%, 1.004 29%, 1.055 33%, 1.079 38%, 1.083 42%, 1.075 46%, 1.06 50%, 1.043 54%, 1.027 58%, 1.015 62%, 1.005 67%, 0.998 71%, 0.995 75%, 0.993 79%, 0.993 83%, 0.994 88%, 0.995 92%, 0.997 96%, 1)"},"dark":{"destructive-foreground":"oklch(0.2 0 0)","destructive-text":"#ff8a5c"}}}