From ff0830de4db84c0b6901791b63fc7ec6a94e4930 Mon Sep 17 00:00:00 2001 From: dobby-d-elf Date: Mon, 11 May 2026 22:08:32 -0600 Subject: [PATCH] fix(ui): smooth iPhone PWA bottom-edge bounce in chat --- static/ui.js | 33 ++++++++++++++++++- ...test_2111_ios_pwa_bottom_scroll_stutter.py | 22 +++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 tests/test_2111_ios_pwa_bottom_scroll_stutter.py diff --git a/static/ui.js b/static/ui.js index 28f94817..3b305474 100644 --- a/static/ui.js +++ b/static/ui.js @@ -1657,6 +1657,36 @@ let _messageUserUnpinned=false; let _bottomSettleToken=0; const NON_MESSAGE_SCROLL_INTENT_SUPPRESS_MS=350; const MESSAGE_UPWARD_INTENT_MS=450; +function _isIosStandalonePwa(){ + try{ + const ua=navigator.userAgent||''; + const isIosDevice=/iP(?:hone|ad|od)/i.test(ua) + || (navigator.platform==='MacIntel'&&navigator.maxTouchPoints>1); + if(!isIosDevice) return false; + const standalone=(typeof navigator!=='undefined'&&navigator.standalone===true) + || (typeof window.matchMedia==='function'&&window.matchMedia('(display-mode: standalone)').matches) + || (typeof window.matchMedia==='function'&&window.matchMedia('(display-mode: fullscreen)').matches); + if(!standalone) return false; + return typeof window.matchMedia!=='function' || window.matchMedia('(pointer: coarse)').matches; + }catch(_){ + return false; + } +} +function _messagePanePreferredBottomScrollTop(el){ + if(!el) return 0; + const maxTop=Math.max(0,el.scrollHeight-el.clientHeight); + if(maxTop<=1) return maxTop; + return _isIosStandalonePwa()?maxTop-1:maxTop; +} +function _maybeInsetIosStandaloneBottomEdge(el, top){ + if(!el||!_isIosStandalonePwa()) return; + const preferredTop=_messagePanePreferredBottomScrollTop(el); + if(preferredTop<=0||top{ setTimeout(()=>{_programmaticScroll=false;},0); }); +} function _cancelBottomSettle(){ _bottomSettleToken++; } function _recordNonMessageScrollIntent(e){ const el=document.getElementById('messages'); @@ -1716,6 +1746,7 @@ if (typeof window !== 'undefined') window._resetScrollDirectionTracker = _resetS } else { _nearBottomCount=0; _scrollPinned=false; } if(_scrollPinned) _messageUserUnpinned=false; } // #1360 + _maybeInsetIosStandaloneBottomEdge(el, top); const btn=$('scrollToBottomBtn'); if(btn) btn.style.display=_scrollPinned?'none':'flex'; if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton(); @@ -2009,7 +2040,7 @@ function _setMessageScrollToBottom(){ const el=$('messages'); if(!el) return; _programmaticScroll=true; - el.scrollTop=el.scrollHeight; + el.scrollTop=_messagePanePreferredBottomScrollTop(el); _lastScrollTop=el.scrollTop; _nearBottomCount=2; _scrollPinned=true; diff --git a/tests/test_2111_ios_pwa_bottom_scroll_stutter.py b/tests/test_2111_ios_pwa_bottom_scroll_stutter.py new file mode 100644 index 00000000..ca58f7f6 --- /dev/null +++ b/tests/test_2111_ios_pwa_bottom_scroll_stutter.py @@ -0,0 +1,22 @@ +from pathlib import Path + +REPO = Path(__file__).parent.parent +UI_JS = (REPO / 'static' / 'ui.js').read_text(encoding='utf-8') + + +def test_ios_standalone_detection_helper_exists(): + assert 'function _isIosStandalonePwa()' in UI_JS + assert "window.matchMedia('(display-mode: standalone)').matches" in UI_JS + assert 'navigator.standalone===true' in UI_JS + + +def test_message_bottom_prefers_one_pixel_inset_on_ios_pwa(): + assert 'function _messagePanePreferredBottomScrollTop(el)' in UI_JS + assert 'return _isIosStandalonePwa()?maxTop-1:maxTop;' in UI_JS + assert 'el.scrollTop=_messagePanePreferredBottomScrollTop(el);' in UI_JS + + +def test_ios_pwa_bottom_edge_guard_installed_on_messages_pane(): + assert 'function _maybeInsetIosStandaloneBottomEdge(el, top)' in UI_JS + assert 'if(preferredTop<=0||top