From 693b0dea30024455c0208dac692fce70255f7d50 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Mon, 24 Aug 2026 02:20:11 +0200 Subject: [PATCH 1/2] fix(clients): dedupe skills in composer menus --- .../src/features/threads/ThreadComposer.tsx | 7 ++- apps/web/src/providerSkillSearch.test.ts | 13 +++++ apps/web/src/providerSkillSearch.ts | 7 ++- .../client-runtime/src/providerSkills.test.ts | 51 +++++++++++++++++++ packages/client-runtime/src/providerSkills.ts | 18 ++++++- 5 files changed, 91 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index c771aaebcb6e..6905cb98c813 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -64,6 +64,7 @@ import { normalizeSearchQuery, scoreQueryMatch, } from "@t3tools/shared/searchRanking"; +import { dedupeProviderSkillsByName } from "@t3tools/client-runtime/providerSkills"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; @@ -431,7 +432,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }); } - const skillItems = (selectedProviderStatus?.skills ?? []) + const skillItems = dedupeProviderSkillsByName(selectedProviderStatus?.skills ?? []) .filter((skill) => matchesSlashSkillQuery(skill, q)) .map((skill) => ({ id: `skill:${skill.name}`, @@ -445,7 +446,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } if (composerTrigger.kind === "skill") { - const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled); + const enabledSkills = dedupeProviderSkillsByName( + (selectedProviderStatus?.skills ?? []).filter((skill) => skill.enabled), + ); const normalizedQuery = normalizeSearchQuery(composerTrigger.query, { trimLeadingPattern: /^\$+/, }); diff --git a/apps/web/src/providerSkillSearch.test.ts b/apps/web/src/providerSkillSearch.test.ts index dbd0304c5e0b..133f65218756 100644 --- a/apps/web/src/providerSkillSearch.test.ts +++ b/apps/web/src/providerSkillSearch.test.ts @@ -69,4 +69,17 @@ describe("searchProviderSkills", () => { "browser", ]); }); + + it("returns the first enabled definition for each skill name", () => { + const skills = [ + makeSkill({ name: "branch-audit", path: "/Users/matt/.codex/skills/branch-audit/SKILL.md" }), + makeSkill({ name: "browser" }), + makeSkill({ name: "branch-audit", path: "/Users/matt/.agents/skills/branch-audit/SKILL.md" }), + ]; + + expect(searchProviderSkills(skills, "").map((skill) => skill.path)).toEqual([ + "/Users/matt/.codex/skills/branch-audit/SKILL.md", + "/tmp/browser/SKILL.md", + ]); + }); }); diff --git a/apps/web/src/providerSkillSearch.ts b/apps/web/src/providerSkillSearch.ts index c12bec1327a4..964907365fb9 100644 --- a/apps/web/src/providerSkillSearch.ts +++ b/apps/web/src/providerSkillSearch.ts @@ -1,5 +1,8 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; -import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills"; +import { + dedupeProviderSkillsByName, + formatProviderSkillDisplayName, +} from "@t3tools/client-runtime/providerSkills"; import { insertRankedSearchResult, normalizeSearchQuery, @@ -70,7 +73,7 @@ export function searchProviderSkills( query: string, limit = Number.POSITIVE_INFINITY, ): ServerProviderSkill[] { - const enabledSkills = skills.filter((skill) => skill.enabled); + const enabledSkills = dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled)); const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ }); if (!normalizedQuery) { diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index 08c79f5d8718..f3b8d1c4ccff 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vite-plus/test"; import { + dedupeProviderSkillsByName, formatProviderSkillDisplayName, getProviderSlashCommandsForSlashMenu, getProviderSkillsForSlashMenu, @@ -26,6 +27,31 @@ describe("formatProviderSkillDisplayName", () => { }); }); +describe("dedupeProviderSkillsByName", () => { + it("keeps the first resolved skill and preserves unrelated skill order", () => { + const firstSkill = { + name: "branch-audit", + path: "/Users/matt/.codex/skills/branch-audit/SKILL.md", + enabled: true, + }; + const otherSkill = { + name: "browser", + path: "/Users/matt/.agents/skills/browser/SKILL.md", + enabled: true, + }; + const duplicateSkill = { + name: "Branch-Audit", + path: "/Users/matt/.agents/skills/branch-audit/SKILL.md", + enabled: true, + }; + + expect(dedupeProviderSkillsByName([firstSkill, otherSkill, duplicateSkill])).toEqual([ + firstSkill, + otherSkill, + ]); + }); +}); + describe("getProviderSkillsForSlashMenu", () => { it("keeps the skill alias when the provider also exposes it as a slash command", () => { const askMatt = { @@ -37,6 +63,31 @@ describe("getProviderSkillsForSlashMenu", () => { "ask-matt", ]); }); + + it("shows one row when enabled skills share a name", () => { + const skills = [ + { + name: "babysit-pr", + path: "/Users/matt/.codex/skills/babysit-pr/SKILL.md", + enabled: true, + }, + { + name: "browser", + path: "/Users/matt/.agents/skills/browser/SKILL.md", + enabled: true, + }, + { + name: "babysit-pr", + path: "/Users/matt/.agents/skills/babysit-pr/SKILL.md", + enabled: true, + }, + ]; + + expect(getProviderSkillsForSlashMenu(skills, true).map((skill) => skill.name)).toEqual([ + "babysit-pr", + "browser", + ]); + }); }); describe("getProviderSlashCommandsForSlashMenu", () => { diff --git a/packages/client-runtime/src/providerSkills.ts b/packages/client-runtime/src/providerSkills.ts index faab12799ba9..653c1dd85474 100644 --- a/packages/client-runtime/src/providerSkills.ts +++ b/packages/client-runtime/src/providerSkills.ts @@ -25,11 +25,27 @@ export function formatProviderSkillDisplayName( return titleCaseWords(skill.name); } +export function dedupeProviderSkillsByName( + skills: ReadonlyArray, +): ServerProviderSkill[] { + const seenNames = new Set(); + return skills.filter((skill) => { + const normalizedName = skill.name.trim().toLowerCase(); + if (seenNames.has(normalizedName)) { + return false; + } + seenNames.add(normalizedName); + return true; + }); +} + export function getProviderSkillsForSlashMenu( skills: ReadonlyArray, showSkillsInSlashMenu: boolean, ): ServerProviderSkill[] { - return showSkillsInSlashMenu ? skills.filter((skill) => skill.enabled) : []; + return showSkillsInSlashMenu + ? dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled)) + : []; } export function getProviderSlashCommandsForSlashMenu( From eb990522ac5197f4249f119e30b95649eafbcf76 Mon Sep 17 00:00:00 2001 From: Adamulek123 Date: Mon, 24 Aug 2026 02:26:02 +0200 Subject: [PATCH 2/2] fix(mobile): filter disabled skills before deduping --- .../src/features/threads/ThreadComposer.tsx | 7 +++++-- .../client-runtime/src/providerSkills.test.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 6905cb98c813..3e37c0ff58ac 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -64,7 +64,10 @@ import { normalizeSearchQuery, scoreQueryMatch, } from "@t3tools/shared/searchRanking"; -import { dedupeProviderSkillsByName } from "@t3tools/client-runtime/providerSkills"; +import { + dedupeProviderSkillsByName, + getProviderSkillsForSlashMenu, +} from "@t3tools/client-runtime/providerSkills"; import { resolveProviderOptionDescriptors } from "../../lib/providerOptions"; import { useComposerPathSearch } from "../../state/use-composer-path-search"; import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover"; @@ -432,7 +435,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer }); } - const skillItems = dedupeProviderSkillsByName(selectedProviderStatus?.skills ?? []) + const skillItems = getProviderSkillsForSlashMenu(selectedProviderStatus?.skills ?? [], true) .filter((skill) => matchesSlashSkillQuery(skill, q)) .map((skill) => ({ id: `skill:${skill.name}`, diff --git a/packages/client-runtime/src/providerSkills.test.ts b/packages/client-runtime/src/providerSkills.test.ts index f3b8d1c4ccff..c49b0b682293 100644 --- a/packages/client-runtime/src/providerSkills.test.ts +++ b/packages/client-runtime/src/providerSkills.test.ts @@ -88,6 +88,24 @@ describe("getProviderSkillsForSlashMenu", () => { "browser", ]); }); + + it("keeps an enabled skill when a disabled duplicate appears first", () => { + const enabledSkill = { + name: "babysit-pr", + path: "/Users/matt/.agents/skills/babysit-pr/SKILL.md", + enabled: true, + }; + const skills = [ + { + name: "babysit-pr", + path: "/Users/matt/.codex/skills/babysit-pr/SKILL.md", + enabled: false, + }, + enabledSkill, + ]; + + expect(getProviderSkillsForSlashMenu(skills, true)).toEqual([enabledSkill]); + }); }); describe("getProviderSlashCommandsForSlashMenu", () => {