feat(site/src): reintroduce chat search cache invalidation (#27892)

Stacked on `feat/chat-cache-semantic-ops`.

## Bug

Chat search results (`chatSearch` queries) were never invalidated, so
the search dialog served stale results after archives, renames,
deletions, new chats, message edits, and watch-driven status changes.

## Fix

Reintroduces `invalidateChatSearches`, a prefix invalidation over the
module-private `chatSearchFamilyKey`, and wires it into:

- `chats.ts`: `archiveChat.onSettled`, `unarchiveChat.onSettled`,
`updateChatTitle.onSettled`, `editChatMessage.onSettled`,
`createChat.onSuccess`
- `useChatStore.ts`: `upsertCacheMessages` (unconditional; assistant
message bodies are indexed too) and `replaceCacheMessages`
- `AgentsPageLayout.tsx`: the `deleted` and root `created` watch
branches, the merge watch branch (gated by a new exported
`shouldInvalidateChatSearches` helper), the `has_unread` clearing
effect, the `onOpen` reconnect convergence, and
`archiveAndDeleteMutation.onSuccess`

The merge-branch gate only invalidates for search-affecting event kinds
(`title_change`, `status_change`, `diff_status_change`,
`action_required`). `summary_change`, `chat_summary_change`, and
`context_dirty` are excluded: stale `last_turn_summary` subtitles are
accepted until reconciliation lands.

## Backend constraint

Message bodies only enter full-text search via the dbpurge backfill
(`search_tsv` starts NULL and is populated every 10 minutes). Frontend
invalidation fixes removals, ordering, and rendered fields immediately,
but a chat that newly matches on message body will not appear until the
next backfill. This is a server-side eventual-consistency limit we
accept.

## Scope decisions (confirmed)

- No invalidation in `createChatMessage.onSuccess`: the send path
already routes through `useChatStore.upsertCacheMessages`; adding both
would double-invalidate every send.
- No invalidation for `pinChat`/`unpinChat`/`reorderPinnedChat`
(ordering-only, self-heals).
- ACL mutations out of scope.
- No coalescing/debouncing; that belongs to a later reconciler PR.

## Tests

- Prefix invalidation: multiple distinct `q` params invalidated,
bystanders (list, by-workspace, entity, messages, cost tree) untouched.
- Mutation wiring: settlement of `archiveChat`, `unarchiveChat`,
`updateChatTitle`, `editChatMessage`, and `createChat` invalidates a
seeded search key; `createChatMessage` asserted NOT to.
- `shouldInvalidateChatSearches` unit-tested over all
`ChatWatchEventKind` values.

PR generated by Coder Agents.
This commit is contained in:
Danielle Maywood
2026-08-06 10:25:36 +01:00
committed by GitHub
parent 4b7494be72
commit 632eecc090
4 changed files with 152 additions and 0 deletions
+114
View File
@@ -2,6 +2,7 @@ import { QueryClient } from "react-query";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { API } from "#/api/api"; import { API } from "#/api/api";
import type * as TypesGen from "#/api/typesGenerated"; import type * as TypesGen from "#/api/typesGenerated";
import { ChatWatchEventKinds } from "#/api/typesGenerated";
import { import {
ERROR_STATUSES, ERROR_STATUSES,
SUCCESS_STATUSES, SUCCESS_STATUSES,
@@ -46,6 +47,7 @@ import {
invalidateChatListQueries, invalidateChatListQueries,
invalidateChatMessages, invalidateChatMessages,
invalidateChatPrompts, invalidateChatPrompts,
invalidateChatSearches,
invalidateChatsByWorkspace, invalidateChatsByWorkspace,
mergeWatchedChatIntoCaches, mergeWatchedChatIntoCaches,
mergeWatchedChatSummary, mergeWatchedChatSummary,
@@ -60,6 +62,7 @@ import {
reorderPinnedChat, reorderPinnedChat,
setChatGroupRole, setChatGroupRole,
setChatUserRole, setChatUserRole,
shouldInvalidateChatSearches,
TERMINAL_RUN_STATUSES, TERMINAL_RUN_STATUSES,
toChatListParams, toChatListParams,
unarchiveChat, unarchiveChat,
@@ -945,6 +948,7 @@ describe("mutation invalidation scope", () => {
const queryClient = createTestQueryClient(); const queryClient = createTestQueryClient();
const chatId = "chat-1"; const chatId = "chat-1";
seedAllActiveQueries(queryClient, chatId); seedAllActiveQueries(queryClient, chatId);
queryClient.setQueryData(chatSearch({ q: "alpha" }).queryKey, []);
const mutation = createChatMessage(queryClient, chatId); const mutation = createChatMessage(queryClient, chatId);
await mutation.onSuccess?.(); await mutation.onSuccess?.();
@@ -956,6 +960,14 @@ describe("mutation invalidation scope", () => {
`${label} should NOT be invalidated by createChatMessage`, `${label} should NOT be invalidated by createChatMessage`,
).not.toBe(true); ).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 () => { 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", "chat list should NOT be invalidated",
).not.toBe(true); ).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", () => { describe("chatListKey shape", () => {
@@ -3057,6 +3114,63 @@ describe("semantic cache operations: prefix invalidations", () => {
"messages entry should NOT be invalidated", "messages entry should NOT be invalidated",
).not.toBe(true); ).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<TypesGen.ChatWatchEventKind, boolean> = {
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", () => { describe("semantic cache operations: cancellation", () => {
+25
View File
@@ -648,6 +648,26 @@ export const invalidateChatsByWorkspace = (queryClient: QueryClient) =>
queryKey: chatsByWorkspaceFamilyKey, 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<TypesGen.ChatWatchEventKind>([
"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 = ( export const invalidateChatDebugRuns = (
queryClient: QueryClient, queryClient: QueryClient,
chatId: string, chatId: string,
@@ -993,6 +1013,7 @@ export const archiveChat = (queryClient: QueryClient) => ({
void invalidateChatListQueries(queryClient); void invalidateChatListQueries(queryClient);
void invalidateChatEntity(queryClient, chatId); void invalidateChatEntity(queryClient, chatId);
void invalidateChatsByWorkspace(queryClient); void invalidateChatsByWorkspace(queryClient);
void invalidateChatSearches(queryClient);
}, },
}); });
@@ -1042,6 +1063,7 @@ export const unarchiveChat = (queryClient: QueryClient) => ({
void invalidateChatListQueries(queryClient); void invalidateChatListQueries(queryClient);
void invalidateChatEntity(queryClient, chatId); void invalidateChatEntity(queryClient, chatId);
void invalidateChatsByWorkspace(queryClient); void invalidateChatsByWorkspace(queryClient);
void invalidateChatSearches(queryClient);
}, },
}); });
@@ -1329,6 +1351,7 @@ export const updateChatTitle = (queryClient: QueryClient) => ({
) => { ) => {
void invalidateChatListQueries(queryClient); void invalidateChatListQueries(queryClient);
void invalidateChatEntity(queryClient, chatId); void invalidateChatEntity(queryClient, chatId);
void invalidateChatSearches(queryClient);
}, },
}); });
@@ -1409,6 +1432,7 @@ export const createChat = (queryClient: QueryClient) => ({
onSuccess: () => { onSuccess: () => {
void invalidateChatListQueries(queryClient); void invalidateChatListQueries(queryClient);
void invalidateChatsByWorkspace(queryClient); void invalidateChatsByWorkspace(queryClient);
void invalidateChatSearches(queryClient);
}, },
}); });
@@ -1501,6 +1525,7 @@ export const editChatMessage = (queryClient: QueryClient, chatId: string) => ({
void invalidateChatEntity(queryClient, chatId); void invalidateChatEntity(queryClient, chatId);
void invalidateChatPrompts(queryClient, chatId); void invalidateChatPrompts(queryClient, chatId);
void invalidateChatDebugRuns(queryClient, chatId); void invalidateChatDebugRuns(queryClient, chatId);
void invalidateChatSearches(queryClient);
}, },
}); });
@@ -29,6 +29,7 @@ import {
invalidateChatDiffContents, invalidateChatDiffContents,
invalidateChatEntity, invalidateChatEntity,
invalidateChatListQueries, invalidateChatListQueries,
invalidateChatSearches,
invalidateChatsByWorkspace, invalidateChatsByWorkspace,
mergeWatchedChatIntoCaches, mergeWatchedChatIntoCaches,
pinChat, pinChat,
@@ -38,6 +39,7 @@ import {
removeChatEntity, removeChatEntity,
removeChildFromParentInCache, removeChildFromParentInCache,
reorderPinnedChat, reorderPinnedChat,
shouldInvalidateChatSearches,
unarchiveChat, unarchiveChat,
unpinChat, unpinChat,
updateChatTitle, updateChatTitle,
@@ -308,6 +310,7 @@ const AgentsPageLayout: FC = () => {
void invalidateChatListQueries(queryClient); void invalidateChatListQueries(queryClient);
void invalidateChatEntity(queryClient, chatId); void invalidateChatEntity(queryClient, chatId);
void invalidateChatsByWorkspace(queryClient); void invalidateChatsByWorkspace(queryClient);
void invalidateChatSearches(queryClient);
void invalidateWorkspaceMutationQueries(queryClient, { void invalidateWorkspaceMutationQueries(queryClient, {
organizationName, organizationName,
username: user.username, username: user.username,
@@ -576,6 +579,7 @@ const AgentsPageLayout: FC = () => {
return changed ? next : chats; return changed ? next : chats;
}); });
void invalidateChatListQueries(queryClient); void invalidateChatListQueries(queryClient);
void invalidateChatSearches(queryClient);
}, [agentId, queryClient]); }, [agentId, queryClient]);
useEffect(() => { useEffect(() => {
return createReconnectingWebSocket({ return createReconnectingWebSocket({
@@ -615,6 +619,7 @@ const AgentsPageLayout: FC = () => {
); );
removeChildFromParentInCache(queryClient, updatedChat.id); removeChildFromParentInCache(queryClient, updatedChat.id);
removeChatEntity(queryClient, updatedChat.id); removeChatEntity(queryClient, updatedChat.id);
void invalidateChatSearches(queryClient);
return; return;
} }
if (chatEvent.kind === "diff_status_change") { if (chatEvent.kind === "diff_status_change") {
@@ -650,6 +655,7 @@ const AgentsPageLayout: FC = () => {
} else { } else {
prependToInfiniteChatsCache(queryClient, updatedChat); prependToInfiniteChatsCache(queryClient, updatedChat);
void invalidateChatListQueries(queryClient); void invalidateChatListQueries(queryClient);
void invalidateChatSearches(queryClient);
} }
} else { } else {
mergeWatchedChatIntoCaches(queryClient, updatedChat, { mergeWatchedChatIntoCaches(queryClient, updatedChat, {
@@ -659,6 +665,9 @@ const AgentsPageLayout: FC = () => {
if (shouldInvalidateFilteredChatList(updatedChat, chatEvent.kind)) { if (shouldInvalidateFilteredChatList(updatedChat, chatEvent.kind)) {
void invalidateChatListQueries(queryClient); void invalidateChatListQueries(queryClient);
} }
if (shouldInvalidateChatSearches(chatEvent.kind)) {
void invalidateChatSearches(queryClient);
}
const costChatId = chatCostIdToInvalidate( const costChatId = chatCostIdToInvalidate(
updatedChat, updatedChat,
chatEvent.kind, chatEvent.kind,
@@ -681,6 +690,7 @@ const AgentsPageLayout: FC = () => {
}, },
onOpen() { onOpen() {
void invalidateChatListQueries(queryClient); void invalidateChatListQueries(queryClient);
void invalidateChatSearches(queryClient);
}, },
}); });
}, [queryClient]); }, [queryClient]);
@@ -14,6 +14,7 @@ import { watchChat } from "#/api/api";
import { import {
chatMessagesKey, chatMessagesKey,
invalidateChatPrompts, invalidateChatPrompts,
invalidateChatSearches,
patchChatMessages, patchChatMessages,
updateInfiniteChatsCache, updateInfiniteChatsCache,
} from "#/api/queries/chats"; } from "#/api/queries/chats";
@@ -234,6 +235,7 @@ export const useChatStore = (
if (hasNewUserPrompt) { if (hasNewUserPrompt) {
void invalidateChatPrompts(queryClient, chatID); void invalidateChatPrompts(queryClient, chatID);
} }
void invalidateChatSearches(queryClient);
}, },
[chatID, queryClient], [chatID, queryClient],
); );
@@ -255,6 +257,7 @@ export const useChatStore = (
pageParams: currentData.pageParams.slice(0, 1), pageParams: currentData.pageParams.slice(0, 1),
}; };
}); });
void invalidateChatSearches(queryClient);
}, },
[chatID, queryClient], [chatID, queryClient],
); );