feat(ui): show the collapsed model listing by default

The gallery listing is what a user reaches for to answer "what can I
install". Answering that with several rows for the same model, one per
build, makes the reader do the deduplication the collapsed view already
does, so the collapsed view is the one to land on.

The UI now asks for collapse_variants=true unless the toggle says
otherwise. The server default is deliberately untouched: a request with
the parameter absent still returns the full listing, because other API
clients depend on that response and collapsing it under them would be a
breaking change. Opting out omits the parameter rather than sending
false, so it asks for exactly the listing everyone else gets.

The stored preference changes vocabulary from '1'/'0' to 'on'/'off'. The
previous build wrote it from an effect that runs on mount, so a stored
'0' recorded that the page had been opened rather than that anyone chose
the expanded view, and honouring it would pin every earlier visitor to a
default they never picked. Only the new vocabulary counts as a choice;
a legacy '1' meant the collapsed view and is what the new default gives
anyway, so no earlier deliberate choice is lost.

Collapsing being the default also changes what the empty state may say
about it. An opted-into filter can be named as the cause of an empty
result; a default cannot, so the filters keep the top line and the
collapsed view drops to a hint below it, shown only once filters are
narrowing the set. For the same reason "Clear filters" now restores the
collapsed default instead of switching it off, and the toggle alone no
longer counts as a filter worth offering to clear.

The label stays "One row per model": it describes the view the user is
looking at rather than an action, so it reads the same whether it is
opted into or out of.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-07-19 20:49:17 +00:00
parent f7ff7b0ed3
commit dd20e8f486
9 changed files with 140 additions and 74 deletions

View File

@@ -750,20 +750,24 @@ test.describe("Models Gallery - Collapse Variants Filter", () => {
await page.route("**/api/models*", (route) => {
const url = new URL(route.request().url());
// Only the listing itself; sibling routes like /api/models/estimate
// must not pollute the record of what the filter sent.
if (url.pathname.endsWith("/api/models")) {
// Only the gallery's own listing. Sibling routes like
// /api/models/estimate share the prefix, and the recommended-models
// panel queries /api/models itself with its own page size, so neither
// must pollute the record of what the filter sent, nor pick up the
// narrowed bodies below.
const isListing =
url.pathname.endsWith("/api/models") &&
url.searchParams.get("items") === "9";
if (isListing) {
listingUrls.push(url);
}
const collapsed = url.searchParams.get("collapse_variants") === "true";
const tag = url.searchParams.get("tag");
let body = MOCK_MODELS_RESPONSE;
if (collapsed) {
// Stacking the toggle with a usecase filter is the case that yields
// nothing but a collapsed view, which is what the empty state has to
// explain.
body = tag ? EMPTY_FILTERED_RESPONSE : COLLAPSED_RESPONSE;
}
// A usecase filter matches nothing in this fixture either way, so the
// empty state is reachable in both toggle states and the specs can pin
// down what it says about each.
let body = collapsed ? COLLAPSED_RESPONSE : MOCK_MODELS_RESPONSE;
if (isListing && tag) body = EMPTY_FILTERED_RESPONSE;
route.fulfill({
contentType: "application/json",
body: JSON.stringify(body),
@@ -780,30 +784,10 @@ test.describe("Models Gallery - Collapse Variants Filter", () => {
await expect(page.getByText("One row per model")).toBeVisible();
});
test("defaults to off, showing everything and sending no filter param", async ({
test("defaults to on, sending collapse_variants=true and collapsing the list", async ({
page,
}) => {
await expect(page.getByLabel("One row per model")).not.toBeChecked();
await expect(page.locator("tr", { hasText: "llama-model" })).toBeVisible();
await expect(
page.locator("tr", { hasText: "whisper-model" }),
).toBeVisible();
await expect(
page.locator("tr", { hasText: "stablediffusion-model" }),
).toBeVisible();
// Asserted positively over every listing request, so a param that leaked
// in as collapse_variants=false would still fail this.
expect(listingUrls.length).toBeGreaterThan(0);
for (const url of listingUrls) {
expect(url.searchParams.has("collapse_variants")).toBe(false);
}
});
test("toggling on sends collapse_variants=true and narrows the list", async ({
page,
}) => {
await collapseToggle(page).click();
await expect(page.getByLabel("One row per model")).toBeChecked();
// The parent keeps its row, the build it already offers drops out, and an
// entry that is nobody's variant is untouched. A filter that kept only the
@@ -816,18 +800,34 @@ test.describe("Models Gallery - Collapse Variants Filter", () => {
page.locator("tr", { hasText: "stablediffusion-model" }),
).toBeVisible();
const last = listingUrls[listingUrls.length - 1];
expect(last.searchParams.get("collapse_variants")).toBe("true");
// Asserted over every listing request, so a first paint that fetched the
// uncollapsed listing before settling on the default would still fail.
expect(listingUrls.length).toBeGreaterThan(0);
for (const url of listingUrls) {
expect(url.searchParams.get("collapse_variants")).toBe("true");
}
});
test("toggling off restores the full list and drops the param", async ({
test("a stored preference from before the default flipped is not honoured", async ({
page,
}) => {
await collapseToggle(page).click();
// The previous build wrote '0' from an effect that runs on mount, so it
// marks a visit rather than a choice. Reading it as one would leave every
// earlier visitor on the old default forever.
await page.evaluate(() => {
localStorage.setItem("localai-models-collapse-variants-filter", "0");
});
await page.reload();
await expect(page.getByLabel("One row per model")).toBeChecked();
await expect(page.locator("tr", { hasText: "whisper-model" })).toHaveCount(
0,
);
});
test("turning it off drops the param and restores the full list", async ({
page,
}) => {
await collapseToggle(page).click();
await expect(
@@ -837,56 +837,92 @@ test.describe("Models Gallery - Collapse Variants Filter", () => {
page.locator("tr", { hasText: "stablediffusion-model" }),
).toBeVisible();
// Omitted rather than sent as false, so opting out asks for exactly the
// listing every other API client gets.
const last = listingUrls[listingUrls.length - 1];
expect(last.searchParams.has("collapse_variants")).toBe(false);
});
test("turning it back on collapses the list again", async ({ page }) => {
await collapseToggle(page).click();
await expect(
page.locator("tr", { hasText: "whisper-model" }),
).toBeVisible();
await collapseToggle(page).click();
await expect(page.locator("tr", { hasText: "whisper-model" })).toHaveCount(
0,
);
const last = listingUrls[listingUrls.length - 1];
expect(last.searchParams.get("collapse_variants")).toBe("true");
});
test("resets to page 1 when toggled", async ({ page }) => {
await collapseToggle(page).click();
const last = listingUrls[listingUrls.length - 1];
// A filter that narrowed the rows while holding page 3 would strand the
// user on a page the filtered set no longer has.
// A filter that widened or narrowed the rows while holding page 3 would
// strand the user on a page the new set may not have.
expect(last.searchParams.get("page") || "1").toBe("1");
});
test("state persists after reload, like the other filters", async ({
test("turning it off persists after reload, like the other filters", async ({
page,
}) => {
await collapseToggle(page).click();
await page.reload();
await expect(page.getByLabel("One row per model")).toBeChecked();
await expect(page.locator("tr", { hasText: "whisper-model" })).toHaveCount(
0,
);
await expect(page.getByLabel("One row per model")).not.toBeChecked();
await expect(
page.locator("tr", { hasText: "whisper-model" }),
).toBeVisible();
const last = listingUrls[listingUrls.length - 1];
expect(last.searchParams.has("collapse_variants")).toBe(false);
});
test("names the collapsed view in the empty state when it is the cause", async ({
test("mentions the collapsed view in the empty state as a hint, not a cause", async ({
page,
}) => {
await collapseToggle(page).click();
await page.locator(".filter-btn", { hasText: "Chat" }).click();
await expect(page.locator(".empty-state-title")).toHaveText(
"No models found",
);
// The generic copy would leave a user thinking the gallery is broken
// rather than that this toggle is doing exactly what it says.
// Collapsing is the default, so it cannot be blamed for the empty result
// the way an opted-into filter could; the filters get the top line and the
// toggle only gets a hint underneath.
await expect(page.locator(".empty-state-text")).toHaveText(
'The models matching your filters are alternative builds that another entry already offers. Turn off "One row per model" to see them.',
"No models match your current search or filters.",
);
await expect(page.locator(".empty-state-hint")).toHaveText(
'Alternative builds that another entry already offers are hidden. Turn off "One row per model" to include them.',
);
});
test("clear filters turns the toggle back off", async ({ page }) => {
test("no collapsed-view hint once the toggle is off", async ({ page }) => {
await collapseToggle(page).click();
await page.locator(".filter-btn", { hasText: "Chat" }).click();
await expect(page.locator(".empty-state")).toBeVisible();
// Nothing is hidden any more, so the hint would be pointing at a toggle
// that is already doing what it suggests.
await expect(page.locator(".empty-state-hint")).toHaveCount(0);
});
test("clear filters restores the collapsed default", async ({ page }) => {
await collapseToggle(page).click();
await page.locator(".filter-btn", { hasText: "Chat" }).click();
await expect(page.locator(".empty-state")).toBeVisible();
await page.getByRole("button", { name: "Clear filters" }).click();
await expect(page.getByLabel("One row per model")).not.toBeChecked();
await expect(
page.locator("tr", { hasText: "whisper-model" }),
).toBeVisible();
// Clearing filters means going back to the view a fresh visit gets, which
// is now the collapsed one.
await expect(page.getByLabel("One row per model")).toBeChecked();
await expect(page.locator("tr", { hasText: "llama-model" })).toBeVisible();
await expect(page.locator("tr", { hasText: "whisper-model" })).toHaveCount(
0,
);
});
});

View File

@@ -72,7 +72,7 @@
"empty": {
"title": "Keine Modelle gefunden",
"withFilters": "Keine Modelle entsprechen den aktuellen Such- oder Filterkriterien.",
"withCollapsedVariants": "Die passenden Modelle sind alternative Builds, die ein anderer Eintrag bereits anbietet. Schalte \"Eine Zeile pro Modell\" aus, um sie zu sehen.",
"collapsedVariantsHint": "Alternative Builds, die ein anderer Eintrag bereits anbietet, sind ausgeblendet. Schalte \"Eine Zeile pro Modell\" aus, um sie einzubeziehen.",
"noFilters": "Die Modellgalerie ist leer."
},
"deleteDialog": {

View File

@@ -91,7 +91,7 @@
"empty": {
"title": "No models found",
"withFilters": "No models match your current search or filters.",
"withCollapsedVariants": "The models matching your filters are alternative builds that another entry already offers. Turn off \"One row per model\" to see them.",
"collapsedVariantsHint": "Alternative builds that another entry already offers are hidden. Turn off \"One row per model\" to include them.",
"noFilters": "The model gallery is empty."
},
"deleteDialog": {

View File

@@ -72,7 +72,7 @@
"empty": {
"title": "No se encontraron modelos",
"withFilters": "Ningún modelo coincide con la búsqueda o filtros actuales.",
"withCollapsedVariants": "Los modelos que coinciden con los filtros son compilaciones alternativas que otra entrada ya ofrece. Desactiva \"Una fila por modelo\" para verlas.",
"collapsedVariantsHint": "Las compilaciones alternativas que otra entrada ya ofrece están ocultas. Desactiva \"Una fila por modelo\" para incluirlas.",
"noFilters": "La galería de modelos está vacía."
},
"deleteDialog": {

View File

@@ -79,7 +79,7 @@
"empty": {
"title": "Model tidak ditemukan",
"withFilters": "Tidak ada model yang cocok dengan pencarian atau filter Anda.",
"withCollapsedVariants": "Model yang cocok dengan filter adalah build alternatif yang sudah ditawarkan entri lain. Matikan \"Satu baris per model\" untuk melihatnya.",
"collapsedVariantsHint": "Build alternatif yang sudah ditawarkan entri lain disembunyikan. Matikan \"Satu baris per model\" untuk menyertakannya.",
"noFilters": "Galeri model kosong."
},
"deleteDialog": {

View File

@@ -72,7 +72,7 @@
"empty": {
"title": "Nessun modello trovato",
"withFilters": "Nessun modello corrisponde ai filtri attuali.",
"withCollapsedVariants": "I modelli che corrispondono ai filtri sono build alternative gia offerte da un'altra voce. Disattiva \"Una riga per modello\" per vederle.",
"collapsedVariantsHint": "Le build alternative gia offerte da un'altra voce sono nascoste. Disattiva \"Una riga per modello\" per includerle.",
"noFilters": "La galleria modelli è vuota."
},
"deleteDialog": {

View File

@@ -72,7 +72,7 @@
"empty": {
"title": "未找到模型",
"withFilters": "没有模型与当前搜索或筛选条件匹配。",
"withCollapsedVariants": "符合筛选条件的模型是其他条目已经提供的替代构建。关闭“每个模型一行”即可查看。",
"collapsedVariantsHint": "其他条目已经提供的替代构建已被隐藏。关闭“每个模型一行”即可将其包含在内。",
"noFilters": "模型库为空。"
},
"deleteDialog": {

View File

@@ -2890,6 +2890,16 @@ button.collapsible-header:focus-visible {
margin: 0;
}
/* Secondary to .empty-state-text: a hint about a default that may be narrowing
the result, so it must not compete with the reason stated above it. */
.empty-state-hint {
color: var(--color-text-muted);
font-size: var(--text-sm);
line-height: var(--leading-normal);
max-width: 52ch;
margin: 0;
}
/* Opt-in editorial sub-parts — pages can adopt these class names over time */
.empty-state__eyebrow {
display: inline-flex;

View File

@@ -26,6 +26,27 @@ const FITS_FILTER_STORAGE_KEY = 'localai-models-fits-filter'
// Renamed alongside the filter it persists: the old key's stored value meant
// "show only entries declaring variants", which is not what this filter does.
const COLLAPSE_VARIANTS_STORAGE_KEY = 'localai-models-collapse-variants-filter'
// The deduplicated gallery is what a user asking "what can I install" wants, so
// the UI asks for it unless told otherwise. Only the UI decides this: the server
// still returns the full listing when the parameter is absent, because other API
// clients depend on that response.
const COLLAPSE_VARIANTS_DEFAULT = true
// The stored vocabulary changed with the default. The previous build wrote
// '1'/'0' from an effect that runs on mount, so a stored '0' recorded that the
// page had been opened, not that anyone chose the expanded view. Honouring it
// would pin every earlier visitor to a default they never picked, so only the
// 'on'/'off' written since counts as a choice.
const readCollapseVariantsPreference = () => {
try {
const stored = localStorage.getItem(COLLAPSE_VARIANTS_STORAGE_KEY)
if (stored === 'on') return true
if (stored === 'off') return false
return COLLAPSE_VARIANTS_DEFAULT
} catch {
return COLLAPSE_VARIANTS_DEFAULT
}
}
const FILTERS = [
@@ -94,13 +115,7 @@ export default function Models() {
// another entry already offers as variants. Server-side, unlike fitsFilter,
// because the listing paginates and a client-side narrowing would leave the
// page count describing the unfiltered set.
const [collapseVariants, setCollapseVariants] = useState(() => {
try {
return localStorage.getItem(COLLAPSE_VARIANTS_STORAGE_KEY) === '1'
} catch {
return false
}
})
const [collapseVariants, setCollapseVariants] = useState(readCollapseVariantsPreference)
// Total GPU memory for "fits" check
const totalGpuMemory = resources?.aggregate?.total_memory || 0
@@ -120,8 +135,8 @@ export default function Models() {
if (filtersVal.length > 0) queryParams.tag = filtersVal.join(',')
if (searchVal) queryParams.term = searchVal
if (backendVal) queryParams.backend = backendVal
// Omitted entirely when off, so the default request is byte-for-byte
// what it was before the toggle existed.
// Omitted entirely when off rather than sent as false, so opting out asks
// for exactly the listing every other API client gets.
if (collapseVal) queryParams.collapse_variants = 'true'
if (sortVal) {
queryParams.sort = sortVal
@@ -311,7 +326,7 @@ export default function Models() {
useEffect(() => {
try {
localStorage.setItem(COLLAPSE_VARIANTS_STORAGE_KEY, collapseVariants ? '1' : '0')
localStorage.setItem(COLLAPSE_VARIANTS_STORAGE_KEY, collapseVariants ? 'on' : 'off')
} catch {
// Ignore storage errors (e.g., private browsing restrictions).
}
@@ -442,14 +457,19 @@ export default function Models() {
<div className="empty-state-icon"><i className="fas fa-search" /></div>
<h2 className="empty-state-title">{t('empty.title')}</h2>
<p className="empty-state-text">
{collapseVariants
? t('empty.withCollapsedVariants')
: search || filters.length > 0 || backendFilter || fitsFilter ? t('empty.withFilters') : t('empty.noFilters')}
{search || filters.length > 0 || backendFilter || fitsFilter ? t('empty.withFilters') : t('empty.noFilters')}
</p>
{(search || filters.length > 0 || backendFilter || fitsFilter || collapseVariants) && (
{/* Collapsing is the default now, so it can no longer be named as the
cause of an empty result the way an opted-into filter could. It is
still worth mentioning as something the filters may be hiding
behind, but only once the user has filters narrowing the set. */}
{collapseVariants && (search || filters.length > 0 || backendFilter || fitsFilter) && (
<p className="empty-state-hint">{t('empty.collapsedVariantsHint')}</p>
)}
{(search || filters.length > 0 || backendFilter || fitsFilter) && (
<button
className="btn btn-secondary btn-sm"
onClick={() => { handleSearch(''); setFilters([]); setBackendFilter(''); setFitsFilter(false); setCollapseVariants(false); setPage(1) }}
onClick={() => { handleSearch(''); setFilters([]); setBackendFilter(''); setFitsFilter(false); setCollapseVariants(COLLAPSE_VARIANTS_DEFAULT); setPage(1) }}
>
<i className="fas fa-times" /> {t('search.clearFilters')}
</button>