diff --git a/core/http/react-ui/e2e/models-gallery.spec.js b/core/http/react-ui/e2e/models-gallery.spec.js
index 5556fa239..fe32f3d9e 100644
--- a/core/http/react-ui/e2e/models-gallery.spec.js
+++ b/core/http/react-ui/e2e/models-gallery.spec.js
@@ -1081,3 +1081,141 @@ test.describe("Models Gallery - Markdown descriptions", () => {
await expect(detail).toContainText("Backend");
});
});
+
+// The filter block is three deliberate bands: query scope (search + backend
+// select), the use-case chip row, and the refinements (fits-in-GPU + context).
+// These assert the separation holds, because the regression they guard against
+// is the refinements being swept back into the chip row's wrap, where their
+// position depends on how many chips happen to wrap at the current width.
+test.describe("Models Gallery - Filter layout structure", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.route("**/api/models*", (route) => {
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify(MOCK_MODELS_RESPONSE),
+ });
+ });
+ await page.route("**/api/backends/usecases", (route) => {
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify(BACKEND_USECASES_MOCK),
+ });
+ });
+ await page.route("**/api/resources", (route) => {
+ route.fulfill({
+ contentType: "application/json",
+ body: JSON.stringify(MOCK_GPU_RESOURCES_RESPONSE),
+ });
+ });
+ await page.goto("/app/models");
+ await expect(page.locator("th", { hasText: "Backend" })).toBeVisible({
+ timeout: 10_000,
+ });
+ });
+
+ test("the chip row contains only use-case chips", async ({ page }) => {
+ const chipRow = page.locator(".filter-bar");
+ await expect(chipRow).toHaveCount(1);
+ // Nothing but .filter-btn children: no toggle, no select, no slider.
+ const childClasses = await chipRow.evaluate((el) =>
+ Array.from(el.children).map((c) => c.className),
+ );
+ expect(childClasses.length).toBeGreaterThan(0);
+ for (const cls of childClasses) {
+ expect(cls).toContain("filter-btn");
+ }
+ await expect(chipRow.locator("input[type='range']")).toHaveCount(0);
+ await expect(chipRow.locator(".filter-bar-group__toggle")).toHaveCount(0);
+ await expect(chipRow.getByText("All Backends")).toHaveCount(0);
+ });
+
+ test("refinements live in their own band, outside the chip row", async ({
+ page,
+ }) => {
+ const refine = page.getByTestId("models-filters-refine");
+ await expect(refine).toBeVisible();
+ await expect(refine.locator(".filter-bar")).toHaveCount(0);
+ await expect(refine.getByText("Fits in GPU")).toBeVisible();
+ await expect(refine.locator("#models-context-size")).toBeVisible();
+ // The band is a sibling of the chip row, never a descendant.
+ const nested = await page
+ .locator(".filter-bar")
+ .locator('[data-testid="models-filters-refine"]')
+ .count();
+ expect(nested).toBe(0);
+ });
+
+ test("the backend select sits in the query band above the chips", async ({
+ page,
+ }) => {
+ const selectBtn = page.locator("button", { hasText: "All Backends" });
+ await expect(selectBtn).toBeVisible();
+ const inChipRow = await page
+ .locator(".filter-bar")
+ .locator("button", { hasText: "All Backends" })
+ .count();
+ expect(inChipRow).toBe(0);
+ // Reads above the chips it gates.
+ const selectBox = await selectBtn.boundingBox();
+ const chipBox = await page.locator(".filter-bar").boundingBox();
+ expect(selectBox.y).toBeLessThan(chipBox.y);
+ });
+
+ test("refinements stay grouped and on one band at a narrow width", async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width: 900, height: 900 });
+ const refine = page.getByTestId("models-filters-refine");
+ await expect(refine).toBeVisible();
+ const chipBox = await page.locator(".filter-bar").boundingBox();
+ const refineBox = await refine.boundingBox();
+ // Below the chip row, not interleaved with it.
+ expect(refineBox.y).toBeGreaterThanOrEqual(chipBox.y + chipBox.height - 1);
+ await expect(refine.getByText("Fits in GPU")).toBeVisible();
+ await expect(refine.locator("#models-context-size")).toBeVisible();
+ });
+
+ test("chips expose pressed state and the context slider is labelled", async ({
+ page,
+ }) => {
+ const chatBtn = page.locator(".filter-btn", { hasText: "Chat" });
+ await expect(chatBtn).toHaveAttribute("aria-pressed", "false");
+ await chatBtn.click();
+ await expect(chatBtn).toHaveAttribute("aria-pressed", "true");
+
+ const slider = page.locator("#models-context-size");
+ // The slider steps over an index, so the announced value must be the size.
+ await expect(slider).toHaveAttribute("aria-valuetext", /^\d+K$/);
+ await expect(page.locator("label[for='models-context-size']")).toBeVisible();
+ });
+
+ test("a keyboard-focused chip shows a focus ring", async ({ page }) => {
+ // The global :focus-visible rule is wrapped in :where(), so it ties with
+ // .filter-btn on specificity and loses on order. Without an explicit rule
+ // the chips render their resting shadow while focused, i.e. no indicator.
+ await page.locator(".filter-bar-group__search input").click();
+ await page.keyboard.press("Tab"); // backend select
+ await page.keyboard.press("Tab"); // first chip
+ const focused = page.locator(".filter-btn:focus-visible");
+ await expect(focused).toHaveCount(1);
+ // The ring transitions in, so settle before reading the computed value.
+ await page.waitForTimeout(400);
+ const shadow = await focused.evaluate(
+ (el) => getComputedStyle(el).boxShadow,
+ );
+ // A 3px spread ring, not the 1px/2px resting drop shadow.
+ expect(shadow).toMatch(/0px 0px 0px 3px/);
+ });
+
+ test("the context control is keyboard reachable and drives the value", async ({
+ page,
+ }) => {
+ const slider = page.locator("#models-context-size");
+ const before = await slider.inputValue();
+ await slider.focus();
+ await expect(slider).toBeFocused();
+ await page.keyboard.press("ArrowRight");
+ await expect(slider).not.toHaveValue(before);
+ await expect(slider).toHaveAttribute("aria-valuetext", /^\d+K$/);
+ });
+});
diff --git a/core/http/react-ui/public/locales/de/models.json b/core/http/react-ui/public/locales/de/models.json
index 9142a1b8b..6f165a8e8 100644
--- a/core/http/react-ui/public/locales/de/models.json
+++ b/core/http/react-ui/public/locales/de/models.json
@@ -25,7 +25,10 @@
"rerank": "Rerank",
"fitsGpu": "Passt in die GPU",
"allBackends": "Alle Backends",
- "searchBackends": "Backends suchen..."
+ "searchBackends": "Backends suchen...",
+ "contextSize": "Kontext:",
+ "useCaseLabel": "Nach Anwendungsfall filtern",
+ "unavailableForBackend": "Für das gewählte Backend nicht verfügbar"
},
"search": {
"placeholder": "Modelle suchen...",
diff --git a/core/http/react-ui/public/locales/en/models.json b/core/http/react-ui/public/locales/en/models.json
index ec5180f3b..804c54490 100644
--- a/core/http/react-ui/public/locales/en/models.json
+++ b/core/http/react-ui/public/locales/en/models.json
@@ -44,7 +44,10 @@
"ner": "NER",
"fitsGpu": "Fits in GPU",
"allBackends": "All Backends",
- "searchBackends": "Search backends..."
+ "searchBackends": "Search backends...",
+ "contextSize": "Context:",
+ "useCaseLabel": "Filter by use case",
+ "unavailableForBackend": "Not available for the selected backend"
},
"search": {
"placeholder": "Search models...",
diff --git a/core/http/react-ui/public/locales/es/models.json b/core/http/react-ui/public/locales/es/models.json
index 69ca19dae..15e13a613 100644
--- a/core/http/react-ui/public/locales/es/models.json
+++ b/core/http/react-ui/public/locales/es/models.json
@@ -25,7 +25,10 @@
"rerank": "Rerank",
"fitsGpu": "Cabe en la GPU",
"allBackends": "Todos los backends",
- "searchBackends": "Buscar backends..."
+ "searchBackends": "Buscar backends...",
+ "contextSize": "Contexto:",
+ "useCaseLabel": "Filtrar por caso de uso",
+ "unavailableForBackend": "No disponible para el backend seleccionado"
},
"search": {
"placeholder": "Buscar modelos...",
diff --git a/core/http/react-ui/public/locales/id/models.json b/core/http/react-ui/public/locales/id/models.json
index 3acff3afb..4811edc6d 100644
--- a/core/http/react-ui/public/locales/id/models.json
+++ b/core/http/react-ui/public/locales/id/models.json
@@ -32,7 +32,10 @@
"vad": "VAD",
"fitsGpu": "Muat di GPU",
"allBackends": "Semua Backend",
- "searchBackends": "Cari backends..."
+ "searchBackends": "Cari backends...",
+ "contextSize": "Konteks:",
+ "useCaseLabel": "Filter berdasarkan kasus penggunaan",
+ "unavailableForBackend": "Tidak tersedia untuk backend yang dipilih"
},
"search": {
"placeholder": "Cari model...",
diff --git a/core/http/react-ui/public/locales/it/models.json b/core/http/react-ui/public/locales/it/models.json
index 15eca936f..a23ab3f9e 100644
--- a/core/http/react-ui/public/locales/it/models.json
+++ b/core/http/react-ui/public/locales/it/models.json
@@ -25,7 +25,10 @@
"rerank": "Rerank",
"fitsGpu": "Entra nella GPU",
"allBackends": "Tutti i backend",
- "searchBackends": "Cerca backend..."
+ "searchBackends": "Cerca backend...",
+ "contextSize": "Contesto:",
+ "useCaseLabel": "Filtra per caso d'uso",
+ "unavailableForBackend": "Non disponibile per il backend selezionato"
},
"search": {
"placeholder": "Cerca modelli...",
diff --git a/core/http/react-ui/public/locales/ko/models.json b/core/http/react-ui/public/locales/ko/models.json
index 2ca05d4a7..40b18f26a 100644
--- a/core/http/react-ui/public/locales/ko/models.json
+++ b/core/http/react-ui/public/locales/ko/models.json
@@ -31,7 +31,10 @@
"vad": "VAD",
"fitsGpu": "GPU에 적합",
"allBackends": "모든 백엔드",
- "searchBackends": "백엔드 검색..."
+ "searchBackends": "백엔드 검색...",
+ "contextSize": "컨텍스트:",
+ "useCaseLabel": "사용 사례로 필터링",
+ "unavailableForBackend": "선택한 백엔드에서 사용할 수 없음"
},
"search": {
"placeholder": "모델 검색...",
diff --git a/core/http/react-ui/public/locales/zh-CN/models.json b/core/http/react-ui/public/locales/zh-CN/models.json
index 88cd49d7d..f1ddd7dbf 100644
--- a/core/http/react-ui/public/locales/zh-CN/models.json
+++ b/core/http/react-ui/public/locales/zh-CN/models.json
@@ -25,7 +25,10 @@
"rerank": "重排",
"fitsGpu": "适合 GPU",
"allBackends": "所有后端",
- "searchBackends": "搜索后端..."
+ "searchBackends": "搜索后端...",
+ "contextSize": "上下文:",
+ "useCaseLabel": "按用例筛选",
+ "unavailableForBackend": "所选后端不支持"
},
"search": {
"placeholder": "搜索模型...",
diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css
index 1b4273a21..e47b76b68 100644
--- a/core/http/react-ui/src/App.css
+++ b/core/http/react-ui/src/App.css
@@ -2191,6 +2191,68 @@ select.input {
user-select: none;
white-space: nowrap;
}
+/* Models gallery filter block. Three bands (query / taxonomy / refinements)
+ stacked by .filter-bar-group. The refinements live in their own band so the
+ chip row's wrap height can never displace them. */
+.models-filters__query {
+ flex-wrap: nowrap;
+ gap: var(--spacing-sm);
+}
+.models-filters__backend {
+ flex: 0 0 auto;
+ min-width: 0;
+}
+/* .filter-bar carries its own bottom margin for standalone use; inside the
+ group the parent's gap owns the rhythm. */
+.models-filters > .filter-bar {
+ margin-bottom: 0;
+}
+.models-filters__refine {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: var(--spacing-sm) var(--spacing-lg);
+ padding-top: var(--spacing-sm);
+ border-top: 1px solid var(--color-border-subtle);
+}
+/* Both refinements read as one secondary group: same size, same colour and the
+ same icon-then-label rhythm as .filter-bar-group__toggle. */
+.models-filters__context {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-sm);
+ font-size: var(--text-xs);
+ color: var(--color-text-secondary);
+}
+.models-filters__context > label {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ white-space: nowrap;
+ cursor: pointer;
+}
+.models-filters__context input[type='range'] {
+ width: 140px;
+ accent-color: var(--color-primary);
+ cursor: pointer;
+}
+.models-filters__context-value {
+ font-weight: var(--font-weight-medium);
+ min-width: 3em;
+ font-variant-numeric: tabular-nums;
+}
+
+@media (max-width: 768px) {
+ /* Search and backend select each take a full row rather than squeezing the
+ input below a usable width. */
+ .models-filters__query {
+ flex-wrap: wrap;
+ }
+ .models-filters__backend {
+ flex: 1 1 100%;
+ }
+}
+
.filter-btn__count {
display: inline-flex;
align-items: center;
@@ -2720,6 +2782,40 @@ button.collapsible-header:focus-visible {
border-color: var(--color-primary-border);
}
+/* The global :focus-visible ring is wrapped in :where(), so it carries the
+ specificity of a bare :focus-visible - the same (0,1,0) as .filter-btn, which
+ is declared later and therefore won with its resting shadow. Chips were left
+ with no visible keyboard focus indicator at all; this restates the ring where
+ it outranks both the resting and the hover shadow. */
+.filter-btn:focus-visible {
+ box-shadow: 0 0 0 3px var(--color-focus-ring);
+ border-color: var(--color-primary);
+}
+
+/* A chip is disabled when the selected backend cannot serve that use case. It
+ keeps its resting colours so the row still reads as a set, dimmed enough to
+ register as out of play. */
+.filter-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ box-shadow: none;
+}
+.filter-btn:disabled:hover {
+ transform: none;
+ box-shadow: none;
+ color: var(--color-text-secondary);
+ border-color: var(--color-border-default);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .filter-btn {
+ transition: none;
+ }
+ .filter-btn:hover {
+ transform: none;
+ }
+}
+
/* Login page */
.login-page {
min-height: 100vh;
diff --git a/core/http/react-ui/src/pages/Models.jsx b/core/http/react-ui/src/pages/Models.jsx
index e5665b44e..152fef833 100644
--- a/core/http/react-ui/src/pages/Models.jsx
+++ b/core/http/react-ui/src/pages/Models.jsx
@@ -336,73 +336,96 @@ export default function Models() {