Raw Source
niyue / DeepSeek User Message Markdown Renderer

// ==UserScript==
// @name         DeepSeek User Message Markdown Renderer
// @name:zh-CN   DeepSeek 用户消息 Markdown 渲染器
// @namespace    http://tampermonkey.net/
// @version      1.0.10
// @description  Render your own messages on DeepSeek web with native-style Markdown, LaTeX math, and official code blocks; safe editing and history highlight included.
// @description:zh-CN  让 DeepSeek 网页版中你自己发送的消息以原生样式渲染 Markdown、LaTeX 公式和官方风格代码块;支持安全编辑与历史消息高亮。
// @author       NIyueeE
// @license      MIT
// @homepageURL  https://github.com/NIyueeE/deepseek-user-message-renderer
// @supportURL   https://github.com/NIyueeE/deepseek-user-message-renderer/issues
// @updateURL    https://raw.githubusercontent.com/NIyueeE/deepseek-user-message-renderer/master/src/deepseek-user-message-renderer.user.js
// @downloadURL  https://raw.githubusercontent.com/NIyueeE/deepseek-user-message-renderer/master/src/deepseek-user-message-renderer.user.js
// @match        https://chat.deepseek.com/*
// @require      https://cdn.jsdelivr.net/npm/marked@18.0.9/lib/marked.umd.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/contrib/auto-render.min.js
// @resource     HLJS_CSS https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css
// @resource     KATEX_CSS https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.9/katex.min.css
// @grant        GM_addStyle
// @grant        GM_getResourceText
// @run-at       document-idle
// ==/UserScript==

(() => {
    // Source: https://github.com/NIyueeE/deepseek-user-message-renderer
    // Synced to GreasyFork & OpenUserJS via GitHub webhooks.
    // 1. Load stylesheets safely
    try {
        const hljsCss = GM_getResourceText("HLJS_CSS");
        const katexCss = GM_getResourceText("KATEX_CSS");
        if (hljsCss) {
            GM_addStyle(hljsCss);
        }
        if (katexCss) {
            GM_addStyle(katexCss);
        }
    } catch (e) {
        console.warn("Failed to inject stylesheets", e);
    }

    // While the Markdown lives in a sibling container inside a collapsible
    // container, the host app's own children stay in the DOM (React must keep
    // finding them for its commits to succeed) but out of view. They must stay
    // MEASURABLE: the host component derives the toggle visibility and the box
    // heights from the content element's size, so display:none (which measures
    // 0 and made the toggle disappear) is wrong — take the nodes out of flow
    // with an absolute position and hide them visually instead. Their size
    // then still reports the native text dimensions. The container itself
    // becomes the positioning parent, and the toggle button is excluded in
    // case a build moves it inside the container.
    try {
        GM_addStyle(
            "[data-md-collapsible] { position: relative !important; }" +
                "[data-md-collapsible] > :not(.md-user-markdown):not(.ds-collapsible-text-toggle-button) {" +
                " position: absolute !important; top: 0 !important; left: 0 !important;" +
                " width: 100% !important; visibility: hidden !important; }",
        );
    } catch (e) {
        console.warn("Failed to inject collapsible style", e);
    }

    // The user message text element: the hashed class is confirmed stable in
    // DeepSeek's current build (same kind as _9663006; update if DeepSeek
    // changes it). Note: the companion class _8271fc3 only marks messages that
    // carry an attachment, so fbb737a4 alone identifies the text element.
    // Rendering happens in place on this element — no extra bubble is created
    // and no original node is hidden, so attachments and the native bubble
    // styling are never affected.
    const USER_TEXT_SELECTOR = "div.fbb737a4";
    // Class DeepSeek's own Markdown containers carry; adding it makes the
    // page's built-in stylesheet render the injected HTML with native styles.
    // Deliberately only `ds-markdown` (the previous implementation pattern):
    // `ds-assistant-message-main-content` carries assistant-column layout
    // rules (line-height, paragraph spacing) that make user bubbles too loose.
    const MARKDOWN_CONTAINER_CLASSES = ["ds-markdown"];

    // Newer DeepSeek builds wrap long user messages in a collapsible container:
    // fbb737a4 > div.ds-collapsible-text (clipped via an inline max-height, with
    // the measured height set inline too) plus a sibling
    // div.ds-collapsible-text-toggle-button that expands/collapses it. The host
    // app (React) inserts and removes its own children inside that container on
    // every toggle (a fade mask etc.), so its recorded child nodes must survive
    // at all times: the Markdown is rendered into a SIBLING container
    // (md-user-markdown) and the host's original children stay in the DOM,
    // hidden by the injected stylesheet rule. Replacing them instead would make
    // the host's next commit throw (NotFoundError) and swallow the message.
    // Short messages keep the flat structure and still render into fbb737a4
    // directly, in place.
    const COLLAPSIBLE_TEXT_CLASS = "ds-collapsible-text";
    // Class of our sibling Markdown container inside the collapsible container
    const MD_MARKDOWN_CLASS = "md-user-markdown";
    // dataset key (data-md-collapsible) marking a collapsible container whose
    // host children are hidden by the injected stylesheet rule. A data
    // attribute is used instead of a class because React rewrites className
    // when its own class state changes, while unknown data attributes are
    // never touched.
    const MD_COLLAPSIBLE_ATTR = "mdCollapsible";
    // After a collapse/expand toggle click, wait for the host's commit and
    // height animation before re-checking the message (see handleToggleClick)
    const TOGGLE_RECHECK_MS = 450;
    // The host's collapsed max-height per container, captured at first render;
    // used to tell collapsed from expanded when correcting stale box heights
    const COLLAPSED_MAX_HEIGHTS = new WeakMap();

    // The element whose content this script manages: the collapsible container
    // when the message is wrapped in one, otherwise the text element itself.
    // It carries the markdown classes and all md-rendered dataset markers.
    function resolveContentEl(textEl) {
        for (const child of Array.from(textEl.children)) {
            if (child.classList.contains(COLLAPSIBLE_TEXT_CLASS)) {
                return child;
            }
        }
        return textEl;
    }

    // 2. Configure marked
    //    - html: legal HTML tags render as native HTML; illegal ones (unknown tags,
    //      comments, declarations, or tags with event handlers or dangerous
    //      protocols) are kept as plain text without losing line breaks
    //    - Soft line breaks follow chat-style Markdown (GFM breaks): a single
    //      newline renders as a <br>, matching how DeepSeek displays user
    //      input; two trailing spaces or a backslash also produce a hard break,
    //      blank lines separate paragraphs, and newlines inside text kept
    //      verbatim are not affected
    function escapeHtml(text, preserveBreaks) {
        const escaped = text
            .replace(/&/g, "&amp;")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;")
            .replace(/'/g, "&#039;");
        return preserveBreaks ? escaped.replace(/\n/g, "<br>") : escaped;
    }

    const HTML_TAG_RE = /^<\/?([a-zA-Z][a-zA-Z0-9-]*)(?:\s+(?:"[^"]*"|'[^']*'|[^\s"'>])+)*\s*\/?>$/;
    const OPEN_TAG_RE = /^<([a-zA-Z][a-zA-Z0-9-]*)(?:\s+(?:"[^"]*"|'[^']*'|[^\s"'>])+)*\s*\/?>/;
    const TAG_ATTR_RE = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
    const BLOCK_TAG_SCAN_RE = /<([a-zA-Z][a-zA-Z0-9-]*)(?:\s+(?:"[^"]*"|'[^']*'|[^\s"'>])+)*\s*\/?>/g;
    const VOID_TAGS = new Set([
        "area",
        "base",
        "br",
        "col",
        "embed",
        "hr",
        "img",
        "input",
        "link",
        "meta",
        "param",
        "source",
        "track",
        "wbr",
    ]);
    // Tags that can hijack or impersonate the page (phishing iframes, base/meta
    // redirects, form exfiltration, remote CSS, autoplaying media). They are
    // always escaped, regardless of their attributes.
    const BLOCKED_TAGS = new Set([
        "applet",
        "audio",
        "base",
        "embed",
        "form",
        "frame",
        "frameset",
        "iframe",
        "link",
        "meta",
        "noframes",
        "object",
        "portal",
        "script",
        "style",
        "video",
    ]);
    // Attribute names the browser resolves as URLs: their values are checked for
    // dangerous schemes. Other attributes (title, alt, class, ...) are never
    // treated as dangerous, so legal tags that merely mention "data:" or
    // "javascript:" in prose stay legal.
    const URL_ATTRS = new Set([
        "action",
        "background",
        "cite",
        "classid",
        "codebase",
        "data",
        "formaction",
        "href",
        "icon",
        "longdesc",
        "manifest",
        "poster",
        "profile",
        "src",
        "usemap",
        "xlink:href",
    ]);
    const EVENT_ATTR_RE = /^on\w+$/i;
    // Browsers decode character references in attribute values and strip tabs
    // and newlines from URLs before parsing the scheme; mirror both steps so
    // "jav&#x61;script:" can never slip through as a live handler.
    const DANGEROUS_SCHEME_RE = /^(?:javascript|vbscript|data|file):/i;

    function decodeAttrEntities(value) {
        const named = {
            amp: "&",
            lt: "<",
            gt: ">",
            quot: '"',
            apos: "'",
            Tab: "\t",
            NewLine: "\n",
            nbsp: "\u00a0",
        };
        return value
            .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
            .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
            .replace(/&(amp|lt|gt|quot|apos|Tab|NewLine|nbsp);/g, (_, name) => named[name]);
    }

    function isKnownElement(tagName) {
        try {
            // createElement is case-insensitive for known elements in browsers;
            // lowercase first so happy-dom and real browsers agree
            return !(document.createElement(tagName.toLowerCase()) instanceof HTMLUnknownElement);
        } catch {
            return false;
        }
    }

    // Any attribute that can execute code or smuggle a dangerous URL makes the
    // whole tag illegal: event-handler names, srcdoc, and URL-valued attributes
    // whose decoded value starts with a dangerous scheme
    function hasUnsafeAttrs(tagText) {
        TAG_ATTR_RE.lastIndex = 0;
        for (let m = TAG_ATTR_RE.exec(tagText); m !== null; m = TAG_ATTR_RE.exec(tagText)) {
            const name = m[1].toLowerCase();
            const value = m[2] ?? m[3] ?? m[4] ?? "";
            if (EVENT_ATTR_RE.test(name) || name === "srcdoc") {
                return true;
            }
            if (URL_ATTRS.has(name)) {
                const decoded = decodeAttrEntities(value).replace(/[\t\n\r]/g, "");
                if (DANGEROUS_SCHEME_RE.test(decoded)) {
                    return true;
                }
            }
        }
        return false;
    }

    // Single tag (open/close): render as HTML only if the element name is legal,
    // not blocked, and carries no unsafe attribute
    function isLegalHtmlTag(text) {
        const match = HTML_TAG_RE.exec(text.trim());
        if (!match || !isKnownElement(match[1])) {
            return false;
        }
        if (BLOCKED_TAGS.has(match[1].toLowerCase())) {
            return false;
        }
        return !hasUnsafeAttrs(text);
    }

    // Block-level HTML (marked merges things like <div>...</div> into one token):
    // render only when the opening tag is legal, the closing tag matches, and
    // every tag inside the block is legal too (inner tags such as <img onerror>
    // are live HTML just like the outer one)
    function isLegalHtmlBlock(text) {
        const trimmed = text.trim();
        const open = OPEN_TAG_RE.exec(trimmed);
        if (!open || !isKnownElement(open[1]) || BLOCKED_TAGS.has(open[1].toLowerCase())) {
            return false;
        }
        if (VOID_TAGS.has(open[1].toLowerCase())) {
            return open[0] === trimmed;
        }
        if (!new RegExp(`</${open[1]}>\\s*$`, "i").test(trimmed)) {
            return false;
        }
        BLOCK_TAG_SCAN_RE.lastIndex = 0;
        for (let m = BLOCK_TAG_SCAN_RE.exec(trimmed); m !== null; m = BLOCK_TAG_SCAN_RE.exec(trimmed)) {
            const name = m[1].toLowerCase();
            if (BLOCKED_TAGS.has(name) || hasUnsafeAttrs(m[0])) {
                return false;
            }
        }
        return true;
    }

    function isSafeUrl(value) {
        if (!value) {
            return true;
        }
        const decoded = decodeAttrEntities(value)
            .replace(/[\t\n\r]/g, "")
            .trim();
        return !DANGEROUS_SCHEME_RE.test(decoded);
    }

    function isSafeImageUrl(value) {
        if (!value) {
            return true;
        }
        const decoded = decodeAttrEntities(value)
            .replace(/[\t\n\r]/g, "")
            .trim();
        if (/^(?:javascript|vbscript|file):/i.test(decoded)) {
            return false;
        }
        // data: URIs are acceptable for images (they cannot execute); other
        // data: payloads are not
        return !/^data:/i.test(decoded) || /^data:image\//i.test(decoded);
    }

    // Names of opening tags judged illegal and escaped; their matching closing
    // tags are also escaped so the original text is preserved completely. The
    // set is scoped to a single parse (see parseMarkdown) so one message's
    // state can never leak into another message's render.
    let activeBlockedTags = null;

    function parseMarkdown(text) {
        activeBlockedTags = new Set();
        try {
            return md.parse(text);
        } finally {
            activeBlockedTags = null;
        }
    }

    const mdRenderer = {
        html(token) {
            const text = typeof token === "string" ? token : token.text || "";
            const trimmed = text.trim();

            // Handle closing tags first (e.g. </a>) to avoid misreading them
            // as block-level HTML
            const close = /^<\/\s*([a-zA-Z][a-zA-Z0-9-]*)\s*>/.exec(trimmed);
            if (close) {
                const name = close[1].toLowerCase();
                if (activeBlockedTags?.delete(name)) {
                    return escapeHtml(text, /\n/.test(text));
                }
                if (isKnownElement(name) && !BLOCKED_TAGS.has(name)) {
                    return text;
                }
                return escapeHtml(text, /\n/.test(text));
            }

            // Block-level HTML (marked merges things like <div>...</div> into
            // a single token)
            if (/\n/.test(text) || /<\/[a-zA-Z][a-zA-Z0-9-]*\s*>/.test(text)) {
                return isLegalHtmlBlock(text) ? text : escapeHtml(text, true);
            }

            const open = /^<([a-zA-Z][a-zA-Z0-9-]*)/.exec(trimmed);
            if (open && !/^<[!?]/.test(trimmed)) {
                const name = open[1].toLowerCase();
                if (isLegalHtmlTag(text)) {
                    activeBlockedTags?.delete(name);
                    return text;
                }
                activeBlockedTags?.add(name);
                return escapeHtml(text, false);
            }

            return escapeHtml(text, false);
        },
        // Markdown links and images are not raw HTML: marked generates the
        // anchor/img elements itself, so the html() policy never sees them.
        // Validate the destination here (the default renderer would emit the
        // dangerous URL verbatim) and render the text only when it is unsafe.
        link({ href, title, tokens, text }) {
            const inner = this.parser ? this.parser.parseInline(tokens) : escapeHtml(text ?? "");
            if (!isSafeUrl(href)) {
                return inner;
            }
            return `<a href="${escapeHtml(href)}"${title ? ` title="${escapeHtml(title)}"` : ""}>${inner}</a>`;
        },
        image({ href, title, text }) {
            if (!isSafeImageUrl(href)) {
                return escapeHtml(text ?? "");
            }
            return `<img src="${escapeHtml(href)}" alt="${escapeHtml(text ?? "")}"${title ? ` title="${escapeHtml(title)}"` : ""}>`;
        },
    };

    // Configure a private marked instance so the page-global `marked` (shared
    // with any other scripts) is never mutated; fall back to configuring the
    // shared instance only on very old UMD builds without the Marked constructor
    let md = null;
    if (typeof marked !== "undefined") {
        if (typeof marked.Marked === "function") {
            md = new marked.Marked({ breaks: true, renderer: mdRenderer });
        } else {
            marked.use({ breaks: true, renderer: mdRenderer });
            md = marked;
        }
    }

    // 2.5 Code blocks: rebuild marked's <pre><code> into DeepSeek's native
    //     md-code-block structure (banner with language label plus corner
    //     decorations) so the page's built-in CSS renders it exactly like native
    //     code blocks. Copy/download buttons are intentionally omitted: these
    //     are the user's own messages, so the actions would be pointless. The
    //     hashed class names come from DeepSeek's current build (same as
    //     _9663006 above).
    const SVG_NS = "http://www.w3.org/2000/svg";
    const CORNER_SVG_PATH = "M-5.24537e-07 0C-2.34843e-07 6.62742 5.37258 12 12 12L0 12L-5.24537e-07 0Z";
    const CODE_BLOCK_CLASSES = {
        label: "d813de27",
        header: "_121d384",
        side: "d2a24f03",
        cornerLeft: "_9bc997d _33882ae",
        cornerRight: "_9bc997d _28d7e84",
    };
    // highlight.js emits hljs-* span classes, but DeepSeek's CSS colors
    // Prism-style `token *` classes. Map them so the official theme applies.
    const HLJS_TO_PRISM = {
        keyword: "keyword",
        string: "string",
        comment: "comment",
        number: "number",
        function: "function",
        title: "function",
        "title.function_": "function",
        "title.class_": "class-name",
        built_in: "builtin",
        literal: "boolean",
        punctuation: "punctuation",
        operator: "operator",
        attr: "attr-name",
        attribute: "attr-name",
        variable: "variable",
        "variable.constant_": "constant",
        meta: "prolog",
        type: "class-name",
        params: "variable",
        regexp: "regex",
        symbol: "symbol",
        bullet: "bullet",
        link: "link",
        section: "heading",
        quote: "quote",
        deletion: "deleted",
        addition: "inserted",
        emphasis: "italic",
        strong: "bold",
        name: "tag",
        selector: "selector",
        tag: "tag",
        "template-variable": "variable",
        property: "property",
        "literal-property": "property",
        doctag: "tag",
        "meta string": "string",
    };

    function codeLanguageOf(codeEl) {
        const match = /language-([\w-]+)/.exec(codeEl.className);
        return match ? match[1] : "text";
    }

    function buildCornerSvg(doc, cornerClass) {
        const svg = doc.createElementNS(SVG_NS, "svg");
        svg.setAttribute("width", "12");
        svg.setAttribute("height", "12");
        svg.setAttribute("viewBox", "0 0 12 12");
        svg.setAttribute("fill", "none");
        svg.setAttribute("class", cornerClass);
        const pathEl = doc.createElementNS(SVG_NS, "path");
        pathEl.setAttribute("d", CORNER_SVG_PATH);
        pathEl.setAttribute("fill", "currentColor");
        svg.appendChild(pathEl);
        return svg;
    }

    function upgradeCodeBlock(pre) {
        if (!pre || pre.closest(".md-code-block")) {
            return;
        }
        const doc = pre.ownerDocument;
        const code = pre.querySelector("code");
        if (!code) {
            return;
        }
        const language = codeLanguageOf(code);

        // highlight.js put hljs-* classes on the code element and its spans;
        // DeepSeek's CSS styles `token *` classes instead, so rewrite them.
        code.classList.remove("hljs");
        code.querySelectorAll("span").forEach((span) => {
            const hljsClasses = Array.from(span.classList).filter((cls) => cls.startsWith("hljs-"));
            if (hljsClasses.length === 0) {
                return;
            }
            const tokens = [];
            for (const cls of hljsClasses) {
                const key = cls.slice("hljs-".length);
                const token = HLJS_TO_PRISM[key] ?? HLJS_TO_PRISM[key.split(".")[0]];
                if (token) {
                    tokens.push(token);
                }
            }
            span.className = tokens.length > 0 ? `token ${tokens.join(" ")}` : "token";
        });

        // Native DeepSeek code blocks have no <code> element: the highlighted
        // spans live directly inside <pre>. Unwrap ours the same way so page
        // rules targeting `code` (inline-code styles, white-space overrides)
        // can never hit the code block.
        while (code.firstChild) {
            pre.appendChild(code.firstChild);
        }
        code.remove();

        // Guarantee hard line breaks regardless of any page CSS: an inline
        // !important declaration beats every stylesheet rule (even other
        // !important ones), and pre-wrap matches native code blocks
        pre.style.setProperty("white-space", "pre-wrap", "important");

        const dark = doc.body.classList.contains("dark");
        const wrapper = doc.createElement("div");
        wrapper.className = `md-code-block md-code-block-${dark ? "dark" : "light"}`;

        const bannerWrap = doc.createElement("div");
        bannerWrap.className = "md-code-block-banner-wrap";
        const banner = doc.createElement("div");
        banner.className = "md-code-block-banner md-code-block-banner-lite";
        const header = doc.createElement("div");
        header.className = CODE_BLOCK_CLASSES.header;
        const left = doc.createElement("div");
        left.className = CODE_BLOCK_CLASSES.side;
        const label = doc.createElement("span");
        label.className = CODE_BLOCK_CLASSES.label;
        label.textContent = language;
        left.appendChild(label);
        header.appendChild(left);
        banner.appendChild(header);
        bannerWrap.appendChild(banner);
        wrapper.appendChild(bannerWrap);

        // The official CSS targets pre[class*=language-]; marked only puts the
        // language class on <code>
        pre.classList.add(`language-${language}`);
        const cornerLeft = buildCornerSvg(doc, CODE_BLOCK_CLASSES.cornerLeft);
        const cornerRight = buildCornerSvg(doc, CODE_BLOCK_CLASSES.cornerRight);
        wrapper.append(bannerWrap, cornerLeft, cornerRight);

        // Swap the wrapper in where the pre used to be, then move the pre inside
        // it (in that order: appending first would make replaceWith insert the
        // wrapper into itself)
        pre.replaceWith(wrapper);
        wrapper.insertBefore(pre, cornerLeft);
    }

    // Mirror DeepSeek's native inline structure: the assistant messages'
    // renderer wraps every text segment in <span class="">, and its stylesheet
    // is built around that shape. Bare text nodes are wrapped the same way.
    // Inline <code> and code blocks are left untouched: native inline code is
    // a bare <code> element and code blocks are rebuilt later (step 7).
    function wrapTextSegments(root) {
        for (const node of Array.from(root.childNodes)) {
            if (node.nodeType === 3) {
                if (!node.textContent) {
                    continue;
                }
                const span = document.createElement("span");
                span.className = "";
                node.replaceWith(span);
                span.textContent = node.textContent;
            } else if (node.nodeType === 1 && node.tagName !== "CODE" && node.tagName !== "PRE") {
                wrapTextSegments(node);
            }
        }
    }

    // marked emits a newline after every block element; the bubble's
    // white-space: pre-wrap would render each of those whitespace-only text
    // nodes as an extra empty line, so drop them everywhere except inside
    // <pre> (code indentation) and inline elements (spacing between words)
    const INLINE_ANCESTOR_SELECTOR =
        "a, span, code, em, strong, kbd, u, del, ins, sub, sup, small, b, i, label, q, s, mark";

    function removeWhitespaceOnlyTextNodes(root) {
        const toRemove = [];
        const collect = (node) => {
            for (const child of Array.from(node.childNodes)) {
                if (child.nodeType === 3) {
                    const text = child.textContent ?? "";
                    if (
                        !text.trim() &&
                        /\n/.test(text) &&
                        !child.parentElement?.closest(`pre, ${INLINE_ANCESTOR_SELECTOR}`)
                    ) {
                        toRemove.push(child);
                    }
                } else if (child.nodeType === 1) {
                    collect(child);
                }
            }
        };
        collect(root);
        for (const node of toRemove) {
            node.remove();
        }
    }

    // Chat-style line breaks (Typora-like): a run of M newlines between lines
    // renders as M-1 line breaks, so one blank line is just a <br> and only
    // three or more blank lines produce visible empty lines. Runs are rewritten
    // so marked keeps everything in one paragraph: the first newline stays a
    // soft break and each extra newline becomes a backslash hard break.
    // The scanner mirrors CommonMark's code constructs instead of guessing with
    // a regex: block fences (3+ backticks or tildes at line start, closed by a
    // run of the same character with at least the opening length) and inline
    // code spans (a backtick string closed by a backtick string of equal
    // length) keep their content verbatim — including blank lines — and a
    // blank line is preserved before fence openers: standard Markdown
    // formatting, plus a guard against setext misparsing (marked <=12 treated a
    // fence directly after a paragraph as a heading when its content started
    // with ---; see test/marked-quirk.test.ts).
    const FENCE_OPEN_RE = /^[ \t]{0,3}(`{3,}|~{3,})/;
    const FENCE_CLOSE_RE = /^[ \t]{0,3}([`~]+)[ \t]*$/;

    function findBacktickRun(str, exactLen) {
        for (let i = 0; i < str.length; i++) {
            if (str[i] !== "`") {
                continue;
            }
            let j = i;
            while (j < str.length && str[j] === "`") {
                j += 1;
            }
            const len = j - i;
            if (exactLen === undefined || len === exactLen) {
                return { index: i, len };
            }
            i = j - 1;
        }
        return null;
    }

    function isEscaped(str, index) {
        let slashes = 0;
        for (let i = index - 1; i >= 0 && str[i] === "\\"; i--) {
            slashes += 1;
        }
        return slashes % 2 === 1;
    }

    function collapseBlankLinesOutsideFences(text) {
        const lines = text.split("\n");
        let out = "";
        let pending = ""; // normal text accumulated since the last fence/span
        let fence = null; // { char, len } when inside a block fence
        let fenceContent = "";
        let spanLen = 0; // >0 when inside an inline code span
        let spanContent = "";

        for (const line of lines) {
            if (fence) {
                const close = FENCE_CLOSE_RE.exec(line);
                if (close && close[1][0] === fence.char && close[1].length >= fence.len) {
                    out += collapseBlankRuns(pending, true);
                    pending = "";
                    out += fenceContent;
                    fence = null;
                    fenceContent = "";
                    pending = `${line}\n`;
                    continue;
                }
                fenceContent += `${line}\n`;
                continue;
            }
            if (spanLen > 0) {
                const close = findBacktickRun(line, spanLen);
                if (close) {
                    spanContent += line.slice(0, close.index + close.len);
                    out += collapseBlankRuns(pending, false);
                    pending = "";
                    out += spanContent;
                    spanLen = 0;
                    spanContent = "";
                    pending = `${line.slice(close.index + close.len)}\n`;
                    continue;
                }
                spanContent += `${line}\n`;
                continue;
            }

            // Fence opener: the whole opening line (info string included) is
            // verbatim fence content
            const fenceOpen = FENCE_OPEN_RE.exec(line);
            if (fenceOpen) {
                out += collapseBlankRuns(pending, true);
                pending = "";
                fence = { char: fenceOpen[1][0], len: fenceOpen[1].length };
                fenceContent = `${line}\n`;
                continue;
            }

            // Inline code spans: split the line into normal text and span
            // content, handling multiple spans per line and spans that
            // continue onto later lines
            let rest = line;
            let normalPart = "";
            let continued = false;
            for (;;) {
                const run = findBacktickRun(rest);
                if (!run || isEscaped(rest, run.index)) {
                    if (!run) {
                        normalPart += rest;
                        break;
                    }
                    normalPart += rest.slice(0, run.index + run.len);
                    rest = rest.slice(run.index + run.len);
                    continue;
                }
                normalPart += rest.slice(0, run.index + run.len);
                const after = rest.slice(run.index + run.len);
                const close = findBacktickRun(after, run.len);
                if (close) {
                    normalPart += after.slice(0, close.index + close.len);
                    rest = after.slice(close.index + close.len);
                    continue;
                }
                continued = true;
                spanLen = run.len;
                spanContent = `${after}\n`;
                break;
            }
            pending += continued ? normalPart : `${normalPart}\n`;
        }

        if (fence) {
            out += collapseBlankRuns(pending, true);
            out += fenceContent;
        } else if (spanLen > 0) {
            out += collapseBlankRuns(pending, false);
            out += spanContent;
        } else {
            out += collapseBlankRuns(pending, false);
        }
        return out;
    }

    function collapseBlankRuns(text, beforeFence) {
        let collapsed = text
            // A newline right next to an explicit <br> would add a second
            // <br> through GFM soft breaks (breaks: true), so drop it
            .replace(/\n[ \t]*(?=<br\b)/gi, "")
            .replace(/(?<=<br\b[^>]*>)[ \t]*\n/gi, "")
            // Keep a blank line before setext underlines (---, ===) and
            // footnote definitions ([^1]: ...) so they stay separate blocks
            // instead of collapsing into the previous paragraph
            .replace(/\n{2,}/g, (run, offset, whole) => {
                const after = whole.slice(offset + run.length);
                if (/^[ \t]*(?:(?:-{3,}|={3,})[ \t]*(?:\n|$)|\[\^[^\]]+\]:)/.test(after)) {
                    return "\n\n";
                }
                return `\n${"\\\n".repeat(run.length - 2)}`;
            });
        if (beforeFence && collapsed && !collapsed.endsWith("\n\n")) {
            collapsed += "\n";
        }
        return collapsed;
    }

    function renderUserMessage(textEl) {
        // Only handle user messages: the text element lives inside the user
        // message container _9663006 (status rows and AI messages do not)
        if (!textEl.closest("._9663006")) {
            return;
        }

        // Edit-mode guard: skip messages while the group hosts an editor
        // (textarea / contenteditable), so DeepSeek's edit placeholder nodes
        // are never rendered in the first place
        const group = textEl.closest("._9663006");
        if (group?.querySelector("textarea, [contenteditable]")) {
            return;
        }

        // Edit-state guard: skip rendering while the message box contains an input
        // control (textarea / contenteditable) so DeepSeek's editor is not clobbered
        if (textEl.querySelector("textarea, [contenteditable]")) {
            return;
        }

        // Render into DeepSeek's collapsible container when the message is
        // wrapped in one; everything below operates on that element
        const contentEl = resolveContentEl(textEl);

        // Right after an edit-button restore, give the host app a moment to set up
        // its editor without the observer racing in and re-rendering the message
        const restoredAt = Number(contentEl.dataset.mdRestoredAt) || 0;
        if (restoredAt && Date.now() - restoredAt < RESTORE_COOLDOWN_MS) {
            return;
        }

        // While a collapse/expand toggle re-check is pending, the host app is
        // committing its own children in and out of the container — do not race it
        const toggledAt = Number(contentEl.dataset.mdToggledAt) || 0;
        if (toggledAt && Date.now() - toggledAt < TOGGLE_RECHECK_MS) {
            return;
        }

        // Theme switch: code blocks carry a dark/light variant class decided at
        // upgrade time, so when the page theme changes the rendered message
        // must be rebuilt. The observer picks up the body class change.
        const themeKey = document.body.classList.contains("dark") ? "dark" : "light";

        // Collapsible messages render into a sibling container so the host's
        // own child nodes stay valid for its commits; short (flat) messages
        // render in place, which has always been safe there
        if (contentEl !== textEl) {
            renderCollapsibleMessage(contentEl, themeKey);
            return;
        }

        // -- Flat message: render in place --
        // Use textContent, not innerText: innerText depends on the page's
        // white-space CSS and collapses newlines to spaces when the container
        // is not pre-wrap, which would flatten code fences and code lines.
        // Trim DOM whitespace around the message text: wrapper elements may
        // carry indentation-only text nodes, and leading spaces beyond three
        // would break fence detection (the message is its trimmed text)
        let rawText = (contentEl.textContent ?? "").trim();
        if (!rawText) {
            // An emptied message box (DeepSeek's edit UI moved the content into
            // the editor) should go back to a clean native state
            if (contentEl.classList.contains("ds-markdown")) {
                contentEl.classList.remove(...MARKDOWN_CONTAINER_CLASSES);
                contentEl.style.whiteSpace = "";
                delete contentEl.dataset.mdRendered;
                delete contentEl.dataset.mdRenderedText;
            }
            return;
        }

        // Re-render from the stored raw Markdown on a theme switch: the current
        // textContent is the script's own render output and would re-parse as
        // plain text.
        if (
            contentEl.dataset.mdRendered != null &&
            contentEl.dataset.mdTheme &&
            contentEl.dataset.mdTheme !== themeKey
        ) {
            if (contentEl.dataset.mdRenderedText === (contentEl.textContent ?? "").trim()) {
                rawText = contentEl.dataset.mdRendered;
            }
            delete contentEl.dataset.mdRenderedText;
        }

        // Skip if already rendered for the current text; re-render if the SPA
        // updated the text in place. mdRenderedText stores the trimmed text of
        // the last render output and is compared against the trimmed input, so
        // trailing whitespace (e.g. the newline a code fence keeps in
        // textContent) can never make the comparison unstable.
        if (contentEl.dataset.mdRenderedText === rawText) {
            return;
        }

        // Parse Markdown and render it in place: the text element becomes the
        // Markdown container. No original node is hidden or removed and no
        // extra bubble is created, so attachments and the native bubble layout
        // are never touched.
        const parsed = parseRawMarkdown(rawText);
        if (parsed == null) {
            // Fallback: keep the raw text visible, preserving line breaks
            contentEl.textContent = rawText.replace(/\s+$/, "");
            contentEl.style.whiteSpace = "pre-wrap";
        } else {
            contentEl.innerHTML = parsed;
            removeWhitespaceOnlyTextNodes(contentEl);
            contentEl.style.whiteSpace = "";
        }
        contentEl.classList.add(...MARKDOWN_CONTAINER_CLASSES);
        decorateMarkdown(contentEl);

        // Mark only after a successful render: mdRendered stores the raw
        // Markdown (restored on edit click), mdRenderedText stores the
        // trimmed text of the render output, which is what the next scan
        // reads, so the dedup check above stops the observer from
        // re-rendering in a loop. Failed renders can retry next time.
        contentEl.dataset.mdRendered = rawText;
        contentEl.dataset.mdRenderedText = (contentEl.textContent ?? "").trim();
        contentEl.dataset.mdTheme = themeKey;
    }

    // Parse raw Markdown into HTML for injection; null when marked is
    // unavailable or parsing failed (callers fall back to plain text)
    function parseRawMarkdown(rawText) {
        if (!md) {
            return null;
        }
        try {
            // marked always appends a trailing newline; the bubble's
            // white-space: pre-wrap would render it as an extra empty line
            // below the content, so strip trailing whitespace outside tags
            return parseMarkdown(collapseBlankLinesOutsideFences(rawText)).replace(/\s+$/, "");
        } catch (err) {
            console.error("Markdown parsing failed", err);
            return null;
        }
    }

    // Shared pipeline for a container that received parsed Markdown: native
    // paragraph classes and text-segment wrapping (must run before KaTeX so
    // math output is untouched), LaTeX math, syntax highlighting, and the
    // native md-code-block rebuild.
    function decorateMarkdown(container) {
        container.querySelectorAll("p").forEach((p) => {
            p.classList.add("ds-markdown-paragraph");
            wrapTextSegments(p);
        });

        if (typeof renderMathInElement === "function") {
            try {
                renderMathInElement(container, {
                    delimiters: [
                        { left: "$$", right: "$$", display: true },
                        { left: "$", right: "$", display: false },
                        { left: "\\(", right: "\\)", display: false },
                        { left: "\\[", right: "\\]", display: true },
                    ],
                    throwOnError: false,
                });
            } catch (err) {
                console.error("KaTeX math rendering failed", err);
            }
        }

        if (typeof hljs !== "undefined" && typeof hljs.highlightElement === "function") {
            try {
                container.querySelectorAll("pre code").forEach((block) => {
                    // Skip languages hljs does not know (e.g. mermaid, text):
                    // highlightElement would log a console warning and fall back
                    // to no highlighting anyway
                    const langMatch = /language-([\w-]+)/.exec(block.className);
                    if (langMatch) {
                        const language = langMatch[1];
                        if (language === "text" || !hljs.getLanguage(language)) {
                            return;
                        }
                    }
                    hljs.highlightElement(block);
                });
            } catch (err) {
                console.error("Highlight.js rendering failed", err);
            }
        }

        container.querySelectorAll("pre code").forEach((codeEl) => {
            try {
                if (codeEl.parentElement) {
                    upgradeCodeBlock(codeEl.parentElement);
                }
            } catch (err) {
                console.error("Code block upgrade failed", err);
            }
        });
    }

    function findMarkdownContainer(contentEl) {
        for (const child of Array.from(contentEl.children)) {
            if (child.classList.contains(MD_MARKDOWN_CLASS)) {
                return child;
            }
        }
        return null;
    }

    // -- Collapsible message --
    // The host app commits its own children in and out of the container on
    // every toggle, so they must stay in the DOM at all times. The Markdown
    // therefore lives in a sibling container (hidden host children via the
    // injected stylesheet rule) and the dedup fingerprint is our own
    // container's text, which the host never touches.
    function renderCollapsibleMessage(contentEl, themeKey) {
        const markdownEl = findMarkdownContainer(contentEl);
        if (markdownEl) {
            const unchanged = contentEl.dataset.mdRenderedText === markdownEl.textContent.trim();
            const themeChanged = contentEl.dataset.mdTheme !== themeKey;
            if (unchanged && !themeChanged) {
                return;
            }
            // Theme switch (or a mutated output container): rebuild from the
            // stored raw Markdown — the host's children may have been swapped
            // out by its toggle commits, so they are not a reliable source
            buildCollapsibleMarkdown(contentEl, contentEl.dataset.mdRendered || "", themeKey);
            return;
        }

        // Not rendered yet (fresh message, or a restore removed our container):
        // the host's children hold the raw message
        const rawText = (contentEl.textContent ?? "").trim();
        if (!rawText) {
            delete contentEl.dataset.mdRendered;
            delete contentEl.dataset.mdRenderedText;
            delete contentEl.dataset.mdTheme;
            delete contentEl.dataset[MD_COLLAPSIBLE_ATTR];
            return;
        }
        buildCollapsibleMarkdown(contentEl, rawText, themeKey);
    }

    function buildCollapsibleMarkdown(contentEl, rawText, themeKey) {
        const markdownEl = contentEl.ownerDocument.createElement("div");
        markdownEl.className = [MD_MARKDOWN_CLASS, ...MARKDOWN_CONTAINER_CLASSES].join(" ");
        const parsed = parseRawMarkdown(rawText);
        if (parsed == null) {
            // Fallback: keep the raw text visible, preserving line breaks
            markdownEl.textContent = rawText;
            markdownEl.style.whiteSpace = "pre-wrap";
        } else {
            markdownEl.innerHTML = parsed;
            removeWhitespaceOnlyTextNodes(markdownEl);
        }
        decorateMarkdown(markdownEl);

        findMarkdownContainer(contentEl)?.remove();
        contentEl.appendChild(markdownEl);
        // Stylesheet-scoped hiding of the host's own children (see the
        // injected rule at the top). A data attribute survives React rewriting
        // className, unlike a class.
        contentEl.dataset[MD_COLLAPSIBLE_ATTR] = "1";
        // Remember the host's collapsed box height so toggle handling can tell
        // collapsed from expanded later (see handleToggleClick)
        if (!COLLAPSED_MAX_HEIGHTS.has(contentEl)) {
            COLLAPSED_MAX_HEIGHTS.set(contentEl, contentEl.style.maxHeight);
        }
        // The message may already be expanded here (theme rebuild, or the host
        // expanded while we rendered): lift its stale measured height so the
        // taller Markdown is not clipped
        liftStaleExpandedHeight(contentEl);

        contentEl.dataset.mdRendered = rawText;
        contentEl.dataset.mdRenderedText = markdownEl.textContent.trim();
        contentEl.dataset.mdTheme = themeKey;
    }

    // The host sizes the collapsible box to the height it measured on the
    // native text. Our rendered Markdown is usually taller, so when the
    // message is expanded (its max-height differs from the collapsed value
    // captured at first render), lift the stale height to the actual content —
    // otherwise the expanded view clips. Collapsed boxes are left untouched.
    function liftStaleExpandedHeight(contentEl) {
        const collapsedMax = COLLAPSED_MAX_HEIGHTS.get(contentEl);
        if (!collapsedMax || contentEl.style.maxHeight === collapsedMax) {
            return;
        }
        contentEl.style.height = "auto";
        if (contentEl.style.maxHeight !== "none") {
            contentEl.style.maxHeight = "none";
        }
    }

    // 10. Edit-button restore: when DeepSeek's "edit" is clicked it reads/takes
    //     over the message box content. If the box still holds the script's
    //     ds-markdown structure, the host app errors out. So the message box is
    //     restored to its original content before the event reaches the app
    //     (capture phase).
    //     The edit button is identified by its pencil icon: its SVG path starts
    //     with a fixed d value (from DeepSeek's current build; update if it changes).
    const EDIT_ICON_PATH_PREFIX = "M9.94076 1.34942";
    // Labels of the cancel button shown in DeepSeek's edit UI
    const CANCEL_BUTTON_LABELS = new Set(["取消", "Cancel"]);
    // After a restore, skip re-rendering for this long so the host app can enter
    // edit mode without the observer re-rendering the message in between
    const RESTORE_COOLDOWN_MS = 2000;

    function findMessageForButton(el) {
        // Walk up from the button to the nearest container that holds a user
        // message box (a div.ds-message inside ._9663006)
        let node = el;
        while (node && node !== document.body && node !== document.documentElement) {
            const msg = node.querySelector?.("div.ds-message");
            if (msg?.closest("._9663006")) {
                return msg;
            }
            node = node.parentElement;
        }
        return null;
    }

    function handleToggleClick(toggleBtn) {
        const msg = findMessageForButton(toggleBtn);
        const textEl = msg?.querySelector(USER_TEXT_SELECTOR);
        if (!textEl?.isConnected) {
            return;
        }
        const contentEl = resolveContentEl(textEl);
        // Only messages rendered by this script need the re-check; native
        // messages and flat messages have no collapsible container to fix up
        if (contentEl === textEl || contentEl.dataset.mdRendered == null) {
            return;
        }
        // Block the observer while the host commits its collapse/expand state
        contentEl.dataset.mdToggledAt = String(Date.now());
        setTimeout(() => {
            delete contentEl.dataset.mdToggledAt;
            try {
                renderUserMessage(textEl);
                liftStaleExpandedHeight(contentEl);
            } catch (err) {
                console.error("Toggle re-check failed", err);
            }
        }, TOGGLE_RECHECK_MS);
    }

    function restoreUserMessage(msgNode) {
        const textEl = msgNode.querySelector(USER_TEXT_SELECTOR);
        if (!textEl?.isConnected) {
            return;
        }
        const contentEl = resolveContentEl(textEl);
        // Only touch messages rendered by this script; leave native ones alone
        if (contentEl.dataset.mdRendered == null) {
            return;
        }

        if (contentEl !== textEl) {
            // Collapsible message: our Markdown lives in a sibling container.
            // Removing it unhides the host's own children (they were never
            // touched, so the host app takes over a fully native DOM and its
            // commits keep succeeding). Never write into the host's nodes.
            findMarkdownContainer(contentEl)?.remove();
            delete contentEl.dataset[MD_COLLAPSIBLE_ATTR];
            delete contentEl.dataset.mdRendered;
            delete contentEl.dataset.mdRenderedText;
            delete contentEl.dataset.mdTheme;
            delete contentEl.dataset.mdToggledAt;
            contentEl.dataset.mdRestoredAt = String(Date.now());
        } else {
            // Flat message: put the raw Markdown back so the host app reads
            // the original content, and drop the injected classes so the page
            // styles it natively again. A cooldown window blocks the observer
            // while the editor is set up.
            contentEl.textContent = contentEl.dataset.mdRendered;
            contentEl.classList.remove(...MARKDOWN_CONTAINER_CLASSES);
            contentEl.style.whiteSpace = "";
            delete contentEl.dataset.mdRenderedText;
            contentEl.dataset.mdRestoredAt = String(Date.now());
        }

        // After the cooldown, re-check the message once: if the edit was submitted
        // and the text changed, render the new content; if the editor is still
        // active, the input-control guard skips it
        setTimeout(() => {
            delete contentEl.dataset.mdRestoredAt;
            try {
                renderUserMessage(textEl);
            } catch (err) {
                console.error("Post-restore re-check failed", err);
            }
        }, RESTORE_COOLDOWN_MS + 50);
    }

    // After the edit is dismissed (cancel), put the message back into rendered
    // mode: clear the "processed" markers so the next scan renders it again.
    // The actual render is deferred so the host app can finish dismissing the
    // edit UI first; the observer also picks it up if DeepSeek mutates the node.
    function reRenderRestoredMessage(msgNode) {
        const textEl = msgNode.querySelector(USER_TEXT_SELECTOR);
        if (!textEl) {
            return;
        }
        const contentEl = resolveContentEl(textEl);
        setTimeout(() => {
            try {
                renderUserMessage(textEl);
            } catch (err) {
                console.error("Re-render after cancel failed", err);
            }
        }, 50);
        if (contentEl !== textEl) {
            // Collapsible message: the restore already removed our container
            // and markers; lifting the cooldown is enough — the host's own
            // commit puts the text back and the re-render rebuilds from it
            if (contentEl.dataset.mdRestoredAt == null) {
                return;
            }
            delete contentEl.dataset.mdRestoredAt;
            return;
        }
        // Only flat messages restored by this script can be re-rendered on
        // cancel; the raw Markdown is put back first so it re-parses
        if (contentEl.dataset.mdRendered == null) {
            return;
        }
        delete contentEl.dataset.mdRendered;
        delete contentEl.dataset.mdRenderedText;
        delete contentEl.dataset.mdRestoredAt;
    }

    // Both window and document listen in the capture phase, so every event
    // arrives twice; only the first pass may act (restore must not run twice
    // and schedule two cooldown timers)
    let lastHandledEvent = null;
    function handleEditUiEvent(e) {
        if (e === lastHandledEvent) {
            return;
        }
        lastHandledEvent = e;
        // Mouse/pointer events: primary button only; keyboard events: Enter/Space
        if (e.type !== "keydown" && e.button !== undefined && e.button !== 0) {
            return;
        }
        if (e.type === "keydown" && e.key !== "Enter" && e.key !== " " && e.key !== "Spacebar") {
            return;
        }
        const target = e.target;
        if (!target || typeof target.closest !== "function") {
            return;
        }

        // Collapse/expand toggle of a collapsible long message: the host handles
        // the toggle itself; we only re-check the message once it has finished
        // (and lift its stale measured height when expanded — see below)
        const toggleBtn = target.closest(".ds-collapsible-text-toggle-button");
        if (toggleBtn) {
            handleToggleClick(toggleBtn);
            return;
        }

        const btn = target.closest('[role="button"], button, .ds-button');
        if (!btn) {
            return;
        }
        const msg = findMessageForButton(btn);
        if (!msg) {
            return;
        }

        // Edit (pencil) button: restore the message before the app reads it
        const path = btn.querySelector?.("svg path");
        const d = path?.getAttribute("d");
        if (d?.startsWith(EDIT_ICON_PATH_PREFIX)) {
            restoreUserMessage(msg);
            return;
        }

        // Cancel button (edit UI): re-render the message after dismissing
        const label = btn.querySelector?.(".ds-button__content");
        if (label && CANCEL_BUTTON_LABELS.has(label.textContent?.trim() || "")) {
            reRenderRestoredMessage(msg);
        }
    }

    // Capture-phase listeners on window/document run before DeepSeek's own
    // (bubble-phase) handlers. Only `click` restores the message: mutating the DOM
    // during pointerdown/mousedown can shift the layout and move the button away
    // from the cursor, so the browser never dispatches the click and the first
    // click appears dead. By `click` time the event is already dispatched.
    for (const scope of [window, document]) {
        scope.addEventListener("click", handleEditUiEvent, true);
        scope.addEventListener("keydown", handleEditUiEvent, true);
    }

    function processMessages() {
        // Select the user message text elements directly. Attachment cards and
        // other message UI are siblings of these elements and are never
        // touched, so they stay visible and native.
        const textEls = document.querySelectorAll(`._9663006 ${USER_TEXT_SELECTOR}`);
        textEls.forEach((textEl) => {
            try {
                renderUserMessage(textEl);
            } catch (err) {
                // A failure on one message must not affect the others
                console.error("Message rendering failed", err);
            }
        });
    }

    // Batch processing: DOM changes are frequent, so coalesce them into a single
    // microtask scan
    let pending = false;
    function scheduleProcess() {
        if (pending) {
            return;
        }
        pending = true;
        queueMicrotask(() => {
            pending = false;
            processMessages();
        });
    }

    // Only relevant mutations should trigger a scan: anything inside a user
    // message group, new groups (appended outside any existing group), and
    // theme changes (the body class). AI-streaming churn outside those areas is
    // ignored, so the observer never scans the whole page per streamed token.
    function isRelevantMutation(records) {
        for (const record of records) {
            if (record.type === "characterData") {
                const parent = record.target.parentElement;
                if (parent?.closest("._9663006") || parent === document.body) {
                    return true;
                }
            } else if (record.type === "attributes") {
                const target = record.target;
                if (target === document.body || target.closest?.("._9663006")) {
                    return true;
                }
            } else if (record.type === "childList") {
                for (const node of [...record.addedNodes, ...record.removedNodes]) {
                    if (node.nodeType !== 1) {
                        if (node.parentElement?.closest("._9663006")) {
                            return true;
                        }
                        continue;
                    }
                    // closest() covers nodes inside a group and new groups
                    // themselves; querySelector() covers wholesale list
                    // re-renders whose root sits outside any group
                    if (node.closest?.("._9663006") || node.querySelector?.("._9663006")) {
                        return true;
                    }
                }
                if (record.target.closest?.("._9663006")) {
                    return true;
                }
            }
        }
        return false;
    }

    // Watch the message list for changes. characterData is observed because
    // React updates text nodes in place via nodeValue (a characterData
    // mutation) instead of replacing them; without it, in-place text edits
    // would never re-render. attributes is observed for the body class so a
    // theme switch re-renders the code blocks.
    if (document.body) {
        const observer = new MutationObserver((records) => {
            if (isRelevantMutation(records)) {
                scheduleProcess();
            }
        });
        observer.observe(document.body, {
            childList: true,
            subtree: true,
            characterData: true,
            attributes: true,
            attributeFilter: ["class"],
        });

        processMessages();
    }
})();