Files
hermes-webui/tests/test_issue3470_touch_unpin_streaming_scroll.py
T
nesquena-hermesandcnogrin 1d7a139c26 Release v0.51.240 — Release HH (stage-q12) (#3496)
## Release v0.51.240 — Release HH (stage-q12)

Mobile UX bug-fix — UX-approved via Telegram.

### Fixed
| PR | Author | Fix |
|----|--------|-----|
| #3470 | @cnogrin | On mobile/touch, you can now **swipe up to stop streaming auto-scroll**. Previously the view snapped to the bottom on every token with no way to read earlier content while a response streamed — `_recordNonMessageScrollIntent()` only detected upward intent on the wheel path (`e.deltaY`), and touch events have no `deltaY`. Now tracks `_touchStartY` on `touchstart` and treats a `touchmove` that drags the finger down >8px (= scroll up into history, `scrollTop` decreases) as upward intent, setting the same `_messageUserUnpinned` flag the wheel path + scroll listener use. |

### Why this is safe for existing installs
- **Only ADDS a touch-unpin path** — wheel + desktop behavior completely untouched, no existing branch modified.
- New `touchstart`/`touchend`/`touchcancel` listeners are **passive + capture-only** (they only write `_touchStartY`), so they can't interfere with existing touch handling.
- Net effect for users: mobile users *gain* the ability to scroll up during streaming (which was simply broken before). No one's working flow changes.

### Absorbed on review (Codex CORE MUST-FIX)
The contributor's gesture sign was inverted (`dy<-8` = finger up = *follow* the stream), which would have unpinned in the wrong direction. Corrected to `dy>8` to match the existing scroll listener's `movedUp` semantics; fixed the comment; strengthened the regression test to pin the gesture direction.

### Gate
- Full pytest suite: **7503 passed, 9 skipped, 3 xpassed, 0 failed**
- ESLint runtime gate: CLEAN · browser-smoke: CLEAN
- Codex (regression): SHIP-ONLY-WITH-FIXES (inverted sign) → fixed → re-reviewed **SAFE TO SHIP**
- Regression test `tests/test_issue3470_touch_unpin_streaming_scroll.py` (verified to fail against master)

UX-approved via Telegram (mobile touch-gesture behavior, no visual/layout delta to screenshot).

Co-authored-by: cnogrin <cnogrin@users.noreply.github.com>
2026-06-03 14:33:50 -07:00

98 lines
4.7 KiB
Python

"""Regression: mobile touch swipe-up must unpin streaming auto-scroll (#3470).
On touch devices the auto-scroll-during-streaming behavior could not be
dismissed: ``_recordNonMessageScrollIntent`` only set ``_messageUserUnpinned``
on the wheel path (``typeof e.deltaY === 'number'``), but touch events never
carry ``deltaY``, so a finger swipe-up never unpinned the stream and it kept
snapping to the bottom on every token.
The fix tracks ``_touchStartY`` on ``touchstart`` and, on ``touchmove``, treats
a finger that moved up by >8px as upward-scroll intent — the same effect as the
wheel ``deltaY < 0`` branch — setting ``_messageUserUnpinned=true`` /
``_scrollPinned=false``. ``_touchStartY`` is cleared on ``touchend`` /
``touchcancel`` and in ``_resetScrollDirectionTracker``.
These are source-structure assertions (the project has no JS test runtime),
matching the sibling sticky-unpin regressions (test_issue1731 / test_issue3250).
"""
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
def _record_intent_fn() -> str:
start = UI_JS.index("function _recordNonMessageScrollIntent")
end = UI_JS.index("function _recentNonMessageScrollIntent")
return UI_JS[start:end]
def test_touchmove_branch_sets_user_unpinned():
"""The touchmove branch must set the same authoritative unpin flags the
wheel deltaY<0 branch sets."""
fn = _record_intent_fn()
compact = fn.replace(" ", "").replace("\n", "")
# The touchmove branch is gated on event type + a recorded start Y + a live touch point.
assert "e.type==='touchmove'" in compact, "must handle the touchmove event type"
assert "_touchStartY!==null" in compact, (
"touchmove must compare against a recorded _touchStartY start position"
)
# Upward intent (finger moved up) flips the same flags as the wheel path.
assert "_messageUserUnpinned=true" in compact, (
"touch swipe-up must set _messageUserUnpinned (the authoritative unpin signal)"
)
assert "_scrollPinned=false" in compact, "touch swipe-up must clear _scrollPinned"
def test_touchmove_uses_correct_downward_finger_threshold():
"""Upward-scroll intent on touch = finger dragged DOWN the screen (dy>0),
which scrolls content up into earlier history (scrollTop decreases) — the
same direction the scroll listener's movedUp branch unpins on. A >8px
deadzone avoids tap/jitter. (The original PR had the sign inverted —
dy<-8 — which would have unpinned on a follow-the-stream upward swipe;
Codex caught it. This test pins the corrected direction.)"""
fn = _record_intent_fn().replace(" ", "").replace("\n", "")
assert "_touchStartY" in fn and "clientY" in fn, (
"touch delta must be computed from clientY vs _touchStartY"
)
assert "dy=e.touches[0].clientY-_touchStartY" in fn, "dy = current clientY - start clientY"
assert "if(dy>8)" in fn, (
"unpin must fire on dy>8 (finger moved DOWN -> scroll up into history); "
"dy<-8 would be backwards and unpin while the user follows the stream"
)
assert "if(dy<-8)" not in fn, "the inverted dy<-8 sign must NOT be present"
def test_touchstart_records_start_y():
"""A touchstart listener must record the initial Y so touchmove has a baseline."""
compact = UI_JS.replace(" ", "").replace("\n", "")
assert "addEventListener('touchstart'" in compact or 'addEventListener("touchstart"' in compact, (
"must register a touchstart listener to seed _touchStartY"
)
assert "_touchStartY=e.touches[0].clientY" in compact, (
"touchstart must record the finger's start clientY into _touchStartY"
)
def test_touch_end_and_cancel_clear_start_y():
"""_touchStartY must be cleared when the gesture ends so a later touchmove
in a different gesture doesn't compare against a stale baseline."""
compact = UI_JS.replace(" ", "").replace("\n", "")
assert "addEventListener('touchend'" in compact or 'addEventListener("touchend"' in compact
assert "addEventListener('touchcancel'" in compact or 'addEventListener("touchcancel"' in compact
# Both handlers reset the baseline.
assert compact.count("_touchStartY=null") >= 2, (
"touchend and touchcancel (and the reset hook) must clear _touchStartY"
)
def test_reset_scroll_direction_tracker_clears_touch_start_y():
"""Session switch / reset must also clear _touchStartY (no cross-chat bleed)."""
start = UI_JS.index("function _resetScrollDirectionTracker")
end = UI_JS.index("}", UI_JS.index("{", start))
fn = UI_JS[start:end].replace(" ", "").replace("\n", "")
assert "_touchStartY=null" in fn, (
"_resetScrollDirectionTracker must clear _touchStartY on session switch"
)