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:
nesquena-hermes
2026-06-21 23:15:47 -07:00
committed by GitHub
parent 74d7732778
commit fc3d6b68e6
9 changed files with 234 additions and 14 deletions
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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."
)
+2 -2
View File
@@ -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."
+158
View File
@@ -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"
)
+1 -1
View File
@@ -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)"
)
+2 -2
View File
@@ -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