authorgravatar for adam.bogucki2018@gmail.comAdamulek123 <adam.bogucki2018@gmail.com> 2026-09-01 12:44:36+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-01 03:44:36-07:00
log8b033de48247086c1b6c6968ce7cf33b358a1ec5
tree11ca837bc444e62916abb7e4143392b8933adb14
parentf32f9a2f41342bf8a1a109d6ebe9b70044c5311b
signature Signed by PGP key B5690EEEBB952194

fix(clients): dedupe skills in composer menus (#8043)


5 files changed, 112 insertions(+), 5 deletions(-)

apps/mobile/src/features/threads/use-composer-command-menu.ts+8-2
......@@ -9,6 +9,10 @@ import {
99 normalizeSearchQuery,
1010 scoreQueryMatch,
1111} from "@t3tools/shared/searchRanking";
12import {
13 dedupeProviderSkillsByName,
14 getProviderSkillsForSlashMenu,
15} from "@t3tools/client-runtime/providerSkills";
1216import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1317
1418import type { ComposerEditorSelection } from "../../components/ComposerEditor";
......@@ -130,7 +134,7 @@ export function useComposerCommandMenu({
130134 });
131135 }
132136
133 const skillItems = (selectedProviderStatus?.skills ?? [])
137 const skillItems = getProviderSkillsForSlashMenu(selectedProviderStatus?.skills ?? [], true)
134138 .filter((skill) => matchesSlashSkillQuery(skill, q))
135139 .map((skill) => ({
136140 id: `skill:${skill.name}`,
......@@ -144,7 +148,9 @@ export function useComposerCommandMenu({
144148 }
145149
146150 if (trigger.kind === "skill") {
147 const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((skill) => skill.enabled);
151 const enabledSkills = dedupeProviderSkillsByName(
152 (selectedProviderStatus?.skills ?? []).filter((skill) => skill.enabled),
153 );
148154 const normalizedQuery = normalizeSearchQuery(trigger.query, {
149155 trimLeadingPattern: /^\$+/,
150156 });
apps/web/src/providerSkillSearch.test.ts+13
......@@ -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+5-2
......@@ -1,5 +1,8 @@
11import type { ServerProviderSkill } from "@t3tools/contracts";
2import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
2import {
3 dedupeProviderSkillsByName,
4 formatProviderSkillDisplayName,
5} from "@t3tools/client-runtime/providerSkills";
36import {
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+69
......@@ -1,6 +1,7 @@
11import { describe, expect, it } from "vite-plus/test";
22
33import {
4 dedupeProviderSkillsByName,
45 formatProviderSkillDisplayName,
56 getProviderSlashCommandsForSlashMenu,
67 getProviderSkillsForSlashMenu,
......@@ -26,6 +27,31 @@ describe("formatProviderSkillDisplayName", () => {
2627 });
2728});
2829
30describe("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
2955describe("getProviderSkillsForSlashMenu", () => {
3056 it("keeps the skill alias when the provider also exposes it as a slash command", () => {
3157 const askMatt = {
......@@ -37,6 +63,49 @@ describe("getProviderSkillsForSlashMenu", () => {
3763 "ask-matt",
3864 ]);
3965 });
66
67 it("shows one row when enabled skills share a name", () => {
68 const skills = [
69 {
70 name: "babysit-pr",
71 path: "/Users/matt/.codex/skills/babysit-pr/SKILL.md",
72 enabled: true,
73 },
74 {
75 name: "browser",
76 path: "/Users/matt/.agents/skills/browser/SKILL.md",
77 enabled: true,
78 },
79 {
80 name: "babysit-pr",
81 path: "/Users/matt/.agents/skills/babysit-pr/SKILL.md",
82 enabled: true,
83 },
84 ];
85
86 expect(getProviderSkillsForSlashMenu(skills, true).map((skill) => skill.name)).toEqual([
87 "babysit-pr",
88 "browser",
89 ]);
90 });
91
92 it("keeps an enabled skill when a disabled duplicate appears first", () => {
93 const enabledSkill = {
94 name: "babysit-pr",
95 path: "/Users/matt/.agents/skills/babysit-pr/SKILL.md",
96 enabled: true,
97 };
98 const skills = [
99 {
100 name: "babysit-pr",
101 path: "/Users/matt/.codex/skills/babysit-pr/SKILL.md",
102 enabled: false,
103 },
104 enabledSkill,
105 ];
106
107 expect(getProviderSkillsForSlashMenu(skills, true)).toEqual([enabledSkill]);
108 });
40109});
41110
42111describe("getProviderSlashCommandsForSlashMenu", () => {
packages/client-runtime/src/providerSkills.ts+17-1
......@@ -25,11 +25,27 @@ export function formatProviderSkillDisplayName(
2525 return titleCaseWords(skill.name);
2626}
2727
28export 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
2842export 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
3551export function getProviderSlashCommandsForSlashMenu(