From d21c97205e5b1503e51ad3ad80d2609df8496247 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 1 May 2026 04:46:12 +0000 Subject: [PATCH] Harden streaming scroll unpin behavior (#1360) (#1377) --- static/style.css | 2 +- static/ui.js | 10 +-- ...st_issue1360_streaming_scroll_hardening.py | 62 +++++++++++++++++++ 3 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 tests/test_issue1360_streaming_scroll_hardening.py diff --git a/static/style.css b/static/style.css index e9a19cd7..08a4dc07 100644 --- a/static/style.css +++ b/static/style.css @@ -681,7 +681,7 @@ .workspace-toggle-btn.active{color:var(--accent-text);border-color:var(--accent-bg);background:var(--accent-bg);} .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{flex:1;overflow-y:auto;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;} + .messages{flex:1;overflow-y:auto;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;} /* sticky-first-child: button is first child of .messages so its natural position is above viewport; sticky+bottom:16px pins it there when visible */ .scroll-to-bottom-btn{position:sticky;bottom:16px;align-self:flex-end;margin-right:20px;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;} .scroll-to-bottom-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);} diff --git a/static/ui.js b/static/ui.js index 06d44933..43005d2c 100644 --- a/static/ui.js +++ b/static/ui.js @@ -827,13 +827,13 @@ document.addEventListener('click',function(e){ // ── Scroll pinning ────────────────────────────────────────────────────────── // When streaming, auto-scroll only if the user hasn't manually scrolled up. -// Once the user scrolls back to within 150px of the bottom, re-pin. +// Once the user scrolls back to within 250px of the bottom, re-pin. let _scrollPinned=true; (function(){ const el=document.getElementById('messages'); if(!el) return; el.addEventListener('scroll',()=>{ - const nearBottom=el.scrollHeight-el.scrollTop-el.clientHeight<150; + const nearBottom=el.scrollHeight-el.scrollTop-el.clientHeight<250; _scrollPinned=nearBottom; const btn=$('scrollToBottomBtn'); if(btn) btn.style.display=_scrollPinned?'none':'flex'; @@ -1757,7 +1757,8 @@ function _renderQueueChips(sid){ if(!card.classList.contains('visible')) return; const h=card.getBoundingClientRect().height; if(h>0) _msgs.style.setProperty('--queue-card-height', h+'px'); - if(typeof scrollToBottom==='function') scrollToBottom(); + if(S.activeStreamId&&typeof scrollIfPinned==='function') scrollIfPinned(); + else if(!S.activeStreamId&&typeof scrollToBottom==='function') scrollToBottom(); }, 360); } @@ -1946,7 +1947,8 @@ function _updateQueuePill(sid,count){ }, 360); } if(pillOuter) pillOuter.classList.remove('show'); - if(typeof scrollToBottom==='function') scrollToBottom(); + if(S.activeStreamId&&typeof scrollIfPinned==='function') scrollIfPinned(); + else if(!S.activeStreamId&&typeof scrollToBottom==='function') scrollToBottom(); }; } else { if(pillOuter) pillOuter.classList.remove('show'); diff --git a/tests/test_issue1360_streaming_scroll_hardening.py b/tests/test_issue1360_streaming_scroll_hardening.py new file mode 100644 index 00000000..39f57c1f --- /dev/null +++ b/tests/test_issue1360_streaming_scroll_hardening.py @@ -0,0 +1,62 @@ +"""Regression tests for #1360: streaming must not re-pin user scroll.""" + +from pathlib import Path + +REPO = Path(__file__).parent.parent +UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8") +STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8") + + +def _extract_function(src: str, name: str) -> str: + marker = f"function {name}(" + idx = src.find(marker) + assert idx != -1, f"{name} not found" + depth = 0 + for i, ch in enumerate(src[idx:], idx): + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return src[idx:i + 1] + 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_scroll_repin_dead_zone_is_wider_for_mac_app_windows(): + assert "clientHeight<250" in UI_JS, ( + "The near-bottom re-pin threshold should be at least 250px so small " + "macOS app windows and trackpad momentum do not re-pin too eagerly." + ) + + +def test_queue_card_measurement_does_not_force_repin_during_streaming(): + fn = _extract_function(UI_JS, "_renderQueueChips") + measurement_idx = fn.find("setTimeout(()=>") + assert measurement_idx != -1, "queue card measurement timeout not found" + measurement_block = fn[measurement_idx:measurement_idx + 500] + + assert "S.activeStreamId" in measurement_block + assert "scrollIfPinned()" in measurement_block + assert "!S.activeStreamId" in measurement_block + assert "scrollToBottom()" in measurement_block + assert measurement_block.find("scrollIfPinned()") < measurement_block.find("scrollToBottom()") + + +def test_queue_pill_click_does_not_force_repin_during_streaming(): + fn = _extract_function(UI_JS, "_updateQueuePill") + click_idx = fn.find("pill.onclick=()=>") + assert click_idx != -1, "queue pill click handler not found" + click_block = fn[click_idx:click_idx + 700] + + assert "S.activeStreamId" in click_block + assert "scrollIfPinned()" in click_block + assert "!S.activeStreamId" in click_block + assert "scrollToBottom()" in click_block + assert click_block.find("scrollIfPinned()") < click_block.find("scrollToBottom()")