From 498b51bfc6f2d8efbdcfb72abc3bce1a859955fe Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Sat, 25 Apr 2026 23:08:59 -0700 Subject: [PATCH] v0.50.218: chat bubble overflow, project color picker, blockquote renderer (#1085) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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
 body and the blockquote got fragmented around the rendered
   code, sometimes leaving raw 
/
as visible text. 2. Blank > continuation lines fragmented multi-paragraph blockquotes into separate
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
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

wrapping for text content (was: bare text directly inside

). 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) * fix(renderer): entity decode before blockquote pre-pass + CSS margin fix - Move the >/</& 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 >-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

wrapping inside blockquotes doesn't add extra vertical spacing. Prior shape (bare text) had no default p-margins. - Add Node-driven tests: TestBlockquoteEntityEncodedInput covers > prefix and >-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 * docs: v0.50.218 release notes, test count 2458, roadmap update --------- Co-authored-by: nesquena-hermes Co-authored-by: Nathan Esquenazi Co-authored-by: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 10 + ROADMAP.md | 2 +- TESTING.md | 2 +- static/sessions.js | 61 +++++- static/style.css | 5 +- static/ui.js | 107 ++++++---- tests/test_745_code_block_newlines.py | 10 +- tests/test_blockquote_rendering.py | 52 ++++- tests/test_renderer_js_behaviour.py | 288 +++++++++++++++++++++++++- 9 files changed, 476 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 89c40375..c1ce159e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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; `>`-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 diff --git a/ROADMAP.md b/ROADMAP.md index ac9c18e6..e64819d2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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: / diff --git a/TESTING.md b/TESTING.md index a36d24b0..d062add1 100644 --- a/TESTING.md +++ b/TESTING.md @@ -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. diff --git a/static/sessions.js b/static/sessions.js index a7a8ff26..df1d9463 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -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.', diff --git a/static/style.css b/static/style.css index 44bfd67d..416b9a72 100644 --- a/static/style.css +++ b/static/style.css @@ -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 { diff --git a/static/ui.js b/static/ui.js index cbb8d42c..08e5ac94 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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 > lines become > for the blockquote + // pre-pass below. LLMs sometimes emit HTML-entity-encoded output; without this + // a blockquote sent as "> text" would never be recognised as a blockquote. + s=s.replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&').replace(/"/g,'"').replace(/'/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('

'+rendered+'
'); + // Surround the token with blank lines so the paragraph splitter + // isolates it as its own chunk (otherwise the token gets wrapped + // in

...
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/.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: tokens emitted by the agent (e.g. screenshots, // generated images) and replace them with inline 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)=>`

${inlineMd(t)}

`).replace(/^## (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`).replace(/^# (.+)$/gm,(_,t)=>`

${inlineMd(t)}

`); s=s.replace(/^---+$/gm,'
'); - // Group consecutive > lines into one
. - // 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 >> →
- 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='
    '; - for(const li of ll){ - const txt=li.replace(/^ {0,4}[-*+] /,''); - let ih; - if(/^\[x\] /i.test(txt)) ih=' '+inlineMd(txt.slice(4)); - else if(/^\[ \] /.test(txt)) ih=' '+inlineMd(txt.slice(4)); - else ih=inlineMd(txt); - h+=`
  • ${ih}
  • `; - } - return h+'
'; - }); - } else { - // Plain lines: blank line →
, text → inlineMd - inner=stripped.map(l=>l.trim()===''?'
':inlineMd(l)).join('\n'); - } - return `
${inner}
`; - }); - } - 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.replace(/\n/g,'
')}

`;}).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.replace(/\n/g,'
')}

`;}).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 `📎 ${fname}`; }); // ── 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; } diff --git a/tests/test_745_code_block_newlines.py b/tests/test_745_code_block_newlines.py index de3f7160..08a564b5 100644 --- a/tests/test_745_code_block_newlines.py +++ b/tests/test_745_code_block_newlines.py @@ -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 '
' 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
before
."""
diff --git a/tests/test_blockquote_rendering.py b/tests/test_blockquote_rendering.py
index fad580ad..0345b4d0 100644
--- a/tests/test_blockquote_rendering.py
+++ b/tests/test_blockquote_rendering.py
@@ -73,16 +73,34 @@ class TestBlockquoteSourceStructure:
             " lines and creates one 
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

.""" + 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("

"):] 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 >-prefixed lines are recognised as blockquotes.""" + # The entity decode is represented by '>' replacement or the + # inline decode line, whichever appears first. + decode_idx = min( + UI_JS.find("replace(/>/g"), + UI_JS.find("replace(/</g"), + ) + bq_stash_idx = UI_JS.find("_bq_stash") + assert decode_idx != -1, "Entity decode (> or <) 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}" + ) diff --git a/tests/test_renderer_js_behaviour.py b/tests/test_renderer_js_behaviour.py index dd95c098..e6ed4c88 100644 --- a/tests/test_renderer_js_behaviour.py +++ b/tests/test_renderer_js_behaviour.py @@ -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 "
Hello world
" in out, ( - f"`> Hello world` must render as
Hello world
" - 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

(CommonMark-correct). + assert "

Hello world

" in out, ( + f"`> Hello world` must render as

Hello world

" + 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
between soft-wrapped lines. + assert "

Line one
Line two

" 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("
", ""), ( @@ -164,7 +166,7 @@ class TestCommonLLMShapes: def test_quote_then_heading(self, driver_path): out = _render(driver_path, "> Note this.\n\n## Heading") - assert "
Note this.
" in out + assert "

Note this.

" in out assert "

Heading

" 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("
", "") + + +# ───────────────────────────────────────────────────────────────────────────── +# 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
, broke the 
wrapper, and sometimes left + raw
/
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 "
" in out and "
" in out, ( + f"Fenced code inside blockquote must render as
: {out!r}"
+        )
+        # The > prefixes must be stripped from the code content, not preserved
+        # inside the 
.
+        assert "> x = 1" not in out, (
+            f"Code content inside 
 must not contain > prefixes: {out!r}"
+        )
+        # Raw 
 or pre-header tags must NOT appear as visible text
+        assert "<pre>" not in out
+        assert "<div class="pre-header" not in out
+        # Single 
wrapping everything (not split by the
)
+        assert out.count("
") == 1, ( + f"Expected ONE
, got {out.count('
')}: {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
+ assert out.count("
") == 1, ( + f"Expected ONE
, got {out.count('
')}: {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 "

Bug description

" in out, ( + f"## inside blockquote must render as

: {out!r}" + ) + assert "

Steps to reproduce

" 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

" in out + assert "

H2

" in out + assert "

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 "
    " in out and "
" in out, ( + f"Numbered list inside blockquote must render as
    : {out!r}" + ) + # All three list items present + for item in ["Open the app", "Click the button", "Observe the crash"]: + assert f">{item}" in out, ( + f"Missing
  1. {item}
  2. in {out!r}" + ) + + +class TestBugHorizontalRuleInsideBlockquote: + """Bug 6: --- inside a blockquote rendered as literal text instead of
    .""" + + 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 "
    " in out, ( + f"--- inside blockquote must render as
    : {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

    headings + assert out.count("

    ") >= 4, ( + f"Expected at least 4

    headings, got {out.count('

    ')}: {out!r}" + ) + # Fenced code block + assert "
    " in out
    +        assert 'class="language-python"' in out
    +        # Ordered list
    +        assert "
      " in out + # Inline code + assert "MIME_MAP" in out + assert "api/config.py" in out + assert "master" 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
      wraps everything + assert out.count("
      ") == 1, ( + f"Expected ONE
      , got {out.count('
      ')}: {out!r}" + ) + # No raw
      /
      as escaped text + assert "<pre>" not in out + assert "<div class="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 "
      " not in out, ( + f"> line inside ```bash``` must NOT become a blockquote: {out!r}" + ) + assert "
      " in out
      +        # Escaped > preserved as code content
      +        assert "> 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("
      ") == 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
      + assert out.count("
      ") == 2, ( + f"Expected 2
      : {out!r}" + ) + + +class TestBlockquoteEntityEncodedInput: + """Blockquotes sent as HTML-entity-encoded text must still render correctly. + LLMs sometimes emit > 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 = "> Hello quote" + out = _render(driver_path, src) + assert "
      " in out, ( + f">-prefixed line must render as
      : {out!r}" + ) + text_only = re.sub(r"<[^>]+>", "", out) + assert "Hello quote" in text_only + # Should not see a literal > or > in the rendered text + assert ">" not in out, f"> should have been decoded: {out!r}" + + def test_amp_gt_fenced_code_in_blockquote(self, driver_path): + src = "> ```python\n> x = 1\n> ```" + out = _render(driver_path, src) + assert "
      " in out, ( + f"Entity-encoded blockquote with fenced code must render: {out!r}" + ) + assert "
      " in out, f"Fenced code inside entity-encoded blockquote must render: {out!r}"