Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions src/chrome/src/ui/chat-history-store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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<Object>} messages - Messages recovered from restored chat DOM.
* @returns {Promise<Object|null>} 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 }.
Expand Down
117 changes: 117 additions & 0 deletions src/chrome/src/ui/history-text.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Serialize rendered chat DOM back to readable Markdown for durable history.
*
* textContent intentionally ignores visual structure such as <br> and block
* boundaries, while semantic elements such as <strong> 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 <br> represents a source newline, including consecutive
// <br>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') {
const language = String(node.parentElement?.querySelector?.('.code-lang')?.textContent || '').trim();
ensureBreak();
output += `\`\`\`${language}\n`;
for (const child of Array.from(node.childNodes || [])) visit(child, false, true);
ensureBreak();
output += '```';
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);
const isRenderedCodeBlock = markdown && node.classList?.contains?.('code-block-wrapper');
if (isBlock) ensureBreak();
for (const child of Array.from(node.childNodes || [])) visit(child, false, inPre);
// formatMarkdown leaves the source newline after a fenced block as a
// sibling <br>. Do not also synthesize a block-boundary newline here.
if (isBlock && !isRenderedCodeBlock) ensureBreak();
};

visit(root, true);
return output;
}
37 changes: 37 additions & 0 deletions src/chrome/src/ui/history.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 6 additions & 1 deletion src/chrome/src/ui/history.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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 `
<article class="message ${escapeAttr(role)}">
<div class="message-role">${escapeHtml(t(`hist.role.${role}`))}</div>
<div class="message-text">${escapeHtml(displayMessageText(message))}</div>
<div class="message-text">${renderedText}</div>
</article>
`;
}
Expand Down
33 changes: 30 additions & 3 deletions src/chrome/src/ui/sidepanel.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(),
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
17 changes: 15 additions & 2 deletions src/chrome/src/ui/skill-markdown.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -52,6 +52,15 @@ export function renderSkillMarkdown(content) {

text = escapeHtml(text);

const quoteBlocks = [];
text = text.replace(/(?:^[ \t]*&gt;[^\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]*&gt;[ \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 = [];
Expand All @@ -71,6 +80,10 @@ export function renderSkillMarkdown(content) {
const block = `<${tag}>${items.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join('')}</${tag}>`;
text = text.replace(`__SKILL_LIST_BLOCK_${index}__`, () => block);
});
quoteBlocks.forEach((quote, index) => {
const block = `<blockquote>${renderInlineMarkdown(quote).replace(/\n/g, '<br>')}</blockquote>`;
text = text.replace(`__SKILL_QUOTE_BLOCK_${index}__`, () => block);
});
inlineCodes.forEach((code, index) => {
text = text.replace(`__SKILL_INLINE_CODE_${index}__`, () => `<code>${escapeHtml(code)}</code>`);
});
Expand Down
Loading
Loading