From dbd41cbc5bf7d2d97437b9c9cf73012573585b41 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 5 Aug 2026 14:53:47 +0000 Subject: [PATCH] feat(site/src): reintroduce chat search cache invalidation --- site/src/api/queries/chats.test.ts | 114 ++++++++++++++++++ site/src/api/queries/chats.ts | 25 ++++ .../src/pages/AgentsPage/AgentsPageLayout.tsx | 10 ++ .../ChatConversation/useChatStore.ts | 3 + 4 files changed, 152 insertions(+) diff --git a/site/src/api/queries/chats.test.ts b/site/src/api/queries/chats.test.ts index 5f4afc546dd06..5f6e68ce70bcd 100644 --- a/site/src/api/queries/chats.test.ts +++ b/site/src/api/queries/chats.test.ts @@ -2,6 +2,7 @@ import { QueryClient } from "react-query"; import { describe, expect, it, vi } from "vitest"; import { API } from "#/api/api"; import type * as TypesGen from "#/api/typesGenerated"; +import { ChatWatchEventKinds } from "#/api/typesGenerated"; import { ERROR_STATUSES, SUCCESS_STATUSES, @@ -46,6 +47,7 @@ import { invalidateChatListQueries, invalidateChatMessages, invalidateChatPrompts, + invalidateChatSearches, invalidateChatsByWorkspace, mergeWatchedChatIntoCaches, mergeWatchedChatSummary, @@ -60,6 +62,7 @@ import { reorderPinnedChat, setChatGroupRole, setChatUserRole, + shouldInvalidateChatSearches, TERMINAL_RUN_STATUSES, toChatListParams, unarchiveChat, @@ -945,6 +948,7 @@ describe("mutation invalidation scope", () => { const queryClient = createTestQueryClient(); const chatId = "chat-1"; seedAllActiveQueries(queryClient, chatId); + queryClient.setQueryData(chatSearch({ q: "alpha" }).queryKey, []); const mutation = createChatMessage(queryClient, chatId); await mutation.onSuccess?.(); @@ -956,6 +960,14 @@ describe("mutation invalidation scope", () => { `${label} should NOT be invalidated by createChatMessage`, ).not.toBe(true); } + // The send path invalidates searches through + // useChatStore.upsertCacheMessages; doing it here too would + // double-invalidate every send. + expect( + queryClient.getQueryState(chatSearch({ q: "alpha" }).queryKey) + ?.isInvalidated, + "chat searches should NOT be invalidated by createChatMessage", + ).not.toBe(true); }); it("createChatMessage invalidates debug runs and chat detail, not messages", async () => { @@ -1550,6 +1562,51 @@ describe("mutation invalidation scope", () => { "chat list should NOT be invalidated", ).not.toBe(true); }); + + it.each<{ + name: string; + settle: (queryClient: QueryClient) => unknown; + }>([ + { + name: "archiveChat onSettled", + settle: (queryClient) => + archiveChat(queryClient).onSettled(undefined, undefined, "chat-1"), + }, + { + name: "unarchiveChat onSettled", + settle: (queryClient) => + unarchiveChat(queryClient).onSettled(undefined, undefined, "chat-1"), + }, + { + name: "updateChatTitle onSettled", + settle: (queryClient) => + updateChatTitle(queryClient).onSettled(undefined, undefined, { + chatId: "chat-1", + title: "New", + }), + }, + { + name: "editChatMessage onSettled", + settle: (queryClient) => + editChatMessage(queryClient, "chat-1").onSettled(), + }, + { + name: "createChat onSuccess", + settle: (queryClient) => createChat(queryClient).onSuccess(), + }, + ])("$name invalidates chat searches", async ({ settle }) => { + const queryClient = createTestQueryClient(); + queryClient.setQueryData(chatSearch({ q: "alpha" }).queryKey, []); + + settle(queryClient); + await new Promise((r) => setTimeout(r, 0)); + + expect( + queryClient.getQueryState(chatSearch({ q: "alpha" }).queryKey) + ?.isInvalidated, + "chat search entry should be invalidated", + ).toBe(true); + }); }); describe("chatListKey shape", () => { @@ -3057,6 +3114,63 @@ describe("semantic cache operations: prefix invalidations", () => { "messages entry should NOT be invalidated", ).not.toBe(true); }); + + it("invalidateChatSearches touches every search entry and nothing outside the family", async () => { + const queryClient = createTestQueryClient(); + queryClient.setQueryData(chatSearch({ q: "alpha" }).queryKey, []); + queryClient.setQueryData(chatSearch({ q: "beta" }).queryKey, []); + seedInfiniteChats(queryClient, [makeChat("chat-1")]); + queryClient.setQueryData(chatsByWorkspace(["ws-1"]).queryKey, {}); + queryClient.setQueryData(chatEntityKey("chat-1"), makeChat("chat-1")); + queryClient.setQueryData(chatMessagesKey("chat-1"), []); + queryClient.setQueryData(chatCostTreeKey("chat-1"), {}); + + await invalidateChatSearches(queryClient); + + expect( + queryClient.getQueryState(chatSearch({ q: "alpha" }).queryKey) + ?.isInvalidated, + ).toBe(true); + expect( + queryClient.getQueryState(chatSearch({ q: "beta" }).queryKey) + ?.isInvalidated, + ).toBe(true); + for (const [label, key] of [ + ["chat list", infiniteChatsTestKey], + ["by-workspace", chatsByWorkspace(["ws-1"]).queryKey], + ["chat detail", chatEntityKey("chat-1")], + ["messages", chatMessagesKey("chat-1")], + ["cost tree", chatCostTreeKey("chat-1")], + ] as const) { + expect( + queryClient.getQueryState(key)?.isInvalidated, + `${label} entry should NOT be invalidated`, + ).not.toBe(true); + } + }); + + describe(shouldInvalidateChatSearches.name, () => { + // Search results render title, status, diff status, and the + // action-required badge. Summary and context events are excluded: + // stale last_turn_summary subtitles are accepted until + // reconciliation lands. The created and deleted kinds are handled + // by their own watch branches before the merge path runs. + const expectedByKind: Record = { + action_required: true, + chat_summary_change: false, + context_dirty: false, + created: false, + deleted: false, + diff_status_change: true, + status_change: true, + summary_change: false, + title_change: true, + }; + + it.each(ChatWatchEventKinds)("%s", (kind) => { + expect(shouldInvalidateChatSearches(kind)).toBe(expectedByKind[kind]); + }); + }); }); describe("semantic cache operations: cancellation", () => { diff --git a/site/src/api/queries/chats.ts b/site/src/api/queries/chats.ts index 49a4896c6ae7c..5a0d4b364e1a2 100644 --- a/site/src/api/queries/chats.ts +++ b/site/src/api/queries/chats.ts @@ -648,6 +648,26 @@ export const invalidateChatsByWorkspace = (queryClient: QueryClient) => queryKey: chatsByWorkspaceFamilyKey, }); +// Watch events that change fields rendered in search results (title, +// status, diff status, action-required badge). Summary events are +// deliberately excluded: stale last_turn_summary subtitles are accepted +// until reconciliation lands. +const SEARCH_AFFECTING_EVENT_KINDS = new Set([ + "title_change", + "status_change", + "diff_status_change", + "action_required", +]); + +export const shouldInvalidateChatSearches = ( + eventKind: TypesGen.ChatWatchEventKind, +): boolean => SEARCH_AFFECTING_EVENT_KINDS.has(eventKind); + +export const invalidateChatSearches = (queryClient: QueryClient) => + queryClient.invalidateQueries({ + queryKey: chatSearchFamilyKey, + }); + export const invalidateChatDebugRuns = ( queryClient: QueryClient, chatId: string, @@ -993,6 +1013,7 @@ export const archiveChat = (queryClient: QueryClient) => ({ void invalidateChatListQueries(queryClient); void invalidateChatEntity(queryClient, chatId); void invalidateChatsByWorkspace(queryClient); + void invalidateChatSearches(queryClient); }, }); @@ -1042,6 +1063,7 @@ export const unarchiveChat = (queryClient: QueryClient) => ({ void invalidateChatListQueries(queryClient); void invalidateChatEntity(queryClient, chatId); void invalidateChatsByWorkspace(queryClient); + void invalidateChatSearches(queryClient); }, }); @@ -1329,6 +1351,7 @@ export const updateChatTitle = (queryClient: QueryClient) => ({ ) => { void invalidateChatListQueries(queryClient); void invalidateChatEntity(queryClient, chatId); + void invalidateChatSearches(queryClient); }, }); @@ -1409,6 +1432,7 @@ export const createChat = (queryClient: QueryClient) => ({ onSuccess: () => { void invalidateChatListQueries(queryClient); void invalidateChatsByWorkspace(queryClient); + void invalidateChatSearches(queryClient); }, }); @@ -1501,6 +1525,7 @@ export const editChatMessage = (queryClient: QueryClient, chatId: string) => ({ void invalidateChatEntity(queryClient, chatId); void invalidateChatPrompts(queryClient, chatId); void invalidateChatDebugRuns(queryClient, chatId); + void invalidateChatSearches(queryClient); }, }); diff --git a/site/src/pages/AgentsPage/AgentsPageLayout.tsx b/site/src/pages/AgentsPage/AgentsPageLayout.tsx index bfaf3eacc9ac0..fa4abedeba3c9 100644 --- a/site/src/pages/AgentsPage/AgentsPageLayout.tsx +++ b/site/src/pages/AgentsPage/AgentsPageLayout.tsx @@ -29,6 +29,7 @@ import { invalidateChatDiffContents, invalidateChatEntity, invalidateChatListQueries, + invalidateChatSearches, invalidateChatsByWorkspace, mergeWatchedChatIntoCaches, pinChat, @@ -38,6 +39,7 @@ import { removeChatEntity, removeChildFromParentInCache, reorderPinnedChat, + shouldInvalidateChatSearches, unarchiveChat, unpinChat, updateChatTitle, @@ -308,6 +310,7 @@ const AgentsPageLayout: FC = () => { void invalidateChatListQueries(queryClient); void invalidateChatEntity(queryClient, chatId); void invalidateChatsByWorkspace(queryClient); + void invalidateChatSearches(queryClient); void invalidateWorkspaceMutationQueries(queryClient, { organizationName, username: user.username, @@ -576,6 +579,7 @@ const AgentsPageLayout: FC = () => { return changed ? next : chats; }); void invalidateChatListQueries(queryClient); + void invalidateChatSearches(queryClient); }, [agentId, queryClient]); useEffect(() => { return createReconnectingWebSocket({ @@ -615,6 +619,7 @@ const AgentsPageLayout: FC = () => { ); removeChildFromParentInCache(queryClient, updatedChat.id); removeChatEntity(queryClient, updatedChat.id); + void invalidateChatSearches(queryClient); return; } if (chatEvent.kind === "diff_status_change") { @@ -650,6 +655,7 @@ const AgentsPageLayout: FC = () => { } else { prependToInfiniteChatsCache(queryClient, updatedChat); void invalidateChatListQueries(queryClient); + void invalidateChatSearches(queryClient); } } else { mergeWatchedChatIntoCaches(queryClient, updatedChat, { @@ -659,6 +665,9 @@ const AgentsPageLayout: FC = () => { if (shouldInvalidateFilteredChatList(updatedChat, chatEvent.kind)) { void invalidateChatListQueries(queryClient); } + if (shouldInvalidateChatSearches(chatEvent.kind)) { + void invalidateChatSearches(queryClient); + } const costChatId = chatCostIdToInvalidate( updatedChat, chatEvent.kind, @@ -681,6 +690,7 @@ const AgentsPageLayout: FC = () => { }, onOpen() { void invalidateChatListQueries(queryClient); + void invalidateChatSearches(queryClient); }, }); }, [queryClient]); diff --git a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts index d56fc8b5fac00..298a8b1d7df17 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts @@ -14,6 +14,7 @@ import { watchChat } from "#/api/api"; import { chatMessagesKey, invalidateChatPrompts, + invalidateChatSearches, patchChatMessages, updateInfiniteChatsCache, } from "#/api/queries/chats"; @@ -234,6 +235,7 @@ export const useChatStore = ( if (hasNewUserPrompt) { void invalidateChatPrompts(queryClient, chatID); } + void invalidateChatSearches(queryClient); }, [chatID, queryClient], ); @@ -255,6 +257,7 @@ export const useChatStore = ( pageParams: currentData.pageParams.slice(0, 1), }; }); + void invalidateChatSearches(queryClient); }, [chatID, queryClient], );