From fae108ebdc8658500c49e00cd7d2d705b8f0062f Mon Sep 17 00:00:00 2001 From: LinYushen Date: Tue, 28 Apr 2026 16:54:41 +0800 Subject: [PATCH] fix: refresh mention issue search results --- .../extensions/mention-suggestion.test.tsx | 63 ++++-- .../editor/extensions/mention-suggestion.tsx | 201 +++++++++++------- 2 files changed, 167 insertions(+), 97 deletions(-) diff --git a/packages/views/editor/extensions/mention-suggestion.test.tsx b/packages/views/editor/extensions/mention-suggestion.test.tsx index ef60322fe0..f505065f28 100644 --- a/packages/views/editor/extensions/mention-suggestion.test.tsx +++ b/packages/views/editor/extensions/mention-suggestion.test.tsx @@ -1,3 +1,5 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { createRef } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { workspaceKeys } from "@multica/core/workspace/queries"; import { issueKeys, PAGINATED_STATUSES } from "@multica/core/issues/queries"; @@ -19,7 +21,12 @@ vi.mock("@multica/core/api", () => ({ }, })); -import { createMentionSuggestion, type MentionItem } from "./mention-suggestion"; +import { + createMentionSuggestion, + MentionList, + type MentionListRef, + type MentionItem, +} from "./mention-suggestion"; function fakeQc(data: { members?: Array<{ user_id: string; name: string }>; @@ -66,34 +73,50 @@ describe("createMentionSuggestion", () => { expect(items.some((i) => i.type === "agent" && i.label === "Aegis")).toBe(true); }); - it("calls searchIssues with include_closed=true so done issues are findable", async () => { - const qc = fakeQc({}); - searchIssuesMock.mockResolvedValue({ issues: [], total: 0 }); + it("loads server issue matches into the popup when the list cache misses", async () => { + searchIssuesMock.mockResolvedValue({ + issues: [ + { + id: "i-1007", + identifier: "MUL-1007", + title: "多 Agent 协作探索", + status: "done", + }, + ], + total: 1, + }); - const config = createMentionSuggestion(qc); - config.items!({ query: "bug-xyz", editor: {} as never }); + render(); - // Wait past the 150ms debounce. - await new Promise((r) => setTimeout(r, 200)); + expect(screen.getByText("Searching...")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText("MUL-1007")).toBeInTheDocument(); + }); + expect(screen.getByText("多 Agent 协作探索")).toBeInTheDocument(); expect(searchIssuesMock).toHaveBeenCalledWith( - expect.objectContaining({ q: "bug-xyz", include_closed: true }), + expect.objectContaining({ + q: "协作", + limit: 20, + include_closed: true, + }), ); }); - it("does not call searchIssues for an empty query", async () => { - const qc = fakeQc({}); - searchIssuesMock.mockResolvedValue({ issues: [], total: 0 }); + it("does not call searchIssues for an empty query", () => { + render(); - const config = createMentionSuggestion(qc); - config.items!({ query: "", editor: {} as never }); + expect(searchIssuesMock).not.toHaveBeenCalled(); + }); - await new Promise((r) => setTimeout(r, 200)); - // No call with an empty q (other tests' fire-and-forget closures may leak, - // so assert on the *content* of any call rather than absence). - for (const call of searchIssuesMock.mock.calls) { - expect(call[0].q).not.toBe(""); - } + it("captures Enter while the popup has no selectable items", () => { + const ref = createRef(); + + render(); + + expect( + ref.current?.onKeyDown({ event: new KeyboardEvent("keydown", { key: "Enter" }) }), + ).toBe(true); }); it("includes cached issues in the synchronous response", () => { diff --git a/packages/views/editor/extensions/mention-suggestion.tsx b/packages/views/editor/extensions/mention-suggestion.tsx index 000cad1f4d..6252a6fdc1 100644 --- a/packages/views/editor/extensions/mention-suggestion.tsx +++ b/packages/views/editor/extensions/mention-suggestion.tsx @@ -5,6 +5,7 @@ import { useCallback, useEffect, useImperativeHandle, + useMemo, useRef, useState, } from "react"; @@ -15,7 +16,12 @@ import { getCurrentWsId } from "@multica/core/platform"; import { flattenIssueBuckets, issueKeys } from "@multica/core/issues/queries"; import { workspaceKeys } from "@multica/core/workspace/queries"; import { api } from "@multica/core/api"; -import type { Issue, ListIssuesCache, MemberWithUser, Agent } from "@multica/core/types"; +import type { + Issue, + ListIssuesCache, + MemberWithUser, + Agent, +} from "@multica/core/types"; import { ActorAvatar } from "../../common/actor-avatar"; import { StatusIcon } from "../../issues/components/status-icon"; import { Badge } from "@multica/ui/components/ui/badge"; @@ -38,6 +44,7 @@ export interface MentionItem { interface MentionListProps { items: MentionItem[]; + query: string; command: (item: MentionItem) => void; } @@ -76,14 +83,100 @@ function groupItems(items: MentionItem[]): MentionGroup[] { // MentionList — the popup rendered inside the editor // --------------------------------------------------------------------------- -const MentionList = forwardRef( - function MentionList({ items, command }, ref) { +const MAX_ITEMS = 20; +const SERVER_ISSUE_SEARCH_LIMIT = 20; +const SERVER_SEARCH_DEBOUNCE_MS = 150; + +function mentionItemKey(item: MentionItem): string { + return `${item.type}:${item.id}`; +} + +function mergeMentionItems( + syncItems: MentionItem[], + serverIssueItems: MentionItem[], +): MentionItem[] { + const seen = new Set(); + const merged: MentionItem[] = []; + + for (const item of [...syncItems, ...serverIssueItems]) { + const key = mentionItemKey(item); + if (seen.has(key)) continue; + seen.add(key); + merged.push(item); + } + + return merged; +} + +export const MentionList = forwardRef( + function MentionList({ items, query, command }, ref) { const [selectedIndex, setSelectedIndex] = useState(0); + const [serverIssueItems, setServerIssueItems] = useState([]); + const [isSearchingIssues, setIsSearchingIssues] = useState(false); + const [searchedIssueQuery, setSearchedIssueQuery] = useState(""); const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); + const normalizedQuery = query.trim(); + + useEffect(() => { + const q = normalizedQuery; + setServerIssueItems([]); + + if (!q) { + setIsSearchingIssues(false); + setSearchedIssueQuery(""); + return; + } + + const wsId = getCurrentWsId(); + if (!wsId) { + setIsSearchingIssues(false); + setSearchedIssueQuery(q); + return; + } + + let cancelled = false; + const controller = new AbortController(); + setIsSearchingIssues(true); + + const timer = setTimeout(() => { + void (async () => { + try { + const res = await api.searchIssues({ + q, + limit: SERVER_ISSUE_SEARCH_LIMIT, + include_closed: true, + signal: controller.signal, + }); + if (!cancelled && !controller.signal.aborted) { + setServerIssueItems(res.issues.map(issueToMention)); + } + } catch { + // Aborted or network error: keep the synchronous cache results. + } finally { + if (!cancelled && !controller.signal.aborted) { + setSearchedIssueQuery(q); + setIsSearchingIssues(false); + } + } + })(); + }, SERVER_SEARCH_DEBOUNCE_MS); + + return () => { + cancelled = true; + clearTimeout(timer); + controller.abort(); + }; + }, [normalizedQuery]); + + const displayItems = useMemo(() => { + const currentServerIssueItems = + searchedIssueQuery === normalizedQuery ? serverIssueItems : []; + return mergeMentionItems(items, currentServerIssueItems).slice(0, MAX_ITEMS); + }, [items, normalizedQuery, searchedIssueQuery, serverIssueItems]); useEffect(() => { setSelectedIndex(0); - }, [items]); + }, [displayItems]); useEffect(() => { itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" }); @@ -91,23 +184,28 @@ const MentionList = forwardRef( const selectItem = useCallback( (index: number) => { - const item = items[index]; + const item = displayItems[index]; if (item) command(item); }, - [items, command], + [displayItems, command], ); useImperativeHandle(ref, () => ({ onKeyDown: ({ event }) => { if (event.key === "ArrowUp") { - setSelectedIndex((i) => (i + items.length - 1) % items.length); + if (displayItems.length === 0) return true; + setSelectedIndex( + (i) => (i + displayItems.length - 1) % displayItems.length, + ); return true; } if (event.key === "ArrowDown") { - setSelectedIndex((i) => (i + 1) % items.length); + if (displayItems.length === 0) return true; + setSelectedIndex((i) => (i + 1) % displayItems.length); return true; } if (event.key === "Enter") { + if (displayItems.length === 0) return true; selectItem(selectedIndex); return true; } @@ -115,15 +213,19 @@ const MentionList = forwardRef( }, })); - if (items.length === 0) { + if (displayItems.length === 0) { + const isWaitingForServer = + normalizedQuery !== "" && + (isSearchingIssues || searchedIssueQuery !== normalizedQuery); + return (
- No results + {isWaitingForServer ? "Searching..." : "No results"}
); } - const groups = groupItems(items); + const groups = groupItems(displayItems); // Build a flat index mapping: globalIndex → item let globalIndex = 0; @@ -231,18 +333,13 @@ function issueToMention(i: Pick }; } -const MAX_ITEMS = 15; - export function createMentionSuggestion(qc: QueryClient): Omit< SuggestionOptions, "editor" > { - // Per-editor state lives in this closure so multiple ContentEditor instances - // (e.g. comment input + reply box) don't abort each other's searches. + // Renderer/popup instances live in this closure so each ContentEditor owns + // its own TipTap suggestion popup lifecycle. let renderer: ReactRenderer | null = null; - let activeCommand: ((item: MentionItem) => void) | null = null; - let searchSeq = 0; - let searchAbort: AbortController | null = null; let popup: HTMLDivElement | null = null; function buildSyncItems(query: string): MentionItem[] { @@ -276,8 +373,8 @@ export function createMentionSuggestion(qc: QueryClient): Omit< .filter((a) => !a.archived_at && a.name.toLowerCase().includes(q)) .map((a) => ({ id: a.id, label: a.name, type: "agent" as const })); - // Cached issues give an instant first paint; the server search below - // adds done/cancelled and any other matches not in the local cache. + // Cached issues give an instant first paint; MentionList adds server + // matches for done/cancelled and any other issues not in this cache. const issueItems: MentionItem[] = cachedIssues .filter( (i) => @@ -289,68 +386,23 @@ export function createMentionSuggestion(qc: QueryClient): Omit< return [...allItem, ...memberItems, ...agentItems, ...issueItems]; } - function startServerIssueSearch(query: string, syncItems: MentionItem[]) { - // Supersede any in-flight search; the next-arrived response wins. - if (searchAbort) searchAbort.abort(); - const mySeq = ++searchSeq; - const wsId = getCurrentWsId(); - if (!wsId) return; - - void (async () => { - // Debounce: skip the fetch if a newer keystroke arrives within 150ms. - await new Promise((r) => setTimeout(r, 150)); - if (mySeq !== searchSeq) return; - - const controller = new AbortController(); - searchAbort = controller; - try { - const res = await api.searchIssues({ - q: query, - limit: 10, - include_closed: true, - signal: controller.signal, - }); - if (mySeq !== searchSeq) return; - if (!renderer || !activeCommand) return; - - const existingIssueIds = new Set( - syncItems.filter((i) => i.type === "issue").map((i) => i.id), - ); - const extraIssueItems = res.issues - .map(issueToMention) - .filter((i) => !existingIssueIds.has(i.id)); - if (extraIssueItems.length === 0) return; - - const merged = [...syncItems, ...extraIssueItems].slice(0, MAX_ITEMS); - renderer.updateProps({ items: merged, command: activeCommand }); - } catch { - // Aborted or network error: nothing to do — sync items remain. - } - })(); - } - return { items: ({ query }) => { const syncItems = buildSyncItems(query); - // Empty query has no server search — cached issues are enough, and - // we still bump the seq to cancel any pending fetch from a prior key. - if (query === "") { - if (searchAbort) searchAbort.abort(); - ++searchSeq; - } else { - startServerIssueSearch(query, syncItems); - } - return syncItems.slice(0, MAX_ITEMS); + return syncItems; }, render: () => { return { onStart: (props: SuggestionProps) => { renderer = new ReactRenderer(MentionList, { - props: { items: props.items, command: props.command }, + props: { + items: props.items, + query: props.query, + command: props.command, + }, editor: props.editor, }); - activeCommand = props.command; popup = document.createElement("div"); popup.style.position = "fixed"; @@ -364,9 +416,9 @@ export function createMentionSuggestion(qc: QueryClient): Omit< onUpdate: (props: SuggestionProps) => { renderer?.updateProps({ items: props.items, + query: props.query, command: props.command, }); - activeCommand = props.command; if (popup) updatePosition(popup, props.clientRect); }, @@ -404,13 +456,8 @@ export function createMentionSuggestion(qc: QueryClient): Omit< function cleanup() { renderer?.destroy(); renderer = null; - activeCommand = null; popup?.remove(); popup = null; - // Cancel any in-flight server search; its result would target a - // destroyed renderer. - if (searchAbort) searchAbort.abort(); - ++searchSeq; } }, };