fix(webui): rank skill autocomplete results

This commit is contained in:
chengyongru 2026-07-28 11:28:53 +08:00 committed by chengyongru
parent ef9e687f19
commit fa5d27696a
2 changed files with 106 additions and 10 deletions

View File

@ -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

View File

@ -1506,6 +1506,80 @@ describe("ThreadComposer", () => {
expect(input).toHaveValue(`please use $${skillName} `);
});
it("ranks skill name matches ahead of earlier description matches", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
skills={[
{
name: "skill-creator",
description: "Create or update AgentSkills",
source: "builtin",
available: true,
},
{
name: "setup-update",
description: "Configure upgrades",
source: "builtin",
available: true,
},
{
name: "update-setup",
description: "One-time setup wizard",
source: "builtin",
available: true,
},
{
name: "up",
description: "Exact match",
source: "workspace",
available: true,
},
]}
/>,
);
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(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
skills={skills}
/>,
);
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(
<ThreadComposer