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 {...@@ -9,6 +9,10 @@ import {
9 normalizeSearchQuery,9 normalizeSearchQuery,
10 scoreQueryMatch,10 scoreQueryMatch,
11} from "@t3tools/shared/searchRanking";11} from "@t3tools/shared/searchRanking";
12import {
13 dedupeProviderSkillsByName,
14 getProviderSkillsForSlashMenu,
15} from "@t3tools/client-runtime/providerSkills";
12import { useCallback, useEffect, useMemo, useRef, useState } from "react";16import { useCallback, useEffect, useMemo, useRef, useState } from "react";
1317
14import type { ComposerEditorSelection } from "../../components/ComposerEditor";18import type { ComposerEditorSelection } from "../../components/ComposerEditor";
...@@ -130,7 +134,7 @@ export function useComposerCommandMenu({...@@ -130,7 +134,7 @@ export function useComposerCommandMenu({
130 });134 });
131 }135 }
132136
133 const skillItems = (selectedProviderStatus?.skills ?? [])137 const skillItems = getProviderSkillsForSlashMenu(selectedProviderStatus?.skills ?? [], true)
134 .filter((skill) => matchesSlashSkillQuery(skill, q))138 .filter((skill) => matchesSlashSkillQuery(skill, q))
135 .map((skill) => ({139 .map((skill) => ({
136 id: `skill:${skill.name}`,140 id: `skill:${skill.name}`,
...@@ -144,7 +148,9 @@ export function useComposerCommandMenu({...@@ -144,7 +148,9 @@ export function useComposerCommandMenu({
144 }148 }
145149
146 if (trigger.kind === "skill") {150 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 );
148 const normalizedQuery = normalizeSearchQuery(trigger.query, {154 const normalizedQuery = normalizeSearchQuery(trigger.query, {
149 trimLeadingPattern: /^\$+/,155 trimLeadingPattern: /^\$+/,
150 });156 });
apps/web/src/providerSkillSearch.test.ts+13
...@@ -69,4 +69,17 @@ describe("searchProviderSkills", () => {...@@ -69,4 +69,17 @@ describe("searchProviderSkills", () => {
69 "browser",69 "browser",
70 ]);70 ]);
71 });71 });
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 });
72});85});
apps/web/src/providerSkillSearch.ts+5-2
...@@ -1,5 +1,8 @@...@@ -1,5 +1,8 @@
1import type { ServerProviderSkill } from "@t3tools/contracts";1import type { ServerProviderSkill } from "@t3tools/contracts";
2import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";2import {
3 dedupeProviderSkillsByName,
4 formatProviderSkillDisplayName,
5} from "@t3tools/client-runtime/providerSkills";
3import {6import {
4 insertRankedSearchResult,7 insertRankedSearchResult,
5 normalizeSearchQuery,8 normalizeSearchQuery,
...@@ -70,7 +73,7 @@ export function searchProviderSkills(...@@ -70,7 +73,7 @@ export function searchProviderSkills(
70 query: string,73 query: string,
71 limit = Number.POSITIVE_INFINITY,74 limit = Number.POSITIVE_INFINITY,
72): ServerProviderSkill[] {75): ServerProviderSkill[] {
73 const enabledSkills = skills.filter((skill) => skill.enabled);76 const enabledSkills = dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled));
74 const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ });77 const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ });
7578
76 if (!normalizedQuery) {79 if (!normalizedQuery) {
packages/client-runtime/src/providerSkills.test.ts+69
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { describe, expect, it } from "vite-plus/test";1import { describe, expect, it } from "vite-plus/test";
22
3import {3import {
4 dedupeProviderSkillsByName,
4 formatProviderSkillDisplayName,5 formatProviderSkillDisplayName,
5 getProviderSlashCommandsForSlashMenu,6 getProviderSlashCommandsForSlashMenu,
6 getProviderSkillsForSlashMenu,7 getProviderSkillsForSlashMenu,
...@@ -26,6 +27,31 @@ describe("formatProviderSkillDisplayName", () => {...@@ -26,6 +27,31 @@ describe("formatProviderSkillDisplayName", () => {
26 });27 });
27});28});
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
29describe("getProviderSkillsForSlashMenu", () => {55describe("getProviderSkillsForSlashMenu", () => {
30 it("keeps the skill alias when the provider also exposes it as a slash command", () => {56 it("keeps the skill alias when the provider also exposes it as a slash command", () => {
31 const askMatt = {57 const askMatt = {
...@@ -37,6 +63,49 @@ describe("getProviderSkillsForSlashMenu", () => {...@@ -37,6 +63,49 @@ describe("getProviderSkillsForSlashMenu", () => {
37 "ask-matt",63 "ask-matt",
38 ]);64 ]);
39 });65 });
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 });
40});109});
41110
42describe("getProviderSlashCommandsForSlashMenu", () => {111describe("getProviderSlashCommandsForSlashMenu", () => {
packages/client-runtime/src/providerSkills.ts+17-1
...@@ -25,11 +25,27 @@ export function formatProviderSkillDisplayName(...@@ -25,11 +25,27 @@ export function formatProviderSkillDisplayName(
25 return titleCaseWords(skill.name);25 return titleCaseWords(skill.name);
26}26}
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
28export function getProviderSkillsForSlashMenu(42export function getProviderSkillsForSlashMenu(
29 skills: ReadonlyArray<ServerProviderSkill>,43 skills: ReadonlyArray<ServerProviderSkill>,
30 showSkillsInSlashMenu: boolean,44 showSkillsInSlashMenu: boolean,
31): ServerProviderSkill[] {45): ServerProviderSkill[] {
32 return showSkillsInSlashMenu ? skills.filter((skill) => skill.enabled) : [];46 return showSkillsInSlashMenu
47 ? dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled))
48 : [];
33}49}
3450
35export function getProviderSlashCommandsForSlashMenu(51export function getProviderSlashCommandsForSlashMenu(