Skip to content

Commit 191ce6b

Browse files
Adamulek123rynfar
authored andcommitted
fix(clients): dedupe skills in composer menus (#8043)
1 parent 1bd192d commit 191ce6b

5 files changed

Lines changed: 108 additions & 4 deletions

File tree

‎apps/mobile/src/features/threads/use-composer-command-menu.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
dedupeProviderSkillsByName,
23
getProviderSkillsForSlashMenu,
34
getProviderSlashCommandsForSlashMenu,
45
} from "@t3tools/client-runtime/providerSkills";
@@ -158,7 +159,9 @@ export function buildComposerCommandItems({
158159
}
159160

160161
if (trigger.kind === "skill") {
161-
const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled);
162+
const enabledSkills = dedupeProviderSkillsByName(
163+
(selectedProviderStatus?.skills ?? []).filter((s) => s.enabled),
164+
);
162165
const normalizedQuery = normalizeSearchQuery(trigger.query, {
163166
trimLeadingPattern: /^\$+/,
164167
});

‎apps/web/src/providerSkillSearch.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,4 +69,17 @@ describe("searchProviderSkills", () => {
6969
"browser",
7070
]);
7171
});
72+
73+
it("returns the first enabled definition for each skill name", () => {
74+
const skills = [
75+
makeSkill({ name: "branch-audit", path: "/Users/matt/.codex/skills/branch-audit/SKILL.md" }),
76+
makeSkill({ name: "browser" }),
77+
makeSkill({ name: "branch-audit", path: "/Users/matt/.agents/skills/branch-audit/SKILL.md" }),
78+
];
79+
80+
expect(searchProviderSkills(skills, "").map((skill) => skill.path)).toEqual([
81+
"/Users/matt/.codex/skills/branch-audit/SKILL.md",
82+
"/tmp/browser/SKILL.md",
83+
]);
84+
});
7285
});

‎apps/web/src/providerSkillSearch.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import type { ServerProviderSkill } from "@t3tools/contracts";
2-
import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
2+
import {
3+
dedupeProviderSkillsByName,
4+
formatProviderSkillDisplayName,
5+
} from "@t3tools/client-runtime/providerSkills";
36
import {
47
insertRankedSearchResult,
58
normalizeSearchQuery,
@@ -70,7 +73,7 @@ export function searchProviderSkills(
7073
query: string,
7174
limit = Number.POSITIVE_INFINITY,
7275
): ServerProviderSkill[] {
73-
const enabledSkills = skills.filter((skill) => skill.enabled);
76+
const enabledSkills = dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled));
7477
const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ });
7578

7679
if (!normalizedQuery) {

‎packages/client-runtime/src/providerSkills.test.ts‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, expect, it } from "vite-plus/test";
22

33
import {
4+
dedupeProviderSkillsByName,
45
formatProviderSkillDisplayName,
56
getProviderSlashCommandsForSlashMenu,
67
getProviderSkillsForSlashMenu,
@@ -26,6 +27,31 @@ describe("formatProviderSkillDisplayName", () => {
2627
});
2728
});
2829

30+
describe("dedupeProviderSkillsByName", () => {
31+
it("keeps the first resolved skill and preserves unrelated skill order", () => {
32+
const firstSkill = {
33+
name: "branch-audit",
34+
path: "/Users/matt/.codex/skills/branch-audit/SKILL.md",
35+
enabled: true,
36+
};
37+
const otherSkill = {
38+
name: "browser",
39+
path: "/Users/matt/.agents/skills/browser/SKILL.md",
40+
enabled: true,
41+
};
42+
const duplicateSkill = {
43+
name: "Branch-Audit",
44+
path: "/Users/matt/.agents/skills/branch-audit/SKILL.md",
45+
enabled: true,
46+
};
47+
48+
expect(dedupeProviderSkillsByName([firstSkill, otherSkill, duplicateSkill])).toEqual([
49+
firstSkill,
50+
otherSkill,
51+
]);
52+
});
53+
});
54+
2955
describe("getProviderSkillsForSlashMenu", () => {
3056
it("keeps the skill alias when the provider also exposes it as a slash command", () => {
3157
const askMatt = {
@@ -52,6 +78,49 @@ describe("getProviderSkillsForSlashMenu", () => {
5278
const skills = [{ name: "ask-matt", path: "/skills/ask-matt/SKILL.md", enabled: true }];
5379
expect(getProviderSkillsForSlashMenu(skills, false)).toEqual([]);
5480
});
81+
82+
it("shows one row when enabled skills share a name", () => {
83+
const skills = [
84+
{
85+
name: "babysit-pr",
86+
path: "/Users/matt/.codex/skills/babysit-pr/SKILL.md",
87+
enabled: true,
88+
},
89+
{
90+
name: "browser",
91+
path: "/Users/matt/.agents/skills/browser/SKILL.md",
92+
enabled: true,
93+
},
94+
{
95+
name: "babysit-pr",
96+
path: "/Users/matt/.agents/skills/babysit-pr/SKILL.md",
97+
enabled: true,
98+
},
99+
];
100+
101+
expect(getProviderSkillsForSlashMenu(skills, true).map((skill) => skill.name)).toEqual([
102+
"babysit-pr",
103+
"browser",
104+
]);
105+
});
106+
107+
it("keeps an enabled skill when a disabled duplicate appears first", () => {
108+
const enabledSkill = {
109+
name: "babysit-pr",
110+
path: "/Users/matt/.agents/skills/babysit-pr/SKILL.md",
111+
enabled: true,
112+
};
113+
const skills = [
114+
{
115+
name: "babysit-pr",
116+
path: "/Users/matt/.codex/skills/babysit-pr/SKILL.md",
117+
enabled: false,
118+
},
119+
enabledSkill,
120+
];
121+
122+
expect(getProviderSkillsForSlashMenu(skills, true)).toEqual([enabledSkill]);
123+
});
55124
});
56125

57126
describe("getProviderSlashCommandsForSlashMenu", () => {

‎packages/client-runtime/src/providerSkills.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,27 @@ export function formatProviderSkillDisplayName(
2525
return titleCaseWords(skill.name);
2626
}
2727

28+
export function dedupeProviderSkillsByName(
29+
skills: ReadonlyArray<ServerProviderSkill>,
30+
): ServerProviderSkill[] {
31+
const seenNames = new Set<string>();
32+
return skills.filter((skill) => {
33+
const normalizedName = skill.name.trim().toLowerCase();
34+
if (seenNames.has(normalizedName)) {
35+
return false;
36+
}
37+
seenNames.add(normalizedName);
38+
return true;
39+
});
40+
}
41+
2842
export function getProviderSkillsForSlashMenu(
2943
skills: ReadonlyArray<ServerProviderSkill>,
3044
showSkillsInSlashMenu: boolean,
3145
): ServerProviderSkill[] {
32-
return showSkillsInSlashMenu ? skills.filter((skill) => skill.enabled) : [];
46+
return showSkillsInSlashMenu
47+
? dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled))
48+
: [];
3349
}
3450

3551
export function getProviderSlashCommandsForSlashMenu(

0 commit comments

Comments
 (0)