⏱️ refactor: Optimistically Show New Chats In Sidebar (#13298)

* fix: optimistically show new chats in sidebar

* fix: reconcile optimistic conversation ids

* fix: clean optimistic conversation edge cases
This commit is contained in:
Danny Avila 2026-05-24 20:03:46 -04:00 committed by GitHub
parent 6f7dc1b289
commit 05bb690fde
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 539 additions and 20 deletions

View file

@ -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) && <ConvoOptions {...convoOptionsProps} />}
{!renaming && !isGenerating && (hasInteracted || isActiveConvo) && (
<ConvoOptions {...convoOptionsProps} />
)}
</div>
</div>
);

View file

@ -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<MessageEvent> & { 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();
});
});

View file

@ -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<TConversation>([
QueryKeys.conversation,
conversation.conversationId,
]);
if (!cachedConvo) {
queryClient.setQueryData(
if (conversation.conversationId) {
queryClient.setQueryData<TConversation>(
[QueryKeys.conversation, conversation.conversationId],
update,
(cachedConvo) =>
({
...cachedConvo,
...serverConversation,
}) as TConversation,
);
}
return update;

View file

@ -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<TConversation>(
[QueryKeys.conversation, conversationId],
(current) => current ?? optimisticConversation,
);
queryClient.setQueryData<TMessage[]>(
[QueryKeys.messages, conversationId],
optimisticMessages,
);
queryClient.setQueryData<TMessage[]>(
[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<string | null>(null);
const setAbortScroll = useSetRecoilState(store.abortScrollFamily(runIndex));
@ -90,9 +200,8 @@ export default function useResumableSSE(
const reconnectAttemptRef = useRef(0);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const submissionRef = useRef<TSubmission | null>(null);
const { setMessages, getMessages, setConversation, setIsSubmitting, newConversation } =
chatHelpers;
const optimisticStreamIdsRef = useRef(new Set<string>());
const createdStreamIdsRef = useRef(new Set<string>());
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');
}

View file

@ -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<InfiniteData<{ conversations: TConversation[] }>>([
'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<InfiniteData<{ conversations: TConversation[] }>>([
'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<InfiniteData<{ conversations: TConversation[] }>>([
'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<InfiniteData<any>>(['allConversations']);

View file

@ -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<InfiniteData<ConversationCursorData>>(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,