Skip to content

Commit 632eecc

Browse files
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.
1 parent 4b7494b commit 632eecc

4 files changed

Lines changed: 152 additions & 0 deletions

File tree

site/src/api/queries/chats.test.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { QueryClient } from "react-query";
22
import { describe, expect, it, vi } from "vitest";
33
import { API } from "#/api/api";
44
import type * as TypesGen from "#/api/typesGenerated";
5+
import { ChatWatchEventKinds } from "#/api/typesGenerated";
56
import {
67
ERROR_STATUSES,
78
SUCCESS_STATUSES,
@@ -46,6 +47,7 @@ import {
4647
invalidateChatListQueries,
4748
invalidateChatMessages,
4849
invalidateChatPrompts,
50+
invalidateChatSearches,
4951
invalidateChatsByWorkspace,
5052
mergeWatchedChatIntoCaches,
5153
mergeWatchedChatSummary,
@@ -60,6 +62,7 @@ import {
6062
reorderPinnedChat,
6163
setChatGroupRole,
6264
setChatUserRole,
65+
shouldInvalidateChatSearches,
6366
TERMINAL_RUN_STATUSES,
6467
toChatListParams,
6568
unarchiveChat,
@@ -945,6 +948,7 @@ describe("mutation invalidation scope", () => {
945948
const queryClient = createTestQueryClient();
946949
const chatId = "chat-1";
947950
seedAllActiveQueries(queryClient, chatId);
951+
queryClient.setQueryData(chatSearch({ q: "alpha" }).queryKey, []);
948952

949953
const mutation = createChatMessage(queryClient, chatId);
950954
await mutation.onSuccess?.();
@@ -956,6 +960,14 @@ describe("mutation invalidation scope", () => {
956960
`${label} should NOT be invalidated by createChatMessage`,
957961
).not.toBe(true);
958962
}
963+
// The send path invalidates searches through
964+
// useChatStore.upsertCacheMessages; doing it here too would
965+
// double-invalidate every send.
966+
expect(
967+
queryClient.getQueryState(chatSearch({ q: "alpha" }).queryKey)
968+
?.isInvalidated,
969+
"chat searches should NOT be invalidated by createChatMessage",
970+
).not.toBe(true);
959971
});
960972

961973
it("createChatMessage invalidates debug runs and chat detail, not messages", async () => {
@@ -1550,6 +1562,51 @@ describe("mutation invalidation scope", () => {
15501562
"chat list should NOT be invalidated",
15511563
).not.toBe(true);
15521564
});
1565+
1566+
it.each<{
1567+
name: string;
1568+
settle: (queryClient: QueryClient) => unknown;
1569+
}>([
1570+
{
1571+
name: "archiveChat onSettled",
1572+
settle: (queryClient) =>
1573+
archiveChat(queryClient).onSettled(undefined, undefined, "chat-1"),
1574+
},
1575+
{
1576+
name: "unarchiveChat onSettled",
1577+
settle: (queryClient) =>
1578+
unarchiveChat(queryClient).onSettled(undefined, undefined, "chat-1"),
1579+
},
1580+
{
1581+
name: "updateChatTitle onSettled",
1582+
settle: (queryClient) =>
1583+
updateChatTitle(queryClient).onSettled(undefined, undefined, {
1584+
chatId: "chat-1",
1585+
title: "New",
1586+
}),
1587+
},
1588+
{
1589+
name: "editChatMessage onSettled",
1590+
settle: (queryClient) =>
1591+
editChatMessage(queryClient, "chat-1").onSettled(),
1592+
},
1593+
{
1594+
name: "createChat onSuccess",
1595+
settle: (queryClient) => createChat(queryClient).onSuccess(),
1596+
},
1597+
])("$name invalidates chat searches", async ({ settle }) => {
1598+
const queryClient = createTestQueryClient();
1599+
queryClient.setQueryData(chatSearch({ q: "alpha" }).queryKey, []);
1600+
1601+
settle(queryClient);
1602+
await new Promise((r) => setTimeout(r, 0));
1603+
1604+
expect(
1605+
queryClient.getQueryState(chatSearch({ q: "alpha" }).queryKey)
1606+
?.isInvalidated,
1607+
"chat search entry should be invalidated",
1608+
).toBe(true);
1609+
});
15531610
});
15541611

15551612
describe("chatListKey shape", () => {
@@ -3057,6 +3114,63 @@ describe("semantic cache operations: prefix invalidations", () => {
30573114
"messages entry should NOT be invalidated",
30583115
).not.toBe(true);
30593116
});
3117+
3118+
it("invalidateChatSearches touches every search entry and nothing outside the family", async () => {
3119+
const queryClient = createTestQueryClient();
3120+
queryClient.setQueryData(chatSearch({ q: "alpha" }).queryKey, []);
3121+
queryClient.setQueryData(chatSearch({ q: "beta" }).queryKey, []);
3122+
seedInfiniteChats(queryClient, [makeChat("chat-1")]);
3123+
queryClient.setQueryData(chatsByWorkspace(["ws-1"]).queryKey, {});
3124+
queryClient.setQueryData(chatEntityKey("chat-1"), makeChat("chat-1"));
3125+
queryClient.setQueryData(chatMessagesKey("chat-1"), []);
3126+
queryClient.setQueryData(chatCostTreeKey("chat-1"), {});
3127+
3128+
await invalidateChatSearches(queryClient);
3129+
3130+
expect(
3131+
queryClient.getQueryState(chatSearch({ q: "alpha" }).queryKey)
3132+
?.isInvalidated,
3133+
).toBe(true);
3134+
expect(
3135+
queryClient.getQueryState(chatSearch({ q: "beta" }).queryKey)
3136+
?.isInvalidated,
3137+
).toBe(true);
3138+
for (const [label, key] of [
3139+
["chat list", infiniteChatsTestKey],
3140+
["by-workspace", chatsByWorkspace(["ws-1"]).queryKey],
3141+
["chat detail", chatEntityKey("chat-1")],
3142+
["messages", chatMessagesKey("chat-1")],
3143+
["cost tree", chatCostTreeKey("chat-1")],
3144+
] as const) {
3145+
expect(
3146+
queryClient.getQueryState(key)?.isInvalidated,
3147+
`${label} entry should NOT be invalidated`,
3148+
).not.toBe(true);
3149+
}
3150+
});
3151+
3152+
describe(shouldInvalidateChatSearches.name, () => {
3153+
// Search results render title, status, diff status, and the
3154+
// action-required badge. Summary and context events are excluded:
3155+
// stale last_turn_summary subtitles are accepted until
3156+
// reconciliation lands. The created and deleted kinds are handled
3157+
// by their own watch branches before the merge path runs.
3158+
const expectedByKind: Record<TypesGen.ChatWatchEventKind, boolean> = {
3159+
action_required: true,
3160+
chat_summary_change: false,
3161+
context_dirty: false,
3162+
created: false,
3163+
deleted: false,
3164+
diff_status_change: true,
3165+
status_change: true,
3166+
summary_change: false,
3167+
title_change: true,
3168+
};
3169+
3170+
it.each(ChatWatchEventKinds)("%s", (kind) => {
3171+
expect(shouldInvalidateChatSearches(kind)).toBe(expectedByKind[kind]);
3172+
});
3173+
});
30603174
});
30613175

30623176
describe("semantic cache operations: cancellation", () => {

site/src/api/queries/chats.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,26 @@ export const invalidateChatsByWorkspace = (queryClient: QueryClient) =>
648648
queryKey: chatsByWorkspaceFamilyKey,
649649
});
650650

651+
// Watch events that change fields rendered in search results (title,
652+
// status, diff status, action-required badge). Summary events are
653+
// deliberately excluded: stale last_turn_summary subtitles are accepted
654+
// until reconciliation lands.
655+
const SEARCH_AFFECTING_EVENT_KINDS = new Set<TypesGen.ChatWatchEventKind>([
656+
"title_change",
657+
"status_change",
658+
"diff_status_change",
659+
"action_required",
660+
]);
661+
662+
export const shouldInvalidateChatSearches = (
663+
eventKind: TypesGen.ChatWatchEventKind,
664+
): boolean => SEARCH_AFFECTING_EVENT_KINDS.has(eventKind);
665+
666+
export const invalidateChatSearches = (queryClient: QueryClient) =>
667+
queryClient.invalidateQueries({
668+
queryKey: chatSearchFamilyKey,
669+
});
670+
651671
export const invalidateChatDebugRuns = (
652672
queryClient: QueryClient,
653673
chatId: string,
@@ -993,6 +1013,7 @@ export const archiveChat = (queryClient: QueryClient) => ({
9931013
void invalidateChatListQueries(queryClient);
9941014
void invalidateChatEntity(queryClient, chatId);
9951015
void invalidateChatsByWorkspace(queryClient);
1016+
void invalidateChatSearches(queryClient);
9961017
},
9971018
});
9981019

@@ -1042,6 +1063,7 @@ export const unarchiveChat = (queryClient: QueryClient) => ({
10421063
void invalidateChatListQueries(queryClient);
10431064
void invalidateChatEntity(queryClient, chatId);
10441065
void invalidateChatsByWorkspace(queryClient);
1066+
void invalidateChatSearches(queryClient);
10451067
},
10461068
});
10471069

@@ -1329,6 +1351,7 @@ export const updateChatTitle = (queryClient: QueryClient) => ({
13291351
) => {
13301352
void invalidateChatListQueries(queryClient);
13311353
void invalidateChatEntity(queryClient, chatId);
1354+
void invalidateChatSearches(queryClient);
13321355
},
13331356
});
13341357

@@ -1409,6 +1432,7 @@ export const createChat = (queryClient: QueryClient) => ({
14091432
onSuccess: () => {
14101433
void invalidateChatListQueries(queryClient);
14111434
void invalidateChatsByWorkspace(queryClient);
1435+
void invalidateChatSearches(queryClient);
14121436
},
14131437
});
14141438

@@ -1501,6 +1525,7 @@ export const editChatMessage = (queryClient: QueryClient, chatId: string) => ({
15011525
void invalidateChatEntity(queryClient, chatId);
15021526
void invalidateChatPrompts(queryClient, chatId);
15031527
void invalidateChatDebugRuns(queryClient, chatId);
1528+
void invalidateChatSearches(queryClient);
15041529
},
15051530
});
15061531

site/src/pages/AgentsPage/AgentsPageLayout.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
invalidateChatDiffContents,
3030
invalidateChatEntity,
3131
invalidateChatListQueries,
32+
invalidateChatSearches,
3233
invalidateChatsByWorkspace,
3334
mergeWatchedChatIntoCaches,
3435
pinChat,
@@ -38,6 +39,7 @@ import {
3839
removeChatEntity,
3940
removeChildFromParentInCache,
4041
reorderPinnedChat,
42+
shouldInvalidateChatSearches,
4143
unarchiveChat,
4244
unpinChat,
4345
updateChatTitle,
@@ -308,6 +310,7 @@ const AgentsPageLayout: FC = () => {
308310
void invalidateChatListQueries(queryClient);
309311
void invalidateChatEntity(queryClient, chatId);
310312
void invalidateChatsByWorkspace(queryClient);
313+
void invalidateChatSearches(queryClient);
311314
void invalidateWorkspaceMutationQueries(queryClient, {
312315
organizationName,
313316
username: user.username,
@@ -576,6 +579,7 @@ const AgentsPageLayout: FC = () => {
576579
return changed ? next : chats;
577580
});
578581
void invalidateChatListQueries(queryClient);
582+
void invalidateChatSearches(queryClient);
579583
}, [agentId, queryClient]);
580584
useEffect(() => {
581585
return createReconnectingWebSocket({
@@ -615,6 +619,7 @@ const AgentsPageLayout: FC = () => {
615619
);
616620
removeChildFromParentInCache(queryClient, updatedChat.id);
617621
removeChatEntity(queryClient, updatedChat.id);
622+
void invalidateChatSearches(queryClient);
618623
return;
619624
}
620625
if (chatEvent.kind === "diff_status_change") {
@@ -650,6 +655,7 @@ const AgentsPageLayout: FC = () => {
650655
} else {
651656
prependToInfiniteChatsCache(queryClient, updatedChat);
652657
void invalidateChatListQueries(queryClient);
658+
void invalidateChatSearches(queryClient);
653659
}
654660
} else {
655661
mergeWatchedChatIntoCaches(queryClient, updatedChat, {
@@ -659,6 +665,9 @@ const AgentsPageLayout: FC = () => {
659665
if (shouldInvalidateFilteredChatList(updatedChat, chatEvent.kind)) {
660666
void invalidateChatListQueries(queryClient);
661667
}
668+
if (shouldInvalidateChatSearches(chatEvent.kind)) {
669+
void invalidateChatSearches(queryClient);
670+
}
662671
const costChatId = chatCostIdToInvalidate(
663672
updatedChat,
664673
chatEvent.kind,
@@ -681,6 +690,7 @@ const AgentsPageLayout: FC = () => {
681690
},
682691
onOpen() {
683692
void invalidateChatListQueries(queryClient);
693+
void invalidateChatSearches(queryClient);
684694
},
685695
});
686696
}, [queryClient]);

site/src/pages/AgentsPage/components/ChatConversation/useChatStore.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { watchChat } from "#/api/api";
1414
import {
1515
chatMessagesKey,
1616
invalidateChatPrompts,
17+
invalidateChatSearches,
1718
patchChatMessages,
1819
updateInfiniteChatsCache,
1920
} from "#/api/queries/chats";
@@ -234,6 +235,7 @@ export const useChatStore = (
234235
if (hasNewUserPrompt) {
235236
void invalidateChatPrompts(queryClient, chatID);
236237
}
238+
void invalidateChatSearches(queryClient);
237239
},
238240
[chatID, queryClient],
239241
);
@@ -255,6 +257,7 @@ export const useChatStore = (
255257
pageParams: currentData.pageParams.slice(0, 1),
256258
};
257259
});
260+
void invalidateChatSearches(queryClient);
258261
},
259262
[chatID, queryClient],
260263
);

0 commit comments

Comments
 (0)