diff --git a/src/components/skills/UnifiedSkillsPanel.tsx b/src/components/skills/UnifiedSkillsPanel.tsx index 77199ef17..215e3cbca 100644 --- a/src/components/skills/UnifiedSkillsPanel.tsx +++ b/src/components/skills/UnifiedSkillsPanel.tsx @@ -454,6 +454,7 @@ const UnifiedSkillsPanel = React.forwardRef< {importDialogOpen && unmanagedSkills && ( setImportDialogOpen(false)} /> @@ -600,6 +601,7 @@ interface ImportSkillsDialogProps { foundIn: string[]; path: string; }>; + isImporting: boolean; onImport: (imports: ImportSkillSelection[]) => void; onClose: () => void; } @@ -724,6 +726,7 @@ const RestoreSkillsDialog: React.FC = ({ const ImportSkillsDialog: React.FC = ({ skills, + isImporting, onImport, onClose, }) => { @@ -846,10 +849,13 @@ const ImportSkillsDialog: React.FC = ({
- -
diff --git a/src/hooks/useSkills.helpers.ts b/src/hooks/useSkills.helpers.ts new file mode 100644 index 000000000..01c7e011c --- /dev/null +++ b/src/hooks/useSkills.helpers.ts @@ -0,0 +1,19 @@ +import type { InstalledSkill } from "@/lib/api/skills"; + +/** + * 合并本次导入结果到已安装缓存,按 id 去重。 + * + * 同一技能重复出现时以新记录为准,避免 mutation 被重复触发时 + * 在 installed 列表中看到重复条目。imported 为空时返回原引用, + * 让 React Query 跳过无谓的订阅者通知。 + */ +export function mergeImportedSkills( + existing: InstalledSkill[] | undefined, + imported: InstalledSkill[], +): InstalledSkill[] { + if (!existing) return imported; + if (imported.length === 0) return existing; + const importedIds = new Set(imported.map((s) => s.id)); + const preserved = existing.filter((s) => !importedIds.has(s.id)); + return [...preserved, ...imported]; +} diff --git a/src/hooks/useSkills.ts b/src/hooks/useSkills.ts index ea641f099..095f14328 100644 --- a/src/hooks/useSkills.ts +++ b/src/hooks/useSkills.ts @@ -14,6 +14,7 @@ import { type SkillsShSearchResult, } from "@/lib/api/skills"; import type { AppId } from "@/lib/api/types"; +import { mergeImportedSkills } from "@/hooks/useSkills.helpers"; /** * 查询所有已安装的 Skills @@ -208,10 +209,7 @@ export function useImportSkillsFromApps() { // 直接更新 installed 缓存 queryClient.setQueryData( ["skills", "installed"], - (oldData) => { - if (!oldData) return importedSkills; - return [...oldData, ...importedSkills]; - }, + (oldData) => mergeImportedSkills(oldData, importedSkills), ); // 刷新 unmanaged 列表(已被导入的应该移除) queryClient.invalidateQueries({ queryKey: ["skills", "unmanaged"] }); diff --git a/tests/hooks/useImportSkillsFromApps.test.tsx b/tests/hooks/useImportSkillsFromApps.test.tsx new file mode 100644 index 000000000..27eea0875 --- /dev/null +++ b/tests/hooks/useImportSkillsFromApps.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { mergeImportedSkills } from "@/hooks/useSkills.helpers"; +import type { InstalledSkill } from "@/lib/api/skills"; + +function makeSkill(overrides: Partial = {}): InstalledSkill { + return { + id: "skill-a", + name: "Skill A", + directory: "skill-a", + apps: { + claude: true, + codex: false, + gemini: false, + opencode: false, + openclaw: false, + }, + installedAt: 0, + updatedAt: 0, + ...overrides, + }; +} + +// Regression coverage for issue #2139: when a user double-clicks the import +// button (or the mutation otherwise fires twice with the same payload), the +// installed cache must not accumulate duplicate entries for the same skill. +describe("mergeImportedSkills", () => { + it("returns the imported list as-is when no cache exists yet", () => { + const imported = [makeSkill()]; + expect(mergeImportedSkills(undefined, imported)).toEqual(imported); + }); + + it("dedupes by id when the same skill is imported twice in a row", () => { + const existing = [makeSkill()]; + const secondImport = [makeSkill()]; + const merged = mergeImportedSkills(existing, secondImport); + expect(merged).toHaveLength(1); + expect(merged[0]).toBe(secondImport[0]); + }); + + it("replaces stale cache entries with fresh imports for the same id", () => { + const stale = [makeSkill({ name: "Stale Name" })]; + const fresh = [makeSkill({ name: "Fresh Name" })]; + const merged = mergeImportedSkills(stale, fresh); + expect(merged).toHaveLength(1); + expect(merged[0].name).toBe("Fresh Name"); + }); + + it("returns the existing reference unchanged when the imported list is empty", () => { + const existing = [makeSkill()]; + expect(mergeImportedSkills(existing, [])).toBe(existing); + }); + + it("appends newly imported skills without dropping existing unrelated ones", () => { + const existing = [makeSkill({ id: "skill-a", directory: "skill-a" })]; + const imported = [ + makeSkill({ id: "skill-b", directory: "skill-b", name: "Skill B" }), + ]; + const merged = mergeImportedSkills(existing, imported); + expect(merged.map((s) => s.id).sort()).toEqual(["skill-a", "skill-b"]); + }); +});