fix: macOS auto-scroll momentum race (#1360) + custom:* provider model list (#1619)

#1360 — On macOS WKWebView, trackpad momentum scrolling fires scroll
events that interleave with the _programmaticScroll setTimeout(0) guard.
A mid-momentum scroll event either gets swallowed (_programmaticScroll
still true) or falsely reports nearBottom (momentum hasn't settled),
keeping _scrollPinned=true and snapping the viewport back down.

Fix: rAF-debounce the scroll listener so the nearBottom check runs at
the next paint frame when the browser's scroll position has settled.
Added a hysteresis counter requiring 2 consecutive near-bottom samples
before re-pinning, preventing accidental re-pin during deceleration.

#1619 — When a custom:* provider (e.g. custom:relay via custom_providers)
has models that overlap with auto-detected models from base_url /v1/models,
the dedup logic at config.py:2263 skipped them all. The named custom
group ended up empty, and the continue at line 2334 silently discarded
the auto-detected models. Result: only the default model appeared.

Fix 1 (config.py): When custom:* named group has 0 models after dedup,
fall back to auto_detected_models_by_provider instead of dropping them.

Fix 2 (routes.py): Extended /api/models/live fallback to handle
custom:* slugs (not just bare "custom") for both custom_providers
config lookup and base_url live fetch.
This commit is contained in:
bergeouss authored and test committed 2026-05-04 18:23:04 +00:00
1 parent 304a422814
commit 324aeaaded
3 files changed
+35 -15

No files matched your search

+5
View File
@@ -2524,6 +2524,11 @@ def get_available_models() -> dict:
for pid in sorted(detected_providers):
if pid.startswith("custom:") and pid in _named_custom_groups:
_nc_display, _nc_models = _named_custom_groups[pid]
# If all named-group models were deduped (already auto-detected
# from base_url /v1/models), fall back to auto-detected models
# instead of silently dropping the group (issue #1619).
if not _nc_models:
_nc_models = auto_detected_models_by_provider.get(pid, [])
if _nc_models:
groups.append({"provider": _nc_display, "provider_id": pid, "models": _nc_models})
continue
+8 -7
View File
@@ -4523,11 +4523,12 @@ def _handle_live_models(handler, parsed):
ids = []
if not ids:
# For 'custom' provider, provider_model_ids() returns [] because
# 'custom' isn't a real endpoint. Fall back to the custom_providers
# entries from config.yaml so the live-model enrichment step can
# add any models that weren't already in the static list.
if provider == "custom":
# For 'custom' and 'custom:*' providers, provider_model_ids()
# returns [] because they aren't real hermes_cli endpoints.
# Fall back to the custom_providers entries from config.yaml so
# the live-model enrichment step can add any models that weren't
# already in the static list (issue #1619).
if provider == "custom" or provider.startswith("custom:"):
try:
_cp_entries = cfg.get("custom_providers", [])
if isinstance(_cp_entries, list):
@@ -4539,8 +4540,8 @@ def _handle_live_models(handler, parsed):
except Exception:
pass
# If still no ids, try fetching from model.base_url directly (OpenAI-compat endpoint)
if not ids and provider == "custom":
# If still no ids, try fetching from base_url directly (OpenAI-compat endpoint)
if not ids and (provider == "custom" or provider.startswith("custom:")):
_base_url = cfg.get("model", {}).get("base_url")
_api_key = cfg.get("model", {}).get("api_key")
if _base_url and _api_key:
+22 -8
View File
@@ -1191,21 +1191,35 @@ window.addEventListener('resize',function(){
// Uses a guard flag to avoid the race where programmatic scrolls (from
// scrollIfPinned / scrollToBottom) re-set _scrollPinned=true, overriding
// the user's explicit scroll-up. Fixes #1469 / #1360.
// rAF-debounced scroll listener (issue #1360): on macOS WKWebView, trackpad
// momentum scrolling fires scroll events that interleave with the
// _programmaticScroll setTimeout(0) guard. A mid-momentum scroll event can
// either get swallowed (_programmaticScroll still true) or falsely report
// nearBottom (momentum hasn't settled). rAF defers the nearBottom check to
// the next paint frame when the browser's scroll position has settled.
// A hysteresis counter requires two consecutive near-bottom samples before
// re-pinning, preventing accidental re-pin during initial deceleration.
let _scrollPinned=true;
let _programmaticScroll=false;
let _nearBottomCount=0;
(function(){
const el=document.getElementById('messages');
if(!el) return;
let _scrollRaf=0;
el.addEventListener('scroll',()=>{
if(_programmaticScroll) return; // ignore scrolls we triggered ourselves
const nearBottom=el.scrollHeight-el.scrollTop-el.clientHeight<250;
_scrollPinned=nearBottom;
const btn=$('scrollToBottomBtn');
if(btn) btn.style.display=_scrollPinned?'none':'flex';
// Load older messages when scrolled near the top
if(el.scrollTop<80 && typeof _messagesTruncated!=='undefined' && _messagesTruncated && typeof _loadOlderMessages==='function'){
_loadOlderMessages();
}
cancelAnimationFrame(_scrollRaf);
_scrollRaf=requestAnimationFrame(()=>{
const nearBottom=el.scrollHeight-el.scrollTop-el.clientHeight<250;
_nearBottomCount=nearBottom?_nearBottomCount+1:0;
_scrollPinned=_nearBottomCount>=2;
const btn=$('scrollToBottomBtn');
if(btn) btn.style.display=_scrollPinned?'none':'flex';
// Load older messages when scrolled near the top
if(el.scrollTop<80 && typeof _messagesTruncated!=='undefined' && _messagesTruncated && typeof _loadOlderMessages==='function'){
_loadOlderMessages();
}
});
});
})();
function _fmtTokens(n){if(!n||n<0)return'0';if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1e3)return(n/1e3).toFixed(1)+'k';return String(n);}