From 273a756869f823549769554f5ec83af46e0ccb46 Mon Sep 17 00:00:00 2001 From: Jason Date: Tue, 10 Mar 2026 22:59:14 +0800 Subject: [PATCH] fix: sync session search index with query data to refresh list after deletion Replace useRef+useEffect async index rebuild with useMemo so the FlexSearch index and the sessions array always reference the same data. This ensures filtered search results update immediately when a session is deleted via TanStack Query setQueryData. --- src/hooks/useSessionSearch.ts | 61 +++----------------- tests/components/SessionManagerPage.test.tsx | 48 +++++++++++++++ 2 files changed, 56 insertions(+), 53 deletions(-) diff --git a/src/hooks/useSessionSearch.ts b/src/hooks/useSessionSearch.ts index b22437431..2f1b8c920 100644 --- a/src/hooks/useSessionSearch.ts +++ b/src/hooks/useSessionSearch.ts @@ -1,10 +1,7 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo } from "react"; import FlexSearch from "flexsearch"; import type { SessionMeta } from "@/types"; -// FlexSearch Index 类型 -type FlexSearchIndex = InstanceType; - interface UseSessionSearchOptions { sessions: SessionMeta[]; providerFilter: string; @@ -12,7 +9,6 @@ interface UseSessionSearchOptions { interface UseSessionSearchResult { search: (query: string) => SessionMeta[]; - isIndexing: boolean; } /** @@ -23,27 +19,14 @@ export function useSessionSearch({ sessions, providerFilter, }: UseSessionSearchOptions): UseSessionSearchResult { - const [isIndexing, setIsIndexing] = useState(false); - - // 会话元数据索引 - const indexRef = useRef(null); - // 索引 ID 到 session 的映射 - const sessionByIdxRef = useRef([]); - - // 初始化索引 - useEffect(() => { - setIsIndexing(true); - - // 创建索引实例 + const index = useMemo(() => { // 使用 forward tokenizer 支持中文前缀搜索 - const index = new FlexSearch.Index({ + const nextIndex = new FlexSearch.Index({ tokenize: "forward", resolution: 9, }); - // 索引所有会话 sessions.forEach((session, idx) => { - // 索引会话元数据 const metaContent = [ session.sessionId, session.title, @@ -54,13 +37,10 @@ export function useSessionSearch({ .filter(Boolean) .join(" "); - index.add(idx, metaContent); + nextIndex.add(idx, metaContent); }); - indexRef.current = index; - sessionByIdxRef.current = sessions; - - setIsIndexing(false); + return nextIndex; }, [sessions]); // 搜索函数 @@ -83,37 +63,12 @@ export function useSessionSearch({ }); } - const index = indexRef.current; - - if (!index) { - // 索引未就绪,使用简单搜索 - return filtered - .filter((session) => { - const haystack = [ - session.sessionId, - session.title, - session.summary, - session.projectDir, - session.sourcePath, - ] - .filter(Boolean) - .join(" ") - .toLowerCase(); - return haystack.includes(needle); - }) - .sort((a, b) => { - const aTs = a.lastActiveAt ?? a.createdAt ?? 0; - const bTs = b.lastActiveAt ?? b.createdAt ?? 0; - return bTs - aTs; - }); - } - // 使用 FlexSearch 搜索 const results = index.search(needle, { limit: 100 }) as number[]; // 转换为 session 并过滤 const matchedSessions = results - .map((idx) => sessionByIdxRef.current[idx]) + .map((idx) => sessions[idx]) .filter( (session) => session && @@ -127,8 +82,8 @@ export function useSessionSearch({ return bTs - aTs; }); }, - [sessions, providerFilter], + [index, providerFilter, sessions], ); - return useMemo(() => ({ search, isIndexing }), [search, isIndexing]); + return useMemo(() => ({ search }), [search]); } diff --git a/tests/components/SessionManagerPage.test.tsx b/tests/components/SessionManagerPage.test.tsx index b5195d368..b1301eeeb 100644 --- a/tests/components/SessionManagerPage.test.tsx +++ b/tests/components/SessionManagerPage.test.tsx @@ -69,6 +69,18 @@ const renderPage = () => { ); }; +const openSearch = () => { + const searchButton = Array.from(screen.getAllByRole("button")).find((button) => + button.querySelector(".lucide-search"), + ); + + if (!searchButton) { + throw new Error("Search button not found"); + } + + fireEvent.click(searchButton); +}; + describe("SessionManagerPage", () => { beforeEach(() => { toastSuccessMock.mockReset(); @@ -137,4 +149,40 @@ describe("SessionManagerPage", () => { expect(toastErrorMock).not.toHaveBeenCalled(); expect(toastSuccessMock).toHaveBeenCalled(); }); + + it("removes a deleted session from filtered search results", async () => { + renderPage(); + + await waitFor(() => + expect( + screen.getByRole("heading", { name: "Alpha Session" }), + ).toBeInTheDocument(), + ); + + openSearch(); + + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "Alpha" }, + }); + + await waitFor(() => + expect(screen.getAllByText("Alpha Session")).toHaveLength(2), + ); + + fireEvent.click(screen.getByRole("button", { name: /删除会话/i })); + + const dialog = screen.getByTestId("confirm-dialog"); + fireEvent.click(within(dialog).getByRole("button", { name: /删除会话/i })); + + await waitFor(() => + expect(screen.queryByText("Alpha Session")).not.toBeInTheDocument(), + ); + + expect(screen.getByText("sessionManager.selectSession")).toBeInTheDocument(); + expect( + screen.queryByText("sessionManager.emptySession"), + ).not.toBeInTheDocument(); + expect(toastErrorMock).not.toHaveBeenCalled(); + expect(toastSuccessMock).toHaveBeenCalled(); + }); });