mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 05:19:30 +02:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -80,6 +80,9 @@
|
||||
},
|
||||
"slash_command": {
|
||||
"no_skills_configured": "設定済みスキルなし",
|
||||
"no_results": "一致するスキルなし"
|
||||
"no_results": "一致するスキルなし",
|
||||
"commands": {
|
||||
"note": "メモを追加 — 担当エージェントをトリガーしません"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,9 @@
|
||||
},
|
||||
"slash_command": {
|
||||
"no_skills_configured": "구성된 스킬 없음",
|
||||
"no_results": "일치하는 스킬 없음"
|
||||
"no_results": "일치하는 스킬 없음",
|
||||
"commands": {
|
||||
"note": "메모 추가 — 담당 에이전트를 트리거하지 않음"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,10 @@
|
||||
},
|
||||
"slash_command": {
|
||||
"no_skills_configured": "暂无配置的技能",
|
||||
"no_results": "没有匹配的技能"
|
||||
"no_results": "没有匹配的技能",
|
||||
"commands": {
|
||||
"note": "添加备注 — 不会触发已分配的 Agent"
|
||||
}
|
||||
},
|
||||
"code_block": {
|
||||
"copy_code": "复制代码",
|
||||
|
||||
Reference in New Issue
Block a user