From 3b45b0ff48867696b3d79bebc4e614776e2eea2f Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Sun, 24 May 2026 16:51:29 +0800 Subject: [PATCH] fix: respect visible apps in skills and mcp UI --- src-tauri/src/commands/mcp.rs | 56 +++- src-tauri/tests/mcp_commands.rs | 58 +++- src/App.tsx | 62 ++-- src/components/AppSwitcher.tsx | 16 +- src/components/mcp/McpFormModal.tsx | 157 ++++----- src/components/mcp/UnifiedMcpPanel.tsx | 21 +- .../settings/AppVisibilitySettings.tsx | 11 +- src/components/skills/SkillsPage.tsx | 7 +- src/components/skills/UnifiedSkillsPanel.tsx | 115 ++++--- src/config/appConfig.tsx | 50 +++ src/hooks/useMcp.ts | 2 +- src/i18n/locales/en.json | 1 + src/i18n/locales/ja.json | 1 + src/i18n/locales/zh.json | 1 + src/lib/api/mcp.ts | 4 +- src/types.ts | 2 +- tests/components/McpFormModal.test.tsx | 115 ++++++- tests/components/UnifiedMcpPanel.test.tsx | 102 ++++++ tests/components/UnifiedSkillsPanel.test.tsx | 314 ++++++++++++++++-- tests/config/appConfig.test.ts | 55 +++ 20 files changed, 907 insertions(+), 243 deletions(-) create mode 100644 tests/components/UnifiedMcpPanel.test.tsx create mode 100644 tests/config/appConfig.test.ts diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 5d2558e91..687d5a4df 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -1,7 +1,7 @@ #![allow(non_snake_case)] use indexmap::IndexMap; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use serde::Serialize; use tauri::State; @@ -194,14 +194,52 @@ pub async fn toggle_mcp_app( McpService::toggle_app(&state, &server_id, app_ty, enabled).map_err(|e| e.to_string()) } -/// 从所有应用导入 MCP 服务器(复用已有的导入逻辑) -#[tauri::command] -pub async fn import_mcp_from_apps(state: State<'_, AppState>) -> Result { +fn import_mcp_from_app(state: &AppState, app: &AppType) -> usize { + match app { + AppType::Claude => McpService::import_from_claude(state), + AppType::ClaudeDesktop => Ok(0), + AppType::Codex => McpService::import_from_codex(state), + AppType::Gemini => McpService::import_from_gemini(state), + AppType::OpenCode => McpService::import_from_opencode(state), + AppType::OpenClaw => Ok(0), + AppType::Hermes => McpService::import_from_hermes(state), + } + .unwrap_or(0) +} + +pub fn import_mcp_from_selected_apps( + state: &AppState, + apps: Option>, +) -> Result { + let mut apps_to_import = match apps { + Some(apps) => apps + .iter() + .map(|app| AppType::from_str(app).map_err(|e| e.to_string())) + .collect::, _>>()?, + None => vec![ + AppType::Claude, + AppType::Codex, + AppType::Gemini, + AppType::OpenCode, + AppType::Hermes, + ], + }; + let mut seen = HashSet::new(); + apps_to_import.retain(|app| seen.insert(app.clone())); + let mut total = 0; - total += McpService::import_from_claude(&state).unwrap_or(0); - total += McpService::import_from_codex(&state).unwrap_or(0); - total += McpService::import_from_gemini(&state).unwrap_or(0); - total += McpService::import_from_opencode(&state).unwrap_or(0); - total += McpService::import_from_hermes(&state).unwrap_or(0); + for app in apps_to_import { + total += import_mcp_from_app(state, &app); + } + Ok(total) } + +/// 从指定应用导入 MCP 服务器(复用已有的导入逻辑);未指定时保持全量导入。 +#[tauri::command] +pub async fn import_mcp_from_apps( + state: State<'_, AppState>, + apps: Option>, +) -> Result { + import_mcp_from_selected_apps(&state, apps) +} diff --git a/src-tauri/tests/mcp_commands.rs b/src-tauri/tests/mcp_commands.rs index 242b6e835..0e5c27800 100644 --- a/src-tauri/tests/mcp_commands.rs +++ b/src-tauri/tests/mcp_commands.rs @@ -4,8 +4,9 @@ use std::fs; use serde_json::json; use cc_switch_lib::{ - get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, AppError, - AppType, McpApps, McpServer, McpService, MultiAppConfig, + get_claude_mcp_path, get_claude_settings_path, import_default_config_test_hook, + import_mcp_from_selected_apps, AppError, AppType, McpApps, McpServer, McpService, + MultiAppConfig, }; #[path = "support.rs"] @@ -528,6 +529,59 @@ command = "echo" assert!(entry.apps.codex, "shared should enable Codex"); } +#[test] +fn import_mcp_from_selected_apps_skips_hidden_apps() { + let _guard = test_mutex().lock().expect("acquire test mutex"); + reset_test_fs(); + let home = ensure_test_home(); + + let mcp_path = get_claude_mcp_path(); + let claude_json = json!({ + "mcpServers": { + "claude-only": { + "type": "stdio", + "command": "echo" + } + } + }); + fs::write( + &mcp_path, + serde_json::to_string_pretty(&claude_json).expect("serialize claude mcp"), + ) + .expect("seed ~/.claude.json"); + + let gemini_dir = home.join(".gemini"); + fs::create_dir_all(&gemini_dir).expect("create gemini dir"); + let gemini_settings = json!({ + "mcpServers": { + "hidden-gemini": { + "type": "stdio", + "command": "gemini-echo" + } + } + }); + fs::write( + gemini_dir.join("settings.json"), + serde_json::to_string_pretty(&gemini_settings).expect("serialize gemini settings"), + ) + .expect("seed ~/.gemini/settings.json"); + + let state = support::create_test_state().expect("create test state"); + let changed = import_mcp_from_selected_apps(&state, Some(vec!["claude".to_string()])) + .expect("import selected MCP apps"); + + assert_eq!(changed, 1, "only Claude server should be imported"); + let servers = state.db.get_all_mcp_servers().expect("get all mcp servers"); + assert!( + servers.contains_key("claude-only"), + "visible Claude server should be imported" + ); + assert!( + !servers.contains_key("hidden-gemini"), + "hidden Gemini server should not be imported" + ); +} + #[test] fn import_mcp_from_gemini_sse_url_only_is_valid() { let _guard = test_mutex().lock().expect("acquire test mutex"); diff --git a/src/App.tsx b/src/App.tsx index 26d64fd6f..719c26054 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -89,6 +89,12 @@ import ToolsPanel from "@/components/openclaw/ToolsPanel"; import AgentsDefaultsPanel from "@/components/openclaw/AgentsDefaultsPanel"; import OpenClawHealthBanner from "@/components/openclaw/OpenClawHealthBanner"; import HermesMemoryPanel from "@/components/hermes/HermesMemoryPanel"; +import { + EMPTY_VISIBLE_APPS, + getFirstVisibleApp, + getSkillTargetApp, + resolveVisibleApps, +} from "@/config/appConfig"; type View = | "providers" @@ -181,32 +187,22 @@ function App() { isLinux() && (settingsData?.useAppWindowControls ?? false); const dragBarHeight = useAppWindowControls ? 32 : DEFAULT_DRAG_BAR_HEIGHT; const contentTopOffset = dragBarHeight + HEADER_HEIGHT; - const visibleApps: VisibleApps = settingsData?.visibleApps ?? { - claude: true, - "claude-desktop": true, - codex: true, - gemini: true, - opencode: true, - openclaw: true, - hermes: true, - }; - - const getFirstVisibleApp = (): AppId => { - if (visibleApps.claude) return "claude"; - if (visibleApps["claude-desktop"]) return "claude-desktop"; - if (visibleApps.codex) return "codex"; - if (visibleApps.gemini) return "gemini"; - if (visibleApps.opencode) return "opencode"; - if (visibleApps.openclaw) return "openclaw"; - if (visibleApps.hermes) return "hermes"; - return "claude"; // fallback - }; + const visibleApps: VisibleApps | null = useMemo( + () => (settingsData ? resolveVisibleApps(settingsData.visibleApps) : null), + [settingsData], + ); + const uiVisibleApps = visibleApps ?? EMPTY_VISIBLE_APPS; useEffect(() => { + if (!visibleApps) return; if (!visibleApps[activeApp]) { - setActiveApp(getFirstVisibleApp()); + setActiveApp(getFirstVisibleApp(visibleApps)); } }, [visibleApps, activeApp]); + const skillTargetApp = useMemo( + () => (visibleApps ? getSkillTargetApp(activeApp, visibleApps) : null), + [activeApp, visibleApps], + ); // Fallback from sessions view when switching to an app without session support useEffect(() => { @@ -944,18 +940,15 @@ function App() { setCurrentView("skillsDiscovery")} - currentApp={ - sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp - } + currentApp={skillTargetApp} + visibleApps={uiVisibleApps} /> ); case "skillsDiscovery": return ( ); case "mcp": @@ -963,6 +956,7 @@ function App() { setCurrentView("providers")} + visibleApps={uiVisibleApps} /> ); case "agents": @@ -1403,12 +1397,14 @@ function App() { )} {currentView === "providers" && ( <> - + {visibleApps && ( + + )}
diff --git a/src/components/AppSwitcher.tsx b/src/components/AppSwitcher.tsx index 5328562f2..cf7033c06 100644 --- a/src/components/AppSwitcher.tsx +++ b/src/components/AppSwitcher.tsx @@ -3,6 +3,7 @@ import type { VisibleApps } from "@/types"; import { ProviderIcon } from "@/components/ProviderIcon"; import { cn } from "@/lib/utils"; import { Monitor, Terminal } from "lucide-react"; +import { APP_IDS, filterVisibleAppIds } from "@/config/appConfig"; const APP_BADGE_ICON: Partial< Record @@ -18,15 +19,6 @@ interface AppSwitcherProps { compact?: boolean; } -const ALL_APPS: AppId[] = [ - "claude", - "claude-desktop", - "codex", - "gemini", - "opencode", - "openclaw", - "hermes", -]; const STORAGE_KEY = "cc-switch-last-app"; export function AppSwitcher({ @@ -60,11 +52,7 @@ export function AppSwitcher({ hermes: "Hermes", }; - // Filter apps based on visibility settings (default all visible) - const appsToShow = ALL_APPS.filter((app) => { - if (!visibleApps) return true; - return visibleApps[app]; - }); + const appsToShow = filterVisibleAppIds(APP_IDS, visibleApps); return (
diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index 0fd784b05..a468f90fb 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -7,8 +7,9 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import JsonEditor from "@/components/JsonEditor"; import type { AppId } from "@/lib/api/types"; -import { McpServer, McpServerSpec } from "@/types"; +import type { McpApps, McpServer, McpServerSpec } from "@/types"; import { mcpPresets, getMcpPresetWithDescription } from "@/config/mcpPresets"; +import { MCP_APP_IDS } from "@/config/appConfig"; import McpWizardModal from "./McpWizardModal"; import { extractErrorMessage, @@ -33,6 +34,7 @@ interface McpFormModalProps { existingIds?: string[]; defaultFormat?: "json" | "toml"; defaultEnabledApps?: AppId[]; + visibleAppIds?: AppId[]; } const McpFormModal: React.FC = ({ @@ -43,6 +45,7 @@ const McpFormModal: React.FC = ({ existingIds = [], defaultFormat = "json", defaultEnabledApps = ["claude", "codex", "gemini"], + visibleAppIds = MCP_APP_IDS, }) => { const { t } = useTranslation(); const { formatTomlError, validateTomlConfig, validateJsonConfig } = @@ -61,26 +64,38 @@ const McpFormModal: React.FC = ({ const [formDocs, setFormDocs] = useState(initialData?.docs || ""); const [formTags, setFormTags] = useState(initialData?.tags?.join(", ") || ""); - const [enabledApps, setEnabledApps] = useState<{ - claude: boolean; - codex: boolean; - gemini: boolean; - opencode: boolean; - openclaw: boolean; - hermes: boolean; - }>(() => { + const [enabledApps, setEnabledApps] = useState(() => { if (initialData?.apps) { return { ...initialData.apps }; } return { - claude: defaultEnabledApps.includes("claude"), - codex: defaultEnabledApps.includes("codex"), - gemini: defaultEnabledApps.includes("gemini"), - opencode: defaultEnabledApps.includes("opencode"), - openclaw: defaultEnabledApps.includes("openclaw"), - hermes: defaultEnabledApps.includes("hermes"), + claude: + visibleAppIds.includes("claude") && + defaultEnabledApps.includes("claude"), + codex: + visibleAppIds.includes("codex") && defaultEnabledApps.includes("codex"), + gemini: + visibleAppIds.includes("gemini") && + defaultEnabledApps.includes("gemini"), + opencode: + visibleAppIds.includes("opencode") && + defaultEnabledApps.includes("opencode"), + openclaw: + visibleAppIds.includes("openclaw") && + defaultEnabledApps.includes("openclaw"), + hermes: + visibleAppIds.includes("hermes") && + defaultEnabledApps.includes("hermes"), }; }); + const enabledAppOptions = useMemo( + () => MCP_APP_IDS.filter((app) => visibleAppIds.includes(app)), + [visibleAppIds], + ); + const visibleAppSet = useMemo( + () => new Set(visibleAppIds), + [visibleAppIds], + ); const isEditing = !!editingId; @@ -282,6 +297,20 @@ const McpFormModal: React.FC = ({ } }; + const getSubmitApps = () => { + if (isEditing) { + return enabledApps; + } + + return MCP_APP_IDS.reduce( + (apps, app) => ({ + ...apps, + [app]: visibleAppSet.has(app) && enabledApps[app], + }), + { ...enabledApps, openclaw: false }, + ); + }; + const handleSubmit = async () => { const trimmedId = formId.trim(); if (!trimmedId) { @@ -362,7 +391,7 @@ const McpFormModal: React.FC = ({ id: trimmedId, name: finalName, server: serverSpec, - apps: enabledApps, + apps: getSubmitApps(), }; const descriptionTrimmed = formDescription.trim(); @@ -518,85 +547,23 @@ const McpFormModal: React.FC = ({ {t("mcp.form.enabledApps")}
-
- - setEnabledApps({ ...enabledApps, claude: checked }) - } - /> - -
- -
- - setEnabledApps({ ...enabledApps, codex: checked }) - } - /> - -
- -
- - setEnabledApps({ ...enabledApps, gemini: checked }) - } - /> - -
- -
- - setEnabledApps({ ...enabledApps, opencode: checked }) - } - /> - -
- -
- - setEnabledApps({ ...enabledApps, hermes: checked }) - } - /> - -
+ {enabledAppOptions.map((app) => ( +
+ + setEnabledApps({ ...enabledApps, [app]: checked }) + } + /> + +
+ ))}
diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx index 754e9d954..6b2ccb848 100644 --- a/src/components/mcp/UnifiedMcpPanel.tsx +++ b/src/components/mcp/UnifiedMcpPanel.tsx @@ -9,7 +9,7 @@ import { useDeleteMcpServer, useImportMcpFromApps, } from "@/hooks/useMcp"; -import type { McpServer } from "@/types"; +import type { McpServer, VisibleApps } from "@/types"; import type { AppId } from "@/lib/api/types"; import McpFormModal from "./McpFormModal"; import { ConfirmDialog } from "../ConfirmDialog"; @@ -17,13 +17,14 @@ import { Edit3, Trash2, ExternalLink } from "lucide-react"; import { settingsApi } from "@/lib/api"; import { mcpPresets } from "@/config/mcpPresets"; import { toast } from "sonner"; -import { MCP_APP_IDS } from "@/config/appConfig"; +import { filterVisibleAppIds, MCP_APP_IDS } from "@/config/appConfig"; import { AppCountBar } from "@/components/common/AppCountBar"; import { AppToggleGroup } from "@/components/common/AppToggleGroup"; import { ListItemRow } from "@/components/common/ListItemRow"; interface UnifiedMcpPanelProps { onOpenChange: (open: boolean) => void; + visibleApps?: VisibleApps; } export interface UnifiedMcpPanelHandle { @@ -34,7 +35,7 @@ export interface UnifiedMcpPanelHandle { const UnifiedMcpPanel = React.forwardRef< UnifiedMcpPanelHandle, UnifiedMcpPanelProps ->(({ onOpenChange: _onOpenChange }, ref) => { +>(({ onOpenChange: _onOpenChange, visibleApps }, ref) => { const { t } = useTranslation(); const [isFormOpen, setIsFormOpen] = useState(false); const [editingId, setEditingId] = useState(null); @@ -49,6 +50,10 @@ const UnifiedMcpPanel = React.forwardRef< const toggleAppMutation = useToggleMcpApp(); const deleteServerMutation = useDeleteMcpServer(); const importMutation = useImportMcpFromApps(); + const visibleMcpAppIds = useMemo( + () => filterVisibleAppIds(MCP_APP_IDS, visibleApps), + [visibleApps], + ); const serverEntries = useMemo((): Array<[string, McpServer]> => { if (!serversMap) return []; @@ -97,7 +102,7 @@ const UnifiedMcpPanel = React.forwardRef< const handleImport = async () => { try { - const count = await importMutation.mutateAsync(); + const count = await importMutation.mutateAsync(visibleMcpAppIds); if (count === 0) { toast.success(t("mcp.unifiedPanel.noImportFound"), { closeButton: true, @@ -144,7 +149,7 @@ const UnifiedMcpPanel = React.forwardRef<
@@ -175,6 +180,7 @@ const UnifiedMcpPanel = React.forwardRef< onToggleApp={handleToggleApp} onEdit={handleEdit} onDelete={handleDelete} + visibleAppIds={visibleMcpAppIds} isLast={index === serverEntries.length - 1} /> ))} @@ -191,6 +197,7 @@ const UnifiedMcpPanel = React.forwardRef< } existingIds={serversMap ? Object.keys(serversMap) : []} defaultFormat="json" + visibleAppIds={visibleMcpAppIds} onSave={async () => { setIsFormOpen(false); setEditingId(null); @@ -220,6 +227,7 @@ interface UnifiedMcpListItemProps { onToggleApp: (serverId: string, app: AppId, enabled: boolean) => void; onEdit: (id: string) => void; onDelete: (id: string) => void; + visibleAppIds: AppId[]; isLast?: boolean; } @@ -229,6 +237,7 @@ const UnifiedMcpListItem: React.FC = ({ onToggleApp, onEdit, onDelete, + visibleAppIds, isLast, }) => { const { t } = useTranslation(); @@ -286,7 +295,7 @@ const UnifiedMcpListItem: React.FC = ({ onToggleApp(id, app, enabled)} - appIds={MCP_APP_IDS} + appIds={visibleAppIds} />
diff --git a/src/components/settings/AppVisibilitySettings.tsx b/src/components/settings/AppVisibilitySettings.tsx index 1b94b1ed5..bc6800bb8 100644 --- a/src/components/settings/AppVisibilitySettings.tsx +++ b/src/components/settings/AppVisibilitySettings.tsx @@ -5,6 +5,7 @@ import { ProviderIcon } from "@/components/ProviderIcon"; import type { SettingsFormState } from "@/hooks/useSettings"; import type { VisibleApps } from "@/types"; import type { AppId } from "@/lib/api"; +import { resolveVisibleApps } from "@/config/appConfig"; interface AppVisibilitySettingsProps { settings: SettingsFormState; @@ -35,15 +36,7 @@ export function AppVisibilitySettings({ }: AppVisibilitySettingsProps) { const { t } = useTranslation(); - const visibleApps: VisibleApps = settings.visibleApps ?? { - claude: true, - "claude-desktop": true, - codex: true, - gemini: true, - opencode: true, - openclaw: true, - hermes: true, - }; + const visibleApps: VisibleApps = resolveVisibleApps(settings.visibleApps); // Count how many apps are currently visible const visibleCount = Object.values(visibleApps).filter(Boolean).length; diff --git a/src/components/skills/SkillsPage.tsx b/src/components/skills/SkillsPage.tsx index c5a801e3a..fce3668cb 100644 --- a/src/components/skills/SkillsPage.tsx +++ b/src/components/skills/SkillsPage.tsx @@ -37,7 +37,7 @@ import type { import { formatSkillError } from "@/lib/errors/skillErrorParser"; interface SkillsPageProps { - initialApp?: AppId; + initialApp?: AppId | null; } export interface SkillsPageHandle { @@ -211,6 +211,11 @@ export const SkillsPage = forwardRef( return; } + if (!currentApp) { + toast.error(t("skills.noVisibleTargetApp")); + return; + } + try { await installMutation.mutateAsync({ skill, diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx index 309c17bf9..68e25dd2c 100644 --- a/src/components/skills/UnifiedSkillsPanel.tsx +++ b/src/components/skills/UnifiedSkillsPanel.tsx @@ -31,10 +31,15 @@ import type { AppId } from "@/lib/api/types"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { settingsApi, skillsApi } from "@/lib/api"; import { toast } from "sonner"; -import { SKILLS_APP_IDS } from "@/config/appConfig"; +import { + filterVisibleAppIds, + getSkillTargetApp, + SKILLS_APP_IDS, +} from "@/config/appConfig"; import { AppCountBar } from "@/components/common/AppCountBar"; import { AppToggleGroup } from "@/components/common/AppToggleGroup"; import { ListItemRow } from "@/components/common/ListItemRow"; +import type { VisibleApps } from "@/types"; import { Dialog, DialogContent, @@ -46,7 +51,8 @@ import { interface UnifiedSkillsPanelProps { onOpenDiscovery: () => void; - currentApp: AppId; + currentApp: AppId | null; + visibleApps?: VisibleApps; } export interface UnifiedSkillsPanelHandle { @@ -67,7 +73,7 @@ function formatSkillBackupDate(unixSeconds: number): string { const UnifiedSkillsPanel = React.forwardRef< UnifiedSkillsPanelHandle, UnifiedSkillsPanelProps ->(({ onOpenDiscovery, currentApp }, ref) => { +>(({ onOpenDiscovery, currentApp, visibleApps }, ref) => { const { t } = useTranslation(); const [confirmDialog, setConfirmDialog] = useState<{ isOpen: boolean; @@ -101,6 +107,14 @@ const UnifiedSkillsPanel = React.forwardRef< } = useCheckSkillUpdates(); const updateSkillMutation = useUpdateSkill(); const [isUpdatingAll, setIsUpdatingAll] = useState(false); + const visibleSkillAppIds = useMemo( + () => filterVisibleAppIds(SKILLS_APP_IDS, visibleApps), + [visibleApps], + ); + const targetApp = useMemo( + () => (currentApp ? getSkillTargetApp(currentApp, visibleApps) : null), + [currentApp, visibleApps], + ); const updatesMap = useMemo(() => { const map: Record = {}; @@ -197,12 +211,17 @@ const UnifiedSkillsPanel = React.forwardRef< const handleInstallFromZip = async () => { try { + if (!targetApp) { + toast.error(t("skills.noVisibleTargetApp")); + return; + } + const filePath = await skillsApi.openZipFileDialog(); if (!filePath) return; const installed = await installFromZipMutation.mutateAsync({ filePath, - currentApp, + currentApp: targetApp, }); if (installed.length === 0) { @@ -287,9 +306,14 @@ const UnifiedSkillsPanel = React.forwardRef< const handleRestoreFromBackup = async (backupId: string) => { try { + if (!targetApp) { + toast.error(t("skills.noVisibleTargetApp")); + return; + } + const restored = await restoreBackupMutation.mutateAsync({ backupId, - currentApp, + currentApp: targetApp, }); setRestoreDialogOpen(false); toast.success( @@ -350,7 +374,7 @@ const UnifiedSkillsPanel = React.forwardRef<
handleUninstall(skill)} onUpdate={() => handleUpdateSkill(skill)} + visibleAppIds={visibleSkillAppIds} isLast={index === skills.length - 1} /> ))} @@ -458,6 +483,7 @@ const UnifiedSkillsPanel = React.forwardRef< isImporting={importMutation.isPending} onImport={handleImport} onClose={() => setImportDialogOpen(false)} + visibleAppIds={visibleSkillAppIds} /> )} @@ -484,6 +510,7 @@ interface InstalledSkillListItemProps { onToggleApp: (id: string, app: AppId, enabled: boolean) => void; onUninstall: () => void; onUpdate?: () => void; + visibleAppIds: AppId[]; isLast?: boolean; } @@ -494,6 +521,7 @@ const InstalledSkillListItem: React.FC = ({ onToggleApp, onUninstall, onUpdate, + visibleAppIds, isLast, }) => { const { t } = useTranslation(); @@ -555,7 +583,7 @@ const InstalledSkillListItem: React.FC = ({ onToggleApp(skill.id, app, enabled)} - appIds={SKILLS_APP_IDS} + appIds={visibleAppIds} />
void; onClose: () => void; + visibleAppIds: AppId[]; } interface RestoreSkillsDialogProps { @@ -730,8 +759,37 @@ const ImportSkillsDialog: React.FC = ({ isImporting, onImport, onClose, + visibleAppIds, }) => { const { t } = useTranslation(); + const visibleAppSet = useMemo(() => new Set(visibleAppIds), [visibleAppIds]); + const emptyApps = (): ImportSkillSelection["apps"] => ({ + claude: false, + codex: false, + gemini: false, + opencode: false, + openclaw: false, + hermes: false, + }); + const applyVisibleAppMask = ( + apps: ImportSkillSelection["apps"], + ): ImportSkillSelection["apps"] => ({ + claude: visibleAppSet.has("claude") && apps.claude, + codex: visibleAppSet.has("codex") && apps.codex, + gemini: visibleAppSet.has("gemini") && apps.gemini, + opencode: visibleAppSet.has("opencode") && apps.opencode, + openclaw: visibleAppSet.has("openclaw") && apps.openclaw, + hermes: visibleAppSet.has("hermes") && apps.hermes, + }); + const appsFromFoundIn = (foundIn: string[]): ImportSkillSelection["apps"] => + applyVisibleAppMask({ + claude: foundIn.includes("claude"), + codex: foundIn.includes("codex"), + gemini: foundIn.includes("gemini"), + opencode: foundIn.includes("opencode"), + openclaw: false, + hermes: foundIn.includes("hermes"), + }); const [selected, setSelected] = useState>( new Set(skills.map((s) => s.directory)), ); @@ -739,17 +797,7 @@ const ImportSkillsDialog: React.FC = ({ Record >(() => Object.fromEntries( - skills.map((skill) => [ - skill.directory, - { - claude: skill.foundIn.includes("claude"), - codex: skill.foundIn.includes("codex"), - gemini: skill.foundIn.includes("gemini"), - opencode: skill.foundIn.includes("opencode"), - openclaw: false, - hermes: skill.foundIn.includes("hermes"), - }, - ]), + skills.map((skill) => [skill.directory, appsFromFoundIn(skill.foundIn)]), ), ); @@ -767,14 +815,7 @@ const ImportSkillsDialog: React.FC = ({ onImport( Array.from(selected).map((directory) => ({ directory, - apps: selectedApps[directory] ?? { - claude: false, - codex: false, - gemini: false, - opencode: false, - openclaw: false, - hermes: false, - }, + apps: applyVisibleAppMask(selectedApps[directory] ?? emptyApps()), })), ); }; @@ -809,33 +850,17 @@ const ImportSkillsDialog: React.FC = ({ )}
{ setSelectedApps((prev) => ({ ...prev, [skill.directory]: { - ...(prev[skill.directory] ?? { - claude: false, - codex: false, - gemini: false, - opencode: false, - openclaw: false, - hermes: false, - }), + ...(prev[skill.directory] ?? emptyApps()), [app]: enabled, }, })); }} - appIds={SKILLS_APP_IDS} + appIds={visibleAppIds} />
resolved[app]); +} + +export function getFirstVisibleApp(visibleApps?: VisibleApps): AppId { + return filterVisibleAppIds(APP_IDS, visibleApps)[0] ?? "claude"; +} + /** App IDs shown in Skills panels (excludes OpenClaw — it doesn't support Skills) */ export const SKILLS_APP_IDS: AppId[] = [ "claude", @@ -34,6 +74,16 @@ export const SKILLS_APP_IDS: AppId[] = [ "hermes", ]; +export function getSkillTargetApp( + currentApp: AppId, + visibleApps?: VisibleApps, +): AppId | null { + const visibleSkillAppIds = filterVisibleAppIds(SKILLS_APP_IDS, visibleApps); + return visibleSkillAppIds.includes(currentApp) + ? currentApp + : (visibleSkillAppIds[0] ?? null); +} + /** App IDs shown in MCP panels (excludes OpenClaw) */ export const MCP_APP_IDS: AppId[] = [...SKILLS_APP_IDS]; diff --git a/src/hooks/useMcp.ts b/src/hooks/useMcp.ts index ff9f505fd..62ca1fe05 100644 --- a/src/hooks/useMcp.ts +++ b/src/hooks/useMcp.ts @@ -66,7 +66,7 @@ export function useDeleteMcpServer() { export function useImportMcpFromApps() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: () => mcpApi.importFromApps(), + mutationFn: (apps?: AppId[]) => mcpApi.importFromApps(apps), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["mcp", "all"] }); }, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 52ead9983..3a04c2c2a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1856,6 +1856,7 @@ "loadFailed": "Failed to load", "installSuccess": "Skill {{name}} installed", "installFailed": "Failed to install", + "noVisibleTargetApp": "Enable Claude, Codex, Gemini, OpenCode, or Hermes before installing skills", "uninstallSuccess": "Skill {{name}} uninstalled", "uninstallFailed": "Failed to uninstall", "update": "Update", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index bad19d1ed..b4b4e8597 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1856,6 +1856,7 @@ "loadFailed": "読み込みに失敗しました", "installSuccess": "スキル {{name}} をインストールしました", "installFailed": "インストールに失敗しました", + "noVisibleTargetApp": "スキルをインストールする前に Claude、Codex、Gemini、OpenCode、Hermes のいずれかを表示してください", "uninstallSuccess": "スキル {{name}} をアンインストールしました", "uninstallFailed": "アンインストールに失敗しました", "update": "更新", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index e1ad3ef38..50fcbe6a4 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1856,6 +1856,7 @@ "loadFailed": "加载失败", "installSuccess": "技能 {{name}} 已安装", "installFailed": "安装失败", + "noVisibleTargetApp": "请先显示 Claude、Codex、Gemini、OpenCode 或 Hermes,再安装技能", "uninstallSuccess": "技能 {{name}} 已卸载", "uninstallFailed": "卸载失败", "update": "更新", diff --git a/src/lib/api/mcp.ts b/src/lib/api/mcp.ts index 983daf70e..888bae443 100644 --- a/src/lib/api/mcp.ts +++ b/src/lib/api/mcp.ts @@ -123,7 +123,7 @@ export const mcpApi = { /** * 从所有应用导入 MCP 服务器 */ - async importFromApps(): Promise { - return await invoke("import_mcp_from_apps"); + async importFromApps(apps?: AppId[]): Promise { + return await invoke("import_mcp_from_apps", { apps }); }, }; diff --git a/src/types.ts b/src/types.ts index 92958463d..1b028ae5f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -300,7 +300,7 @@ export interface Settings { // 首选语言(可选,默认中文) language?: "en" | "zh" | "ja"; - // 主页面显示的应用(默认全部显示) + // 主页面显示的应用(默认显示 Claude/Codex/Gemini/OpenCode/OpenClaw,Hermes 默认隐藏) visibleApps?: VisibleApps; // ===== 设备级目录覆盖 ===== diff --git a/tests/components/McpFormModal.test.tsx b/tests/components/McpFormModal.test.tsx index 535b99557..02a16d5d6 100644 --- a/tests/components/McpFormModal.test.tsx +++ b/tests/components/McpFormModal.test.tsx @@ -156,7 +156,7 @@ describe("McpFormModal", () => { } = props ?? {}; const onSave = overrideOnSave ?? vi.fn().mockResolvedValue(undefined); const onClose = overrideOnClose ?? vi.fn(); - render( + const renderResult = render( { {...rest} />, ); - return { onSave, onClose }; + return { onSave, onClose, ...renderResult }; }; it("应用预设后填充 ID 与配置内容", async () => { @@ -395,6 +395,117 @@ type = "stdio" expect(onSave).toHaveBeenCalledWith(); }); + it("隐藏应用不显示复选框,保存时保留隐藏应用已有状态", async () => { + const initialData: McpServer = { + id: "existing", + name: "Existing", + server: { type: "stdio", command: "old" }, + apps: { + claude: true, + codex: false, + gemini: true, + opencode: false, + openclaw: false, + hermes: false, + }, + }; + + renderForm({ + editingId: "existing", + initialData, + visibleAppIds: ["claude", "codex"], + }); + + expect( + screen.queryByLabelText("mcp.unifiedPanel.apps.gemini"), + ).not.toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText("mcp.form.jsonPlaceholder"), { + target: { value: '{"type":"stdio","command":"updated"}' }, + }); + fireEvent.click(screen.getByText("common.save")); + + await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1)); + const [entry] = upsertMock.mock.calls.at(-1) ?? []; + expect(entry.apps).toEqual({ + claude: true, + codex: false, + gemini: true, + opencode: false, + openclaw: false, + hermes: false, + }); + }); + + it("新增时隐藏应用不会被默认启用", async () => { + renderForm({ visibleAppIds: ["claude", "codex"] }); + + expect( + screen.queryByLabelText("mcp.unifiedPanel.apps.gemini"), + ).not.toBeInTheDocument(); + + fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), { + target: { value: "visible-only" }, + }); + fireEvent.change(screen.getByPlaceholderText("mcp.form.jsonPlaceholder"), { + target: { value: '{"type":"stdio","command":"run"}' }, + }); + fireEvent.click(screen.getByText("common.add")); + + await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1)); + const [entry] = upsertMock.mock.calls.at(-1) ?? []; + expect(entry.apps).toEqual({ + claude: true, + codex: true, + gemini: false, + opencode: false, + openclaw: false, + hermes: false, + }); + }); + + it("新增表单打开后应用变为隐藏时不会保存隐藏应用启用状态", async () => { + const { onSave, onClose, rerender } = renderForm({ + visibleAppIds: ["claude", "codex", "gemini"], + }); + + expect(screen.getByLabelText("mcp.unifiedPanel.apps.gemini")).toBeChecked(); + + fireEvent.change(screen.getByPlaceholderText("mcp.form.titlePlaceholder"), { + target: { value: "visible-changed" }, + }); + fireEvent.change(screen.getByPlaceholderText("mcp.form.jsonPlaceholder"), { + target: { value: '{"type":"stdio","command":"run"}' }, + }); + + rerender( + , + ); + + expect( + screen.queryByLabelText("mcp.unifiedPanel.apps.gemini"), + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText("common.add")); + + await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1)); + const [entry] = upsertMock.mock.calls.at(-1) ?? []; + expect(entry.apps).toEqual({ + claude: true, + codex: true, + gemini: false, + opencode: false, + openclaw: false, + hermes: false, + }); + }); + it("允许未选择任何应用保存配置,并保持 apps 全 false", async () => { const { onSave } = renderForm(); diff --git a/tests/components/UnifiedMcpPanel.test.tsx b/tests/components/UnifiedMcpPanel.test.tsx new file mode 100644 index 000000000..6c2fbfb8b --- /dev/null +++ b/tests/components/UnifiedMcpPanel.test.tsx @@ -0,0 +1,102 @@ +import { createRef } from "react"; +import { act, render, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi, beforeEach } from "vitest"; + +import UnifiedMcpPanel, { + type UnifiedMcpPanelHandle, +} from "@/components/mcp/UnifiedMcpPanel"; + +const importMcpMock = vi.fn(); + +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +})); + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string, params?: Record) => + params ? `${key}:${JSON.stringify(params)}` : key, + }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})); + +vi.mock("@/hooks/useMcp", () => ({ + useAllMcpServers: () => ({ + data: {}, + isLoading: false, + }), + useToggleMcpApp: () => ({ + mutateAsync: vi.fn(), + }), + useDeleteMcpServer: () => ({ + mutateAsync: vi.fn(), + }), + useImportMcpFromApps: () => ({ + mutateAsync: (...args: unknown[]) => importMcpMock(...args), + isPending: false, + }), +})); + +vi.mock("@/components/ui/button", () => ({ + Button: ({ children, onClick, type = "button", ...rest }: any) => ( + + ), +})); + +vi.mock("@/components/ui/badge", () => ({ + Badge: ({ children, ...rest }: any) => {children}, +})); + +vi.mock("@/components/ui/tooltip", () => ({ + TooltipProvider: ({ children }: any) =>
{children}
, + Tooltip: ({ children }: any) =>
{children}
, + TooltipTrigger: ({ children }: any) => <>{children}, + TooltipContent: ({ children }: any) =>
{children}
, +})); + +vi.mock("@/components/mcp/McpFormModal", () => ({ + default: () =>
, +})); + +vi.mock("@/components/ConfirmDialog", () => ({ + ConfirmDialog: () => null, +})); + +describe("UnifiedMcpPanel", () => { + beforeEach(() => { + importMcpMock.mockReset(); + importMcpMock.mockResolvedValue(0); + }); + + it("imports MCP servers only from visible apps", async () => { + const ref = createRef(); + + render( + {}} + visibleApps={{ + claude: true, + "claude-desktop": false, + codex: true, + gemini: false, + opencode: true, + openclaw: true, + hermes: false, + }} + />, + ); + + await act(async () => { + await ref.current?.openImport(); + }); + + await waitFor(() => expect(importMcpMock).toHaveBeenCalledTimes(1)); + expect(importMcpMock).toHaveBeenCalledWith(["claude", "codex", "opencode"]); + }); +}); diff --git a/tests/components/UnifiedSkillsPanel.test.tsx b/tests/components/UnifiedSkillsPanel.test.tsx index 38518ad9b..6c5339540 100644 --- a/tests/components/UnifiedSkillsPanel.test.tsx +++ b/tests/components/UnifiedSkillsPanel.test.tsx @@ -1,5 +1,11 @@ import { createRef } from "react"; -import { render, screen, waitFor, act } from "@testing-library/react"; +import { + render, + screen, + waitFor, + act, + fireEvent, +} from "@testing-library/react"; import { describe, expect, it, vi, beforeEach } from "vitest"; import UnifiedSkillsPanel, { @@ -13,12 +19,26 @@ const importSkillsMock = vi.fn(); const installFromZipMock = vi.fn(); const deleteSkillBackupMock = vi.fn(); const restoreSkillBackupMock = vi.fn(); +const openZipFileDialogMock = vi.hoisted(() => vi.fn()); +const toastErrorMock = vi.hoisted(() => vi.fn()); +const toastSuccessMock = vi.hoisted(() => vi.fn()); +const toastInfoMock = vi.hoisted(() => vi.fn()); +let unmanagedSkillsData = [ + { + directory: "shared-skill", + name: "Shared Skill", + description: "Imported from Claude", + foundIn: ["claude"], + path: "/tmp/shared-skill", + }, +]; +let skillBackupsData: any[] = []; vi.mock("sonner", () => ({ toast: { - success: vi.fn(), - error: vi.fn(), - info: vi.fn(), + success: (...args: unknown[]) => toastSuccessMock(...args), + error: (...args: unknown[]) => toastErrorMock(...args), + info: (...args: unknown[]) => toastInfoMock(...args), }, })); @@ -28,7 +48,7 @@ vi.mock("@/hooks/useSkills", () => ({ isLoading: false, }), useSkillBackups: () => ({ - data: [], + data: skillBackupsData, refetch: vi.fn(), isFetching: false, }), @@ -47,15 +67,7 @@ vi.mock("@/hooks/useSkills", () => ({ mutateAsync: uninstallSkillMock, }), useScanUnmanagedSkills: () => ({ - data: [ - { - directory: "shared-skill", - name: "Shared Skill", - description: "Imported from Claude", - foundIn: ["claude"], - path: "/tmp/shared-skill", - }, - ], + data: unmanagedSkillsData, refetch: scanUnmanagedMock, }), useImportSkillsFromApps: () => ({ @@ -75,23 +87,39 @@ vi.mock("@/hooks/useSkills", () => ({ }), })); +vi.mock("@/lib/api", () => ({ + settingsApi: { + openExternal: vi.fn(), + }, + skillsApi: { + openZipFileDialog: openZipFileDialogMock, + }, +})); + describe("UnifiedSkillsPanel", () => { beforeEach(() => { + unmanagedSkillsData = [ + { + directory: "shared-skill", + name: "Shared Skill", + description: "Imported from Claude", + foundIn: ["claude"], + path: "/tmp/shared-skill", + }, + ]; + skillBackupsData = []; scanUnmanagedMock.mockResolvedValue({ - data: [ - { - directory: "shared-skill", - name: "Shared Skill", - description: "Imported from Claude", - foundIn: ["claude"], - path: "/tmp/shared-skill", - }, - ], + data: unmanagedSkillsData, }); toggleSkillAppMock.mockReset(); uninstallSkillMock.mockReset(); importSkillsMock.mockReset(); installFromZipMock.mockReset(); + openZipFileDialogMock.mockReset(); + openZipFileDialogMock.mockResolvedValue(null); + toastErrorMock.mockReset(); + toastSuccessMock.mockReset(); + toastInfoMock.mockReset(); deleteSkillBackupMock.mockReset(); restoreSkillBackupMock.mockReset(); }); @@ -117,4 +145,244 @@ describe("UnifiedSkillsPanel", () => { expect(screen.getByText("/tmp/shared-skill")).toBeInTheDocument(); }); }); + + it("does not enable hidden apps when importing unmanaged skills", async () => { + unmanagedSkillsData = [ + { + directory: "hermes-only-skill", + name: "Hermes Only Skill", + description: "Imported from Hermes", + foundIn: ["hermes"], + path: "/tmp/hermes-only-skill", + }, + ]; + scanUnmanagedMock.mockResolvedValue({ data: unmanagedSkillsData }); + importSkillsMock.mockResolvedValue([]); + + const ref = createRef(); + + render( + {}} + currentApp="claude" + visibleApps={{ + claude: true, + "claude-desktop": false, + codex: true, + gemini: true, + opencode: true, + openclaw: true, + hermes: false, + }} + />, + ); + + await act(async () => { + await ref.current?.openImport(); + }); + + await waitFor(() => { + expect(screen.getByText("Hermes Only Skill")).toBeInTheDocument(); + expect(screen.queryByText("Hermes")).not.toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText("skills.importSelected")); + + await waitFor(() => expect(importSkillsMock).toHaveBeenCalledTimes(1)); + expect(importSkillsMock).toHaveBeenCalledWith([ + { + directory: "hermes-only-skill", + apps: { + claude: false, + codex: false, + gemini: false, + opencode: false, + openclaw: false, + hermes: false, + }, + }, + ]); + }); + + it("uses the first visible skills app for ZIP install when current app cannot host skills", async () => { + installFromZipMock.mockResolvedValue([]); + openZipFileDialogMock.mockResolvedValue("/tmp/skills.zip"); + const ref = createRef(); + + render( + {}} + currentApp="openclaw" + visibleApps={{ + claude: false, + "claude-desktop": false, + codex: true, + gemini: true, + opencode: true, + openclaw: true, + hermes: false, + }} + />, + ); + + await act(async () => { + await ref.current?.openInstallFromZip(); + }); + + await waitFor(() => expect(installFromZipMock).toHaveBeenCalledTimes(1)); + expect(installFromZipMock).toHaveBeenCalledWith({ + filePath: "/tmp/skills.zip", + currentApp: "codex", + }); + }); + + it("uses the first visible skills app for backup restore when current app cannot host skills", async () => { + skillBackupsData = [ + { + backupId: "backup-1", + backupPath: "/tmp/backup-1", + createdAt: 1, + skill: { + id: "restored-skill", + name: "Restored Skill", + directory: "restored-skill", + apps: { + claude: true, + "claude-desktop": false, + codex: false, + gemini: false, + opencode: false, + openclaw: false, + hermes: false, + }, + installedAt: 1, + updatedAt: 0, + }, + }, + ]; + restoreSkillBackupMock.mockResolvedValue({ + name: "Restored Skill", + }); + const ref = createRef(); + + render( + {}} + currentApp="claude" + visibleApps={{ + claude: false, + "claude-desktop": false, + codex: true, + gemini: true, + opencode: true, + openclaw: true, + hermes: false, + }} + />, + ); + + await act(async () => { + await ref.current?.openRestoreFromBackup(); + }); + + fireEvent.click( + await screen.findByText("skills.restoreFromBackup.restore"), + ); + + await waitFor(() => + expect(restoreSkillBackupMock).toHaveBeenCalledTimes(1), + ); + expect(restoreSkillBackupMock).toHaveBeenCalledWith({ + backupId: "backup-1", + currentApp: "codex", + }); + }); + + it("does not install from ZIP when only OpenClaw is visible", async () => { + openZipFileDialogMock.mockResolvedValue("/tmp/skills.zip"); + const ref = createRef(); + + render( + {}} + currentApp={null} + visibleApps={{ + claude: false, + "claude-desktop": false, + codex: false, + gemini: false, + opencode: false, + openclaw: true, + hermes: false, + }} + />, + ); + + await act(async () => { + await ref.current?.openInstallFromZip(); + }); + + expect(openZipFileDialogMock).not.toHaveBeenCalled(); + expect(installFromZipMock).not.toHaveBeenCalled(); + expect(toastErrorMock).toHaveBeenCalledWith("skills.noVisibleTargetApp"); + }); + + it("does not restore a backup when only OpenClaw is visible", async () => { + skillBackupsData = [ + { + backupId: "backup-1", + backupPath: "/tmp/backup-1", + createdAt: 1, + skill: { + id: "restored-skill", + name: "Restored Skill", + directory: "restored-skill", + apps: { + claude: true, + "claude-desktop": false, + codex: false, + gemini: false, + opencode: false, + openclaw: false, + hermes: false, + }, + installedAt: 1, + updatedAt: 0, + }, + }, + ]; + const ref = createRef(); + + render( + {}} + currentApp={null} + visibleApps={{ + claude: false, + "claude-desktop": false, + codex: false, + gemini: false, + opencode: false, + openclaw: true, + hermes: false, + }} + />, + ); + + await act(async () => { + await ref.current?.openRestoreFromBackup(); + }); + + fireEvent.click( + await screen.findByText("skills.restoreFromBackup.restore"), + ); + + expect(restoreSkillBackupMock).not.toHaveBeenCalled(); + expect(toastErrorMock).toHaveBeenCalledWith("skills.noVisibleTargetApp"); + }); }); diff --git a/tests/config/appConfig.test.ts b/tests/config/appConfig.test.ts new file mode 100644 index 000000000..75eb55940 --- /dev/null +++ b/tests/config/appConfig.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_VISIBLE_APPS, + EMPTY_VISIBLE_APPS, + filterVisibleAppIds, + getSkillTargetApp, + resolveVisibleApps, +} from "@/config/appConfig"; + +describe("appConfig visibility helpers", () => { + it("keeps Hermes hidden by default and hides every app before settings resolve", () => { + expect(DEFAULT_VISIBLE_APPS.hermes).toBe(false); + expect(Object.values(EMPTY_VISIBLE_APPS).every((visible) => !visible)).toBe( + true, + ); + expect(resolveVisibleApps().hermes).toBe(false); + }); + + it("filters app ids using persisted defaults after settings load", () => { + expect(filterVisibleAppIds(["claude", "hermes"], undefined)).toEqual([ + "claude", + ]); + expect( + filterVisibleAppIds(["claude", "hermes"], EMPTY_VISIBLE_APPS), + ).toEqual([]); + }); + + it("selects a visible app for skill install flows", () => { + const visibleApps = { + claude: false, + "claude-desktop": false, + codex: true, + gemini: true, + opencode: true, + openclaw: true, + hermes: false, + }; + + expect(getSkillTargetApp("openclaw", visibleApps)).toBe("codex"); + expect(getSkillTargetApp("claude", visibleApps)).toBe("codex"); + expect(getSkillTargetApp("gemini", visibleApps)).toBe("gemini"); + expect( + getSkillTargetApp("openclaw", { + claude: false, + "claude-desktop": false, + codex: false, + gemini: false, + opencode: false, + openclaw: true, + hermes: false, + }), + ).toBeNull(); + }); +});