diff --git a/src-tauri/src/proxy/providers/transform.rs b/src-tauri/src/proxy/providers/transform.rs index 5e7869cdae..72e6b0f029 100644 --- a/src-tauri/src/proxy/providers/transform.rs +++ b/src-tauri/src/proxy/providers/transform.rs @@ -661,7 +661,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_openai(input, None).unwrap(); + let result = anthropic_to_openai(input).unwrap(); assert_eq!(result["messages"][0]["role"], "system"); assert_eq!( result["messages"][0]["content"], @@ -682,7 +682,7 @@ mod tests { "messages": [{"role": "user", "content": "Hello"}] }); - let result = anthropic_to_openai(input, None).unwrap(); + let result = anthropic_to_openai(input).unwrap(); assert_eq!(result["messages"][0]["role"], "system"); assert_eq!( result["messages"][0]["content"], diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx index 701af49292..df8143033e 100644 --- a/src/components/skills/UnifiedSkillsPanel.tsx +++ b/src/components/skills/UnifiedSkillsPanel.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { + Search, Sparkles, Trash2, ExternalLink, @@ -9,10 +10,14 @@ import { } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; import { TooltipProvider } from "@/components/ui/tooltip"; import { type ImportSkillSelection, type SkillBackupEntry, + useBatchToggleSkillApp, + useBatchUninstallSkill, useDeleteSkillBackup, useInstalledSkills, useSkillBackups, @@ -31,7 +36,8 @@ import type { AppId } from "@/lib/api/types"; import { ConfirmDialog } from "@/components/ConfirmDialog"; import { settingsApi, skillsApi } from "@/lib/api"; import { toast } from "sonner"; -import { MCP_SKILLS_APP_IDS } from "@/config/appConfig"; +import { APP_ICON_MAP, MCP_SKILLS_APP_IDS } from "@/config/appConfig"; +import { cn } from "@/lib/utils"; import { AppCountBar } from "@/components/common/AppCountBar"; import { AppToggleGroup } from "@/components/common/AppToggleGroup"; import { ListItemRow } from "@/components/common/ListItemRow"; @@ -49,6 +55,8 @@ interface UnifiedSkillsPanelProps { currentApp: AppId; } +type BatchTargetApp = (typeof MCP_SKILLS_APP_IDS)[number]; + export interface UnifiedSkillsPanelHandle { openDiscovery: () => void; openImport: () => void; @@ -79,6 +87,16 @@ const UnifiedSkillsPanel = React.forwardRef< } | null>(null); const [importDialogOpen, setImportDialogOpen] = useState(false); const [restoreDialogOpen, setRestoreDialogOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [batchMode, setBatchMode] = useState(false); + const [selectedSkillIds, setSelectedSkillIds] = useState>( + new Set(), + ); + const [batchTargetApp, setBatchTargetApp] = useState(() => + MCP_SKILLS_APP_IDS.includes(currentApp as BatchTargetApp) + ? (currentApp as BatchTargetApp) + : "claude", + ); const { data: skills, isLoading } = useInstalledSkills(); const { @@ -88,7 +106,9 @@ const UnifiedSkillsPanel = React.forwardRef< } = useSkillBackups(); const deleteBackupMutation = useDeleteSkillBackup(); const toggleAppMutation = useToggleSkillApp(); + const batchToggleMutation = useBatchToggleSkillApp(); const uninstallMutation = useUninstallSkill(); + const batchUninstallMutation = useBatchUninstallSkill(); const restoreBackupMutation = useRestoreSkillBackup(); const { data: unmanagedSkills, refetch: scanUnmanaged } = useScanUnmanagedSkills(); @@ -123,6 +143,82 @@ const UnifiedSkillsPanel = React.forwardRef< return counts; }, [skills]); + const filteredSkills = useMemo(() => { + if (!skills) return []; + const query = searchQuery.trim().toLowerCase(); + if (!query) return skills; + + return skills.filter((skill) => { + const source = + skill.repoOwner && skill.repoName + ? `${skill.repoOwner}/${skill.repoName}`.toLowerCase() + : ""; + const fields = [ + skill.name?.toLowerCase() || "", + skill.description?.toLowerCase() || "", + source, + skill.directory?.toLowerCase() || "", + ]; + return fields.some((field) => field.includes(query)); + }); + }, [skills, searchQuery]); + + React.useEffect(() => { + if (!batchMode) return; + const visibleIds = new Set(filteredSkills.map((skill) => skill.id)); + setSelectedSkillIds((current) => { + let changed = false; + const next = new Set(); + current.forEach((id) => { + if (visibleIds.has(id)) { + next.add(id); + } else { + changed = true; + } + }); + return changed ? next : current; + }); + }, [batchMode, filteredSkills]); + + React.useEffect(() => { + if (MCP_SKILLS_APP_IDS.includes(currentApp as BatchTargetApp)) { + setBatchTargetApp(currentApp as BatchTargetApp); + } + }, [currentApp]); + + const selectedSkills = useMemo( + () => filteredSkills.filter((skill) => selectedSkillIds.has(skill.id)), + [filteredSkills, selectedSkillIds], + ); + + const allFilteredSelected = + filteredSkills.length > 0 && + filteredSkills.every((skill) => selectedSkillIds.has(skill.id)); + + const toggleSelectSkill = (id: string, checked: boolean) => { + setSelectedSkillIds((current) => { + const next = new Set(current); + if (checked) { + next.add(id); + } else { + next.delete(id); + } + return next; + }); + }; + + const toggleSelectAllFiltered = () => { + setSelectedSkillIds((current) => { + const next = new Set(current); + if (allFilteredSelected) { + filteredSkills.forEach((skill) => next.delete(skill.id)); + } else { + filteredSkills.forEach((skill) => next.add(skill.id)); + } + return next; + }); + }; + const handleToggleApp = async (id: string, app: AppId, enabled: boolean) => { try { await toggleAppMutation.mutateAsync({ id, app, enabled }); @@ -328,6 +424,114 @@ const UnifiedSkillsPanel = React.forwardRef< }); }; + const handleBatchToggleApp = async (enabled: boolean) => { + if (selectedSkills.length === 0 || batchToggleMutation.isPending) return; + + try { + const result = await batchToggleMutation.mutateAsync({ + items: selectedSkills.map((skill) => ({ + id: skill.id, + app: batchTargetApp, + enabled, + })), + }); + + if (result.successIds.length > 0) { + toast.success( + t( + enabled + ? "skills.batch.enableSuccess" + : "skills.batch.disableSuccess", + { + count: result.successIds.length, + appName: t(`skills.apps.${batchTargetApp}`), + }, + ), + { closeButton: true }, + ); + } + + if (result.failed.length > 0) { + toast.error( + t( + enabled + ? "skills.batch.enableFailed" + : "skills.batch.disableFailed", + { + failed: result.failed.length, + appName: t(`skills.apps.${batchTargetApp}`), + }, + ), + { + description: result.failed[0]?.error || t("common.unknown"), + }, + ); + } + } catch (error) { + toast.error(t("common.error"), { description: String(error) }); + } + }; + + const handleBatchUninstall = () => { + if (selectedSkills.length === 0 || batchUninstallMutation.isPending) return; + + setConfirmDialog({ + isOpen: true, + title: t("skills.batch.deleteConfirmTitle"), + message: t("skills.batch.deleteConfirmMessage", { + count: selectedSkills.length, + }), + confirmText: t("skills.batch.deleteConfirmAction"), + variant: "destructive", + onConfirm: async () => { + try { + const result = await batchUninstallMutation.mutateAsync({ + items: selectedSkills.map((skill) => ({ id: skill.id })), + }); + + setConfirmDialog(null); + + if (result.successIds.length > 0) { + toast.success( + t("skills.batch.deleteSuccess", { + count: result.successIds.length, + }), + { closeButton: true }, + ); + } + + if (result.failed.length > 0) { + toast.error( + t("skills.batch.deleteFailed", { failed: result.failed.length }), + { + description: result.failed[0]?.error || t("common.unknown"), + }, + ); + } + + if (result.successIds.length > 0) { + setSelectedSkillIds((current) => { + const next = new Set(current); + result.successIds.forEach((id) => next.delete(id)); + return next; + }); + } + } catch (error) { + toast.error(t("common.error"), { description: String(error) }); + } + }, + }); + }; + + const handleToggleBatchMode = () => { + setBatchMode((prev) => { + if (prev) { + setSelectedSkillIds(new Set()); + } + return !prev; + }); + }; + React.useImperativeHandle(ref, () => ({ openDiscovery: onOpenDiscovery, openImport: handleOpenImport, @@ -391,6 +595,155 @@ const UnifiedSkillsPanel = React.forwardRef< + {!isLoading && skills && skills.length > 0 && ( +
+
+
+ + setSearchQuery(e.target.value)} + className="pl-9 pr-3" + /> +
+ +
+ +
+ {t("skills.batch.filteredCount", { + count: filteredSkills.length, + total: skills.length, + })} +
+ + {batchMode && ( +
+
+ + {t("skills.batch.selectedCount", { + count: selectedSkills.length, + })} + +
+ +
+ + {t("skills.batch.targetApp")} + + {MCP_SKILLS_APP_IDS.map((app) => ( + + ))} +
+ +
+ {filteredSkills.length > 0 && ( + + )} + + + + + + + + +
+
+ )} +
+ )} +
{isLoading ? (
@@ -408,10 +761,14 @@ const UnifiedSkillsPanel = React.forwardRef< {t("skills.noInstalledDescription")}

+ ) : filteredSkills.length === 0 ? ( +
+ {t("skills.noResults")} +
) : (
- {skills.map((skill, index) => ( + {filteredSkills.map((skill, index) => ( handleUninstall(skill)} onUpdate={() => handleUpdateSkill(skill)} - isLast={index === skills.length - 1} + selectionMode={batchMode} + checked={selectedSkillIds.has(skill.id)} + onToggleChecked={(checked) => + toggleSelectSkill(skill.id, checked) + } + disableActions={ + batchMode || + batchToggleMutation.isPending || + batchUninstallMutation.isPending + } + isLast={index === filteredSkills.length - 1} /> ))}
@@ -475,6 +842,10 @@ interface InstalledSkillListItemProps { onToggleApp: (id: string, app: AppId, enabled: boolean) => void; onUninstall: () => void; onUpdate?: () => void; + selectionMode?: boolean; + checked?: boolean; + onToggleChecked?: (checked: boolean) => void; + disableActions?: boolean; isLast?: boolean; } @@ -485,6 +856,10 @@ const InstalledSkillListItem: React.FC = ({ onToggleApp, onUninstall, onUpdate, + selectionMode, + checked = false, + onToggleChecked, + disableActions, isLast, }) => { const { t } = useTranslation(); @@ -507,6 +882,16 @@ const InstalledSkillListItem: React.FC = ({ return ( + {selectionMode && ( + + onToggleChecked?.(Boolean(nextChecked)) + } + aria-label={t("skills.batch.selectItem", { name: skill.name })} + /> + )} +
@@ -545,7 +930,10 @@ const InstalledSkillListItem: React.FC = ({ onToggleApp(skill.id, app, enabled)} + onToggle={(app, enabled) => { + if (disableActions) return; + onToggleApp(skill.id, app, enabled); + }} appIds={MCP_SKILLS_APP_IDS} /> @@ -576,6 +964,7 @@ const InstalledSkillListItem: React.FC = ({ size="icon" className="h-7 w-7 hover:text-red-500 hover:bg-red-100 dark:hover:text-red-400 dark:hover:bg-red-500/10" onClick={onUninstall} + disabled={disableActions} title={t("skills.uninstall")} > diff --git a/src/hooks/useSkills.ts b/src/hooks/useSkills.ts index ea641f0996..4129df2b7b 100644 --- a/src/hooks/useSkills.ts +++ b/src/hooks/useSkills.ts @@ -15,6 +15,16 @@ import { } from "@/lib/api/skills"; import type { AppId } from "@/lib/api/types"; +export interface BatchOperationFailure { + id: string; + error: string; +} + +export interface BatchOperationResult { + successIds: string[]; + failed: BatchOperationFailure[]; +} + /** * 查询所有已安装的 Skills * 使用 staleTime: Infinity 和 placeholderData: keepPreviousData @@ -184,6 +194,41 @@ export function useToggleSkillApp() { }); } +/** + * 批量切换 Skill 在特定应用的启用状态 + * 遇错继续执行,返回成功/失败汇总 + */ +export function useBatchToggleSkillApp() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + items, + }: { + items: Array<{ id: string; app: AppId; enabled: boolean }>; + }): Promise => { + const successIds: string[] = []; + const failed: BatchOperationFailure[] = []; + + for (const item of items) { + try { + await skillsApi.toggleApp(item.id, item.app, item.enabled); + successIds.push(item.id); + } catch (error) { + failed.push({ + id: item.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return { successIds, failed }; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }); + }, + }); +} + /** * 扫描未管理的 Skills */ @@ -327,6 +372,42 @@ export function useUpdateSkill() { }); } +/** + * 批量卸载 Skill + * 遇错继续执行,返回成功/失败汇总 + */ +export function useBatchUninstallSkill() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ + items, + }: { + items: Array<{ id: string }>; + }): Promise => { + const successIds: string[] = []; + const failed: BatchOperationFailure[] = []; + + for (const item of items) { + try { + await skillsApi.uninstallUnified(item.id); + successIds.push(item.id); + } catch (error) { + failed.push({ + id: item.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + return { successIds, failed }; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["skills", "installed"] }); + queryClient.invalidateQueries({ queryKey: ["skills", "discoverable"] }); + }, + }); +} + // ========== skills.sh 搜索 ========== /** diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 97f447a6bb..cef537b5b7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -1802,6 +1802,35 @@ "opencode": "OpenCode", "openclaw": "OpenClaw" }, + "batch": { + "enterMode": "Batch Manage", + "exitMode": "Exit Batch", + "searchInstalledPlaceholder": "Search installed skills (name/description/repo/directory)", + "filteredCount": "Showing {{count}} / {{total}}", + "selectedCount": "{{count}} selected", + "selectAllFiltered": "Select Current", + "clearFilteredSelection": "Clear Current", + "clearSelection": "Clear Selection", + "enableSelected": "Batch Enable", + "disableSelected": "Batch Disable", + "enableSelectedFor": "Enable to {{appName}}", + "disableSelectedFor": "Disable in {{appName}}", + "targetApp": "Target app:", + "targetAppItem": "Target app {{appName}}", + "deleteSelected": "Batch Delete", + "processing": "Processing...", + "deleting": "Deleting...", + "enableSuccess": "Enabled {{count}} skills in {{appName}}", + "disableSuccess": "Disabled {{count}} skills in {{appName}}", + "enableFailed": "{{failed}} skills failed to enable in {{appName}}", + "disableFailed": "{{failed}} skills failed to disable in {{appName}}", + "deleteSuccess": "Deleted {{count}} skills", + "deleteFailed": "{{failed}} skills failed to delete", + "deleteConfirmTitle": "Batch Delete Skills", + "deleteConfirmMessage": "This will permanently delete {{count}} selected skills and create local backups automatically.\n\nThis action cannot be undone.", + "deleteConfirmAction": "Delete Selected Skills", + "selectItem": "Select skill {{name}}" + }, "installFromZip": { "button": "Install from ZIP", "installing": "Installing...", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 715d5697e8..f259c5f9a8 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -1802,6 +1802,35 @@ "opencode": "OpenCode", "openclaw": "OpenClaw" }, + "batch": { + "enterMode": "一括管理", + "exitMode": "一括管理を終了", + "searchInstalledPlaceholder": "インストール済みスキルを検索(名前/説明/リポジトリ/ディレクトリ)", + "filteredCount": "{{total}} 件中 {{count}} 件を表示", + "selectedCount": "{{count}} 件を選択中", + "selectAllFiltered": "現在を全選択", + "clearFilteredSelection": "現在の選択を解除", + "clearSelection": "選択をクリア", + "enableSelected": "一括有効化", + "disableSelected": "一括無効化", + "enableSelectedFor": "{{appName}} に一括有効化", + "disableSelectedFor": "{{appName}} で一括無効化", + "targetApp": "対象アプリ:", + "targetAppItem": "対象アプリ {{appName}}", + "deleteSelected": "一括削除", + "processing": "処理中...", + "deleting": "削除中...", + "enableSuccess": "{{appName}} で {{count}} 件のスキルを有効化しました", + "disableSuccess": "{{appName}} で {{count}} 件のスキルを無効化しました", + "enableFailed": "{{appName}} で {{failed}} 件のスキルの有効化に失敗しました", + "disableFailed": "{{appName}} で {{failed}} 件のスキルの無効化に失敗しました", + "deleteSuccess": "{{count}} 件のスキルを削除しました", + "deleteFailed": "{{failed}} 件のスキルの削除に失敗しました", + "deleteConfirmTitle": "スキルを一括削除", + "deleteConfirmMessage": "選択した {{count}} 件のスキルを完全に削除し、ローカルバックアップを自動作成します。\n\nこの操作は元に戻せません。", + "deleteConfirmAction": "選択したスキルを削除", + "selectItem": "スキル {{name}} を選択" + }, "installFromZip": { "button": "ZIP からインストール", "installing": "インストール中...", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 7cd0d25163..45ea25779d 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -1803,6 +1803,35 @@ "opencode": "OpenCode", "openclaw": "OpenClaw" }, + "batch": { + "enterMode": "批量管理", + "exitMode": "退出批量管理", + "searchInstalledPlaceholder": "搜索已安装技能(名称/描述/仓库/目录)", + "filteredCount": "显示 {{count}} / {{total}} 项", + "selectedCount": "已选 {{count}} 项", + "selectAllFiltered": "全选当前", + "clearFilteredSelection": "取消全选", + "clearSelection": "清空已选", + "enableSelected": "批量启用", + "disableSelected": "批量禁用", + "enableSelectedFor": "批量启用到 {{appName}}", + "disableSelectedFor": "批量禁用到 {{appName}}", + "targetApp": "目标应用:", + "targetAppItem": "目标应用 {{appName}}", + "deleteSelected": "批量删除", + "processing": "处理中...", + "deleting": "删除中...", + "enableSuccess": "已在 {{appName}} 启用 {{count}} 个技能", + "disableSuccess": "已在 {{appName}} 禁用 {{count}} 个技能", + "enableFailed": "{{failed}} 个技能在 {{appName}} 启用失败", + "disableFailed": "{{failed}} 个技能在 {{appName}} 禁用失败", + "deleteSuccess": "已删除 {{count}} 个技能", + "deleteFailed": "{{failed}} 个技能删除失败", + "deleteConfirmTitle": "批量删除技能", + "deleteConfirmMessage": "将永久删除已选中的 {{count}} 个技能,并自动创建本地备份。\n\n此操作不可恢复。", + "deleteConfirmAction": "删除所选技能", + "selectItem": "选择技能 {{name}}" + }, "installFromZip": { "button": "从 ZIP 安装", "installing": "安装中...", diff --git a/tests/components/UnifiedSkillsPanel.test.tsx b/tests/components/UnifiedSkillsPanel.test.tsx index 38518ad9b2..b5349c0d80 100644 --- a/tests/components/UnifiedSkillsPanel.test.tsx +++ b/tests/components/UnifiedSkillsPanel.test.tsx @@ -1,14 +1,41 @@ import { createRef } from "react"; -import { render, screen, waitFor, act } from "@testing-library/react"; +import { + render, + screen, + waitFor, + act, + fireEvent, + within, +} from "@testing-library/react"; import { describe, expect, it, vi, beforeEach } from "vitest"; import UnifiedSkillsPanel, { type UnifiedSkillsPanelHandle, } from "@/components/skills/UnifiedSkillsPanel"; +const installedSkillsDataMock: Array<{ + id: string; + name: string; + description?: string; + directory: string; + repoOwner?: string; + repoName?: string; + repoBranch?: string; + readmeUrl?: string; + apps: { + claude: boolean; + codex: boolean; + gemini: boolean; + opencode: boolean; + openclaw: boolean; + }; + installedAt: number; +}> = []; const scanUnmanagedMock = vi.fn(); const toggleSkillAppMock = vi.fn(); const uninstallSkillMock = vi.fn(); +const batchToggleSkillAppMock = vi.fn(); +const batchUninstallSkillMock = vi.fn(); const importSkillsMock = vi.fn(); const installFromZipMock = vi.fn(); const deleteSkillBackupMock = vi.fn(); @@ -24,7 +51,7 @@ vi.mock("sonner", () => ({ vi.mock("@/hooks/useSkills", () => ({ useInstalledSkills: () => ({ - data: [], + data: installedSkillsDataMock, isLoading: false, }), useSkillBackups: () => ({ @@ -39,6 +66,10 @@ vi.mock("@/hooks/useSkills", () => ({ useToggleSkillApp: () => ({ mutateAsync: toggleSkillAppMock, }), + useBatchToggleSkillApp: () => ({ + mutateAsync: batchToggleSkillAppMock, + isPending: false, + }), useRestoreSkillBackup: () => ({ mutateAsync: restoreSkillBackupMock, isPending: false, @@ -46,6 +77,10 @@ vi.mock("@/hooks/useSkills", () => ({ useUninstallSkill: () => ({ mutateAsync: uninstallSkillMock, }), + useBatchUninstallSkill: () => ({ + mutateAsync: batchUninstallSkillMock, + isPending: false, + }), useScanUnmanagedSkills: () => ({ data: [ { @@ -77,6 +112,7 @@ vi.mock("@/hooks/useSkills", () => ({ describe("UnifiedSkillsPanel", () => { beforeEach(() => { + installedSkillsDataMock.splice(0, installedSkillsDataMock.length); scanUnmanagedMock.mockResolvedValue({ data: [ { @@ -90,6 +126,14 @@ describe("UnifiedSkillsPanel", () => { }); toggleSkillAppMock.mockReset(); uninstallSkillMock.mockReset(); + batchToggleSkillAppMock.mockResolvedValue({ + successIds: [], + failed: [], + }); + batchUninstallSkillMock.mockResolvedValue({ + successIds: [], + failed: [], + }); importSkillsMock.mockReset(); installFromZipMock.mockReset(); deleteSkillBackupMock.mockReset(); @@ -117,4 +161,177 @@ describe("UnifiedSkillsPanel", () => { expect(screen.getByText("/tmp/shared-skill")).toBeInTheDocument(); }); }); + + it("filters installed skills by search query", async () => { + installedSkillsDataMock.push( + { + id: "owner/repo:alpha", + name: "Alpha Skill", + description: "Handle alpha workflow", + directory: "alpha-skill", + repoOwner: "owner", + repoName: "repo", + repoBranch: "main", + apps: { + claude: true, + codex: false, + gemini: false, + opencode: false, + openclaw: false, + }, + installedAt: Date.now(), + }, + { + id: "local:beta", + name: "Beta Toolkit", + description: "Local beta helper", + directory: "beta-toolkit", + apps: { + claude: false, + codex: true, + gemini: false, + opencode: false, + openclaw: false, + }, + installedAt: Date.now(), + }, + ); + + render( + {}} currentApp="claude" />, + ); + + expect(screen.getByText("Alpha Skill")).toBeInTheDocument(); + expect(screen.getByText("Beta Toolkit")).toBeInTheDocument(); + + fireEvent.change( + screen.getByPlaceholderText("skills.batch.searchInstalledPlaceholder"), + { + target: { value: "beta-toolkit" }, + }, + ); + + await waitFor(() => { + expect(screen.queryByText("Alpha Skill")).not.toBeInTheDocument(); + expect(screen.getByText("Beta Toolkit")).toBeInTheDocument(); + }); + }); + + it("selects only filtered skills in batch mode", async () => { + installedSkillsDataMock.push( + { + id: "owner/repo:alpha", + name: "Alpha Skill", + description: "Handle alpha workflow", + directory: "alpha-skill", + repoOwner: "owner", + repoName: "repo", + repoBranch: "main", + apps: { + claude: true, + codex: false, + gemini: false, + opencode: false, + openclaw: false, + }, + installedAt: Date.now(), + }, + { + id: "owner/repo:beta", + name: "Beta Skill", + description: "Handle beta workflow", + directory: "beta-skill", + repoOwner: "owner", + repoName: "repo", + repoBranch: "main", + apps: { + claude: false, + codex: true, + gemini: false, + opencode: false, + openclaw: false, + }, + installedAt: Date.now(), + }, + ); + + render( + {}} currentApp="claude" />, + ); + + fireEvent.click( + screen.getByRole("button", { name: "skills.batch.enterMode" }), + ); + + fireEvent.change( + screen.getByPlaceholderText("skills.batch.searchInstalledPlaceholder"), + { + target: { value: "beta" }, + }, + ); + + await waitFor(() => { + expect(screen.queryByText("Alpha Skill")).not.toBeInTheDocument(); + expect(screen.getByText("Beta Skill")).toBeInTheDocument(); + }); + + fireEvent.click( + screen.getByRole("button", { name: "skills.batch.selectAllFiltered" }), + ); + + expect(screen.getByText("skills.batch.selectedCount")).toBeInTheDocument(); + + const listContainer = screen.getByText("Beta Skill").closest(".group"); + expect(listContainer).not.toBeNull(); + const checkbox = within(listContainer as HTMLElement).getByRole("checkbox"); + expect(checkbox).toHaveAttribute("data-state", "checked"); + }); + + it("applies batch toggle to the selected target app instead of defaulting to claude", async () => { + installedSkillsDataMock.push({ + id: "owner/repo:alpha", + name: "Alpha Skill", + description: "Handle alpha workflow", + directory: "alpha-skill", + repoOwner: "owner", + repoName: "repo", + repoBranch: "main", + apps: { + claude: false, + codex: false, + gemini: false, + opencode: false, + openclaw: false, + }, + installedAt: Date.now(), + }); + + render( + {}} currentApp="openclaw" />, + ); + + fireEvent.click( + screen.getByRole("button", { name: "skills.batch.enterMode" }), + ); + fireEvent.click( + screen.getByRole("button", { name: "skills.batch.selectAllFiltered" }), + ); + + fireEvent.click(screen.getByTestId("skills-batch-target-opencode")); + fireEvent.click( + screen.getByRole("button", { name: "skills.batch.enableSelectedFor" }), + ); + + await waitFor(() => { + expect(batchToggleSkillAppMock).toHaveBeenCalledWith({ + items: [ + { + id: "owner/repo:alpha", + app: "opencode", + enabled: true, + }, + ], + }); + }); + }); });