feat(chat): export UserMessage and let a sub-type restyle the bubble

What does this MR do?

Follow-up to !695 (merged), and targets that branch rather than main so the diff stays scoped. Retarget to main once !695 (merged) merges.

A user turn is currently rendered exactly one way. A sub-type that needs to label itself — a goal, say, which shows a badge above the prompt — has no way to do that without rebuilding the component.

UserMessage is now exported, alongside AgentMessage and the other message types, so a host can wrap it in its own renderer and contribute that through messageRenderers.

It gains two slots, both rendered inside the bubble:

  • header — for labelling, above the content. Everything else stays with the component: the markdown pipeline, the bubble styling, the attribution label and any context references. A caller adds a badge and reasons about nothing else.
  • content — replaces the body, defaulting to the built-in renderer. It exposes content, isHtml, isDoneStreaming, trustedUrls and message.

To make header possible the bubble classes move off MarkdownRenderer onto a wrapper element. That also makes exporting the renderer worthwhile: it now carries no styling of its own, so DuoChatMarkdownRenderer is exported for callers who replace the body but still want the chat's markdown pipeline rather than a reimplementation of it.

Usage

Labelling only — the common case:

<user-message :message="message">
  <template #header>
    <gl-badge icon="issue-type-objective" class="gl-mb-3">Goal</gl-badge>
  </template>
</user-message>

Presenting the body differently, reusing the exported renderer:

<user-message :message="message">
  <template #header>
    <gl-badge icon="issue-type-objective" class="gl-mb-3">Goal</gl-badge>
  </template>
  <template #content="{ content, isHtml, trustedUrls, isDoneStreaming }">
    <duo-chat-markdown-renderer
      :markdown="content"
      :is-html="isHtml"
      :trusted-urls="trustedUrls"
      :smooth="!isDoneStreaming"
    />
    <p class="gl-mb-0 gl-mt-3 gl-text-sm gl-text-subtle">Tracking until merged</p>
  </template>
</user-message>
// contributed to AgenticDuoChat via `messageRenderers`
{ matchMessage: (m) => m.message_sub_type === 'goal', component: GoalMessage }

Screenshots or screen recordings

New chat/user-prompt-message stories, rendered from a running Storybook.

Default — the standard right-aligned bubble.

WithCustomSubType — the header slot in use: a Goal badge above the prompt, in the same bubble. The design this reproduces is dark mode, where that bubble reads grey; the width comes from the prompt being long, not from any restyling.

WithCustomContent — both slots together: the badge, the exported renderer, and a caller-supplied footer beneath it.

The default rendering is byte-identical before and after moving the bubble classes — same MD5 on a Playwright capture of the Default story, so the wrapper introduces no visual change.

Testing

  • Nine new cases in message_user_prompt.spec.js covering both slots: each defaults correctly when absent, header renders above the content, content replaces the renderer and sits below the header, the exposed slot props are correct, and the label and context references still render around both.
  • The bubble-styling assertion now targets the wrapper and asserts the renderer no longer carries it.
  • 2483 tests pass on both Vue 2 and Vue 3.
  • The three new stories need visual baselines generated by the update_screenshots job on first run. No existing snapshot should change.

Integration merge requests

Additive only — two new exports and two new slots, both with defaults. No existing behaviour changes.

🤖 Generated with Claude Code

Edited by Enrique Alcántara

Merge request reports

Loading
Loading