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.
This commit is contained in:
Daniel Han 2026-03-18 03:17:01 -07:00 committed by GitHub
commit 65acefd2a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 70 additions and 14 deletions

View file

@ -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)

View file

@ -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<HTMLDivElement | null>(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 ? (
<>
<ListLabel>{"\uD83E\uDDA5"} Recommended</ListLabel>
{recommendedIds.length === 0 ? (
{visibleRecommendedIds.length === 0 ? (
<div className="px-2.5 py-2 text-xs text-muted-foreground">
No default models.
</div>
) : (
recommendedIds.map((id) => {
visibleRecommendedIds.map((id) => {
const vram = recommendedVramMap.get(id);
return (
<div key={id}>
@ -651,6 +699,14 @@ export function HubModelPicker({
);
})
)}
{hasMoreRecommended && (
<>
<div ref={recommendedSentinelRef} className="h-px" />
<div className="flex items-center justify-center py-2">
<Spinner className="size-3.5 text-muted-foreground" />
</div>
</>
)}
</>
) : null}