| 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,6 +9,10 @@ import { |
| 9 | normalizeSearchQuery, | 9 | normalizeSearchQuery, |
| 10 | scoreQueryMatch, | 10 | scoreQueryMatch, |
| 11 | } from "@t3tools/shared/searchRanking"; | 11 | } from "@t3tools/shared/searchRanking"; |
| 12 | import { | ||
| 13 | dedupeProviderSkillsByName, | ||
| 14 | getProviderSkillsForSlashMenu, | ||
| 15 | } from "@t3tools/client-runtime/providerSkills"; | ||
| 12 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; | 16 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; |
| 13 | 17 | ||
| 14 | import type { ComposerEditorSelection } from "../../components/ComposerEditor"; | 18 | import type { ComposerEditorSelection } from "../../components/ComposerEditor"; |
| ... | @@ -130,7 +134,7 @@ export function useComposerCommandMenu({ | ... | @@ -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 | .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 | } |
| 145 | 149 | ||
| 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 @@ |
| 1 | import type { ServerProviderSkill } from "@t3tools/contracts"; | 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 | import { | 6 | import { |
| 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: /^\$+/ }); |
| 75 | 78 | ||
| 76 | if (!normalizedQuery) { | 79 | if (!normalizedQuery) { |
packages/client-runtime/src/providerSkills.test.ts+69| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import { describe, expect, it } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | import { | 3 | import { |
| 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 | }); |
| 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 | describe("getProviderSkillsForSlashMenu", () => { | 55 | describe("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 | }); |
| 41 | 110 | ||
| 42 | describe("getProviderSlashCommandsForSlashMenu", () => { | 111 | describe("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 | } |
| 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 | export function getProviderSkillsForSlashMenu( | 42 | export 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 | } |
| 34 | 50 | ||
| 35 | export function getProviderSlashCommandsForSlashMenu( | 51 | export function getProviderSlashCommandsForSlashMenu( |