mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 19:41:14 +02:00
fix: refresh mention issue search results
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user