diff --git a/packages/views/issues/hooks/use-comment-trigger-preview.test.ts b/packages/views/issues/hooks/use-comment-trigger-preview.test.ts index 246f74b41..75e5aba4a 100644 --- a/packages/views/issues/hooks/use-comment-trigger-preview.test.ts +++ b/packages/views/issues/hooks/use-comment-trigger-preview.test.ts @@ -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", () => { diff --git a/packages/views/issues/hooks/use-comment-trigger-preview.ts b/packages/views/issues/hooks/use-comment-trigger-preview.ts index 1f5147254..b5b7f968c 100644 --- a/packages/views/issues/hooks/use-comment-trigger-preview.ts +++ b/packages/views/issues/hooks/use-comment-trigger-preview.ts @@ -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()); + const debouncedSignature = useDebouncedSignature(signature); const contentRef = useRef(content); - const requestIdRef = useRef(0); - const [agents, setAgents] = useState([]); - const [status, setStatus] = useState("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, + }; }