Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
256563e9a2 fix(editor): unblock member/agent results in @ mention picker
The previous patch made items() async and awaited the server-side issue
search before returning anything, which forced even local member/agent
matches to wait for the 150ms debounce + roundtrip.

Return sync items (members, agents, cached issues) immediately and let
the renderer be updated in-place when extra server results arrive. Also
move the search seq/abort state into the createMentionSuggestion closure
so concurrent ContentEditor instances no longer abort each other's
fetches, and aborts on cleanup so a late response can't write to a
destroyed renderer.

Adds a focused test that locks in the sync member/agent path and the
include_closed=true flag.
2026-04-16 19:06:22 +08:00
Jiang Bohan
4714b108b8 fix(editor): include done issues in @ mention search
The mention picker filtered against the cached issue list, which only
holds the first page of done issues. Older done issues were unfindable
via @, so users had to hand-write `[MUL-xxx](mention://issue/...)` to
reference them.

Switch the issue portion of the picker to the server-side search
endpoint with `include_closed=true` (matching the global Cmd+K search),
debounced and abortable. Done/cancelled rows render dimmed with a
strikethrough title so they remain visually distinct but selectable.
2026-04-16 18:41:37 +08:00
2 changed files with 234 additions and 45 deletions

View File

@@ -0,0 +1,108 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { issueKeys } from "@multica/core/issues/queries";
import type { QueryClient } from "@tanstack/react-query";
// Mock the workspace id singleton — items() reads it imperatively.
vi.mock("@multica/core/platform", () => ({
getCurrentWsId: () => "ws-1",
}));
// Mock the API so we control searchIssues responses + observe calls.
const searchIssuesMock = vi.fn();
vi.mock("@multica/core/api", () => ({
api: {
get searchIssues() {
return searchIssuesMock;
},
},
}));
import { createMentionSuggestion, type MentionItem } from "./mention-suggestion";
function fakeQc(data: {
members?: Array<{ user_id: string; name: string }>;
agents?: Array<{ id: string; name: string; archived_at: string | null }>;
issues?: Array<{ id: string; identifier: string; title: string; status: string }>;
}): QueryClient {
const map = new Map<string, unknown>();
map.set(JSON.stringify(workspaceKeys.members("ws-1")), data.members ?? []);
map.set(JSON.stringify(workspaceKeys.agents("ws-1")), data.agents ?? []);
map.set(JSON.stringify(issueKeys.list("ws-1")), {
issues: data.issues ?? [],
total: data.issues?.length ?? 0,
});
return {
getQueryData: (key: readonly unknown[]) => map.get(JSON.stringify(key)),
} as unknown as QueryClient;
}
describe("createMentionSuggestion", () => {
beforeEach(() => {
searchIssuesMock.mockReset();
});
it("returns members and agents synchronously without waiting for the server search", () => {
const qc = fakeQc({
members: [{ user_id: "u1", name: "Alice" }],
agents: [{ id: "a1", name: "Aegis", archived_at: null }],
});
// A pending fetch — would block the result if items() awaited it.
searchIssuesMock.mockReturnValue(new Promise(() => {}));
const config = createMentionSuggestion(qc);
const result = config.items!({ query: "a", editor: {} as never });
// Must be synchronous: a plain array, not a Promise.
expect(Array.isArray(result)).toBe(true);
const items = result as MentionItem[];
expect(items.some((i) => i.type === "member" && i.label === "Alice")).toBe(true);
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 });
const config = createMentionSuggestion(qc);
config.items!({ query: "bug-xyz", editor: {} as never });
// Wait past the 150ms debounce.
await new Promise((r) => setTimeout(r, 200));
expect(searchIssuesMock).toHaveBeenCalledWith(
expect.objectContaining({ q: "bug-xyz", include_closed: true }),
);
});
it("does not call searchIssues for an empty query", async () => {
const qc = fakeQc({});
searchIssuesMock.mockResolvedValue({ issues: [], total: 0 });
const config = createMentionSuggestion(qc);
config.items!({ query: "", editor: {} as never });
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("includes cached issues in the synchronous response", () => {
const qc = fakeQc({
issues: [
{ id: "i1", identifier: "MUL-1", title: "Login bug", status: "todo" },
{ id: "i2", identifier: "MUL-2", title: "Other", status: "done" },
],
});
searchIssuesMock.mockReturnValue(new Promise(() => {}));
const config = createMentionSuggestion(qc);
const result = config.items!({ query: "bug", editor: {} as never });
const items = result as MentionItem[];
expect(items.some((i) => i.type === "issue" && i.id === "i1")).toBe(true);
});
});

View File

@@ -14,6 +14,7 @@ import type { QueryClient } from "@tanstack/react-query";
import { getCurrentWsId } from "@multica/core/platform";
import { issueKeys } from "@multica/core/issues/queries";
import { workspaceKeys } from "@multica/core/workspace/queries";
import { api } from "@multica/core/api";
import type { Issue, ListIssuesResponse, MemberWithUser, Agent } from "@multica/core/types";
import { ActorAvatar } from "../../common/actor-avatar";
import { StatusIcon } from "../../issues/components/status-icon";
@@ -169,12 +170,15 @@ function MentionRow({
buttonRef: (el: HTMLButtonElement | null) => void;
}) {
if (item.type === "issue") {
// Visually dim closed issues (done/cancelled) so they're distinguishable
// from active ones in the suggestion list — they're still selectable.
const isClosed = item.status === "done" || item.status === "cancelled";
return (
<button
ref={buttonRef}
className={`flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-xs transition-colors ${
selected ? "bg-accent" : "hover:bg-accent/50"
}`}
} ${isClosed ? "opacity-60" : ""}`}
onClick={onSelect}
>
{item.status && (
@@ -182,7 +186,11 @@ function MentionRow({
)}
<span className="shrink-0 text-muted-foreground">{item.label}</span>
{item.description && (
<span className="truncate text-muted-foreground">{item.description}</span>
<span
className={`truncate text-muted-foreground ${isClosed ? "line-through" : ""}`}
>
{item.description}
</span>
)}
</button>
);
@@ -213,69 +221,136 @@ function MentionRow({
// Suggestion config factory
// ---------------------------------------------------------------------------
function issueToMention(i: Pick<Issue, "id" | "identifier" | "title" | "status">): MentionItem {
return {
id: i.id,
label: i.identifier,
type: "issue" as const,
description: i.title,
status: i.status as IssueStatus,
};
}
const MAX_ITEMS = 15;
export function createMentionSuggestion(qc: QueryClient): Omit<
SuggestionOptions<MentionItem>,
"editor"
> {
return {
items: ({ query }) => {
// Read workspace id imperatively because this runs in TipTap factory scope
// (outside React render). getCurrentWsId() is the non-React
// singleton set by the URL-driven workspace layout.
const wsId = getCurrentWsId();
const members: MemberWithUser[] = wsId ? qc.getQueryData(workspaceKeys.members(wsId)) ?? [] : [];
const agents: Agent[] = wsId ? qc.getQueryData(workspaceKeys.agents(wsId)) ?? [] : [];
const issues: Issue[] = wsId
? qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId))?.issues ?? []
// Per-editor state lives in this closure so multiple ContentEditor instances
// (e.g. comment input + reply box) don't abort each other's searches.
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[] {
// Read workspace id imperatively because this runs in TipTap factory scope
// (outside React render). getCurrentWsId() is the non-React singleton set
// by the URL-driven workspace layout.
const wsId = getCurrentWsId();
if (!wsId) return [];
const members: MemberWithUser[] = qc.getQueryData(workspaceKeys.members(wsId)) ?? [];
const agents: Agent[] = qc.getQueryData(workspaceKeys.agents(wsId)) ?? [];
const cachedIssues: Issue[] =
qc.getQueryData<ListIssuesResponse>(issueKeys.list(wsId))?.issues ?? [];
const q = query.toLowerCase();
const allItem: MentionItem[] =
"all members".includes(q) || "all".includes(q)
? [{ id: "all", label: "All members", type: "all" as const }]
: [];
const q = query.toLowerCase();
const memberItems: MentionItem[] = members
.filter((m) => m.name.toLowerCase().includes(q))
.map((m) => ({
id: m.user_id,
label: m.name,
type: "member" as const,
}));
// Show "All members" option when query is empty or matches "all"
const allItem: MentionItem[] =
"all members".includes(q) || "all".includes(q)
? [{ id: "all", label: "All members", type: "all" as const }]
: [];
const agentItems: MentionItem[] = agents
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
const memberItems: MentionItem[] = members
.filter((m) => m.name.toLowerCase().includes(q))
.map((m) => ({
id: m.user_id,
label: m.name,
type: "member" 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.
const issueItems: MentionItem[] = cachedIssues
.filter(
(i) =>
i.identifier.toLowerCase().includes(q) ||
i.title.toLowerCase().includes(q),
)
.map(issueToMention);
const agentItems: MentionItem[] = agents
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
return [...allItem, ...memberItems, ...agentItems, ...issueItems];
}
const issueItems: MentionItem[] = issues
.filter(
(i) =>
i.identifier.toLowerCase().includes(q) ||
i.title.toLowerCase().includes(q),
)
.map((i) => ({
id: i.id,
label: i.identifier,
type: "issue" as const,
description: i.title,
status: i.status as IssueStatus,
}));
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;
return [...allItem, ...memberItems, ...agentItems, ...issueItems].slice(0, 10);
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);
},
render: () => {
let renderer: ReactRenderer<MentionListRef> | null = null;
let popup: HTMLDivElement | null = null;
return {
onStart: (props: SuggestionProps<MentionItem>) => {
renderer = new ReactRenderer(MentionList, {
props: { items: props.items, command: props.command },
editor: props.editor,
});
activeCommand = props.command;
popup = document.createElement("div");
popup.style.position = "fixed";
@@ -291,6 +366,7 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
items: props.items,
command: props.command,
});
activeCommand = props.command;
if (popup) updatePosition(popup, props.clientRect);
},
@@ -328,8 +404,13 @@ 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;
}
},
};