Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,10 @@ import {
normalizeSearchQuery,
scoreQueryMatch,
} from "@t3tools/shared/searchRanking";
import {
dedupeProviderSkillsByName,
getProviderSkillsForSlashMenu,
} from "@t3tools/client-runtime/providerSkills";
import { resolveProviderOptionDescriptors } from "../../lib/providerOptions";
import { useComposerPathSearch } from "../../state/use-composer-path-search";
import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
Expand Down Expand Up @@ -431,7 +435,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
});
}

const skillItems = (selectedProviderStatus?.skills ?? [])
const skillItems = getProviderSkillsForSlashMenu(selectedProviderStatus?.skills ?? [], true)
.filter((skill) => matchesSlashSkillQuery(skill, q))
.map((skill) => ({
id: `skill:${skill.name}`,
Expand All @@ -445,7 +449,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}

if (composerTrigger.kind === "skill") {
const enabledSkills = (selectedProviderStatus?.skills ?? []).filter((s) => s.enabled);
const enabledSkills = dedupeProviderSkillsByName(
(selectedProviderStatus?.skills ?? []).filter((skill) => skill.enabled),
);
const normalizedQuery = normalizeSearchQuery(composerTrigger.query, {
trimLeadingPattern: /^\$+/,
});
Expand Down
13 changes: 13 additions & 0 deletions apps/web/src/providerSkillSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,17 @@ describe("searchProviderSkills", () => {
"browser",
]);
});

it("returns the first enabled definition for each skill name", () => {
const skills = [
makeSkill({ name: "branch-audit", path: "/Users/matt/.codex/skills/branch-audit/SKILL.md" }),
makeSkill({ name: "browser" }),
makeSkill({ name: "branch-audit", path: "/Users/matt/.agents/skills/branch-audit/SKILL.md" }),
];

expect(searchProviderSkills(skills, "").map((skill) => skill.path)).toEqual([
"/Users/matt/.codex/skills/branch-audit/SKILL.md",
"/tmp/browser/SKILL.md",
]);
});
});
7 changes: 5 additions & 2 deletions apps/web/src/providerSkillSearch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { ServerProviderSkill } from "@t3tools/contracts";
import { formatProviderSkillDisplayName } from "@t3tools/client-runtime/providerSkills";
import {
dedupeProviderSkillsByName,
formatProviderSkillDisplayName,
} from "@t3tools/client-runtime/providerSkills";
import {
insertRankedSearchResult,
normalizeSearchQuery,
Expand Down Expand Up @@ -70,7 +73,7 @@ export function searchProviderSkills(
query: string,
limit = Number.POSITIVE_INFINITY,
): ServerProviderSkill[] {
const enabledSkills = skills.filter((skill) => skill.enabled);
const enabledSkills = dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled));
const normalizedQuery = normalizeSearchQuery(query, { trimLeadingPattern: /^\$+/ });

if (!normalizedQuery) {
Expand Down
69 changes: 69 additions & 0 deletions packages/client-runtime/src/providerSkills.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vite-plus/test";

import {
dedupeProviderSkillsByName,
formatProviderSkillDisplayName,
getProviderSlashCommandsForSlashMenu,
getProviderSkillsForSlashMenu,
Expand All @@ -26,6 +27,31 @@ describe("formatProviderSkillDisplayName", () => {
});
});

describe("dedupeProviderSkillsByName", () => {
it("keeps the first resolved skill and preserves unrelated skill order", () => {
const firstSkill = {
name: "branch-audit",
path: "/Users/matt/.codex/skills/branch-audit/SKILL.md",
enabled: true,
};
const otherSkill = {
name: "browser",
path: "/Users/matt/.agents/skills/browser/SKILL.md",
enabled: true,
};
const duplicateSkill = {
name: "Branch-Audit",
path: "/Users/matt/.agents/skills/branch-audit/SKILL.md",
enabled: true,
};

expect(dedupeProviderSkillsByName([firstSkill, otherSkill, duplicateSkill])).toEqual([
firstSkill,
otherSkill,
]);
});
});

describe("getProviderSkillsForSlashMenu", () => {
it("keeps the skill alias when the provider also exposes it as a slash command", () => {
const askMatt = {
Expand All @@ -37,6 +63,49 @@ describe("getProviderSkillsForSlashMenu", () => {
"ask-matt",
]);
});

it("shows one row when enabled skills share a name", () => {
const skills = [
{
name: "babysit-pr",
path: "/Users/matt/.codex/skills/babysit-pr/SKILL.md",
enabled: true,
},
{
name: "browser",
path: "/Users/matt/.agents/skills/browser/SKILL.md",
enabled: true,
},
{
name: "babysit-pr",
path: "/Users/matt/.agents/skills/babysit-pr/SKILL.md",
enabled: true,
},
];

expect(getProviderSkillsForSlashMenu(skills, true).map((skill) => skill.name)).toEqual([
"babysit-pr",
"browser",
]);
});

it("keeps an enabled skill when a disabled duplicate appears first", () => {
const enabledSkill = {
name: "babysit-pr",
path: "/Users/matt/.agents/skills/babysit-pr/SKILL.md",
enabled: true,
};
const skills = [
{
name: "babysit-pr",
path: "/Users/matt/.codex/skills/babysit-pr/SKILL.md",
enabled: false,
},
enabledSkill,
];

expect(getProviderSkillsForSlashMenu(skills, true)).toEqual([enabledSkill]);
});
});

describe("getProviderSlashCommandsForSlashMenu", () => {
Expand Down
18 changes: 17 additions & 1 deletion packages/client-runtime/src/providerSkills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,27 @@ export function formatProviderSkillDisplayName(
return titleCaseWords(skill.name);
}

export function dedupeProviderSkillsByName(
skills: ReadonlyArray<ServerProviderSkill>,
): ServerProviderSkill[] {
const seenNames = new Set<string>();
return skills.filter((skill) => {
const normalizedName = skill.name.trim().toLowerCase();
if (seenNames.has(normalizedName)) {
return false;
}
seenNames.add(normalizedName);
return true;
});
}

export function getProviderSkillsForSlashMenu(
skills: ReadonlyArray<ServerProviderSkill>,
showSkillsInSlashMenu: boolean,
): ServerProviderSkill[] {
return showSkillsInSlashMenu ? skills.filter((skill) => skill.enabled) : [];
return showSkillsInSlashMenu
? dedupeProviderSkillsByName(skills.filter((skill) => skill.enabled))
: [];
}

export function getProviderSlashCommandsForSlashMenu(
Expand Down
Loading