fix(site): prevent duplicate chat in agents sidebar on creation (#23077)

## Problem

When creating a new chat in the agents page (`/agents`), the chat could
appear multiple times in the sidebar. This was a race condition
triggered by the WebSocket `created` event handler.

## Root Cause

`updateInfiniteChatsCache` applies its updater function **independently
on each page** of the infinite query:

```ts
const nextPages = prev.pages.map((page) => updater(page));
```

When the `watchChats` WebSocket received a `"created"` event, the
handler checked `exists` only within the *current page*, then prepended
the new chat if not found:

```ts
updateInfiniteChatsCache(queryClient, (chats) => {
    const exists = chats.some((c) => c.id === updatedChat.id);
    // ...
    if (chatEvent.kind === "created") {
        return [updatedChat, ...chats]; // runs per page!
    }
});
```

Since a brand-new chat doesn't exist in any page, **every loaded page**
prepends it. After `pages.flat()`, the chat appears once per loaded page
in the sidebar.

## Fix

- Added `prependToInfiniteChatsCache` in `chats.ts` that checks across
**all pages** before prepending, and only adds to page 0.
- Split the WebSocket handler so `"created"` events use the new safe
prepend, while update events (`title_change`, `status_change`) continue
using `updateInfiniteChatsCache` (which is safe for `.map()` operations
that don't add entries).
This commit is contained in:
Kyle Carberry
2026-03-14 13:27:54 -04:00
committed by GitHub
parent 0d3e39a24e
commit ff9d061ae9
2 changed files with 41 additions and 9 deletions
+29
View File
@@ -30,6 +30,35 @@ export const updateInfiniteChatsCache = (
});
};
/**
* Prepends a new chat to the first page of every infinite chats query
* in the cache, but only if the chat doesn't already exist in any
* page. This avoids the per-page duplication that would occur if
* a prepend updater were passed to updateInfiniteChatsCache, which
* runs independently on each page.
*/
export const prependToInfiniteChatsCache = (
queryClient: QueryClient,
chat: TypesGen.Chat,
) => {
queryClient.setQueriesData<{
pages: TypesGen.Chat[][];
pageParams: unknown[];
}>({ queryKey: chatsKey }, (prev) => {
if (!prev?.pages) return prev;
// Check across ALL pages to avoid duplicates.
const exists = prev.pages.some((page) =>
page.some((c) => c.id === chat.id),
);
if (exists) return prev;
// Only prepend to the first page.
const nextPages = prev.pages.map((page, i) =>
i === 0 ? [chat, ...page] : page,
);
return { ...prev, pages: nextPages };
});
};
/**
* Reads the flat list of chats from the first matching infinite query
* in the cache. Returns undefined when no data is cached yet.
+12 -9
View File
@@ -10,6 +10,7 @@ import {
chatsKey,
createChat,
infiniteChats,
prependToInfiniteChatsCache,
readInfiniteChatsCache,
unarchiveChat,
updateInfiniteChatsCache,
@@ -405,9 +406,15 @@ const AgentsPage: FC = () => {
const isTitleEvent = chatEvent.kind === "title_change";
const isStatusEvent = chatEvent.kind === "status_change";
updateInfiniteChatsCache(queryClient, (chats) => {
const exists = chats.some((c) => c.id === updatedChat.id);
if (exists) {
// For "created" events, use a cross-page existence
// check and prepend only to the first page.
// updateInfiniteChatsCache runs the updater per
// page, so a naive prepend would duplicate the
// chat into every loaded page.
if (chatEvent.kind === "created") {
prependToInfiniteChatsCache(queryClient, updatedChat);
} else {
updateInfiniteChatsCache(queryClient, (chats) => {
return chats.map((c) => {
if (c.id !== updatedChat.id) return c;
return {
@@ -420,12 +427,8 @@ const AgentsPage: FC = () => {
: updatedChat.updated_at,
};
});
}
if (chatEvent.kind === "created") {
return [updatedChat, ...chats];
}
return chats;
});
});
}
queryClient.setQueryData<TypesGen.Chat | undefined>(
chatKey(updatedChat.id),
(previousChat) => {