Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 34 additions & 2 deletions prototype/ui-redesign/ACCESSIBILITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,40 @@
**Date:** 2026-06-25
**Branch:** `feat/gui3-model-detail-redesign`
**Scope:** `prototype/ui-redesign/` only
**Status:** Phase 1 ✅ complete, Phase 2 ✅ mostly complete (items 16–18 deferred to Phase 3), Phase 3 (GUI3 preset a11y) ✅ complete, Group C (BackendManager) ✅ complete, Group D (MCP Gateway panel) ✅ complete, Group E (Master-detail model view, #2355 Slice 1) ✅ complete, Group F (#2355 Slice 1 reconciliation — fl0rianr clarifications) ✅ complete, Group G (Left navigation rail — three-pane model view) ✅ complete, Group H (Model-detail Presets card grid — #2424 fl0rianr) ✅ complete, Group I (Model view refinements — #2424 fl0rianr review) ✅ complete, Group J (Model view merge items — #2424 fl0rianr 2nd review) ✅ complete
**Test status (2026-06-25):** All 168 automated tests passing, 7 skipped, 0 failed on `feat/gui3-model-detail-redesign` (A149–A153 added for the #2424 second-round merge items)
**Status:** Phase 1 ✅ complete, Phase 2 ✅ mostly complete (items 16–18 deferred to Phase 3), Phase 3 (GUI3 preset a11y) ✅ complete, Group C (BackendManager) ✅ complete, Group D (MCP Gateway panel) ✅ complete, Group E (Master-detail model view, #2355 Slice 1) ✅ complete, Group F (#2355 Slice 1 reconciliation — fl0rianr clarifications) ✅ complete, Group G (Left navigation rail — three-pane model view) ✅ complete, Group H (Model-detail Presets card grid — #2424 fl0rianr) ✅ complete, Group I (Model view refinements — #2424 fl0rianr review) ✅ complete, Group J (Model view merge items — #2424 fl0rianr 2nd review) ✅ complete, Group K (Update preset while loaded — #2356) ✅ complete
**Test status (2026-06-26):** All 181 automated tests passing, 7 skipped, 0 failed on `feat/gui3-update-preset-while-loaded` (A154–A166 cover #2356 update-preset-while-loaded, simplified design)

---

## Group K — Update preset while a model is loaded (#2356, 2026-06-26, simplified)

Lets a user change the preset of an already-loaded model. When a model is loaded and a **different** preset is linked to it, an **"Apply preset"** (live) / **"Reload to apply preset"** (load-time) button appears next to **Unload** in the detail-panel header actions.

Simplified per maintainer @fl0rianr review + Lovell: **no `update-preset` endpoint and no client `mode` parameter** — the UI is not the source of truth for runtime capability.

1. **Live vs reload classification (client-local).** `classifyPresetChange(running, next)` in `presetStore.ts` returns `'none' | 'live' | 'reload'`. Reload-requiring fields = `recipe_options`, `engine_hint` (runtime binds them at init). Live-updateable fields = `sampling`, `system_prompt_id`, `system_prompts`, `tools_enabled` (request-time). **Correctness fix:** the function no longer early-returns `'none'` on identical preset ids — same-id in-place edits (changed temperature/system_prompt/ctx_size) now classify correctly.
2. **Running-preset store.** A distinct `running_presets` localStorage map records the preset each loaded model is actually running. The divergence between *linked* (`applied_presets`) and *running* is what surfaces the button.
3. **No endpoint, no mode param.** Live changes are a pure client-local **rebind** of the active preset — request composition (`samplingForModel` in `api.ts`, `systemPromptTextForPreset` in `ChatView.tsx`) carries the new values on the next generation request; nothing is POSTed. Load-time changes go through `api.reloadModel(modelName, recipeOptions, modelInfo)` = **unload + load** (named for a possible future in-place backend reload). The active-preset binding **persists across the reload** so the reloaded model runs the newly-bound preset. The same primitives back the `change_preset` Lemonade tool (`src/tools/lemonadeTools.ts`). Documented in `docs/UPDATE_PRESET_CONTRACT.md`.

**a11y specifics**
- The button is a real `<button>`; its `aria-label` names the model: "Apply preset for {name}" (live) vs "Reload {name} to apply preset" (load-time). `aria-busy` is set while reloading.
- An always-present `role="status" aria-live="polite" aria-atomic="true"` region announces the outcome ("applied live, no reload needed" / "model reloaded"). A sighted hint (`aria-hidden`) explains why the button appeared.
- Focus is moved to the **Unload** button on success (button unmounts once running == linked) to avoid focus loss; stays on the Apply/Reload button on error.
- The reload spinner respects `prefers-reduced-motion`. Contrast for status colours ≥ 4.5:1.

**Tests added:** A154–A166 (13 tests) in `tests/a11y.spec.ts`.
- A154: no Update preset button when linked == running
- A155: switching to a live-only preset reveals Update preset next to Unload (label has no "reload")
- A156: clicking (live) calls the contract with `mode=live` and announces no reload; button disappears
- A157: switching to a reload-requiring preset labels the button as reloading
- A158: clicking (reload) calls the contract with `mode=reload` and announces a reload
- A159: Update preset is keyboard operable (Enter)
- A160: feedback is a polite live region (`role=status`, `aria-live=polite`)
- A161: no Update preset button for a non-loaded model
- A162: Update-preset visible state passes WCAG 2.1 AA axe-core scan
- A163: focus moves to Unload after a live update (no focus loss)

**Files changed:** `presetStore.ts`, `api.ts`, `components/ModelDetailPanel.tsx`, `components/ModelManager.tsx`, `styles/styles.css`, `tests/a11y.spec.ts`, `docs/UPDATE_PRESET_CONTRACT.md`, `ACCESSIBILITY.md`.

---

Expand Down
159 changes: 159 additions & 0 deletions prototype/ui-redesign/docs/UPDATE_PRESET_CONTRACT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
# Update preset while loaded — design & `window.api` contract (#2356)

**Status:** UI POC on `feat/gui3-update-preset-while-loaded` (PR #2429).
**Revised** per maintainer feedback (@fl0rianr) to drop the dedicated
`update-preset` endpoint and the client-provided `mode` parameter. `lemond`
remains off-limits to this POC.

The previous revision of this doc proposed a `POST /api/v{0,1}/update-preset`
endpoint that took a client-classified `mode: 'live' | 'reload'`. That put
runtime-capability responsibility on the UI and required a new backend endpoint.
It is **withdrawn**. The simplified design below uses the existing request and
load/unload paths only.

## Guiding principle (from review)

> The UI should not be the source of truth for runtime capability.

Preset changes fall into two kinds, and each already has a home in the existing
architecture — no new endpoint and no client `mode` flag are needed:

- **Request-time fields** (`system_prompt`, `sampling`/temperature, `tools`):
applied by **request composition** in the frontend on the next generation
request. No model runtime state changes; no reload.
- **Load-time fields** (`ctx_size`, `backend`, `device`, model args via
`recipe_options`): require a real reload. The client performs an
**unload + load** (exactly as `main` does today). A named `reloadModel`
helper may wrap this so a future in-place backend reload can drop in, but for
now it is literally unload->load.

## Active-preset binding (UI state)

The UI stores a per-model **active-preset binding** (`linkedPresetId`) in
client-local storage (invariant #11 — never in `lemond`). This already exists:

- `activePresetForModel(modelName)` resolves the linked preset.
- Request composition already reads it:
- sampling — `api.ts` spreads `samplingForModel(model)` into chat bodies.
- system prompt — `ChatView.tsx` pushes `systemPromptTextForPreset(currentPreset)`.

So for request-time changes, **rebinding the active preset is the whole
operation** — the next request automatically carries the new values. There is
nothing to POST.

## Live vs reload classification

`classifyPresetChange(running, next)` in `src/presetStore.ts` returns
`'none' | 'live' | 'reload'`:

| Field group | Fields | Kind | Why |
|------------------|-----------------------------------------------------------------------------------------|----------|-----|
| Reload-requiring | `recipe_options` (ctx_size, backend, device, args, steps, cfg, ...), `engine_hint` | `reload` | Bound at init; needs reinitialization. |
| Live-updateable | `sampling`, `system_prompt_id`, `system_prompts`, `tools_enabled` | `live` | Applied per request at generation time. |
| Everything else | name, description, applies_to, id, ... | `none` | No effect on a running model. |

First reload-requiring diff wins (-> `reload`); else any live diff -> `live`;
else `none`.

### Correctness fix (same-ID edits)

The current implementation short-circuits on identical ids:

```ts
if (running.id === next.id) return 'none'; // presetStore.ts:570 — BUG
```

This means **editing a preset in place** (same id, but changed `temperature`,
`system_prompt`, or `ctx_size`) classifies as `none`, so the model keeps running
stale values and no Apply/Reload affordance appears. **Remove that early
`return 'none'`** and let the `RELOAD_FIELDS` / `LIVE_FIELDS` comparisons run
regardless of whether the id matches. Only return `none` when no field in either
group actually differs.

## UI affordance

When the active preset differs from the running preset (now also true for
same-id edits after the fix), the detail panel shows a button next to **Unload**:

- **live-only changes** -> label **"Apply preset"**.
Action: rebind the active preset; request composition handles the rest. The
prior "POST live" call is removed.
- **load-time changes** -> label **"Reload to apply preset"**, and the loaded
model is marked **"needs reload"**.
Action: `reloadModel(modelName, recipeOptions)` = `api.unloadModel()` then
`api.loadModel(modelName, recipeOptions, modelInfo)`.

a11y: the button's accessible name conveys which path it takes ("Apply preset
for {name}" vs "Reload {name} to apply preset"); status uses the existing polite
live region; focus management preserved. New/changed controls get aria-labels
and Playwright coverage (next ids **A164+**). Existing A154–A163 must be updated
to the new (no-`mode`, no-endpoint) workflow so the suite stays green.

## `window.api` surface

No `updatePreset(...)` and no `mode` parameter. The POC uses only existing
methods:

```ts
api.loadModel(modelName, recipeOptions?, modelInfo?): Promise<unknown>;
api.unloadModel(modelName?): Promise<unknown>;
```

A thin client helper expresses the load-time path (future-proofing only — it is
unload->load today):

```ts
// src/api.ts (or a small wrapper)
async reloadModel(
modelName: string,
recipeOptions?: Record<string, unknown>,
modelInfo?: unknown,
): Promise<unknown> {
await this.unloadModel(modelName);
return this.loadModel(modelName, recipeOptions, modelInfo);
}
```

If a real in-place backend reload ever lands, only this helper's body changes;
callers and tests stay the same. The old `api.updatePreset(...)` method and its
`/api/v1/update-preset` POST are removed.

## Lemonade tools (`src/tools/lemonadeTools.ts`)

Add a **`change_preset`** tool that drives the same preset-update workflow
directly, so chat-/agent-triggered preset changes behave identically to the UI
button:

- Inputs: `model_name` (optional -> most-recently-used loaded model),
`preset` / `preset_id` / `preset_name` (reuse the existing
`presetSpecifier` / `resolvePreset` helpers already used by `load_model`).
- Steps:
1. Resolve model + preset; reject incompatible presets via `isCompatible`
(same error shape as `load_model`).
2. Rebind the active preset (`applyPresetBinding`).
3. `classifyPresetChange(running, next)`:
- `live` -> no further action; report "applied live".
- `reload` -> `reloadModel(...)` (unload->load); report "reloaded".
- `none` -> report no-op.
- Return the usual `toolPayload` summary with an `answer_instruction`.

This replaces the deferred "tool calls the update-preset endpoint" plan: the
tool now executes the workflow with the same primitives as the UI.

## What is NOT in this POC

- No `lemond` changes, no new HTTP endpoint, no server-side preset registry.
- No client `mode` flag is sent anywhere.
- Presets remain 100% client-local (invariant #11).

## Open questions for @fl0rianr

1. Should the active-preset binding **persist across an unload/reload** (the UI
re-binds before reloading, so the reloaded model runs the new preset), and is
that the behaviour you want for the tool path too?
2. Request-time **tool** changes: confirm tools belong on the request-composition
side for every backend (we compose them with sampling + system prompt), with
no reload — i.e. nothing tool-related is load-time.
3. If a future backend gains real in-place reload, do you want it surfaced as a
distinct capability flag, or is the silent `reloadModel` swap (unload->load ->
in-place) acceptable since the contract is identical to callers?
27 changes: 27 additions & 0 deletions prototype/ui-redesign/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -962,6 +962,33 @@ class LemonadeAPI {
return result;
}

/**
* Apply a *load-time* preset change to an already-loaded model (#2356).
*
* Simplified design (per @fl0rianr review + Lovell): there is NO dedicated
* update-preset endpoint and NO client-provided `mode` parameter — the UI is
* not the source of truth for runtime capability. Load-time fields (ctx_size,
* backend, device, model args via recipe_options) require a real reload, which
* today is literally an unload followed by a load, exactly as `main` does.
*
* This helper is named `reloadModel` (rather than inlining unload→load at every
* call site) so that if a real in-place backend reload ever lands, only this
* method's body changes; callers and tests stay identical.
*
* Request-time fields (system_prompt, sampling/temperature, tools) are NOT
* handled here — rebinding the active preset is the whole "live" operation and
* request composition (`samplingForModel`, `systemPromptTextForPreset`) carries
* the new values on the next generation request; nothing is POSTed.
*/
async reloadModel(
modelName: string,
recipeOptions?: Record<string, unknown>,
modelInfo?: ModelInfo | null,
): Promise<unknown> {
await this.unloadModel(modelName);
return this.loadModel(modelName, recipeOptions, modelInfo);
}

async deleteModel(modelName: string): Promise<unknown> {
const result = await this._json('/api/v1/delete', {
method: 'POST',
Expand Down
Loading
Loading