diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx
index ba6ce2121b..46e3be09eb 100644
--- a/client/src/components/Conversations/Convo.tsx
+++ b/client/src/components/Conversations/Convo.tsx
@@ -142,7 +142,7 @@ export default function Conversation({
}, []);
const handleNavigation = (ctrlOrMetaKey: boolean) => {
- if (ctrlOrMetaKey) {
+ if (ctrlOrMetaKey && !isGenerating) {
toggleNav();
const baseUrl = window.location.origin;
const path = `/c/${conversationId}`;
@@ -278,7 +278,9 @@ export default function Conversation({
// aria-hidden={!(isPopoverActive || isActiveConvo)}
>
{/* Only render ConvoOptions when user interacts (hover/focus) or for active conversation */}
- {!renaming && (hasInteracted || isActiveConvo) && }
+ {!renaming && !isGenerating && (hasInteracted || isActiveConvo) && (
+
+ )}
);
diff --git a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts
index 2cedcf5ce5..67350e2ae4 100644
--- a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts
+++ b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts
@@ -1,5 +1,5 @@
import { renderHook, act } from '@testing-library/react';
-import { Constants, LocalStorageKeys } from 'librechat-data-provider';
+import { Constants, LocalStorageKeys, QueryKeys } from 'librechat-data-provider';
import type { TSubmission } from 'librechat-data-provider';
type SSEEventListener = (e: Partial & { responseCode?: number }) => void;
@@ -34,12 +34,18 @@ jest.mock('sse.js', () => ({
}));
const mockSetQueryData = jest.fn();
+const mockGetQueryData = jest.fn();
const mockInvalidateQueries = jest.fn();
const mockRemoveQueries = jest.fn();
+const mockFindAll = jest.fn((): Array<{ queryKey: unknown[] }> => []);
const mockQueryClient = {
setQueryData: mockSetQueryData,
+ getQueryData: mockGetQueryData,
invalidateQueries: mockInvalidateQueries,
removeQueries: mockRemoveQueries,
+ getQueryCache: () => ({
+ findAll: mockFindAll,
+ }),
};
jest.mock('@tanstack/react-query', () => ({
@@ -73,6 +79,7 @@ jest.mock('~/data-provider', () => ({
}));
const mockErrorHandler = jest.fn();
+const mockCreatedHandler = jest.fn();
const mockSetIsSubmitting = jest.fn();
const mockClearStepMaps = jest.fn();
@@ -80,7 +87,7 @@ jest.mock('~/hooks/SSE/useEventHandlers', () =>
jest.fn(() => ({
errorHandler: mockErrorHandler,
finalHandler: jest.fn(),
- createdHandler: jest.fn(),
+ createdHandler: mockCreatedHandler,
attachmentHandler: jest.fn(),
stepHandler: jest.fn(),
contentHandler: jest.fn(),
@@ -169,10 +176,14 @@ describe('useResumableSSE - 404 error path', () => {
mockSSEInstances.length = 0;
localStorage.clear();
mockErrorHandler.mockClear();
+ mockCreatedHandler.mockClear();
mockClearStepMaps.mockClear();
mockSetIsSubmitting.mockClear();
+ mockSetQueryData.mockClear();
+ mockGetQueryData.mockClear();
mockInvalidateQueries.mockClear();
mockRemoveQueries.mockClear();
+ mockFindAll.mockClear();
});
const seedDraft = (conversationId: string) => {
@@ -251,6 +262,68 @@ describe('useResumableSSE - 404 error path', () => {
unmount();
});
+ it('invalidates the stream conversation id on 404 for a new conversation', async () => {
+ mockFindAll.mockReturnValue([{ queryKey: [QueryKeys.allConversations] }]);
+ const submission = buildSubmission({
+ conversation: {},
+ userMessage: {
+ messageId: 'msg-1',
+ conversationId: null,
+ text: 'Hello',
+ isCreatedByUser: true,
+ sender: 'User',
+ parentMessageId: Constants.NO_PARENT,
+ },
+ initialResponse: {
+ messageId: 'msg-1_',
+ conversationId: null,
+ text: '',
+ isCreatedByUser: false,
+ sender: 'Assistant',
+ },
+ });
+ const chatHelpers = buildChatHelpers();
+
+ const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ const sse = getLastSSE();
+ await act(async () => {
+ sse._emit('error', { responseCode: 404 });
+ });
+
+ expect(mockInvalidateQueries).toHaveBeenCalledWith({
+ queryKey: [QueryKeys.messages, 'stream-123'],
+ });
+ expect(mockRemoveQueries).toHaveBeenCalledWith({
+ queryKey: ['streamStatus', 'stream-123'],
+ });
+
+ const allConversationWrites = mockSetQueryData.mock.calls.filter(
+ ([queryKey]) => Array.isArray(queryKey) && queryKey[0] === QueryKeys.allConversations,
+ );
+ expect(allConversationWrites).toHaveLength(2);
+
+ const removeUpdater = allConversationWrites[1][1] as (data: {
+ pages: { conversations: { conversationId: string }[]; nextCursor: null }[];
+ pageParams: never[];
+ }) => { pages: { conversations: { conversationId: string }[] }[] };
+ const result = removeUpdater({
+ pages: [
+ {
+ conversations: [{ conversationId: 'stream-123' }, { conversationId: 'other' }],
+ nextCursor: null,
+ },
+ ],
+ pageParams: [],
+ });
+ expect(result.pages[0].conversations).toEqual([{ conversationId: 'other' }]);
+ unmount();
+ });
+
it('closes the SSE connection on 404', async () => {
const { sse, unmount } = await render404Scenario();
@@ -258,6 +331,105 @@ describe('useResumableSSE - 404 error path', () => {
unmount();
});
+ it('seeds sidebar and message caches for a new conversation once the stream id is known', async () => {
+ const submission = buildSubmission({
+ conversation: {},
+ userMessage: {
+ messageId: 'msg-1',
+ conversationId: null,
+ text: 'Hello',
+ isCreatedByUser: true,
+ sender: 'User',
+ parentMessageId: Constants.NO_PARENT,
+ },
+ initialResponse: {
+ messageId: 'msg-1_',
+ conversationId: null,
+ text: '',
+ isCreatedByUser: false,
+ sender: 'Assistant',
+ },
+ });
+ const chatHelpers = buildChatHelpers();
+
+ const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(mockSetQueryData).toHaveBeenCalledWith(
+ [QueryKeys.conversation, 'stream-123'],
+ expect.any(Function),
+ );
+ expect(mockSetQueryData).toHaveBeenCalledWith(
+ [QueryKeys.messages, 'stream-123'],
+ expect.arrayContaining([
+ expect.objectContaining({ messageId: 'msg-1', conversationId: 'stream-123' }),
+ expect.objectContaining({ messageId: 'msg-1_', conversationId: 'stream-123' }),
+ ]),
+ );
+ expect(mockSetQueryData).toHaveBeenCalledWith(
+ [QueryKeys.messages, Constants.NEW_CONVO],
+ expect.arrayContaining([
+ expect.objectContaining({ messageId: 'msg-1', conversationId: 'stream-123' }),
+ ]),
+ );
+ expect(mockFindAll).toHaveBeenCalledWith([QueryKeys.allConversations], { exact: false });
+
+ unmount();
+ });
+
+ it('hydrates the submission conversation id before created handlers run', async () => {
+ const submission = buildSubmission({
+ conversation: {},
+ userMessage: {
+ messageId: 'msg-1',
+ conversationId: null,
+ text: 'Hello',
+ isCreatedByUser: true,
+ sender: 'User',
+ parentMessageId: Constants.NO_PARENT,
+ },
+ initialResponse: {
+ messageId: 'msg-1_',
+ conversationId: null,
+ text: '',
+ isCreatedByUser: false,
+ sender: 'Assistant',
+ },
+ });
+ const chatHelpers = buildChatHelpers();
+
+ const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ const sse = getLastSSE();
+ await act(async () => {
+ sse._emit('message', {
+ data: JSON.stringify({
+ created: true,
+ message: {
+ messageId: 'msg-1',
+ conversationId: 'stream-123',
+ },
+ }),
+ });
+ });
+
+ expect(mockCreatedHandler).toHaveBeenCalledWith(
+ expect.any(Object),
+ expect.objectContaining({
+ conversation: expect.objectContaining({ conversationId: 'stream-123' }),
+ userMessage: expect.objectContaining({ conversationId: 'stream-123' }),
+ }),
+ );
+ unmount();
+ });
+
it.each([undefined, 500, 503])(
'does not call errorHandler for responseCode %s (reconnect path)',
async (responseCode) => {
@@ -327,4 +499,66 @@ describe('useResumableSSE - 404 error path', () => {
expect(mockErrorHandler).toHaveBeenCalledTimes(1);
unmount();
});
+
+ it('removes the optimistic sidebar row when a new conversation errors before created', async () => {
+ mockFindAll.mockReturnValue([{ queryKey: [QueryKeys.allConversations] }]);
+ const submission = buildSubmission({
+ conversation: {},
+ userMessage: {
+ messageId: 'msg-1',
+ conversationId: null,
+ text: 'Hello',
+ isCreatedByUser: true,
+ sender: 'User',
+ parentMessageId: Constants.NO_PARENT,
+ },
+ initialResponse: {
+ messageId: 'msg-1_',
+ conversationId: null,
+ text: '',
+ isCreatedByUser: false,
+ sender: 'Assistant',
+ },
+ });
+ const chatHelpers = buildChatHelpers();
+
+ const { unmount } = renderHook(() => useResumableSSE(submission, chatHelpers));
+
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ const sse = getLastSSE();
+ await act(async () => {
+ sse._emit('error', { data: JSON.stringify({ error: 'failed before created' }) });
+ });
+
+ const allConversationWrites = mockSetQueryData.mock.calls.filter(
+ ([queryKey]) => Array.isArray(queryKey) && queryKey[0] === QueryKeys.allConversations,
+ );
+ expect(allConversationWrites).toHaveLength(2);
+
+ const removeUpdater = allConversationWrites[1][1] as (data: {
+ pages: { conversations: { conversationId: string }[]; nextCursor: null }[];
+ pageParams: never[];
+ }) => { pages: { conversations: { conversationId: string }[] }[] };
+ const result = removeUpdater({
+ pages: [
+ {
+ conversations: [{ conversationId: 'stream-123' }, { conversationId: 'other' }],
+ nextCursor: null,
+ },
+ ],
+ pageParams: [],
+ });
+ expect(result.pages[0].conversations).toEqual([{ conversationId: 'other' }]);
+ expect(mockErrorHandler).toHaveBeenCalledWith(
+ expect.objectContaining({
+ submission: expect.objectContaining({
+ conversation: expect.objectContaining({ conversationId: 'stream-123' }),
+ }),
+ }),
+ );
+ unmount();
+ });
});
diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts
index 32a530c0f8..accbe37547 100644
--- a/client/src/hooks/SSE/useEventHandlers.ts
+++ b/client/src/hooks/SSE/useEventHandlers.ts
@@ -28,7 +28,7 @@ import {
setDraft,
scrollToEnd,
getAllContentText,
- addConvoToAllQueries,
+ upsertConvoInAllQueries,
updateConvoInAllQueries,
removeConvoFromAllQueries,
findConversationInInfinite,
@@ -354,7 +354,7 @@ export default function useEventHandlers({
});
if (requestMessage.parentMessageId === Constants.NO_PARENT) {
- addConvoToAllQueries(queryClient, update);
+ upsertConvoInAllQueries(queryClient, update);
} else {
updateConvoInAllQueries(queryClient, update.conversationId!, (_c) => update, true);
}
@@ -429,7 +429,7 @@ export default function useEventHandlers({
if (!isTemporary) {
if (parentMessageId === Constants.NO_PARENT) {
- addConvoToAllQueries(queryClient, update);
+ upsertConvoInAllQueries(queryClient, update);
} else {
updateConvoInAllQueries(queryClient, update.conversationId!, (_c) => update, true);
}
@@ -476,6 +476,7 @@ export default function useEventHandlers({
isRegenerate = false,
isTemporary: _isTemporary = false,
} = submission;
+ const serverConversation = conversation as TConversation;
try {
// Handle early abort - aborted during tool loading before any messages saved
@@ -608,14 +609,14 @@ export default function useEventHandlers({
if (prevState?.model != null && prevState.model !== submissionConvo.model) {
update.model = prevState.model;
}
- const cachedConvo = queryClient.getQueryData([
- QueryKeys.conversation,
- conversation.conversationId,
- ]);
- if (!cachedConvo) {
- queryClient.setQueryData(
+ if (conversation.conversationId) {
+ queryClient.setQueryData(
[QueryKeys.conversation, conversation.conversationId],
- update,
+ (cachedConvo) =>
+ ({
+ ...cachedConvo,
+ ...serverConversation,
+ }) as TConversation,
);
}
return update;
diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts
index 88a5391cce..e693208c09 100644
--- a/client/src/hooks/SSE/useResumableSSE.ts
+++ b/client/src/hooks/SSE/useResumableSSE.ts
@@ -14,7 +14,13 @@ import {
ViolationTypes,
removeNullishValues,
} from 'librechat-data-provider';
-import type { TMessage, TPayload, TSubmission, EventSubmission } from 'librechat-data-provider';
+import type {
+ TMessage,
+ TPayload,
+ TSubmission,
+ TConversation,
+ EventSubmission,
+} from 'librechat-data-provider';
import type { EventHandlerParams } from './useEventHandlers';
import {
useGetUserBalance,
@@ -25,7 +31,7 @@ import {
import type { ActiveJobsResponse } from '~/data-provider';
import { useAuthContext } from '~/hooks/AuthContext';
import useEventHandlers from './useEventHandlers';
-import { clearAllDrafts } from '~/utils';
+import { clearAllDrafts, removeConvoFromAllQueries, upsertConvoInAllQueries } from '~/utils';
import store from '~/store';
type ChatHelpers = Pick<
@@ -35,6 +41,76 @@ type ChatHelpers = Pick<
const MAX_RETRIES = 5;
+const hasConcreteConversationId = (conversationId?: string | null) =>
+ !!conversationId &&
+ conversationId !== Constants.NEW_CONVO &&
+ conversationId !== Constants.PENDING_CONVO;
+
+const isInitialNewConversation = (submission: TSubmission) => {
+ const conversationId = submission.conversation?.conversationId;
+ return (
+ submission.userMessage?.parentMessageId === Constants.NO_PARENT &&
+ !hasConcreteConversationId(conversationId)
+ );
+};
+
+const shouldHydrateMessage = (message: TMessage) =>
+ !hasConcreteConversationId(message.conversationId);
+
+const hydrateMessageConversationId = (message: TMessage, conversationId: string): TMessage =>
+ shouldHydrateMessage(message) ? { ...message, conversationId } : message;
+
+const getOptimisticMessages = (
+ submission: TSubmission,
+ conversationId: string,
+ messages?: TMessage[],
+): TMessage[] => {
+ const sourceMessages =
+ messages && messages.length > 0
+ ? messages
+ : [submission.userMessage, submission.initialResponse].filter(
+ (message): message is TMessage => message != null,
+ );
+
+ return sourceMessages.map((message) => hydrateMessageConversationId(message, conversationId));
+};
+
+const buildOptimisticConversation = (
+ submission: TSubmission,
+ conversationId: string,
+): TConversation => {
+ const now = new Date().toISOString();
+ const messageIds = [
+ submission.userMessage?.messageId,
+ submission.initialResponse?.messageId,
+ ].filter((messageId): messageId is string => typeof messageId === 'string' && messageId !== '');
+
+ return {
+ ...submission.conversation,
+ conversationId,
+ endpoint: submission.conversation.endpoint ?? null,
+ title: submission.conversation.title ?? 'New Chat',
+ messages: messageIds.length > 0 ? messageIds : submission.conversation.messages,
+ createdAt: submission.conversation.createdAt ?? now,
+ updatedAt: now,
+ } as TConversation;
+};
+
+const hydrateSubmissionMessages = (
+ submission: TSubmission,
+ conversationId: string,
+): TSubmission => ({
+ ...submission,
+ conversation: {
+ ...submission.conversation,
+ conversationId,
+ },
+ userMessage: hydrateMessageConversationId(submission.userMessage, conversationId),
+ initialResponse: submission.initialResponse
+ ? hydrateMessageConversationId(submission.initialResponse, conversationId)
+ : submission.initialResponse,
+});
+
/**
* Hook for resumable SSE streams.
* Separates generation start (POST) from stream subscription (GET EventSource).
@@ -55,6 +131,8 @@ export default function useResumableSSE(
const setActiveRunId = useSetRecoilState(store.activeRunFamily(runIndex));
const { token, isAuthenticated } = useAuthContext();
+ const { setMessages, getMessages, setConversation, setIsSubmitting, newConversation } =
+ chatHelpers;
/**
* Optimistically add a job ID to the active jobs cache.
@@ -81,6 +159,38 @@ export default function useResumableSSE(
},
[queryClient],
);
+
+ const addOptimisticConversation = useCallback(
+ (conversationId: string, currentSubmission: TSubmission): TSubmission => {
+ if (!isInitialNewConversation(currentSubmission)) {
+ return currentSubmission;
+ }
+
+ const optimisticConversation = buildOptimisticConversation(currentSubmission, conversationId);
+ const optimisticMessages = getOptimisticMessages(
+ currentSubmission,
+ conversationId,
+ getMessages(),
+ );
+
+ queryClient.setQueryData(
+ [QueryKeys.conversation, conversationId],
+ (current) => current ?? optimisticConversation,
+ );
+ queryClient.setQueryData(
+ [QueryKeys.messages, conversationId],
+ optimisticMessages,
+ );
+ queryClient.setQueryData(
+ [QueryKeys.messages, Constants.NEW_CONVO],
+ optimisticMessages,
+ );
+ upsertConvoInAllQueries(queryClient, optimisticConversation);
+
+ return hydrateSubmissionMessages(currentSubmission, conversationId);
+ },
+ [getMessages, queryClient],
+ );
const [_completed, setCompleted] = useState(new Set());
const [streamId, setStreamId] = useState(null);
const setAbortScroll = useSetRecoilState(store.abortScrollFamily(runIndex));
@@ -90,9 +200,8 @@ export default function useResumableSSE(
const reconnectAttemptRef = useRef(0);
const reconnectTimeoutRef = useRef(null);
const submissionRef = useRef(null);
-
- const { setMessages, getMessages, setConversation, setIsSubmitting, newConversation } =
- chatHelpers;
+ const optimisticStreamIdsRef = useRef(new Set());
+ const createdStreamIdsRef = useRef(new Set());
const {
stepHandler,
@@ -161,6 +270,9 @@ export default function useResumableSSE(
hasResponseMessage: !!data.responseMessage,
});
clearAllDrafts(currentSubmission.conversation?.conversationId);
+ if (optimisticStreamIdsRef.current.has(currentStreamId)) {
+ clearAllDrafts(Constants.NEW_CONVO);
+ }
try {
finalHandler(data, currentSubmission as EventSubmission);
} catch (error) {
@@ -175,6 +287,8 @@ export default function useResumableSSE(
(startupConfig?.balance?.enabled ?? false) && balanceQuery.refetch();
sse.close();
setStreamId(null);
+ optimisticStreamIdsRef.current.delete(currentStreamId);
+ createdStreamIdsRef.current.delete(currentStreamId);
return;
}
@@ -183,6 +297,7 @@ export default function useResumableSSE(
messageId: data.message?.messageId,
conversationId: data.message?.conversationId,
});
+ createdStreamIdsRef.current.add(currentStreamId);
const runId = v4();
setActiveRunId(runId);
userMessage = {
@@ -344,14 +459,25 @@ export default function useResumableSSE(
sse.close();
removeActiveJob(currentStreamId);
clearAllDrafts(convoId);
+ if (optimisticStreamIdsRef.current.has(currentStreamId)) {
+ clearAllDrafts(Constants.NEW_CONVO);
+ }
clearStepMaps();
if (convoId) {
queryClient.invalidateQueries({ queryKey: [QueryKeys.messages, convoId] });
queryClient.removeQueries({ queryKey: streamStatusQueryKey(convoId) });
}
+ if (
+ !createdStreamIdsRef.current.has(currentStreamId) &&
+ optimisticStreamIdsRef.current.has(currentStreamId)
+ ) {
+ removeConvoFromAllQueries(queryClient, currentStreamId);
+ }
setIsSubmitting(false);
setShowStopButton(false);
setStreamId(null);
+ optimisticStreamIdsRef.current.delete(currentStreamId);
+ createdStreamIdsRef.current.delete(currentStreamId);
reconnectAttemptRef.current = 0;
return;
}
@@ -386,6 +512,12 @@ export default function useResumableSSE(
console.log('[ResumableSSE] Server-sent error event received:', e.data);
sse.close();
removeActiveJob(currentStreamId);
+ if (
+ !createdStreamIdsRef.current.has(currentStreamId) &&
+ optimisticStreamIdsRef.current.has(currentStreamId)
+ ) {
+ removeConvoFromAllQueries(queryClient, currentStreamId);
+ }
try {
const errorData = JSON.parse(e.data);
@@ -425,6 +557,8 @@ export default function useResumableSSE(
setIsSubmitting(false);
setShowStopButton(false);
setStreamId(null);
+ optimisticStreamIdsRef.current.delete(currentStreamId);
+ createdStreamIdsRef.current.delete(currentStreamId);
reconnectAttemptRef.current = 0;
return;
}
@@ -463,9 +597,17 @@ export default function useResumableSSE(
errorHandler({ data: undefined, submission: currentSubmission as EventSubmission });
// Optimistically remove from active jobs on max retries
removeActiveJob(currentStreamId);
+ if (
+ !createdStreamIdsRef.current.has(currentStreamId) &&
+ optimisticStreamIdsRef.current.has(currentStreamId)
+ ) {
+ removeConvoFromAllQueries(queryClient, currentStreamId);
+ }
setIsSubmitting(false);
setShowStopButton(false);
setStreamId(null);
+ optimisticStreamIdsRef.current.delete(currentStreamId);
+ createdStreamIdsRef.current.delete(currentStreamId);
}
});
@@ -664,7 +806,12 @@ export default function useResumableSSE(
if (isNewConvo) {
queueTitleGeneration(newStreamId);
}
- subscribeToStream(newStreamId, submission);
+ if (isInitialNewConversation(submission)) {
+ optimisticStreamIdsRef.current.add(newStreamId);
+ }
+ const streamSubmission = addOptimisticConversation(newStreamId, submission);
+ submissionRef.current = streamSubmission;
+ subscribeToStream(newStreamId, streamSubmission);
} else {
console.error('[ResumableSSE] Failed to get streamId from startGeneration');
}
diff --git a/client/src/utils/convos.spec.ts b/client/src/utils/convos.spec.ts
index c00cb20085..e47751ae08 100644
--- a/client/src/utils/convos.spec.ts
+++ b/client/src/utils/convos.spec.ts
@@ -10,6 +10,7 @@ import {
groupConversationsByDate,
updateConvoFieldsInfinite,
addConvoToAllQueries,
+ upsertConvoInAllQueries,
updateConvoInAllQueries,
removeConvoFromAllQueries,
addConversationToAllConversationsQueries,
@@ -590,6 +591,47 @@ describe('Conversation Utilities', () => {
expect(data!.pages[0].conversations.filter((c) => c.conversationId === 'a').length).toBe(1);
});
+ it('upsertConvoInAllQueries adds missing conversations to the top', () => {
+ upsertConvoInAllQueries(queryClient, convoB);
+ const data = queryClient.getQueryData>([
+ 'allConversations',
+ ]);
+
+ expect(data!.pages[0].conversations[0].conversationId).toBe('b');
+ expect(data!.pages[0].conversations[1].conversationId).toBe('a');
+ });
+
+ it('upsertConvoInAllQueries updates existing conversations without duplicating them', () => {
+ upsertConvoInAllQueries(queryClient, {
+ ...convoA,
+ title: 'Updated Conversation A',
+ });
+ const data = queryClient.getQueryData>([
+ 'allConversations',
+ ]);
+
+ expect(data!.pages[0].conversations).toHaveLength(1);
+ expect(data!.pages[0].conversations[0].title).toBe('Updated Conversation A');
+ });
+
+ it('upsertConvoInAllQueries moves an existing conversation to the top once', () => {
+ const convoC = { conversationId: 'c', updatedAt: '2024-01-03T12:00:00Z' } as TConversation;
+ queryClient.setQueryData(['allConversations'], {
+ pages: [{ conversations: [convoC, convoA], nextCursor: null }],
+ pageParams: [],
+ });
+
+ upsertConvoInAllQueries(queryClient, {
+ ...convoA,
+ title: 'Updated Conversation A',
+ });
+ const data = queryClient.getQueryData>([
+ 'allConversations',
+ ]);
+
+ expect(data!.pages[0].conversations.map((c) => c.conversationId)).toEqual(['a', 'c']);
+ });
+
it('updateConvoInAllQueries updates correct convo', () => {
updateConvoInAllQueries(queryClient, 'a', (c) => ({ ...c, model: 'gpt-4' }));
const data = queryClient.getQueryData>(['allConversations']);
diff --git a/client/src/utils/convos.ts b/client/src/utils/convos.ts
index e92d75d2da..5b0f6b7a55 100644
--- a/client/src/utils/convos.ts
+++ b/client/src/utils/convos.ts
@@ -347,6 +347,99 @@ export function addConvoToAllQueries(queryClient: QueryClient, newConvo: TConver
}
}
+export function upsertConvoInAllQueries(
+ queryClient: QueryClient,
+ nextConvo: TConversation,
+ moveToTop = true,
+) {
+ if (!nextConvo.conversationId) {
+ return;
+ }
+
+ const queries = queryClient
+ .getQueryCache()
+ .findAll([QueryKeys.allConversations], { exact: false });
+
+ for (const query of queries) {
+ queryClient.setQueryData>(query.queryKey, (oldData) => {
+ if (!oldData) {
+ return oldData;
+ }
+
+ let pageIdx = -1;
+ let convoIdx = -1;
+ for (let pi = 0; pi < oldData.pages.length; pi++) {
+ const ci = oldData.pages[pi].conversations.findIndex(
+ (c) => c.conversationId === nextConvo.conversationId,
+ );
+ if (ci !== -1) {
+ pageIdx = pi;
+ convoIdx = ci;
+ break;
+ }
+ }
+
+ const now = new Date().toISOString();
+ if (pageIdx === -1) {
+ const firstPage = oldData.pages[0] ?? { conversations: [], nextCursor: null };
+ return {
+ ...oldData,
+ pages: [
+ {
+ ...firstPage,
+ conversations: [
+ { ...nextConvo, updatedAt: nextConvo.updatedAt ?? now },
+ ...firstPage.conversations,
+ ],
+ },
+ ...oldData.pages.slice(1),
+ ],
+ };
+ }
+
+ const found = oldData.pages[pageIdx].conversations[convoIdx];
+ const updated = {
+ ...found,
+ ...nextConvo,
+ updatedAt: nextConvo.updatedAt ?? (moveToTop ? now : found.updatedAt),
+ };
+
+ if (!moveToTop || (pageIdx === 0 && convoIdx === 0)) {
+ return {
+ ...oldData,
+ pages: oldData.pages.map((page, pi) =>
+ pi === pageIdx
+ ? {
+ ...page,
+ conversations: page.conversations.map((c, ci) => (ci === convoIdx ? updated : c)),
+ }
+ : page,
+ ),
+ };
+ }
+
+ const pages = oldData.pages.map((page, pi) => {
+ if (pi === 0 && pageIdx === 0) {
+ const conversations = page.conversations.filter((_, ci) => ci !== convoIdx);
+ return { ...page, conversations: [updated, ...conversations] };
+ }
+ if (pi === 0) {
+ return { ...page, conversations: [updated, ...page.conversations] };
+ }
+ if (pi === pageIdx) {
+ return {
+ ...page,
+ conversations: page.conversations.filter((_, ci) => ci !== convoIdx),
+ };
+ }
+ return page;
+ });
+
+ return { ...oldData, pages };
+ });
+ }
+}
+
// Update
export function updateConvoInAllQueries(
queryClient: QueryClient,