commit 6066f300044b1dabdb17c66f2396d709eb526233
parent 0ab0e71fbce04e7cba91c7a372df5251821a44cd
Author: Nick Grato <ngrato@gmail.com>
Date: Fri, 9 Jan 2026 20:25:51 +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:
4 files changed, 226 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
@@ -4,6 +4,12 @@
import { html } 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";
@@ -12,12 +18,11 @@ import "chrome://browser/content/aiwindow/components/ai-chat-search-button.mjs";
* A custom element for managing AI Chat Content
*/
export class AIChatMessage extends MozLitElement {
- /**
- * @member {object} message - {role:"user"|"assistant" , content: string}
- */
+ #lastMessage = null;
+ #lastMessageElement = "";
static properties = {
- role: { type: String },
+ role: { type: String }, // "user" | "assistant"
message: { type: String },
};
@@ -47,6 +52,61 @@ export class AIChatMessage extends MozLitElement {
this.dispatchEvent(e);
}
+ /**
+ * Parse markdown content to HTML using ProseMirror
+ *
+ * @param {string} markdown the Markdown to parse
+ * @param {Element} element the element in which to insert the parsed markdown.
+ */
+ parseMarkdown(markdown, element) {
+ 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 by using "setHTML"
+ element.setHTML(containerString);
+ }
+
+ /**
+ * Ensure our message element is up to date. This gets called from
+ * render and memoizes based on `this.message` to avoid re-renders.
+ *
+ * @returns {Element} HTML element containing the parsed markdown
+ */
+ getAssistantMessage() {
+ if (this.message == this.#lastMessage) {
+ return this.#lastMessageElement;
+ }
+ let messageElement = this.ownerDocument.createElement("div");
+ messageElement.className = "message-" + this.role;
+ if (!this.message) {
+ return messageElement;
+ }
+
+ this.parseMarkdown(this.message, messageElement);
+
+ this.#lastMessage = this.message;
+ this.#lastMessageElement = messageElement;
+
+ return messageElement;
+ }
+
+ getUserMessage() {
+ return html`<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>`;
+ }
+
render() {
return html`
<link
@@ -55,15 +115,9 @@ export class AIChatMessage extends MozLitElement {
/>
<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>
+ ${this.role === "user"
+ ? this.getUserMessage()
+ : this.getAssistantMessage()}
</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,170 @@
"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 => {
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:aichatcontent"
+ );
+ const browser = tab.linkedBrowser;
+
+ try {
+ // Wait for content to be fully loaded
await SpecialPowers.spawn(browser, [], async () => {
- await content.customElements.whenDefined("ai-chat-message");
+ if (content.document.readyState !== "complete") {
+ await ContentTaskUtils.waitForEvent(content, "load");
+ }
+ });
- // Create a test ai-chat-message element
- const messageElement = content.document.createElement("ai-chat-message");
- content.document.body.appendChild(messageElement);
+ await SpecialPowers.spawn(browser, [], async () => {
+ const doc = content.document;
- Assert.ok(messageElement, "ai-chat-message element should be created");
+ function sleep(ms) {
+ return new content.Promise(resolve => content.setTimeout(resolve, ms));
+ }
- // Test setting a user message
- messageElement.message = { role: "user", content: "Test user message" };
- await messageElement.updateComplete;
+ 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
+ )
+ ),
+ ]);
+ }
- 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"
- );
+ 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}`);
}
- // 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 root(el) {
+ return el.shadowRoot ?? el;
}
- // Clean up
- messageElement.remove();
- });
- });
+ 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);
+
+ Assert.ok(el, "ai-chat-message element should be created");
- await SpecialPowers.popPrefEnv();
+ // --- 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();
+ }
});
diff --git a/dom/security/DOMSecurityMonitor.cpp b/dom/security/DOMSecurityMonitor.cpp
@@ -70,6 +70,7 @@ void DOMSecurityMonitor::AuditParsingOfHTMLXMLFragments(
"resource://devtools/client/shared/widgets/Spectrum.js"_ns,
"resource://gre/modules/narrate/VoiceSelect.sys.mjs"_ns,
"chrome://global/content/vendor/react-dom.js"_ns,
+ "chrome://browser/content/aiwindow/components/ai-chat-message.mjs"_ns,
// ------------------------------------------------------------------
// test pages
// ------------------------------------------------------------------