From 25e1733c931f440434b966a78d62fd0ccc03cbfa Mon Sep 17 00:00:00 2001 From: wsp Date: Tue, 28 Jul 2026 00:33:06 +0800 Subject: [PATCH] feat(skills): add global skill availability controls - persist globally disabled user skill keys in shared configuration - exclude disabled skills from runtime selection across all agent types - add desktop APIs and Skills page controls for global availability - show globally disabled skills as locked in agent skill suites - cover runtime behavior and frontend state with focused tests --- .../src/api/remote_workspace_policy.rs | 8 +++ src/apps/desktop/src/api/skill_api.rs | 66 +++++++++++++++++-- src/apps/desktop/src/lib.rs | 2 + .../implementations/skills/mode_overrides.rs | 46 ++++++++++++- .../tools/implementations/skills/registry.rs | 52 +++++++++++++-- .../assembly/core/src/service/config/types.rs | 14 ++++ .../execution/agent-runtime/src/skills/mod.rs | 2 +- .../agent-runtime/src/skills/selection.rs | 12 +++- .../agent-runtime/src/skills/types.rs | 1 + .../agent-runtime/tests/skill_contracts.rs | 46 ++++++++++++- .../src/app/scenes/skills/SkillsScene.scss | 24 +++++++ .../src/app/scenes/skills/SkillsScene.tsx | 46 ++++++++++--- .../skills/components/SkillsSuiteView.tsx | 12 +++- .../skills/hooks/useInstalledSkills.test.tsx | 12 ++++ .../scenes/skills/hooks/useInstalledSkills.ts | 63 ++++++++++++++++-- .../api/service-api/ConfigAPI.ts | 27 ++++++++ .../src/infrastructure/config/types/index.ts | 6 ++ .../src/locales/en-US/scenes/skills.json | 8 ++- .../src/locales/zh-CN/scenes/skills.json | 8 ++- .../src/locales/zh-TW/scenes/skills.json | 8 ++- 20 files changed, 428 insertions(+), 35 deletions(-) diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 7ae84e9e51..119a4ab489 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -505,6 +505,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "get_global_config_status", RemoteWorkspacePolicy::LegacyUnaudited, ), + ( + "get_global_skill_settings", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ( "get_health_status", RemoteWorkspacePolicy::WorkspaceAgnostic, @@ -1527,6 +1531,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "set_external_subagent_activation_command", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "set_global_skill_disabled", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ("set_macos_edit_menu_mode", RemoteWorkspacePolicy::LocalOnly), ( "set_main_window_transient_geometry", diff --git a/src/apps/desktop/src/api/skill_api.rs b/src/apps/desktop/src/api/skill_api.rs index 849402da9d..a0d8e05bec 100644 --- a/src/apps/desktop/src/api/skill_api.rs +++ b/src/apps/desktop/src/api/skill_api.rs @@ -17,10 +17,10 @@ use tokio::time::{timeout, Duration}; use crate::api::app_state::AppState; use bitfun_core::agentic::tools::implementations::skills::mode_overrides::{ - clear_user_mode_skill_overrides, load_project_mode_skills_document_local, - project_mode_skills_path_for_remote, save_project_mode_skills_document_local, - set_disabled_mode_skills_in_document, set_mode_skill_disabled_in_document, - set_user_mode_skill_state, + clear_user_mode_skill_overrides, load_globally_disabled_user_skills, + load_project_mode_skills_document_local, project_mode_skills_path_for_remote, + save_project_mode_skills_document_local, set_disabled_mode_skills_in_document, + set_global_user_skill_disabled, set_mode_skill_disabled_in_document, set_user_mode_skill_state, }; use bitfun_core::agentic::tools::implementations::skills::{ resolver::resolve_skill_default_enabled_for_mode, ModeSkillInfo, SkillData, SkillInfo, @@ -123,6 +123,19 @@ pub struct ResetModeSkillSelectionRequest { pub workspace_path: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetGlobalSkillDisabledRequest { + pub skill_key: String, + pub disabled: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GlobalSkillSettingsResponse { + pub globally_disabled_user_skill_keys: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SkillMarketItem { @@ -497,6 +510,51 @@ pub async fn get_skill_configs( .map_err(|e| format!("Failed to serialize skill configs: {}", e)) } +#[tauri::command] +pub async fn get_global_skill_settings() -> Result { + let globally_disabled_user_skill_keys = load_globally_disabled_user_skills() + .await + .map_err(|error| format!("Failed to load global Skill settings: {}", error))?; + Ok(GlobalSkillSettingsResponse { + globally_disabled_user_skill_keys, + }) +} + +#[tauri::command] +pub async fn set_global_skill_disabled( + request: SetGlobalSkillDisabledRequest, +) -> Result { + let skill_key = request.skill_key.trim(); + if !skill_key.starts_with("user::") { + return Err("Global Skill availability only applies to user-level Skills".to_string()); + } + + let known_skill = SkillRegistry::global() + .get_all_skills() + .await + .into_iter() + .any(|skill| skill.key == skill_key && skill.level == SkillLocation::User); + if !known_skill { + return Err(format!("User-level Skill '{}' was not found", skill_key)); + } + + let globally_disabled_user_skill_keys = + set_global_user_skill_disabled(skill_key, request.disabled) + .await + .map_err(|error| format!("Failed to update global Skill settings: {}", error))?; + if let Err(error) = bitfun_core::service::config::reload_global_config().await { + log::warn!( + "Failed to reload global configuration after Skill availability update: skill_key={}, error={}", + skill_key, + error + ); + } + + Ok(GlobalSkillSettingsResponse { + globally_disabled_user_skill_keys, + }) +} + #[tauri::command] pub async fn get_mode_skill_configs( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index c37ba0913b..c0a34328ec 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1301,10 +1301,12 @@ pub async fn run() { list_agent_tool_names, update_subagent_config, get_skill_configs, + get_global_skill_settings, get_mode_skill_configs, list_skill_market, search_skill_market, download_skill_market, + set_global_skill_disabled, set_mode_skill_disabled, replace_mode_skill_selection, reset_mode_skill_selection, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/mode_overrides.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/mode_overrides.rs index 290a05d643..3212d395ec 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/mode_overrides.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/mode_overrides.rs @@ -10,10 +10,10 @@ use crate::service::config::agent_profile_project_store::{ }; use crate::service::config::global::GlobalConfigManager; use crate::service::config::mode_config_canonicalizer::persist_agent_profile_from_value; -use crate::service::config::types::AgentProfileConfig; +use crate::service::config::types::{AgentProfileConfig, SkillSettingsConfig}; use crate::util::errors::{BitFunError, BitFunResult}; -use bitfun_agent_runtime::skills::normalize_user_mode_skill_overrides; pub use bitfun_agent_runtime::skills::UserModeSkillOverrides; +use bitfun_agent_runtime::skills::{normalize_skill_keys, normalize_user_mode_skill_overrides}; use serde_json::json; use std::collections::HashMap; use std::path::Path; @@ -89,6 +89,48 @@ pub async fn clear_user_mode_skill_overrides( load_user_mode_skill_overrides(mode_id).await } +pub async fn load_globally_disabled_user_skills() -> BitFunResult> { + let config_service = GlobalConfigManager::get_service().await?; + let settings: SkillSettingsConfig = config_service + .get_config(Some("ai.skill_settings")) + .await + .unwrap_or_default(); + Ok(normalize_skill_keys(settings.globally_disabled_user_skills)) +} + +pub async fn set_global_user_skill_disabled( + skill_key: &str, + disabled: bool, +) -> BitFunResult> { + let skill_key = skill_key.trim(); + if skill_key.is_empty() { + return Ok(Vec::new()); + } + + let config_service = GlobalConfigManager::get_service().await?; + let mut settings: SkillSettingsConfig = config_service + .get_config(Some("ai.skill_settings")) + .await + .unwrap_or_default(); + + if disabled { + settings + .globally_disabled_user_skills + .push(skill_key.to_string()); + } else { + settings + .globally_disabled_user_skills + .retain(|key| key != skill_key); + } + settings.globally_disabled_user_skills = + normalize_skill_keys(settings.globally_disabled_user_skills); + + config_service + .set_config("ai.skill_settings", &settings) + .await?; + Ok(settings.globally_disabled_user_skills) +} + pub fn project_mode_skills_path_for_remote(remote_root: &str) -> String { project_agent_profiles_path_for_remote(remote_root) } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs index 368230e1e7..dafb4e48ba 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs @@ -5,7 +5,7 @@ use super::builtin::ensure_builtin_skills_installed; use super::mode_overrides::{ load_disabled_mode_skills_local, load_disabled_mode_skills_remote, - load_user_mode_skill_overrides, UserModeSkillOverrides, + load_globally_disabled_user_skills, load_user_mode_skill_overrides, UserModeSkillOverrides, }; use super::types::{ModeSkillInfo, SkillData, SkillInfo, SkillLocation}; use crate::agentic::workspace::WorkspaceFileSystem; @@ -13,7 +13,7 @@ use crate::infrastructure::get_path_manager_arc; use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_agent_runtime::skills::{ annotate_shadowed_skills, build_mode_skill_infos, filter_candidates_for_mode, - filter_implicitly_invocable_skills, normalize_local_skill_dir_name, + filter_implicitly_invocable_skills, is_skill_globally_enabled, normalize_local_skill_dir_name, normalize_remote_skill_dir_name, normalize_skill_keys, resolve_default_hidden_builtin_for_explicit_invocation, resolve_user_config_skill_root, resolve_visible_skills, sort_skill_candidates_by_dir, sort_skills, @@ -79,6 +79,26 @@ impl SkillRegistry { SKILL_REGISTRY.get_or_init(Self::new) } + async fn globally_disabled_user_skill_keys() -> HashSet { + load_globally_disabled_user_skills() + .await + .unwrap_or_default() + .into_iter() + .collect() + } + + fn filter_globally_disabled_candidates( + candidates: Vec, + globally_disabled_user_skills: &HashSet, + ) -> Vec { + candidates + .into_iter() + .filter(|candidate| { + is_skill_globally_enabled(&candidate.info, globally_disabled_user_skills) + }) + .collect() + } + async fn apply_local_openai_policy(skill_data: &mut SkillData, skill_dir: &Path) { let policy_path = skill_dir.join("agents").join("openai.yaml"); let content = match fs::read_to_string(&policy_path).await { @@ -431,6 +451,9 @@ impl SkillRegistry { workspace_root: Option<&Path>, agent_type: Option<&str>, ) -> Vec { + let globally_disabled_user_skills = Self::globally_disabled_user_skill_keys().await; + let candidates = + Self::filter_globally_disabled_candidates(candidates, &globally_disabled_user_skills); let Some(mode_id) = agent_type.map(str::trim).filter(|value| !value.is_empty()) else { return candidates; }; @@ -458,6 +481,9 @@ impl SkillRegistry { remote_root: &str, agent_type: Option<&str>, ) -> Vec { + let globally_disabled_user_skills = Self::globally_disabled_user_skill_keys().await; + let candidates = + Self::filter_globally_disabled_candidates(candidates, &globally_disabled_user_skills); let Some(mode_id) = agent_type.map(str::trim).filter(|value| !value.is_empty()) else { return candidates; }; @@ -506,6 +532,9 @@ impl SkillRegistry { let candidates = self .scan_skill_candidates_for_workspace(workspace_root) .await; + let globally_disabled_user_skills = Self::globally_disabled_user_skill_keys().await; + let candidates = + Self::filter_globally_disabled_candidates(candidates, &globally_disabled_user_skills); let filtered = self .apply_mode_filters_for_workspace(candidates.clone(), workspace_root, agent_type) .await; @@ -531,6 +560,9 @@ impl SkillRegistry { let candidates = self .scan_skill_candidates_for_remote_workspace(fs, remote_root) .await; + let globally_disabled_user_skills = Self::globally_disabled_user_skill_keys().await; + let candidates = + Self::filter_globally_disabled_candidates(candidates, &globally_disabled_user_skills); let filtered = self .apply_mode_filters_for_remote_workspace( candidates.clone(), @@ -670,8 +702,11 @@ impl SkillRegistry { }; let disabled_project: HashSet = normalize_skill_keys(disabled_project).into_iter().collect(); - let filtered = - filter_candidates_for_mode(candidates, mode_id, &user_overrides, &disabled_project); + let globally_disabled_user_skills = Self::globally_disabled_user_skill_keys().await; + let filtered = Self::filter_globally_disabled_candidates( + filter_candidates_for_mode(candidates, mode_id, &user_overrides, &disabled_project), + &globally_disabled_user_skills, + ); let resolved = resolve_visible_skills(filtered); build_mode_skill_infos( @@ -680,6 +715,7 @@ impl SkillRegistry { mode_id, &user_overrides, &disabled_project, + &globally_disabled_user_skills, ) } @@ -701,8 +737,11 @@ impl SkillRegistry { .unwrap_or_default(); let disabled_project: HashSet = normalize_skill_keys(disabled_project).into_iter().collect(); - let filtered = - filter_candidates_for_mode(candidates, mode_id, &user_overrides, &disabled_project); + let globally_disabled_user_skills = Self::globally_disabled_user_skill_keys().await; + let filtered = Self::filter_globally_disabled_candidates( + filter_candidates_for_mode(candidates, mode_id, &user_overrides, &disabled_project), + &globally_disabled_user_skills, + ); let resolved = resolve_visible_skills(filtered); build_mode_skill_infos( @@ -711,6 +750,7 @@ impl SkillRegistry { mode_id, &user_overrides, &disabled_project, + &globally_disabled_user_skills, ) } diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index 2fe5789428..01ae0265a0 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -753,6 +753,10 @@ pub struct AIConfig { #[serde(default, deserialize_with = "deserialize_agent_profiles")] pub agent_profiles: HashMap, + /// User-level Skill availability shared by every agent profile. + #[serde(default)] + pub skill_settings: SkillSettingsConfig, + /// Review team configuration. /// team_id -> ReviewTeamConfig #[serde(default = "default_review_team_configs")] @@ -978,6 +982,15 @@ pub struct AgentProfileConfig { pub tool_permission_rules: Vec, } +/// User-level Skill configuration shared by every agent profile. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct SkillSettingsConfig { + /// User-level Skill keys disabled for every agent profile. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub globally_disabled_user_skills: Vec, +} + /// API view of a mode configuration. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] @@ -1839,6 +1852,7 @@ impl Default for AIConfig { default_models: DefaultModelsConfig::default(), agent_model_defaults: AgentModelDefaultsConfig::default(), agent_profiles: std::collections::HashMap::new(), + skill_settings: SkillSettingsConfig::default(), review_teams: default_review_team_configs(), review_team_rate_limit_status: default_review_team_rate_limit_status(), subagent_max_concurrency: default_subagent_max_concurrency(), diff --git a/src/crates/execution/agent-runtime/src/skills/mod.rs b/src/crates/execution/agent-runtime/src/skills/mod.rs index a3a9026dff..326af66a56 100644 --- a/src/crates/execution/agent-runtime/src/skills/mod.rs +++ b/src/crates/execution/agent-runtime/src/skills/mod.rs @@ -28,7 +28,7 @@ pub use roots::{ }; pub use selection::{ annotate_shadowed_skills, build_mode_skill_infos, filter_candidates_for_mode, - filter_implicitly_invocable_skills, normalize_skill_keys, + filter_implicitly_invocable_skills, is_skill_globally_enabled, normalize_skill_keys, resolve_default_hidden_builtin_for_explicit_invocation, resolve_visible_skills, sort_skill_candidates_by_dir, sort_skills, ExplicitSkillInvocationResolution, SkillCandidate, }; diff --git a/src/crates/execution/agent-runtime/src/skills/selection.rs b/src/crates/execution/agent-runtime/src/skills/selection.rs index 34ec620ef9..c73ae40562 100644 --- a/src/crates/execution/agent-runtime/src/skills/selection.rs +++ b/src/crates/execution/agent-runtime/src/skills/selection.rs @@ -149,6 +149,13 @@ pub fn filter_implicitly_invocable_skills(skills: Vec) -> Vec, +) -> bool { + skill.level != SkillLocation::User || !globally_disabled_user_skills.contains(&skill.key) +} + pub fn filter_candidates_for_mode( candidates: Vec, mode_id: &str, @@ -206,6 +213,7 @@ pub fn build_mode_skill_infos( mode_id: &str, user_overrides: &UserModeSkillOverrides, disabled_project_skills: &HashSet, + globally_disabled_user_skills: &HashSet, ) -> Vec { let resolved_by_name: HashMap = resolved_skills .iter() @@ -223,7 +231,8 @@ pub fn build_mode_skill_infos( user_overrides, disabled_project_skills, ); - let selected_for_runtime = resolved_keys.contains(&skill.key); + let globally_enabled = is_skill_globally_enabled(&skill, globally_disabled_user_skills); + let selected_for_runtime = globally_enabled && resolved_keys.contains(&skill.key); let mode_winner_key = state .effective_enabled .then(|| resolved_by_name.get(&skill.name)) @@ -237,6 +246,7 @@ pub fn build_mode_skill_infos( ModeSkillInfo { skill, default_enabled: state.default_enabled, + globally_enabled, effective_enabled: state.effective_enabled, disabled_by_mode: !state.effective_enabled, selected_for_runtime, diff --git a/src/crates/execution/agent-runtime/src/skills/types.rs b/src/crates/execution/agent-runtime/src/skills/types.rs index 88fea2d876..68549f3064 100644 --- a/src/crates/execution/agent-runtime/src/skills/types.rs +++ b/src/crates/execution/agent-runtime/src/skills/types.rs @@ -84,6 +84,7 @@ pub struct ModeSkillInfo { #[serde(flatten)] pub skill: SkillInfo, pub default_enabled: bool, + pub globally_enabled: bool, pub effective_enabled: bool, pub disabled_by_mode: bool, pub selected_for_runtime: bool, diff --git a/src/crates/execution/agent-runtime/tests/skill_contracts.rs b/src/crates/execution/agent-runtime/tests/skill_contracts.rs index b18b8268fa..35128ae3a2 100644 --- a/src/crates/execution/agent-runtime/tests/skill_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/skill_contracts.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use bitfun_agent_runtime::skills::{ annotate_shadowed_skills, build_mode_skill_infos, builtin_skill_group_key, - filter_candidates_for_mode, filter_implicitly_invocable_skills, + filter_candidates_for_mode, filter_implicitly_invocable_skills, is_skill_globally_enabled, render_loaded_skill_for_assistant, resolve_builtin_default_enabled, resolve_default_hidden_builtin_for_explicit_invocation, resolve_skill_default_enabled_for_mode, resolve_skill_state_for_mode, resolve_user_config_skill_root, resolve_visible_skills, @@ -510,6 +510,7 @@ fn mode_skill_candidate_filtering_and_info_are_runtime_owned() { "agentic", &UserModeSkillOverrides::default(), &disabled_project, + &HashSet::new(), ); let project_doc = infos @@ -576,6 +577,7 @@ fn mode_skill_info_reports_the_actual_runtime_winner_after_filtering() { "agentic", &UserModeSkillOverrides::default(), &disabled_project, + &HashSet::new(), ); let project = infos @@ -608,6 +610,48 @@ fn mode_skill_info_reports_the_actual_runtime_winner_after_filtering() { ); } +#[test] +fn global_skill_disable_overrides_mode_selection_without_changing_mode_defaults() { + let skill = custom_user_skill("my-custom-skill"); + let candidate = SkillCandidate { + info: skill.clone(), + priority: 0, + }; + let mut globally_disabled = HashSet::new(); + globally_disabled.insert(skill.key.clone()); + assert!(!is_skill_globally_enabled( + &candidate.info, + &globally_disabled + )); + + let infos = build_mode_skill_infos( + vec![skill], + Vec::new(), + "agentic", + &UserModeSkillOverrides::default(), + &HashSet::new(), + &globally_disabled, + ); + + let info = infos.first().expect("skill info should be present"); + assert!(info.default_enabled); + assert!(!info.globally_enabled); + assert!(info.effective_enabled); + assert!(!info.selected_for_runtime); + assert_eq!( + info.state_reason, + ModeSkillStateReason::CustomUserDefaultEnabled + ); + + let filtered = filter_candidates_for_mode( + vec![candidate], + "agentic", + &UserModeSkillOverrides::default(), + &HashSet::new(), + ); + assert_eq!(filtered.len(), 1, "mode policy itself remains unchanged"); +} + #[test] fn explicit_invocation_hidden_builtin_fallback_is_runtime_owned() { let candidate = SkillCandidate { diff --git a/src/web-ui/src/app/scenes/skills/SkillsScene.scss b/src/web-ui/src/app/scenes/skills/SkillsScene.scss index 60ab34a2fe..9199b5c721 100644 --- a/src/web-ui/src/app/scenes/skills/SkillsScene.scss +++ b/src/web-ui/src/app/scenes/skills/SkillsScene.scss @@ -644,6 +644,16 @@ border-color: color-mix(in srgb, var(--color-accent-500) 34%, var(--border-subtle)); box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-accent-500) 12%, transparent); } + + &.is-globally-disabled { + border-color: var(--border-subtle); + background: var(--element-bg-subtle); + } + + &.is-globally-disabled { + opacity: 0.62; + cursor: not-allowed; + } } .skills-suite__skill-chip-name { @@ -749,6 +759,14 @@ gap: 3px; } +.skills-card__status-badges { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; + flex-shrink: 0; +} + .skills-card__name { font-size: var(--font-size-sm); font-weight: $font-weight-semibold; @@ -788,6 +806,12 @@ margin-top: auto; } +.skills-card__global-toggle { + display: inline-flex; + align-items: center; + margin-right: auto; +} + .skills-card__delete { display: inline-flex; align-items: center; diff --git a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx index 621f08cd7b..bf3ed5796c 100644 --- a/src/web-ui/src/app/scenes/skills/SkillsScene.tsx +++ b/src/web-ui/src/app/scenes/skills/SkillsScene.tsx @@ -19,7 +19,7 @@ import { Zap, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { Badge, Button, ConfirmDialog, Input, Modal, Search, Select } from '@/component-library'; +import { Badge, Button, ConfirmDialog, Input, Modal, Search, Select, Switch } from '@/component-library'; import { GalleryDetailModal } from '@/app/components'; import type { SkillInfo, SkillLevel, SkillMarketItem } from '@/infrastructure/config/types'; import { @@ -156,13 +156,18 @@ const SkillsScene: React.FC = () => { skill.name, source, scope, + skill.level === 'user' + ? installed.globallyDisabledSkillKeys.has(skill.key) + ? t('list.item.globalDisabled') + : t('list.item.globalEnabled') + : null, skill.isShadowed ? t('list.item.shadowedTooltip', { source: coverageSourceBySkillKey.get(skill.key) ?? t('list.item.unknownSource'), }) : null, ].filter(Boolean).join('. '); - }, [coverageSourceBySkillKey, market.isRemoteWorkspace, t]); + }, [coverageSourceBySkillKey, installed.globallyDisabledSkillKeys, market.isRemoteWorkspace, t]); const refetchSkillsScene = useCallback(async () => { await Promise.all([installed.loadSkills(true), market.refresh()]); @@ -355,6 +360,9 @@ const SkillsScene: React.FC = () => { className={[ 'skills-card', skill.isShadowed && 'is-shadowed', + skill.level === 'user' + && installed.globallyDisabledSkillKeys.has(skill.key) + && 'is-globally-disabled', ].filter(Boolean).join(' ')} style={{ '--surface-stagger-index': index } as React.CSSProperties} onClick={() => setSelectedDetail({ type: 'installed', skillKey: skill.key })} @@ -384,12 +392,20 @@ const SkillsScene: React.FC = () => { {skill.description} )} - {skill.isBuiltin && ( - - - {t('list.item.builtin')} - - )} +
+ {skill.isBuiltin && ( + + + {t('list.item.builtin')} + + )} + {skill.level === 'user' + && installed.globallyDisabledSkillKeys.has(skill.key) && ( + + {t('list.item.globalDisabled')} + + )} +
@@ -428,6 +444,20 @@ const SkillsScene: React.FC = () => { onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} > + {skill.level === 'user' && ( +
+ { + void installed.handleGlobalSkillToggle(skill, event.target.checked); + }} + /> +
+ )} ); })} diff --git a/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.test.tsx b/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.test.tsx index a63b733d0d..14909b12f5 100644 --- a/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.test.tsx +++ b/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.test.tsx @@ -7,6 +7,8 @@ import type { SkillInfo } from '@/infrastructure/config/types'; import { useInstalledSkills } from './useInstalledSkills'; const getSkillConfigsMock = vi.hoisted(() => vi.fn()); +const getGlobalSkillSettingsMock = vi.hoisted(() => vi.fn()); +const setGlobalSkillDisabledMock = vi.hoisted(() => vi.fn()); const deleteSkillMock = vi.hoisted(() => vi.fn()); const validateSkillPathMock = vi.hoisted(() => vi.fn()); const notificationMocks = vi.hoisted(() => ({ @@ -22,6 +24,8 @@ vi.mock('react-i18next', () => ({ vi.mock('@/infrastructure/api', () => ({ configAPI: { getSkillConfigs: getSkillConfigsMock, + getGlobalSkillSettings: getGlobalSkillSettingsMock, + setGlobalSkillDisabled: setGlobalSkillDisabledMock, validateSkillPath: validateSkillPathMock, addSkill: vi.fn(), deleteSkill: deleteSkillMock, @@ -59,6 +63,12 @@ describe('useInstalledSkills', () => { document.body.appendChild(container); root = createRoot(container); getSkillConfigsMock.mockReset().mockResolvedValue([]); + getGlobalSkillSettingsMock.mockReset().mockResolvedValue({ + globallyDisabledUserSkillKeys: [], + }); + setGlobalSkillDisabledMock.mockReset().mockResolvedValue({ + globallyDisabledUserSkillKeys: [], + }); deleteSkillMock.mockReset().mockResolvedValue(undefined); validateSkillPathMock.mockReset().mockResolvedValue({ valid: true, name: 'test' }); notificationMocks.success.mockReset(); @@ -80,6 +90,7 @@ describe('useInstalledSkills', () => { }); expect(getSkillConfigsMock).not.toHaveBeenCalled(); + expect(getGlobalSkillSettingsMock).not.toHaveBeenCalled(); expect(container.textContent).toBe('idle'); await act(async () => { @@ -88,6 +99,7 @@ describe('useInstalledSkills', () => { }); expect(getSkillConfigsMock).toHaveBeenCalledTimes(1); + expect(getGlobalSkillSettingsMock).toHaveBeenCalledTimes(1); }); it('ignores a desktop skill load that finishes after switching away', async () => { diff --git a/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.ts b/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.ts index 94e7171c5c..b2f5f8fb68 100644 --- a/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.ts +++ b/src/web-ui/src/app/scenes/skills/hooks/useInstalledSkills.ts @@ -27,6 +27,8 @@ export function useInstalledSkills({ const { workspacePath, hasWorkspace, isRemoteWorkspace } = useWorkspaceManagerSync(); const [skills, setSkills] = useState([]); + const [globallyDisabledSkillKeys, setGloballyDisabledSkillKeys] = useState>(new Set()); + const [savingGlobalSkillKey, setSavingGlobalSkillKey] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -68,14 +70,18 @@ export function useInstalledSkills({ try { setLoading(true); setError(null); - const list = await configAPI.getSkillConfigs({ - forceRefresh, - workspacePath: workspacePath || undefined, - }); + const [list, globalSettings] = await Promise.all([ + configAPI.getSkillConfigs({ + forceRefresh, + workspacePath: workspacePath || undefined, + }), + configAPI.getGlobalSkillSettings(), + ]); if (requestId !== loadRequestIdRef.current || !capabilityIsCurrent(capabilityEpoch)) { return; } setSkills(list); + setGloballyDisabledSkillKeys(new Set(globalSettings.globallyDisabledUserSkillKeys)); } catch (err) { if (requestId !== loadRequestIdRef.current || !capabilityIsCurrent(capabilityEpoch)) { return; @@ -97,6 +103,8 @@ export function useInstalledSkills({ setIsAdding(false); if (!enabled) { setSkills([]); + setGloballyDisabledSkillKeys(new Set()); + setSavingGlobalSkillKey(null); setError(null); setLoading(false); return; @@ -257,6 +265,50 @@ export function useInstalledSkills({ } }, [capabilityIsCurrent, currentCapabilityEpoch, loadSkills, notification, t, workspacePath]); + const handleGlobalSkillToggle = useCallback(async (skill: SkillInfo, enabled: boolean) => { + const capabilityEpoch = currentCapabilityEpoch(); + if (capabilityEpoch === null || skill.level !== 'user') { + return false; + } + + setSavingGlobalSkillKey(skill.key); + try { + const settings = await configAPI.setGlobalSkillDisabled({ + skillKey: skill.key, + disabled: !enabled, + }); + if (!capabilityIsCurrent(capabilityEpoch)) { + return false; + } + + setGloballyDisabledSkillKeys(new Set(settings.globallyDisabledUserSkillKeys)); + const { globalEventBus } = await import('@/infrastructure/event-bus'); + globalEventBus.emit('mode:config:updated'); + notification.success(t('messages.toggleSuccess', { + name: skill.name, + status: enabled ? t('messages.enabled') : t('messages.disabled'), + })); + return true; + } catch (err) { + if (!capabilityIsCurrent(capabilityEpoch)) { + return false; + } + log.error('Failed to update global Skill availability', { + skillKey: skill.key, + enabled, + error: err, + }); + notification.error(t('messages.toggleFailed', { + error: err instanceof Error ? err.message : String(err), + })); + return false; + } finally { + if (capabilityIsCurrent(capabilityEpoch)) { + setSavingGlobalSkillKey(null); + } + } + }, [capabilityIsCurrent, currentCapabilityEpoch, notification, t]); + const normalizedQuery = searchQuery.trim().toLowerCase(); const filteredSkills = useMemo(() => { @@ -291,12 +343,15 @@ export function useInstalledSkills({ return { skills, + globallyDisabledSkillKeys, + savingGlobalSkillKey, filteredSkills, counts, loading, error, loadSkills, handleDelete, + handleGlobalSkillToggle, formLevel, setFormLevel, formPath, diff --git a/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts index 491238239f..cd953cbb27 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ConfigAPI.ts @@ -5,6 +5,7 @@ import { createTauriCommandError } from '../errors/TauriCommandError'; import type { AgentProfileConfigItem, DiagnosticsBundleInfo, + GlobalSkillSettings, ModeSkillInfo, RuntimeLoggingInfo, SkillInfo, @@ -25,6 +26,11 @@ export interface GetModeSkillConfigsParams { workspacePath?: string; } +export interface SetGlobalSkillDisabledParams { + skillKey: string; + disabled: boolean; +} + export interface SetModeSkillDisabledParams { modeId: string; skillKey: string; @@ -299,6 +305,27 @@ export class ConfigAPI { } } + async getGlobalSkillSettings(): Promise { + try { + return await api.invoke('get_global_skill_settings'); + } catch (error) { + throw createTauriCommandError('get_global_skill_settings', error); + } + } + + async setGlobalSkillDisabled({ + skillKey, + disabled, + }: SetGlobalSkillDisabledParams): Promise { + try { + return await api.invoke('set_global_skill_disabled', { + request: { skillKey, disabled }, + }); + } catch (error) { + throw createTauriCommandError('set_global_skill_disabled', error, { skillKey, disabled }); + } + } + async setModeSkillDisabled({ modeId, diff --git a/src/web-ui/src/infrastructure/config/types/index.ts b/src/web-ui/src/infrastructure/config/types/index.ts index d1a1c16632..3681098235 100644 --- a/src/web-ui/src/infrastructure/config/types/index.ts +++ b/src/web-ui/src/infrastructure/config/types/index.ts @@ -356,6 +356,8 @@ export interface SkillInfo { export interface ModeSkillInfo extends SkillInfo { /** True when this skill is enabled before any mode-specific override is applied. */ defaultEnabled: boolean; + /** False when this user-level skill is disabled for every agent profile. */ + globallyEnabled: boolean; /** True when this skill remains enabled after all mode-specific overrides are applied. */ effectiveEnabled: boolean; /** Backward-compatible inverse of `effectiveEnabled`. */ @@ -373,6 +375,10 @@ export interface ModeSkillInfo extends SkillInfo { | 'disabled_by_user_override'; } +export interface GlobalSkillSettings { + globallyDisabledUserSkillKeys: string[]; +} + export interface SkillMarketItem { id: string; name: string; diff --git a/src/web-ui/src/locales/en-US/scenes/skills.json b/src/web-ui/src/locales/en-US/scenes/skills.json index d69795b0ec..173b272b51 100644 --- a/src/web-ui/src/locales/en-US/scenes/skills.json +++ b/src/web-ui/src/locales/en-US/scenes/skills.json @@ -160,7 +160,8 @@ "disabled": "Disabled for this mode", "pending": "Unsaved", "covered": "Covered · {{source}}", - "coveredDetail": "Enabled, but this mode uses the same-name skill from {{source}}." + "coveredDetail": "Enabled, but this mode uses the same-name skill from {{source}}.", + "globalDisabled": "Globally disabled" }, "groupCount": "{{total}} skills", "messages": { @@ -224,7 +225,10 @@ "shadowedTooltip": "The same-name skill from {{source}} normally has higher priority.", "shadowedLabel": "Higher-priority source:", "shadowedDetail": "The same-name skill from {{source}}", - "unknownSource": "another source" + "unknownSource": "another source", + "globalEnabled": "Available to agents", + "globalDisabled": "Globally disabled", + "globalToggleLabel": "Toggle global availability for {{name}}" } }, "deleteModal": { diff --git a/src/web-ui/src/locales/zh-CN/scenes/skills.json b/src/web-ui/src/locales/zh-CN/scenes/skills.json index 49785bbf5a..7b3163cb31 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/skills.json +++ b/src/web-ui/src/locales/zh-CN/scenes/skills.json @@ -160,7 +160,8 @@ "disabled": "已为此模式禁用", "pending": "未保存", "covered": "已覆盖 · {{source}}", - "coveredDetail": "已启用,但此模式会使用 {{source}} 中的同名技能。" + "coveredDetail": "已启用,但此模式会使用 {{source}} 中的同名技能。", + "globalDisabled": "已全局禁用" }, "groupCount": "{{total}} 个", "messages": { @@ -224,7 +225,10 @@ "shadowedTooltip": "{{source}} 中的同名技能通常具有更高优先级。", "shadowedLabel": "更高优先级来源:", "shadowedDetail": "{{source}} 中的同名技能", - "unknownSource": "其他来源" + "unknownSource": "其他来源", + "globalEnabled": "智能体可用", + "globalDisabled": "已全局禁用", + "globalToggleLabel": "切换 {{name}} 的全局可用状态" } }, "deleteModal": { diff --git a/src/web-ui/src/locales/zh-TW/scenes/skills.json b/src/web-ui/src/locales/zh-TW/scenes/skills.json index 66f9633c55..7f6fd8acdd 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/skills.json +++ b/src/web-ui/src/locales/zh-TW/scenes/skills.json @@ -160,7 +160,8 @@ "disabled": "已為此模式停用", "pending": "未儲存", "covered": "已覆蓋 · {{source}}", - "coveredDetail": "已啟用,但此模式會使用 {{source}} 中的同名技能。" + "coveredDetail": "已啟用,但此模式會使用 {{source}} 中的同名技能。", + "globalDisabled": "已全域停用" }, "groupCount": "{{total}} 個", "messages": { @@ -224,7 +225,10 @@ "shadowedTooltip": "{{source}} 中的同名技能通常具有更高優先順序。", "shadowedLabel": "更高優先順序來源:", "shadowedDetail": "{{source}} 中的同名技能", - "unknownSource": "其他來源" + "unknownSource": "其他來源", + "globalEnabled": "智能體可用", + "globalDisabled": "已全域停用", + "globalToggleLabel": "切換 {{name}} 的全域可用狀態" } }, "deleteModal": {