fix: respect visible apps in skills and mcp UI

This commit is contained in:
saladday
2026-05-24 16:51:29 +08:00
parent 5315fa284b
commit 3b45b0ff48
20 changed files with 907 additions and 243 deletions

View File

@@ -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<usize, String> {
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<Vec<String>>,
) -> Result<usize, String> {
let mut apps_to_import = match apps {
Some(apps) => apps
.iter()
.map(|app| AppType::from_str(app).map_err(|e| e.to_string()))
.collect::<Result<Vec<_>, _>>()?,
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<Vec<String>>,
) -> Result<usize, String> {
import_mcp_from_selected_apps(&state, apps)
}

View File

@@ -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");

View File

@@ -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() {
<UnifiedSkillsPanel
ref={unifiedSkillsPanelRef}
onOpenDiscovery={() => setCurrentView("skillsDiscovery")}
currentApp={
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
}
currentApp={skillTargetApp}
visibleApps={uiVisibleApps}
/>
);
case "skillsDiscovery":
return (
<SkillsPage
ref={skillsPageRef}
initialApp={
sharedFeatureApp === "openclaw" ? "claude" : sharedFeatureApp
}
initialApp={skillTargetApp ?? null}
/>
);
case "mcp":
@@ -963,6 +956,7 @@ function App() {
<UnifiedMcpPanel
ref={mcpPanelRef}
onOpenChange={() => setCurrentView("providers")}
visibleApps={uiVisibleApps}
/>
);
case "agents":
@@ -1403,12 +1397,14 @@ function App() {
)}
{currentView === "providers" && (
<>
<AppSwitcher
activeApp={activeApp}
onSwitch={setActiveApp}
visibleApps={visibleApps}
compact={isToolbarCompact}
/>
{visibleApps && (
<AppSwitcher
activeApp={activeApp}
onSwitch={setActiveApp}
visibleApps={visibleApps}
compact={isToolbarCompact}
/>
)}
<div className="flex items-center gap-1 p-1 bg-muted rounded-xl">
<AnimatePresence mode="wait">

View File

@@ -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<AppId, { icon: typeof Terminal; offsetY?: number }>
@@ -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 (
<div className="inline-flex bg-muted rounded-xl p-1 gap-1">

View File

@@ -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<McpFormModalProps> = ({
@@ -43,6 +45,7 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
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<McpFormModalProps> = ({
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<McpApps>(() => {
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<AppId>(visibleAppIds),
[visibleAppIds],
);
const isEditing = !!editingId;
@@ -282,6 +297,20 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}
};
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<McpFormModalProps> = ({
id: trimmedId,
name: finalName,
server: serverSpec,
apps: enabledApps,
apps: getSubmitApps(),
};
const descriptionTrimmed = formDescription.trim();
@@ -518,85 +547,23 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
{t("mcp.form.enabledApps")}
</label>
<div className="flex flex-wrap gap-4">
<div className="flex items-center gap-2">
<Checkbox
id="enable-claude"
checked={enabledApps.claude}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, claude: checked })
}
/>
<label
htmlFor="enable-claude"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.claude")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="enable-codex"
checked={enabledApps.codex}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, codex: checked })
}
/>
<label
htmlFor="enable-codex"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.codex")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="enable-gemini"
checked={enabledApps.gemini}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, gemini: checked })
}
/>
<label
htmlFor="enable-gemini"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.gemini")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="enable-opencode"
checked={enabledApps.opencode}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, opencode: checked })
}
/>
<label
htmlFor="enable-opencode"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.opencode")}
</label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="enable-hermes"
checked={enabledApps.hermes}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, hermes: checked })
}
/>
<label
htmlFor="enable-hermes"
className="text-sm text-foreground cursor-pointer select-none"
>
{t("mcp.unifiedPanel.apps.hermes")}
</label>
</div>
{enabledAppOptions.map((app) => (
<div key={app} className="flex items-center gap-2">
<Checkbox
id={`enable-${app}`}
checked={enabledApps[app]}
onCheckedChange={(checked: boolean) =>
setEnabledApps({ ...enabledApps, [app]: checked })
}
/>
<label
htmlFor={`enable-${app}`}
className="text-sm text-foreground cursor-pointer select-none"
>
{t(`mcp.unifiedPanel.apps.${app}`)}
</label>
</div>
))}
</div>
</div>

View File

@@ -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<string | null>(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<
<AppCountBar
totalLabel={t("mcp.serverCount", { count: serverEntries.length })}
counts={enabledCounts}
appIds={MCP_APP_IDS}
appIds={visibleMcpAppIds}
/>
<div className="flex-1 overflow-y-auto overflow-x-hidden pb-24">
@@ -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<UnifiedMcpListItemProps> = ({
onToggleApp,
onEdit,
onDelete,
visibleAppIds,
isLast,
}) => {
const { t } = useTranslation();
@@ -286,7 +295,7 @@ const UnifiedMcpListItem: React.FC<UnifiedMcpListItemProps> = ({
<AppToggleGroup
apps={server.apps}
onToggle={(app, enabled) => onToggleApp(id, app, enabled)}
appIds={MCP_APP_IDS}
appIds={visibleAppIds}
/>
<div className="flex items-center gap-0.5 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">

View File

@@ -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;

View File

@@ -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<SkillsPageHandle, SkillsPageProps>(
return;
}
if (!currentApp) {
toast.error(t("skills.noVisibleTargetApp"));
return;
}
try {
await installMutation.mutateAsync({
skill,

View File

@@ -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<string, SkillUpdateInfo> = {};
@@ -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<
<AppCountBar
totalLabel={t("skills.installed", { count: skills?.length || 0 })}
counts={enabledCounts}
appIds={SKILLS_APP_IDS}
appIds={visibleSkillAppIds}
/>
<div className="flex items-center gap-1.5">
<div
@@ -431,6 +455,7 @@ const UnifiedSkillsPanel = React.forwardRef<
onToggleApp={handleToggleApp}
onUninstall={() => 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<InstalledSkillListItemProps> = ({
onToggleApp,
onUninstall,
onUpdate,
visibleAppIds,
isLast,
}) => {
const { t } = useTranslation();
@@ -555,7 +583,7 @@ const InstalledSkillListItem: React.FC<InstalledSkillListItemProps> = ({
<AppToggleGroup
apps={skill.apps}
onToggle={(app, enabled) => onToggleApp(skill.id, app, enabled)}
appIds={SKILLS_APP_IDS}
appIds={visibleAppIds}
/>
<div
@@ -605,6 +633,7 @@ interface ImportSkillsDialogProps {
isImporting: boolean;
onImport: (imports: ImportSkillSelection[]) => void;
onClose: () => void;
visibleAppIds: AppId[];
}
interface RestoreSkillsDialogProps {
@@ -730,8 +759,37 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
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<Set<string>>(
new Set(skills.map((s) => s.directory)),
);
@@ -739,17 +797,7 @@ const ImportSkillsDialog: React.FC<ImportSkillsDialogProps> = ({
Record<string, ImportSkillSelection["apps"]>
>(() =>
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<ImportSkillsDialogProps> = ({
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<ImportSkillsDialogProps> = ({
)}
<div className="mt-2">
<AppToggleGroup
apps={
selectedApps[skill.directory] ?? {
claude: false,
codex: false,
gemini: false,
opencode: false,
openclaw: false,
hermes: false,
}
}
apps={selectedApps[skill.directory] ?? emptyApps()}
onToggle={(app, enabled) => {
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}
/>
</div>
<div

View File

@@ -1,5 +1,6 @@
import React from "react";
import type { AppId } from "@/lib/api/types";
import type { VisibleApps } from "@/types";
import {
ClaudeIcon,
CodexIcon,
@@ -25,6 +26,45 @@ export const APP_IDS: AppId[] = [
"hermes",
];
export const DEFAULT_VISIBLE_APPS: VisibleApps = {
claude: true,
"claude-desktop": true,
codex: true,
gemini: true,
opencode: true,
openclaw: true,
hermes: false,
};
export const EMPTY_VISIBLE_APPS: VisibleApps = {
claude: false,
"claude-desktop": false,
codex: false,
gemini: false,
opencode: false,
openclaw: false,
hermes: false,
};
export function resolveVisibleApps(visibleApps?: VisibleApps): VisibleApps {
return {
...DEFAULT_VISIBLE_APPS,
...visibleApps,
};
}
export function filterVisibleAppIds(
appIds: readonly AppId[],
visibleApps?: VisibleApps,
): AppId[] {
const resolved = resolveVisibleApps(visibleApps);
return appIds.filter((app) => 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];

View File

@@ -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"] });
},

View File

@@ -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",

View File

@@ -1856,6 +1856,7 @@
"loadFailed": "読み込みに失敗しました",
"installSuccess": "スキル {{name}} をインストールしました",
"installFailed": "インストールに失敗しました",
"noVisibleTargetApp": "スキルをインストールする前に Claude、Codex、Gemini、OpenCode、Hermes のいずれかを表示してください",
"uninstallSuccess": "スキル {{name}} をアンインストールしました",
"uninstallFailed": "アンインストールに失敗しました",
"update": "更新",

View File

@@ -1856,6 +1856,7 @@
"loadFailed": "加载失败",
"installSuccess": "技能 {{name}} 已安装",
"installFailed": "安装失败",
"noVisibleTargetApp": "请先显示 Claude、Codex、Gemini、OpenCode 或 Hermes再安装技能",
"uninstallSuccess": "技能 {{name}} 已卸载",
"uninstallFailed": "卸载失败",
"update": "更新",

View File

@@ -123,7 +123,7 @@ export const mcpApi = {
/**
* 从所有应用导入 MCP 服务器
*/
async importFromApps(): Promise<number> {
return await invoke("import_mcp_from_apps");
async importFromApps(apps?: AppId[]): Promise<number> {
return await invoke("import_mcp_from_apps", { apps });
},
};

View File

@@ -300,7 +300,7 @@ export interface Settings {
// 首选语言(可选,默认中文)
language?: "en" | "zh" | "ja";
// 主页面显示的应用(默认全部显示)
// 主页面显示的应用(默认显示 Claude/Codex/Gemini/OpenCode/OpenClawHermes 默认隐藏
visibleApps?: VisibleApps;
// ===== 设备级目录覆盖 =====

View File

@@ -156,7 +156,7 @@ describe("McpFormModal", () => {
} = props ?? {};
const onSave = overrideOnSave ?? vi.fn().mockResolvedValue(undefined);
const onClose = overrideOnClose ?? vi.fn();
render(
const renderResult = render(
<McpFormModal
onSave={onSave}
onClose={onClose}
@@ -165,7 +165,7 @@ describe("McpFormModal", () => {
{...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(
<McpFormModal
onSave={onSave}
onClose={onClose}
existingIds={[]}
defaultFormat="json"
visibleAppIds={["claude", "codex"]}
/>,
);
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();

View File

@@ -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<string, unknown>) =>
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) => (
<button type={type} onClick={onClick} {...rest}>
{children}
</button>
),
}));
vi.mock("@/components/ui/badge", () => ({
Badge: ({ children, ...rest }: any) => <span {...rest}>{children}</span>,
}));
vi.mock("@/components/ui/tooltip", () => ({
TooltipProvider: ({ children }: any) => <div>{children}</div>,
Tooltip: ({ children }: any) => <div>{children}</div>,
TooltipTrigger: ({ children }: any) => <>{children}</>,
TooltipContent: ({ children }: any) => <div>{children}</div>,
}));
vi.mock("@/components/mcp/McpFormModal", () => ({
default: () => <div data-testid="mcp-form" />,
}));
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<UnifiedMcpPanelHandle>();
render(
<UnifiedMcpPanel
ref={ref}
onOpenChange={() => {}}
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"]);
});
});

View File

@@ -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<UnifiedSkillsPanelHandle>();
render(
<UnifiedSkillsPanel
ref={ref}
onOpenDiscovery={() => {}}
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<UnifiedSkillsPanelHandle>();
render(
<UnifiedSkillsPanel
ref={ref}
onOpenDiscovery={() => {}}
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<UnifiedSkillsPanelHandle>();
render(
<UnifiedSkillsPanel
ref={ref}
onOpenDiscovery={() => {}}
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<UnifiedSkillsPanelHandle>();
render(
<UnifiedSkillsPanel
ref={ref}
onOpenDiscovery={() => {}}
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<UnifiedSkillsPanelHandle>();
render(
<UnifiedSkillsPanel
ref={ref}
onOpenDiscovery={() => {}}
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");
});
});

View File

@@ -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();
});
});