Merge pull request #5741 from nesquena/release/stage-5738

Release: keep the composer above iPad keyboards (#5701)
This commit is contained in:
nesquena-hermes
2026-07-06 22:20:06 -07:00
committed by GitHub
4 changed files with 104 additions and 2 deletions
+2
View File
@@ -5,6 +5,8 @@
### Fixed
- **The message composer no longer hides behind the on-screen keyboard on iPad.** On touch tablets (iPad Safari), the soft keyboard shrinks the visual viewport but not the layout viewport, so the bottom-docked composer was left covered by the keyboard. The composer now lifts to sit just above the keyboard, tracking it as it shows and hides, and drops back to normal when the keyboard closes. It's a no-op on desktop, on devices with a hardware keyboard/trackpad, and while the page is pinch-zoomed. Thanks @rodboev. (#5701)
- **Turns no longer fail with an HTTP 400 from strict providers on empty tool-call history.** Some stored assistant messages carry an empty `tool_calls: []` array, which DeepSeek v4 and newer OpenAI models reject outright ("empty array. Expected an array with minimum length 1"). The model-facing history sanitizer now drops an empty `tool_calls` key on every path that builds the API payload, so a session that accumulated such a message can still send. Populated tool-call chains and their linked results are untouched. Thanks @Swanzb. (#5737)
- **Switching profiles no longer occasionally sticks a session to the wrong provider.** When you switched profiles or tabs while the model dropdown was mid-rebuild, the app could read the *previous* profile's selected model and stamp its provider (e.g. `ollama`) onto a model that doesn't belong to it. That wrong provider got saved to the session and re-sent on every turn, bricking it with a "Provider 'X' is set… but no API key was found" error for a provider the session never used. The provider is now resolved from the dropdown option whose value actually matches the model being sent, so the mismatch can't happen. Credit @b3nw for the root-cause trace. (#5567)
+31
View File
@@ -149,6 +149,34 @@ function _isPhoneWidthViewport(){
return window.matchMedia('(max-width: 640px)').matches;
}
function _isTouchKeyboardViewport(){
try{return matchMedia('(hover:none) and (pointer:coarse)').matches&&!_hasFinePointerCoexisting();}catch(_){return false;}
}
function _syncKeyboardBottomInset(){
const root=document.documentElement;
if(!root) return;
if(!window.visualViewport||!_isTouchKeyboardViewport()){
root.style.removeProperty('--keyboard-bottom-inset');
return;
}
const vv=window.visualViewport;
// A pinch-zoomed viewport (vv.scale != 1) makes innerHeight - vv.height
// reflect the zoom, not the keyboard — on Chromium touch devices with
// accessibility "force enable zoom" that yields a large spurious inset that
// jitters on pan. Treat only the unzoomed state as keyboard occlusion.
if(Math.abs((vv.scale||1)-1)>0.05){
root.style.removeProperty('--keyboard-bottom-inset');
return;
}
const inset=Math.max(0,Math.ceil(window.innerHeight-(vv.height+vv.offsetTop)));
if(inset>0){
root.style.setProperty('--keyboard-bottom-inset',`${inset}px`);
}else{
root.style.removeProperty('--keyboard-bottom-inset');
}
}
// Mobile PWA viewport reflow guard. When the on-screen keyboard / browser
// chrome shows or hides, visualViewport (or a plain resize on browsers without
// it) changes height without a layout invalidation, leaving the phone layout
@@ -156,6 +184,7 @@ function _isPhoneWidthViewport(){
// (which applies a cheap GPU-promotion transform under the @media(max-width:640px)
// rule) forces a repaint, then we resync the workspace panel + sidebar aria.
function _forceMobileViewportReflow(){
_syncKeyboardBottomInset();
if(!_isPhoneWidthViewport()) return;
const layout=document.querySelector('.layout');
if(!layout) return;
@@ -2183,6 +2212,7 @@ window.addEventListener('resize',()=>{
// URL-bar collapse fire visualViewport resize/scroll rather than window resize.
// Debounce a reflow so the phone layout repaints against the new geometry.
if(window.visualViewport){
_syncKeyboardBottomInset();
let _mobileViewportReflowTimer=0;
const _scheduleMobileViewportReflow=()=>{
if(_mobileViewportReflowTimer) clearTimeout(_mobileViewportReflowTimer);
@@ -3391,6 +3421,7 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
// chrome aren't left in the stale bfcache snapshot.
window.addEventListener('pageshow', async (event) => {
if (!event.persisted) return; // fresh loads are handled by the IIFE above
_syncKeyboardBottomInset();
const _srch = document.getElementById('sessionSearch');
if (_srch) _srch.value = '';
if (typeof syncSessionSearchClear === 'function') syncSessionSearchClear();
+5 -1
View File
@@ -3062,7 +3062,7 @@
.messages-inner{padding:12px 10px 20px;max-width:100%;overflow-x:clip;word-break:break-word;min-width:0;padding-left:max(10px,env(safe-area-inset-left,0));padding-right:max(10px,env(safe-area-inset-right,0));}
.msg-body{padding-left:0;max-width:100%;}
.msg-role{font-size:12px;}
.composer-wrap{padding:8px 10px 12px!important;padding-left:max(10px,env(safe-area-inset-left,0))!important;padding-right:max(10px,env(safe-area-inset-right,0))!important;}
.composer-wrap{padding:8px 10px calc(12px + var(--keyboard-bottom-inset, 0px))!important;padding-left:max(10px,env(safe-area-inset-left,0))!important;padding-right:max(10px,env(safe-area-inset-right,0))!important;}
.composer-box{border-radius:12px;}
.composer-box textarea{min-height:40px;}
.composer-footer{padding:6px 8px 8px!important;gap:6px;flex-wrap:nowrap;align-items:center;}
@@ -3432,6 +3432,10 @@ body.tts-enabled .msg-tts-btn{display:inline-flex;align-items:center;}
.composer-wrap{padding:10px 20px 14px;position:relative;z-index:10;}
.composer-wrap::before{content:"";position:absolute;left:0;right:0;bottom:100%;height:32px;background:linear-gradient(to bottom,transparent,var(--bg));pointer-events:none;}
@media (hover:none) and (pointer:coarse){
.composer-wrap{padding-bottom:calc(14px + var(--keyboard-bottom-inset, 0px));}
}
/* Cron status badges: pill shape refinement */
.cron-status{border-radius:99px;font-size:10px;letter-spacing:.04em;}
+66 -1
View File
@@ -1048,7 +1048,7 @@ def test_safe_area_variables_available_for_pwa_shell():
assert "--app-titlebar-safe-top:env(safe-area-inset-top" in CSS, (
"CSS must expose env(safe-area-inset-top) through --app-titlebar-safe-top"
)
assert "padding:8px 10px 12px!important" in CSS, (
assert "padding:8px 10px calc(12px + var(--keyboard-bottom-inset, 0px))!important" in CSS, (
"Phone composer should keep the proven pre-cover-mode padding contract"
)
@@ -1572,6 +1572,71 @@ def test_touch_device_inputs_meet_zoom_threshold():
)
def test_touch_keyboard_inset_uses_touch_primary_media_query():
"""The keyboard inset path must key off touch-primary media queries."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "function _isTouchKeyboardViewport()" in boot_js, \
"boot.js must define a touch-keyboard viewport predicate"
assert "matchMedia('(hover:none) and (pointer:coarse)')" in boot_js or \
'matchMedia("(hover:none) and (pointer:coarse)")' in boot_js, \
"touch keyboard inset eligibility must use the hover:none + pointer:coarse media query"
assert "any-pointer:fine" in boot_js, \
"touch keyboard inset eligibility must exclude co-existing fine-pointer setups"
assert "!_hasFinePointerCoexisting()" in boot_js, \
"touch keyboard inset eligibility must skip hardware-keyboard/trackpad devices"
def test_touch_keyboard_inset_writes_and_clears_css_variable():
"""visualViewport geometry must write and clear the keyboard inset variable."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "setProperty('--keyboard-bottom-inset'" in boot_js, \
"boot.js must write --keyboard-bottom-inset on touch keyboard viewport changes"
assert "removeProperty('--keyboard-bottom-inset')" in boot_js, \
"boot.js must clear --keyboard-bottom-inset when the inset is zero or ineligible"
assert "Math.max(0,Math.ceil(window.innerHeight-(vv.height+vv.offsetTop)))" in boot_js, \
"boot.js must compute the bottom inset from innerHeight and visualViewport geometry"
def test_touch_keyboard_inset_ignores_pinch_zoom_scale():
"""A pinch-zoomed viewport (vv.scale != 1) must not be read as keyboard
occlusion otherwise Chromium 'force enable zoom' produces a large spurious
inset that jitters on pan (#5738 UX-gate hardening)."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
assert "vv.scale" in boot_js, \
"boot.js must consult visualViewport.scale before treating shrinkage as keyboard occlusion"
assert "Math.abs((vv.scale||1)-1)>0.05" in boot_js, \
"boot.js must bail out of the inset when the viewport is pinch-zoomed"
def test_touch_keyboard_inset_primes_during_visual_viewport_setup():
"""The existing visualViewport setup path must prime the inset immediately."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
setup_start = boot_js.index("if(window.visualViewport){")
setup_end = boot_js.index("window.visualViewport.addEventListener('resize'", setup_start)
setup_block = boot_js[setup_start:setup_end]
assert "_syncKeyboardBottomInset();" in setup_block, \
"boot.js must sync the keyboard inset once during visualViewport setup"
def test_touch_keyboard_inset_primes_on_pageshow_restore():
"""BFCache restore must resync the inset before restore work continues."""
boot_js = (REPO / "static" / "boot.js").read_text(encoding="utf-8")
pageshow_start = boot_js.index("window.addEventListener('pageshow'")
pageshow_end = boot_js.index("const _srch = document.getElementById('sessionSearch');", pageshow_start)
pageshow_block = boot_js[pageshow_start:pageshow_end]
assert "_syncKeyboardBottomInset();" in pageshow_block, \
"boot.js must sync the keyboard inset during pageshow restore"
def test_touch_keyboard_inset_applies_to_composer_padding():
"""Touch/coarse composer padding must consume the keyboard inset variable."""
css_ns = re.sub(r'\s+', '', CSS)
assert "@media(hover:none)and(pointer:coarse)" in css_ns, \
"style.css must scope the keyboard inset padding to touch-primary viewports"
assert ".composer-wrap{padding-bottom:calc(14px+var(--keyboard-bottom-inset,0px));}" in css_ns, \
"style.css must add the keyboard inset to composer bottom padding"
# ── Sidebar tabs on mobile ───────────────────────────────────────────────────