simplify plugin config modal opening

Move plugin config modal initialization into pluginSettingsStore.openConfig(), so callers only pass plugin name and optional scope. Resolve invalid project/agent scopes inside the store, initialize toggle state on the resolved scope, remove the old saveMode/core-save path, and delete stale saveMode guidance from docs and plugin skills.

Alessandro committed Mar 19, 2026 at 13:53 UTC 5ed58fb10fdeb7ca8a2c3b45afc96d9d8e0d3c70
9 files changed +118 -99
AGENTS.md
+1 -1
@@ -140,7 +140,7 @@ Key Files:
140 - Runtime hooks: Plugins may also expose hooks in hooks.py, callable by the framework through helpers.plugins.call_plugin_hook(...).
141 - Hook runtime: hooks.py executes inside the Agent Zero framework Python environment, so sys.executable -m pip installs dependencies into that same framework runtime.
142 - Environment targeting: If a plugin needs packages or binaries for the separate agent execution runtime or system environment, it must explicitly switch environments in a subprocess by targeting the correct interpreter, virtualenv, or package manager.
143 -- Settings: Use get_plugin_config(plugin_name, agent=agent) to retrieve settings. Plugins can expose a UI for settings via webui/config.html. Plugin settings modals instantiate a local context from $store.pluginSettingsPrototype; bind plugin fields to config.* and use context.* for modal-level state and actions. For plugins wrapping core settings, set context.saveMode = 'core' in x-init.
143 +- Settings: Use get_plugin_config(plugin_name, agent=agent) to retrieve settings. Plugins can expose a UI for settings via webui/config.html. Plugin settings modals instantiate a local context from $store.pluginSettingsPrototype; bind plugin fields to config.* and use context.* for modal-level state and actions.
144 - Activation: Global and scoped activation rules are stored as .toggle-1 (ON) and .toggle-0 (OFF). Scoped rules are handled via the plugin "Switch" modal.
145 - Cleanup rule: Plugins should not permanently modify the system in ways that outlive the plugin. Deleting a plugin should not leave behind symlinks, unmanaged services, or stray files outside plugin-owned paths unless the user explicitly requested that behavior.
146
plugins/_plugin_installer/webui/pluginInstallStore.js
+7 -1
@@ -7,6 +7,7 @@ import { showConfirmDialog } from "/js/confirmDialog.js";
7 import { store as imageViewerStore } from "/components/modals/image-viewer/image-viewer-store.js";
8 import { store as pluginListStore } from "/components/plugins/list/pluginListStore.js";
9 import { store as pluginExecuteStore } from "/components/plugins/list/plugin-execute-store.js";
10 +import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
11
12 const PLUGIN_API = "plugins/_plugin_installer/plugin_install";
13 const PER_PAGE = 20;
@@ -590,7 +591,12 @@ const model = {
591
592 async handleOpenConfig() {
593 if (this.installedPluginInfo) {
593 - await pluginListStore.openPluginConfig(this.installedPluginInfo);
594 + try {
595 + await pluginSettingsStore.openConfig(this.installedPluginInfo.name);
596 + } catch (e) {
597 + const message = e instanceof Error ? e.message : String(e);
598 + void toastFrontendError(message, "Plugin Installer");
599 + }
600 }
601 },
602
skills/a0-create-plugin/SKILL.md
-13
@@ -132,19 +132,6 @@ The modal provides Project + Agent profile context selectors. The plugin setting
132
133 The modal's Save button persists `config` to `config.json` in the correct scope (project/agent/global).
134
135 -### Surfacing core settings (e.g. memory pattern)
136 -
137 -If your plugin exposes existing core settings rather than plugin-specific ones, set `saveMode = 'core'` so Save delegates to the core settings API:
138 -
139 -```html
140 -<div x-data x-init="
141 - context.saveMode = 'core';
142 - if ($store.settings && !$store.settings.settings) $store.settings.onOpen();
143 -">
144 - <x-component path="settings/agent/memory.html"></x-component>
145 -</div>
146 -```
147 -
135 ### Sidebar Button (sidebar entry point)
136 - Extension point: `sidebar-quick-actions-main-start`
137 - Class: `class="config-button"`
skills/a0-review-plugin/checklists.md
-7
@@ -188,13 +188,6 @@ save_plugin_config(
188 </html>
189 ```
190
191 -Use `saveMode = 'core'` if exposing core settings instead of plugin-specific ones:
192 -```html
193 -<div x-data x-init="context.saveMode = 'core'">
194 - <!-- core settings component -->
195 -</div>
196 -```
197 -
191 ---
192
193 ## Sidebar Button (extension point)
webui/components/plugins/list/pluginListStore.js
+2 -13
@@ -79,21 +79,10 @@ const model = {
79 async openPluginConfig(plugin) {
80 if (!plugin?.name || !plugin?.has_config_screen) return;
81 try {
82 - // Initialize toggle store for activation state UI in settings modal
83 - if (pluginToggleStore?.open) await pluginToggleStore.open(plugin);
84 -
85 - if (!pluginSettingsStore?.open) {
82 + if (!pluginSettingsStore?.openConfig) {
83 throw new Error("Plugin settings store is unavailable.");
84 }
88 - await pluginSettingsStore.open(plugin.name, {
89 - perProjectConfig: !!plugin.per_project_config,
90 - perAgentConfig: !!plugin.per_agent_config,
91 - });
92 - // Set saveMode after open() (open resets it to 'plugin')
93 - if (plugin.settings_sections?.includes('core')) {
94 - pluginSettingsStore.saveMode = 'core';
95 - }
96 - window.openModal?.("components/plugins/plugin-settings.html");
85 + await pluginSettingsStore.openConfig(plugin.name);
86 } catch (e) {
87 showErrorNotification(e, "Failed to open plugin config");
88 }
webui/components/plugins/plugin-settings-store.js
+86 -46
@@ -1,6 +1,6 @@
1 import { createStore } from "/js/AlpineStore.js";
2 +import * as api from "/js/api.js";
3 import { showConfirmDialog } from "/js/confirmDialog.js";
3 -import { store as settingsStore } from "/components/settings/settings-store.js";
4 import { store as pluginToggleStore } from "/components/plugins/toggle/plugin-toggle-store.js";
5
6 const fetchApi = globalThis.fetchApi;
@@ -41,6 +41,64 @@ const model = {
41 return window.confirm("You have unsaved changes that will be lost. Continue?");
42 },
43
44 + async _loadPluginMeta(pluginName) {
45 + const response = await api.callJsonApi("plugins_list", {
46 + filter: { custom: true, builtin: true, search: "" },
47 + });
48 + const plugins = Array.isArray(response?.plugins) ? response.plugins : [];
49 + return plugins.find((plugin) => plugin?.name === pluginName) || null;
50 + },
51 +
52 + _hasProject(projectName) {
53 + if (!projectName) return false;
54 + return (this.projects || []).some((project) => project?.key === projectName);
55 + },
56 +
57 + _hasAgentProfile(agentProfileKey) {
58 + if (!agentProfileKey) return false;
59 + return (this.agentProfiles || []).some((profile) => profile?.key === agentProfileKey);
60 + },
61 +
62 + _resolveScope(pluginMeta, projectName = "", agentProfileKey = "") {
63 + const resolvedProjectName =
64 + pluginMeta?.per_project_config && this._hasProject(projectName)
65 + ? projectName
66 + : "";
67 + const resolvedAgentProfileKey =
68 + pluginMeta?.per_agent_config && this._hasAgentProfile(agentProfileKey)
69 + ? agentProfileKey
70 + : "";
71 +
72 + return {
73 + projectName: resolvedProjectName,
74 + agentProfileKey: resolvedAgentProfileKey,
75 + };
76 + },
77 +
78 + _applyPluginState(pluginMeta, { projectName = "", agentProfileKey = "" } = {}) {
79 + this.pluginName = pluginMeta?.name || null;
80 + this.pluginMeta = pluginMeta || null;
81 + this.settings = {};
82 + this.settingsSnapshotJson = "";
83 + this.error = null;
84 + this.projectName = projectName;
85 + this.agentProfileKey = agentProfileKey;
86 + this.previousProjectName = projectName;
87 + this.previousAgentProfileKey = agentProfileKey;
88 + this.loadedPath = "";
89 + this.loadedProjectName = "";
90 + this.loadedAgentProfile = "";
91 + this.perProjectConfig = !!pluginMeta?.per_project_config;
92 + this.perAgentConfig = !!pluginMeta?.per_agent_config;
93 + },
94 +
95 + async _syncToggleScope(projectName = "", agentProfileKey = "") {
96 + if (!pluginToggleStore?.loadToggleStatus) return;
97 + pluginToggleStore.projectName = projectName;
98 + pluginToggleStore.agentProfileKey = agentProfileKey;
99 + await pluginToggleStore.loadToggleStatus();
100 + },
101 +
102 async onScopeChanged() {
103 const nextProject = this.projectName || "";
104 const nextProfile = this.agentProfileKey || "";
@@ -56,13 +114,7 @@ const model = {
114 }
115
116 await this.loadSettings();
59 -
60 - // Mirror scope change to pluginToggle so activation state stays in sync
61 - if (pluginToggleStore?.loadToggleStatus) {
62 - pluginToggleStore.projectName = nextProject;
63 - pluginToggleStore.agentProfileKey = nextProfile;
64 - await pluginToggleStore.loadToggleStatus();
65 - }
117 + await this._syncToggleScope(nextProject, nextProfile);
118 },
119
120 // where the settings were actually loaded from
@@ -131,6 +183,7 @@ const model = {
183 this.projectName = projectName || "";
184 this.agentProfileKey = agentProfile || "";
185 await this.loadSettings();
186 + await this._syncToggleScope(this.projectName, this.agentProfileKey);
187 await window.closeModal?.();
188 },
189
@@ -168,10 +221,6 @@ const model = {
221 }
222 },
223
171 - // 'plugin' = save to plugin settings API
172 - // 'core' = save via $store.settings.saveSettings() (for plugins that surface core settings)
173 - saveMode: 'plugin',
174 -
224 perProjectConfig: true,
225 perAgentConfig: true,
226
@@ -179,32 +228,30 @@ const model = {
228 isSaving: false,
229 error: null,
230
182 - // Called by the subsection button before openModal()
183 - // Optional scope: { projectName, agentProfileKey } — skips redundant global loadSettings()
184 - // when the caller already knows which scope to open at.
185 - async open(pluginName, { projectName = "", agentProfileKey = "", perProjectConfig = true, perAgentConfig = true } = {}) {
186 - this.pluginName = pluginName;
187 - this.pluginMeta = null;
188 - this.settings = {};
189 - this.settingsSnapshotJson = "";
190 - this.error = null;
191 - this.saveMode = 'plugin';
192 - this.perProjectConfig = perProjectConfig;
193 - this.perAgentConfig = perAgentConfig;
194 - this.projectName = projectName;
195 - this.agentProfileKey = agentProfileKey;
196 - this.previousProjectName = projectName;
197 - this.previousAgentProfileKey = agentProfileKey;
198 - this.loadedPath = "";
199 - this.loadedProjectName = "";
200 - this.loadedAgentProfile = "";
231 + async openConfig(pluginName, projectName = "", agentProfile = "") {
232 + if (!pluginName) {
233 + throw new Error("Missing plugin name.");
234 + }
235 +
236 + this.cleanup();
237 + const pluginMeta = await this._loadPluginMeta(pluginName);
238 + if (!pluginMeta) {
239 + throw new Error(`Plugin "${pluginName}" not found.`);
240 + }
241 + if (!pluginMeta.has_config_screen) {
242 + throw new Error(`Plugin "${pluginName}" has no config screen.`);
243 + }
244 +
245 await Promise.all([this.loadProjects(), this.loadAgentProfiles()]);
246 + const resolvedScope = this._resolveScope(pluginMeta, projectName || "", agentProfile || "");
247 + this._applyPluginState(pluginMeta, resolvedScope);
248 await this.loadSettings();
203 - },
249
205 - // Called by x-create inside the modal on every open
206 - async onModalOpen() {
207 - if (this.pluginName) await this.loadSettings();
250 + if (!pluginToggleStore?.open) {
251 + throw new Error("Plugin toggle store is unavailable.");
252 + }
253 + await pluginToggleStore.open(pluginMeta, resolvedScope);
254 + await window.openModal?.("/components/plugins/plugin-settings.html");
255 },
256
257 async loadAgentProfiles() {
@@ -290,17 +337,6 @@ const model = {
337
338 async save() {
339 if (!this.pluginName) return;
293 -
294 - // Core-backed plugins (e.g. memory) delegate to the settings store
295 - if (this.saveMode === 'core') {
296 - if (settingsStore?.saveSettings) {
297 - const ok = await settingsStore.saveSettings();
298 - if (ok) window.closeModal?.();
299 - }
300 - return;
301 - }
302 -
303 - // Plugin-specific settings: persist to plugin settings API
340 this.isSaving = true;
341 this.error = null;
342 try {
@@ -331,6 +367,10 @@ const model = {
367 cleanup() {
368 this.pluginName = null;
369 this.pluginMeta = null;
370 + this.projects = [];
371 + this.agentProfiles = [];
372 + this.projectName = "";
373 + this.agentProfileKey = "";
374 this.settings = {};
375 this.settingsSnapshotJson = "";
376 this.previousProjectName = "";
webui/components/plugins/plugin-settings.html
+1 -1
@@ -17,7 +17,7 @@
17 })()">
18 <template x-if="context">
19 <div>
20 - <div x-create="context.onModalOpen(); (() => { const modal = $el.closest('.modal'); if (modal) modal.__pluginSettingsContext = context; })()"
20 + <div x-create="(() => { const modal = $el.closest('.modal'); if (modal) modal.__pluginSettingsContext = context; })()"
21 x-destroy="context.cleanup()">
22
23 <!-- Context toolbar: Project + Agent profile (only when at least one scope is configurable) -->
webui/components/plugins/toggle/plugin-toggle-store.js
+12 -15
@@ -32,13 +32,13 @@ const model = {
32
33 configs: [],
34
35 - async open(plugin) {
35 + async open(plugin, { projectName = "", agentProfileKey = "" } = {}) {
36 this.isLoading = true;
37 this.error = null;
38 this.projects = [];
39 this.agentProfiles = [];
40 - this.projectName = "";
41 - this.agentProfileKey = "";
40 + this.projectName = projectName || "";
41 + this.agentProfileKey = agentProfileKey || "";
42 this.configs = [];
43 this.status = 'enabled';
44 this.hasExplicitRuleForScope = false;
@@ -172,19 +172,16 @@ const model = {
172
173 async openConfigWithScope() {
174 if (!this.pluginName) return;
175 -
176 - if (settingsStore.pluginName !== this.pluginName) {
177 - // Different plugin — full init with current scope
178 - await settingsStore.open(this.pluginName, {
179 - projectName: this.projectName || "",
180 - agentProfileKey: this.agentProfileKey || "",
181 - });
182 - } else {
183 - // Same plugin — push current scope explicitly.
184 - settingsStore.projectName = this.projectName || "";
185 - settingsStore.agentProfileKey = this.agentProfileKey || "";
175 + this.error = null;
176 + try {
177 + await settingsStore.openConfig(
178 + this.pluginName,
179 + this.projectName || "",
180 + this.agentProfileKey || ""
181 + );
182 + } catch (e) {
183 + this.error = e?.message || "Failed to open plugin config";
184 }
187 - await window.openModal?.("/components/plugins/plugin-settings.html");
185 },
186
187 async openConfigListModal() {
webui/components/settings/plugins/plugins-subsection-store.js
+9 -2
@@ -1,6 +1,7 @@
1 import { createStore } from "/js/AlpineStore.js";
2 import * as api from "/js/api.js";
3 -import { store as pluginListStore } from "/components/plugins/list/pluginListStore.js";
3 +import { toastFrontendError } from "/components/notifications/notification-store.js";
4 +import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js";
5
6 const model = {
7 tab: "",
@@ -51,7 +52,13 @@ const model = {
52 },
53
54 async openPluginConfig(plugin) {
54 - await pluginListStore.openPluginConfig(plugin);
55 + if (!plugin?.name) return;
56 + try {
57 + await pluginSettingsStore.openConfig(plugin.name);
58 + } catch (e) {
59 + const message = e instanceof Error ? e.message : String(e);
60 + void toastFrontendError(message, "Plugin Settings");
61 + }
62 },
63 };
64