fix(ui): stabilize chat bottom scrolling on iPhone PWA
This commit is contained in:
@@ -304,9 +304,10 @@
|
||||
</aside>
|
||||
<main class="main">
|
||||
<div id="mainChat" class="main-view">
|
||||
<div class="messages" id="messages">
|
||||
<div class="messages-shell">
|
||||
<button id="jumpToSessionStartBtn" class="session-jump-btn session-jump-btn--start" aria-label="Jump to beginning of session" data-i18n-aria-label="session_jump_start_label" data-i18n-title="session_jump_start_label" onclick="jumpToSessionStart()" style="display:none"><span aria-hidden="true">↑</span><span data-i18n="session_jump_start">Start</span></button>
|
||||
<button id="scrollToBottomBtn" class="scroll-to-bottom-btn" style="display:none" onclick="scrollToBottom()" aria-label="Scroll to bottom" data-i18n-aria-label="session_jump_end_label" data-i18n-title="session_jump_end_label"><span aria-hidden="true">↓</span><span class="session-jump-btn__text" data-i18n="session_jump_end">End</span></button>
|
||||
<div class="messages" id="messages">
|
||||
<div class="empty-state" id="emptyState">
|
||||
<div class="empty-logo"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="80" height="80" aria-label="Hermes caduceus">
|
||||
<defs>
|
||||
@@ -337,6 +338,7 @@
|
||||
<div id="liveCompressionCards" class="live-compression-cards"></div>
|
||||
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="update-banner" id="updateBanner">
|
||||
<div style="display:flex;flex-direction:column;flex:1;min-width:0">
|
||||
<span id="updateMsg"></span>
|
||||
|
||||
@@ -779,14 +779,15 @@
|
||||
.workspace-toggle-btn.active{color:var(--accent-text);border-color:var(--accent-bg);background:var(--accent-bg);}
|
||||
.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;}
|
||||
/* sticky-first-child: button is early in .messages so its natural position is above viewport; sticky+bottom pins it there when visible */
|
||||
.scroll-to-bottom-btn{position:sticky;bottom:16px;align-self:flex-end;margin-right:20px;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;}
|
||||
/* 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);}
|
||||
.session-jump-btn__text{display:none;}
|
||||
.session-jump-btn{position:sticky;align-self:flex-end;flex:0 0 32px;min-height:32px;margin-right:20px;height:32px;border-radius:999px;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:12px;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 11px;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:11;transition:color .12s,border-color .12s,background .12s,opacity .12s,transform .12s;}
|
||||
.session-jump-btn{position:absolute;right:20px;height:32px;border-radius:999px;border:1px solid var(--border2);background:var(--code-bg);color:var(--muted);font-size:12px;font-weight:600;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:5px;padding:0 11px;box-shadow:0 2px 8px rgba(0,0,0,.25);z-index:11;transition:color .12s,border-color .12s,background .12s,opacity .12s,transform .12s;}
|
||||
.session-jump-btn:hover{color:var(--text);border-color:var(--border);background:var(--hover-bg);transform:translateY(-1px);}
|
||||
.session-jump-btn--start{top:16px;margin-bottom:-36px;}
|
||||
.session-jump-btn--start{top:16px;}
|
||||
.messages.session-nav-enabled .scroll-to-bottom-btn{width:auto;min-width:32px;border-radius:999px;font-size:12px;font-weight:600;gap:5px;padding:0 11px;}
|
||||
.messages.session-nav-enabled .scroll-to-bottom-btn:hover{transform:translateY(-1px);}
|
||||
.messages.session-nav-enabled .session-jump-btn__text{display:inline;}
|
||||
|
||||
44
static/ui.js
44
static/ui.js
@@ -1657,36 +1657,6 @@ 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<el.scrollHeight-el.clientHeight) return;
|
||||
_programmaticScroll=true;
|
||||
el.scrollTop=preferredTop;
|
||||
_lastScrollTop=el.scrollTop;
|
||||
requestAnimationFrame(()=>{ setTimeout(()=>{_programmaticScroll=false;},0); });
|
||||
}
|
||||
function _cancelBottomSettle(){ _bottomSettleToken++; }
|
||||
function _recordNonMessageScrollIntent(e){
|
||||
const el=document.getElementById('messages');
|
||||
@@ -1702,12 +1672,14 @@ function _recordNonMessageScrollIntent(e){
|
||||
// input event so later scrollTop decreases caused by layout/windowing do
|
||||
// not masquerade as user intent and strand live streaming away from bottom.
|
||||
_lastMessageUpwardIntentMs=performance.now();
|
||||
_messageUserUnpinned=true;
|
||||
// User is intentionally moving in the transcript. Cancel any delayed
|
||||
// scrollToBottom settling that was scheduled by session-load/layout growth.
|
||||
_cancelBottomSettle();
|
||||
_nearBottomCount=0;
|
||||
_scrollPinned=false;
|
||||
if(typeof e.deltaY==='number'&&e.deltaY<0){
|
||||
_messageUserUnpinned=true;
|
||||
_nearBottomCount=0;
|
||||
_scrollPinned=false;
|
||||
}
|
||||
}
|
||||
}
|
||||
function _recentMessageUpwardIntent(){
|
||||
@@ -1746,9 +1718,9 @@ 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';
|
||||
const showBottomButton=!_scrollPinned && el.scrollHeight-top-el.clientHeight>80;
|
||||
if(btn) btn.style.display=showBottomButton?'flex':'none';
|
||||
if(typeof _updateSessionStartJumpButton==='function') _updateSessionStartJumpButton();
|
||||
// Prefetch older messages before the reader hits the hard top. Prepending
|
||||
// then preserving scrollTop is seamless only if there is runway left for
|
||||
@@ -2040,7 +2012,7 @@ function _setMessageScrollToBottom(){
|
||||
const el=$('messages');
|
||||
if(!el) return;
|
||||
_programmaticScroll=true;
|
||||
el.scrollTop=_messagePanePreferredBottomScrollTop(el);
|
||||
el.scrollTop=el.scrollHeight;
|
||||
_lastScrollTop=el.scrollTop;
|
||||
_nearBottomCount=2;
|
||||
_scrollPinned=true;
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).parent.parent
|
||||
INDEX_HTML = (REPO / 'static' / 'index.html').read_text(encoding='utf-8')
|
||||
STYLE_CSS = (REPO / 'static' / 'style.css').read_text(encoding='utf-8')
|
||||
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_scroll_controls_are_overlays_outside_messages_scroller():
|
||||
shell = INDEX_HTML.index('<div class="messages-shell">')
|
||||
scroller = INDEX_HTML.index('<div class="messages" id="messages">')
|
||||
assert shell < INDEX_HTML.index('id="scrollToBottomBtn"') < scroller
|
||||
assert '.messages-shell{flex:1;min-height:0;position:relative;display:flex;flex-direction:column;}' in STYLE_CSS
|
||||
assert '.scroll-to-bottom-btn{position:absolute;' in STYLE_CSS
|
||||
assert '.session-jump-btn{position:absolute;' in STYLE_CSS
|
||||
|
||||
|
||||
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<el.scrollHeight-el.clientHeight) return;' in UI_JS
|
||||
assert '_maybeInsetIosStandaloneBottomEdge(el, top);' in UI_JS
|
||||
def test_bottom_button_has_dead_zone_and_no_platform_scroll_shim():
|
||||
scroll_listener = UI_JS[UI_JS.index("el.addEventListener('scroll'"):UI_JS.index('function _fmtTokens')]
|
||||
assert 'const showBottomButton=!_scrollPinned && el.scrollHeight-top-el.clientHeight>80;' in scroll_listener
|
||||
assert '_isIosStandalonePwa' not in UI_JS
|
||||
assert '_messagePanePreferredBottomScrollTop' not in UI_JS
|
||||
|
||||
@@ -112,13 +112,14 @@ class TestScrollPinningFix:
|
||||
"style.css must define .scroll-to-bottom-btn styles (#677)"
|
||||
)
|
||||
|
||||
def test_scroll_to_bottom_button_is_sticky(self):
|
||||
"""Scroll-to-bottom button must use position:sticky so it stays visible (#677)."""
|
||||
def test_scroll_to_bottom_button_is_overlayed(self):
|
||||
"""Scroll-to-bottom button stays visible as an overlay outside transcript layout (#677)."""
|
||||
btn_css_pos = STYLE_CSS.find(".scroll-to-bottom-btn")
|
||||
assert btn_css_pos != -1
|
||||
btn_css = STYLE_CSS[btn_css_pos:btn_css_pos + 300]
|
||||
assert "sticky" in btn_css, (
|
||||
".scroll-to-bottom-btn must use position:sticky to stay at bottom of viewport (#677)"
|
||||
assert "position:absolute" in btn_css, (
|
||||
".scroll-to-bottom-btn must be an overlay so it stays visible without "
|
||||
"participating in transcript scroll layout (#677)"
|
||||
)
|
||||
|
||||
def test_scroll_listener_hides_button_when_pinned(self):
|
||||
|
||||
@@ -32,7 +32,8 @@ def test_session_jump_buttons_are_opt_in_and_keep_existing_bottom_button():
|
||||
assert "session_jump_buttons: !!($('settingsSessionJumpButtons')||{}).checked" in PANELS_JS
|
||||
|
||||
scroll_listener = UI_JS[UI_JS.index("el.addEventListener('scroll'") : UI_JS.index("})();", UI_JS.index("el.addEventListener('scroll'"))]
|
||||
assert "if(btn) btn.style.display=_scrollPinned?'none':'flex'" in scroll_listener
|
||||
assert "const showBottomButton=!_scrollPinned && el.scrollHeight-top-el.clientHeight>80" in scroll_listener
|
||||
assert "if(btn) btn.style.display=showBottomButton?'flex':'none'" in scroll_listener
|
||||
assert "!_isSessionJumpButtonsEnabled()||_scrollPinned" not in UI_JS
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user