v0.50.218: chat bubble overflow, project color picker, blockquote renderer (#1085)

* fix(css): add overflow-wrap:anywhere to chat bubbles — prevents long URL overflow (#1080)

* fix(projects): rename now works via dblclick timer guard + right-click color picker (#1078)

* fix(renderer): block-level constructs inside blockquotes now render

Fenced code blocks, headings, horizontal rules, and ordered lists inside
blockquotes now render correctly. Six related bugs documented in
blockquote-rendering-bugs.md were collapsed into one architectural fix
in renderMd().

Bugs fixed (all 6):

1. Fenced code blocks inside blockquotes -- > prefixes leaked into the
   <pre> body and the blockquote got fragmented around the rendered
   code, sometimes leaving raw <pre>/<div class="pre-header"> as
   visible text.
2. Blank > continuation lines fragmented multi-paragraph blockquotes
   into separate <blockquote> elements with literal > between them.
3. ## headings inside blockquotes rendered as literal "##" text.
4. Numbered lists inside blockquotes rendered as plain prose.
5. Complex blockquote (mixed headings + code + list + inline code)
   collapsed into a monospace blob with raw markdown syntax leaking
   everywhere.
6. Horizontal rules (---) inside blockquotes rendered as literal text.

Root cause:

The per-line passes for fenced code, headings, hr, ordered lists all ran
BEFORE the blockquote handler and could not match lines that started
with >, so by the time blockquote stripping ran those constructs had
already been mishandled.

Fix:

A new blockquote pre-pass at the top of renderMd():

- Walks lines fence-aware so > -prefixed lines inside non-blockquote
  code fences (e.g. shell prompts in bash code blocks) are not
  miscaptured as a blockquote.
- Groups consecutive > -prefixed lines, strips the > prefix, and
  recursively calls renderMd() on the stripped content. The recursive
  call handles all block-level constructs (fenced code, headings, hr,
  ordered/unordered lists, nested blockquotes) using the same pipeline.
- Wraps the rendered HTML in <blockquote> and stashes it with a \x00Q
  token. Restored at the very end of renderMd() so no later pass can
  mangle the inner HTML.

The old _applyBlockquotes regex-replace is removed entirely along with
its limited inline branches for nested blockquotes and unordered lists.

Behaviour change:

Blockquotes now produce CommonMark-compliant <p> wrapping for text
content (was: bare text directly inside <blockquote>). The visual
output is the same in browsers but the HTML structure is now standard.

Tests:

- 14 new behavioural tests in tests/test_renderer_js_behaviour.py
  drive the actual renderMd() via node and lock all 6 bug fixes.
- .local-review/test_blockquote_bugs.js -- node harness covering the
  same scenarios, runnable manually for fast iteration.
- 2407/2408 tests pass (1 pre-existing macOS-only failure deselected).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(renderer): entity decode before blockquote pre-pass + CSS margin fix

- Move the &gt;/&lt;/&amp; entity-decode to run at the very top of
  renderMd(), before the blockquote pre-pass. Previously decode() ran
  at line 756 (after the pre-pass at line 697), so LLM output containing
  &gt;-encoded blockquotes was never matched by the pre-pass.

- Add .msg-body blockquote p{margin:0} and .preview-md blockquote p{margin:0}
  so the new CommonMark-compliant <p> wrapping inside blockquotes doesn't
  add extra vertical spacing. Prior shape (bare text) had no default p-margins.

- Add Node-driven tests: TestBlockquoteEntityEncodedInput covers &gt; prefix
  and &gt;-encoded fenced code inside blockquotes.

- Add struct test: TestBlockquotePrePassOrdering::test_entity_decode_runs_before_blockquote_pre_pass
  locks decode < _bq_stash ordering in ui.js.

Fixes found during Opus independent review of #1083.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* docs: v0.50.218 release notes, test count 2458, roadmap update

---------

Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: Nathan Esquenazi <nesquena@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nesquena-hermes
2026-04-25 23:08:59 -07:00
committed by GitHub
parent 62adc0c00d
commit 498b51bfc6
9 changed files with 476 additions and 61 deletions
+10
View File
@@ -2,7 +2,17 @@
## [Unreleased]
### Fixed
## v0.50.218 — 2026-04-26
### Fixed
- **Long URL / unbreakable string overflow** — chat bubble boundaries no longer overflow when a message contains very long URLs, file paths, or base64 data. `overflow-wrap: anywhere` added to `.msg-body` and the user-bubble variant so continuous non-whitespace text wraps at the column edge instead of bleeding into adjacent layout areas. (`static/style.css`) Closes #1080 [#1081]
- **Project chip rename now works** — double-clicking a project chip now reliably triggers the rename input. Root cause: `onclick` was calling `renderSessionListFromCache()` which destroyed the chip DOM node before `ondblclick` could fire. Fixed with a 220ms `_clickTimer` delay on `onclick` (same pattern used by session items), so a double-click cancels the single-click and invokes rename instead. (`static/sessions.js`) Closes #1078 [#1082]
- **Block-level constructs inside blockquotes** — fenced code blocks, headings, horizontal rules, and ordered lists inside blockquotes now render correctly; `&gt;`-entity-encoded blockquotes from LLM output also render correctly (entity decode moved before the blockquote pre-pass). New pre-pass walks lines fence-aware, strips `>` prefix, recursively renders stripped content with the full pipeline, stashes rendered HTML with `\x00Q` token. (`static/ui.js`, `static/style.css`) [#1083]
### Added
- **Project color picker** — right-clicking a project chip now shows a context menu with Rename, a row of color swatches, and Delete. Selecting a swatch updates the project color via `/api/projects/rename`. (`static/sessions.js`) Closes #1078 [#1082]
## v0.50.217 — 2026-04-26
### Fixed
+1 -1
View File
@@ -3,7 +3,7 @@
> Goal: Full 1:1 parity with the Hermes CLI experience via a clean dark web UI.
> Everything you can do from the CLI terminal, you can do from this UI.
>
> Last updated: v0.50.217 (April 26, 2026) — 2442 tests collected
> Last updated: v0.50.218 (April 26, 2026) — 2458 tests collected
> Tests: 2107 collected (`pytest tests/ --collect-only -q`)
> Source: <repo>/
+1 -1
View File
@@ -8,7 +8,7 @@
> Prerequisites: SSH tunnel is active on port 8787. Open http://localhost:8787 in browser.
> Server health check: curl http://127.0.0.1:8787/health should return {"status":"ok"}.
>
> Automated coverage: 2442 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
> Automated coverage: 2458 tests collected via `pytest tests/ --collect-only -q`. Includes onboarding coverage for bootstrap/static wizard presence, real provider config persistence (`config.yaml` + `.env`), the `/api/onboarding/*` backend, the onboarding skip/existing-config guard, and CSS regression coverage for smooth thinking/tool card disclosure animation.
> Run: `pytest tests/ -v --timeout=60`
>
> Local regression focus: verify that a previously closed workspace panel stays visually closed from first paint through boot completion on desktop refresh; there should be no brief open-then-close flash.
+58 -3
View File
@@ -779,9 +779,13 @@ function renderSessionListFromCache(){
const nameSpan=document.createElement('span');
nameSpan.textContent=p.name;
chip.appendChild(nameSpan);
chip.onclick=()=>{_activeProject=p.project_id;renderSessionListFromCache();};
chip.ondblclick=(e)=>{e.stopPropagation();_startProjectRename(p,chip);};
chip.oncontextmenu=(e)=>{e.preventDefault();_confirmDeleteProject(p);};
let _pClickTimer=null;
chip.onclick=(e)=>{
clearTimeout(_pClickTimer);
_pClickTimer=setTimeout(()=>{_pClickTimer=null;_activeProject=p.project_id;renderSessionListFromCache();},220);
};
chip.ondblclick=(e)=>{e.stopPropagation();clearTimeout(_pClickTimer);_pClickTimer=null;_startProjectRename(p,chip);};
chip.oncontextmenu=(e)=>{e.preventDefault();_showProjectContextMenu(e,p,chip);};
bar.appendChild(chip);
}
// Create button
@@ -1232,6 +1236,57 @@ function _startProjectRename(proj, chip){
setTimeout(()=>{inp.focus();inp.select();},10);
}
function _showProjectContextMenu(e, proj, chip){
document.querySelectorAll('.project-ctx-menu').forEach(el=>el.remove());
const menu=document.createElement('div');
menu.className='project-ctx-menu';
menu.style.cssText='position:fixed;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:6px 0;z-index:9999;min-width:140px;box-shadow:0 4px 16px rgba(0,0,0,.35);';
menu.style.left=e.clientX+'px';
menu.style.top=e.clientY+'px';
// Rename option
const renameItem=document.createElement('div');
renameItem.textContent='Rename';
renameItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--text);';
renameItem.onmouseenter=()=>renameItem.style.background='var(--hover)';
renameItem.onmouseleave=()=>renameItem.style.background='';
renameItem.onclick=()=>{menu.remove();_startProjectRename(proj,chip);};
menu.appendChild(renameItem);
// Color picker row
const colorRow=document.createElement('div');
colorRow.style.cssText='display:flex;gap:5px;padding:7px 14px;align-items:center;';
PROJECT_COLORS.forEach(hex=>{
const dot=document.createElement('span');
dot.style.cssText=`width:16px;height:16px;border-radius:50%;background:${hex};cursor:pointer;display:inline-block;flex-shrink:0;`;
if(hex===(proj.color||'')) dot.style.outline='2px solid var(--text)';
dot.onclick=async()=>{
menu.remove();
await api('/api/projects/rename',{method:'POST',body:JSON.stringify({project_id:proj.project_id,name:proj.name,color:hex})});
await renderSessionList();
showToast('Color updated');
};
colorRow.appendChild(dot);
});
menu.appendChild(colorRow);
// Divider + Delete
const sep=document.createElement('hr');
sep.style.cssText='border:none;border-top:1px solid var(--border);margin:4px 0;';
menu.appendChild(sep);
const delItem=document.createElement('div');
delItem.textContent='Delete';
delItem.style.cssText='padding:7px 14px;cursor:pointer;font-size:13px;color:var(--error,#e94560);';
delItem.onmouseenter=()=>delItem.style.background='var(--hover)';
delItem.onmouseleave=()=>delItem.style.background='';
delItem.onclick=()=>{menu.remove();_confirmDeleteProject(proj);};
menu.appendChild(delItem);
document.body.appendChild(menu);
const dismiss=()=>{menu.remove();document.removeEventListener('click',dismiss);};
setTimeout(()=>document.addEventListener('click',dismiss),0);
}
async function _confirmDeleteProject(proj){
const ok=await showConfirmDialog({
message:'Delete project "'+proj.name+'"? Sessions will be unassigned but not deleted.',
+4 -1
View File
@@ -568,7 +568,7 @@
.role-icon{width:22px;height:22px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:700;flex-shrink:0;}
.role-icon.user{background:var(--accent-bg);color:var(--accent-text);border:1px solid var(--accent-bg-strong);}
.role-icon.assistant{background:var(--accent-bg-strong);color:var(--accent-text);border:1px solid var(--accent-bg-strong);}
.msg-body{font-size:14px;line-height:1.75;color:var(--text);padding-left:30px;max-width:680px;}
.msg-body{font-size:14px;line-height:1.75;color:var(--text);padding-left:30px;max-width:680px;overflow-wrap:anywhere;}
.msg-body p{margin-bottom:10px;}.msg-body p:last-child{margin-bottom:0;}
.msg-body ul,.msg-body ol{margin:6px 0 10px 20px;}.msg-body li{margin-bottom:3px;}
.msg-body h1,.msg-body h2,.msg-body h3{margin:16px 0 6px;font-weight:600;}
@@ -583,6 +583,7 @@
.pre-header::before{content:'';width:8px;height:8px;border-radius:50%;background:var(--muted);opacity:.4;}
.pre-header+pre{border-radius:0 0 10px 10px;border-top:none;margin-top:0;}
.msg-body blockquote{border-left:3px solid var(--blue);padding-left:14px;color:var(--muted);font-style:italic;margin:10px 0;}
.msg-body blockquote p{margin:0;}
.msg-body a{color:var(--blue);text-decoration:underline;}
.msg-body hr{border:none;border-top:1px solid var(--border);margin:14px 0;}
.msg-body table{border-collapse:collapse;width:100%;margin:8px 0;font-size:12px;}
@@ -761,6 +762,7 @@
/* Keep original theme background — prevent prism-tomorrow from overriding --code-bg */
.preview-md pre[class*="language-"],.preview-md pre code[class*="language-"]{background:var(--code-bg) !important;}
.preview-md blockquote{border-left:3px solid var(--blue);padding-left:12px;color:var(--muted);font-style:italic;margin:8px 0;}
.preview-md blockquote p{margin:0;}
.preview-md strong{color:var(--strong);font-weight:600;}.preview-md em{color:var(--em);}
.preview-md a{color:var(--blue);text-decoration:underline;}
.preview-md hr{border:none;border-top:1px solid var(--border);margin:12px 0;}
@@ -1969,6 +1971,7 @@ main.main.showing-profiles > #mainProfiles{display:flex;}
padding-left: 14px;
max-width: none;
color: var(--user-bubble-text);
overflow-wrap: anywhere;
}
.msg-row[data-role="user"] .msg-body::selection,
.msg-row[data-role="user"] .msg-body *::selection {
+69 -38
View File
@@ -681,6 +681,68 @@ function _sanitizeThinkingDisplayText(text){
function renderMd(raw){
let s=(raw||'').replace(/\r\n/g,'\n').replace(/\r/g,'\n');
// ── Entity decode: must run FIRST so &gt; lines become > for the blockquote
// pre-pass below. LLMs sometimes emit HTML-entity-encoded output; without this
// a blockquote sent as "&gt; text" would never be recognised as a blockquote.
s=s.replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&amp;/g,'&').replace(/&quot;/g,'"').replace(/&#39;/g,"'");
// ── Blockquote pre-pass (must run BEFORE every other markdown pass) ────────
// Group consecutive >-prefixed lines, strip the > prefix from each line,
// recursively render the stripped content with the full pipeline, and
// replace the group with a stash token. This is the only way fenced code,
// headings, hr, and ordered lists inside a blockquote can render correctly:
// the per-line passes downstream don't know about > prefixes, and by the
// time the blockquote handler used to run those passes had already mangled
// the >-prefixed lines.
//
// Walks lines (instead of using a single regex) so >-prefixed lines that
// sit inside a non-blockquote fenced block (e.g. a shell prompt in a
// ```bash``` example) are not miscaptured as a blockquote.
const _bq_stash=[];
s=(function _applyBlockquotes(input){
const lines=input.split('\n');
const out=[];
let inFence=false; // inside a non-blockquote ```...``` fence
let bqStart=-1;
const flush=(end)=>{
if(bqStart<0) return;
// Strip "> " prefix (and bare ">" → empty) from each line
const stripped=lines.slice(bqStart,end).map(l=>l.replace(/^> ?/,'')).join('\n');
// Recursive call: full pipeline on stripped content. Handles fenced
// code, headings, hr, ordered/unordered lists, nested blockquotes
// (>>) — anything that renderMd handles at the top level.
const rendered=renderMd(stripped);
_bq_stash.push('<blockquote>'+rendered+'</blockquote>');
// Surround the token with blank lines so the paragraph splitter
// isolates it as its own chunk (otherwise the token gets wrapped
// in <p>...<br> with adjacent text, producing invalid HTML).
out.push('');
out.push('\x00Q'+(_bq_stash.length-1)+'\x00');
out.push('');
bqStart=-1;
};
for(let i=0;i<lines.length;i++){
const line=lines[i];
if(inFence){
out.push(line);
if(/^```/.test(line)) inFence=false;
continue;
}
if(/^```/.test(line)){
flush(i);
out.push(line);
inFence=true;
continue;
}
if(/^>/.test(line)){
if(bqStart<0) bqStart=i;
} else {
flush(i);
out.push(line);
}
}
flush(lines.length);
return out.join('\n');
})(s);
// ── MEDIA: token stash (must run first, before any other processing) ───────
// Detect MEDIA:<path-or-url> tokens emitted by the agent (e.g. screenshots,
// generated images) and replace them with inline <img> or download links.
@@ -781,43 +843,8 @@ function renderMd(raw){
s=s.replace(/\x00O(\d+)\x00/g,(_,i)=>_ob_stash[+i]);
s=s.replace(/^### (.+)$/gm,(_,t)=>`<h3>${inlineMd(t)}</h3>`).replace(/^## (.+)$/gm,(_,t)=>`<h2>${inlineMd(t)}</h2>`).replace(/^# (.+)$/gm,(_,t)=>`<h1>${inlineMd(t)}</h1>`);
s=s.replace(/^---+$/gm,'<hr>');
// Group consecutive > lines into one <blockquote>.
// Handles: blank continuation lines (> alone), nested blockquotes (>>),
// lists inside blockquotes (> - item), and inline markdown in quoted text.
function _applyBlockquotes(src){
return src.replace(/((?:^>[^\n]*(?:\n|$))+)/gm,block=>{
const lines=block.split('\n');
// Drop trailing bare '>' artifact
while(lines.length&&(lines[lines.length-1].trim()==='>'||lines[lines.length-1]===''))
{if(lines[lines.length-1].trim()==='>'){lines.pop();break;}lines.pop();}
const stripped=lines.map(l=>l.replace(/^>[ \t]?/,''));
const innerRaw=stripped.join('\n');
let inner;
if(/^>/m.test(innerRaw)){
// Nested blockquote: recurse so >> → <blockquote><blockquote>
inner=_applyBlockquotes(innerRaw);
} else if(/(^(?: )?[-*+] .+)/m.test(innerRaw)){
// List inside blockquote: run list pass on stripped inner content
inner=innerRaw.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,lb=>{
const ll=lb.trimEnd().split('\n');let h='<ul>';
for(const li of ll){
const txt=li.replace(/^ {0,4}[-*+] /,'');
let ih;
if(/^\[x\] /i.test(txt)) ih='<span class="task-done">✅</span> '+inlineMd(txt.slice(4));
else if(/^\[ \] /.test(txt)) ih='<span class="task-todo">☐</span> '+inlineMd(txt.slice(4));
else ih=inlineMd(txt);
h+=`<li>${ih}</li>`;
}
return h+'</ul>';
});
} else {
// Plain lines: blank line → <br>, text → inlineMd
inner=stripped.map(l=>l.trim()===''?'<br>':inlineMd(l)).join('\n');
}
return `<blockquote>${inner}</blockquote>`;
});
}
s=_applyBlockquotes(s);
// (Blockquotes are handled by the pre-pass at the top of renderMd, before
// fence_stash. The per-line passes below never see > prefixes.)
// B8: improved list handling supporting up to 2 levels of indentation
s=s.replace(/((?:^(?: )?[-*+] .+\n?)+)/gm,block=>{
const lines=block.trimEnd().split('\n');
@@ -911,7 +938,7 @@ function renderMd(raw){
return '\x00E'+(_pre_stash.length-1)+'\x00';
});
const parts=s.split(/\n{2,}/);
s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)|^\x00E/.test(p))return p;return `<p>${p.replace(/\n/g,'<br>')}</p>`;}).join('\n');
s=parts.map(p=>{p=p.trim();if(!p)return '';if(/^<(h[1-6]|ul|ol|pre|hr|blockquote)|^\x00[EQ]/.test(p))return p;return `<p>${p.replace(/\n/g,'<br>')}</p>`;}).join('\n');
s=s.replace(/\x00E(\d+)\x00/g,(_,i)=>_pre_stash[+i]);
// ── Restore MEDIA stash → inline images or download links ─────────────────
s=s.replace(/\x00D(\d+)\x00/g,(_,i)=>{
@@ -945,6 +972,10 @@ function renderMd(raw){
return `<a class="msg-media-link" href="${esc(apiUrl+'&download=1')}" download="${fname}">📎 ${fname}</a>`;
});
// ── End MEDIA restore ──────────────────────────────────────────────────────
// Restore blockquote stash. Done last so the inner HTML (already produced
// by the recursive renderMd in the pre-pass) is dropped into the final
// string verbatim — no further passes can mangle it.
s=s.replace(/\x00Q(\d+)\x00/g,(_,i)=>_bq_stash[+i]);
return s;
}
+7 -3
View File
@@ -47,15 +47,19 @@ class TestCodeBlockNewlinePreservation:
"_pre_stash must be restored after the paragraph split/join"
def test_paragraph_split_bypasses_stash_tokens(self):
"""The paragraph map must bypass lines that start with \\x00E."""
"""The paragraph map must bypass lines that start with \\x00E (pre stash).
Also accepts a character class like \\x00[EQ] when other stash tokens
share the same bypass (e.g. \\x00Q for blockquote stash)."""
src = get_ui_js()
# The map line must check for \x00E in its bypass condition
map_line = next(
l for l in src.splitlines()
if 'parts.map' in l and '<br>' in l
)
assert r'\x00E' in map_line, \
r"paragraph map must bypass \x00E stash tokens"
assert r'\x00E' in map_line or r'\x00[E' in map_line, (
r"paragraph map must bypass \x00E stash tokens (literally or as "
r"part of a character class like \x00[EQ])"
)
def test_pre_regex_covers_pre_header_div(self):
"""The stash regex must match <div class=\"pre-header\"> before <pre>."""
+46 -6
View File
@@ -73,16 +73,34 @@ class TestBlockquoteSourceStructure:
" lines and creates one <blockquote> per line"
)
def test_new_group_rule_present(self):
"""The new grouping regex must be present."""
assert "(?:^>[^\\n]*(?:\\n|$))+" in UI_JS, (
"New group-based blockquote rule not found in ui.js"
def test_blockquote_pre_pass_present(self):
"""The blockquote pre-pass (line walker + recursive render + stash)
must be present in ui.js."""
assert "_bq_stash" in UI_JS, (
"Blockquote stash array (_bq_stash) not found — pre-pass missing"
)
assert "_applyBlockquotes" in UI_JS, (
"_applyBlockquotes line-walker function not found"
)
def test_prefix_strip_present(self):
"""The fix must strip the '> ' prefix from each line."""
assert "replace(/^>[" in UI_JS or "replace(/^>[ " in UI_JS, (
"Expected prefix-strip pattern not found in the blockquote block"
assert "replace(/^> ?/" in UI_JS, (
"Expected prefix-strip pattern `^> ?` not found in the blockquote block"
)
def test_bq_stash_token_in_paragraph_bypass(self):
"""\\x00Q must be in the paragraph-splitter bypass so blockquote
stash tokens are not wrapped in <p>."""
assert r"\x00[EQ]" in UI_JS, (
"Paragraph-splitter bypass must accept \\x00Q (blockquote token) "
"alongside \\x00E (pre stash token)"
)
def test_bq_stash_restore_present(self):
"""The stash restore must run at the end of renderMd."""
assert r"\x00Q(\d+)\x00" in UI_JS, (
"Blockquote stash restore regex not found in ui.js"
)
@@ -213,3 +231,25 @@ class TestBlockquoteFollowedByParagraph:
# Normal paragraph must be outside the blockquote
after_bq = out[out.index("</blockquote>"):]
assert "Normal paragraph" in after_bq
class TestBlockquotePrePassOrdering:
"""Structural checks that lock the ordering of the blockquote pre-pass
relative to the entity-decode and MEDIA-stash passes in renderMd()."""
def test_entity_decode_runs_before_blockquote_pre_pass(self):
"""The entity decode must appear BEFORE the blockquote pre-pass in
renderMd() so &gt;-prefixed lines are recognised as blockquotes."""
# The entity decode is represented by '&gt;' replacement or the
# inline decode line, whichever appears first.
decode_idx = min(
UI_JS.find("replace(/&gt;/g"),
UI_JS.find("replace(/&lt;/g"),
)
bq_stash_idx = UI_JS.find("_bq_stash")
assert decode_idx != -1, "Entity decode (&gt; or &lt;) not found in renderMd"
assert bq_stash_idx != -1, "_bq_stash not found"
assert decode_idx < bq_stash_idx, (
"Entity decode must appear before the blockquote pre-pass (_bq_stash). "
f"decode at {decode_idx}, _bq_stash at {bq_stash_idx}"
)
+280 -8
View File
@@ -13,6 +13,7 @@ Add a case here whenever the renderer fix targets a class of input the
Python mirror cannot exercise faithfully.
"""
import os
import re
import shutil
import subprocess
import sys
@@ -94,17 +95,18 @@ class TestBlockquotePrefixStrip:
def test_single_line_blockquote_no_leading_space(self, driver_path):
out = _render(driver_path, "> Hello world").strip()
assert "<blockquote>Hello world</blockquote>" in out, (
f"`> Hello world` must render as <blockquote>Hello world</blockquote> "
f"with no leading space. Got: {out!r}. Likely cause: prefix-strip "
f"regex consumes only \\t, not space."
# New shape: recursive renderMd wraps content in <p> (CommonMark-correct).
assert "<blockquote><p>Hello world</p></blockquote>" in out, (
f"`> Hello world` must render as <blockquote><p>Hello world</p></blockquote> "
f"with no leading space. Got: {out!r}."
)
def test_multiline_blockquote_no_leading_space(self, driver_path):
out = _render(driver_path, "> Line one\n> Line two").strip()
assert ">Line one\nLine two<" in out, (
f"Multi-line blockquote must strip the space after each `>`. "
f"Got: {out!r}"
# New shape: single paragraph with <br> between soft-wrapped lines.
assert "<blockquote><p>Line one<br>Line two</p></blockquote>" in out, (
f"Multi-line blockquote must strip the space after each `>` and "
f"render as a single paragraph. Got: {out!r}"
)
# Belt-and-braces: there must be no space-after-newline-in-content
assert "\n " not in out.replace("</blockquote>", ""), (
@@ -164,7 +166,7 @@ class TestCommonLLMShapes:
def test_quote_then_heading(self, driver_path):
out = _render(driver_path, "> Note this.\n\n## Heading")
assert "<blockquote>Note this.</blockquote>" in out
assert "<blockquote><p>Note this.</p></blockquote>" in out
assert "<h2>Heading</h2>" in out
def test_crlf_does_not_leak_carriage_return(self, driver_path):
@@ -189,3 +191,273 @@ class TestCommonLLMShapes:
assert "And a closing remark." in out
# No leading-space artifacts in the quoted text
assert "\n " not in out.replace("</blockquote>", "")
# ─────────────────────────────────────────────────────────────────────────────
# Block-level constructs INSIDE blockquotes — the six bugs documented in
# blockquote-rendering-bugs.md. Each test feeds the exact input from the
# bug report and asserts the rendered HTML structure.
#
# Root cause of all six: every block-level pass (fenced code, headings, hr,
# ordered lists) used to run BEFORE the blockquote handler, on > -prefixed
# lines those passes don't recognise. The fix moved blockquote handling to a
# pre-pass that strips > prefixes and recursively renders the inner content.
# ─────────────────────────────────────────────────────────────────────────────
class TestBugFencedCodeInBlockquote:
"""Bug 1: fenced code blocks inside blockquotes leaked > prefixes inside
the rendered <pre>, broke the <blockquote> wrapper, and sometimes left
raw <pre>/<div class="pre-header"> as visible text."""
def test_fenced_code_inside_blockquote_renders_pre(self, driver_path):
src = (
"> Here is some code:\n"
">\n"
"> ```python\n"
"> x = 1\n"
"> y = 2\n"
"> ```\n"
">\n"
"> That was the code."
)
out = _render(driver_path, src)
assert "<pre>" in out and "</pre>" in out, (
f"Fenced code inside blockquote must render as <pre>: {out!r}"
)
# The > prefixes must be stripped from the code content, not preserved
# inside the <pre>.
assert "&gt; x = 1" not in out, (
f"Code content inside <pre> must not contain &gt; prefixes: {out!r}"
)
# Raw <pre> or pre-header tags must NOT appear as visible text
assert "&lt;pre&gt;" not in out
assert "&lt;div class=&quot;pre-header" not in out
# Single <blockquote> wrapping everything (not split by the <pre>)
assert out.count("<blockquote>") == 1, (
f"Expected ONE <blockquote>, got {out.count('<blockquote>')}: {out!r}"
)
def test_fenced_code_with_lang_class(self, driver_path):
src = "> ```python\n> x = 1\n> ```"
out = _render(driver_path, src)
assert 'class="language-python"' in out
assert "x = 1" in out
class TestBugBlankContinuationInBlockquote:
"""Bug 2: blank > lines between paragraphs fragmented the blockquote into
separate elements with literal > characters between them."""
def test_three_paragraphs_one_blockquote(self, driver_path):
src = (
"> First paragraph of the quote.\n"
">\n"
"> Second paragraph of the quote.\n"
">\n"
"> Third paragraph of the quote."
)
out = _render(driver_path, src)
# All three paragraphs in ONE <blockquote>
assert out.count("<blockquote>") == 1, (
f"Expected ONE <blockquote>, got {out.count('<blockquote>')}: {out!r}"
)
assert "First paragraph" in out
assert "Second paragraph" in out
assert "Third paragraph" in out
# No literal > between paragraphs (would indicate fragmented blockquote)
text_only = re.sub(r"<[^>]+>", "", out)
assert ">" not in text_only, (
f"Literal > in rendered text indicates fragmented blockquote: {text_only!r}"
)
class TestBugHeadingsInsideBlockquote:
"""Bug 3: # headings inside blockquotes rendered as literal '##' text
because the heading pass ran before the blockquote pass."""
def test_h2_inside_blockquote(self, driver_path):
src = (
"> ## Bug description\n"
">\n"
"> The widget is broken.\n"
">\n"
"> ## Steps to reproduce\n"
">\n"
"> Click the button."
)
out = _render(driver_path, src)
assert "<h2>Bug description</h2>" in out, (
f"## inside blockquote must render as <h2>: {out!r}"
)
assert "<h2>Steps to reproduce</h2>" in out
# No literal '##' as visible text
text_only = re.sub(r"<[^>]+>", "", out)
assert "##" not in text_only, (
f"Literal ## in rendered text — heading pass missed it: {text_only!r}"
)
def test_h1_h2_h3_all_render(self, driver_path):
src = "> # H1\n> ## H2\n> ### H3"
out = _render(driver_path, src)
assert "<h1>H1</h1>" in out
assert "<h2>H2</h2>" in out
assert "<h3>H3</h3>" in out
class TestBugOrderedListInsideBlockquote:
"""Bug 4: ordered (numbered) lists inside blockquotes rendered as plain
text the OL pass had no equivalent of the UL branch in the old
blockquote handler."""
def test_ordered_list_renders_as_ol(self, driver_path):
src = (
"> Steps to reproduce:\n"
">\n"
"> 1. Open the app\n"
"> 2. Click the button\n"
"> 3. Observe the crash"
)
out = _render(driver_path, src)
assert "<ol>" in out and "</ol>" in out, (
f"Numbered list inside blockquote must render as <ol>: {out!r}"
)
# All three list items present
for item in ["Open the app", "Click the button", "Observe the crash"]:
assert f">{item}</li>" in out, (
f"Missing <li>{item}</li> in {out!r}"
)
class TestBugHorizontalRuleInsideBlockquote:
"""Bug 6: --- inside a blockquote rendered as literal text instead of <hr>."""
def test_hr_renders_inside_blockquote(self, driver_path):
src = "> Above the rule\n>\n> ---\n>\n> Below the rule"
out = _render(driver_path, src)
assert "<hr>" in out, (
f"--- inside blockquote must render as <hr>: {out!r}"
)
assert "Above the rule" in out
assert "Below the rule" in out
# No literal '---' as text
text_only = re.sub(r"<[^>]+>", "", out)
assert "---" not in text_only, (
f"Literal --- in rendered text: {text_only!r}"
)
class TestBugComplexBlockquoteAllFeatures:
"""Bug 5 (worst-case): a blockquote with headings, paragraphs, inline code,
fenced code, and an ordered list. Old behaviour collapsed the entire thing
into a monospace blob with raw markdown syntax leaking everywhere."""
def test_complex_blockquote_renders_all_constructs(self, driver_path):
src = (
"> ## Description\n"
">\n"
"> The widget is broken when X happens.\n"
">\n"
"> ## Root cause\n"
">\n"
"> The `MIME_MAP` in `api/config.py` is missing entries.\n"
">\n"
"> ## Fix\n"
">\n"
"> Add two entries:\n"
">\n"
"> ```python\n"
'> ".html": "text/html",\n'
'> ".htm": "text/html",\n'
"> ```\n"
">\n"
"> ## Workflow rules\n"
">\n"
"> 1. Never edit the file directly\n"
"> 2. Create a worktree\n"
"> 3. Run the tests\n"
">\n"
"> Target branch is `master`."
)
out = _render(driver_path, src)
# Multiple <h2> headings
assert out.count("<h2>") >= 4, (
f"Expected at least 4 <h2> headings, got {out.count('<h2>')}: {out!r}"
)
# Fenced code block
assert "<pre>" in out
assert 'class="language-python"' in out
# Ordered list
assert "<ol>" in out
# Inline code
assert "<code>MIME_MAP</code>" in out
assert "<code>api/config.py</code>" in out
assert "<code>master</code>" in out
# No literal markdown syntax leaking
text_only = re.sub(r"<[^>]+>", "", out)
assert "##" not in text_only, f"Literal ## in {text_only!r}"
# Single <blockquote> wraps everything
assert out.count("<blockquote>") == 1, (
f"Expected ONE <blockquote>, got {out.count('<blockquote>')}: {out!r}"
)
# No raw <pre>/<div class="pre-header"> as escaped text
assert "&lt;pre&gt;" not in out
assert "&lt;div class=&quot;pre-header" not in out
class TestBlockquoteRegressionsDontTouchOutsideContent:
"""Make sure the blockquote pre-pass doesn't grab > -prefixed lines that
sit inside a non-blockquote fenced code block (e.g. shell prompts in
```bash``` examples)."""
def test_shell_prompt_in_bash_fence_not_treated_as_blockquote(self, driver_path):
src = "```bash\n> echo hello\n```"
out = _render(driver_path, src)
# The > line is part of the bash code, not a blockquote
assert "<blockquote>" not in out, (
f"> line inside ```bash``` must NOT become a blockquote: {out!r}"
)
assert "<pre>" in out
# Escaped > preserved as code content
assert "&gt; echo hello" in out
def test_two_separate_blockquotes_stay_separate(self, driver_path):
src = "> First quote\n\nSome plain text.\n\n> Second quote"
out = _render(driver_path, src)
assert out.count("<blockquote>") == 2, (
f"Two separated blockquotes must stay separate: {out!r}"
)
assert "Some plain text." in out
def test_nested_double_blockquote(self, driver_path):
src = "> outer line\n> > inner line"
out = _render(driver_path, src)
# Should produce nested <blockquote><blockquote>
assert out.count("<blockquote>") == 2, (
f"Expected 2 <blockquote>: {out!r}"
)
class TestBlockquoteEntityEncodedInput:
"""Blockquotes sent as HTML-entity-encoded text must still render correctly.
LLMs sometimes emit &gt; instead of > the entity-decode pass must run
BEFORE the blockquote pre-pass, not after it."""
def test_amp_gt_prefix_becomes_blockquote(self, driver_path):
src = "&gt; Hello quote"
out = _render(driver_path, src)
assert "<blockquote>" in out, (
f"&gt;-prefixed line must render as <blockquote>: {out!r}"
)
text_only = re.sub(r"<[^>]+>", "", out)
assert "Hello quote" in text_only
# Should not see a literal > or &gt; in the rendered text
assert "&gt;" not in out, f"&gt; should have been decoded: {out!r}"
def test_amp_gt_fenced_code_in_blockquote(self, driver_path):
src = "&gt; ```python\n&gt; x = 1\n&gt; ```"
out = _render(driver_path, src)
assert "<blockquote>" in out, (
f"Entity-encoded blockquote with fenced code must render: {out!r}"
)
assert "<pre>" in out, f"Fenced code inside entity-encoded blockquote must render: {out!r}"