mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-14 03:30:51 +00:00
98688491c1
Root cause: .messages had overflow-anchor:none unconditionally. On mobile touch devices (iOS Safari, Android Chrome), the compositor can paint a frame with scrollTop=0 between innerHTML='' and snapshot restore, scroll-janking the user to the top of the transcript. Three-layer fix: 1. CSS (style.css): Use overflow-anchor:auto on touch devices (mobile), keep overflow-anchor:none on desktop (mouse-driven) via @media (hover:hover) and (pointer:fine) — preserves the existing behavior where tool/card inserts don't yank a reading user. 2. JS guard (ui.js): Add window._fixMobileScrollJank() that temporarily sets overflowAnchor='auto' on #messages before innerHTML='' and clears it one rAF later — prevents the compositor frame with scrollTop=0. 3. Streaming protection (messages.js): Call _fixMobileScrollJank() at the top of each _doRender() tick so streaming DOM growth doesn't jank the viewport position. Tests: - Updated test_issue1360_streaming_scroll_hardening.py: split the overflow-anchor test into desktop (none) and mobile (auto) variants. - Added test_mobile_scroll_jank.html: interactive visual test that simulates the scroll jank on a mobile-sized viewport.
391 lines
15 KiB
HTML
391 lines
15 KiB
HTML
<!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>
|