diff --git a/docs/agents/AGENTS.plugins.md b/docs/agents/AGENTS.plugins.md index dcdd29829..8aa069b79 100644 --- a/docs/agents/AGENTS.plugins.md +++ b/docs/agents/AGENTS.plugins.md @@ -228,9 +228,9 @@ embedding: - Global and scoped activation are independent, with no inheritance between scopes. - Activation flags are files: `.toggle-1` (ON) and `.toggle-0` (OFF). -- UI states are `ON`, `OFF`, and `Advanced` (shown when any project/profile-specific override exists). +- The plugin list shows a binary `ON`/`OFF` global activation switch. - `always_enabled: true` in `plugin.yaml` forces ON and disables toggle controls in the UI. -- The "Switch" modal is the canonical per-scope activation surface, and "Configure Plugin" keeps scope synchronized with the settings modal. +- For plugins with project/profile scoping, the plugin config modal is the canonical per-scope activation surface. --- diff --git a/helpers/plugins.py b/helpers/plugins.py index 45bac7b05..e7fd776e9 100644 --- a/helpers/plugins.py +++ b/helpers/plugins.py @@ -44,7 +44,7 @@ _META_TARGET_RE = re.compile( ) -type ToggleState = Literal["enabled", "disabled", "advanced"] +type ToggleState = Literal["enabled", "disabled"] class PluginAssetFile(TypedDict): @@ -517,30 +517,15 @@ def get_toggle_state(plugin_name: str) -> ToggleState: if meta.always_enabled: return "enabled" - # root plugin paths + # List-level activation is the global/root state. Scoped project/profile + # overrides are managed inside the plugin config modal. plugin_paths = get_plugin_roots(plugin_name) - state = ( + return ( "enabled" if determined_toggle_from_paths(True, reversed(plugin_paths)) else "disabled" ) - # additional toggles in project/agent directories, return advanced - if meta.per_agent_config or meta.per_project_config: - configs = find_plugin_assets( - TOGGLE_FILE_PATTERN, - plugin_name=plugin_name, - project_name="*" if meta.per_project_config else "", - agent_profile="*" if meta.per_agent_config else "", - only_first=False, - ) - - # Advanced if there are specific overrides (project or agent specific) - if any(c.get("project_name") or c.get("agent_profile") for c in configs): - state = "advanced" - - return state - @extension.extensible def toggle_plugin( diff --git a/tests/test_plugin_activation_ui.py b/tests/test_plugin_activation_ui.py new file mode 100644 index 000000000..e839aa30c --- /dev/null +++ b/tests/test_plugin_activation_ui.py @@ -0,0 +1,108 @@ +import sys +from pathlib import Path +from types import SimpleNamespace + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from helpers import files, plugins + + +def test_plugins_list_uses_binary_toggle_instead_of_advanced_dropdown(): + html = (PROJECT_ROOT / "webui/components/plugins/list/plugin-list.html").read_text( + encoding="utf-8" + ) + store = ( + PROJECT_ROOT / "webui/components/plugins/list/pluginListStore.js" + ).read_text(encoding="utf-8") + + assert "plugin-status-toggle" in html + assert "plugin-status-select" not in html + assert "Open Advanced" not in html + assert 'value="advanced"' not in html + assert "openPluginAdvancedToggle" not in store + assert "plugin-toggle-advanced.html" not in store + + +def test_advanced_plugin_toggle_modal_was_removed(): + assert not ( + PROJECT_ROOT / "webui/components/plugins/toggle/plugin-toggle-advanced.html" + ).exists() + + +def test_list_toggle_state_is_global_even_when_scoped_rules_exist(monkeypatch): + monkeypatch.setattr( + plugins, + "get_plugin_meta", + lambda _plugin_name: SimpleNamespace(always_enabled=False), + ) + monkeypatch.setattr( + plugins, + "get_plugin_roots", + lambda _plugin_name: ["plugins/example", "usr/plugins/example"], + ) + monkeypatch.setattr( + plugins, + "determined_toggle_from_paths", + lambda _default, _paths: False, + ) + + def fail_on_scoped_lookup(*_args, **_kwargs): + raise AssertionError("Plugin list toggle state should not inspect scoped rules") + + monkeypatch.setattr(plugins, "find_plugin_assets", fail_on_scoped_lookup) + + assert plugins.get_toggle_state("example") == "disabled" + + +def test_config_scope_activation_toggle_saves_immediately_for_selected_scope(): + html = (PROJECT_ROOT / "webui/components/plugins/plugin-settings.html").read_text( + encoding="utf-8" + ) + settings_store = ( + PROJECT_ROOT / "webui/components/plugins/plugin-settings-store.js" + ).read_text(encoding="utf-8") + toggle_store = ( + PROJECT_ROOT / "webui/components/plugins/toggle/plugin-toggle-store.js" + ).read_text(encoding="utf-8") + + assert "@change=\"context.setPluginEnabled($event.target.checked)\"" in html + assert "projectName: this.projectName || \"\"" in settings_store + assert "agentProfileKey: this.agentProfileKey || \"\"" in settings_store + assert ( + "async setEnabled(enabled, { projectName = this.projectName, " + "agentProfileKey = this.agentProfileKey } = {})" + ) in toggle_store + assert "action: \"toggle_plugin\"" in toggle_store + + +def test_toggle_plugin_writes_project_scope_file_immediately(tmp_path, monkeypatch): + monkeypatch.setattr(files, "_base_dir", str(tmp_path)) + monkeypatch.setattr(plugins, "after_plugin_change", lambda *_args, **_kwargs: None) + monkeypatch.setitem( + sys.modules, + "helpers.projects", + SimpleNamespace( + get_project_meta=lambda name, *sub_dirs: files.get_abs_path( + "usr/projects", + name, + ".a0proj", + *sub_dirs, + ) + ), + ) + + plugins.toggle_plugin.__wrapped__("example", False, project_name="alpha") + + scoped_plugin_dir = ( + tmp_path / "usr/projects/alpha/.a0proj/plugins/example" + ) + assert (scoped_plugin_dir / ".toggle-0").exists() + assert not (scoped_plugin_dir / ".toggle-1").exists() + + plugins.toggle_plugin.__wrapped__("example", True, project_name="alpha") + + assert (scoped_plugin_dir / ".toggle-1").exists() + assert not (scoped_plugin_dir / ".toggle-0").exists() diff --git a/webui/components/plugins/list/plugin-list.html b/webui/components/plugins/list/plugin-list.html index afb8cf8df..67797ae95 100644 --- a/webui/components/plugins/list/plugin-list.html +++ b/webui/components/plugins/list/plugin-list.html @@ -180,24 +180,17 @@ @@ -389,35 +382,40 @@ flex: 0 0 auto; } - .plugin-status-group .button { - padding: 0.3rem 0.5rem; - height: 2.2rem; - display: flex; - align-items: center; - justify-content: center; + .plugin-status-toggle { + width: 48px; + height: 28px; + flex: 0 0 48px; } - .plugin-status-group .button .icon { - font-size: 1.1rem; + .plugin-status-toggle .toggler { + border-radius: 999px; } - .plugin-status-select { - padding: 0.3rem 2rem 0.3rem 0.6rem; - font-size: 0.9rem; - border: 1px solid var(--color-border); - border-radius: 4px; - background: var(--color-background); - color: var(--color-text-primary); - cursor: pointer; - height: 2.2rem; - width: 8rem; - flex: 0 0 8rem; + .plugin-status-toggle .toggler:before { + width: 20px; + height: 20px; + left: 4px; + bottom: 4px; } - - .plugin-status-select:disabled { - opacity: 0.7; + + .plugin-status-toggle input:checked + .toggler:before { + transform: translateX(20px); + } + + .plugin-status-toggle input:disabled + .toggler { cursor: default; - background: var(--color-bg-secondary); + } + + .plugin-status-toggle.disabled-appearance { + opacity: 0.6; + } + + .plugin-status-text { + color: var(--color-text-primary); + font-size: 0.9rem; + font-weight: 600; + min-width: 2rem; } .plugin-description { diff --git a/webui/components/plugins/list/pluginListStore.js b/webui/components/plugins/list/pluginListStore.js index 5d747af7d..89d3751b0 100644 --- a/webui/components/plugins/list/pluginListStore.js +++ b/webui/components/plugins/list/pluginListStore.js @@ -2,7 +2,6 @@ import { createStore } from "/js/AlpineStore.js"; import * as api from "/js/api.js"; import { renderSafeMarkdown } from "/js/safe-markdown.js"; import { store as pluginSettingsStore } from "/components/plugins/plugin-settings-store.js"; -import { store as pluginToggleStore } from "/components/plugins/toggle/plugin-toggle-store.js"; import { store as pluginExecuteStore } from "/components/plugins/list/plugin-execute-store.js"; import { store as fileBrowserStore } from "/components/modals/file-browser/file-browser-store.js"; import { store as markdownModalStore } from "/components/modals/markdown/markdown-store.js"; @@ -114,47 +113,37 @@ const model = { } }, - async openPluginAdvancedToggle(plugin) { - if (!plugin?.name) return; - this.selectedPlugin = plugin; - try { - if (!pluginToggleStore?.open) { - throw new Error("Plugin toggle store is unavailable."); - } - await pluginToggleStore.open(plugin); - window.openModal?.("components/plugins/toggle/plugin-toggle-advanced.html"); - } catch (e) { - showErrorNotification(e, "Failed to open plugin switch"); - } + isPluginEnabled(plugin) { + if (plugin?.always_enabled) return true; + return plugin?.toggle_state === "enabled"; }, - async updateToggle(plugin, value) { - if (!plugin?.name) return; - - if (value === 'advanced') { - await this.openPluginAdvancedToggle(plugin); - return; - } + toggleStatusLabel(plugin) { + return this.isPluginEnabled(plugin) ? "ON" : "OFF"; + }, - const enabled = value === 'enabled'; - const clearOverrides = plugin.toggle_state === 'advanced'; - if (clearOverrides && !window.confirm( - `"${plugin.display_name || plugin.name}" has per-scope activation rules that will be removed. Set globally to ${enabled ? 'ON' : 'OFF'}?` - )) return; + async updateToggle(plugin, enabled) { + if (!plugin?.name) return; + if (plugin.always_enabled) return; + + const nextEnabled = !!enabled; + const previousState = plugin.toggle_state; + plugin.toggle_state = nextEnabled ? "enabled" : "disabled"; this.loading = true; try { const response = await api.callJsonApi("plugins", { action: "toggle_plugin", plugin_name: plugin.name, - enabled: enabled, + enabled: nextEnabled, project_name: "", agent_profile: "", - clear_overrides: clearOverrides, + clear_overrides: false, }); if (response?.error) throw new Error(response.error); await this.refresh(); } catch (e) { + plugin.toggle_state = previousState; showErrorNotification(e, "Failed to toggle plugin"); this.loading = false; } diff --git a/webui/components/plugins/plugin-settings-store.js b/webui/components/plugins/plugin-settings-store.js index e5e5dd59d..bfe76a102 100644 --- a/webui/components/plugins/plugin-settings-store.js +++ b/webui/components/plugins/plugin-settings-store.js @@ -114,6 +114,19 @@ const model = { await pluginToggleStore.loadToggleStatus(); }, + async setPluginEnabled(enabled) { + if (!pluginToggleStore?.setEnabled) return; + this.error = null; + try { + await pluginToggleStore.setEnabled(enabled, { + projectName: this.projectName || "", + agentProfileKey: this.agentProfileKey || "", + }); + } catch (e) { + this.error = e?.message || "Failed to save activation state"; + } + }, + async onScopeChanged() { const nextProject = this.projectName || ""; const nextProfile = this.agentProfileKey || ""; diff --git a/webui/components/plugins/plugin-settings.html b/webui/components/plugins/plugin-settings.html index fcf7f3d07..eab2e4043 100644 --- a/webui/components/plugins/plugin-settings.html +++ b/webui/components/plugins/plugin-settings.html @@ -49,8 +49,8 @@ diff --git a/webui/components/plugins/toggle/plugin-toggle-advanced.html b/webui/components/plugins/toggle/plugin-toggle-advanced.html deleted file mode 100644 index 4440fbad7..000000000 --- a/webui/components/plugins/toggle/plugin-toggle-advanced.html +++ /dev/null @@ -1,261 +0,0 @@ - - - Plugin Switch - - - -
- -
- - - - - - - diff --git a/webui/components/plugins/toggle/plugin-toggle-store.js b/webui/components/plugins/toggle/plugin-toggle-store.js index f1c99e19f..38bf94eb6 100644 --- a/webui/components/plugins/toggle/plugin-toggle-store.js +++ b/webui/components/plugins/toggle/plugin-toggle-store.js @@ -169,24 +169,6 @@ const model = { } }, - async openConfigWithScope() { - if (!this.pluginName) return; - this.error = null; - try { - await settingsStore.openConfig( - this.pluginName, - this.projectName || "", - this.agentProfileKey || "" - ); - } catch (e) { - this.error = e?.message || "Failed to open plugin config"; - } - }, - - async openConfigListModal() { - await window.openModal?.("/components/plugins/toggle/plugin-toggles.html"); - }, - async switchToConfig(projectName, agentProfile) { this.projectName = projectName || ""; this.agentProfileKey = agentProfile || ""; @@ -217,8 +199,14 @@ const model = { } }, - async setEnabled(enabled) { + async setEnabled(enabled, { projectName = this.projectName, agentProfileKey = this.agentProfileKey } = {}) { if (!this.pluginName || this.alwaysEnabled) return; + const previousStatus = this.status; + const previousProjectName = this.projectName; + const previousAgentProfileKey = this.agentProfileKey; + this.projectName = projectName || ""; + this.agentProfileKey = agentProfileKey || ""; + this.status = enabled ? 'enabled' : 'disabled'; this.isSaving = true; try { const response = await fetchApi("/plugins", { @@ -237,7 +225,11 @@ const model = { await new Promise(r => setTimeout(r, 100)); await this.loadConfigs(); } catch (e) { + this.status = previousStatus; + this.projectName = previousProjectName; + this.agentProfileKey = previousAgentProfileKey; this.error = e.message || "Failed to save"; + throw e; } finally { this.isSaving = false; }