mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-27 17:47:43 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/j/ec
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
256563e9a2 | ||
|
|
4714b108b8 |
108
packages/views/editor/extensions/mention-suggestion.test.tsx
Normal file
108
packages/views/editor/extensions/mention-suggestion.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user