Root cause: .messages had overflow-anchor:none unconditionally. On mobile touch devices (iOS Safari, Android Chrome), the compositor can paint a frame with scrollTop=0 between innerHTML='' and snapshot restore, scroll-janking the user to the top of the transcript. Three-layer fix: 1. CSS (style.css): Use overflow-anchor:auto on touch devices (mobile), keep overflow-anchor:none on desktop (mouse-driven) via @media (hover:hover) and (pointer:fine) — preserves the existing behavior where tool/card inserts don't yank a reading user. 2. JS guard (ui.js): Add window._fixMobileScrollJank() that temporarily sets overflowAnchor='auto' on #messages before innerHTML='' and clears it one rAF later — prevents the compositor frame with scrollTop=0. 3. Streaming protection (messages.js): Call _fixMobileScrollJank() at the top of each _doRender() tick so streaming DOM growth doesn't jank the viewport position. Tests: - Updated test_issue1360_streaming_scroll_hardening.py: split the overflow-anchor test into desktop (none) and mobile (auto) variants. - Added test_mobile_scroll_jank.html: interactive visual test that simulates the scroll jank on a mobile-sized viewport.
94 lines
4.1 KiB
Python
94 lines
4.1 KiB
Python
"""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_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."
|
|
)
|
|
|
|
|
|
def test_scroll_repin_dead_zone_is_wider_for_mac_app_windows():
|
|
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."
|
|
)
|
|
|
|
|
|
def test_queue_card_measurement_respects_manual_unpin_even_when_idle():
|
|
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 "scrollIfPinned()" in measurement_block
|
|
assert "scrollToBottom()" not in measurement_block, (
|
|
"queue-card layout measurement is a background update; it must not clear "
|
|
"manual _messageUserUnpinned state by calling explicit scrollToBottom()."
|
|
)
|
|
|
|
|
|
def test_queue_pill_click_respects_manual_unpin_even_when_idle():
|
|
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 "scrollIfPinned()" in click_block
|
|
assert "scrollToBottom()" not in click_block, (
|
|
"queue-pill expansion is a layout update, not an explicit transcript jump; "
|
|
"it must not reset manual unpin state."
|
|
)
|
|
|
|
|
|
def test_idle_render_preserves_manual_unpin_until_explicit_bottom():
|
|
render_body = _extract_function(UI_JS, "renderMessages")
|
|
scroll_helper = _extract_function(UI_JS, "_scrollAfterMessageRender")
|
|
|
|
assert "preserveScroll||_messageUserUnpinned" in render_body.replace(" ", ""), (
|
|
"renderMessages() must capture a scroll snapshot whenever the reader is "
|
|
"manually unpinned, even for idle/non-preserve renders."
|
|
)
|
|
assert "if(_messageUserUnpinned){" in scroll_helper.replace(" ", ""), (
|
|
"idle render must restore the manually-unpinned viewport instead of "
|
|
"falling through to scrollToBottom()."
|
|
)
|
|
manual_unpin_block = scroll_helper[scroll_helper.index("if(_messageUserUnpinned)") : scroll_helper.index("scrollToBottom();")]
|
|
assert "_restoreMessageScrollSnapshot(scrollSnapshot);" in manual_unpin_block
|
|
assert "_maybeShowNewMessageScrollCue(scrollSnapshot);" in manual_unpin_block
|