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:
5 files changed
+441
-5
No files matched your search
@@ -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
@@ -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);}
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user