commit 2423564a12f4c173a834c35df91115fcfb963830
parent 1c47c07babba0438f1fcaa992fa530353a6c6c8a
Author: Nick Grato <ngrato@gmail.com>
Date: Fri, 9 Jan 2026 00:55:21 +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, 219 insertions(+), 53 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);
// eslint-disable-next-line import/no-unassigned-import
import "chrome://browser/content/aiwindow/components/ai-chat-search-button.mjs";
@@ -47,6 +53,31 @@ export class AIChatMessage extends MozLitElement {
this.dispatchEvent(e);
}
+ /**
+ * 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
@@ -54,17 +85,23 @@ 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>
- <!-- TODO: update props based on assistant response -->
- <ai-chat-search-button
- query="Ada Lovelace"
- label="Ada Lovelace"
- ></ai-chat-search-button>
- </article>
+ ${this.role === "user"
+ ? html`<article>
+ <div class=${"message-" + this.role}>
+ <!-- TODO: Parse user prompt to add any mentions pills -->
+ ${this.message}
+ </div>
+ <!-- TODO: update props based on assistant response -->
+ <ai-chat-search-button
+ query="Ada Lovelace"
+ label="Ada Lovelace"
+ ></ai-chat-search-button>
+ </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,59 +4,181 @@
"use strict";
/**
- * Test ai-chat-message custom element basic rendering
+ * Basic rendering + markdown/sanitization test for <ai-chat-message>.
+ *
+ * Notes:
+ * - Uses a content-side readiness gate (readyState polling) instead of
+ * BrowserTestUtils.browserLoaded to avoid missing the load event.
+ * - 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");
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:aichatcontent"
+ );
+ const browser = tab.linkedBrowser;
- // Create a test ai-chat-message element
- const messageElement = content.document.createElement("ai-chat-message");
- content.document.body.appendChild(messageElement);
+ try {
+ // Content-side readiness gate (more reliable than waiting for load events).
+ await ContentTask.spawn(browser, null, async () => {
+ function sleep(ms) {
+ return new content.Promise(resolve => content.setTimeout(resolve, ms));
+ }
- Assert.ok(messageElement, "ai-chat-message element should be created");
+ for (let i = 0; i < 200; i++) {
+ if (content.document.readyState === "complete") {
+ return;
+ }
+ await sleep(0);
+ }
- // Test setting a user message
- messageElement.message = { role: "user", content: "Test user message" };
- await messageElement.updateComplete;
+ throw new Error(
+ `Content never reached readyState=complete (got: ${content.document.readyState})`
+ );
+ });
- 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"
- );
+ await ContentTask.spawn(browser, null, async () => {
+ const doc = content.document;
+
+ function sleep(ms) {
+ return new content.Promise(resolve => content.setTimeout(resolve, ms));
}
- // 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"
- );
+ async function withTimeout(promise, ms, label) {
+ return content.Promise.race([
+ promise,
+ new content.Promise((_, reject) =>
+ content.setTimeout(
+ () => reject(new Error(`Timeout (${ms}ms): ${label}`)),
+ ms
+ )
+ ),
+ ]);
}
- // Clean up
- messageElement.remove();
- });
- });
+ async function waitFor(fn, msg, maxTicks = 200) {
+ for (let i = 0; i < maxTicks; i++) {
+ try {
+ if (fn()) {
+ return;
+ }
+ } catch (_) {
+ // Keep looping; DOM may not be ready yet.
+ }
+ await sleep(0);
+ }
+ throw new Error(`Timed out waiting: ${msg}`);
+ }
+
+ function root(el) {
+ return el.shadowRoot ?? el;
+ }
+
+ function setRoleAndMessage(el, 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);
+ }
+
+ // Ensure the custom element is registered. If the module failed to load,
+ // this will fail fast instead of hanging until harness teardown.
+ await withTimeout(
+ content.customElements.whenDefined("ai-chat-message"),
+ 5000,
+ "customElements.whenDefined('ai-chat-message')"
+ );
+
+ const el = doc.createElement("ai-chat-message");
+ doc.body.appendChild(el);
- await SpecialPowers.popPrefEnv();
+ Assert.ok(el, "ai-chat-message element should be created");
+
+ // --- User message ---
+ setRoleAndMessage(el, "user", "Test user message");
+
+ await waitFor(() => {
+ const div = root(el).querySelector(".message-user");
+ return div && div.textContent.includes("Test user message");
+ }, "User message should render with expected text");
+
+ const userDiv = root(el).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(el, "assistant", "Test AI response");
+
+ await waitFor(() => {
+ const div = root(el).querySelector(".message-assistant");
+ return div && div.textContent.includes("Test AI response");
+ }, "Assistant message should render with expected text");
+
+ let assistantDiv = root(el).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) ---
+ // Verifies that markdown like "**Bold** and *italic*" becomes markup
+ // (<strong> and <em> elements) rather than literal asterisks.
+ setRoleAndMessage(el, "assistant", "**Bold** and *italic* text");
+
+ await waitFor(() => {
+ const div = root(el).querySelector(".message-assistant");
+ return div && div.querySelector("strong") && div.querySelector("em");
+ }, "Markdown should produce <strong> and <em>");
+
+ assistantDiv = root(el).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 ---
+ // Verifies sanitization / safe rendering: raw HTML should not be
+ // interpreted as elements, but should remain visible as text.
+ setRoleAndMessage(el, "assistant", "<b>not bolded</b>");
+
+ await waitFor(() => {
+ const div = root(el).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(el).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();
+ });
+ } finally {
+ BrowserTestUtils.removeTab(tab);
+ await SpecialPowers.popPrefEnv();
+ }
});