From 16cf55c96d0fc1cc7d494b99092dccd4ca0a0b10 Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Mon, 3 Aug 2026 20:03:31 +0300 Subject: [PATCH 1/3] Fix chat history formatting --- src/chrome/src/ui/chat-history-store.js | 70 +++++++++++ src/chrome/src/ui/history-text.js | 114 ++++++++++++++++++ src/chrome/src/ui/history.html | 37 ++++++ src/chrome/src/ui/history.js | 7 +- src/chrome/src/ui/sidepanel.js | 33 +++++- src/chrome/src/ui/skill-markdown.js | 17 ++- src/firefox/src/ui/chat-history-store.js | 70 +++++++++++ src/firefox/src/ui/history-text.js | 114 ++++++++++++++++++ src/firefox/src/ui/history.html | 37 ++++++ src/firefox/src/ui/history.js | 7 +- src/firefox/src/ui/sidepanel.js | 33 +++++- src/firefox/src/ui/skill-markdown.js | 17 ++- test/run.js | 144 ++++++++++++++++++++++- 13 files changed, 686 insertions(+), 14 deletions(-) create mode 100644 src/chrome/src/ui/history-text.js create mode 100644 src/firefox/src/ui/history-text.js diff --git a/src/chrome/src/ui/chat-history-store.js b/src/chrome/src/ui/chat-history-store.js index 8712eface..8100cd95d 100644 --- a/src/chrome/src/ui/chat-history-store.js +++ b/src/chrome/src/ui/chat-history-store.js @@ -44,6 +44,7 @@ function normalizeMessage(message, index) { return { role: ['user', 'assistant', 'system', 'error'].includes(message?.role) ? message.role : 'unknown', text: normalizeText(message?.text), + format: message?.format === 'markdown' ? 'markdown' : 'text', index: Number.isFinite(Number(message?.index)) ? Number(message.index) : index, createdAt: Number.isFinite(Number(message?.createdAt)) ? Number(message.createdAt) : null, }; @@ -126,6 +127,75 @@ export async function saveChatHistoryRecord(input) { return record; } +function sameMessageContent(left, right) { + return left.role === right.role + && left.text === right.text + && (left.format || 'text') === (right.format || 'text') + && Number(left.index) === Number(right.index); +} + +/** + * Repair only the serialized message content of an existing history record. + * Conversation metadata and timestamps remain unchanged, and a missing record + * is never recreated by this migration path. + * @param {string} id - Existing record ID. + * @param {Array} messages - Messages recovered from restored chat DOM. + * @returns {Promise} Repaired/existing record, or null if absent. + */ +export async function repairChatHistoryRecordMessages(id, messages) { + if (!id || !Array.isArray(messages)) return null; + const db = await openDB(); + const transaction = tx(db, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + + return new Promise((resolve, reject) => { + let result = null; + transaction.oncomplete = () => resolve(result); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error || new Error('History repair transaction aborted')); + + const getReq = store.get(String(id)); + getReq.onsuccess = () => { + const existing = getReq.result; + if (!existing) return; + + const previousMessages = Array.isArray(existing.messages) ? existing.messages : []; + const normalizedMessages = messages + .map((message, index) => { + const normalized = normalizeMessage(message, index); + const previous = previousMessages.find((candidate) => ( + Number(candidate?.index) === normalized.index && candidate?.role === normalized.role + )); + if (Number.isFinite(Number(previous?.createdAt))) { + normalized.createdAt = Number(previous.createdAt); + } + return normalized; + }) + .filter((message) => message.text); + + const unchanged = previousMessages.length === normalizedMessages.length + && previousMessages.every((message, index) => sameMessageContent(message, normalizedMessages[index])); + if (unchanged) { + result = existing; + return; + } + + const repaired = normalizeRecord({ + ...existing, + messages: normalizedMessages, + createdAt: existing.createdAt, + updatedAt: existing.updatedAt, + }, existing); + if (repaired.userMessageCount < 1) { + result = existing; + return; + } + result = repaired; + store.put(repaired); + }; + }); +} + /** * List chat history records, newest first. * @param {Object} [params] - { limit }. diff --git a/src/chrome/src/ui/history-text.js b/src/chrome/src/ui/history-text.js new file mode 100644 index 000000000..65237c301 --- /dev/null +++ b/src/chrome/src/ui/history-text.js @@ -0,0 +1,114 @@ +/** + * Serialize rendered chat DOM back to readable Markdown for durable history. + * + * textContent intentionally ignores visual structure such as
and block + * boundaries, while semantic elements such as have already consumed + * their Markdown markers. Walking the DOM preserves both forms of structure. + * Keep this module DOM-global-free so its traversal can be unit-tested in Node. + */ + +const ELEMENT_NODE = 1; +const TEXT_NODE = 3; + +const BLOCK_TAGS = new Set([ + 'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'DD', 'DIV', 'DL', 'DT', + 'FIELDSET', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'FORM', 'H1', 'H2', 'H3', + 'H4', 'H5', 'H6', 'HEADER', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE', + 'SECTION', 'TABLE', 'TBODY', 'TD', 'TFOOT', 'TH', 'THEAD', 'TR', 'UL', +]); + +const SKIPPED_TAGS = new Set(['SCRIPT', 'STYLE', 'TEMPLATE']); + +export function historyTextFromElement(root, { markdown = true } = {}) { + if (!root) return ''; + let output = ''; + + const trimTrailingHorizontalSpace = () => { + output = output.replace(/[ \t]+$/g, ''); + }; + + const appendHardBreak = () => { + trimTrailingHorizontalSpace(); + output += '\n'; + }; + + const ensureBreak = () => { + trimTrailingHorizontalSpace(); + if (output && !output.endsWith('\n')) output += '\n'; + }; + + const visit = (node, isRoot = false, inPre = false) => { + if (!node) return; + if (node.nodeType === TEXT_NODE) { + output += String(node.nodeValue ?? node.textContent ?? ''); + return; + } + if (node.nodeType !== ELEMENT_NODE) return; + + const tagName = String(node.tagName || '').toUpperCase(); + if (SKIPPED_TAGS.has(tagName)) return; + if (node.classList?.contains?.('code-block-header')) return; + if (tagName === 'BR') { + // Every rendered
represents a source newline, including consecutive + //
s used for blank lines. Do not deduplicate these hard breaks. + appendHardBreak(); + return; + } + if (tagName === 'HR') { + ensureBreak(); + output += '---'; + appendHardBreak(); + return; + } + + if (markdown && /^H[1-6]$/.test(tagName)) { + ensureBreak(); + output += `${'#'.repeat(Number(tagName.slice(1)))} `; + for (const child of Array.from(node.childNodes || [])) visit(child); + ensureBreak(); + return; + } + if (markdown && tagName === 'PRE') { + ensureBreak(); + output += '```\n'; + for (const child of Array.from(node.childNodes || [])) visit(child, false, true); + ensureBreak(); + output += '```'; + ensureBreak(); + return; + } + if (markdown && tagName === 'CODE' && !inPre) { + output += '`'; + for (const child of Array.from(node.childNodes || [])) visit(child); + output += '`'; + return; + } + if (markdown && (tagName === 'STRONG' || tagName === 'B')) { + output += '**'; + for (const child of Array.from(node.childNodes || [])) visit(child, false, inPre); + output += '**'; + return; + } + if (markdown && (tagName === 'EM' || tagName === 'I')) { + output += '*'; + for (const child of Array.from(node.childNodes || [])) visit(child, false, inPre); + output += '*'; + return; + } + if (markdown && tagName === 'A') { + const href = String(node.getAttribute?.('href') || ''); + if (href) output += '['; + for (const child of Array.from(node.childNodes || [])) visit(child, false, inPre); + if (href) output += `](${href})`; + return; + } + + const isBlock = !isRoot && BLOCK_TAGS.has(tagName); + if (isBlock) ensureBreak(); + for (const child of Array.from(node.childNodes || [])) visit(child, false, inPre); + if (isBlock) ensureBreak(); + }; + + visit(root, true); + return output; +} diff --git a/src/chrome/src/ui/history.html b/src/chrome/src/ui/history.html index 90310c09a..4e6b382e8 100644 --- a/src/chrome/src/ui/history.html +++ b/src/chrome/src/ui/history.html @@ -289,6 +289,43 @@ line-height: 1.5; font-size: 12px; } + .message-text h1, + .message-text h2, + .message-text h3, + .message-text h4, + .message-text h5, + .message-text h6 { + margin: 10px 0 5px; + line-height: 1.25; + } + .message-text h1:first-child, + .message-text h2:first-child, + .message-text h3:first-child { margin-top: 0; } + .message-text blockquote { + margin: 6px 0; + padding: 2px 0 2px 10px; + border-left: 3px solid var(--accent); + color: var(--text2); + } + .message-text ul, + .message-text ol { margin: 6px 0; padding-left: 24px; } + .message-text pre { + margin: 7px 0; + padding: 9px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg3); + overflow-x: auto; + white-space: pre; + } + .message-text code { + padding: 1px 4px; + border-radius: 4px; + background: var(--bg3); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.95em; + } + .message-text pre code { padding: 0; background: transparent; } .empty-inline { padding: 20px; text-align: center; diff --git a/src/chrome/src/ui/history.js b/src/chrome/src/ui/history.js index 4c231fffd..ec5d2d880 100644 --- a/src/chrome/src/ui/history.js +++ b/src/chrome/src/ui/history.js @@ -8,6 +8,7 @@ import { formatSelectionPromptForDisplay } from '../context-menu-storage.js'; import { listRuns } from '../trace/recorder.js'; import { t } from './i18n.js'; import { escapeHtml, escapeAttr } from './utils.js'; +import { renderSkillMarkdown as renderHistoryMarkdown } from './skill-markdown.js'; const listEl = document.getElementById('history-list'); const mainPane = document.getElementById('main-pane'); @@ -254,10 +255,14 @@ function displayRecordTitle(record) { function renderMessage(message) { const role = ['user', 'assistant', 'system', 'error'].includes(message?.role) ? message.role : 'unknown'; + const text = displayMessageText(message); + const renderedText = message?.format === 'markdown' + ? renderHistoryMarkdown(text) + : escapeHtml(text); return `
${escapeHtml(t(`hist.role.${role}`))}
-
${escapeHtml(displayMessageText(message))}
+
${renderedText}
`; } diff --git a/src/chrome/src/ui/sidepanel.js b/src/chrome/src/ui/sidepanel.js index 511566ba3..15903421d 100644 --- a/src/chrome/src/ui/sidepanel.js +++ b/src/chrome/src/ui/sidepanel.js @@ -13,7 +13,12 @@ import { formatSelectionPromptForDisplay, SELECTION_ONLY_SOURCE_GROUNDING, } from '../context-menu-storage.js'; -import { deleteChatHistoryRecord, saveChatHistoryRecord } from './chat-history-store.js'; +import { + deleteChatHistoryRecord, + repairChatHistoryRecordMessages, + saveChatHistoryRecord, +} from './chat-history-store.js'; +import { historyTextFromElement } from './history-text.js'; import { claimRunError } from './run-error-dedupe.js'; import { RUN_CAPTURE_START_ERROR_PREFIX } from '../run-capture.js'; import { runUiUnavailableBeforeSeq } from '../run-ui-journal.js'; @@ -2281,9 +2286,12 @@ function extractChatHistoryMessages(root = messagesEl) { clone.querySelectorAll('button, input, textarea, select, .msg-copy-btn, .code-copy-btn, .error-retry-btn') .forEach((el) => el.remove()); const textEl = clone.querySelector('.message-text') || clone.querySelector('.message-content') || clone; + const role = roleFromMessageElement(msgEl); + const format = role === 'assistant' || role === 'error' ? 'markdown' : 'text'; return { - role: roleFromMessageElement(msgEl), - text: normalizeHistoryText(textEl.textContent), + role, + text: normalizeHistoryText(historyTextFromElement(textEl, { markdown: format === 'markdown' })), + format, index, createdAt: Date.now(), }; @@ -2399,6 +2407,20 @@ async function persistChatHistorySnapshot(tabId, { refreshTabInfo = false } = {} }); } +async function repairRestoredChatHistorySnapshot(tabId) { + const numericTabId = Number(tabId); + if (document.visibilityState === 'hidden' + || !Number.isFinite(numericTabId) + || renderedTabId !== numericTabId) return; + const recordId = chatHistoryRecordIdsByTab.get(numericTabId); + if (!recordId) return; + const messages = extractChatHistoryMessages(messagesEl); + if (!messages.some((message) => message.role === 'user')) return; + await repairChatHistoryRecordMessages(recordId, messages).catch((error) => { + console.warn('[WebBrain] failed to repair restored chat history:', error); + }); +} + function scheduleHistoryPersist(tabId) { if (historyPersistTimer) clearTimeout(historyPersistTimer); historyPersistTimerTabId = tabId; @@ -3740,6 +3762,11 @@ async function init() { // Start observing the messages container for changes to persist. persistObserver.observe(messagesEl, { childList: true, subtree: true, characterData: true }); await restoreActiveRunState(restoreTabId); + if (restoreTabId != null && currentTabId === restoreTabId) { + // Repair only stored message formatting; opening the panel must not make a + // conversation look newer or replace its original mode/page metadata. + await repairRestoredChatHistorySnapshot(restoreTabId); + } restoreLatestChatTurnPosition(); await loadProviders(); diff --git a/src/chrome/src/ui/skill-markdown.js b/src/chrome/src/ui/skill-markdown.js index 2376b7bd6..ecf45ea95 100644 --- a/src/chrome/src/ui/skill-markdown.js +++ b/src/chrome/src/ui/skill-markdown.js @@ -1,6 +1,6 @@ /** - * Small, dependency-free Markdown formatter for read-only skill previews. - * All skill-controlled HTML is escaped before fixed markup is introduced. + * Small, dependency-free Markdown formatter for read-only skill previews and + * saved chat history. All source HTML is escaped before fixed markup is added. */ import { escapeHtml } from './utils.js'; @@ -52,6 +52,15 @@ export function renderSkillMarkdown(content) { text = escapeHtml(text); + const quoteBlocks = []; + text = text.replace(/(?:^[ \t]*>[^\r\n]*(?:\r?\n|$))+/gm, (block) => { + const placeholder = `__SKILL_QUOTE_BLOCK_${quoteBlocks.length}__`; + quoteBlocks.push(block.trimEnd().split(/\r?\n/) + .map((line) => line.replace(/^[ \t]*>[ \t]?/, '')) + .join('\n')); + return `${placeholder}\n`; + }); + // Pull list markers out before parsing emphasis so a `*` bullet cannot be // paired with an emphasis marker later in the same item. const listBlocks = []; @@ -71,6 +80,10 @@ export function renderSkillMarkdown(content) { const block = `<${tag}>${items.map((item) => `
  • ${renderInlineMarkdown(item)}
  • `).join('')}`; text = text.replace(`__SKILL_LIST_BLOCK_${index}__`, () => block); }); + quoteBlocks.forEach((quote, index) => { + const block = `
    ${renderInlineMarkdown(quote).replace(/\n/g, '
    ')}
    `; + text = text.replace(`__SKILL_QUOTE_BLOCK_${index}__`, () => block); + }); inlineCodes.forEach((code, index) => { text = text.replace(`__SKILL_INLINE_CODE_${index}__`, () => `${escapeHtml(code)}`); }); diff --git a/src/firefox/src/ui/chat-history-store.js b/src/firefox/src/ui/chat-history-store.js index 8712eface..8100cd95d 100644 --- a/src/firefox/src/ui/chat-history-store.js +++ b/src/firefox/src/ui/chat-history-store.js @@ -44,6 +44,7 @@ function normalizeMessage(message, index) { return { role: ['user', 'assistant', 'system', 'error'].includes(message?.role) ? message.role : 'unknown', text: normalizeText(message?.text), + format: message?.format === 'markdown' ? 'markdown' : 'text', index: Number.isFinite(Number(message?.index)) ? Number(message.index) : index, createdAt: Number.isFinite(Number(message?.createdAt)) ? Number(message.createdAt) : null, }; @@ -126,6 +127,75 @@ export async function saveChatHistoryRecord(input) { return record; } +function sameMessageContent(left, right) { + return left.role === right.role + && left.text === right.text + && (left.format || 'text') === (right.format || 'text') + && Number(left.index) === Number(right.index); +} + +/** + * Repair only the serialized message content of an existing history record. + * Conversation metadata and timestamps remain unchanged, and a missing record + * is never recreated by this migration path. + * @param {string} id - Existing record ID. + * @param {Array} messages - Messages recovered from restored chat DOM. + * @returns {Promise} Repaired/existing record, or null if absent. + */ +export async function repairChatHistoryRecordMessages(id, messages) { + if (!id || !Array.isArray(messages)) return null; + const db = await openDB(); + const transaction = tx(db, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + + return new Promise((resolve, reject) => { + let result = null; + transaction.oncomplete = () => resolve(result); + transaction.onerror = () => reject(transaction.error); + transaction.onabort = () => reject(transaction.error || new Error('History repair transaction aborted')); + + const getReq = store.get(String(id)); + getReq.onsuccess = () => { + const existing = getReq.result; + if (!existing) return; + + const previousMessages = Array.isArray(existing.messages) ? existing.messages : []; + const normalizedMessages = messages + .map((message, index) => { + const normalized = normalizeMessage(message, index); + const previous = previousMessages.find((candidate) => ( + Number(candidate?.index) === normalized.index && candidate?.role === normalized.role + )); + if (Number.isFinite(Number(previous?.createdAt))) { + normalized.createdAt = Number(previous.createdAt); + } + return normalized; + }) + .filter((message) => message.text); + + const unchanged = previousMessages.length === normalizedMessages.length + && previousMessages.every((message, index) => sameMessageContent(message, normalizedMessages[index])); + if (unchanged) { + result = existing; + return; + } + + const repaired = normalizeRecord({ + ...existing, + messages: normalizedMessages, + createdAt: existing.createdAt, + updatedAt: existing.updatedAt, + }, existing); + if (repaired.userMessageCount < 1) { + result = existing; + return; + } + result = repaired; + store.put(repaired); + }; + }); +} + /** * List chat history records, newest first. * @param {Object} [params] - { limit }. diff --git a/src/firefox/src/ui/history-text.js b/src/firefox/src/ui/history-text.js new file mode 100644 index 000000000..65237c301 --- /dev/null +++ b/src/firefox/src/ui/history-text.js @@ -0,0 +1,114 @@ +/** + * Serialize rendered chat DOM back to readable Markdown for durable history. + * + * textContent intentionally ignores visual structure such as
    and block + * boundaries, while semantic elements such as have already consumed + * their Markdown markers. Walking the DOM preserves both forms of structure. + * Keep this module DOM-global-free so its traversal can be unit-tested in Node. + */ + +const ELEMENT_NODE = 1; +const TEXT_NODE = 3; + +const BLOCK_TAGS = new Set([ + 'ADDRESS', 'ARTICLE', 'ASIDE', 'BLOCKQUOTE', 'DD', 'DIV', 'DL', 'DT', + 'FIELDSET', 'FIGCAPTION', 'FIGURE', 'FOOTER', 'FORM', 'H1', 'H2', 'H3', + 'H4', 'H5', 'H6', 'HEADER', 'LI', 'MAIN', 'NAV', 'OL', 'P', 'PRE', + 'SECTION', 'TABLE', 'TBODY', 'TD', 'TFOOT', 'TH', 'THEAD', 'TR', 'UL', +]); + +const SKIPPED_TAGS = new Set(['SCRIPT', 'STYLE', 'TEMPLATE']); + +export function historyTextFromElement(root, { markdown = true } = {}) { + if (!root) return ''; + let output = ''; + + const trimTrailingHorizontalSpace = () => { + output = output.replace(/[ \t]+$/g, ''); + }; + + const appendHardBreak = () => { + trimTrailingHorizontalSpace(); + output += '\n'; + }; + + const ensureBreak = () => { + trimTrailingHorizontalSpace(); + if (output && !output.endsWith('\n')) output += '\n'; + }; + + const visit = (node, isRoot = false, inPre = false) => { + if (!node) return; + if (node.nodeType === TEXT_NODE) { + output += String(node.nodeValue ?? node.textContent ?? ''); + return; + } + if (node.nodeType !== ELEMENT_NODE) return; + + const tagName = String(node.tagName || '').toUpperCase(); + if (SKIPPED_TAGS.has(tagName)) return; + if (node.classList?.contains?.('code-block-header')) return; + if (tagName === 'BR') { + // Every rendered
    represents a source newline, including consecutive + //
    s used for blank lines. Do not deduplicate these hard breaks. + appendHardBreak(); + return; + } + if (tagName === 'HR') { + ensureBreak(); + output += '---'; + appendHardBreak(); + return; + } + + if (markdown && /^H[1-6]$/.test(tagName)) { + ensureBreak(); + output += `${'#'.repeat(Number(tagName.slice(1)))} `; + for (const child of Array.from(node.childNodes || [])) visit(child); + ensureBreak(); + return; + } + if (markdown && tagName === 'PRE') { + ensureBreak(); + output += '```\n'; + for (const child of Array.from(node.childNodes || [])) visit(child, false, true); + ensureBreak(); + output += '```'; + ensureBreak(); + return; + } + if (markdown && tagName === 'CODE' && !inPre) { + output += '`'; + for (const child of Array.from(node.childNodes || [])) visit(child); + output += '`'; + return; + } + if (markdown && (tagName === 'STRONG' || tagName === 'B')) { + output += '**'; + for (const child of Array.from(node.childNodes || [])) visit(child, false, inPre); + output += '**'; + return; + } + if (markdown && (tagName === 'EM' || tagName === 'I')) { + output += '*'; + for (const child of Array.from(node.childNodes || [])) visit(child, false, inPre); + output += '*'; + return; + } + if (markdown && tagName === 'A') { + const href = String(node.getAttribute?.('href') || ''); + if (href) output += '['; + for (const child of Array.from(node.childNodes || [])) visit(child, false, inPre); + if (href) output += `](${href})`; + return; + } + + const isBlock = !isRoot && BLOCK_TAGS.has(tagName); + if (isBlock) ensureBreak(); + for (const child of Array.from(node.childNodes || [])) visit(child, false, inPre); + if (isBlock) ensureBreak(); + }; + + visit(root, true); + return output; +} diff --git a/src/firefox/src/ui/history.html b/src/firefox/src/ui/history.html index 90310c09a..4e6b382e8 100644 --- a/src/firefox/src/ui/history.html +++ b/src/firefox/src/ui/history.html @@ -289,6 +289,43 @@ line-height: 1.5; font-size: 12px; } + .message-text h1, + .message-text h2, + .message-text h3, + .message-text h4, + .message-text h5, + .message-text h6 { + margin: 10px 0 5px; + line-height: 1.25; + } + .message-text h1:first-child, + .message-text h2:first-child, + .message-text h3:first-child { margin-top: 0; } + .message-text blockquote { + margin: 6px 0; + padding: 2px 0 2px 10px; + border-left: 3px solid var(--accent); + color: var(--text2); + } + .message-text ul, + .message-text ol { margin: 6px 0; padding-left: 24px; } + .message-text pre { + margin: 7px 0; + padding: 9px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--bg3); + overflow-x: auto; + white-space: pre; + } + .message-text code { + padding: 1px 4px; + border-radius: 4px; + background: var(--bg3); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.95em; + } + .message-text pre code { padding: 0; background: transparent; } .empty-inline { padding: 20px; text-align: center; diff --git a/src/firefox/src/ui/history.js b/src/firefox/src/ui/history.js index 4c231fffd..ec5d2d880 100644 --- a/src/firefox/src/ui/history.js +++ b/src/firefox/src/ui/history.js @@ -8,6 +8,7 @@ import { formatSelectionPromptForDisplay } from '../context-menu-storage.js'; import { listRuns } from '../trace/recorder.js'; import { t } from './i18n.js'; import { escapeHtml, escapeAttr } from './utils.js'; +import { renderSkillMarkdown as renderHistoryMarkdown } from './skill-markdown.js'; const listEl = document.getElementById('history-list'); const mainPane = document.getElementById('main-pane'); @@ -254,10 +255,14 @@ function displayRecordTitle(record) { function renderMessage(message) { const role = ['user', 'assistant', 'system', 'error'].includes(message?.role) ? message.role : 'unknown'; + const text = displayMessageText(message); + const renderedText = message?.format === 'markdown' + ? renderHistoryMarkdown(text) + : escapeHtml(text); return `
    ${escapeHtml(t(`hist.role.${role}`))}
    -
    ${escapeHtml(displayMessageText(message))}
    +
    ${renderedText}
    `; } diff --git a/src/firefox/src/ui/sidepanel.js b/src/firefox/src/ui/sidepanel.js index ae6fa824f..8f90362f0 100644 --- a/src/firefox/src/ui/sidepanel.js +++ b/src/firefox/src/ui/sidepanel.js @@ -13,7 +13,12 @@ import { formatSelectionPromptForDisplay, SELECTION_ONLY_SOURCE_GROUNDING, } from '../context-menu-storage.js'; -import { deleteChatHistoryRecord, saveChatHistoryRecord } from './chat-history-store.js'; +import { + deleteChatHistoryRecord, + repairChatHistoryRecordMessages, + saveChatHistoryRecord, +} from './chat-history-store.js'; +import { historyTextFromElement } from './history-text.js'; import { claimRunError } from './run-error-dedupe.js'; import { RUN_CAPTURE_START_ERROR_PREFIX } from '../run-capture.js'; import { runUiUnavailableBeforeSeq } from '../run-ui-journal.js'; @@ -1693,9 +1698,12 @@ function extractChatHistoryMessages(root = messagesEl) { clone.querySelectorAll('button, input, textarea, select, .msg-copy-btn, .code-copy-btn, .error-retry-btn') .forEach((el) => el.remove()); const textEl = clone.querySelector('.message-text') || clone.querySelector('.message-content') || clone; + const role = roleFromMessageElement(msgEl); + const format = role === 'assistant' || role === 'error' ? 'markdown' : 'text'; return { - role: roleFromMessageElement(msgEl), - text: normalizeHistoryText(textEl.textContent), + role, + text: normalizeHistoryText(historyTextFromElement(textEl, { markdown: format === 'markdown' })), + format, index, createdAt: Date.now(), }; @@ -1811,6 +1819,20 @@ async function persistChatHistorySnapshot(tabId, { refreshTabInfo = false } = {} }); } +async function repairRestoredChatHistorySnapshot(tabId) { + const numericTabId = Number(tabId); + if (document.visibilityState === 'hidden' + || !Number.isFinite(numericTabId) + || renderedTabId !== numericTabId) return; + const recordId = chatHistoryRecordIdsByTab.get(numericTabId); + if (!recordId) return; + const messages = extractChatHistoryMessages(messagesEl); + if (!messages.some((message) => message.role === 'user')) return; + await repairChatHistoryRecordMessages(recordId, messages).catch((error) => { + console.warn('[WebBrain] failed to repair restored chat history:', error); + }); +} + function scheduleHistoryPersist(tabId) { if (historyPersistTimer) clearTimeout(historyPersistTimer); historyPersistTimerTabId = tabId; @@ -3592,6 +3614,11 @@ async function init() { // Start observing the messages container for changes to persist. persistObserver.observe(messagesEl, { childList: true, subtree: true, characterData: true }); await restoreActiveRunState(restoreTabId); + if (restoreTabId != null && currentTabId === restoreTabId) { + // Repair only stored message formatting; opening the panel must not make a + // conversation look newer or replace its original mode/page metadata. + await repairRestoredChatHistorySnapshot(restoreTabId); + } restoreLatestChatTurnPosition(); await loadProviders(); diff --git a/src/firefox/src/ui/skill-markdown.js b/src/firefox/src/ui/skill-markdown.js index 2376b7bd6..ecf45ea95 100644 --- a/src/firefox/src/ui/skill-markdown.js +++ b/src/firefox/src/ui/skill-markdown.js @@ -1,6 +1,6 @@ /** - * Small, dependency-free Markdown formatter for read-only skill previews. - * All skill-controlled HTML is escaped before fixed markup is introduced. + * Small, dependency-free Markdown formatter for read-only skill previews and + * saved chat history. All source HTML is escaped before fixed markup is added. */ import { escapeHtml } from './utils.js'; @@ -52,6 +52,15 @@ export function renderSkillMarkdown(content) { text = escapeHtml(text); + const quoteBlocks = []; + text = text.replace(/(?:^[ \t]*>[^\r\n]*(?:\r?\n|$))+/gm, (block) => { + const placeholder = `__SKILL_QUOTE_BLOCK_${quoteBlocks.length}__`; + quoteBlocks.push(block.trimEnd().split(/\r?\n/) + .map((line) => line.replace(/^[ \t]*>[ \t]?/, '')) + .join('\n')); + return `${placeholder}\n`; + }); + // Pull list markers out before parsing emphasis so a `*` bullet cannot be // paired with an emphasis marker later in the same item. const listBlocks = []; @@ -71,6 +80,10 @@ export function renderSkillMarkdown(content) { const block = `<${tag}>${items.map((item) => `
  • ${renderInlineMarkdown(item)}
  • `).join('')}`; text = text.replace(`__SKILL_LIST_BLOCK_${index}__`, () => block); }); + quoteBlocks.forEach((quote, index) => { + const block = `
    ${renderInlineMarkdown(quote).replace(/\n/g, '
    ')}
    `; + text = text.replace(`__SKILL_QUOTE_BLOCK_${index}__`, () => block); + }); inlineCodes.forEach((code, index) => { text = text.replace(`__SKILL_INLINE_CODE_${index}__`, () => `${escapeHtml(code)}`); }); diff --git a/test/run.js b/test/run.js index 88447e062..6cf9e2fe1 100644 --- a/test/run.js +++ b/test/run.js @@ -355,6 +355,9 @@ const { codeFenceLanguage, highlightCode, renderMarkdownHeadings } = await impor const { renderSkillMarkdown } = await import( 'file://' + path.join(ROOT, 'src/chrome/src/ui/skill-markdown.js').replace(/\\/g, '/') ); +const { historyTextFromElement } = await import( + 'file://' + path.join(ROOT, 'src/chrome/src/ui/history-text.js').replace(/\\/g, '/') +); const { RunUiJournal: RunUiJournalCh, @@ -462,6 +465,9 @@ const { codeFenceLanguage: codeFenceLanguageFx, highlightCode: highlightCodeFx, const { renderSkillMarkdown: renderSkillMarkdownFx } = await import( 'file://' + path.join(ROOT, 'src/firefox/src/ui/skill-markdown.js').replace(/\\/g, '/') ); +const { historyTextFromElement: historyTextFromElementFx } = await import( + 'file://' + path.join(ROOT, 'src/firefox/src/ui/history-text.js').replace(/\\/g, '/') +); const { CONTEXT_MENU_CLAIM_LEASE_MS: CONTEXT_MENU_CLAIM_LEASE_MS_CH, SELECTION_ONLY_SOURCE_GROUNDING: SELECTION_ONLY_SOURCE_GROUNDING_CH, @@ -8878,6 +8884,8 @@ test('skill preview protects link destinations and star list markers before emph '[docs](https://host/path/*wildcard*)', '', '* Item with *emphasis*', + '', + '> Quoted **reply**', ].join('\n'); for (const [label, render] of [ ['chrome', renderSkillMarkdown], @@ -8887,6 +8895,7 @@ test('skill preview protects link destinations and star list markers before emph assert.match(out, /href="https:\/\/host\/path\/\*wildcard\*"/, `${label}: wildcard link path changed`); assert.doesNotMatch(out, /href="[^"]*/, `${label}: emphasis leaked into link destination`); assert.match(out, /
    • Item with emphasis<\/em><\/li><\/ul>/, `${label}: star bullet with emphasis was not rendered as a list`); + assert.match(out, /
      Quoted reply<\/strong><\/blockquote>/, `${label}: quote block was not rendered`); } }); @@ -18536,6 +18545,132 @@ test('trace viewer locale changes rerender the active pane', () => { } }); +test('chat history text serialization preserves rendered line structure', () => { + const textNode = (value) => ({ nodeType: 3, nodeValue: value }); + const element = (tagName, ...childNodes) => ({ nodeType: 1, tagName, childNodes }); + const renderedReply = element( + 'DIV', + textNode('You could reply warmly:'), + element('BR'), + textNode('> Hi Richard,'), + element('BR'), + element('BR'), + textNode('> Congratulations on the new role.'), + ); + const structuredReply = element( + 'DIV', + element('H2', textNode('Suggested reply')), + element('BR'), + element('STRONG', textNode('Opening:')), + textNode(' Hello'), + element('DIV', element('PRE', textNode('line 1\nline 2\n'))), + textNode('Closing'), + ); + + for (const [label, serialize] of [ + ['chrome', historyTextFromElement], + ['firefox', historyTextFromElementFx], + ]) { + assert.equal( + serialize(renderedReply), + 'You could reply warmly:\n> Hi Richard,\n\n> Congratulations on the new role.', + `${label}:
      elements, including blank lines, should survive history serialization`, + ); + assert.equal( + serialize(structuredReply).trim(), + '## Suggested reply\n\n**Opening:** Hello\n```\nline 1\nline 2\n```\nClosing', + `${label}: block and inline Markdown semantics should survive serialization`, + ); + assert.equal( + serialize(structuredReply, { markdown: false }).trim(), + 'Suggested reply\n\nOpening: Hello\nline 1\nline 2\nClosing', + `${label}: plain user and system roles should not gain visible Markdown markers`, + ); + } +}); + +test('sidepanel history snapshots use the line-preserving DOM serializer', () => { + for (const [label, panelRel] of [ + ['chrome', 'src/chrome/src/ui/sidepanel.js'], + ['firefox', 'src/firefox/src/ui/sidepanel.js'], + ]) { + const source = fs.readFileSync(path.join(ROOT, panelRel), 'utf8'); + assert.match( + source, + /import \{ historyTextFromElement \} from '\.\/history-text\.js';/, + `${label}: sidepanel should import the history DOM serializer`, + ); + const extractStart = source.indexOf('function extractChatHistoryMessages(root = messagesEl) {'); + const extractEnd = source.indexOf('\n}\n\nfunction chatHistoryHtmlHasUserMessage', extractStart); + assert.notEqual(extractStart, -1, `${label}: history message extractor missing`); + assert.notEqual(extractEnd, -1, `${label}: history message extractor boundary missing`); + const extractBody = source.slice(extractStart, extractEnd); + assert.match( + extractBody, + /text: normalizeHistoryText\(historyTextFromElement\(textEl, \{ markdown: format === 'markdown' \}\)\)/, + `${label}: stored history should preserve rendered line boundaries`, + ); + assert.match( + extractBody, + /const format = role === 'assistant' \|\| role === 'error' \? 'markdown' : 'text'/, + `${label}: only model-authored display roles should be marked as Markdown`, + ); + assert.doesNotMatch( + extractBody, + /normalizeHistoryText\(textEl\.textContent\)/, + `${label}: stored history must not flatten
      elements through textContent`, + ); + } +}); + +test('history page safely renders stored assistant Markdown and keeps plain roles escaped', () => { + for (const [label, prefix] of [ + ['chrome', 'src/chrome'], + ['firefox', 'src/firefox'], + ]) { + const source = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/history.js'), 'utf8'); + const store = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/chat-history-store.js'), 'utf8'); + const html = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/history.html'), 'utf8'); + assert.match(source, /import \{ renderSkillMarkdown as renderHistoryMarkdown \} from '\.\/skill-markdown\.js';/, `${label}: history should use the tested read-only Markdown renderer`); + assert.match(source, /message\?\.format === 'markdown'[\s\S]*?renderHistoryMarkdown\(text\)[\s\S]*?: escapeHtml\(text\)/, `${label}: history should render only explicitly formatted messages as Markdown`); + assert.match(store, /format: message\?\.format === 'markdown' \? 'markdown' : 'text'/, `${label}: history storage should default legacy and untrusted formats to plain text`); + assert.match(html, /\.message-text blockquote \{[\s\S]*?border-left: 3px solid var\(--accent\)/, `${label}: history quotes should have readable styling`); + assert.match(html, /\.message-text pre \{[\s\S]*?white-space: pre;/, `${label}: history code blocks should retain their layout`); + } + + const malicious = '> **Safe quote**\n> \n> [bad](javascript:alert(1))'; + const rendered = renderSkillMarkdown(malicious); + assert.match(rendered, /
      Safe quote<\/strong>
      /); + assert.doesNotMatch(rendered, /]*javascript:/i); + assert.match(rendered, /<img src=x onerror=alert\(1\)>/); +}); + +test('restored history formatting repair preserves record metadata and recency', () => { + for (const [label, prefix] of [ + ['chrome', 'src/chrome'], + ['firefox', 'src/firefox'], + ]) { + const panel = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/sidepanel.js'), 'utf8'); + const store = fs.readFileSync(path.join(ROOT, prefix, 'src/ui/chat-history-store.js'), 'utf8'); + const repairStart = panel.indexOf('async function repairRestoredChatHistorySnapshot(tabId) {'); + const repairEnd = panel.indexOf('\n}\n\nfunction scheduleHistoryPersist', repairStart); + assert.notEqual(repairStart, -1, `${label}: restored history repair helper missing`); + assert.notEqual(repairEnd, -1, `${label}: restored history repair helper boundary missing`); + const repairBody = panel.slice(repairStart, repairEnd); + assert.match(repairBody, /repairChatHistoryRecordMessages\(recordId, messages\)/, `${label}: startup repair should use the content-only store path`); + assert.doesNotMatch(repairBody, /persistChatHistorySnapshot|flushChatHistorySnapshot/, `${label}: startup repair must not stamp ordinary snapshot metadata`); + + const storeRepairStart = store.indexOf('export async function repairChatHistoryRecordMessages(id, messages) {'); + const storeRepairEnd = store.indexOf('\n}\n\n/**\n * List chat history records', storeRepairStart); + assert.notEqual(storeRepairStart, -1, `${label}: content-only history store repair missing`); + assert.notEqual(storeRepairEnd, -1, `${label}: content-only history store repair boundary missing`); + const storeRepairBody = store.slice(storeRepairStart, storeRepairEnd); + assert.match(storeRepairBody, /if \(!existing\) return;/, `${label}: repair must not recreate a deleted or missing record`); + assert.match(storeRepairBody, /createdAt: existing\.createdAt,[\s\S]*?updatedAt: existing\.updatedAt,/, `${label}: repair should preserve record timestamps`); + assert.doesNotMatch(storeRepairBody, /updatedAt: Date\.now\(\)|mode: agentMode|refreshTabInfo/, `${label}: repair must not rewrite recency or live panel metadata`); + } +}); + test('history page refresh rerenders the selected conversation pane', () => { for (const [label, historyRel] of [ ['chrome', 'src/chrome/src/ui/history.js'], @@ -19689,18 +19824,23 @@ test('sidepanel hydrates restored history ids before fallback records', () => { assert.notEqual(restoreDomIdx, -1, `${label}: switchToTab should restore chat HTML`); assert.equal(loadIdx < restoredHasUserIdx && restoredHasUserIdx < hydrateRestoredIdx && hydrateRestoredIdx < postHydrateGuardIdx && postHydrateGuardIdx < restoreDomIdx, true, `${label}: restored chat ids must hydrate before the MutationObserver sees restored HTML`); - const initMatch = panel.match(/async function init\(\) \{([\s\S]*?)\n \/\/ Start observing the messages container/); + const initMatch = panel.match(/async function init\(\) \{([\s\S]*?)\n restoreLatestChatTurnPosition\(\);/); assert.ok(initMatch, `${label}: init restore block missing`); const initBody = initMatch[1]; const initLoadIdx = initBody.indexOf('loadTabChat(restoreTabId, { waitForHandoff: true })'); const initHydrateIdx = initBody.indexOf('await hydrateRestoredChatHistory(restoreTabId, html);'); const initGuardIdx = initBody.indexOf('if (currentTabId === restoreTabId) {', initHydrateIdx); const initDomIdx = initBody.indexOf('messagesEl.innerHTML = html;', initGuardIdx); + const initObserveIdx = initBody.indexOf('persistObserver.observe(messagesEl'); + const initRepairIdx = initBody.indexOf('await repairRestoredChatHistorySnapshot(restoreTabId);'); assert.notEqual(initLoadIdx, -1, `${label}: init should load restored tab chat`); assert.notEqual(initHydrateIdx, -1, `${label}: init should hydrate restored chat history ids`); assert.notEqual(initGuardIdx, -1, `${label}: init should recheck the active tab after hydration`); assert.notEqual(initDomIdx, -1, `${label}: init should restore chat HTML after hydration`); + assert.notEqual(initObserveIdx, -1, `${label}: init persistence observer missing`); + assert.notEqual(initRepairIdx, -1, `${label}: init should content-repair restored durable history snapshots`); assert.equal(initLoadIdx < initHydrateIdx && initHydrateIdx < initGuardIdx && initGuardIdx < initDomIdx, true, `${label}: startup restore must hydrate ids before restoring HTML`); + assert.equal(initDomIdx < initObserveIdx && initObserveIdx < initRepairIdx, true, `${label}: startup should repair history after restoring and observing the chat DOM`); const persistMatch = panel.match(/async function persistChatHistorySnapshot\(tabId, \{ refreshTabInfo = false \} = \{\}\) \{([\s\S]*?)\n\}/); assert.ok(persistMatch, `${label}: persistChatHistorySnapshot missing`); @@ -19721,7 +19861,7 @@ test('sidepanel deletes durable history when clearing conversations', () => { ['firefox', 'src/firefox/src/ui/sidepanel.js'], ]) { const panel = fs.readFileSync(path.join(ROOT, panelRel), 'utf8'); - assert.match(panel, /import \{ deleteChatHistoryRecord, saveChatHistoryRecord \} from '\.\/chat-history-store\.js';/, `${label}: sidepanel should import durable history deletion`); + assert.match(panel, /import \{[\s\S]*?deleteChatHistoryRecord,[\s\S]*?saveChatHistoryRecord,[\s\S]*?\} from '\.\/chat-history-store\.js';/, `${label}: sidepanel should import durable history deletion`); const resetMatch = panel.match(/async function resetChatHistoryStateForTab\(tabId\) \{([\s\S]*?)\n\}/); assert.ok(resetMatch, `${label}: resetChatHistoryStateForTab should be async`); From 926c79c2be6092c01a1124ca995701276150f3de Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Mon, 3 Aug 2026 20:24:46 +0300 Subject: [PATCH 2/3] Avoid duplicate newline after code blocks --- src/chrome/src/ui/history-text.js | 6 ++++-- src/firefox/src/ui/history-text.js | 6 ++++-- test/run.js | 13 +++++++++++++ 3 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/chrome/src/ui/history-text.js b/src/chrome/src/ui/history-text.js index 65237c301..277a39286 100644 --- a/src/chrome/src/ui/history-text.js +++ b/src/chrome/src/ui/history-text.js @@ -74,7 +74,6 @@ export function historyTextFromElement(root, { markdown = true } = {}) { for (const child of Array.from(node.childNodes || [])) visit(child, false, true); ensureBreak(); output += '```'; - ensureBreak(); return; } if (markdown && tagName === 'CODE' && !inPre) { @@ -104,9 +103,12 @@ export function historyTextFromElement(root, { markdown = true } = {}) { } const isBlock = !isRoot && BLOCK_TAGS.has(tagName); + const isRenderedCodeBlock = markdown && node.classList?.contains?.('code-block-wrapper'); if (isBlock) ensureBreak(); for (const child of Array.from(node.childNodes || [])) visit(child, false, inPre); - if (isBlock) ensureBreak(); + // formatMarkdown leaves the source newline after a fenced block as a + // sibling
      . Do not also synthesize a block-boundary newline here. + if (isBlock && !isRenderedCodeBlock) ensureBreak(); }; visit(root, true); diff --git a/src/firefox/src/ui/history-text.js b/src/firefox/src/ui/history-text.js index 65237c301..277a39286 100644 --- a/src/firefox/src/ui/history-text.js +++ b/src/firefox/src/ui/history-text.js @@ -74,7 +74,6 @@ export function historyTextFromElement(root, { markdown = true } = {}) { for (const child of Array.from(node.childNodes || [])) visit(child, false, true); ensureBreak(); output += '```'; - ensureBreak(); return; } if (markdown && tagName === 'CODE' && !inPre) { @@ -104,9 +103,12 @@ export function historyTextFromElement(root, { markdown = true } = {}) { } const isBlock = !isRoot && BLOCK_TAGS.has(tagName); + const isRenderedCodeBlock = markdown && node.classList?.contains?.('code-block-wrapper'); if (isBlock) ensureBreak(); for (const child of Array.from(node.childNodes || [])) visit(child, false, inPre); - if (isBlock) ensureBreak(); + // formatMarkdown leaves the source newline after a fenced block as a + // sibling
      . Do not also synthesize a block-boundary newline here. + if (isBlock && !isRenderedCodeBlock) ensureBreak(); }; visit(root, true); diff --git a/test/run.js b/test/run.js index 6cf9e2fe1..74e591d04 100644 --- a/test/run.js +++ b/test/run.js @@ -18566,6 +18566,14 @@ test('chat history text serialization preserves rendered line structure', () => element('DIV', element('PRE', textNode('line 1\nline 2\n'))), textNode('Closing'), ); + const renderedCodeWrapper = element('DIV', element('PRE', element('CODE', textNode('line 1\n')))); + renderedCodeWrapper.classList = { contains: (name) => name === 'code-block-wrapper' }; + const renderedCodeFollowedByText = element( + 'DIV', + renderedCodeWrapper, + element('BR'), + textNode('Closing'), + ); for (const [label, serialize] of [ ['chrome', historyTextFromElement], @@ -18586,6 +18594,11 @@ test('chat history text serialization preserves rendered line structure', () => 'Suggested reply\n\nOpening: Hello\nline 1\nline 2\nClosing', `${label}: plain user and system roles should not gain visible Markdown markers`, ); + assert.equal( + serialize(renderedCodeFollowedByText), + '```\nline 1\n```\nClosing', + `${label}: the source
      after a rendered code wrapper should provide the only post-fence newline`, + ); } }); From be9edbeca5cbf3d65bab97612ab371cc8b6536cf Mon Sep 17 00:00:00 2001 From: Emre Sokullu Date: Mon, 3 Aug 2026 20:39:51 +0300 Subject: [PATCH 3/3] Preserve fenced code languages in history --- src/chrome/src/ui/history-text.js | 3 ++- src/firefox/src/ui/history-text.js | 3 ++- test/run.js | 13 ++++++++++--- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/chrome/src/ui/history-text.js b/src/chrome/src/ui/history-text.js index 277a39286..28e0f68bd 100644 --- a/src/chrome/src/ui/history-text.js +++ b/src/chrome/src/ui/history-text.js @@ -69,8 +69,9 @@ export function historyTextFromElement(root, { markdown = true } = {}) { return; } if (markdown && tagName === 'PRE') { + const language = String(node.parentElement?.querySelector?.('.code-lang')?.textContent || '').trim(); ensureBreak(); - output += '```\n'; + output += `\`\`\`${language}\n`; for (const child of Array.from(node.childNodes || [])) visit(child, false, true); ensureBreak(); output += '```'; diff --git a/src/firefox/src/ui/history-text.js b/src/firefox/src/ui/history-text.js index 277a39286..28e0f68bd 100644 --- a/src/firefox/src/ui/history-text.js +++ b/src/firefox/src/ui/history-text.js @@ -69,8 +69,9 @@ export function historyTextFromElement(root, { markdown = true } = {}) { return; } if (markdown && tagName === 'PRE') { + const language = String(node.parentElement?.querySelector?.('.code-lang')?.textContent || '').trim(); ensureBreak(); - output += '```\n'; + output += `\`\`\`${language}\n`; for (const child of Array.from(node.childNodes || [])) visit(child, false, true); ensureBreak(); output += '```'; diff --git a/test/run.js b/test/run.js index 74e591d04..16dc05f83 100644 --- a/test/run.js +++ b/test/run.js @@ -18566,8 +18566,15 @@ test('chat history text serialization preserves rendered line structure', () => element('DIV', element('PRE', textNode('line 1\nline 2\n'))), textNode('Closing'), ); - const renderedCodeWrapper = element('DIV', element('PRE', element('CODE', textNode('line 1\n')))); + const renderedCodeLanguage = element('SPAN', textNode('javascript')); + renderedCodeLanguage.textContent = 'javascript'; + const renderedCodeHeader = element('DIV', renderedCodeLanguage); + renderedCodeHeader.classList = { contains: (name) => name === 'code-block-header' }; + const renderedCode = element('PRE', element('CODE', textNode('line 1\n'))); + const renderedCodeWrapper = element('DIV', renderedCodeHeader, renderedCode); renderedCodeWrapper.classList = { contains: (name) => name === 'code-block-wrapper' }; + renderedCodeWrapper.querySelector = (selector) => selector === '.code-lang' ? renderedCodeLanguage : null; + renderedCode.parentElement = renderedCodeWrapper; const renderedCodeFollowedByText = element( 'DIV', renderedCodeWrapper, @@ -18596,8 +18603,8 @@ test('chat history text serialization preserves rendered line structure', () => ); assert.equal( serialize(renderedCodeFollowedByText), - '```\nline 1\n```\nClosing', - `${label}: the source
      after a rendered code wrapper should provide the only post-fence newline`, + '```javascript\nline 1\n```\nClosing', + `${label}: rendered code wrappers should preserve their language and use the source
      as the only post-fence newline`, ); } });