diff --git a/packages/views/editor/extensions/mention-suggestion.test.tsx b/packages/views/editor/extensions/mention-suggestion.test.tsx index dcd800bfec..eb2ba8825b 100644 --- a/packages/views/editor/extensions/mention-suggestion.test.tsx +++ b/packages/views/editor/extensions/mention-suggestion.test.tsx @@ -285,4 +285,55 @@ describe("createMentionSuggestion", () => { const items = result as MentionItem[]; expect(items.filter((i) => i.type === "squad")).toHaveLength(0); }); + + it("matches Chinese names by full pinyin", () => { + const qc = fakeQc({ + members: [ + { user_id: "u1", name: "Alice", role: "member" }, + { user_id: "u2", name: "李云龙", role: "member" }, + ], + }); + searchIssuesMock.mockReturnValue(new Promise(() => {})); + + const config = createMentionSuggestion(qc); + const result = config.items!({ query: "liyunlong", editor: {} as never }); + + const items = result as MentionItem[]; + expect(items.some((i) => i.type === "member" && i.label === "李云龙")).toBe(true); + expect(items.some((i) => i.type === "member" && i.label === "Alice")).toBe(false); + }); + + it("matches Chinese names by pinyin initials", () => { + const qc = fakeQc({ + members: [ + { user_id: "u1", name: "Alice", role: "member" }, + { user_id: "u2", name: "李云龙", role: "member" }, + { user_id: "u3", name: "张大彪", role: "member" }, + ], + }); + searchIssuesMock.mockReturnValue(new Promise(() => {})); + + const config = createMentionSuggestion(qc); + const result = config.items!({ query: "lyl", editor: {} as never }); + + const items = result as MentionItem[]; + expect(items.some((i) => i.type === "member" && i.label === "李云龙")).toBe(true); + expect(items.some((i) => i.type === "member" && i.label === "张大彪")).toBe(false); + }); + + it("matches Chinese agent names by pinyin", () => { + const qc = fakeQc({ + members: [{ user_id: "u1", name: "Alice", role: "member" }], + agents: [ + { id: "a1", name: "魏和尚", archived_at: null, visibility: "workspace", owner_id: null }, + ], + }); + searchIssuesMock.mockReturnValue(new Promise(() => {})); + + const config = createMentionSuggestion(qc); + const result = config.items!({ query: "whs", editor: {} as never }); + + const items = result as MentionItem[]; + expect(items.some((i) => i.type === "agent" && i.label === "魏和尚")).toBe(true); + }); }); diff --git a/packages/views/editor/extensions/mention-suggestion.tsx b/packages/views/editor/extensions/mention-suggestion.tsx index f31267b613..604f519bb7 100644 --- a/packages/views/editor/extensions/mention-suggestion.tsx +++ b/packages/views/editor/extensions/mention-suggestion.tsx @@ -37,6 +37,7 @@ import { recordMentionUsage, sortUserItemsByRecency, } from "./mention-recency"; +import { matchesPinyin } from "./pinyin-match"; // --------------------------------------------------------------------------- // Types @@ -407,7 +408,7 @@ export function createMentionSuggestion(qc: QueryClient): Omit< : []; const memberItems: MentionItem[] = members - .filter((m) => m.name.toLowerCase().includes(q)) + .filter((m) => m.name.toLowerCase().includes(q) || matchesPinyin(m.name, q)) .map((m) => ({ id: m.user_id, label: m.name, @@ -418,13 +419,13 @@ export function createMentionSuggestion(qc: QueryClient): Omit< .filter( (a) => !a.archived_at && - a.name.toLowerCase().includes(q) && + (a.name.toLowerCase().includes(q) || matchesPinyin(a.name, q)) && canAssignAgentToIssue(a, { userId, role: myRole }).allowed, ) .map((a) => ({ id: a.id, label: a.name, type: "agent" as const })); const squadItems: MentionItem[] = squads - .filter((s) => !s.archived_at && s.name.toLowerCase().includes(q)) + .filter((s) => !s.archived_at && (s.name.toLowerCase().includes(q) || matchesPinyin(s.name, q))) .map((s) => ({ id: s.id, label: s.name, type: "squad" as const })); // Members and agents share a single ranked list — recently mentioned diff --git a/packages/views/editor/extensions/pinyin-match.test.ts b/packages/views/editor/extensions/pinyin-match.test.ts new file mode 100644 index 0000000000..de369d93d0 --- /dev/null +++ b/packages/views/editor/extensions/pinyin-match.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from "vitest"; +import { matchesPinyin } from "./pinyin-match"; + +describe("matchesPinyin", () => { + it("matches full pinyin", () => { + expect(matchesPinyin("李云龙", "liyunlong")).toBe(true); + }); + + it("matches pinyin initials", () => { + expect(matchesPinyin("李云龙", "lyl")).toBe(true); + }); + + it("matches partial pinyin prefix", () => { + expect(matchesPinyin("李云龙", "liyu")).toBe(true); + }); + + it("matches hybrid pinyin (full + initials)", () => { + expect(matchesPinyin("李云龙", "liyunl")).toBe(true); + }); + + it("does not match unrelated query", () => { + expect(matchesPinyin("李云龙", "zhangsan")).toBe(false); + }); + + it("returns false for non-Chinese names", () => { + expect(matchesPinyin("Alice", "ali")).toBe(false); + }); + + it("returns true for empty query", () => { + expect(matchesPinyin("李云龙", "")).toBe(true); + }); + + it("matches single character pinyin", () => { + expect(matchesPinyin("张大彪", "z")).toBe(true); + expect(matchesPinyin("张大彪", "zdb")).toBe(true); + expect(matchesPinyin("张大彪", "zhangdabiao")).toBe(true); + }); + + it("matches mixed Chinese/English names", () => { + expect(matchesPinyin("魏和尚", "whs")).toBe(true); + expect(matchesPinyin("魏和尚", "weiheshang")).toBe(true); + }); + + it("normalizes ü to v for names like 吕布", () => { + expect(matchesPinyin("吕布", "lvbu")).toBe(true); + expect(matchesPinyin("吕布", "lb")).toBe(true); + expect(matchesPinyin("吕布", "lv")).toBe(true); + }); +}); diff --git a/packages/views/editor/extensions/pinyin-match.ts b/packages/views/editor/extensions/pinyin-match.ts new file mode 100644 index 0000000000..2dffcce05a --- /dev/null +++ b/packages/views/editor/extensions/pinyin-match.ts @@ -0,0 +1,58 @@ +import { pinyin } from "pinyin-pro"; + +/** + * Check if a query matches a name via pinyin. + * Supports: + * - Full pinyin match: "liyunlong" matches "李云龙" + * - Initial letter abbreviation: "lyl" matches "李云龙" + * - Partial prefix match: "liyu" matches "李云龙" + */ +export function matchesPinyin(name: string, query: string): boolean { + if (!query) return true; + + // Only attempt pinyin matching if the name contains Chinese characters + if (!/[\u4e00-\u9fff]/.test(name)) return false; + + const q = query.toLowerCase(); + + // Get full pinyin (no tone, no separator, ü→v for standard input) + const full = pinyin(name, { toneType: "none", type: "array", v: true }); + const fullStr = full.join(""); + + // Full pinyin prefix match: "liyunlong" or "liyun" + if (fullStr.startsWith(q)) return true; + + // Initial letters match: "lyl" + const initials = full.map((p) => p[0] || "").join(""); + if (initials.startsWith(q)) return true; + + // Hybrid match: some chars matched by full pinyin, rest by initials + // e.g. "liyl" matches "李云龙" (li + y + l) + return hybridMatch(full, q); +} + +/** + * Hybrid matching: the query can be a mix of full pinyin for some characters + * and initials for others, consumed left-to-right. + * e.g. for ["li", "yun", "long"], query "liyunl" matches (li + yun + l) + */ +function hybridMatch(pinyinArr: string[], query: string): boolean { + return match(pinyinArr, 0, query, 0); +} + +function match(arr: string[], ai: number, q: string, qi: number): boolean { + if (qi >= q.length) return true; + if (ai >= arr.length) return false; + + const syllable = arr[ai]!; + + // Try matching full syllable or any prefix of it + for (let len = syllable.length; len >= 1; len--) { + if (qi + len > q.length) continue; + if (q.substring(qi, qi + len) === syllable.substring(0, len)) { + if (match(arr, ai + 1, q, qi + len)) return true; + } + } + + return false; +} diff --git a/packages/views/package.json b/packages/views/package.json index 75b83b42d3..7e37093a5f 100644 --- a/packages/views/package.json +++ b/packages/views/package.json @@ -81,6 +81,7 @@ "lowlight": "^3.3.0", "mermaid": "catalog:", "motion": "^12.38.0", + "pinyin-pro": "3.26.0", "react-markdown": "^10.1.0", "react-resizable-panels": "^4.7.5", "react-virtuoso": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 885914ec9d..80b3abe07a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -793,6 +793,9 @@ importers: motion: specifier: ^12.38.0 version: 12.38.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + pinyin-pro: + specifier: 3.26.0 + version: 3.26.0 react: specifier: 'catalog:' version: 19.2.3 @@ -6206,6 +6209,9 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pinyin-pro@3.26.0: + resolution: {integrity: sha512-HcBZZb0pvm0/JkPhZHWA5Hqp2cWHXrrW/WrV+OtaYYM+kf35ffvZppIUuGmyuQ7gDr1JDJKMkbEE+GN0wfMoGg==} + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -13685,6 +13691,8 @@ snapshots: picomatch@4.0.3: {} + pinyin-pro@3.26.0: {} + pkce-challenge@5.0.1: {} pkg-types@1.3.1: