Keep model presets bound after deletion
Give each preset editor row a stable UI-only key so Alpine does not reuse nested model state after a middle row is removed. Add a regression guard and document the row identity invariant in the model-config plugin contract.
Alessandro committed
Jul 10, 2026 at 14:18 UTC
529dc8c465521e66405b3875489a91d9ce9b7731
3 files changed
+16
-2
plugins/_model_config/AGENTS.md
+1
@@ -20,6 +20,7 @@
20
- `model_config_get` exposes `model_configured` as a derived chat-model readiness flag from provider, model name, and API-key availability.
21
- Applying a model preset may inherit durable tuning such as context windows and rate limits, but must replace or clear per-slot `kwargs` so provider-specific extra params never leak across model providers.
22
- Repair provider-specific model-config aliases at the model-config read/build boundary; keep provider-specific repairs out of provider-agnostic core wrappers such as `models.py`.
23
+- Preset editor rows use stable UI-only keys; never key them by array position or editable preset name because deleting or renaming a row must not rebind nested model fields to another preset.
24
25
## Work Guidance
26
plugins/_model_config/webui/main.html
+6
-2
@@ -15,13 +15,16 @@
15
await $store.modelConfig.refreshApiKeyStatus();
16
">
17
<template x-if="$store.modelConfig._loaded && $store.modelConfig._presetsLoaded">
18
- <div class="presets-page" x-data="{ presets: JSON.parse(JSON.stringify($store.modelConfig.globalPresets)) }">
18
+ <div class="presets-page" x-data="{
19
+ presets: JSON.parse(JSON.stringify($store.modelConfig.globalPresets)).map((preset, idx) => ({ ...preset, _key: idx })),
20
+ nextPresetKey: $store.modelConfig.globalPresets.length
21
+ }">
22
<div class="presets-header">
23
<div class="field-title" style="font-size:1rem;">Model Presets</div>
24
<div class="field-description">Global presets shared across all projects and agents. Used by the model switcher in the chat area.</div>
25
</div>
26
24
- <template x-for="(preset, idx) in presets" :key="idx">
27
+ <template x-for="(preset, idx) in presets" :key="preset._key">
28
<div class="preset-card" x-data="{ expanded: false }">
29
<div class="preset-card-header" @click="expanded = !expanded">
30
<span class="material-symbols-outlined preset-expand-icon"
@@ -64,6 +67,7 @@
67
<button class="text-button preset-add-btn"
68
@click="
69
presets = [...presets, {
70
+ _key: nextPresetKey++,
71
name: 'Preset ' + (presets.length + 1),
72
chat: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: '' },
73
utility: { provider: '', name: '', api_key: '', api_base: '', kwargs: {}, _kwargs_text: '' }
tests/test_model_config_ui.py
+9
@@ -20,3 +20,12 @@ def test_model_config_text_buttons_have_shared_primitive() -> None:
20
assert 'class="text-button preset-add-btn"' in preset_modal
21
assert 'class="text-button preset-delete-btn"' in preset_modal
22
assert 'class="text-button"' in config_modal
23
+
24
+
25
+def test_model_preset_rows_keep_stable_identity_after_middle_delete() -> None:
26
+ preset_modal = read("plugins", "_model_config", "webui", "main.html")
27
+
28
+ assert ':key="preset._key"' in preset_modal
29
+ assert "_key: idx" in preset_modal
30
+ assert "_key: nextPresetKey++" in preset_modal
31
+ assert ':key="idx"' not in preset_modal