From 324aeaaded9cead9da58ee9ab639bbe4416fa063 Mon Sep 17 00:00:00 2001 From: bergeouss Date: Mon, 4 May 2026 17:25:31 +0000 Subject: [PATCH] fix: macOS auto-scroll momentum race (#1360) + custom:* provider model list (#1619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #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. --- api/config.py | 5 +++++ api/routes.py | 15 ++++++++------- static/ui.js | 30 ++++++++++++++++++++++-------- 3 files changed, 35 insertions(+), 15 deletions(-) diff --git a/api/config.py b/api/config.py index 3614504c..fc7bf07c 100644 --- a/api/config.py +++ b/api/config.py @@ -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 diff --git a/api/routes.py b/api/routes.py index 4af18933..47a2b12e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -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: diff --git a/static/ui.js b/static/ui.js index 73775d26..54e94ca7 100644 --- a/static/ui.js +++ b/static/ui.js @@ -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);}