diff --git a/static/style.css b/static/style.css index dd9c8cbb..4e85c8e7 100644 --- a/static/style.css +++ b/static/style.css @@ -2728,6 +2728,8 @@ /* ── Message action buttons (copy, edit, retry) ── */ .msg-actions{display:flex;align-items:center;gap:2px;opacity:0;transition:opacity .15s;margin-left:auto;} .msg-row:hover .msg-actions{opacity:1;} +.vscroll-measuring .msg-foot, +.vscroll-measuring .msg-actions{transition:none !important;} .msg-action-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:5px;transition:color .12s,background .12s;line-height:1;} .msg-action-btn:hover{color:var(--accent-text);background:var(--accent-bg);} .selected-text-reply-btn{position:fixed;z-index:1200;display:inline-flex;align-items:center;gap:6px;padding:7px 11px;border:2px solid var(--accent);border-radius:999px;background:var(--bg);color:var(--text);box-shadow:0 8px 24px rgba(0,0,0,.26),0 0 0 1px var(--surface);font-size:12px;font-weight:700;line-height:1;cursor:pointer;opacity:0;pointer-events:none;transform:translateY(4px);transition:opacity .12s ease,transform .12s ease,background .12s ease,border-color .12s ease;user-select:none;} diff --git a/static/ui.js b/static/ui.js index 938b9857..1c9f759c 100644 --- a/static/ui.js +++ b/static/ui.js @@ -871,7 +871,8 @@ function _compensateScrollForMeasurementDelta(renderFn){ if(!container) return renderFn(); const anchorBefore=_captureMessageViewportAnchor(); const scrollTopBefore=container.scrollTop; - renderFn(); + container.classList.add('vscroll-measuring'); + try{ renderFn(); }finally{ container.classList.remove('vscroll-measuring'); } if(!anchorBefore) return; if(scrollTopBefore<1){ const spacer=container.querySelector('[data-virtual-spacer="before"]'); diff --git a/tests/test_issue4346_vscroll_footer_jitter.py b/tests/test_issue4346_vscroll_footer_jitter.py new file mode 100644 index 00000000..5df5de07 --- /dev/null +++ b/tests/test_issue4346_vscroll_footer_jitter.py @@ -0,0 +1,50 @@ +"""Static source assertions for issue #4346 virtual-scroll footer jitter fix.""" +import pathlib +import re + +ROOT = pathlib.Path(__file__).parent.parent + +CSS = (ROOT / 'static' / 'style.css').read_text(encoding='utf-8') +JS = (ROOT / 'static' / 'ui.js').read_text(encoding='utf-8') + + +def test_css_vscroll_measuring_guard(): + """style.css suppresses opacity transitions on .msg-foot and .msg-actions + while .vscroll-measuring is present on the scroll container.""" + assert 'vscroll-measuring' in CSS + assert re.search( + r'\.vscroll-measuring\s+\.msg-foot.*transition\s*:\s*none\s*!important', + CSS, re.DOTALL + ), "missing transition:none !important for .vscroll-measuring .msg-foot" + assert re.search( + r'\.vscroll-measuring\s+\.msg-actions.*transition\s*:\s*none\s*!important', + CSS, re.DOTALL + ), "missing transition:none !important for .vscroll-measuring .msg-actions" + + +def test_js_compensate_adds_vscroll_measuring(): + """_compensateScrollForMeasurementDelta adds and removes the vscroll-measuring + class around the render callback.""" + fn_match = re.search( + r'function _compensateScrollForMeasurementDelta\(renderFn\)\{(.+?)^(?=function )', + JS, re.DOTALL | re.MULTILINE + ) + assert fn_match, "_compensateScrollForMeasurementDelta not found" + body = fn_match.group(1) + assert "classList.add('vscroll-measuring')" in body + assert "classList.remove('vscroll-measuring')" in body + + +def test_js_try_finally_guards_class_removal(): + """The classList.remove is in a finally block so the class is always cleared.""" + fn_match = re.search( + r'function _compensateScrollForMeasurementDelta\(renderFn\)\{(.+?)^(?=function )', + JS, re.DOTALL | re.MULTILINE + ) + assert fn_match + body = fn_match.group(1) + add_pos = body.find("classList.add('vscroll-measuring')") + try_pos = body.find('try{', add_pos) if add_pos != -1 else -1 + finally_pos = body.find('finally{', try_pos) if try_pos != -1 else -1 + remove_pos = body.find("classList.remove('vscroll-measuring')", finally_pos) if finally_pos != -1 else -1 + assert remove_pos != -1, "classList.remove must be inside a finally block after classList.add"