diff --git a/CHANGELOG.md b/CHANGELOG.md index 727af953..d7c701f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ ## [Unreleased] +## [v0.51.641] — 2026-06-25 — Release WV (disabled plugins read as disabled in dark mode, active ones sort first) + +### Fixed + +- **In dark mode, disabled plugins no longer show a green "enabled-looking" badge, and active plugins sort to the top of the Plugins list.** The disabled-badge style lost a CSS specificity contest to the dark provider-badge rule, so a disabled plugin's badge rendered the same green as an enabled one — indistinguishable at a glance. A dark-mode-specific rule now renders the disabled badge in the muted surface color. The Plugins list also sorts active/enabled plugins above inactive ones (a stable partition that never reorders within a bucket), with the sort bucket always matching the badge actually shown. Thanks @rodboev. (#4848, closes #4713) + ## [v0.51.640] — 2026-06-25 — Release WU (faster profile switching — Claude Code transcript parses are cached) ### Fixed diff --git a/static/panels.js b/static/panels.js index 59626b86..617dcb3f 100644 --- a/static/panels.js +++ b/static/panels.js @@ -8253,6 +8253,32 @@ async function handlePluginEnableToggle(pluginKey, checked){ } } +function _pluginActivationState(plugin){ + const activation=(plugin&&typeof plugin.activation==='string') + ? plugin.activation + : (plugin&&plugin.enabled===false ? 'disabled' : 'enabled'); + // Mirror _buildPluginCard's isProviderActive precedence: an explicit + // is_active_provider===true overrides the activation string so the sort + // bucket always matches the badge. + if(plugin&&plugin.is_active_provider===true) return 'provider'; + if(activation==='exclusive'||activation==='provider'){ + if(plugin&&plugin.is_active_provider===false) return 'disabled'; + return 'provider'; + } + if(activation==='enabled') return 'enabled'; + return 'disabled'; +} + +function _partitionPluginsActiveFirst(plugins){ + const active=[]; + const inactive=[]; + for(const p of plugins){ + if(_pluginActivationState(p)==='disabled') inactive.push(p); + else active.push(p); + } + return active.concat(inactive); +} + async function loadPluginsPanel(){ const list=$('pluginsList'); const empty=$('pluginsEmpty'); @@ -8271,7 +8297,7 @@ async function loadPluginsPanel(){ } if(empty) empty.style.display='none'; list.style.display=''; - for(const plugin of plugins){ + for(const plugin of _partitionPluginsActiveFirst(plugins)){ list.appendChild(_buildPluginCard(plugin)); } }catch(e){ diff --git a/static/style.css b/static/style.css index 140bfb8d..dbd6e194 100644 --- a/static/style.css +++ b/static/style.css @@ -4654,6 +4654,7 @@ main.main > #mainPlugin{display:none;} .plugin-card-header{cursor:default;} .plugin-card-header:hover{background:transparent;} .plugin-card-badge-disabled{background:var(--surface);color:var(--muted);} +:root.dark .provider-card-badge.plugin-card-badge-disabled{background:var(--surface);color:var(--muted);} .plugin-hook-list{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;} .plugin-hook-badge{display:inline-flex;align-items:center;border:1px solid var(--border2);background:var(--code-bg);color:var(--text);border-radius:999px;padding:3px 8px;font-size:11px;font-family:var(--font-mono);} .plugin-hook-empty{font-size:12px;color:var(--muted);font-style:italic;} diff --git a/tests/test_issue4713_plugins_badge_and_sort.py b/tests/test_issue4713_plugins_badge_and_sort.py new file mode 100644 index 00000000..8dfd4684 --- /dev/null +++ b/tests/test_issue4713_plugins_badge_and_sort.py @@ -0,0 +1,77 @@ +"""Regression checks for Issue #4713: plugin badge and sort-order alignment. + +Focus: +- _pluginActivationState must check is_active_provider===true before the + activation string branch so the sort bucket always matches the badge shown + by _buildPluginCard (Greptile P2 finding on PR #4848). +- _buildPluginCard must show the active-provider badge when is_active_provider + is true, regardless of the activation string. +""" +import re +from pathlib import Path + + +PANELS_JS = (Path(__file__).parent.parent / "static" / "panels.js").read_text(encoding="utf-8") + + +def _function_block(src: str, name: str) -> str: + marker = re.search(rf"(^|\n)(?:async\s+)?function\s+{re.escape(name)}\(", src) + assert marker is not None, f"{name}() not found" + start = marker.start() + next_marker = re.search(r"\n(?:function\s+\w+\(|async\s+function\s+\w+\()", src[start + 1:]) + end = start + 1 + next_marker.start() if next_marker else len(src) + return src[start:end] + + +def test_activation_state_checks_is_active_provider_before_activation_string(): + """is_active_provider===true must be checked before the activation branch.""" + block = _function_block(PANELS_JS, "_pluginActivationState") + pos_early = block.find("is_active_provider===true") + pos_branch = block.find("activation==='exclusive'") + assert pos_early != -1, "_pluginActivationState must check is_active_provider===true" + assert pos_branch != -1, "_pluginActivationState must have the exclusive|provider branch" + assert pos_early < pos_branch, ( + "is_active_provider===true check must precede the activation==='exclusive' branch" + ) + + +def test_activation_state_early_guard_returns_provider(): + """The early is_active_provider===true guard must return 'provider'.""" + block = _function_block(PANELS_JS, "_pluginActivationState") + match = re.search(r"is_active_provider===true\)?\s*return\s*'provider'", block) + assert match is not None, ( + "_pluginActivationState must return 'provider' when is_active_provider===true" + ) + + +def test_build_plugin_card_uses_is_active_provider_boolean(): + """_buildPluginCard must read plugin.is_active_provider when it's a boolean.""" + block = _function_block(PANELS_JS, "_buildPluginCard") + assert "is_active_provider" in block, ( + "_buildPluginCard must reference plugin.is_active_provider" + ) + + +def test_activation_state_disabled_plugin_with_active_provider_sorts_active(): + """Ensure badge and sort agree: is_active_provider===true wins over activation='disabled'. + + Static code check verifying the fix structure rather than a runtime + execution, since panels.js runs in a browser context. + """ + block = _function_block(PANELS_JS, "_pluginActivationState") + lines = block.splitlines() + early_return_line = None + branch_line = None + for i, line in enumerate(lines): + if "is_active_provider===true" in line and "return 'provider'" in line: + early_return_line = i + if early_return_line is None and "is_active_provider===true" in line: + if i + 1 < len(lines) and "return 'provider'" in lines[i + 1]: + early_return_line = i + if "activation==='exclusive'" in line: + branch_line = i + assert early_return_line is not None, "early is_active_provider===true return not found" + assert branch_line is not None, "exclusive|provider branch not found" + assert early_return_line < branch_line, ( + "is_active_provider===true return must precede the exclusive|provider branch" + )