tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

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:
Mbrowser/components/aiwindow/ui/components/ai-chat-message/ai-chat-message.mjs | 80++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
Mbrowser/components/aiwindow/ui/components/ai-chat-message/ai-chat-message.stories.mjs | 9++++++++-
Mbrowser/components/aiwindow/ui/test/browser/browser_aichat_message.js | 189++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mdom/security/DOMSecurityMonitor.cpp | 1+
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 // ------------------------------------------------------------------