{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["lucide-react","motion"],"description":"Chat message bubble whose action row slides out of the bubble's own edge, revealed on hover and on focus.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { Check, Copy, RotateCcw, ThumbsDown, ThumbsUp } from \"lucide-react\";\nimport { type ReactNode, useEffect, useState } from \"react\";\n\nconst COPIED_RESET_MS = 1600;\nconst ACTION_STAGGER_MS = 30;\n\n/**\n * The reveal is CSS so it needs no state and cannot desync from the pointer.\n * `motion`'s `animate` target was not being re-applied on state change here, and\n * a hover fade does not need a spring — a 200ms ease-out is the whole effect.\n */\nconst ACTION_STYLES = `\n.ai-message-action {\n  opacity: 0;\n  transform: translateX(var(--ai-message-slide)) scale(0.9);\n  transition:\n    opacity 200ms cubic-bezier(.23, 1, .32, 1),\n    transform 200ms cubic-bezier(.23, 1, .32, 1),\n    background-color 150ms ease,\n    color 150ms ease;\n}\n.ai-message-action-agent { --ai-message-slide: -6px; }\n.ai-message-action-user { --ai-message-slide: 6px; }\n.ai-message-root:hover .ai-message-action,\n.ai-message-root:focus-within .ai-message-action {\n  opacity: 1;\n  transform: translateX(0) scale(1);\n}\n.ai-message-pop { animation: ai-message-pop 250ms cubic-bezier(.23, 1, .32, 1); }\n@keyframes ai-message-pop {\n  0% { transform: scale(1); }\n  45% { transform: scale(1.25); }\n  100% { transform: scale(1); }\n}\n@media (prefers-reduced-motion: reduce) {\n  .ai-message-action { transition-duration: 0ms; transition-delay: 0ms !important; transform: none; }\n  .ai-message-root:hover .ai-message-action,\n  .ai-message-root:focus-within .ai-message-action { transform: none; }\n  .ai-message-pop { animation: none; }\n}\n`;\n\nexport type AIMessageAuthor = \"user\" | \"assistant\";\n\nexport type AIMessageProps = {\n  /** Rendered to the side of the bubble — an avatar or an orb. */\n  avatar?: ReactNode;\n  /**\n   * Draw the tinted bubble. Turn it off for assistant turns that carry their own\n   * surfaces — reasoning traces, tool calls, diffs — where a bubble around a\n   * stack of cards reads as a box inside a box.\n   */\n  bubble?: boolean;\n  children: ReactNode;\n  className?: string;\n  /** Plain text handed to the clipboard. Omit to hide the copy action. */\n  copyText?: string;\n  /**\n   * Who wrote it. Named `from` rather than `role` on purpose: `role` is an ARIA\n   * attribute, and a component prop of that name misleads both readers and\n   * accessibility linters.\n   */\n  from?: AIMessageAuthor;\n  onRetry?: () => void;\n  onVote?: (vote: \"up\" | \"down\") => void;\n  /** Preformatted timestamp, e.g. \"14:32\". */\n  timestamp?: string;\n};\n\n/**\n * A chat message with actions that stay out of the way.\n *\n * The action row slides out of the bubble's own edge rather than fading in from\n * nowhere, so it reads as belonging to that message. It is revealed on hover and\n * on focus-within, because a hover-only control row is unreachable by keyboard.\n */\nconst AIMessage = ({\n  avatar,\n  bubble = true,\n  children,\n  className,\n  copyText,\n  onRetry,\n  onVote,\n  from = \"assistant\",\n  timestamp,\n}: AIMessageProps) => {\n  const [hasCopied, setHasCopied] = useState(false);\n  const [vote, setVote] = useState<\"up\" | \"down\" | null>(null);\n\n  const isUser = from === \"user\";\n\n  useEffect(() => {\n    if (!hasCopied) {\n      return;\n    }\n    const timeout = setTimeout(() => setHasCopied(false), COPIED_RESET_MS);\n    return () => clearTimeout(timeout);\n  }, [hasCopied]);\n\n  const copy = async () => {\n    if (!copyText) {\n      return;\n    }\n    try {\n      await navigator.clipboard.writeText(copyText);\n      setHasCopied(true);\n    } catch {\n      // A blocked clipboard is not worth interrupting the conversation over.\n    }\n  };\n\n  const actions = [\n    copyText\n      ? {\n          active: hasCopied,\n          icon: hasCopied ? Check : Copy,\n          key: \"copy\",\n          label: hasCopied ? \"Copied\" : \"Copy\",\n          onClick: copy,\n        }\n      : null,\n    onRetry\n      ? {\n          active: false,\n          icon: RotateCcw,\n          key: \"retry\",\n          label: \"Retry\",\n          onClick: onRetry,\n        }\n      : null,\n    // Voting on your own message makes no sense, so the feedback pair is\n    // assistant-only even when the consumer passes `onVote` for the thread.\n    onVote && !isUser\n      ? {\n          active: vote === \"up\",\n          icon: ThumbsUp,\n          key: \"up\",\n          label: \"Good response\",\n          onClick: () => {\n            setVote(\"up\");\n            onVote(\"up\");\n          },\n        }\n      : null,\n    onVote && !isUser\n      ? {\n          active: vote === \"down\",\n          icon: ThumbsDown,\n          key: \"down\",\n          label: \"Bad response\",\n          onClick: () => {\n            setVote(\"down\");\n            onVote(\"down\");\n          },\n        }\n      : null,\n  ].filter((action): action is NonNullable<typeof action> => action !== null);\n\n  return (\n    <div\n      className={cn(\n        // The reveal is scoped to this class rather than Tailwind's `group`, so a\n        // `group` ancestor elsewhere on the page cannot reveal every row at once.\n        \"ai-message-root flex w-full gap-2.5\",\n        isUser ? \"flex-row-reverse\" : \"flex-row\",\n        className\n      )}\n    >\n      {/* biome-ignore lint/security/noDangerouslySetInnerHtml: a static, local stylesheet with no interpolation */}\n      <style dangerouslySetInnerHTML={{ __html: ACTION_STYLES }} />\n\n      {avatar ? <div className=\"mt-0.5 shrink-0\">{avatar}</div> : null}\n\n      <div className={cn(\"flex min-w-0 flex-col gap-1\", isUser && \"items-end\")}>\n        <div\n          className={cn(\n            \"w-fit max-w-prose text-sm leading-relaxed\",\n            bubble && \"rounded-2xl px-3.5 py-2.5\",\n            bubble && isUser && \"rounded-br-md bg-foreground text-background\",\n            bubble && !isUser && \"rounded-bl-md bg-muted text-foreground\",\n            !bubble && \"text-foreground\"\n          )}\n        >\n          {children}\n        </div>\n\n        <div\n          className={cn(\n            \"flex items-center gap-1 px-1\",\n            isUser ? \"flex-row-reverse\" : \"flex-row\"\n          )}\n        >\n          {/* The timestamp comes first so it stays pinned to the edge the\n              bubble is anchored to — left for the assistant, right for the\n              user. Putting the (always-mounted, invisible) action slots before\n              it pushed it toward the middle of the row, where it read as\n              floating in nothing. */}\n          {timestamp ? (\n            <span className=\"text-muted-foreground text-xs tabular-nums\">\n              {timestamp}\n            </span>\n          ) : null}\n\n          {/* Always mounted, only faded — mounting the row on hover changed its\n              height, so every message below jumped as the pointer moved down a\n              thread. The reveal is plain CSS rather than a motion `animate`\n              target: the group already knows about hover and focus-within, so no\n              state, no listeners, and the row cannot get stuck half-revealed. */}\n          {actions.map((action, index) => {\n            const Icon = action.icon;\n            return (\n              <button\n                aria-label={action.label}\n                aria-pressed={action.active}\n                className={cn(\n                  \"ai-message-action cursor-pointer rounded-lg p-1.5\",\n                  isUser ? \"ai-message-action-user\" : \"ai-message-action-agent\",\n                  action.active\n                    ? \"text-foreground\"\n                    : \"text-muted-foreground hover:bg-muted hover:text-foreground\"\n                )}\n                key={action.key}\n                onClick={action.onClick}\n                style={{ transitionDelay: `${index * ACTION_STAGGER_MS}ms` }}\n                type=\"button\"\n              >\n                <Icon\n                  aria-hidden=\"true\"\n                  className={\n                    action.key === \"copy\" && hasCopied\n                      ? \"ai-message-pop\"\n                      : undefined\n                  }\n                  key={action.key === \"copy\" && hasCopied ? \"copied\" : \"idle\"}\n                  size={14}\n                />\n              </button>\n            );\n          })}\n        </div>\n      </div>\n    </div>\n  );\n};\n\nexport default AIMessage;\n","path":"index.tsx","target":"components/smoothui/ai-message/index.tsx","type":"registry:ui"}],"name":"ai-message","registryDependencies":[],"title":"Ai Message","type":"registry:ui"}