diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 1cbcb1e30..4541ef0bf 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -309,6 +309,16 @@ interface SlashPaletteCommand { recent: boolean; } +function skillMatchRank(skill: SkillSummary, query: string): number | null { + if (!query) return 0; + const name = skill.name.toLowerCase(); + if (name === query) return 0; + if (name.startsWith(query)) return 1; + if (name.includes(query)) return 2; + if (skill.description.toLowerCase().includes(query)) return 3; + return null; +} + function slashCommandI18nKey(command: string): string { return command.replace(/^\//, "").replace(/-/g, "_"); } @@ -1029,15 +1039,28 @@ export function ThreadComposer({ const query = skillQuery.text; return skills .filter((skill) => skill.available) - .filter((skill) => { - const haystack = [ - skill.name, - skill.description, - ].join(" ").toLowerCase(); - return haystack.includes(query); + .flatMap((skill) => { + const matchRank = skillMatchRank(skill, query); + return matchRank === null + ? [] + : [{ + command: `$${skill.name}`, + matchRank, + skill, + }]; }) - .map((skill) => { - const command = `$${skill.name}`; + .sort((a, b) => { + if (a.matchRank !== b.matchRank) return a.matchRank - b.matchRank; + if (query !== "") return 0; + const aRecent = recentSlashCommands.indexOf(a.command); + const bRecent = recentSlashCommands.indexOf(b.command); + if (aRecent === -1 && bRecent === -1) return 0; + if (aRecent === -1) return 1; + if (bRecent === -1) return -1; + return aRecent - bRecent; + }) + .slice(0, 8) + .map(({ command, skill }) => { const description = skill.description || skill.name; return { command, @@ -1048,8 +1071,7 @@ export function ThreadComposer({ kind: "skill" as const, recent: recentSlashCommands.includes(command), }; - }) - .slice(0, 8); + }); } if (slashQuery === null) return []; const withDetails = visibleSlashCommands diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index b50f802ae..b821ee4e2 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -1506,6 +1506,80 @@ describe("ThreadComposer", () => { expect(input).toHaveValue(`please use $${skillName} `); }); + it("ranks skill name matches ahead of earlier description matches", () => { + render( + , + ); + + const input = screen.getByLabelText("Message input"); + fireEvent.change(input, { target: { value: "$up", selectionStart: 3 } }); + + const options = within(screen.getByRole("listbox", { name: "Slash commands" })) + .getAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual([ + "upExact match", + "update-setupOne-time setup wizard", + "setup-updateConfigure upgrades", + "skill-creatorCreate or update AgentSkills", + ]); + }); + + it("keeps a recently selected skill visible at the top of the blank skill menu", () => { + const skills = Array.from({ length: 9 }, (_, index) => ({ + name: `skill-${index}`, + description: `Skill ${index}`, + source: "builtin", + available: true, + })); + render( + , + ); + + const input = screen.getByLabelText("Message input"); + fireEvent.change(input, { target: { value: "$skill-8", selectionStart: 8 } }); + fireEvent.keyDown(input, { key: "Tab" }); + fireEvent.change(input, { target: { value: "$", selectionStart: 1 } }); + + const options = within(screen.getByRole("listbox", { name: "Slash commands" })) + .getAllByRole("option"); + expect(options).toHaveLength(8); + expect(options[0]).toHaveTextContent("skill-8"); + expect(options[0]).toHaveTextContent("Recent"); + }); + it("shows right-side source badges so users can distinguish CLI apps from MCP servers", () => { render(