fix: refresh mention issue search results

This commit is contained in:
LinYushen
2026-04-28 16:54:41 +08:00
committed by GitHub
parent 0236e409e4
commit fae108ebdc
2 changed files with 167 additions and 97 deletions

View File

@@ -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(<MentionList items={[]} query="协作" command={vi.fn()} />);
// 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(<MentionList items={[]} query="" command={vi.fn()} />);
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<MentionListRef>();
render(<MentionList ref={ref} items={[]} query="协作" command={vi.fn()} />);
expect(
ref.current?.onKeyDown({ event: new KeyboardEvent("keydown", { key: "Enter" }) }),
).toBe(true);
});
it("includes cached issues in the synchronous response", () => {

View File

@@ -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<MentionListRef, MentionListProps>(
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<string>();
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<MentionListRef, MentionListProps>(
function MentionList({ items, query, command }, ref) {
const [selectedIndex, setSelectedIndex] = useState(0);
const [serverIssueItems, setServerIssueItems] = useState<MentionItem[]>([]);
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<MentionListRef, MentionListProps>(
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<MentionListRef, MentionListProps>(
},
}));
if (items.length === 0) {
if (displayItems.length === 0) {
const isWaitingForServer =
normalizedQuery !== "" &&
(isSearchingIssues || searchedIssueQuery !== normalizedQuery);
return (
<div className="rounded-md border bg-popover p-2 text-xs text-muted-foreground shadow-md">
No results
{isWaitingForServer ? "Searching..." : "No results"}
</div>
);
}
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<Issue, "id" | "identifier" | "title" | "status">
};
}
const MAX_ITEMS = 15;
export function createMentionSuggestion(qc: QueryClient): Omit<
SuggestionOptions<MentionItem>,
"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<MentionListRef> | 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<MentionItem>) => {
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<MentionItem>) => {
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;
}
},
};