mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-16 12:40:18 +00:00
stage batch #4584+#4583 (rodboev): scroll re-pin read-position fix + mobile horizontal-pan fix (#4668)
#4584 (fixes #4295): scroll re-pin requires deliberate downward intent (deltaY<-30 unpin threshold, re-pin only if not user-unpinned OR bottomDistance<=80, reset counter) so reading mid-stream isn't hijacked back to bottom. ui.js only + 5 scroll-regression tests (4 existing assertions adjusted, all green; new test_issue4295_scroll_pin_reentry). #4583 (fixes #4553): mobile transcript overflow-x:hidden + max-width:100% + word-break so wide content can't pan sideways. style.css only + own test. Disjoint files. 32 scroll-family tests pass. Co-authored-by: nesquena-hermes <agent@nesquena-hermes> Co-authored-by: rodboev <rodboev@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,13 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.576] — 2026-06-22 — Release UI (scroll read-position + mobile horizontal-pan fixes)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **The transcript no longer yanks you back to the bottom while you're reading mid-stream.** Scroll re-pinning now requires a deliberate move toward the bottom (and ignores tiny scroll jitter), so reading earlier messages during an active stream stays put instead of getting hijacked back to the latest token. Thanks @rodboev. (#4584, fixes #4295)
|
||||
- **No more accidental horizontal panning of the mobile transcript.** Wide content (long code lines, URLs) could let the message area pan sideways on phones; the transcript now clips horizontal overflow and wraps long words. Thanks @rodboev. (#4583, fixes #4553)
|
||||
|
||||
## [v0.51.575] — 2026-06-22 — Release UH (session-list perf for long histories)
|
||||
|
||||
### Fixed
|
||||
|
||||
+2
-2
@@ -1640,7 +1640,7 @@
|
||||
.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;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: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);}
|
||||
@@ -2407,7 +2407,7 @@
|
||||
.topbar-chips .chip,.topbar-chips .ws-chip,.topbar-chips button{font-size:11px!important;padding:4px 8px!important;white-space:nowrap;}
|
||||
.settings-main{padding:18px 16px;}
|
||||
.hermes-action-grid{grid-template-columns:1fr;}
|
||||
.messages-inner{padding:12px 10px 20px;}
|
||||
.messages-inner{padding:12px 10px 20px;max-width:100%;overflow-x:hidden;word-break:break-word;min-width:0;}
|
||||
.msg-body{padding-left:0;max-width:100%;}
|
||||
.msg-role{font-size:12px;}
|
||||
.composer-wrap{padding:8px 10px 12px!important;}
|
||||
|
||||
+10
-6
@@ -3632,9 +3632,9 @@ function _recordNonMessageScrollIntent(e){
|
||||
const target=e&&e.target;
|
||||
if(!el||!target) return;
|
||||
if(!el.contains(target)) _lastNonMessageScrollIntentMs=performance.now();
|
||||
else if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY<0)){
|
||||
else if(e.type==='touchmove'||(typeof e.deltaY==='number'&&e.deltaY< -30)){
|
||||
_cancelBottomSettle();
|
||||
if(typeof e.deltaY==='number'&&e.deltaY<0){
|
||||
if(typeof e.deltaY==='number'&&e.deltaY< -30){
|
||||
_messageUserUnpinned=true;
|
||||
_nearBottomCount=0;
|
||||
_scrollPinned=false;
|
||||
@@ -3808,7 +3808,8 @@ if(typeof window!=='undefined'){
|
||||
cancelAnimationFrame(_scrollRaf);
|
||||
_scrollRaf=requestAnimationFrame(()=>{
|
||||
const top=el.scrollTop;
|
||||
const nearBottom=el.scrollHeight-top-el.clientHeight<250;
|
||||
const bottomDistance=el.scrollHeight-top-el.clientHeight;
|
||||
const nearBottom=bottomDistance<250;
|
||||
const movedUp=_lastScrollTop!==null&&top<_lastScrollTop-2;
|
||||
const movedDown=_lastScrollTop!==null&&top>_lastScrollTop+2;
|
||||
_lastScrollTop=top;
|
||||
@@ -3820,13 +3821,16 @@ if(typeof window!=='undefined'){
|
||||
}else if(movedDown&&nearBottom){
|
||||
_nearBottomCount=_nearBottomCount+1;
|
||||
if(_nearBottomCount>=2){
|
||||
_scrollPinned=true;
|
||||
_messageUserUnpinned=false;
|
||||
if(!_messageUserUnpinned||bottomDistance<=80){
|
||||
_messageUserUnpinned=false;
|
||||
_scrollPinned=true;
|
||||
}
|
||||
_nearBottomCount=0;
|
||||
}
|
||||
}else if(!_messageUserUnpinned){
|
||||
if(nearBottom){
|
||||
_nearBottomCount=_nearBottomCount+1;
|
||||
if(_nearBottomCount>=2) _scrollPinned=true;
|
||||
if(_nearBottomCount>=2){_scrollPinned=true;_nearBottomCount=0;}
|
||||
}else{
|
||||
_nearBottomCount=0;
|
||||
_scrollPinned=false;
|
||||
|
||||
@@ -30,7 +30,7 @@ def test_messages_scroller_disables_browser_scroll_anchoring():
|
||||
|
||||
|
||||
def test_scroll_repin_dead_zone_is_wider_for_mac_app_windows():
|
||||
assert "clientHeight<250" in UI_JS, (
|
||||
assert "clientHeight<250" in UI_JS or "bottomDistance<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."
|
||||
)
|
||||
|
||||
@@ -106,7 +106,7 @@ def test_wheel_touch_upward_intent_unpins_immediately_inside_messages():
|
||||
fn_end = UI_JS.index("function _recentNonMessageScrollIntent", fn_start)
|
||||
fn = UI_JS[fn_start:fn_end]
|
||||
assert "_messageUserUnpinned=true" in fn.replace(" ", "")
|
||||
assert "e.deltaY<0" in fn and "e.type==='touchmove'" in fn
|
||||
assert "e.deltaY< -30" in fn and "e.type==='touchmove'" in fn
|
||||
|
||||
|
||||
def test_downward_path_preserves_macos_momentum_hysteresis():
|
||||
@@ -126,7 +126,7 @@ def test_repin_threshold_is_still_250px():
|
||||
stay. Direction detection is the new lever, not threshold relaxation.
|
||||
"""
|
||||
block = _scroll_listener_block()
|
||||
assert "clientHeight<250" in block, (
|
||||
assert "clientHeight<250" in block or "bottomDistance<250" in block, (
|
||||
"The 250px re-pin dead zone must remain — #1360 / #677 require it "
|
||||
"for macOS small-window + trackpad momentum cases. The #1731 fix "
|
||||
"uses direction detection, not threshold changes."
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""Behavioral regression locks for #4295 scroll re-pin handling."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).parent.parent
|
||||
NODE = shutil.which("node")
|
||||
|
||||
|
||||
def _ui_js() -> str:
|
||||
return (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _balanced_block(src: str, brace_start: int) -> str:
|
||||
depth = 0
|
||||
for i in range(brace_start, len(src)):
|
||||
ch = src[i]
|
||||
if ch == "{":
|
||||
depth += 1
|
||||
elif ch == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return src[brace_start + 1 : i]
|
||||
raise AssertionError("balanced block not found")
|
||||
|
||||
|
||||
def _scroll_listener_raf_body() -> str:
|
||||
src = _ui_js()
|
||||
listener_start = src.index("el.addEventListener('scroll'")
|
||||
raf_start = src.index("requestAnimationFrame(()=>", listener_start)
|
||||
brace_start = src.index("{", raf_start)
|
||||
return _balanced_block(src, brace_start)
|
||||
|
||||
|
||||
def _record_non_message_scroll_intent() -> str:
|
||||
src = _ui_js()
|
||||
start = src.index("function _recordNonMessageScrollIntent")
|
||||
end = src.index("function _recentNonMessageScrollIntent", start)
|
||||
return src[start:end]
|
||||
|
||||
|
||||
pytestmark = pytest.mark.skipif(NODE is None, reason="node not on PATH")
|
||||
|
||||
|
||||
def _run_scroll_listener(samples: list[dict[str, int]]) -> dict[str, int | bool | None]:
|
||||
payload = {
|
||||
"body": _scroll_listener_raf_body(),
|
||||
"samples": samples,
|
||||
}
|
||||
script = (
|
||||
"const payload = " + json.dumps(payload) + ";\n"
|
||||
+ r"""
|
||||
const step = new Function(
|
||||
'el',
|
||||
'_lastScrollTop',
|
||||
'_nearBottomCount',
|
||||
'_scrollPinned',
|
||||
'_messageUserUnpinned',
|
||||
'_newMessageCueVisible',
|
||||
'_programmaticScroll',
|
||||
'_cancelBottomSettle',
|
||||
'_clearNewMessageScrollCue',
|
||||
'_syncScrollToBottomCue',
|
||||
'_updateSessionStartJumpButton',
|
||||
'_isSessionEndlessScrollEnabled',
|
||||
'_messagesTruncated',
|
||||
'_loadOlderMessages',
|
||||
payload.body + `
|
||||
return {
|
||||
_lastScrollTop,
|
||||
_nearBottomCount,
|
||||
_scrollPinned,
|
||||
_messageUserUnpinned,
|
||||
};
|
||||
`
|
||||
);
|
||||
|
||||
let state = {
|
||||
_lastScrollTop: 800,
|
||||
_nearBottomCount: 0,
|
||||
_scrollPinned: true,
|
||||
_messageUserUnpinned: false,
|
||||
};
|
||||
|
||||
const noop = () => {};
|
||||
for (const sample of payload.samples) {
|
||||
state = step(
|
||||
sample,
|
||||
state._lastScrollTop,
|
||||
state._nearBottomCount,
|
||||
state._scrollPinned,
|
||||
state._messageUserUnpinned,
|
||||
false,
|
||||
false,
|
||||
noop,
|
||||
noop,
|
||||
noop,
|
||||
noop,
|
||||
() => false,
|
||||
false,
|
||||
noop
|
||||
);
|
||||
}
|
||||
|
||||
console.log(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
result = subprocess.run(
|
||||
[NODE, "-e", script],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
return json.loads(result.stdout.strip())
|
||||
|
||||
|
||||
class TestScrollPinReentry:
|
||||
def test_manual_scroll_back_to_true_bottom_rearms_follow(self):
|
||||
state = _run_scroll_listener(
|
||||
[
|
||||
{"scrollTop": 760, "scrollHeight": 1000, "clientHeight": 200},
|
||||
{"scrollTop": 770, "scrollHeight": 1000, "clientHeight": 200},
|
||||
{"scrollTop": 780, "scrollHeight": 1000, "clientHeight": 200},
|
||||
]
|
||||
)
|
||||
assert state["_scrollPinned"] is True, (
|
||||
"Reaching the true bottom tail after a manual scroll-up must re-arm auto-follow."
|
||||
)
|
||||
assert state["_messageUserUnpinned"] is False, (
|
||||
"The sticky unpin flag must clear once the reader manually scrolls back to the real bottom."
|
||||
)
|
||||
|
||||
def test_near_bottom_proximity_alone_does_not_repin(self):
|
||||
state = _run_scroll_listener(
|
||||
[
|
||||
{"scrollTop": 760, "scrollHeight": 1200, "clientHeight": 200},
|
||||
{"scrollTop": 775, "scrollHeight": 1200, "clientHeight": 200},
|
||||
{"scrollTop": 790, "scrollHeight": 1200, "clientHeight": 200},
|
||||
]
|
||||
)
|
||||
assert state["_scrollPinned"] is False, (
|
||||
"The reader must stay unpinned inside the 250px near-bottom band until they reach the true bottom tail."
|
||||
)
|
||||
assert state["_messageUserUnpinned"] is True, (
|
||||
"Near-bottom proximity alone must not clear the sticky unpin flag."
|
||||
)
|
||||
|
||||
def test_scroll_up_threshold_prevents_jitter_unpin(self):
|
||||
record = _record_non_message_scroll_intent()
|
||||
assert "e.deltaY< -30" in record or "e.deltaY < -30" in record, (
|
||||
"The wheel-intent path must keep the 30px upward threshold to avoid touch jitter false unpins."
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_messages_rule_has_overflow_x_hidden():
|
||||
"""Verify .messages rule includes overflow-x:hidden property."""
|
||||
css_file = Path(__file__).resolve().parent.parent / "static" / "style.css"
|
||||
content = css_file.read_text()
|
||||
|
||||
# Find the .messages{ rule
|
||||
messages_rule_match = re.search(r'\.messages\{[^}]*overflow-y:auto[^}]*\}', content)
|
||||
assert messages_rule_match, ".messages rule not found"
|
||||
|
||||
messages_rule = messages_rule_match.group(0)
|
||||
assert 'overflow-x:hidden' in messages_rule, (
|
||||
".messages rule does not contain overflow-x:hidden"
|
||||
)
|
||||
|
||||
|
||||
def test_messages_inner_mobile_has_containment():
|
||||
"""Verify .messages-inner in mobile breakpoint includes containment properties."""
|
||||
css_file = Path(__file__).resolve().parent.parent / "static" / "style.css"
|
||||
content = css_file.read_text()
|
||||
|
||||
# Find the @media(max-width:640px) block and then .messages-inner within a reasonable window
|
||||
media_match = re.search(r'@media\(max-width:640px\)\{', content)
|
||||
assert media_match, "@media(max-width:640px) block not found"
|
||||
|
||||
# Extract content after the media query opening brace
|
||||
media_start = media_match.start()
|
||||
remaining_content = content[media_start:media_start + 5000] # Look ahead 5000 chars
|
||||
|
||||
# Find .messages-inner rule within this section
|
||||
messages_inner_match = re.search(r'\.messages-inner\{([^}]*)\}', remaining_content)
|
||||
assert messages_inner_match, ".messages-inner rule not found in @media(max-width:640px) block"
|
||||
|
||||
messages_inner_rule = messages_inner_match.group(0)
|
||||
|
||||
# Verify all required properties are present
|
||||
assert 'max-width:100%' in messages_inner_rule, (
|
||||
".messages-inner in mobile block missing max-width:100%"
|
||||
)
|
||||
assert 'overflow-x:hidden' in messages_inner_rule, (
|
||||
".messages-inner in mobile block missing overflow-x:hidden"
|
||||
)
|
||||
assert 'word-break:break-word' in messages_inner_rule, (
|
||||
".messages-inner in mobile block missing word-break:break-word"
|
||||
)
|
||||
assert 'min-width:0' in messages_inner_rule, (
|
||||
".messages-inner in mobile block missing min-width:0"
|
||||
)
|
||||
@@ -132,7 +132,7 @@ class TestScrollPinningFix:
|
||||
assert scroll_listener_start != -1, "scroll event listener not found"
|
||||
# After #1360 fix, the nearBottom + btn logic lives inside an rAF
|
||||
# callback — extend search window to cover the full listener block.
|
||||
listener_block = UI_JS[scroll_listener_start:scroll_listener_start + 1400]
|
||||
listener_block = UI_JS[scroll_listener_start:scroll_listener_start + 1600]
|
||||
assert "_syncScrollToBottomCue(showBottomButton" in listener_block, (
|
||||
"Scroll listener must show/hide scrollToBottomBtn based on _scrollPinned (#677)"
|
||||
)
|
||||
|
||||
@@ -84,7 +84,7 @@ def test_message_scroll_listener_does_not_downgrade_explicit_bottom_pin_on_first
|
||||
|
||||
assert "_nearBottomCount=2" in set_bottom
|
||||
assert "_scrollPinned=_nearBottomCount>=2" not in listener_block
|
||||
assert "if(_nearBottomCount>=2) _scrollPinned=true" in listener_block
|
||||
assert "if(_nearBottomCount>=2){" in listener_block
|
||||
assert "_scrollPinned=false" in listener_block
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ def test_user_scroll_cancels_delayed_bottom_settling():
|
||||
|
||||
assert "function _cancelBottomSettle" in UI_JS
|
||||
assert "_cancelBottomSettle();" in listener_block
|
||||
assert "e.deltaY<0" in record
|
||||
assert "e.deltaY< -30" in record
|
||||
assert "_cancelBottomSettle();" in record
|
||||
assert "_scrollPinned=false" in record
|
||||
assert "if(_messageUserUnpinned) return;" in pinned
|
||||
|
||||
Reference in New Issue
Block a user