Use TanStack Query for trigger preview

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-06-05 10:27:43 +08:00
parent f216070620
commit 92047ee41e
2 changed files with 151 additions and 42 deletions

View File

@@ -1,5 +1,114 @@
import { describe, expect, it } from "vitest";
import { commentTriggerPreviewSignature } from "./use-comment-trigger-preview";
import { createElement, type ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { api } from "@multica/core/api";
import {
commentTriggerPreviewSignature,
useCommentTriggerPreview,
} from "./use-comment-trigger-preview";
vi.mock("@multica/core/api", () => ({
api: {
previewCommentTriggers: vi.fn(),
},
}));
const previewCommentTriggers = vi.mocked(api.previewCommentTriggers);
function createWrapper() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return function Wrapper({ children }: { children: ReactNode }) {
return createElement(QueryClientProvider, { client: queryClient }, children);
};
}
async function advancePreviewDebounce() {
act(() => {
vi.advanceTimersByTime(300);
});
await act(async () => {});
}
describe("useCommentTriggerPreview", () => {
beforeEach(() => {
vi.useFakeTimers();
previewCommentTriggers.mockResolvedValue({ agents: [] });
});
afterEach(() => {
vi.useRealTimers();
previewCommentTriggers.mockReset();
});
it("debounces preview and sends the latest content for an unchanged signature", async () => {
const { rerender } = renderHook(
({ content }) => useCommentTriggerPreview({ issueId: "issue-1", content }),
{
wrapper: createWrapper(),
initialProps: { content: "hello" },
},
);
rerender({ content: "hello with more ordinary text" });
expect(previewCommentTriggers).not.toHaveBeenCalled();
await advancePreviewDebounce();
expect(previewCommentTriggers).toHaveBeenCalledTimes(1);
expect(previewCommentTriggers).toHaveBeenCalledWith(
"issue-1",
"hello with more ordinary text",
undefined,
);
});
it("uses the TanStack Query cache when the debounced signature repeats", async () => {
const agentA = "00000000-0000-0000-0000-000000000001";
const content = `[@A](mention://agent/${agentA})`;
const { rerender } = renderHook(
({ content }) => useCommentTriggerPreview({ issueId: "issue-1", content }),
{
wrapper: createWrapper(),
initialProps: { content },
},
);
await advancePreviewDebounce();
expect(previewCommentTriggers).toHaveBeenCalledTimes(1);
rerender({ content: "" });
rerender({ content });
await advancePreviewDebounce();
expect(previewCommentTriggers).toHaveBeenCalledTimes(1);
});
it("fetches again when routing mention tokens change", async () => {
const agentA = "00000000-0000-0000-0000-000000000001";
const agentB = "00000000-0000-0000-0000-000000000002";
const { rerender } = renderHook(
({ content }) => useCommentTriggerPreview({ issueId: "issue-1", content }),
{
wrapper: createWrapper(),
initialProps: { content: `[@A](mention://agent/${agentA})` },
},
);
await advancePreviewDebounce();
expect(previewCommentTriggers).toHaveBeenCalledTimes(1);
rerender({ content: `[@A](mention://agent/${agentA}) [@B](mention://agent/${agentB})` });
await advancePreviewDebounce();
expect(previewCommentTriggers).toHaveBeenCalledTimes(2);
});
});
describe("commentTriggerPreviewSignature", () => {
it("ignores ordinary text changes", () => {

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "@multica/core/api";
import type { CommentTriggerPreviewAgent } from "@multica/core/types";
@@ -32,6 +33,25 @@ export function commentTriggerPreviewSignature(content: string): string {
return `nonempty|${tokens.join(",")}`;
}
function useDebouncedSignature(signature: string) {
const [debouncedSignature, setDebouncedSignature] = useState("empty");
useEffect(() => {
if (signature === "empty") {
setDebouncedSignature("empty");
return;
}
const timer = window.setTimeout(() => {
setDebouncedSignature(signature);
}, COMMENT_TRIGGER_PREVIEW_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [signature]);
return debouncedSignature;
}
export function useCommentTriggerPreview({
issueId,
parentId,
@@ -42,53 +62,33 @@ export function useCommentTriggerPreview({
content: string;
}): UseCommentTriggerPreviewResult {
const signature = useMemo(() => commentTriggerPreviewSignature(content), [content]);
const cacheRef = useRef(new Map<string, CommentTriggerPreviewAgent[]>());
const debouncedSignature = useDebouncedSignature(signature);
const contentRef = useRef(content);
const requestIdRef = useRef(0);
const [agents, setAgents] = useState<CommentTriggerPreviewAgent[]>([]);
const [status, setStatus] = useState<CommentTriggerPreviewStatus>("idle");
useEffect(() => {
contentRef.current = content;
}, [content]);
useEffect(() => {
const cacheKey = `${issueId}:${parentId ?? ""}:${signature}`;
requestIdRef.current += 1;
const requestId = requestIdRef.current;
const previewQuery = useQuery({
queryKey: ["issues", "comment-trigger-preview", issueId, parentId ?? "", debouncedSignature],
queryFn: () => api.previewCommentTriggers(issueId, contentRef.current, parentId),
enabled: debouncedSignature !== "empty",
retry: false,
staleTime: Infinity,
});
if (signature === "empty") {
setAgents([]);
setStatus("idle");
return;
}
if (debouncedSignature === "empty") {
return { agents: [], status: "idle" };
}
const cached = cacheRef.current.get(cacheKey);
if (cached) {
setAgents(cached);
setStatus("idle");
return;
}
const status: CommentTriggerPreviewStatus = previewQuery.isError
? "error"
: previewQuery.isFetching
? "loading"
: "idle";
setStatus("loading");
const timer = window.setTimeout(() => {
api.previewCommentTriggers(issueId, contentRef.current, parentId)
.then((preview) => {
if (requestIdRef.current !== requestId) return;
const nextAgents = preview.agents ?? [];
cacheRef.current.set(cacheKey, nextAgents);
setAgents(nextAgents);
setStatus("idle");
})
.catch(() => {
if (requestIdRef.current !== requestId) return;
setAgents([]);
setStatus("error");
});
}, COMMENT_TRIGGER_PREVIEW_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [issueId, parentId, signature]);
return { agents, status };
return {
agents: previewQuery.data?.agents ?? [],
status,
};
}