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