Skip to content

feat(gui3): remove backend preset rail, move assignment to global Presets view (#2432) - #2433

Merged
fl0rianr merged 3 commits into
kpoin/ui-testingfrom
feat/gui3-backend-preset-rail-removal
Jun 26, 2026
Merged

fl0rianr merged 3 commits into
kpoin/ui-testingfrom
feat/gui3-backend-preset-rail-removal

Conversation

@kpoineal

Copy link
Copy Markdown
Collaborator

Summary

Removes the preset rail and visual preset-selection flow from the Backend view, and moves backend-preset assignment into the global Presets view (#2432, milestone GUI3). A backend no longer behaves like a model: it has no preset by default, and an assigned backend preset is shown read-only on its cell only when present.

Backend presets are framed as optional global runtime-argument defaults that MERGE with (do not replace) model presets. The new assignment control in the Presets slideover clearly communicates the binding is global. Backend-preset binding stays client-local (localStorage, invariant #11) — lemond is untouched.

What changed

  • BackendManager.tsx — removed the preset rail, visual preset picker, and the model-like cell__select-btn selection affordance + all rail-only state. Cells now show an assigned preset read-only ([data-cell-preset]) only when one is actually assigned and compatible.
  • PresetManager.tsx — added an "Apply to a backend" section in the slideover (mirrors the model flow) with explicit global copy ("This preset applies globally to all models using this backend"), an accessible <select> (aria-label + aria-describedby → global note), an "Assign globally" button, a role="status" polite success region, and an "Applied to backends" zone listing global bindings with a qualified Detach action. Backend options are derived from /system-info.
  • styles.css — added .preset-backend-success styling.
  • tests/a11y.spec.ts — removed obsolete A51–A54/A58 (cell-select + rail notice region); added A167–A173.

Acceptance criteria

  • ✅ Backend view no longer shows the preset rail.
  • ✅ Backend view no longer shows the visual preset picker / model-like selection.
  • ✅ Backends do not appear to require a preset by default; a backend can exist with no assigned preset.
  • ✅ Backend preset assignment moved to the global Presets view; that view assigns to BOTH a backend and a model.
  • ✅ UI clearly communicates backend preset assignment is global (copy + button text + zone chip).
  • ✅ Backend presets treated as optional global defaults that merge with — not replace — model presets (copy).
  • ✅ Read-only preset name + icon may still show in the Backend view when assigned.
  • ✅ Programmatic semantics verified for screen readers (roles, accessible names, states) + keyboard operability.
  • ✅ Playwright/axe-core a11y tests cover the change (A167–A173).
  • ✅ All tests pass.

Tests

npx playwright test --reporter=line → 183 passed, 7 skipped (baseline 181 passed; removed 5 obsolete tests for removed features, added 7 new A167–A173).

Notes

Closes #2432

— Mattingly (Squad)

…sets view (#2432)

Backend view no longer behaves like a model: the preset rail, visual preset picker, and model-like cell-selection affordance are removed. A backend has no preset by default; an assigned backend preset is now shown read-only on its cell only when present.

Backend-preset assignment moves to the global Presets slideover, mirroring the model-assignment flow. The new 'Apply to a backend' control communicates that the binding is GLOBAL ('applies globally to all models using this backend') and that backend presets are optional runtime defaults that MERGE with model presets rather than replace them. A new 'Applied to backends' zone lists global bindings with a Detach action. Backend-preset binding stays client-local (localStorage, invariant #11).

Tests: removed obsolete A51-A54/A58 (cell-select + rail notice), added A167-A173 covering rail/picker removal, axe scan, accessible backend-assignment control with global copy, keyboard operability + role=status announcement, and read-only display. Full suite: 183 passed, 7 skipped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

@fl0rianr fl0rianr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the UI direction matches #2432 well: Backend view is de-modelified, assignment moved into Presets, and the a11y coverage is targeted.

I would not merge this quite yet; I found two functional gaps:

  1. Backend preset assignments look inert at runtime. The PR stores backend_presets and displays the binding, but the load path still appears to only use the model preset. api.loadModel() builds stagedOptions from recipeOptionsForModel(modelName, cachedModelInfo), and recipeOptionsForModel() only reads activePresetForModel(modelName); it does not read/merge activePresetForBackend(...) or loadBackendApplied(). That means the new backend binding can be visible in the UI but never affect effective recipe_options, which misses the #2432 “merged result” acceptance criterion.

  2. Default can be stored as a real backend binding. The Backend view treats DEFAULT_PRESET.id as “no assigned preset” and hides it, but the new Presets flow allows assigning Default because it is compatible with all backends. That creates an “Applied to backends” row for Default while the Backend view intentionally shows no chip. I’d make assigning Default detach/no-op, or disable backend assignment for Default with copy that “no backend preset” is the default state.

@kpoineal

Copy link
Copy Markdown
Collaborator Author

@fl0rianr thanks for the review — UI direction confirmed, and you're right on both functional gaps. Plan:

GAP 1 (inert backend bindings): Wire recipeOptionsForModel() to also resolve the backend preset for the model's recipe/backend and merge its recipe args into the effective recipe_options on load. Precedence: backend-preset args as the global base, model-preset args layered on top (model wins on conflict) — I'll confirm this matches main's arg-merge in the follow-up.

GAP 2 (Default as a backend binding): Disable backend assignment for the Default preset with accessible copy (Default is the no backend preset state), plus a defensive no-op/detach in the handler — consistent with the Backend view hiding Default.

New a11y tests A174+ for both. Will push and ping you for re-review.

— Mattingly (Squad)

… assign (#2432, PR #2433 review)

Addresses fl0rianr's CHANGES_REQUESTED review on PR #2433.

GAP 1 — backend-preset bindings were inert on load. recipeOptionsForModel()
only read the model preset, so an assigned backend preset never affected the
effective recipe_options. Added activePresetForModelBackend() (matches a
backend binding whose recipe-part equals one of the model's recipes; Default =
no backend preset) and merged its args UNDER the model preset so model-specific
values win on conflict (backend = global base defaults). Mirrors main's
more-specific-source-wins arg merge.

GAP 2 — assigning the Default preset to a backend stored a real binding,
creating an 'Applied to backends' row with no matching Backend-view chip.
Backend assignment is now disabled for the Default preset in the slideover with
accessible copy (programmatically associated via aria-describedby), and
handleApplyBackend() defensively detaches/no-ops if Default is ever passed.

Tests: new A174 (merge asserted on /load body), A175 (Default disabled + a11y
copy/describedby), A176 (axe scan of disabled state). 186 passed, 7 skipped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@kpoineal

Copy link
Copy Markdown
Collaborator Author

@fl0rianr both gaps are fixed and pushed (5830b25). Ready for re-review.

GAP 1 — backend presets now affect the load path

  • presetStore.ts: added activePresetForModelBackend(model). Backend bindings are keyed recipe:backend; a registry model carries its recipe(s) but not the concrete backend, so this matches a binding whose recipe-part equals one of the model's recipes. Default returns null (no backend preset → contributes nothing).

  • presetStore.ts → recipeOptionsForModel() now merges the backend preset's recipe args (scoped to the model capability) under the model preset, then feeds api.loadModel()'s stagedOptions unchanged.

    Merge precedence (please confirm vs main): backend preset = global base/runtime defaults; model preset = model-specific defaults layered on top, so the model preset wins on conflicting keys, and backend-only keys (e.g. llamacpp_backend) still merge in. This is the "more specific source wins" behavior; documented in a code comment on recipeOptionsForModel(). If main's arg-merge resolves conflicts the other way, say so and I'll flip the spread order.

GAP 2 — Default can no longer be stored as a backend binding

  • PresetManager.tsx: in the slideover, the "Apply to a backend" <select> and Assign globally button are now disabled for the Default preset, with a visible, screen-reader-associated note ([data-backend-apply-default-note], wired through aria-describedby on both controls) explaining that Default is the "no backend preset" state. canApplyBackend is false for Default.
  • handleApplyBackend() also defensively detaches/no-ops if Default is ever passed, so no inconsistent "Applied to backends" row can exist — consistent with the Backend view hiding Default.

Tests: added A174 (asserts the merged /load body: backend-only llamacpp_backend present + model wins ctx_size/llamacpp_args), A175 (Default disabled + accessible copy + aria-describedby association), A176 (axe WCAG 2.1 AA scan of the disabled state). Full suite: 186 passed, 7 skipped (was 183/7). No lemond or backend-wrapper changes.

— Mattingly (Squad)

@fl0rianr

Copy link
Copy Markdown
Collaborator

Thanks for addressing the previous two review points. The Default-preset handling is much cleaner now, and the runtime merge path is no longer inert.

I still think this needs one more fix before merge:

activePresetForModelBackend() still resolves backend presets too broadly. Backend bindings are stored as recipe:backend keys, for example llamacpp:cpu or llamacpp:vulkan, but the resolver only compares the recipe part against the model recipes and then returns the first matching binding. That means a preset assigned to llamacpp:cpu can affect every llama.cpp model load, including models that are actually using Vulkan, CUDA, Metal, etc. This changes the semantics from “all models using this backend” to “all models using this recipe”.

The better path is to keep this backend-level, as the UI copy and #2432 acceptance criteria already define it. Please resolve the actual backend key before applying the backend preset, then merge only the preset for that exact recipe:backend binding. The backend can be resolved from the effective load options / model preset options / explicit load options / recipe default backend, but the final merge decision should be based on the concrete backend that will actually be used.

The new A174 test currently locks in the over-broad behavior: it binds a backend preset to llamacpp:cpu, gives the model only recipe: 'llamacpp', and then expects llamacpp_backend: 'cpu' in the load body. That proves recipe-level matching, not backend-level matching. Please update the test so it proves exact backend matching, and add a negative case where a preset bound to llamacpp:cpu does not merge into a llamacpp:vulkan load.

Also, in live testing I was able to attach presets to unsupported backends. That should not be allowed. The Presets view should filter or disable unsupported backend options based on /system-info state, and the test coverage should verify that unsupported backends cannot receive a global backend preset.

Once the resolver uses the concrete backend key and unsupported backends are blocked in the assignment UI, this should be good to merge.

@kpoineal

Copy link
Copy Markdown
Collaborator Author

@fl0rianr Thanks for the third review — acknowledged. Plan for the three remaining items:

  1. Backend resolution too broad — I'll rework activePresetForModelBackend() in presetStore.ts to resolve the concrete backend that the load will actually use (precedence: explicit load options → model-preset recipe_options → recipe default_backend from /system-info), then merge ONLY the preset bound to that exact recipe:backend key. No more first-recipe-match.

  2. Tests — Fix A174 so it proves EXACT backend matching (model resolves to concrete cpu via recipe default_backend), and add a negative test: a llamacpp:cpu preset must NOT merge into a llamacpp:vulkan load.

  3. Unsupported backends in Presets UI — Gate assignment on /system-info backend state: an unsupported backend's option will be disabled (accessible disabled semantics + screen-reader copy explaining why) and not assignable, with defense-in-depth in the apply handler. New a11y test covers it.

Will push and request re-review. — Mattingly (Squad)

…rted backends (#2432)

PR #2433 round-3 review (fl0rianr). Three items:

1. activePresetForModelBackend() now resolves the CONCRETE backend a load
   will use (explicit load options -> model-preset recipe_options -> recipe
   default_backend from /system-info) and merges ONLY the preset bound to that
   exact recipe:backend key. Previously it matched recipe-level and applied a
   cpu-bound preset to every llamacpp load (vulkan/cuda/metal included).
   recipeOptionsForModel()/api.loadModel() thread explicit options + system-info.

2. Tests: A174 now proves EXACT backend matching (model resolves to concrete
   cpu via recipe default_backend); A177 negative test proves a llamacpp:cpu
   preset does NOT merge into a llamacpp:vulkan load.

3. Presets UI gates assignment on /system-info backend state: an 'unsupported'
   backend option is disabled with screen-reader copy + title and is not
   assignable (defense-in-depth in handleApplyBackend). A178/A179 cover it.

189 passed, 7 skipped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@kpoineal

Copy link
Copy Markdown
Collaborator Author

@fl0rianr Round-3 fixes pushed (commit 20c52621). All three items addressed:

Item 1 — exact backend resolution (src/presetStore.ts)
activePresetForModelBackend(model, ctx) no longer matches at recipe level. For each of the model's recipes it now resolves the concrete backend the load will actually use and merges ONLY the preset bound to that exact recipe:backend key. Resolution precedence (concreteBackendForRecipe()):

  1. explicit load options (recipeOptions passed to loadModel)
  2. model-preset recipe_options (e.g. llamacpp_backend)
  3. recipe default_backend from /system-info

The backend preset's own value is deliberately not consulted (would be circular). recipeOptionsForModel() and api.loadModel() thread the explicit options + systemInfoData through. So a preset on llamacpp:cpu only affects loads that actually resolve to cpu — Vulkan/CUDA/Metal loads are untouched.

Item 2 — tests (tests/a11y.spec.ts)

  • A174 rewritten to prove EXACT matching: the model has no concrete backend, so the load resolves to recipe default_backend = cpu; the llamacpp:cpu preset merges (model still wins on conflicting keys).
  • A177 (new, negative): model preset pins llamacpp_backend: vulkan; a preset bound to llamacpp:cpu does NOT merge (asserts its cpu-only ctx_size is absent; backend stays vulkan).

Item 3 — block unsupported backends (src/components/PresetManager.tsx)
backendOptionsFromSystemInfo() now reads each backend's state; a backend reported unsupported is marked supported: false. Its <option> is disabled with an (unsupported) label + explanatory title (kept in the list for screen-reader discoverability, not filtered out), canApplyBackend requires supported, and handleApplyBackend() no-ops as defense-in-depth. A selected unsupported backend shows a role=status explanation.

  • A178 (new): the llamacpp:cuda (unsupported) option is disabled, not selectable, and never produces a binding, while llamacpp:cpu stays assignable.
  • A179 (new): axe-core WCAG 2.1 AA scan of the section with the unsupported option present.

Suite: 189 passed, 7 skipped (was 186/7; +3). New a11y max = A179.

Ready for re-review — thanks! — Mattingly (Squad)

@fl0rianr fl0rianr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM ... for now...

@fl0rianr
fl0rianr merged commit a4fd966 into kpoin/ui-testing Jun 26, 2026
26 checks passed
@fl0rianr
fl0rianr deleted the feat/gui3-backend-preset-rail-removal branch June 26, 2026 14:54
fl0rianr pushed a commit that referenced this pull request Jul 17, 2026
…sets view (#2432) (#2433)

* feat(gui3): remove backend preset rail; move assignment to global Presets view (#2432)

Backend view no longer behaves like a model: the preset rail, visual preset picker, and model-like cell-selection affordance are removed. A backend has no preset by default; an assigned backend preset is now shown read-only on its cell only when present.

Backend-preset assignment moves to the global Presets slideover, mirroring the model-assignment flow. The new 'Apply to a backend' control communicates that the binding is GLOBAL ('applies globally to all models using this backend') and that backend presets are optional runtime defaults that MERGE with model presets rather than replace them. A new 'Applied to backends' zone lists global bindings with a Detach action. Backend-preset binding stays client-local (localStorage, invariant #11).

Tests: removed obsolete A51-A54/A58 (cell-select + rail notice), added A167-A173 covering rail/picker removal, axe scan, accessible backend-assignment control with global copy, keyboard operability + role=status announcement, and read-only display. Full suite: 183 passed, 7 skipped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(gui3): make backend presets affect load + disable Default backend assign (#2432, PR #2433 review)

Addresses fl0rianr's CHANGES_REQUESTED review on PR #2433.

GAP 1 — backend-preset bindings were inert on load. recipeOptionsForModel()
only read the model preset, so an assigned backend preset never affected the
effective recipe_options. Added activePresetForModelBackend() (matches a
backend binding whose recipe-part equals one of the model's recipes; Default =
no backend preset) and merged its args UNDER the model preset so model-specific
values win on conflict (backend = global base defaults). Mirrors main's
more-specific-source-wins arg merge.

GAP 2 — assigning the Default preset to a backend stored a real binding,
creating an 'Applied to backends' row with no matching Backend-view chip.
Backend assignment is now disabled for the Default preset in the slideover with
accessible copy (programmatically associated via aria-describedby), and
handleApplyBackend() defensively detaches/no-ops if Default is ever passed.

Tests: new A174 (merge asserted on /load body), A175 (Default disabled + a11y
copy/describedby), A176 (axe scan of disabled state). 186 passed, 7 skipped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix(gui3): resolve concrete backend for preset binding + gate unsupported backends (#2432)

PR #2433 round-3 review (fl0rianr). Three items:

1. activePresetForModelBackend() now resolves the CONCRETE backend a load
   will use (explicit load options -> model-preset recipe_options -> recipe
   default_backend from /system-info) and merges ONLY the preset bound to that
   exact recipe:backend key. Previously it matched recipe-level and applied a
   cpu-bound preset to every llamacpp load (vulkan/cuda/metal included).
   recipeOptionsForModel()/api.loadModel() thread explicit options + system-info.

2. Tests: A174 now proves EXACT backend matching (model resolves to concrete
   cpu via recipe default_backend); A177 negative test proves a llamacpp:cpu
   preset does NOT merge into a llamacpp:vulkan load.

3. Presets UI gates assignment on /system-info backend state: an 'unsupported'
   backend option is disabled with screen-reader copy + title and is not
   assignable (defense-in-depth in handleApplyBackend). A178/A179 cover it.

189 passed, 7 skipped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants