{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "user-message",
  "type": "registry:ui",
  "dependencies": [
    "@tabler/icons-react",
    "ai",
    "clsx",
    "tailwind-merge"
  ],
  "registryDependencies": [
    "https://agent-elements.21st.dev/r/file-attachment.json"
  ],
  "files": [
    {
      "path": "registry/agent-elements/components/user-message.tsx",
      "content": "import { memo, useState } from \"react\";\nimport type { UIMessage } from \"ai\";\nimport { cn } from \"./utils/cn\";\nimport { FileAttachment } from \"./input/file-attachment\";\nimport { ImageLightbox } from \"./image-lightbox\";\n\nexport type UserMessageProps = {\n  message: UIMessage;\n  className?: string;\n  /**\n   * When true (default) clicking an attached image opens a fullscreen\n   * lightbox preview. Set to false to render images as plain thumbnails.\n   */\n  enableImagePreview?: boolean;\n};\n\ntype MessagePart = UIMessage[\"parts\"][number];\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n  return typeof value === \"object\" && value !== null;\n}\n\nfunction isTextPart(part: MessagePart): part is { type: \"text\"; text: string } {\n  return (\n    part.type === \"text\" &&\n    typeof (part as { text?: unknown }).text === \"string\"\n  );\n}\n\nfunction getImageUrlFromPart(part: unknown): string | null {\n  if (!isRecord(part)) return null;\n  const type = part.type;\n  if (typeof type !== \"string\") return null;\n\n  if (type === \"image\") {\n    const imagePart = part as { url?: string; image?: string };\n    return imagePart.url ?? imagePart.image ?? null;\n  }\n\n  if (type === \"data-image\") {\n    const dataPart = part as { data?: { url?: string } };\n    return dataPart.data?.url ?? null;\n  }\n\n  if (type === \"file\") {\n    const filePart = part as { mimeType?: string; url?: string; data?: string };\n    if (filePart.mimeType?.startsWith(\"image/\")) {\n      if (filePart.url) return filePart.url;\n      if (filePart.data) {\n        return `data:${filePart.mimeType};base64,${filePart.data}`;\n      }\n    }\n  }\n\n  return null;\n}\n\ntype FilePart = {\n  type: \"file\";\n  filename?: string;\n  name?: string;\n  fileName?: string;\n  size?: number;\n  mimeType?: string;\n  url?: string;\n};\n\nfunction getFileFromPart(part: unknown) {\n  if (!isRecord(part)) return null;\n  if (part.type !== \"file\") return null;\n  const filePart = part as FilePart;\n  const filename =\n    filePart.filename || filePart.name || filePart.fileName || \"Attachment\";\n  const isImage = filePart.mimeType?.startsWith(\"image/\") ?? false;\n  if (isImage) return null;\n  return {\n    filename,\n    size: filePart.size,\n  };\n}\n\nexport const UserMessage = memo(function UserMessage({\n  message,\n  className,\n  enableImagePreview = true,\n}: UserMessageProps) {\n  const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);\n  const textParts = message.parts?.filter(isTextPart) ?? [];\n  const text = textParts.map((p) => p.text).join(\"\");\n\n  const images: string[] = [];\n  const files: Array<{ filename: string; size?: number }> = [];\n  for (const part of message.parts ?? []) {\n    const imageUrl = getImageUrlFromPart(part);\n    if (imageUrl) images.push(imageUrl);\n    const file = getFileFromPart(part);\n    if (file) files.push(file);\n  }\n  if (isRecord(message) && Array.isArray(message.experimental_attachments)) {\n    for (const att of message.experimental_attachments as Array<{\n      contentType?: string;\n      url?: string;\n    }>) {\n      if (att.contentType?.startsWith(\"image/\") && att.url) {\n        images.push(att.url);\n      }\n    }\n  }\n\n  if (!text && images.length === 0 && files.length === 0) return null;\n\n  const lightboxImages = images.map((url, i) => ({\n    id: `${message.id}-img-${i}`,\n    url,\n    filename: `image-${i + 1}`,\n  }));\n\n  return (\n    <div className={cn(\"flex flex-col items-end gap-1\", className)}>\n      {images.length > 0 &&\n        images.map((url, i) => (\n          <div\n            key={i}\n            className={cn(\n              \"max-w-[200px] p-1.5 bg-an-foreground/4 rounded-an-message\",\n              enableImagePreview && \"cursor-pointer\",\n            )}\n            onClick={\n              enableImagePreview ? () => setLightboxIndex(i) : undefined\n            }\n          >\n            <img\n              src={url}\n              alt=\"attachment\"\n              className=\"block object-cover max-w-[184px] max-h-[120px] rounded-an-message-inner\"\n            />\n          </div>\n        ))}\n      {enableImagePreview && lightboxImages.length > 0 && (\n        <ImageLightbox\n          open={lightboxIndex !== null}\n          onClose={() => setLightboxIndex(null)}\n          images={lightboxImages}\n          initialIndex={lightboxIndex ?? 0}\n        />\n      )}\n      {files.length > 0 && (\n        <div className=\"flex flex-col items-end gap-2\">\n          {files.map((file, i) => (\n            <FileAttachment\n              key={`${file.filename}-${i}`}\n              id={`${file.filename}-${i}`}\n              filename={file.filename}\n              size={file.size}\n            />\n          ))}\n        </div>\n      )}\n      {text && (\n        <div className=\"max-w-[calc(95%-40px)] ms-[70px]\">\n          <div className=\"px-3.5 py-1.5 text-sm transition-colors rounded-an-message bg-an-user-message-bg text-an-user-message-text\">\n            <p className=\"leading-5 whitespace-pre-wrap wrap-break-word\">\n              {text}\n            </p>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n});\n",
      "type": "registry:ui",
      "target": "components/agent-elements/user-message.tsx"
    },
    {
      "path": "registry/agent-elements/utils/cn.ts",
      "content": "import { clsx, type ClassValue } from \"clsx\";\nimport { twMerge } from \"tailwind-merge\";\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n",
      "type": "registry:lib",
      "target": "components/agent-elements/utils/cn.ts"
    },
    {
      "path": "registry/agent-elements/components/image-lightbox.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useState } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport {\n  IconChevronLeft,\n  IconChevronRight,\n  IconX,\n} from \"@tabler/icons-react\";\nimport { cn } from \"./utils/cn\";\n\nexport type LightboxImage = {\n  /** Stable identifier — used for keys and to know which image is active. */\n  id: string;\n  /** Resolvable image URL (https / data: / blob:). */\n  url: string;\n  /** Optional filename used for the alt text. */\n  filename?: string;\n};\n\nexport type ImageLightboxProps = {\n  /** Whether the overlay is open. */\n  open: boolean;\n  /** Close handler — wired to overlay click, X button, and Esc key. */\n  onClose: () => void;\n  /** Full set of images for gallery navigation. */\n  images: LightboxImage[];\n  /** Index in `images` to start on. */\n  initialIndex?: number;\n};\n\n/**\n * Portal-based fullscreen image preview. Renders to `document.body` so it\n * escapes any clipping/transform/stacking context. Adapted from the\n * 21st-private-1 desktop chat — without copy/save (those are\n * desktop-API-specific).\n */\nexport function ImageLightbox({\n  open,\n  onClose,\n  images,\n  initialIndex = 0,\n}: ImageLightboxProps) {\n  const [currentIndex, setCurrentIndex] = useState(initialIndex);\n  const hasMultipleImages = images.length > 1;\n\n  // Sync the active index whenever the consumer re-opens with a new initial.\n  useEffect(() => {\n    if (open) setCurrentIndex(initialIndex);\n  }, [open, initialIndex]);\n\n  const goToPrevious = useCallback(\n    (event?: React.MouseEvent) => {\n      event?.stopPropagation();\n      setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1));\n    },\n    [images.length],\n  );\n\n  const goToNext = useCallback(\n    (event?: React.MouseEvent) => {\n      event?.stopPropagation();\n      setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0));\n    },\n    [images.length],\n  );\n\n  // Esc / arrow-key navigation. Capture phase so we beat any local handlers\n  // (e.g. an Editor that swallows Esc).\n  useEffect(() => {\n    if (!open) return;\n    const handleKeyDown = (event: KeyboardEvent) => {\n      switch (event.key) {\n        case \"Escape\":\n          event.preventDefault();\n          event.stopPropagation();\n          onClose();\n          break;\n        case \"ArrowLeft\":\n          if (hasMultipleImages) goToPrevious();\n          break;\n        case \"ArrowRight\":\n          if (hasMultipleImages) goToNext();\n          break;\n      }\n    };\n    window.addEventListener(\"keydown\", handleKeyDown, true);\n    return () =>\n      window.removeEventListener(\"keydown\", handleKeyDown, true);\n  }, [open, hasMultipleImages, onClose, goToPrevious, goToNext]);\n\n  // Lock body scroll while open so the page underneath doesn't move.\n  useEffect(() => {\n    if (!open) return;\n    const previousOverflow = document.body.style.overflow;\n    document.body.style.overflow = \"hidden\";\n    return () => {\n      document.body.style.overflow = previousOverflow;\n    };\n  }, [open]);\n\n  if (typeof document === \"undefined\") return null;\n  if (!open) return null;\n  const currentImage = images[currentIndex] ?? images[0];\n  if (!currentImage?.url) return null;\n\n  return createPortal(\n    <div\n      role=\"dialog\"\n      aria-modal=\"true\"\n      className=\"fixed inset-0 z-50 flex items-center justify-center bg-black/90 backdrop-blur-sm\"\n      onClick={onClose}\n    >\n      <button\n        type=\"button\"\n        onClick={onClose}\n        aria-label=\"Close fullscreen (Esc)\"\n        className=\"absolute top-4 right-4 z-10 inline-flex size-9 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors\"\n      >\n        <IconX className=\"size-5\" />\n      </button>\n\n      {hasMultipleImages && (\n        <button\n          type=\"button\"\n          onClick={goToPrevious}\n          aria-label=\"Previous image (←)\"\n          className=\"absolute left-4 top-1/2 z-10 inline-flex size-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors\"\n        >\n          <IconChevronLeft className=\"size-6\" />\n        </button>\n      )}\n\n      <img\n        src={currentImage.url}\n        alt={currentImage.filename ?? \"Image preview\"}\n        className=\"max-w-[90vw] max-h-[85vh] object-contain select-none\"\n        onClick={(event) => event.stopPropagation()}\n        draggable={false}\n      />\n\n      {hasMultipleImages && (\n        <button\n          type=\"button\"\n          onClick={goToNext}\n          aria-label=\"Next image (→)\"\n          className=\"absolute right-4 top-1/2 z-10 inline-flex size-10 -translate-y-1/2 items-center justify-center rounded-full bg-black/50 text-white hover:bg-black/70 transition-colors\"\n        >\n          <IconChevronRight className=\"size-6\" />\n        </button>\n      )}\n\n      {hasMultipleImages && (\n        <div className=\"absolute bottom-6 left-1/2 -translate-x-1/2 flex flex-col items-center gap-3\">\n          <div className=\"flex gap-2\">\n            {images.map((_, idx) => (\n              <button\n                key={idx}\n                type=\"button\"\n                onClick={(event) => {\n                  event.stopPropagation();\n                  setCurrentIndex(idx);\n                }}\n                aria-label={`Go to image ${idx + 1}`}\n                className={cn(\n                  \"size-2 rounded-full transition-all\",\n                  idx === currentIndex\n                    ? \"bg-white scale-125\"\n                    : \"bg-white/40 hover:bg-white/60\",\n                )}\n              />\n            ))}\n          </div>\n          <span className=\"text-white/70 text-sm\">\n            {currentIndex + 1} / {images.length}\n          </span>\n        </div>\n      )}\n    </div>,\n    document.body,\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/agent-elements/image-lightbox.tsx"
    },
    {
      "path": "registry/agent-elements/agent-ui.css",
      "content": "/* Agent Elements tokens + utilities */\n\n/*\n * AN Agent Chat - CSS Custom Properties\n * These are the default values. Override by redefining these vars in your app CSS.\n */\n:root {\n  /* Geometry — all radii derive from --an-border-radius */\n  --an-border-radius: 16px;\n  --an-message-border-radius: var(--an-border-radius);\n  --an-input-border-radius: var(--an-border-radius);\n  --an-tool-border-radius: 10px;\n  --an-message-radius-inner-offset: 4px;\n  --an-max-width: 420px;\n\n  /* Colors - Light mode defaults */\n  --an-background: #ffffff;\n  --an-background-secondary: #f0f0f0;\n  --an-background-tertiary: #f8f8f8;\n  --an-foreground: #1a1a1a;\n  --an-foreground-muted: #737373;\n  --an-foreground-subtle: #a3a3a3;\n  --an-border-color: #e4e4e7;\n  --an-primary-color: #3b82f6;\n\n  /* User Messages */\n  --an-user-message-bg: #f5f5f5;\n  --an-user-message-text: #1a1a1a;\n\n  /* Input */\n  --an-input-background: #ffffff;\n  --an-input-border-color: #e4e4e7;\n  --an-input-color: #1a1a1a;\n  --an-input-placeholder-color: #a3a3a3;\n  --an-input-focus-outline: transparent;\n  --an-context-padding: 10px;\n\n  /* Send/Stop Buttons */\n  --an-send-button-bg: #3b82f6;\n  --an-send-button-color: #ffffff;\n\n  /* Tools */\n  --an-tool-background: #f5f5f5;\n  --an-tool-border-color: #e4e4e7;\n  --an-tool-color: #1a1a1a;\n  --an-tool-color-muted: #737373;\n\n  /* Code */\n  --an-code-background: #1e1e1e;\n  --an-code-color: #d4d4d4;\n\n  /* Diff colors */\n  --an-diff-added-bg: rgba(34, 197, 94, 0.1);\n  --an-diff-added-border: rgba(34, 197, 94, 0.5);\n  --an-diff-added-text: #15803d;\n  --an-diff-removed-bg: rgba(239, 68, 68, 0.1);\n  --an-diff-removed-border: rgba(239, 68, 68, 0.5);\n  --an-diff-removed-text: #dc2626;\n}\n\n/* Dark mode defaults */\n.dark {\n  --an-background: #0a0a0a;\n  --an-background-secondary: #242424;\n  --an-background-tertiary: #141414;\n  --an-foreground: #fafafa;\n  --an-foreground-muted: #8c8c8c;\n  --an-foreground-subtle: #71717a;\n  --an-border-color: #2a2a2a;\n  --an-primary-color: #60a5fa;\n\n  --an-user-message-bg: #1a1a1a;\n  --an-user-message-text: #fafafa;\n\n  --an-input-background: #0a0a0a;\n  --an-input-border-color: #2a2a2a;\n  --an-input-color: #fafafa;\n  --an-input-placeholder-color: #71717a;\n  --an-input-focus-outline: transparent;\n  --an-context-padding: 12px;\n\n  --an-send-button-bg: #60a5fa;\n  /* Dark mode uses a lighter primary (#60a5fa) — pair it with black text so\n     labels like \"Approve\" / send-arrow have proper contrast instead of\n     low-contrast white-on-light-blue. */\n  --an-send-button-color: #0a0a0a;\n\n  --an-tool-background: #141414;\n  --an-tool-border-color: #2a2a2a;\n  --an-tool-color: #fafafa;\n  --an-tool-color-muted: #8c8c8c;\n\n  --an-code-background: #0a0a0a;\n  --an-code-color: #d4d4d4;\n\n  --an-diff-added-bg: rgba(34, 197, 94, 0.15);\n  --an-diff-added-border: rgba(34, 197, 94, 0.4);\n  --an-diff-added-text: #4ade80;\n  --an-diff-removed-bg: rgba(239, 68, 68, 0.15);\n  --an-diff-removed-border: rgba(239, 68, 68, 0.4);\n  --an-diff-removed-text: #f87171;\n}\n\n@theme inline {\n  --color-an-background: var(--an-background);\n  --color-an-background-secondary: var(--an-background-secondary);\n  --color-an-background-tertiary: var(--an-background-tertiary);\n  --color-an-foreground: var(--an-foreground);\n  --color-an-foreground-muted: var(--an-foreground-muted);\n  --color-an-foreground-subtle: var(--an-foreground-subtle);\n  --color-an-border-color: var(--an-border-color);\n  --color-an-primary-color: var(--an-primary-color);\n  --color-an-user-message-bg: var(--an-user-message-bg);\n  --color-an-user-message-text: var(--an-user-message-text);\n  --color-an-input-background: var(--an-input-background);\n  --color-an-input-border-color: var(--an-input-border-color);\n  --color-an-input-color: var(--an-input-color);\n  --color-an-input-placeholder-color: var(--an-input-placeholder-color);\n  --color-an-input-focus-outline: var(--an-input-focus-outline);\n  --color-an-send-button-bg: var(--an-send-button-bg);\n  --color-an-send-button-color: var(--an-send-button-color);\n  --color-an-tool-background: var(--an-tool-background);\n  --color-an-tool-border-color: var(--an-tool-border-color);\n  --color-an-tool-color: var(--an-tool-color);\n  --color-an-tool-color-muted: var(--an-tool-color-muted);\n  --color-an-code-background: var(--an-code-background);\n  --color-an-code-color: var(--an-code-color);\n  --color-an-diff-added-bg: var(--an-diff-added-bg);\n  --color-an-diff-added-border: var(--an-diff-added-border);\n  --color-an-diff-added-text: var(--an-diff-added-text);\n  --color-an-diff-removed-bg: var(--an-diff-removed-bg);\n  --color-an-diff-removed-border: var(--an-diff-removed-border);\n  --color-an-diff-removed-text: var(--an-diff-removed-text);\n  --radius-an-message: var(--an-message-border-radius);\n  --radius-an-message-inner: calc(\n    var(--an-message-border-radius) - var(--an-message-radius-inner-offset)\n  );\n  --radius-an-input-border-radius: var(--an-input-border-radius);\n  --radius-an-tool-border-radius: var(--an-tool-border-radius);\n  --spacing-an-context-padding: var(--an-context-padding);\n  --max-width-an: var(--an-max-width);\n  --spacing-an-user-message-x: 14px;\n  --spacing-an-user-message-y: 10px;\n}\n\n/* TextShimmer animation */\n@keyframes an-shimmer {\n  from {\n    background-position: 100% center;\n  }\n  to {\n    background-position: 0% center;\n  }\n}\n\n@keyframes an-blink {\n  50% {\n    opacity: 0;\n  }\n}\n\n@keyframes loading-dots {\n  0%,\n  100% {\n    opacity: 0;\n  }\n  50% {\n    opacity: 1;\n  }\n}\n\n@keyframes an-ellipsis {\n  0% {\n    width: 0;\n  }\n  33% {\n    width: 0.33em;\n  }\n  66% {\n    width: 0.66em;\n  }\n  100% {\n    width: 1em;\n  }\n}\n\n.an-text-shimmer {\n  display: inline-block;\n  background-size: 250% 100%;\n  background-clip: text;\n  -webkit-background-clip: text;\n  color: transparent;\n  background-image: linear-gradient(\n    90deg,\n    var(--an-foreground-subtle, #a3a3a3) 0%,\n    var(--an-foreground-subtle, #a3a3a3) 40%,\n    var(--an-foreground-muted, #737373) 50%,\n    var(--an-foreground-subtle, #a3a3a3) 60%,\n    var(--an-foreground-subtle, #a3a3a3) 100%\n  );\n  background-repeat: no-repeat;\n}\n\n.an-text-shimmer--active {\n  animation: an-shimmer var(--an-shimmer-duration, 2s) linear infinite;\n}\n\n.an-ellipsis {\n  display: inline-block;\n  overflow: hidden;\n  width: 0;\n  vertical-align: bottom;\n  animation: an-ellipsis 1.2s steps(1, end) infinite;\n}\n\n.an-markdown pre code {\n  counter-reset: none !important;\n}\n\n.an-markdown pre code > span::before {\n  content: none !important;\n  display: none !important;\n}\n\n.an-markdown pre code > span {\n  padding-left: 0 !important;\n}\n\n/* Diff */\n.an-edit-diff,\n.an-edit-diff pre,\n.an-edit-diff code {\n  font-size: 12px;\n}\n\n.dark .an-edit-tool-card {\n  background-color: #000;\n}\n\n[data-theme=\"dark\"] .an-edit-tool-card {\n  background-color: #000;\n}\n\n.dark .an-edit-tool-card .an-edit-diff,\n.dark .an-edit-tool-card .an-edit-diff pre,\n.dark .an-edit-tool-card .an-edit-diff code {\n  background-color: #000 !important;\n}\n\n[data-theme=\"dark\"] .an-edit-tool-card .an-edit-diff,\n[data-theme=\"dark\"] .an-edit-tool-card .an-edit-diff pre,\n[data-theme=\"dark\"] .an-edit-tool-card .an-edit-diff code {\n  background-color: #000 !important;\n}\n\n/* Markdown Code Block */\n.an-markdown [data-streamdown=\"code-block\"] {\n  padding: 0;\n  border: 1px solid var(--an-border-color);\n  border-radius: var(--an-tool-border-radius);\n  background: transparent;\n  gap: 0;\n}\n\n.an-markdown [data-streamdown=\"code-block-header\"] {\n  padding: 0px 10px 0px 8px;\n  height: auto;\n  font-size: 12px;\n  height: 28px;\n  background: transparent;\n  border-bottom: 1px solid var(--color-an-tool-border-color);\n  background-color: var(--an-tool-background);\n  position: relative;\n}\n\n.an-markdown div:has(> [data-streamdown=\"code-block-actions\"]) {\n  position: absolute;\n  height: auto;\n  margin: 0;\n  top: 0;\n  right: 0;\n  height: 28px;\n  padding-right: 8px;\n}\n\n.an-markdown [data-streamdown=\"code-block-actions\"] {\n  background: transparent;\n  border: none;\n  backdrop-filter: none;\n  padding: 0;\n}\n\n.an-markdown [data-streamdown=\"code-block-actions\"] button > svg {\n  width: 14px;\n  height: 14px;\n}\n\n.an-markdown [data-streamdown=\"code-block-body\"] {\n  margin: 0;\n  padding-top: 8px;\n  padding-bottom: 8px;\n  background: transparent;\n  border: transparent;\n  overflow-x: auto;\n}\n\n.an-markdown [data-streamdown=\"code-block\"] pre,\n.an-markdown [data-streamdown=\"code-block\"] code {\n  font-size: 12px;\n  background: transparent;\n}\n",
      "type": "registry:style",
      "target": "components/agent-elements/agent-ui.css"
    }
  ],
  "css": {
    "@import \"../components/agent-elements/agent-ui.css\"": ""
  }
}