refactor(editor): match /note by label prefix and localize its description

Address PR review feedback:
- buildBuiltinCommandItems now matches the command label as a prefix only,
  dropping the description substring match copied from the skill picker. With
  one command this keeps the menu predictable (/no surfaces note; /deploy or a
  description word like /agent shows nothing) and avoids Enter selecting note
  unexpectedly.
- The command description is now a localized UI string: added
  slash_command.commands.note to all four editor locales (en/ja/ko/zh-Hans)
  and the menu renders it via the typed translator. The /label itself stays
  literal since it's the typed token the backend matches.

MUL-3115.

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
J
2026-06-08 14:39:03 +08:00
parent 6e37b298b3
commit 5e489b03e0
6 changed files with 87 additions and 40 deletions

View File

@@ -346,18 +346,39 @@ describe("buildBuiltinCommandItems", () => {
expect(buildBuiltinCommandItems("")).toEqual(BUILTIN_COMMANDS);
});
it("includes /note while the query is a prefix of it", () => {
it("includes /note while the query is a prefix of the label", () => {
expect(buildBuiltinCommandItems("no").map((c) => c.id)).toEqual(["note"]);
expect(buildBuiltinCommandItems("NOTE").map((c) => c.id)).toEqual(["note"]);
});
it("matches against the description as well as the label", () => {
expect(buildBuiltinCommandItems("agent").map((c) => c.id)).toEqual([
"note",
]);
it("matches the label as a prefix only — not the description", () => {
// "agent" appears in the description but is not a label prefix.
expect(buildBuiltinCommandItems("agent")).toEqual([]);
// A non-prefix substring of the label does not match either.
expect(buildBuiltinCommandItems("ote")).toEqual([]);
});
it("returns nothing for a query that matches no command", () => {
expect(buildBuiltinCommandItems("deploy")).toEqual([]);
});
});
describe("SlashCommandList built-in command rendering", () => {
it("renders the localized description for a built-in command", () => {
const { getByText } = render(
<I18nWrapper>
<SlashCommandList
items={buildBuiltinCommandItems("")}
query=""
command={vi.fn()}
hideOnEmpty
/>
</I18nWrapper>,
);
expect(getByText("/note")).toBeInTheDocument();
expect(
getByText("Add a note — won't trigger the assigned agent"),
).toBeInTheDocument();
});
});

View File

@@ -23,10 +23,20 @@ import { createSuggestionPopupRender } from "./suggestion-popup";
const MAX_ITEMS = 20;
/** Known built-in command ids — the keys under editor `slash_command.commands`. */
export type BuiltinCommandKey = "note";
export interface SlashCommandItem {
id: string;
label: string;
description: string;
/** Raw description (skill picker). Built-in commands use descriptionKey. */
description?: string;
/**
* For built-in commands: the i18n key under editor `slash_command.commands`.
* When set, the menu renders the translated copy instead of `description`,
* so the visible string stays localized (the typed `/label` does not).
*/
descriptionKey?: BuiltinCommandKey;
}
interface SlashCommandListProps {
@@ -107,27 +117,37 @@ export const SlashCommandList = forwardRef<
);
}
// Built-in commands carry an i18n key so the visible description stays
// localized; skills carry a raw description string from their config.
const describe = (item: SlashCommandItem): string | undefined =>
item.descriptionKey === "note"
? t(($) => $.slash_command.commands.note)
: item.description;
return (
<div className="rounded-md border bg-popover py-1 shadow-md w-72 max-h-[300px] overflow-y-auto">
{items.map((item, index) => (
<button
key={item.id}
ref={(el) => {
itemRefs.current[index] = el;
}}
className={`flex w-full flex-col gap-0.5 px-3 py-1.5 text-left text-xs transition-colors ${
selectedIndex === index ? "bg-accent" : "hover:bg-accent/50"
}`}
onClick={() => selectItem(index)}
>
<span className="font-medium">/{item.label}</span>
{item.description && (
<span className="truncate text-muted-foreground">
{item.description}
</span>
)}
</button>
))}
{items.map((item, index) => {
const description = describe(item);
return (
<button
key={item.id}
ref={(el) => {
itemRefs.current[index] = el;
}}
className={`flex w-full flex-col gap-0.5 px-3 py-1.5 text-left text-xs transition-colors ${
selectedIndex === index ? "bg-accent" : "hover:bg-accent/50"
}`}
onClick={() => selectItem(index)}
>
<span className="font-medium">/{item.label}</span>
{description && (
<span className="truncate text-muted-foreground">
{description}
</span>
)}
</button>
);
})}
</div>
);
});
@@ -227,21 +247,15 @@ export function createSlashCommandSuggestion(qc: QueryClient): Omit<
* `noteCommentPrefix` in server/internal/handler/comment.go.
*/
export const BUILTIN_COMMANDS: SlashCommandItem[] = [
{
id: "note",
label: "note",
description: "Add a note — won't trigger the assigned agent",
},
{ id: "note", label: "note", descriptionKey: "note" },
];
// Match on the command label as a prefix only — the description is for display,
// not search. With a single command this keeps the menu predictable (typing
// `/no` surfaces `note`; an unrelated `/deploy` shows nothing).
export function buildBuiltinCommandItems(query: string): SlashCommandItem[] {
const q = query.toLowerCase();
return BUILTIN_COMMANDS.filter(
(c) =>
!q ||
c.label.toLowerCase().includes(q) ||
c.description.toLowerCase().includes(q),
);
return BUILTIN_COMMANDS.filter((c) => c.label.toLowerCase().startsWith(q));
}
export function createBuiltinCommandSuggestion(): Omit<

View File

@@ -64,7 +64,10 @@
},
"slash_command": {
"no_skills_configured": "No skills configured",
"no_results": "No matching skills"
"no_results": "No matching skills",
"commands": {
"note": "Add a note — won't trigger the assigned agent"
}
},
"code_block": {
"copy_code": "Copy code",

View File

@@ -80,6 +80,9 @@
},
"slash_command": {
"no_skills_configured": "設定済みスキルなし",
"no_results": "一致するスキルなし"
"no_results": "一致するスキルなし",
"commands": {
"note": "メモを追加 — 担当エージェントをトリガーしません"
}
}
}

View File

@@ -80,6 +80,9 @@
},
"slash_command": {
"no_skills_configured": "구성된 스킬 없음",
"no_results": "일치하는 스킬 없음"
"no_results": "일치하는 스킬 없음",
"commands": {
"note": "메모 추가 — 담당 에이전트를 트리거하지 않음"
}
}
}

View File

@@ -64,7 +64,10 @@
},
"slash_command": {
"no_skills_configured": "暂无配置的技能",
"no_results": "没有匹配的技能"
"no_results": "没有匹配的技能",
"commands": {
"note": "添加备注 — 不会触发已分配的 Agent"
}
},
"code_block": {
"copy_code": "复制代码",