diff --git a/static/ui.js b/static/ui.js index 90c70f5c8..236611fb2 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1291,19 +1291,42 @@ function _rememberUserRowIntrinsicHeight(sessionMsgIdx, height){ function _estimateUserRowIntrinsicHeight(rawText){ const t=String(rawText||''); if(!t) return 96; - // ~48 chars/line at the mobile user-bubble width (≈90% of a phone viewport), ~22px per - // line + ~24px row chrome; floored at the stylesheet's 96px so a short row never reserves - // LESS than today (estimate can only add reserved height for tall rows, never regress). + // ~48 half-width chars/line at the mobile user-bubble width (≈90% of a phone viewport), + // ~22px per line + ~24px row chrome; floored at the stylesheet's 96px so a short row never + // reserves LESS than today (estimate can only add reserved height for tall rows, never + // regress). CJK / full-width characters occupy ~2 columns each, so a Chinese/Japanese/ + // Korean paste wraps at ~24 chars/line — counting them as 1 badly UNDER-estimates the + // height (a 3k-char CJK paste is ~2x taller than the naive length/48 guess). Weight wide + // characters as 2 columns so the fresh-row reserve is close to reality even for a row the + // reader has never scrolled into view (content-visibility:auto reports only the reserve + // for a never-painted row, so a good estimate is the only backstop there). Uses a Unicode + // range test (no \p{} — keep the RegExp engine-portable across the supported browsers). const explicitLines=(t.match(/\n/g)||[]).length+1; - const wrapLines=Math.ceil(t.length/48); + let columns=0; + for(let i=0;i=0x1100&&c<=0x115F)||(c>=0x2E80&&c<=0xA4CF)||(c>=0xAC00&&c<=0xD7A3)|| + (c>=0xF900&&c<=0xFAFF)||(c>=0xFE30&&c<=0xFE4F)||(c>=0xFF00&&c<=0xFF60)||(c>=0xFFE0&&c<=0xFFE6); + columns+=wide?2:1; + } + const wrapLines=Math.ceil(columns/48); const lines=Math.max(explicitLines, wrapLines); return Math.max(96, Math.round(lines*22+24)); } function _applyUserRowIntrinsicHeight(row, rawText){ if(!row||!row.style||!row.dataset) return; const key=Number(row.dataset.sessionMsgIdx); - let h=Number.isFinite(key)?_userRowIntrinsicHeightBySessionIdx[key]:0; - if(!(h>0)) h=_estimateUserRowIntrinsicHeight(rawText!=null?rawText:row.dataset.rawText); + const remembered=Number.isFinite(key)?Number(_userRowIntrinsicHeightBySessionIdx[key])||0:0; + const estimate=_estimateUserRowIntrinsicHeight(rawText!=null?rawText:row.dataset.rawText); + // Reserve the LARGER of the remembered measurement and the content estimate. A remembered + // height can be a PARTIAL paint: a user row taller than the viewport that only ever had its + // top slice scrolled through content-visibility:auto reports just the painted portion, not + // its full height — persisting that would under-reserve and let scrollHeight collapse on the + // next rebuild (the jump-back). Taking the max means a good estimate floors the reserve even + // when the measurement under-read, while a full measurement (row shorter than the viewport, + // fully painted) still wins when it exceeds the estimate. + const h=Math.max(remembered, estimate); if(h>0) row.style.containIntrinsicSize='auto '+Math.round(h)+'px'; } function _measureMessageVirtualRow(inner, entry){ @@ -1361,6 +1384,70 @@ function _updateMessageVirtualMeasurements(renderVisWithIdx, renderVisibleIdxs, _markMessageVirtualMeasurementsSettled(virtualWindow); } } +// #5638 follow-up — the non-virtualized transcript path (the #4325 opt-out, where +// _virtualizeTranscript===false renders every row with no windowing) never runs the +// virtualized measure pass above, so a user row's real height is never remembered. +// content-visibility:auto on user rows then collapses a freshly-rebuilt off-screen tall +// user row to its flat contain-intrinsic-size estimate on every renderMessages() rebuild +// (each streaming frame does inner.innerHTML='' then rebuilds all rows as FRESH elements +// that have never painted at full size). scrollHeight shrinks by (realHeight-estimate), +// the browser force-clamps scrollTop, and the viewport jumps backward — the desktop/mobile +// jump-back, with JS=none because the clamp is the browser's own. +// +// The reliable moment to read a user row's REAL height is JUST BEFORE the wipe: the old +// rows are still in the DOM, laid out at full height (content-visibility:auto reports the +// true rect height once an element has painted, at any scroll position — verified: a tall +// off-screen user row still measures its real height pre-wipe). A POST-render read is +// unreliable because a freshly-rebuilt off-screen row reports its collapsed reserve, not +// its real size, so it would persist the wrong (small) value. Capture pre-wipe, keyed by +// the stable session-relative index, so the rebuild's _applyUserRowIntrinsicHeight reserves +// the real off-screen height and scrollHeight stays stable across the rebuild. +// Desktop rests at content-visibility:visible (intrinsic-size ignored) → inert there. +function _rememberRenderedUserRowIntrinsicHeights(){ + const container=$('messages'); + const inner=$('msgInner'); + if(!container||!inner) return; + const rows=inner.querySelectorAll('.msg-row[data-role="user"][data-msg-idx]'); + if(!rows.length) return; + const cRect=container.getBoundingClientRect(); + // Only trust a row that is currently WITHIN (or straddling) the viewport: such a row has + // been painted at full size, so getBoundingClientRect().height is its REAL height. A row + // that content-visibility:auto is skipping (fully off-screen and never painted this + // session) reports only its contain-intrinsic-size reserve — persisting THAT would poison + // the remembered height with the collapsed value and defeat the estimate backstop for a + // never-seen row. The viewport intersection test is the reliable "has this row painted?" + // signal (an off-screen row that WAS painted earlier keeps its real height too, but we + // don't need it here — it either was captured on a prior in-view pass or the estimate + // covers it). Small margin so a row just above/below the fold still counts as painted. + const margin=Math.max(0, cRect.height||0); + for(let i=0;i0)) continue; + // In-viewport (with a one-screen margin) ⇒ painted ⇒ height is trustworthy — but only + // for a row that FITS the viewport. A row taller than the viewport only ever paints the + // intersecting slice under content-visibility:auto, so its measured height is a PARTIAL + // value, not the full row. Floor every persisted height at the content estimate so a + // partial paint can never lower the reserve below a reasonable full-row guess; a full + // paint (short row) still wins when it exceeds the estimate. + const inView=(r.bottom>=cRect.top-margin)&&(r.top<=cRect.bottom+margin); + if(!inView) continue; + const estimate=(typeof _estimateUserRowIntrinsicHeight==='function') + ? _estimateUserRowIntrinsicHeight(row.dataset.rawText) : 0; + const h=Math.max(measured, estimate); + if(!(h>0)) continue; + const key=Number(row.dataset.sessionMsgIdx); + const remembered=Number.isFinite(key)?Number(_userRowIntrinsicHeightBySessionIdx[key])||0:0; + // Keep the tallest reserve seen — a row mid-collapse (rebuild transient) can report a + // shrunken size; never let that overwrite a good taller remembered value. + if(h>=remembered && typeof _rememberUserRowIntrinsicHeight==='function'){ + _rememberUserRowIntrinsicHeight(row.dataset.sessionMsgIdx, h); + row.style.containIntrinsicSize='auto '+Math.round(h)+'px'; + } + } +} function _scheduleMessageVirtualizedRender(force){ const container=$('messages'); const inner=$('msgInner'); @@ -14049,6 +14136,15 @@ function renderMessages(options){ if(!_m) return false; return (_m.scrollHeight-_m.scrollTop-_m.clientHeight)<=8; })(); + // Pre-wipe capture: read the still-laid-out user rows' REAL heights before the wipe below + // destroys them, and persist so the rebuild reserves the real off-screen height. This is + // the non-virtualized analog of #5638's virtualized measure pass (which never runs when + // _virtualizeTranscript===false). Without it, a fresh off-screen tall user row reserves + // only the flat contain-intrinsic-size estimate, scrollHeight shrinks, and the browser + // clamps scrollTop → the jump-back. Reading pre-wipe (not post-render) is what makes the + // measurement reliable — the old elements have painted, so their rect height is real even + // off-screen; a post-render read of a fresh off-screen row returns its collapsed reserve. + if(typeof _rememberRenderedUserRowIntrinsicHeights==='function') _rememberRenderedUserRowIntrinsicHeights(); // The DOM wipe can briefly collapse #msgInner to zero height, causing the // browser to clamp #messages.scrollTop to 0 and emit a scroll event. That // event is a render artifact, not user intent; if the scroll listener sees it diff --git a/tests/test_issue5744_nonvirtual_userrow_collapse_jumpback.py b/tests/test_issue5744_nonvirtual_userrow_collapse_jumpback.py new file mode 100644 index 000000000..3ba047a3e --- /dev/null +++ b/tests/test_issue5744_nonvirtual_userrow_collapse_jumpback.py @@ -0,0 +1,354 @@ +"""Regression tests for the non-virtualized user-row content-visibility collapse +jump-back (follow-up to #5637 / #5638). + +Root cause (proven in a mobile-emulated Playwright repro on an isolated debug +instance): when a reader has transcript virtualization disabled +(`_virtualizeTranscript === false`, the #4325 opt-out), renderMessages() renders +every row with no windowing and NEVER runs the virtualized measure pass +(`_updateMessageVirtualMeasurements` early-returns when +`!virtualWindow.virtualized`). Under `@media (pointer: coarse)`, +`.msg-row[data-role="user"]` carries `content-visibility: auto; +contain-intrinsic-size: auto 96px`. Every renderMessages() rebuild does +`inner.innerHTML=''` then recreates all rows as FRESH elements; a fresh, +off-screen tall user row (e.g. a long paste measuring thousands of px) reserves +only the flat estimate instead of its real height, so scrollHeight shrinks by +(realHeight - estimate) and the browser force-clamps scrollTop -> the viewport +jumps backward (`JS=none`, a browser clamp, which is why no JS scrollTop-write +compensation catches it). Desktop rests at content-visibility:visible so +intrinsic-size is inert there. + +Three coordinated pieces fix it, each covered below: +1. `_estimateUserRowIntrinsicHeight` weights CJK / full-width characters as ~2 + columns, so a Chinese/Japanese/Korean paste (which wraps at ~24 chars/line, + not 48) reserves close to its real height even before it is ever measured. +2. `_applyUserRowIntrinsicHeight` reserves `max(remembered, estimate)`, so a + PARTIAL-paint remembered height (a row taller than the viewport only ever + paints its intersecting slice under content-visibility:auto) can never + under-reserve below the content estimate. +3. `_rememberRenderedUserRowIntrinsicHeights` (called pre-wipe inside + renderMessages, and the non-virtualized analog of #5638's measure pass) only + persists a height from a row currently within the viewport (painted => + trustworthy) and floors it at the estimate. + +Each behavioral test is written to FAIL on the known-buggy version and PASS only +on the fixed version (mutation notes inline). +""" +import json +import pathlib +import shutil +import subprocess +import tempfile + +import pytest + +ROOT = pathlib.Path(__file__).parent.parent +UI_JS_PATH = ROOT / "static" / "ui.js" +NODE = shutil.which("node") + +pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH") + + +def _run_node(source: str) -> str: + with tempfile.NamedTemporaryFile( + "w", suffix=".cjs", encoding="utf-8", dir=ROOT, delete=False + ) as script: + script.write(source) + script_path = pathlib.Path(script.name) + try: + result = subprocess.run( + [NODE, str(script_path)], + cwd=str(ROOT), + capture_output=True, + text=True, + timeout=30, + ) + finally: + script_path.unlink(missing_ok=True) + if result.returncode != 0: + raise RuntimeError(result.stderr) + return result.stdout.strip() + + +def _extract_func_script(js: str) -> str: + # Brace-matches a function body while skipping braces inside string / template + # / regex literals and comments (same hardened extractor as the sibling #5638 + # suite). + prelude = "const src = " + json.dumps(js) + ";\n" + body = r""" +function extractFunc(name) { + const re = new RegExp('function\\s+' + name + '\\s*\\('); + const start = src.search(re); + if (start < 0) throw new Error(name + ' not found'); + let i = src.indexOf('{', start); + let depth = 1; i++; + let str = null; + let inLine = false; + let inBlock = false; + let inRegex = false; + let prev = ''; + while (depth > 0 && i < src.length) { + const c = src[i]; + const n = src[i + 1]; + if (inLine) { if (c === '\n') inLine = false; i++; continue; } + if (inBlock) { if (c === '*' && n === '/') { inBlock = false; i++; } i++; continue; } + if (str) { + if (c === '\\') { i += 2; continue; } + if (c === str) str = null; + i++; continue; + } + if (inRegex) { + if (c === '\\') { i += 2; continue; } + if (c === '/') inRegex = false; + i++; continue; + } + if (c === '/' && n === '/') { inLine = true; i += 2; continue; } + if (c === '/' && n === '*') { inBlock = true; i += 2; continue; } + if (c === '"' || c === "'" || c === '`') { str = c; i++; continue; } + if (c === '/' && !'})]0123456789'.includes(prev) && !/[A-Za-z_$]/.test(prev)) { + inRegex = true; i++; continue; + } + if (c === '{') depth++; + else if (c === '}') depth--; + if (c.trim()) prev = c; + i++; + } + return src.slice(start, i); +}""" + return prelude + body + + +def _dom_prelude() -> str: + """Fake DOM: a $('id') helper returning a fake #messages container and + #msgInner, plus user rows with getBoundingClientRect + dataset + style, so + the extracted _rememberRenderedUserRowIntrinsicHeights can run headless. + + The container is 600px tall (viewport). A row's `top` is where it sits + relative to the container top; a row is "in view" when it straddles the + [0, 600] band widened by a one-screen margin. + """ + return r""" +var _userRowIntrinsicHeightBySessionIdx = Object.create(null); +function makeRow(role, sessionMsgIdx, rectTop, rectHeight, rawText){ + return { + _top: rectTop, _height: rectHeight, + style: { containIntrinsicSize: '' }, + dataset: { role: role, sessionMsgIdx: String(sessionMsgIdx), + rawText: rawText || '', msgIdx: String(sessionMsgIdx) }, + classList: { contains(){ return false; } }, + getAttribute(){ return null; }, + getBoundingClientRect(){ return { top: this._top, bottom: this._top + this._height, height: this._height }; }, + }; +} +var __rows = []; +var __container = { getBoundingClientRect(){ return { top: 0, bottom: 600, height: 600 }; } }; +var __inner = { querySelectorAll(){ return __rows.slice(); } }; +function $(id){ if(id==='messages') return __container; if(id==='msgInner') return __inner; return null; } +""" + + +def test_estimate_weights_cjk_as_double_width(): + """A CJK (full-width) paste must reserve ~2x the height a same-length latin + paste would, because CJK glyphs occupy ~2 columns and wrap at ~24 chars/line. + The pre-fix estimate counted every char as 1 column, badly under-reserving a + Chinese/Japanese/Korean row -> off-screen collapse -> jump-back. + + Mutation: revert the per-char width weighting (count `columns += 1` for all) + and the CJK estimate drops to the latin value, failing the >1.7x assertion. + """ + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + r""" +eval(extractFunc('_estimateUserRowIntrinsicHeight')); +// Equal CHARACTER counts; the CJK one has ~2x the visual columns. +const latin = 'a'.repeat(1200); +const cjk = '\u4e2d'.repeat(1200); // 1200 CJK ideographs +console.log(JSON.stringify({ + latin: _estimateUserRowIntrinsicHeight(latin), + cjk: _estimateUserRowIntrinsicHeight(cjk), +})); +""" + m = json.loads(_run_node(source)) + assert m["cjk"] > m["latin"] * 1.7, ( + "a CJK paste must reserve ~2x a same-length latin paste (full-width " + f"columns); got cjk={m['cjk']} latin={m['latin']}" + ) + + +def test_estimate_short_row_still_floors_at_96(): + """No regression: a short row must never reserve less than today's 96px.""" + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + r""" +eval(extractFunc('_estimateUserRowIntrinsicHeight')); +console.log(JSON.stringify({ + empty: _estimateUserRowIntrinsicHeight(''), + hi: _estimateUserRowIntrinsicHeight('hi'), + cjkShort: _estimateUserRowIntrinsicHeight('\u4f60\u597d'), +})); +""" + m = json.loads(_run_node(source)) + assert m["empty"] == 96 and m["hi"] == 96 and m["cjkShort"] == 96, ( + f"short rows must floor at 96px; got {m!r}" + ) + + +def test_apply_reserves_max_of_remembered_and_estimate(): + """_applyUserRowIntrinsicHeight must reserve max(remembered, estimate). A + remembered height can be a PARTIAL paint (a row taller than the viewport only + paints its intersecting slice), so when the content estimate is LARGER it must + win -- otherwise a partial 1500px remembered value under-reserves a really + 3000px row and scrollHeight collapses on the next rebuild. + + Mutation: change `Math.max(remembered, estimate)` back to + "remembered if >0 else estimate" and this fails (the small remembered value is + used even though the estimate is larger). + """ + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + _dom_prelude() + r""" +eval(extractFunc('_rememberUserRowIntrinsicHeight')); +eval(extractFunc('_estimateUserRowIntrinsicHeight')); +eval(extractFunc('_applyUserRowIntrinsicHeight')); +// A big CJK paste whose real height >> a stale partial-paint remembered value. +const longCjk = '\u4e2d'.repeat(1500); +const estimate = _estimateUserRowIntrinsicHeight(longCjk); +// Remember a PARTIAL paint far below the estimate (the viewport-slice trap). +_rememberUserRowIntrinsicHeight(4, 900); +const row = makeRow('user', 4, 0, 0, longCjk); +_applyUserRowIntrinsicHeight(row, longCjk); +console.log(JSON.stringify({ reserved: row.style.containIntrinsicSize, estimate: estimate })); +""" + m = json.loads(_run_node(source)) + reserved_px = int("".join(ch for ch in m["reserved"] if ch.isdigit())) + assert reserved_px == m["estimate"], ( + "apply must reserve the larger content estimate when the remembered value " + f"is a smaller partial paint; got {m['reserved']!r}, estimate {m['estimate']}" + ) + assert reserved_px > 900, "the small partial-paint remembered value must not win" + + +def test_apply_still_prefers_a_taller_remembered_full_measurement(): + """The max() must not regress the #5638 case: when a full measurement (taller + than the estimate) was remembered, the rebuild still reserves that real height. + """ + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + _dom_prelude() + r""" +eval(extractFunc('_rememberUserRowIntrinsicHeight')); +eval(extractFunc('_estimateUserRowIntrinsicHeight')); +eval(extractFunc('_applyUserRowIntrinsicHeight')); +_rememberUserRowIntrinsicHeight(7, 4901); +const row = makeRow('user', 7, 0, 0, 'short at build time'); +_applyUserRowIntrinsicHeight(row, 'short at build time'); +console.log(JSON.stringify({ reserved: row.style.containIntrinsicSize })); +""" + m = json.loads(_run_node(source)) + assert m["reserved"] == "auto 4901px", ( + f"a taller remembered full measurement must still win; got {m['reserved']!r}" + ) + + +def test_remember_persists_only_in_viewport_rows(): + """_rememberRenderedUserRowIntrinsicHeights must persist a height ONLY for a + row currently within (or straddling) the viewport band. A fully off-screen row + reports its collapsed content-visibility reserve, not its real height; + persisting THAT would poison the remembered map and defeat the estimate + backstop for a never-seen row. + + Setup: one in-view user row (top 100, height 400 -> within [0,600]) whose + measurement 400 exceeds its tiny estimate, and one far-off-screen user row + (top 9000) whose reported height is a bogus small "reserve". Only the in-view + row's height may be remembered. + + Mutation: drop the `if(!inView) continue;` guard and the off-screen row's bogus + height gets persisted, failing the assertion that its key stays unset. + """ + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + _dom_prelude() + r""" +eval(extractFunc('_rememberUserRowIntrinsicHeight')); +eval(extractFunc('_estimateUserRowIntrinsicHeight')); +eval(extractFunc('_rememberRenderedUserRowIntrinsicHeights')); +// In-view row at session idx 1: straddles the [0,600] viewport, measured 400px. +// Give it empty rawText so its estimate is the 96 floor and the 400 measurement wins. +const inView = makeRow('user', 1, 100, 400, ''); +// Far off-screen row at session idx 2: top 9000 (way past the one-screen margin), +// reports a bogus small collapsed 96px reserve. +const offScreen = makeRow('user', 2, 9000, 96, ''); +__rows = [inView, offScreen]; +_rememberRenderedUserRowIntrinsicHeights(); +console.log(JSON.stringify({ + inViewRemembered: _userRowIntrinsicHeightBySessionIdx[1] || 0, + offScreenRemembered: _userRowIntrinsicHeightBySessionIdx[2] || 0, + offScreenHasKey: (2 in _userRowIntrinsicHeightBySessionIdx), +})); +""" + m = json.loads(_run_node(source)) + assert m["inViewRemembered"] == 400, ( + f"the in-view painted row's real 400px must be remembered; got {m['inViewRemembered']}" + ) + assert not m["offScreenHasKey"], ( + "a fully off-screen row's collapsed reserve must NOT be persisted " + f"(poisons the map); got remembered={m['offScreenRemembered']}" + ) + + +def test_remember_floors_persisted_height_at_estimate(): + """When an in-view row is TALLER than the viewport it only paints its slice, so + its measured height is a partial value. The persisted height must be floored at + the content estimate so a partial paint can never store a value below a + reasonable full-row guess. + + Setup: an in-view CJK row whose measured height (500, a partial slice) is far + below its content estimate. The remembered value must be the estimate, not 500. + + Mutation: remove the `Math.max(measured, estimate)` floor in the capture and + the small 500 partial paint gets stored, failing the assertion. + """ + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + _dom_prelude() + r""" +eval(extractFunc('_rememberUserRowIntrinsicHeight')); +eval(extractFunc('_estimateUserRowIntrinsicHeight')); +eval(extractFunc('_rememberRenderedUserRowIntrinsicHeights')); +const longCjk = '\u4e2d'.repeat(1500); +const estimate = _estimateUserRowIntrinsicHeight(longCjk); +// In-view but only a 500px slice painted (row is taller than the 600px viewport). +const partial = makeRow('user', 3, 0, 500, longCjk); +__rows = [partial]; +_rememberRenderedUserRowIntrinsicHeights(); +console.log(JSON.stringify({ + remembered: _userRowIntrinsicHeightBySessionIdx[3] || 0, + estimate: estimate, +})); +""" + m = json.loads(_run_node(source)) + assert m["remembered"] == m["estimate"], ( + "a partial in-view paint must be floored at the content estimate; " + f"got remembered={m['remembered']}, estimate={m['estimate']}" + ) + assert m["remembered"] > 500, "the small partial slice must not be stored as-is" + + +def test_rendermessages_calls_prewipe_capture_before_wipe(): + """The pre-wipe capture must be invoked BEFORE `inner.innerHTML=''` in + renderMessages, so it reads the still-laid-out old rows. A post-wipe (or + post-render) read would see fresh, never-painted rows reporting their collapsed + reserve. + + Structural guard: in the renderMessages source the + `_rememberRenderedUserRowIntrinsicHeights()` call must appear before the first + `inner.innerHTML=''`. Mutation: move the call after the wipe and this fails. + """ + js = UI_JS_PATH.read_text(encoding="utf-8") + source = _extract_func_script(js) + r""" +eval(extractFunc('renderMessages')); +const body = renderMessages.toString(); +const capIdx = body.indexOf('_rememberRenderedUserRowIntrinsicHeights('); +// Match the ACTUAL wipe statement (with trailing semicolon), not the prose +// mentions of `inner.innerHTML=''` in comments above it. Use the LAST occurrence +// so a comment reference earlier in the body cannot shadow the real wipe. +const wipeIdx = body.lastIndexOf("inner.innerHTML='';"); +console.log(JSON.stringify({ capIdx: capIdx, wipeIdx: wipeIdx })); +""" + m = json.loads(_run_node(source)) + assert m["capIdx"] > 0, "renderMessages must call _rememberRenderedUserRowIntrinsicHeights" + assert m["wipeIdx"] > 0, "renderMessages must wipe inner.innerHTML" + assert m["capIdx"] < m["wipeIdx"], ( + "the pre-wipe capture must run BEFORE inner.innerHTML='' so it reads the " + f"still-laid-out rows; capIdx={m['capIdx']} wipeIdx={m['wipeIdx']}" + )