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 { 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<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", () => {
+25
View File
@@ -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<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 = (
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);
},
});
@@ -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]);
@@ -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],
);