test merge 5672 on master w/ 5685+5681+5666

This commit is contained in:
t 2026-07-06 17:59:15 +00:00
commit efe768fb17
2 changed files with 388 additions and 0 deletions

View file

@ -598,6 +598,7 @@ function _clearMessageVirtualHeightCache(){
clearTimeout(_messageVirtualScrollSettleTimer);
_messageVirtualScrollSettleTimer=0;
_messageVirtualDeferredMeasurement=null;
if(typeof _clearUserRowIntrinsicHeightCache==='function') _clearUserRowIntrinsicHeightCache();
}
function _resetMessageRenderWindow(sid){
_messageRenderWindowSid=sid||null;
@ -1258,6 +1259,53 @@ function _messageViewportIntersectsRenderedRow(){
}
return false;
}
// #5637/#5638 follow-up — kill the content-visibility scrollHeight collapse at its
// source. A virtualization wipe-and-rebuild recreates user rows as FRESH elements, which
// discards content-visibility:auto's last-remembered size, so an off-screen user row
// falls back to the flat `contain-intrinsic-size: auto 96px` estimate in the stylesheet.
// A tall user row (e.g. a long paste) then collapses scrollHeight by (realHeight-96px)
// the instant it's rebuilt off-screen, and the browser either force-clamps scrollTop
// (dTop≈dH layer-1 jump) or re-anchors to a far row (dTop≫dH browser re-anchor jump) —
// both mobile jump-back classes trace to this one collapse. Remember each user row's
// height keyed by its STABLE session-relative index so a rebuild reserves the real
// height, not 96px. Measured height (exact) wins; before a row is ever measured, a
// content-length estimate reserves the bulk so the fresh-element frame doesn't collapse
// either. Refreshed every measure pass, so edits self-heal. Desktop rests at
// content-visibility:visible (intrinsic-size ignored) → inert there, zero behavior change.
const _userRowIntrinsicHeightBySessionIdx=Object.create(null);
// Cleared on session switch alongside _messageVirtualHeightCache (both are
// per-session measured-height caches keyed by session-relative index). Without this,
// keys collide across sessions — _messageSessionIndexForRawIdx = _messageSessionIndexBase()
// + rawIdx and the base is 0 for the common non-offset session — so a new session's
// off-screen user rows would inherit the previous session's remembered heights and
// inflate scrollHeight until each is re-measured. Delete keys in place to keep the
// const binding stable for any closure that captured it.
function _clearUserRowIntrinsicHeightCache(){
for(const k in _userRowIntrinsicHeightBySessionIdx) delete _userRowIntrinsicHeightBySessionIdx[k];
}
function _rememberUserRowIntrinsicHeight(sessionMsgIdx, height){
const key=Number(sessionMsgIdx);
if(!Number.isFinite(key)||!(height>0)) return;
_userRowIntrinsicHeightBySessionIdx[key]=Math.round(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).
const explicitLines=(t.match(/\n/g)||[]).length+1;
const wrapLines=Math.ceil(t.length/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);
if(h>0) row.style.containIntrinsicSize='auto '+Math.round(h)+'px';
}
function _measureMessageVirtualRow(inner, entry){
if(!inner||!entry) return 0;
const primary=inner.querySelector(`[data-msg-idx="${entry.rawIdx}"]`);
@ -1272,6 +1320,16 @@ function _measureMessageVirtualRow(inner, entry){
sibling=sibling.nextElementSibling;
}
}
// Persist the measured height so a later wipe-and-rebuild of this user row reserves its
// real off-screen height instead of collapsing to the 96px estimate (the collapse that
// clamps/re-anchors the viewport — #5637/#5638 mobile jump-back, both classes). The
// typeof guard keeps _measureMessageVirtualRow runnable in the node test harnesses that
// extract it without this helper (they stub every collaborator by name).
if(totalHeight>0 && primary.dataset && primary.dataset.role==='user'
&& typeof _rememberUserRowIntrinsicHeight==='function'){
_rememberUserRowIntrinsicHeight(primary.dataset.sessionMsgIdx, totalHeight);
primary.style.containIntrinsicSize='auto '+Math.round(totalHeight)+'px';
}
return totalHeight;
}
function _updateMessageVirtualMeasurements(renderVisWithIdx, renderVisibleIdxs, virtualWindow){
@ -14140,6 +14198,14 @@ function renderMessages(options){
row.dataset.rawText=newRawText;
row.innerHTML=nextRowHtml;
}
// Reserve this user row's real off-screen height up front so a wipe-and-rebuild
// does not collapse scrollHeight to the flat 96px estimate (the collapse that
// clamps/re-anchors the viewport on mobile — #5637/#5638, both jump classes). Uses
// the remembered measured height when this row has been measured before, else a
// content-length estimate; the measure pass refines it exactly next frame. The
// typeof guard keeps renderMessages runnable in the node test harnesses that
// extract it without this helper (they stub every collaborator by name).
if(typeof _applyUserRowIntrinsicHeight==='function') _applyUserRowIntrinsicHeight(row, newRawText);
inner.appendChild(row);
userRows.set(rawIdx, row);
continue;

View file

@ -0,0 +1,322 @@
"""Regression tests for the mobile scroll jump-back caused by user-row
content-visibility scrollHeight collapse (#5637 / #5638 follow-up).
Root cause (proven on-device + in mobile-emulated Playwright): under
`@media (pointer: coarse)`, `.msg-row[data-role="user"]` carries
`content-visibility: auto; contain-intrinsic-size: auto 96px`. A virtualization
wipe-and-rebuild (`renderMessages`) recreates the user row as a FRESH element,
which discards content-visibility:auto's last-remembered size. An off-screen tall
user row (e.g. a long paste measuring thousands of px) therefore falls back to the
flat 96px estimate the instant it is rebuilt, collapsing scrollHeight by
(realHeight - 96). The browser then either force-clamps scrollTop (the dTop≈dH
"layer-1" jump) or re-anchors to a far row (the dTop≫dH browser re-anchor jump) --
both mobile jump-back classes trace to this one collapse. Desktop rests at
content-visibility:visible so intrinsic-size is inert there (why desktop never
reproduces).
Fix: remember each user row's height keyed by its stable session-relative index,
and apply it as an inline `contain-intrinsic-size` both when the row is (re)built
and when it is measured. A content-length estimate reserves the bulk before the
row has ever been measured so even the first fresh-element frame does not collapse.
Every behavioral test below is designed to FAIL on the known-buggy version (no
inline intrinsic-size written -> the row keeps the flat 96px stylesheet estimate)
and PASS only on the fixed version.
"""
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 vscroll
# suites). Built with a plain string so JS braces need no doubling.
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 _fake_row_prelude() -> str:
"""A minimal fake DOM row/element supporting .style.containIntrinsicSize,
.dataset, and getBoundingClientRect, shared by the tests. Also declares the
module-level backing store the extracted helpers close over."""
return r"""
var _userRowIntrinsicHeightBySessionIdx = Object.create(null);
function makeRow(role, sessionMsgIdx, measuredHeight){
return {
style: { containIntrinsicSize: '' },
dataset: { role: role, sessionMsgIdx: String(sessionMsgIdx) },
classList: { contains(){ return false; } },
getBoundingClientRect(){ return { height: measuredHeight }; },
};
}
"""
def test_estimate_reserves_more_than_96px_for_a_tall_user_message():
"""A long user message must estimate an intrinsic height well above the flat
96px stylesheet fallback, so a rebuilt off-screen row reserves close to its
real height and scrollHeight does not collapse."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + r"""
eval(extractFunc('_estimateUserRowIntrinsicHeight'));
// ~2000 chars across the mobile bubble width => tens of lines => >>96px.
const longText = 'x'.repeat(2000);
const shortText = 'hi';
console.log(JSON.stringify({
tall: _estimateUserRowIntrinsicHeight(longText),
short: _estimateUserRowIntrinsicHeight(shortText),
}));
"""
m = json.loads(_run_node(source))
# A 2000-char row wraps to ~42 lines -> ~948px, far above 96.
assert m["tall"] > 800, (
"a long user message must reserve far more than the flat 96px estimate; "
f"got {m['tall']}"
)
# A short row must never reserve LESS than today's 96px floor (no regression).
assert m["short"] == 96, f"short row must floor at 96px, got {m['short']}"
def test_apply_uses_remembered_measured_height_over_estimate():
"""When a row's real measured height has been remembered (from a prior measure
pass), _applyUserRowIntrinsicHeight must write THAT exact height onto the
rebuilt row's inline contain-intrinsic-size -- not the 96px stylesheet default
and not the coarser content estimate."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + _fake_row_prelude() + r"""
eval(extractFunc('_rememberUserRowIntrinsicHeight'));
eval(extractFunc('_estimateUserRowIntrinsicHeight'));
eval(extractFunc('_applyUserRowIntrinsicHeight'));
// Remember that the user row at sessionIdx 7 really measured 4901px earlier.
_rememberUserRowIntrinsicHeight(7, 4901);
// Now a wipe rebuilds it as a fresh element (short rawText in hand at build time).
const rebuilt = makeRow('user', 7, /*unused*/0);
_applyUserRowIntrinsicHeight(rebuilt, 'short text at build time');
console.log(JSON.stringify({ intrinsic: rebuilt.style.containIntrinsicSize }));
"""
m = json.loads(_run_node(source))
assert m["intrinsic"] == "auto 4901px", (
"rebuilt user row must reserve its remembered measured height (4901px), "
f"not the 96px default; got {m['intrinsic']!r}. On the buggy version no "
"inline intrinsic-size is written and the row keeps the collapsing 96px "
"stylesheet estimate."
)
def test_apply_falls_back_to_estimate_before_first_measure():
"""A never-measured tall row (no remembered height) must still reserve a
content-derived estimate >> 96px at build time, so even the very first
fresh-element frame does not collapse scrollHeight."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + _fake_row_prelude() + r"""
eval(extractFunc('_rememberUserRowIntrinsicHeight'));
eval(extractFunc('_estimateUserRowIntrinsicHeight'));
eval(extractFunc('_applyUserRowIntrinsicHeight'));
// sessionIdx 99 was never measured/remembered.
const fresh = makeRow('user', 99, 0);
const longText = 'y'.repeat(1500);
_applyUserRowIntrinsicHeight(fresh, longText);
const val = fresh.style.containIntrinsicSize; // 'auto <N>px'
const px = parseInt(String(val).replace(/[^0-9]/g,''), 10);
console.log(JSON.stringify({ intrinsic: val, px }));
"""
m = json.loads(_run_node(source))
assert m["px"] > 600, (
"a never-measured tall row must reserve an estimate well above 96px at "
f"build time; got {m['intrinsic']!r}"
)
def test_measure_persists_user_row_height_and_writes_inline_intrinsic():
"""_measureMessageVirtualRow, after measuring a user row, must (a) write the
measured height inline as contain-intrinsic-size on the measured element and
(b) remember it so the NEXT rebuild of that sessionIdx reserves the real
height. The buggy version never touched intrinsic-size, so a rebuilt row
collapsed to 96px."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + _fake_row_prelude() + r"""
eval(extractFunc('_rememberUserRowIntrinsicHeight'));
eval(extractFunc('_estimateUserRowIntrinsicHeight'));
eval(extractFunc('_applyUserRowIntrinsicHeight'));
eval(extractFunc('_measureMessageVirtualRow'));
// The measured user row lives in the fake inner keyed by data-msg-idx.
const measuredRow = makeRow('user', 7, 3200);
const inner = {
querySelector(selector){
if(selector.indexOf('[data-msg-idx="42"]') !== -1) return measuredRow;
return null;
}
};
// Measure it (rawIdx 42 maps to sessionIdx 7).
const h = _measureMessageVirtualRow(inner, { rawIdx: 42 });
// (a) the measured element got its real height reserved inline...
const inlineOnMeasured = measuredRow.style.containIntrinsicSize;
// (b) ...and a subsequent rebuild of sessionIdx 7 reserves that same height.
const rebuilt = makeRow('user', 7, 0);
_applyUserRowIntrinsicHeight(rebuilt, 'short');
console.log(JSON.stringify({
measuredHeight: h,
inlineOnMeasured: inlineOnMeasured,
rebuiltIntrinsic: rebuilt.style.containIntrinsicSize,
}));
"""
m = json.loads(_run_node(source))
assert m["measuredHeight"] == 3200, f"expected measured height 3200, got {m['measuredHeight']}"
assert m["inlineOnMeasured"] == "auto 3200px", (
"measure pass must write the real height inline on the measured user row; "
f"got {m['inlineOnMeasured']!r} (buggy version wrote nothing)"
)
assert m["rebuiltIntrinsic"] == "auto 3200px", (
"a rebuild after measuring must reserve the remembered 3200px, not 96px; "
f"got {m['rebuiltIntrinsic']!r}"
)
def test_measure_ignores_assistant_rows_for_intrinsic_writeback():
"""The intrinsic-size writeback must apply ONLY to user rows (assistant rows
are content-visibility:visible on mobile per #5638; writing intrinsic-size on
them would be meaningless and could mask a real regression). Measuring an
assistant row must NOT write an inline intrinsic-size."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + _fake_row_prelude() + r"""
eval(extractFunc('_rememberUserRowIntrinsicHeight'));
eval(extractFunc('_estimateUserRowIntrinsicHeight'));
eval(extractFunc('_applyUserRowIntrinsicHeight'));
eval(extractFunc('_measureMessageVirtualRow'));
const assistantRow = makeRow('assistant', 5, 1200);
const inner = {
querySelector(selector){
if(selector.indexOf('[data-msg-idx="10"]') !== -1) return assistantRow;
return null;
}
};
_measureMessageVirtualRow(inner, { rawIdx: 10 });
console.log(JSON.stringify({ inline: assistantRow.style.containIntrinsicSize }));
"""
m = json.loads(_run_node(source))
assert m["inline"] == "", (
"assistant rows must NOT get an inline intrinsic-size writeback; "
f"got {m['inline']!r}"
)
def test_cache_cleared_on_session_switch_prevents_stale_height_bleed():
"""Greptile #5672 review: the module-level height cache is keyed by
session-relative index (_messageSessionIndexBase()+rawIdx), and the base is 0
for the common non-offset session, so keys collide across sessions. Without a
clear on session switch, a new session's off-screen user row at the same key
inherits the previous session's remembered height and inflates scrollHeight.
_clearUserRowIntrinsicHeightCache() (wired into _clearMessageVirtualHeightCache,
which _resetMessageRenderWindow calls on session switch) must empty the cache so
a rebuilt row at a colliding key falls back to the content estimate, NOT the
stale remembered height.
Mutation: make _clearUserRowIntrinsicHeightCache a no-op and this fails (the
rebuilt row reserves the stale 5000px instead of the ~short estimate)."""
js = UI_JS_PATH.read_text(encoding="utf-8")
source = _extract_func_script(js) + _fake_row_prelude() + r"""
eval(extractFunc('_rememberUserRowIntrinsicHeight'));
eval(extractFunc('_estimateUserRowIntrinsicHeight'));
eval(extractFunc('_applyUserRowIntrinsicHeight'));
eval(extractFunc('_clearUserRowIntrinsicHeightCache'));
// Session A: a tall user row at session-relative index 3 measured 5000px.
_rememberUserRowIntrinsicHeight(3, 5000);
const beforeClear = makeRow('user', 3, 0);
_applyUserRowIntrinsicHeight(beforeClear, 'x'); // would reserve the remembered 5000
// Session switch clears the cache.
_clearUserRowIntrinsicHeightCache();
// Session B: a SHORT user row at the SAME colliding key 3, never measured here.
const afterClear = makeRow('user', 3, 0);
_applyUserRowIntrinsicHeight(afterClear, 'hi'); // must fall back to the estimate
const estimate = _estimateUserRowIntrinsicHeight('hi');
console.log(JSON.stringify({
beforeClear: beforeClear.style.containIntrinsicSize,
afterClear: afterClear.style.containIntrinsicSize,
estimate: 'auto ' + estimate + 'px',
}));
"""
m = json.loads(_run_node(source))
assert m["beforeClear"] == "auto 5000px", (
"sanity: before the clear, the remembered 5000px must be reserved; "
f"got {m['beforeClear']!r}"
)
assert m["afterClear"] == m["estimate"], (
"after a session switch clear, a rebuilt row at the colliding key must fall "
f"back to the content estimate ({m['estimate']}), NOT the stale remembered "
f"5000px; got {m['afterClear']!r} (buggy: cache not cleared → stale bleed)"
)
assert m["afterClear"] != "auto 5000px", "stale height must not survive the clear"