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(