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