{"version":3,"file":"chat-message.cjs","names":[],"sources":["../src/content/chat-message/chat-message.ts"],"sourcesContent":["import { define, html, onEvent, onMounted, prop, ref, useEmit, useSlots } from '@vielzeug/ore';\nimport { computed, watch } from '@vielzeug/ripple';\n\nimport { announce } from '../../core';\nimport { reducedMotionMixin } from '../../styles';\nimport '../icon/icon';\nimport componentStyles from './chat-message.css?inline';\n\nexport type ChatMessageSender = 'assistant' | 'system' | 'user';\nexport type ChatMessageStatus = 'error' | 'sending' | 'sent';\n\nconst SENDER_LABELS: Record<ChatMessageSender, string> = {\n  assistant: 'Assistant',\n  system: 'System',\n  user: 'You',\n};\n\n/** Events emitted by the chat-message component */\nexport type OreChatMessageEvents = {\n  /** Fired when the retry action is activated on a `status=\"error\"` message */\n  retry: { originalEvent?: Event };\n};\n\n/** Chat message component properties */\nexport type OreChatMessageProps = {\n  /** Error detail shown beneath the bubble; only visible when `status=\"error\"` */\n  error?: string;\n  /** Display name shown above the bubble (falls back to a generic label per `sender`) */\n  name?: string;\n  /** Who sent the message — controls alignment and bubble styling */\n  sender?: ChatMessageSender;\n  /** Delivery status for outgoing messages — shows an inline indicator and, for `\"error\"`, a retry action */\n  status?: ChatMessageStatus;\n  /** Append a blinking cursor after the content, for a message still streaming in */\n  streaming?: boolean;\n  /** ISO 8601 timestamp; rendered as a localized short time in a semantic `<time>` element */\n  timestamp?: string;\n};\n\n/**\n * A single message bubble for chat/conversation UIs — sender-aware alignment, an optional\n * avatar slot, delivery status (sending/sent/error with retry), and a streaming cursor for\n * assistant messages still generating. Content is provided via the default slot, so any\n * markdown-to-HTML rendering stays the consumer's choice.\n *\n * The default slot's leading/trailing whitespace-only text nodes are trimmed in place once\n * per slot assignment (mount, and again on `slotchange`) — pretty-printed HTML's\n * indentation would otherwise render as blank lines, since the bubble preserves line breaks\n * (`white-space: pre-wrap`) for genuine multi-paragraph replies. This mutates those specific\n * text nodes' `textContent` directly; it never touches nodes appended afterward (e.g. by\n * `el.textContent += token` while streaming), only the ones present at assignment time.\n *\n * @element ore-chat-message\n *\n * @attr {string} sender - Who sent the message: 'user' | 'assistant' | 'system' (default 'assistant')\n * @attr {string} name - Display name shown above the bubble\n * @attr {string} timestamp - ISO 8601 timestamp, rendered as a localized short time\n * @attr {string} status - Delivery status: 'sending' | 'sent' | 'error'\n * @attr {string} error - Error detail shown beneath the bubble when status=\"error\"\n * @attr {boolean} streaming - Append a blinking cursor after the content\n *\n * @fires retry - Fired when the retry action is activated. detail: { originalEvent }\n *\n * @slot - Message content\n * @slot avatar - Avatar element (e.g. `<ore-avatar>`)\n * @slot actions - Action buttons shown beneath the message (copy, regenerate, feedback, …)\n *\n * @cssprop --chat-message-bg - Bubble background color (assistant/system)\n * @cssprop --chat-message-color - Bubble text color (assistant/system)\n * @cssprop --chat-message-user-bg - Bubble background color (user)\n * @cssprop --chat-message-user-color - Bubble text color (user)\n * @cssprop --chat-message-radius - Bubble border radius\n * @cssprop --chat-message-max-width - Maximum bubble width\n *\n * @part row - Root row container (avatar + column)\n * @part avatar - Avatar slot container\n * @part column - Column containing name, bubble, meta, and actions\n * @part name - Sender display name\n * @part bubble - Message bubble container\n * @part content - Content container inside the bubble\n * @part cursor - Blinking streaming cursor\n * @part meta - Row of timestamp, status, and error text below the bubble\n * @part timestamp - Timestamp `<time>` element\n * @part status - Status indicator\n * @part error - Error text\n * @part retry - Retry button (status=\"error\" only)\n * @part actions - Actions slot container\n *\n * @example\n * ```html\n * <ore-chat-message sender=\"user\" timestamp=\"2024-01-01T12:00:00Z\" status=\"sent\">\n *   What's the weather like today?\n * </ore-chat-message>\n *\n * <ore-chat-message sender=\"assistant\" name=\"Assistant\" streaming>\n *   Let me check that for you\n * </ore-chat-message>\n * ```\n */\nexport const CHAT_MESSAGE_TAG = 'ore-chat-message' as const;\ndefine<OreChatMessageProps>(CHAT_MESSAGE_TAG, {\n  props: {\n    error: prop.string(),\n    name: prop.string(),\n    sender: prop.oneOf(['user', 'assistant', 'system'] as const, 'assistant'),\n    status: prop.string<ChatMessageStatus>(),\n    streaming: prop.bool(false),\n    timestamp: prop.string(),\n  },\n  setup(props) {\n    const emit = useEmit<OreChatMessageEvents>();\n    const slots = useSlots();\n\n    const senderLabel = () => SENDER_LABELS[props.sender.value ?? 'assistant'];\n\n    const bubbleLabel = computed(() => `Message from ${props.name.value || senderLabel()}`);\n\n    const formattedTime = computed(() => {\n      const raw = props.timestamp.value;\n\n      if (!raw) return '';\n\n      const date = new Date(raw);\n\n      if (Number.isNaN(date.getTime())) return '';\n\n      return new Intl.DateTimeFormat(undefined, { hour: 'numeric', minute: '2-digit' }).format(date);\n    });\n\n    const hasMeta = computed(\n      () => Boolean(formattedTime.value) || Boolean(props.status.value) || Boolean(props.error.value),\n    );\n\n    // Announce delivery failures — the visible retry button and error text are always present\n    // for sighted users, but a failed send is exactly the kind of state change a screen\n    // reader user could otherwise miss entirely. Keyed on `error` text too (not just\n    // `status`) so a retry that fails again with a *different* reason re-announces even\n    // though `status` never left `\"error\"` in between; a second failure with the identical\n    // message doesn't re-fire, since the source value hasn't actually changed.\n    watch(\n      () => (props.status.value === 'error' ? (props.error.value ?? '') : null),\n      (errorText) => {\n        if (errorText !== null) {\n          announce(`Message failed to send${errorText ? `: ${errorText}` : ''}`, { politeness: 'assertive' });\n        }\n      },\n    );\n\n    function handleRetry(e: Event): void {\n      emit('retry', { originalEvent: e });\n    }\n\n    // ── Trim author-time indentation whitespace from the default slot ──\n    // `.content` uses `white-space: pre-wrap` so genuine multi-paragraph replies keep their\n    // line breaks — but that also preserves the leading/trailing newline + indentation from\n    // pretty-printed HTML (`<ore-chat-message>\\n  Hello\\n</ore-chat-message>`), rendering as\n    // visible blank lines around the text. Trim only the outermost edges once per slot\n    // assignment; appending tokens to an existing text node for streaming doesn't re-fire\n    // `slotchange`, so this never interferes with in-progress streaming updates.\n    //\n    // A named-slotted sibling (e.g. `<ore-avatar slot=\"avatar\">` between the opening tag and\n    // the message text) splits the default slot's light-DOM text into *multiple* text nodes\n    // — text nodes can't target a named slot, so each run on either side of the element is\n    // assigned separately. The \"real\" content can start on a later node than index 0 (it's\n    // still preceded by its own leading indentation), so this walks inward from each end,\n    // fully clearing whitespace-only nodes and stopping at the first node with real content\n    // on each side — rather than only touching the very first/last assigned node.\n    const contentSlotRef = ref<HTMLSlotElement>();\n\n    function trimSlotEdgeWhitespace(): void {\n      const nodes = contentSlotRef.value?.assignedNodes({ flatten: true });\n\n      if (!nodes || nodes.length === 0) return;\n\n      for (const node of nodes) {\n        if (node.nodeType !== Node.TEXT_NODE) break;\n\n        const trimmed = (node.textContent ?? '').replace(/^\\s+/, '');\n\n        node.textContent = trimmed;\n\n        if (trimmed !== '') break;\n      }\n\n      for (let i = nodes.length - 1; i >= 0; i--) {\n        const node = nodes[i];\n\n        if (node.nodeType !== Node.TEXT_NODE) break;\n\n        const trimmed = (node.textContent ?? '').replace(/\\s+$/, '');\n\n        node.textContent = trimmed;\n\n        if (trimmed !== '') break;\n      }\n    }\n\n    onMounted(() => {\n      trimSlotEdgeWhitespace();\n\n      const slotEl = contentSlotRef.value;\n\n      if (slotEl) onEvent(slotEl, 'slotchange', trimSlotEdgeWhitespace);\n    });\n\n    return html`\n      <div class=\"row\" part=\"row\">\n        <span class=\"avatar\" part=\"avatar\" ?hidden=\"${() => !slots.has('avatar').value}\">\n          <slot name=\"avatar\"></slot>\n        </span>\n        <div class=\"column\" part=\"column\">\n          <span class=\"name\" part=\"name\" ?hidden=\"${() => !props.name.value}\">${props.name}</span>\n          <div class=\"bubble\" part=\"bubble\" role=\"article\" aria-label=\"${bubbleLabel}\">\n            <div class=\"content\" part=\"content\">\n              <slot class=\"content-slot\" ref=\"${contentSlotRef}\"></slot>\n              <span class=\"cursor\" part=\"cursor\" aria-hidden=\"true\" ?hidden=\"${() => !props.streaming.value}\"></span>\n            </div>\n          </div>\n          <div class=\"meta\" part=\"meta\" ?hidden=\"${() => !hasMeta.value}\">\n            <time\n              class=\"timestamp\"\n              part=\"timestamp\"\n              datetime=\"${props.timestamp}\"\n              ?hidden=\"${() => !formattedTime.value}\">\n              ${formattedTime}\n            </time>\n            <span class=\"status\" part=\"status\" data-status=\"${props.status}\" ?hidden=\"${() => !props.status.value}\">\n              ${() => {\n                switch (props.status.value) {\n                  case 'error':\n                    return html`\n                      <ore-icon name=\"alert-circle\" size=\"12\" stroke-width=\"2.5\" aria-hidden=\"true\"></ore-icon>\n                    `;\n                  case 'sending':\n                    return html`\n                      <span class=\"spinner\" aria-hidden=\"true\"></span>\n                    `;\n                  case 'sent':\n                    return html`\n                      <ore-icon name=\"check\" size=\"12\" stroke-width=\"2.5\" aria-hidden=\"true\"></ore-icon>\n                    `;\n                  default:\n                    return '';\n                }\n              }}\n            </span>\n            <span class=\"error-text\" part=\"error\" role=\"alert\" ?hidden=\"${() => !props.error.value}\">\n              ${props.error}\n            </span>\n            <button\n              class=\"retry\"\n              part=\"retry\"\n              type=\"button\"\n              ?hidden=\"${() => props.status.value !== 'error'}\"\n              @click=\"${handleRetry}\">\n              Retry\n            </button>\n          </div>\n          <div class=\"actions\" part=\"actions\" ?hidden=\"${() => !slots.has('actions').value}\">\n            <slot name=\"actions\"></slot>\n          </div>\n        </div>\n      </div>\n    `;\n  },\n  styles: [reducedMotionMixin, componentStyles],\n});\n"],"mappings":"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"}