From 65acefd2a67b6cf496beb2eb1b3fdadb7802d9bb Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Wed, 18 Mar 2026 03:17:01 -0700 Subject: [PATCH] feat(studio): infinite scroll for recommended models list (#4414) * feat(studio): infinite scroll for recommended models list The model selector showed a hard cap of 4 GGUFs + 4 safetensors in the Recommended section. Users who wanted to browse more had to search manually on Hugging Face. Backend: increase the default model pool from 8+8 to 40+40 (the HF fetch already pulls 80, so no extra network cost). Frontend: replace the static 4+4 cap with on-demand lazy loading. A page counter tracks how many groups of 4 to show per category. An IntersectionObserver on a sentinel div at the bottom of the list increments the page when the user scrolls down. Models are interleaved in groups of 4 GGUFs then 4 hub models per page for a balanced view. Key implementation details: - Callback ref for the sentinel so the observer attaches reliably on first popover open (useRef would miss the initial mount) - Observer disconnects after each fire and re-attaches via useEffect with a 100ms layout delay to prevent runaway page loading - VRAM info fetched incrementally via useRecommendedModelVram on the visible slice only - recommendedSet uses visible IDs so HF search dedup stays correct * refactor: address review feedback on recommended infinite scroll - Simplify visibleRecommendedIds: use findIndex to locate the GGUF/hub split point instead of re-filtering the entire array each time. recommendedIds is already sorted GGUF-first, so a single slice is enough. - Fix VRAM refetch churn: pass the full recommendedIds (stable across page increments) to useRecommendedModelVram instead of the growing visibleRecommendedIds slice. The hook derives its stableKey from the sorted+joined input, so passing the same pool on every page avoids redundant HF modelInfo requests. --- studio/backend/core/inference/orchestrator.py | 10 +-- .../assistant-ui/model-selector/pickers.tsx | 74 ++++++++++++++++--- 2 files changed, 70 insertions(+), 14 deletions(-) diff --git a/studio/backend/core/inference/orchestrator.py b/studio/backend/core/inference/orchestrator.py index 42de014a8d..6ff7fd2cbf 100644 --- a/studio/backend/core/inference/orchestrator.py +++ b/studio/backend/core/inference/orchestrator.py @@ -130,17 +130,17 @@ class InferenceOrchestrator: ) if resp.status_code == 200: models = resp.json() - # Top 8 GGUFs (frontend deduplicates against downloaded, - # so we fetch extra to always fill 4 slots) + # Top 40 GGUFs - frontend pages through them on-demand via + # infinite scroll, so we send a deep pool. gguf_ids = [ m["id"] for m in models if m.get("id", "").upper().endswith("-GGUF") - ][:8] - # Top 8 non-GGUF hub models + ][:40] + # Top 40 non-GGUF hub models hub_ids = [ m["id"] for m in models if not m.get("id", "").upper().endswith("-GGUF") - ][:8] + ][:40] if gguf_ids: self._top_gguf_cache = gguf_ids logger.info("Top GGUF models: %s", gguf_ids) diff --git a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx index 78881384b2..328cba3acd 100644 --- a/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx +++ b/studio/frontend/src/components/assistant-ui/model-selector/pickers.tsx @@ -35,7 +35,7 @@ import { checkVramFit, estimateLoadingVram } from "@/lib/vram"; import { Search01Icon } from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { Trash2Icon } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { toast } from "sonner"; import type { LoraModelOption, @@ -455,21 +455,43 @@ export function HubModelPicker({ const all = dedupe([...models.map((model) => model.id), value ?? ""]) .filter((id) => !downloadedSet.has(id.toLowerCase())) .filter((id) => !chatOnly || isGgufRepo(id)); - // Cap at 4 GGUFs + 4 non-GGUFs so the list stays manageable + // Sort: GGUFs first, then hub models const gguf: string[] = []; const hub: string[] = []; for (const id of all) { - if (isGgufRepo(id) && gguf.length < 4) gguf.push(id); - else if (!isGgufRepo(id) && hub.length < 4) hub.push(id); + if (isGgufRepo(id)) gguf.push(id); + else hub.push(id); } return [...gguf, ...hub]; }, [models, value, downloadedSet, chatOnly]); + // Infinite scroll paging for the recommended section + const [recommendedPage, setRecommendedPage] = useState(1); + // Reset page when the underlying list changes + useEffect(() => { setRecommendedPage(1); }, [models, chatOnly]); + + const visibleRecommendedIds = useMemo(() => { + const hubStartIndex = recommendedIds.findIndex((id) => !isGgufRepo(id)); + const allGguf = hubStartIndex === -1 ? recommendedIds : recommendedIds.slice(0, hubStartIndex); + const allHub = hubStartIndex === -1 ? [] : recommendedIds.slice(hubStartIndex); + // Interleave in chunks of 4: [4 gguf, 4 hub, 4 gguf, 4 hub, ...] + const result: string[] = []; + for (let p = 0; p < recommendedPage; p++) { + result.push(...allGguf.slice(p * 4, (p + 1) * 4)); + result.push(...allHub.slice(p * 4, (p + 1) * 4)); + } + return result; + }, [recommendedIds, recommendedPage]); + + const hasMoreRecommended = visibleRecommendedIds.length < recommendedIds.length; + + // Fetch VRAM info for the full pool once (recommendedIds is stable across + // page increments) so we don't re-fetch on every scroll. const { paramCountById: recommendedParamCountById } = useRecommendedModelVram(recommendedIds); const showHfSection = debouncedQuery.trim().length > 0; - const recommendedSet = useMemo(() => new Set(recommendedIds), [recommendedIds]); + const recommendedSet = useMemo(() => new Set(visibleRecommendedIds), [visibleRecommendedIds]); const hfIds = useMemo(() => { if (!showHfSection) return []; @@ -519,7 +541,7 @@ export function HubModelPicker({ string, { est: number; status: VramFitStatus | null; detail: string | null } >(); - for (const id of recommendedIds) { + for (const id of visibleRecommendedIds) { const totalParams = recommendedParamCountById.get(id); if (totalParams) { const est = estimateLoadingVram(totalParams, "qlora"); @@ -531,10 +553,36 @@ export function HubModelPicker({ } } return map; - }, [recommendedIds, recommendedParamCountById, gpu]); + }, [visibleRecommendedIds, recommendedParamCountById, gpu]); const { scrollRef, sentinelRef } = useInfiniteScroll(fetchMore, results.length); + // Sentinel + IntersectionObserver for recommended infinite scroll. + // We disconnect after each fire so the observer doesn't loop while + // React re-renders; the effect re-creates it on the next page. + // Uses a callback ref for the sentinel so we detect mount/unmount reliably. + const [recommendedSentinel, setRecommendedSentinel] = useState(null); + const recommendedSentinelRef = useCallback((node: HTMLDivElement | null) => { + setRecommendedSentinel(node); + }, []); + useEffect(() => { + if (!recommendedSentinel || !hasMoreRecommended) return; + const root = scrollRef.current; + if (!root) return; + const obs = new IntersectionObserver( + ([e]) => { + if (e.isIntersecting) { + obs.disconnect(); + setRecommendedPage((p) => p + 1); + } + }, + { threshold: 0, root }, + ); + // Small delay so the browser finishes layout after the previous page render + const timer = setTimeout(() => obs.observe(recommendedSentinel), 100); + return () => { clearTimeout(timer); obs.disconnect(); }; + }, [recommendedSentinel, hasMoreRecommended, recommendedPage, scrollRef]); + /** Handle clicking a model row — GGUF repos expand, others load directly. */ const handleModelClick = useCallback( (id: string) => { @@ -622,12 +670,12 @@ export function HubModelPicker({ {!showHfSection && cachedReady ? ( <> {"\uD83E\uDDA5"} Recommended - {recommendedIds.length === 0 ? ( + {visibleRecommendedIds.length === 0 ? (
No default models.
) : ( - recommendedIds.map((id) => { + visibleRecommendedIds.map((id) => { const vram = recommendedVramMap.get(id); return (
@@ -651,6 +699,14 @@ export function HubModelPicker({ ); }) )} + {hasMoreRecommended && ( + <> +
+
+ +
+ + )} ) : null}