mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-06 05:49:12 +02:00
Use TanStack Query for trigger preview
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user