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