commit be98a8d8835c6dd5dad781550c70f87f8b8920b7
parent c0c9e809c5fc717a5517b04a1677f76eeb471fd9
Author: Nick Grato <ngrato@gmail.com>
Date: Thu, 8 Jan 2026 08:23:49 +0000
Bug 2001504 - Chat Assistant markdown rendering r=Mardak,ai-frontend-reviewers,Gijs
Hooking up the new prosemirror lib to turn the model respons from markdown to html in the ai window chat bubbles.
Differential Revision: https://phabricator.services.mozilla.com/D277939
Diffstat:
3 files changed, 156 insertions(+), 42 deletions(-)
diff --git a/browser/components/aiwindow/ui/components/ai-chat-message/ai-chat-message.mjs b/browser/components/aiwindow/ui/components/ai-chat-message/ai-chat-message.mjs
@@ -2,8 +2,14 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
-import { html } from "chrome://global/content/vendor/lit.all.mjs";
+import { html, unsafeHTML } from "chrome://global/content/vendor/lit.all.mjs";
import { MozLitElement } from "chrome://global/content/lit-utils.mjs";
+import {
+ defaultMarkdownParser,
+ DOMSerializer,
+} from "chrome://browser/content/multilineeditor/prosemirror.bundle.mjs";
+
+const SERIALIZER = DOMSerializer.fromSchema(defaultMarkdownParser.schema);
/**
* A custom element for managing AI Chat Content
@@ -26,6 +32,31 @@ export class AIChatMessage extends MozLitElement {
super.connectedCallback();
}
+ /**
+ * Parse markdown content to HTML using ProseMirror
+ *
+ * @param {string} markdown
+ * @returns {string} HTML string
+ */
+ parseMarkdown(markdown) {
+ if (!markdown) {
+ return "";
+ }
+
+ const node = defaultMarkdownParser.parse(markdown);
+ const fragment = SERIALIZER.serializeFragment(node.content);
+
+ // Convert DocumentFragment to HTML string
+ const container = this.ownerDocument.createElement("div");
+ container.appendChild(fragment);
+ const containerString = container.innerHTML;
+
+ // Sanitize the HTML string before returning useing "setHTML"
+ const sanitizedContainer = this.ownerDocument.createElement("div");
+ sanitizedContainer.setHTML(containerString);
+ return sanitizedContainer.innerHTML;
+ }
+
render() {
return html`
<link
@@ -33,12 +64,18 @@ export class AIChatMessage extends MozLitElement {
href="chrome://browser/content/aiwindow/components/ai-chat-message.css"
/>
- <article>
- <div class=${"message-" + this.role}>
- <!-- TODO: Add markdown parsing here -->
- ${this.message}
- </div>
- </article>
+ ${this.role === "user"
+ ? html`<article>
+ <div class=${"message-" + this.role}>
+ <!-- TODO: Parse user prompt to add any mentions pills -->
+ ${this.message}
+ </div>
+ </article>`
+ : html`<article>
+ <div class=${"message-" + this.role}>
+ ${unsafeHTML(this.parseMarkdown(this.message))}
+ </div>
+ </article>`}
`;
}
}
diff --git a/browser/components/aiwindow/ui/components/ai-chat-message/ai-chat-message.stories.mjs b/browser/components/aiwindow/ui/components/ai-chat-message/ai-chat-message.stories.mjs
@@ -20,7 +20,7 @@ export default {
};
const Template = ({ role, content }) => html`
- <ai-chat-message .message=${{ role, content }}></ai-chat-message>
+ <ai-chat-message .role=${role} .message=${content}></ai-chat-message>
`;
export const UserMessage = Template.bind({});
@@ -35,3 +35,10 @@ AssistantMessage.args = {
content:
"Test: I don't have access to real-time weather data, but I can help you with other tasks!",
};
+
+export const AssistantMessageWithMarkdown = Template.bind({});
+AssistantMessageWithMarkdown.args = {
+ role: "assistant",
+ content:
+ "Here's some **bold text** and *italic text*:\n\n- Item 1\n- Item 2\n\n```javascript\nconsole.log('code block');\n```",
+};
diff --git a/browser/components/aiwindow/ui/test/browser/browser_aichat_message.js b/browser/components/aiwindow/ui/test/browser/browser_aichat_message.js
@@ -4,57 +4,127 @@
"use strict";
/**
- * Test ai-chat-message custom element basic rendering
+ * Basic rendering + markdown/sanitization test for <ai-chat-message>.
+ *
+ * Avoids Lit's updateComplete because MozLitElement variants may not expose it
+ * or it may never resolve in this harness.
*/
add_task(async function test_ai_chat_message_rendering() {
await SpecialPowers.pushPrefEnv({
set: [["browser.aiwindow.enabled", true]],
});
- // Use about:aichatcontent which already loads the components properly
await BrowserTestUtils.withNewTab("about:aichatcontent", async browser => {
await SpecialPowers.spawn(browser, [], async () => {
await content.customElements.whenDefined("ai-chat-message");
- // Create a test ai-chat-message element
- const messageElement = content.document.createElement("ai-chat-message");
- content.document.body.appendChild(messageElement);
+ const doc = content.document;
- Assert.ok(messageElement, "ai-chat-message element should be created");
+ const el = doc.createElement("ai-chat-message");
+ doc.body.appendChild(el);
- // Test setting a user message
- messageElement.message = { role: "user", content: "Test user message" };
- await messageElement.updateComplete;
+ Assert.ok(el, "ai-chat-message element should be created");
- const messageDiv =
- messageElement.renderRoot?.querySelector(".message-user");
- if (messageDiv) {
- Assert.ok(messageDiv, "User message div should be rendered");
- Assert.ok(
- messageDiv.textContent.includes("Test user message"),
- "User message content should be present"
- );
+ function root() {
+ return el.shadowRoot ?? el;
}
- // Test setting an assistant message
- messageElement.message = {
- role: "assistant",
- content: "Test AI response",
- };
- await messageElement.updateComplete;
-
- const assistantDiv =
- messageElement.renderRoot?.querySelector(".message-assistant");
- if (assistantDiv) {
- Assert.ok(assistantDiv, "Assistant message div should be rendered");
- Assert.ok(
- assistantDiv.textContent.includes("Test AI response"),
- "Assistant message content should be present"
- );
+ function setRoleAndMessage(role, message) {
+ // Set both property + attribute to avoid any reflection differences.
+ el.role = role;
+ el.setAttribute("role", role);
+
+ el.message = message;
+ el.setAttribute("message", message);
+ }
+
+ async function waitFor(fn, msg) {
+ for (let i = 0; i < 120; i++) {
+ try {
+ if (fn()) {
+ return;
+ }
+ } catch (e) {
+ // Keep looping; DOM may not be ready yet.
+ }
+ await new content.Promise(resolve =>
+ content.requestAnimationFrame(resolve)
+ );
+ }
+ Assert.ok(false, `Timed out: ${msg}`);
}
- // Clean up
- messageElement.remove();
+ // --- User message ---
+ setRoleAndMessage("user", "Test user message");
+
+ await waitFor(() => {
+ const div = root().querySelector(".message-user");
+ return div && div.textContent.includes("Test user message");
+ }, "User message should render with expected text");
+
+ const userDiv = root().querySelector(".message-user");
+ Assert.ok(userDiv, "User message div should exist");
+ Assert.ok(
+ userDiv.textContent.includes("Test user message"),
+ `User message content should be present (got: "${userDiv.textContent}")`
+ );
+
+ // --- Assistant message ---
+ setRoleAndMessage("assistant", "Test AI response");
+
+ await waitFor(() => {
+ const div = root().querySelector(".message-assistant");
+ return div && div.textContent.includes("Test AI response");
+ }, "Assistant message should render with expected text");
+
+ let assistantDiv = root().querySelector(".message-assistant");
+ Assert.ok(assistantDiv, "Assistant message div should exist");
+ Assert.ok(
+ assistantDiv.textContent.includes("Test AI response"),
+ `Assistant message content should be present (got: "${assistantDiv.textContent}")`
+ );
+
+ // --- Markdown parsing (positive) ---
+ setRoleAndMessage("assistant", "**Bold** and *italic* text");
+
+ await waitFor(() => {
+ const div = root().querySelector(".message-assistant");
+ return div && div.querySelector("strong") && div.querySelector("em");
+ }, "Markdown should produce <strong> and <em>");
+
+ assistantDiv = root().querySelector(".message-assistant");
+ Assert.ok(
+ assistantDiv.querySelector("strong"),
+ `Expected <strong> in: ${assistantDiv.innerHTML}`
+ );
+ Assert.ok(
+ assistantDiv.querySelector("em"),
+ `Expected <em> in: ${assistantDiv.innerHTML}`
+ );
+
+ // --- Negative: raw HTML should not become markup ---
+ setRoleAndMessage("assistant", "<b>not bolded</b>");
+
+ await waitFor(() => {
+ const div = root().querySelector(".message-assistant");
+ return (
+ div &&
+ !div.querySelector("b") &&
+ div.textContent.includes("not bolded")
+ );
+ }, "Raw HTML should not become a <b> element, but text should remain");
+
+ assistantDiv = root().querySelector(".message-assistant");
+ Assert.ok(
+ !assistantDiv.querySelector("b"),
+ `Should not contain real <b>: ${assistantDiv.innerHTML}`
+ );
+ Assert.ok(
+ assistantDiv.textContent.includes("not bolded"),
+ `Raw HTML content should still be visible as text (got: "${assistantDiv.textContent}")`
+ );
+
+ el.remove();
});
});