mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user