Merge pull request #4824 from nesquena/stage/4818-mobile-scroll

Release v0.51.617 — fix mobile scroll-jank to top during streaming (#4818)
This commit is contained in:
nesquena-hermes authored and GitHub committed 2026-06-24 00:27:50 -07:00
commit 6b52b06954
5 files changed
+441 -5

No files matched your search

+6
View File
@@ -3,6 +3,12 @@
## [Unreleased]
## [v0.51.617] — 2026-06-24 — Release VX (fix mobile scroll-jank to top during streaming)
### Fixed
- **On mobile (iOS Safari / Android Chrome), the transcript no longer randomly jumps to the top while the assistant is streaming.** The message container carried a global `overflow-anchor: none` (needed for desktop virtual-scroll position stability), but on touch browsers that made the browser paint a frame at `scrollTop=0` during the render's DOM wipe-and-rebuild, scroll-janking the reader to the oldest message mid-stream. `overflow-anchor: none` is now scoped to desktop (hover + fine-pointer) via a media query — mobile keeps the browser's native scroll anchoring — and the render briefly re-enables anchoring on touch devices across the DOM-rebuild gap. Desktop behavior is unchanged. Thanks @akrhin. (#4815, #4818)
## [v0.51.616] — 2026-06-24 — Release VW (harden recycled assistant turns + scrollbar-drag detection)
### Fixed
+2 -1
View File
@@ -1708,7 +1708,8 @@
.workspace-toggle-btn:disabled{opacity:.38;cursor:not-allowed;}
.chip.model{color:var(--accent-text);border-color:var(--accent-bg-strong);background:var(--accent-bg);}
.messages-shell{flex:1;min-height:0;position:relative;display:flex;flex-direction:column;}
.messages{flex:1;overflow-y:auto;overflow-x:hidden;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;overflow-anchor:none;}
.messages{flex:1;overflow-y:auto;overflow-x:hidden;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;overflow-anchor:auto;}
@media (hover:hover) and (pointer:fine){.messages{overflow-anchor:none;}}
/* Overlay scroll controls so they do not affect the transcript's native scroll geometry. */
.scroll-to-bottom-btn{position:absolute;right:20px;bottom:16px;width:32px;height:32px;border-radius:50%;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:10;transition:color .12s,border-color .12s,background .12s,transform .12s;}
.scroll-to-bottom-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);}
+25
View File
@@ -11010,6 +11010,26 @@ function _restoreMessageScrollSnapshot(snapshot){
else requestAnimationFrame(()=>{ setTimeout(()=>{ _programmaticScroll=false; },0); });
}
}
/**
* Mobile scroll-jank guard: temporarily re-enable overflow-anchor so the
* browser preserves scroll position across the innerHTML='' + DOM rebuild gap.
* Safari/Chrome on iOS/Android can paint a frame with scrollTop=0 between
* innerHTML='' and snapshot restore, scroll-janking the user to the top.
* Called from renderMessages() before the DOM wipe — not from streaming ticks,
* since CSS already gives overflow-anchor:auto on mobile via media query.
*/
window._fixMobileScrollJank=function _fixMobileScrollJank(){
const el=document.getElementById('messages');
if(!el) return;
// Desktop with a mouse: keep overflow-anchor:none (explicitly set by CSS).
// Mobile touch devices only: temporarily enable it.
if(window.matchMedia('(hover:hover) and (pointer:fine)').matches) return;
el.style.overflowAnchor='auto';
requestAnimationFrame(()=>{
if(el.style.overflowAnchor==='auto') el.style.overflowAnchor='';
});
};
function _restoreMessageScrollSnapshotSameFrame(snapshot){
const el=$('messages');
if(!el||!snapshot) return;
@@ -11244,6 +11264,11 @@ function renderMessages(options){
_recycleStash.set(Number(key), child);
}
}
// Mobile scroll-jank fix: temporarily re-enable overflow-anchor so browser
// preserves scroll position across the DOM wipe-and-rebuild gap.
// Safari/Chrome on iOS/Android can paint a frame with scrollTop=0 between
// innerHTML='' and snapshot restore, scroll-janking the user to the top.
if(window._fixMobileScrollJank) window._fixMobileScrollJank();
inner.innerHTML='';
const compressionNode=compressionState?_compressionCardsNode(compressionState):null;
const {message:referenceMessage, rawIdx:referenceMessageRawIdx}=_latestCompressionReferenceMessage(
@@ -22,10 +22,24 @@ def _extract_function(src: str, name: str) -> str:
raise AssertionError(f"Could not extract {name}")
def test_messages_scroller_disables_browser_scroll_anchoring():
assert "overflow-anchor:none" in STYLE_CSS, (
"#messages must disable browser scroll anchoring so tool/card inserts "
"cannot yank the transcript while the user reads earlier content."
def test_messages_scroller_disables_browser_scroll_anchoring_on_desktop():
# Desktop (mouse): overflow-anchor:none — tool/card inserts cannot yank
# the transcript while the user reads earlier content.
assert "@media (hover:hover) and (pointer:fine){.messages{overflow-anchor:none;}}" in STYLE_CSS, (
"On desktop (mouse-driven devices) #messages must disable browser scroll "
"anchoring so tool/card inserts cannot yank the transcript. "
"On mobile (touch devices) overflow-anchor:auto is used instead to prevent "
"scrollTop=0 jank during innerHTML rebuild (#MOBILESCROLL)."
)
def test_messages_scroller_uses_overflow_anchor_auto_on_mobile():
# Mobile (touch): overflow-anchor:auto — prevents scrollTop=0 jank during
# innerHTML rebuild and streaming DOM updates (#MOBILESCROLL).
assert "overflow-anchor:auto;" in STYLE_CSS, (
"On mobile/touch devices #messages must default to overflow-anchor:auto "
"to prevent the browser painting a frame with scrollTop=0 between "
"innerHTML='' and snapshot restore."
)
+390
View File
@@ -0,0 +1,390 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title>Mobile Scroll Jank Test</title>
<style>
/* Simulate the Hermes WebUI messages container */
*{box-sizing:border-box;margin:0;padding:0}
.shell{display:flex;flex-direction:column;height:100vh;width:100%;max-width:430px;margin:0 auto;border:1px solid #ccc;position:relative;}
.header{height:48px;background:#333;color:#fff;display:flex;align-items:center;padding:0 16px;font:14px/1.4 sans-serif;flex-shrink:0;}
.messages-shell{flex:1;min-height:0;position:relative;display:flex;flex-direction:column;}
/* EXACT copy of the .messages CSS from hermes-webui */
.messages{flex:1;overflow-y:auto;overflow-x:hidden;display:flex;flex-direction:column;min-height:0;position:relative;z-index:0;
-webkit-overflow-scrolling:touch;touch-action:pan-y;overscroll-behavior-y:contain;
}
/* Test target: start with overflow-anchor:auto so it matches the new fix */
.messages.test-auto{overflow-anchor:auto;}
/* Legacy (buggy): overflow-anchor:none — simulate the OLD behavior */
.messages.test-none{overflow-anchor:none;}
.msgInner{padding:16px;}
.message{padding:12px 16px;margin-bottom:8px;border-radius:12px;background:#f0f0f0;font:14px/1.5 sans-serif;max-width:85%;}
.message.user{background:#007aff;color:#fff;align-self:flex-end;margin-left:auto;}
.message.assistant{background:#e9e9eb;color:#000;align-self:flex-start;}
.message .thinking{color:#666;font-style:italic;margin-bottom:8px;border-left:2px solid #999;padding-left:8px;}
.message .reasoning{background:rgba(0,0,0,.05);border-radius:8px;padding:8px;font-size:13px;line-height:1.4;margin-bottom:6px;}
.composer{height:56px;border-top:1px solid #ddd;display:flex;align-items:center;padding:0 16px;background:#fff;flex-shrink:0;gap:8px;}
.composer input{flex:1;height:36px;border:1px solid #ddd;border-radius:18px;padding:0 16px;font:14px sans-serif;outline:none;}
.composer button{height:36px;padding:0 16px;border:none;border-radius:18px;background:#007aff;color:#fff;font:14px sans-serif;cursor:pointer;}
.controls{display:flex;gap:8px;padding:8px 16px;background:#f8f8f8;align-items:center;flex-wrap:wrap;border-bottom:1px solid #eee;flex-shrink:0;}
.controls label{font:12px sans-serif;display:flex;align-items:center;gap:4px;cursor:pointer;}
.controls button{font:12px sans-serif;padding:4px 8px;border:1px solid #ccc;border-radius:4px;background:#fff;cursor:pointer;}
.controls button.active{background:#007aff;color:#fff;border-color:#007aff;}
.controls .status{font:11px monospace;color:#666;margin-left:auto;}
#status{position:fixed;bottom:0;left:0;right:0;background:rgba(0,0,0,.8);color:#0f0;font:11px monospace;padding:4px 8px;z-index:999;}
#status.fail{color:#f00;}
#status.warn{color:#ff0;}
</style>
</head>
<body>
<div class="shell">
<div class="header">🤖 Hermes WebUI (Mobile Test)</div>
<div class="controls">
<label><input type="checkbox" id="fixCss" checked> overflow-anchor: auto (on mobile)</label>
<label><input type="checkbox" id="fixJs" checked> window._fixMobileScrollJank()</label>
<button id="btnStream">▶ Simulate Stream</button>
<button id="btnRender">⚡ Simulate renderMessages()</button>
<button id="btnReset">↺ Reset</button>
<span class="status" id="stats">Ready</span>
</div>
<div class="messages-shell">
<div class="messages" id="messages" tabindex="0">
<div class="msgInner" id="msgInner"></div>
</div>
</div>
<div class="composer">
<input type="text" id="composerInput" placeholder="Type a message..." value="Привет, расскажи про мобильный баг">
<button id="btnSend">Send</button>
</div>
</div>
<div id="status">Test ready. Open DevTools > Elements > #messages to inspect. Scroll somewhere above bottom, then click "Simulate Stream".</div>
<script>
(function(){
// ---------------------------------------------------------------
// Simulate Hermes WebUI scroll infrastructure
// ---------------------------------------------------------------
const messages=document.getElementById('messages');
const msgInner=document.getElementById('msgInner');
const status=document.getElementById('status');
let _msgIdx=0;
let _scrollPinned=true;
let _messageUserUnpinned=false;
let _nearBottomCount=1;
let _lastScrollTop=0;
let _bottomSettleToken=0;
let _programmaticScroll=false;
let _animationCount=0;
let _jankCount=0;
// Helper to set status
function setStatus(text,type){
status.textContent=text;
status.className=type||'';
}
// ---------------------------------------------------------------
// Overflow-anchor toggle for fix-js test
// ---------------------------------------------------------------
window._fixMobileScrollJank=function _fixMobileScrollJank(){
if(!document.getElementById('fixJs').checked) return; // test: fix disabled
const el=document.getElementById('messages');
if(!el) return;
if(window.matchMedia('(hover:hover) and (pointer:fine)').matches) return;
el.style.overflowAnchor='auto';
requestAnimationFrame(()=>{if(el.style.overflowAnchor==='auto') el.style.overflowAnchor='';});
};
// ---------------------------------------------------------------
// Messages scroll helpers (mirrors Hermes WebUI)
// ---------------------------------------------------------------
function _setMessageScrollToBottom(){
_programmaticScroll=true;
messages.scrollTop=messages.scrollHeight;
_lastScrollTop=messages.scrollTop;
_nearBottomCount=1;
_scrollPinned=true;
}
function scrollIfPinned(){
if(!_scrollPinned||_messageUserUnpinned) return;
const dist=messages.scrollHeight-messages.scrollTop-messages.clientHeight;
if(dist>500) _setMessageScrollToBottom();
}
function _captureMessageScrollSnapshot(){
return {
top:messages.scrollTop,
bottom:Math.max(0,messages.scrollHeight-messages.scrollTop-messages.clientHeight),
scrollHeight:messages.scrollHeight,
pinned:_scrollPinned,
userUnpinned:_messageUserUnpinned,
};
}
function _restoreSnapshot(snap){
if(!snap) return;
const maxTop=Math.max(0,messages.scrollHeight-messages.clientHeight);
const target=snap.pinned
? maxTop-Math.max(0,snap.bottom||0)
: Math.min(Number(snap.top)||0, maxTop);
messages.scrollTop=Math.max(0,target);
_lastScrollTop=messages.scrollTop;
if(snap.pinned) _scrollPinned=true;
}
// ---------------------------------------------------------------
// Content helpers
// ---------------------------------------------------------------
function addMessage(role,content){
const div=document.createElement('div');
div.className='message '+role;
div.innerHTML=content;
msgInner.appendChild(div);
}
function fillInitialMessages(count){
msgInner.innerHTML='';
for(let i=0;i<count;i++){
const role=i%2===0?'user':'assistant';
addMessage(role,`Message ${i+1}: `+'Lorem ipsum dolor sit amet consectetur adipiscing elit. '.repeat(3));
}
// Scroll to bottom initially
_setMessageScrollToBottom();
}
function appendStreamingChunk(text){
// Find or create a "live" assistant turn
let live=document.getElementById('liveAssistantTurn');
if(!live){
const div=document.createElement('div');
div.id='liveAssistantTurn';
div.className='message assistant';
div.dataset.liveAssistant='1';
const thinking=document.createElement('div');
thinking.className='thinking';
thinking.id='liveThinking';
thinking.textContent='Thinking...';
div.appendChild(thinking);
msgInner.appendChild(div);
live=div;
}
// Update thinking content
const thinking=document.getElementById('liveThinking');
if(!text||text.trim()===''){
// Initial: thinking card
} else {
// Replace thinking with content + new thinking card
live.innerHTML='<div style="margin-bottom:6px;background:rgba(0,0,0,.03);border-radius:8px;padding:8px;"><b>Reasoning:</b> '+text.slice(0,100)+'...</div><div style="margin-top:6px;"><span class="reasoning">'+text+'</span><br>▶ Response content...</div>';
}
}
// ---------------------------------------------------------------
// THE BUG REPRODUCTION: simulate innerHTML=''
// ---------------------------------------------------------------
function simulateRenderMessages(preserveScroll){
const snap=_captureMessageScrollSnapshot();
// THE PROBLEM: before the fix, this innerHTML='' on mobile with
// overflow-anchor:none causes scrollTop to jank to 0 for one frame.
if(document.getElementById('fixJs').checked && typeof window._fixMobileScrollJank==='function'){
window._fixMobileScrollJank();
}
msgInner.innerHTML='';
// Rebuild DOM (simple: just add a few messages back)
for(let i=0;i<3;i++){
addMessage('user','User message during re-render');
addMessage('assistant','Assistant response during re-render');
}
// Capture the jank
const currentTop=messages.scrollTop;
// Restore
if(preserveScroll) _restoreSnapshot(snap);
const afterRestore=messages.scrollTop;
return {scrollBeforeWipe:messages.scrollTop,scrollAfterWipe:currentTop,scrollAfterRestore:afterRestore};
}
// ---------------------------------------------------------------
// THE BUG REPRODUCTION: stream simulation
// ---------------------------------------------------------------
function simulateStreamTick(){
const chunk=`Step ${++_animationCount}: `+'Detected potential scroll boundary... overflow-anchor='.repeat(5);
const snap=_captureMessageScrollSnapshot();
// HACK: to trigger the bug reliably, set overflow-anchor:none
// so the browser paints a frame with scrollTop=0
// Only for test-none mode when fixJs is OFF
if(!document.getElementById('fixJs').checked && document.querySelector('#messages').classList.contains('test-none')){
// Force jank: clear DOM without protection
}
// Apply JS fix if enabled
if(document.getElementById('fixJs').checked && typeof window._fixMobileScrollJank==='function'){
window._fixMobileScrollJank();
}
// Simulate _doRender logic:
appendStreamingChunk(chunk);
// Simulate scrollIfPinned
scrollIfPinned();
// Check for jank:
const el=messages;
const current=el.scrollTop;
const maxScroll=Math.max(0,el.scrollHeight-el.clientHeight);
// Detect jank: scrollTop fell to 0 or very low when user was pinned
// at the bottom or scrolled up to read a mid-stream message
if(snap.pinned && maxScroll>100 && (current<10 || (current < maxScroll-50 && snap.bottom>0 && snap.bottom<500))){
// This is the jank!
_jankCount++;
setStatus(`⚠️ JANK DETECTED #${_jankCount}: scrollTop=${current}, maxScroll=${maxScroll}, snap.top=${snap.top}, bottom=${snap.bottom}`, 'fail');
return false;
}
setStatus(`✓ Stream tick #${_animationCount}: scrollTop=${current}/${maxScroll}`, '');
return true;
}
// ---------------------------------------------------------------
// Test: Simulate full stream sequence (5 ticks)
// ---------------------------------------------------------------
async function runStreamTest(){
setStatus('Starting stream test...','');
_jankCount=0;
_animationCount=0;
// Record fix mode
const cssFix=document.getElementById('fixCss').checked;
const jsFix=document.getElementById('fixJs').checked;
// Apply CSS fix: update overflow-anchor
const el=messages;
if(cssFix){
el.classList.remove('test-none');
el.classList.add('test-auto');
} else {
el.classList.remove('test-auto');
el.classList.add('test-none');
}
for(let i=0;i<10;i++){
await new Promise(r=>setTimeout(r,150));
const ok=simulateStreamTick();
if(!ok && !cssFix && !jsFix){
// Expected: buggy mode janks
setStatus(`BUG REPRODUCED: jank #${_jankCount} at tick ${i+1} (expected in buggy mode)`, 'warn');
}
}
// Final status
if(cssFix||jsFix){
if(_jankCount===0){
setStatus(`✓ PASS: ${_jankCount} janks with fix (css=${cssFix}, js=${jsFix})`, '');
} else {
setStatus(`⚠️ PARTIAL: ${_jankCount} janks with fix (css=${cssFix}, js=${jsFix})`, 'warn');
}
} else {
setStatus(`ℹ️ BUG MODE: ${_jankCount} janks (expected — no fixes active)`, 'warn');
}
}
// ---------------------------------------------------------------
// Test: simulate renderMessages innerHTML wipe
// ---------------------------------------------------------------
function runRenderTest(){
const cssFix=document.getElementById('fixCss').checked;
const jsFix=document.getElementById('fixJs').checked;
const el=messages;
if(cssFix){
el.classList.remove('test-none');
el.classList.add('test-auto');
} else {
el.classList.remove('test-auto');
el.classList.add('test-none');
}
// First, scroll user up a bit (as if reading)
messages.scrollTop=Math.max(0,messages.scrollHeight-messages.clientHeight-200);
_scrollPinned=false;
_messageUserUnpinned=true;
setStatus(`Scrolled up to ${messages.scrollTop}. Running renderMessages(preserveScroll)...`,'');
const result=simulateRenderMessages(true);
setStatus(`Render test: jank window=${result.scrollAfterWipe}, restore=${result.scrollAfterRestore}`, result.scrollAfterWipe<10&&!cssFix&&!jsFix?'warn':'');
}
// ---------------------------------------------------------------
// Setup
// ---------------------------------------------------------------
fillInitialMessages(20);
// Event listeners
document.getElementById('btnStream').addEventListener('click', runStreamTest);
document.getElementById('btnRender').addEventListener('click', runRenderTest);
document.getElementById('btnReset').addEventListener('click', function(){
_jankCount=0;
_animationCount=0;
_scrollPinned=true;
_messageUserUnpinned=false;
fillInitialMessages(20);
setStatus('Reset. Tap "Simulate Stream" or "renderMessages" to test.','');
});
document.getElementById('fixCss').addEventListener('change', function(){
const el=messages;
if(this.checked){
el.classList.remove('test-none');
el.classList.add('test-auto');
} else {
el.classList.remove('test-auto');
el.classList.add('test-none');
}
setStatus(`CSS overflow-anchor: ${this.checked?'auto':'none'}`,'');
});
document.getElementById('btnSend').addEventListener('click', function(){
const input=document.getElementById('composerInput');
const text=input.value.trim();
if(!text) return;
addMessage('user',text);
// Auto-scroll bottom
_setMessageScrollToBottom();
input.value='';
setStatus(`Sent: "${text.slice(0,30)}..."`,'');
// Simulate a thinking card appearing
setTimeout(()=>{
appendStreamingChunk('');
_setMessageScrollToBottom();
setStatus('Assistant is thinking...','');
},300);
});
})();
</script>
</body>
</html>