From 3b8a33f9a14e5ba357147f4d4938319804d61d83 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 15 Aug 2026 06:55:53 +0200 Subject: [PATCH 01/15] feat: give the pinned chats section its own fetch The sidebar's pinned section filtered pinned chats out of the paginated chats list, which only holds the 25 most recently updated conversations. Once 25 newer chats existed, a reload hid the pin until the list was scrolled far enough to fetch the page it lived on. Pins are now fetched directly via GET /api/convos?pinned=true behind a dedicated query, so every pin paints with the sidebar regardless of where it falls in the chats list. Pin and unpin invalidate that query, and the shared conversation cache helpers keep it in step so a rename, delete or archive is reflected without waiting for a refetch. Pins stay out of the date groups, which groupConversationsByDate already handled. --- .../__test-utils__/convos-route-mocks.js | 11 +- api/server/routes/__tests__/convos.spec.js | 30 ++++ api/server/routes/convos.js | 2 + .../Conversations/Conversations.tsx | 69 +------- .../Conversations/PinnedSection.tsx | 75 ++++++++ .../__tests__/Conversations.test.tsx | 16 +- .../__tests__/PinnedSection.spec.tsx | 85 +++++++++ .../UnifiedSidebar/ConversationsSection.tsx | 22 ++- .../__tests__/ConversationsSection.spec.tsx | 24 +++ .../__tests__/pinnedConversations.test.tsx | 162 ++++++++++++++++++ client/src/data-provider/mutations.ts | 3 + client/src/data-provider/queries.ts | 39 ++++- client/src/utils/convos.ts | 46 +++++ packages/data-provider/src/keys.ts | 1 + .../src/react-query/react-query-service.ts | 1 + packages/data-provider/src/types/queries.ts | 11 +- .../src/methods/conversation.spec.ts | 103 +++++++++++ .../data-schemas/src/methods/conversation.ts | 7 + 18 files changed, 616 insertions(+), 91 deletions(-) create mode 100644 client/src/components/Conversations/PinnedSection.tsx create mode 100644 client/src/components/Conversations/__tests__/PinnedSection.spec.tsx create mode 100644 client/src/data-provider/__tests__/pinnedConversations.test.tsx diff --git a/api/server/routes/__test-utils__/convos-route-mocks.js b/api/server/routes/__test-utils__/convos-route-mocks.js index 769d2b61d8a..06f6982195a 100644 --- a/api/server/routes/__test-utils__/convos-route-mocks.js +++ b/api/server/routes/__test-utils__/convos-route-mocks.js @@ -2,7 +2,16 @@ module.exports = { agents: () => ({ sleep: jest.fn() }), api: (overrides = {}) => ({ - isEnabled: jest.fn(), + /** Mirrors the real helper so query-flag parsing (`isArchived`, `pinned`) is exercised. */ + isEnabled: jest.fn((value) => { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'string') { + return value.toLowerCase().trim() === 'true'; + } + return false; + }), resolveImportMaxFileSize: jest.fn(() => 262144000), createAxiosInstance: jest.fn(() => ({ get: jest.fn(), diff --git a/api/server/routes/__tests__/convos.spec.js b/api/server/routes/__tests__/convos.spec.js index f18eef9c5f6..1dbe1cd158f 100644 --- a/api/server/routes/__tests__/convos.spec.js +++ b/api/server/routes/__tests__/convos.spec.js @@ -487,6 +487,36 @@ describe('Convos Routes', () => { }); }); + describe('GET / pinned filter', () => { + const { getConvosByCursor } = require('~/models'); + + beforeEach(() => { + getConvosByCursor.mockResolvedValue({ conversations: [], nextCursor: null }); + }); + + it('forwards pinned=true so the sidebar section can fetch pins on their own', async () => { + const response = await request(app) + .get('/api/convos') + .query({ pinned: 'true', limit: '100' }); + + expect(response.status).toBe(200); + expect(getConvosByCursor).toHaveBeenCalledWith( + 'test-user-123', + expect.objectContaining({ pinned: true, limit: 100 }), + ); + }); + + it('leaves the list unfiltered when pinned is absent', async () => { + const response = await request(app).get('/api/convos'); + + expect(response.status).toBe(200); + expect(getConvosByCursor).toHaveBeenCalledWith( + 'test-user-123', + expect.objectContaining({ pinned: false }), + ); + }); + }); + describe('POST /archive', () => { it('should archive a conversation successfully', async () => { const mockConversationId = 'conv-123'; diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 9e86a1b0a59..c52b502a71f 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -39,6 +39,7 @@ router.get('/', async (req, res) => { const limit = parseInt(req.query.limit, 10) || 25; const cursor = req.query.cursor; const isArchived = isEnabled(req.query.isArchived); + const pinned = isEnabled(req.query.pinned); const search = typeof req.query.search === 'string' ? req.query.search.trim() || undefined : undefined; const sortBy = req.query.sortBy || 'updatedAt'; @@ -61,6 +62,7 @@ router.get('/', async (req, res) => { cursor, limit, isArchived, + pinned, tags, search, sortBy, diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index eb3a9813344..fb27f325648 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -115,17 +115,6 @@ const ChatsHeader: FC = memo(({ isExpanded, onToggle }) => { ChatsHeader.displayName = 'ChatsHeader'; -const PinnedHeader: FC = memo(() => { - const localize = useLocalize(); - return ( -

- {localize('com_ui_pinned')} -

- ); -}); - -PinnedHeader.displayName = 'PinnedHeader'; - const DateLabel: FC<{ groupName: string; isFirst?: boolean }> = memo(({ groupName, isFirst }) => { const localize = useLocalize(); return ( @@ -145,8 +134,6 @@ DateLabel.displayName = 'DateLabel'; type FlattenedItem = | { type: 'favorites' } - | { type: 'pinned-header' } - | { type: 'pinned-convo'; convo: TConversation } | { type: 'header'; groupName: string } | { type: 'convo'; convo: TConversation } | { type: 'loading' }; @@ -197,11 +184,6 @@ const Conversations: FC = ({ [rawConversations], ); - const pinnedConversations = useMemo( - () => filteredConversations.filter((c) => c.pinned), - [filteredConversations], - ); - const groupedConversations = useMemo( () => groupConversationsByDate(filteredConversations), [filteredConversations], @@ -215,13 +197,6 @@ const Conversations: FC = ({ } if (isChatsExpanded) { - if (!search.query && pinnedConversations.length > 0) { - items.push({ type: 'pinned-header' }); - items.push( - ...pinnedConversations.map((convo) => ({ type: 'pinned-convo' as const, convo })), - ); - } - groupedConversations.forEach(([groupName, convos]) => { items.push({ type: 'header', groupName }); items.push(...convos.map((convo) => ({ type: 'convo' as const, convo }))); @@ -232,14 +207,7 @@ const Conversations: FC = ({ } } return items; - }, [ - groupedConversations, - pinnedConversations, - isLoading, - isChatsExpanded, - shouldShowFavorites, - search.query, - ]); + }, [groupedConversations, isLoading, isChatsExpanded, shouldShowFavorites]); // Store flattenedItems in a ref for keyMapper to access without recreating cache const flattenedItemsRef = useRef(flattenedItems); @@ -259,12 +227,6 @@ const Conversations: FC = ({ if (item.type === 'favorites') { return `favorites-${favoritesContentKeyRef.current}`; } - if (item.type === 'pinned-header') { - return 'pinned-header'; - } - if (item.type === 'pinned-convo') { - return `pinned-${item.convo.conversationId}`; - } if (item.type === 'header') { const firstHeaderIndex = flattenedItemsRef.current[0]?.type === 'favorites' ? 1 : 0; return `header-${item.groupName}-${index === firstHeaderIndex ? 'first' : 'sub'}`; @@ -357,33 +319,8 @@ const Conversations: FC = ({ ); } - if (item.type === 'pinned-header') { - return ( - - - - ); - } - - if (item.type === 'pinned-convo') { - const isGenerating = activeJobIds.has(item.convo.conversationId ?? ''); - return ( - - - - ); - } - if (item.type === 'header') { - // First date header index depends on favorites row, pinned header, and pinned convos - // At most: [favorites, pinned-header, # pinned-convos] → first-header - const pinnedOffset = pinnedConversations.length > 0 ? pinnedConversations.length + 1 : 0; - const firstHeaderIndex = (flattenedItems[0]?.type === 'favorites' ? 1 : 0) + pinnedOffset; + const firstHeaderIndex = flattenedItems[0]?.type === 'favorites' ? 1 : 0; return ( @@ -407,7 +344,7 @@ const Conversations: FC = ({ return null; }, - [cache, flattenedItems, moveToTop, toggleNav, isSmallScreen, pinnedConversations, activeJobIds], + [cache, flattenedItems, moveToTop, toggleNav, isSmallScreen, activeJobIds], ); const getRowHeight = useCallback( diff --git a/client/src/components/Conversations/PinnedSection.tsx b/client/src/components/Conversations/PinnedSection.tsx new file mode 100644 index 00000000000..717bf43ddf8 --- /dev/null +++ b/client/src/components/Conversations/PinnedSection.tsx @@ -0,0 +1,75 @@ +import { memo, useMemo } from 'react'; +import { ChevronDown } from 'lucide-react'; +import type { TConversation } from 'librechat-data-provider'; +import { useLocalize, useLocalStorage } from '~/hooks'; +import { useActiveJobs } from '~/data-provider'; +import { cn } from '~/utils'; +import Convo from './Convo'; + +const noop = () => {}; + +interface PinnedSectionProps { + conversations: TConversation[]; + toggleNav: () => void; +} + +const PinnedSection = ({ conversations, toggleNav }: PinnedSectionProps) => { + const localize = useLocalize(); + const [isExpanded, setIsExpanded] = useLocalStorage('pinnedSectionExpanded', true); + const { data: activeJobsData } = useActiveJobs(); + const activeJobIds = useMemo( + () => new Set(activeJobsData?.activeJobIds ?? []), + [activeJobsData?.activeJobIds], + ); + + if (conversations.length === 0) { + return null; + } + + return ( +
+
+ +
+ + {isExpanded && ( +
+
    + {conversations.map((convo) => ( +
  • + +
  • + ))} +
+
+ )} +
+ ); +}; + +PinnedSection.displayName = 'PinnedSection'; + +export default memo(PinnedSection); diff --git a/client/src/components/Conversations/__tests__/Conversations.test.tsx b/client/src/components/Conversations/__tests__/Conversations.test.tsx index 0bfd556d88b..636542cf6e6 100644 --- a/client/src/components/Conversations/__tests__/Conversations.test.tsx +++ b/client/src/components/Conversations/__tests__/Conversations.test.tsx @@ -190,7 +190,7 @@ const pinnedConvo = { updatedAt: new Date().toISOString(), } as TConversation; -describe('Conversations – pinned header', () => { +describe('Conversations: pinned chats live in PinnedSection', () => { const containerRef = createRef(); beforeEach(() => { @@ -227,18 +227,8 @@ describe('Conversations – pinned header', () => { , ); - it('shows the pinned header when there are pinned conversations', () => { - const { getByText } = renderConversations([pinnedConvo]); - expect(getByText('com_ui_pinned')).toBeInTheDocument(); - }); - - it('does not show the pinned header when there are no pinned conversations', () => { - const { queryByText } = renderConversations([]); - expect(queryByText('com_ui_pinned')).not.toBeInTheDocument(); - }); - - it('does not show the pinned header during search', () => { - const { queryByText } = renderConversations([pinnedConvo], 'some query'); + it('does not render a pinned header inside the chats list', () => { + const { queryByText } = renderConversations([pinnedConvo]); expect(queryByText('com_ui_pinned')).not.toBeInTheDocument(); }); diff --git a/client/src/components/Conversations/__tests__/PinnedSection.spec.tsx b/client/src/components/Conversations/__tests__/PinnedSection.spec.tsx new file mode 100644 index 00000000000..b7460df8b36 --- /dev/null +++ b/client/src/components/Conversations/__tests__/PinnedSection.spec.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import type { TConversation } from 'librechat-data-provider'; +import PinnedSection from '../PinnedSection'; + +const mockSetExpanded = jest.fn(); +let mockIsExpanded = true; + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, + useLocalStorage: () => [mockIsExpanded, mockSetExpanded], +})); + +jest.mock('~/utils', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), +})); + +jest.mock('~/data-provider', () => ({ + useActiveJobs: () => ({ data: undefined }), +})); + +jest.mock('../Convo', () => ({ + __esModule: true, + default: ({ conversation }: { conversation: TConversation }) => ( +
{conversation.title}
+ ), +})); + +const pinnedConvo = { + conversationId: 'pinned-1', + title: 'Pinned Chat', + pinned: true, + endpoint: 'openAI', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), +} as TConversation; + +const anotherPinnedConvo = { + ...pinnedConvo, + conversationId: 'pinned-2', + title: 'Another Pin', +} as TConversation; + +describe('PinnedSection', () => { + beforeEach(() => { + mockIsExpanded = true; + mockSetExpanded.mockReset(); + }); + + it('renders nothing when there are no pinned conversations', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders a collapsible Pinned header matching Chats and Projects', () => { + render(); + const toggle = screen.getByRole('button', { name: 'com_ui_pinned' }); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + }); + + it('renders pinned conversations when expanded', () => { + render( + , + ); + expect(screen.getByText('Pinned Chat')).toBeInTheDocument(); + expect(screen.getByText('Another Pin')).toBeInTheDocument(); + }); + + it('hides pinned conversations when collapsed', () => { + mockIsExpanded = false; + render(); + expect(screen.getByRole('button', { name: 'com_ui_pinned' })).toHaveAttribute( + 'aria-expanded', + 'false', + ); + expect(screen.queryByText('Pinned Chat')).not.toBeInTheDocument(); + }); + + it('toggles the section when the header is clicked', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'com_ui_pinned' })); + expect(mockSetExpanded).toHaveBeenCalledWith(false); + }); +}); diff --git a/client/src/components/UnifiedSidebar/ConversationsSection.tsx b/client/src/components/UnifiedSidebar/ConversationsSection.tsx index 5faf2b32dcb..1fd5f045f12 100644 --- a/client/src/components/UnifiedSidebar/ConversationsSection.tsx +++ b/client/src/components/UnifiedSidebar/ConversationsSection.tsx @@ -2,9 +2,14 @@ import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef import { useMediaQuery } from '@librechat/client'; import { useSetRecoilState, useRecoilValue } from 'recoil'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; +import type { ConversationListResponse, TConversation } from 'librechat-data-provider'; import type { InfiniteQueryObserverResult } from '@tanstack/react-query'; -import type { ConversationListResponse } from 'librechat-data-provider'; import type { List } from 'react-virtualized'; +import { + useConversationsInfiniteQuery, + usePinnedConversationsQuery, + useTitleGeneration, +} from '~/data-provider'; import { useLocalize, useHasAccess, @@ -12,8 +17,8 @@ import { useLocalStorage, useNavScrolling, } from '~/hooks'; -import { useConversationsInfiniteQuery, useTitleGeneration } from '~/data-provider'; import ProjectsSection from '~/components/Conversations/ProjectsSection'; +import PinnedSection from '~/components/Conversations/PinnedSection'; import FavoritesList from '~/components/Nav/Favorites/FavoritesList'; import { Conversations } from '~/components/Conversations'; import SearchBar from '~/components/Nav/SearchBar'; @@ -75,6 +80,18 @@ const ConversationsSection = memo(() => { return data ? data.pages.flatMap((page) => page.conversations) : []; }, [data]); + /** Pins are fetched on their own so one older than the first page of the chats list + * still shows on first paint, instead of appearing only once that list scrolls to it. */ + const { data: pinnedData } = usePinnedConversationsQuery({ enabled: isAuthenticated }); + + const pinnedConversations = useMemo( + () => + (pinnedData?.conversations ?? []).filter((convo): convo is TConversation => + Boolean(convo?.pinned === true), + ), + [pinnedData?.conversations], + ); + const toggleNav = useCallback(() => { if (isSmallScreen) { setSidebarExpanded(false); @@ -122,6 +139,7 @@ const ConversationsSection = memo(() => { )} {!search.query && } + {!search.query && }
({ isLoading: false, isFetching: false, }), + usePinnedConversationsQuery: () => ({ + data: { conversations: [], nextCursor: null }, + }), useTitleGeneration: () => mockUseTitleGeneration(), useGetEndpointsQuery: () => ({ data: {}, isLoading: false }), useGetStartupConfig: () => ({ data: { modelSpecs: { list: [] } } }), @@ -93,6 +96,11 @@ jest.mock('~/components/Conversations/ProjectsSection', () => ({ default: () =>
, })); +jest.mock('~/components/Conversations/PinnedSection', () => ({ + __esModule: true, + default: () =>
, +})); + jest.mock('~/components/Nav/SearchBar', () => ({ __esModule: true, default: () =>
, @@ -153,6 +161,22 @@ const renderSection = () => , ); +describe('ConversationsSection section order', () => { + it('renders Pinned between Projects and Chats', async () => { + const { getByTestId } = renderSection(); + await settleRenders(); + + const projects = getByTestId('projects-stub'); + const pinned = getByTestId('pinned-stub'); + const chats = getByTestId('conversations-stub'); + + expect( + projects.compareDocumentPosition(pinned) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + expect(pinned.compareDocumentPosition(chats) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); +}); + describe('ConversationsSection streaming re-renders', () => { beforeEach(() => { mockUseFavorites.mockImplementation(() => ({ diff --git a/client/src/data-provider/__tests__/pinnedConversations.test.tsx b/client/src/data-provider/__tests__/pinnedConversations.test.tsx new file mode 100644 index 00000000000..947b3732086 --- /dev/null +++ b/client/src/data-provider/__tests__/pinnedConversations.test.tsx @@ -0,0 +1,162 @@ +import { createElement } from 'react'; +import { dataService, QueryKeys } from 'librechat-data-provider'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import type { ConversationListResponse, TConversation } from 'librechat-data-provider'; +import type { ReactNode } from 'react'; +import { removeConvoFromAllQueries, updateConvoInAllQueries } from '~/utils/convos'; +import { pinnedConversationsLimit, usePinnedConversationsQuery } from '../queries'; +import { usePinConversationMutation } from '../mutations'; + +jest.mock('librechat-data-provider', () => { + const actual = jest.requireActual('librechat-data-provider'); + return { + ...actual, + dataService: { + ...actual.dataService, + listConversations: jest.fn(), + pinConversation: jest.fn(), + }, + }; +}); + +const listConversations = dataService.listConversations as jest.MockedFunction< + typeof dataService.listConversations +>; +const pinConversation = dataService.pinConversation as jest.MockedFunction< + typeof dataService.pinConversation +>; + +const pinnedConvo = { + conversationId: 'convo-pinned', + title: 'Initial Greeting', + endpoint: 'openAI', + pinned: true, +} as TConversation; + +const listResponse = (conversations: TConversation[]): ConversationListResponse => ({ + conversations, + nextCursor: null, +}); + +const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false } } }); + +const createWrapper = (queryClient: QueryClient) => + function Wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children); + }; + +const readPinnedCache = (queryClient: QueryClient) => + queryClient.getQueryData([QueryKeys.pinnedConversations]); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('usePinnedConversationsQuery', () => { + it('fetches pins directly instead of filtering the paginated chats list', async () => { + listConversations.mockResolvedValue(listResponse([pinnedConvo])); + const queryClient = createQueryClient(); + + const { result } = renderHook(() => usePinnedConversationsQuery(), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(listConversations).toHaveBeenCalledTimes(1); + expect(listConversations).toHaveBeenCalledWith({ + pinned: true, + limit: pinnedConversationsLimit, + }); + expect(result.current.data?.conversations).toEqual([pinnedConvo]); + }); + + it('does not fetch while the user is unauthenticated', async () => { + listConversations.mockResolvedValue(listResponse([])); + const queryClient = createQueryClient(); + + const { result } = renderHook(() => usePinnedConversationsQuery({ enabled: false }), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => expect(result.current.isLoading).toBe(true)); + expect(listConversations).not.toHaveBeenCalled(); + }); + + it('refetches the pinned list after a chat is pinned', async () => { + listConversations.mockResolvedValue(listResponse([])); + pinConversation.mockResolvedValue(pinnedConvo); + const queryClient = createQueryClient(); + + const { result } = renderHook( + () => ({ + query: usePinnedConversationsQuery(), + pin: usePinConversationMutation(), + }), + { wrapper: createWrapper(queryClient) }, + ); + + await waitFor(() => expect(result.current.query.isSuccess).toBe(true)); + expect(result.current.query.data?.conversations).toEqual([]); + + listConversations.mockResolvedValue(listResponse([pinnedConvo])); + await act(async () => { + await result.current.pin.mutateAsync({ + conversationId: pinnedConvo.conversationId as string, + pinned: true, + }); + }); + + await waitFor(() => expect(listConversations).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(result.current.query.data?.conversations).toEqual([pinnedConvo])); + }); +}); + +describe('pinned list cache synchronization', () => { + it('drops a chat from the pinned cache as soon as it is unpinned', () => { + const queryClient = createQueryClient(); + queryClient.setQueryData([QueryKeys.pinnedConversations], listResponse([pinnedConvo])); + + updateConvoInAllQueries(queryClient, pinnedConvo.conversationId as string, (convo) => ({ + ...convo, + pinned: false, + })); + + expect(readPinnedCache(queryClient)?.conversations).toEqual([]); + }); + + it('keeps a renamed pin in the section with its new title', () => { + const queryClient = createQueryClient(); + queryClient.setQueryData([QueryKeys.pinnedConversations], listResponse([pinnedConvo])); + + updateConvoInAllQueries(queryClient, pinnedConvo.conversationId as string, (convo) => ({ + ...convo, + title: 'Renamed', + })); + + expect(readPinnedCache(queryClient)?.conversations).toEqual([ + { ...pinnedConvo, title: 'Renamed' }, + ]); + }); + + it('removes a deleted or archived pin from the section', () => { + const queryClient = createQueryClient(); + queryClient.setQueryData([QueryKeys.pinnedConversations], listResponse([pinnedConvo])); + + removeConvoFromAllQueries(queryClient, pinnedConvo.conversationId as string); + + expect(readPinnedCache(queryClient)?.conversations).toEqual([]); + }); + + it('leaves the pinned cache untouched for an unrelated conversation', () => { + const queryClient = createQueryClient(); + queryClient.setQueryData([QueryKeys.pinnedConversations], listResponse([pinnedConvo])); + + updateConvoInAllQueries(queryClient, 'some-other-convo', (convo) => ({ + ...convo, + title: 'Renamed', + })); + + expect(readPinnedCache(queryClient)?.conversations).toEqual([pinnedConvo]); + }); +}); diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index f2048ed6e2b..94fcf3aca74 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -162,6 +162,9 @@ export const usePinConversationMutation = ( { onSuccess: (data, vars, context) => { updateConvoInAllQueries(queryClient, vars.conversationId, () => data); + /** The pinned section has its own fetch, so a new pin is only visible once + * that list is refetched; unpins are already dropped from its cache above. */ + queryClient.invalidateQueries([QueryKeys.pinnedConversations]); onSuccess?.(data, vars, context); }, onError, diff --git a/client/src/data-provider/queries.ts b/client/src/data-provider/queries.ts index de6c571c8bd..75ffe6428af 100644 --- a/client/src/data-provider/queries.ts +++ b/client/src/data-provider/queries.ts @@ -1,3 +1,4 @@ +import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; import { QueryKeys, dataService, @@ -6,14 +7,6 @@ import { defaultOrderQuery, defaultAssistantsVersion, } from 'librechat-data-provider'; -import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query'; -import type { - UseInfiniteQueryOptions, - QueryObserverResult, - UseQueryOptions, - InfiniteData, -} from '@tanstack/react-query'; -import type t from 'librechat-data-provider'; import type { Action, TPreset, @@ -30,6 +23,13 @@ import type { SharedLinksListParams, SharedLinksResponse, } from 'librechat-data-provider'; +import type { + UseInfiniteQueryOptions, + QueryObserverResult, + UseQueryOptions, + InfiniteData, +} from '@tanstack/react-query'; +import type t from 'librechat-data-provider'; import type { ConversationCursorData } from '~/utils/convos'; import { findConversationInInfinite, isNotFoundError } from '~/utils'; @@ -111,6 +111,29 @@ export const useConversationsInfiniteQuery = ( }); }; +/** + * Pinned chats are a hand-curated, deliberately small set, so the sidebar section + * fetches them whole rather than paginating: a pin older than the first page of the + * Chats list would otherwise stay hidden until that list scrolled far enough to reach it. + */ +export const pinnedConversationsLimit = 100; + +export const usePinnedConversationsQuery = ( + config?: UseQueryOptions, +): QueryObserverResult => { + return useQuery( + [QueryKeys.pinnedConversations], + () => dataService.listConversations({ pinned: true, limit: pinnedConversationsLimit }), + { + staleTime: 5 * 60 * 1000, + cacheTime: 30 * 60 * 1000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + ...config, + }, + ); +}; + export const useMessagesInfiniteQuery = ( params: MessagesListParams, config?: UseInfiniteQueryOptions, diff --git a/client/src/utils/convos.ts b/client/src/utils/convos.ts index 456f7b2d939..e689d0442e6 100644 --- a/client/src/utils/convos.ts +++ b/client/src/utils/convos.ts @@ -494,6 +494,48 @@ export function upsertConvoInAllQueries( } } +export type PinnedConversationsData = { + conversations: TConversation[]; + nextCursor?: string | null; +}; + +/** + * The pinned sidebar section is fed by its own request rather than by the paginated + * chats list, so every edit that reaches the chats cache has to reach this one too or + * the section keeps showing a stale title, or a chat that is no longer pinned. + */ +function updatePinnedConvosQuery( + queryClient: QueryClient, + conversationId: string, + updater: (c: TConversation) => TConversation | null, +) { + queryClient.setQueryData([QueryKeys.pinnedConversations], (oldData) => { + if (!oldData) { + return oldData; + } + const index = oldData.conversations.findIndex((c) => c.conversationId === conversationId); + if (index === -1) { + return oldData; + } + const found = oldData.conversations[index]; + const updated = updater(found); + if (!updated || updated.pinned !== true) { + return { + ...oldData, + conversations: oldData.conversations.filter((_, i) => i !== index), + }; + } + const merged = + updated.isShared === undefined && found.isShared !== undefined + ? { ...updated, isShared: found.isShared } + : updated; + return { + ...oldData, + conversations: oldData.conversations.map((c, i) => (i === index ? merged : c)), + }; + }); +} + // Update export function updateConvoInAllQueries( queryClient: QueryClient, @@ -501,6 +543,8 @@ export function updateConvoInAllQueries( updater: (c: TConversation) => TConversation, moveToTop = false, ) { + updatePinnedConvosQuery(queryClient, conversationId, updater); + const queries = queryClient .getQueryCache() .findAll([QueryKeys.allConversations], { exact: false }); @@ -588,6 +632,8 @@ export function updateConvoInAllQueries( // Remove export function removeConvoFromAllQueries(queryClient: QueryClient, conversationId: string) { + updatePinnedConvosQuery(queryClient, conversationId, () => null); + const queries = queryClient .getQueryCache() .findAll([QueryKeys.allConversations], { exact: false }); diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index aec853c863c..cf043f7ce88 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -5,6 +5,7 @@ export enum QueryKeys { sharedLinks = 'sharedLinks', allConversations = 'allConversations', archivedConversations = 'archivedConversations', + pinnedConversations = 'pinnedConversations', searchConversations = 'searchConversations', conversation = 'conversation', searchEnabled = 'searchEnabled', diff --git a/packages/data-provider/src/react-query/react-query-service.ts b/packages/data-provider/src/react-query/react-query-service.ts index 4c74903261b..fdbee5e57e1 100644 --- a/packages/data-provider/src/react-query/react-query-service.ts +++ b/packages/data-provider/src/react-query/react-query-service.ts @@ -134,6 +134,7 @@ export const useClearConversationsMutation = (): UseMutationResult => { return useMutation(() => dataService.clearAllConversations(), { onSuccess: () => { queryClient.invalidateQueries([QueryKeys.allConversations]); + queryClient.invalidateQueries([QueryKeys.pinnedConversations]); queryClient.invalidateQueries([QueryKeys.conversationTags]); }, }); diff --git a/packages/data-provider/src/types/queries.ts b/packages/data-provider/src/types/queries.ts index ebe7c4ecc17..4c2e2c9f288 100644 --- a/packages/data-provider/src/types/queries.ts +++ b/packages/data-provider/src/types/queries.ts @@ -14,7 +14,9 @@ export type Conversation = { export type ConversationListParams = { cursor?: string; + limit?: number; isArchived?: boolean; + pinned?: boolean; sortBy?: 'title' | 'createdAt' | 'updatedAt'; sortDirection?: 'asc' | 'desc'; tags?: string[]; @@ -24,7 +26,14 @@ export type ConversationListParams = { export type MinimalConversation = Pick< s.TConversation, - 'conversationId' | 'endpoint' | 'title' | 'createdAt' | 'updatedAt' | 'user' | 'chatProjectId' + | 'conversationId' + | 'endpoint' + | 'title' + | 'createdAt' + | 'updatedAt' + | 'user' + | 'chatProjectId' + | 'pinned' >; export type ConversationListResponse = { diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index 143d247bbb3..169f4cadea2 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -1592,6 +1592,109 @@ describe('Conversation Operations', () => { }); }); + describe('getConvosByCursor pinned filter', () => { + const insertConvo = async ({ + user = 'user123', + title, + updatedAt, + pinned, + isArchived = false, + }: { + user?: string; + title: string; + updatedAt: Date; + pinned?: boolean; + isArchived?: boolean; + }) => { + const conversationId = uuidv4(); + await Conversation.collection.insertOne({ + conversationId, + user, + title, + endpoint: EModelEndpoint.openAI, + expiredAt: null, + isArchived, + createdAt: updatedAt, + updatedAt, + ...(pinned === undefined ? {} : { pinned }), + }); + return conversationId; + }; + + it('returns only pinned conversations when pinned is requested', async () => { + const baseTime = new Date('2026-05-01T00:00:00.000Z'); + const pinnedId = await insertConvo({ + title: 'Pinned chat', + updatedAt: baseTime, + pinned: true, + }); + await insertConvo({ title: 'Unpinned chat', updatedAt: baseTime, pinned: false }); + await insertConvo({ title: 'Never pinned chat', updatedAt: baseTime }); + + const result = await getConvosByCursor('user123', { pinned: true }); + + expect(result.conversations.map((convo) => convo.conversationId)).toEqual([pinnedId]); + }); + + /** The sidebar's pinned section used to filter the paginated chats list, so a pin + * older than the first page stayed hidden until that list scrolled far enough. */ + it('returns a pin that falls outside the first page of the unfiltered list', async () => { + const baseTime = new Date('2026-05-01T00:00:00.000Z'); + const pinnedId = await insertConvo({ + title: 'Initial Greeting', + updatedAt: baseTime, + pinned: true, + }); + for (let index = 0; index < 30; index++) { + await insertConvo({ + title: `Newer chat ${index}`, + updatedAt: new Date(baseTime.getTime() + (index + 1) * 60000), + }); + } + + const firstPage = await getConvosByCursor('user123', { limit: 25 }); + expect(firstPage.conversations.map((convo) => convo.conversationId)).not.toContain(pinnedId); + + const pinnedResult = await getConvosByCursor('user123', { pinned: true }); + expect(pinnedResult.conversations.map((convo) => convo.conversationId)).toEqual([pinnedId]); + }); + + it('excludes archived pins and other users’ pins', async () => { + const baseTime = new Date('2026-05-01T00:00:00.000Z'); + const visibleId = await insertConvo({ + title: 'Visible pin', + updatedAt: baseTime, + pinned: true, + }); + await insertConvo({ + title: 'Archived pin', + updatedAt: baseTime, + pinned: true, + isArchived: true, + }); + await insertConvo({ + user: 'other-user', + title: 'Someone else’s pin', + updatedAt: baseTime, + pinned: true, + }); + + const result = await getConvosByCursor('user123', { pinned: true }); + + expect(result.conversations.map((convo) => convo.conversationId)).toEqual([visibleId]); + }); + + it('leaves the list unfiltered when pinned is not requested', async () => { + const baseTime = new Date('2026-05-01T00:00:00.000Z'); + await insertConvo({ title: 'Pinned chat', updatedAt: baseTime, pinned: true }); + await insertConvo({ title: 'Unpinned chat', updatedAt: baseTime }); + + const result = await getConvosByCursor('user123', {}); + + expect(result.conversations).toHaveLength(2); + }); + }); + describe('tenantId stripping', () => { it('saveConvo should not write caller-supplied tenantId to the document', async () => { const conversationId = uuidv4(); diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index d4056ef5ffd..2340e936762 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -42,6 +42,7 @@ export interface ConversationMethods { cursor?: string | null; limit?: number; isArchived?: boolean; + pinned?: boolean; tags?: string[]; search?: string; sortBy?: string; @@ -568,6 +569,7 @@ export function createConversationMethods( cursor, limit = 25, isArchived = false, + pinned = false, tags, search, sortBy = 'updatedAt', @@ -577,6 +579,7 @@ export function createConversationMethods( cursor?: string | null; limit?: number; isArchived?: boolean; + pinned?: boolean; tags?: string[]; search?: string; sortBy?: string; @@ -594,6 +597,10 @@ export function createConversationMethods( } as FilterQuery); } + if (pinned) { + filters.push({ pinned: true } as FilterQuery); + } + if (Array.isArray(tags) && tags.length > 0) { filters.push({ tags: { $in: tags } } as FilterQuery); } From 0e41f9889b6a795f3871778a101c86e601e90337 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:24:21 +0200 Subject: [PATCH 02/15] fix: address review findings on the pinned chats section - Drain the cursor rather than capping the pinned request at 100. Since pins are kept out of the chats date groups, anything this query dropped was invisible in the sidebar entirely, not merely further down a list. - Apply the active bookmark filter to the pinned request and key its cache by it, matching the chats list beside it. - Move a pin to the top of the section when the caller asks for it, so a pin that just received a message leads the way it does in the chats list instead of waiting for a refetch. - Invalidate the pinned list when a conversation is unarchived, since archiving removes it from that cache and nothing put it back. - Index the pinned lookup: it filters on user + pinned and sorts by updatedAt, which no existing compound index covered. - Protect `pinned` from saveMessageToDatabase's unset sweep. Any persisted field missing from endpointOptions is unset, so sending a message in a pinned chat silently unpinned it. --- .../UnifiedSidebar/ConversationsSection.tsx | 8 +- .../__tests__/pinnedConversations.test.tsx | 98 +++++++++++++++++-- client/src/data-provider/mutations.ts | 3 + client/src/data-provider/queries.ts | 34 +++++-- client/src/utils/convos.ts | 63 +++++++----- packages/data-provider/src/config.ts | 1 + packages/data-schemas/src/schema/convo.ts | 3 + 7 files changed, 170 insertions(+), 40 deletions(-) diff --git a/client/src/components/UnifiedSidebar/ConversationsSection.tsx b/client/src/components/UnifiedSidebar/ConversationsSection.tsx index 1fd5f045f12..2fb076835d0 100644 --- a/client/src/components/UnifiedSidebar/ConversationsSection.tsx +++ b/client/src/components/UnifiedSidebar/ConversationsSection.tsx @@ -81,8 +81,12 @@ const ConversationsSection = memo(() => { }, [data]); /** Pins are fetched on their own so one older than the first page of the chats list - * still shows on first paint, instead of appearing only once that list scrolls to it. */ - const { data: pinnedData } = usePinnedConversationsQuery({ enabled: isAuthenticated }); + * still shows on first paint, instead of appearing only once that list scrolls to it. + * The bookmark filter still applies, matching the chats list beside it. */ + const { data: pinnedData } = usePinnedConversationsQuery( + { tags: tags.length === 0 ? undefined : tags }, + { enabled: isAuthenticated }, + ); const pinnedConversations = useMemo( () => diff --git a/client/src/data-provider/__tests__/pinnedConversations.test.tsx b/client/src/data-provider/__tests__/pinnedConversations.test.tsx index 947b3732086..95ee7cae779 100644 --- a/client/src/data-provider/__tests__/pinnedConversations.test.tsx +++ b/client/src/data-provider/__tests__/pinnedConversations.test.tsx @@ -4,8 +4,8 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { ConversationListResponse, TConversation } from 'librechat-data-provider'; import type { ReactNode } from 'react'; +import { pinnedConversationsPageSize, usePinnedConversationsQuery } from '../queries'; import { removeConvoFromAllQueries, updateConvoInAllQueries } from '~/utils/convos'; -import { pinnedConversationsLimit, usePinnedConversationsQuery } from '../queries'; import { usePinConversationMutation } from '../mutations'; jest.mock('librechat-data-provider', () => { @@ -47,7 +47,10 @@ const createWrapper = (queryClient: QueryClient) => }; const readPinnedCache = (queryClient: QueryClient) => - queryClient.getQueryData([QueryKeys.pinnedConversations]); + queryClient.getQueryData([ + QueryKeys.pinnedConversations, + { tags: undefined }, + ]); beforeEach(() => { jest.clearAllMocks(); @@ -66,7 +69,9 @@ describe('usePinnedConversationsQuery', () => { expect(listConversations).toHaveBeenCalledTimes(1); expect(listConversations).toHaveBeenCalledWith({ pinned: true, - limit: pinnedConversationsLimit, + tags: undefined, + limit: pinnedConversationsPageSize, + cursor: undefined, }); expect(result.current.data?.conversations).toEqual([pinnedConvo]); }); @@ -75,11 +80,12 @@ describe('usePinnedConversationsQuery', () => { listConversations.mockResolvedValue(listResponse([])); const queryClient = createQueryClient(); - const { result } = renderHook(() => usePinnedConversationsQuery({ enabled: false }), { + const { result } = renderHook(() => usePinnedConversationsQuery({}, { enabled: false }), { wrapper: createWrapper(queryClient), }); - await waitFor(() => expect(result.current.isLoading).toBe(true)); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + expect(result.current.data).toBeUndefined(); expect(listConversations).not.toHaveBeenCalled(); }); @@ -110,12 +116,54 @@ describe('usePinnedConversationsQuery', () => { await waitFor(() => expect(listConversations).toHaveBeenCalledTimes(2)); await waitFor(() => expect(result.current.query.data?.conversations).toEqual([pinnedConvo])); }); + + /** `groupConversationsByDate` keeps pins out of the chats groups, so a pin this query + * drops is invisible everywhere, not merely further down a list. */ + it('drains the cursor instead of truncating at one page', async () => { + const second = { ...pinnedConvo, conversationId: 'convo-pinned-2' } as TConversation; + listConversations + .mockResolvedValueOnce({ conversations: [pinnedConvo], nextCursor: 'cursor-2' }) + .mockResolvedValueOnce({ conversations: [second], nextCursor: null }); + const queryClient = createQueryClient(); + + const { result } = renderHook(() => usePinnedConversationsQuery(), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(listConversations).toHaveBeenCalledTimes(2); + expect(listConversations).toHaveBeenLastCalledWith( + expect.objectContaining({ cursor: 'cursor-2' }), + ); + expect(result.current.data?.conversations).toEqual([pinnedConvo, second]); + expect(result.current.data?.nextCursor).toBeNull(); + }); + + /** The chats list beside it is filtered by the selected bookmarks; the pinned section + * showed every pin regardless until the tags were threaded through. */ + it('applies the active bookmark filter and keys the cache by it', async () => { + listConversations.mockResolvedValue(listResponse([pinnedConvo])); + const queryClient = createQueryClient(); + + const { result } = renderHook(() => usePinnedConversationsQuery({ tags: ['work'] }), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(listConversations).toHaveBeenCalledWith(expect.objectContaining({ tags: ['work'] })); + expect( + queryClient.getQueryData([QueryKeys.pinnedConversations, { tags: ['work'] }]), + ).toBeDefined(); + }); }); describe('pinned list cache synchronization', () => { it('drops a chat from the pinned cache as soon as it is unpinned', () => { const queryClient = createQueryClient(); - queryClient.setQueryData([QueryKeys.pinnedConversations], listResponse([pinnedConvo])); + queryClient.setQueryData( + [QueryKeys.pinnedConversations, { tags: undefined }], + listResponse([pinnedConvo]), + ); updateConvoInAllQueries(queryClient, pinnedConvo.conversationId as string, (convo) => ({ ...convo, @@ -127,7 +175,10 @@ describe('pinned list cache synchronization', () => { it('keeps a renamed pin in the section with its new title', () => { const queryClient = createQueryClient(); - queryClient.setQueryData([QueryKeys.pinnedConversations], listResponse([pinnedConvo])); + queryClient.setQueryData( + [QueryKeys.pinnedConversations, { tags: undefined }], + listResponse([pinnedConvo]), + ); updateConvoInAllQueries(queryClient, pinnedConvo.conversationId as string, (convo) => ({ ...convo, @@ -141,16 +192,45 @@ describe('pinned list cache synchronization', () => { it('removes a deleted or archived pin from the section', () => { const queryClient = createQueryClient(); - queryClient.setQueryData([QueryKeys.pinnedConversations], listResponse([pinnedConvo])); + queryClient.setQueryData( + [QueryKeys.pinnedConversations, { tags: undefined }], + listResponse([pinnedConvo]), + ); removeConvoFromAllQueries(queryClient, pinnedConvo.conversationId as string); expect(readPinnedCache(queryClient)?.conversations).toEqual([]); }); + /** A pin that just received a message must lead the section the way it leads the + * chats list, since the server returns pins newest-first. */ + it('moves a pin to the top when the caller asks for it', () => { + const other = { ...pinnedConvo, conversationId: 'convo-other' } as TConversation; + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.pinnedConversations, { tags: undefined }], + listResponse([other, pinnedConvo]), + ); + + updateConvoInAllQueries( + queryClient, + pinnedConvo.conversationId as string, + (convo) => ({ ...convo, title: 'Replied' }), + true, + ); + + expect(readPinnedCache(queryClient)?.conversations.map((c) => c.conversationId)).toEqual([ + 'convo-pinned', + 'convo-other', + ]); + }); + it('leaves the pinned cache untouched for an unrelated conversation', () => { const queryClient = createQueryClient(); - queryClient.setQueryData([QueryKeys.pinnedConversations], listResponse([pinnedConvo])); + queryClient.setQueryData( + [QueryKeys.pinnedConversations, { tags: undefined }], + listResponse([pinnedConvo]), + ); updateConvoInAllQueries(queryClient, 'some-other-convo', (convo) => ({ ...convo, diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index 94fcf3aca74..417110dc728 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -142,6 +142,9 @@ export const useArchiveConvoMutation = ( queryKey: archivedConvoQueryKey, refetchPage: (_, index) => index === 0, }); + /** Archiving drops the chat from the pinned cache, so restoring one that is + * still pinned has to refetch or the section would stay missing it. */ + queryClient.invalidateQueries([QueryKeys.pinnedConversations]); queryClient.invalidateQueries([QueryKeys.projectConversations]); queryClient.invalidateQueries([QueryKeys.projects]); }, diff --git a/client/src/data-provider/queries.ts b/client/src/data-provider/queries.ts index 75ffe6428af..e34eaa56e0f 100644 --- a/client/src/data-provider/queries.ts +++ b/client/src/data-provider/queries.ts @@ -112,18 +112,40 @@ export const useConversationsInfiniteQuery = ( }; /** - * Pinned chats are a hand-curated, deliberately small set, so the sidebar section - * fetches them whole rather than paginating: a pin older than the first page of the - * Chats list would otherwise stay hidden until that list scrolled far enough to reach it. + * Pinned chats are a hand-curated set, so the sidebar fetches the whole thing rather + * than paginating it: a pin older than the first page of the Chats list would + * otherwise stay hidden until that list scrolled far enough to reach it, and + * `groupConversationsByDate` keeps pins out of the Chats groups entirely, so any pin + * this query does not return is invisible in the sidebar. The page size is therefore a + * request size, not a cap; the query drains the cursor. */ -export const pinnedConversationsLimit = 100; +export const pinnedConversationsPageSize = 100; export const usePinnedConversationsQuery = ( + params: Pick = {}, config?: UseQueryOptions, ): QueryObserverResult => { + const { tags } = params; + return useQuery( - [QueryKeys.pinnedConversations], - () => dataService.listConversations({ pinned: true, limit: pinnedConversationsLimit }), + [QueryKeys.pinnedConversations, { tags }], + async () => { + const conversations: ConversationListResponse['conversations'] = []; + let cursor: string | undefined; + + do { + const page = await dataService.listConversations({ + pinned: true, + tags, + limit: pinnedConversationsPageSize, + cursor, + }); + conversations.push(...page.conversations); + cursor = page.nextCursor ?? undefined; + } while (cursor); + + return { conversations, nextCursor: null }; + }, { staleTime: 5 * 60 * 1000, cacheTime: 30 * 60 * 1000, diff --git a/client/src/utils/convos.ts b/client/src/utils/convos.ts index e689d0442e6..4113800d9eb 100644 --- a/client/src/utils/convos.ts +++ b/client/src/utils/convos.ts @@ -508,32 +508,49 @@ function updatePinnedConvosQuery( queryClient: QueryClient, conversationId: string, updater: (c: TConversation) => TConversation | null, + moveToTop = false, ) { - queryClient.setQueryData([QueryKeys.pinnedConversations], (oldData) => { - if (!oldData) { - return oldData; - } - const index = oldData.conversations.findIndex((c) => c.conversationId === conversationId); - if (index === -1) { - return oldData; - } - const found = oldData.conversations[index]; - const updated = updater(found); - if (!updated || updated.pinned !== true) { + /* Keyed by the active bookmark filter, so every cached variant has to be touched + rather than only the unfiltered one. */ + const queries = queryClient + .getQueryCache() + .findAll([QueryKeys.pinnedConversations], { exact: false }); + + for (const query of queries) { + queryClient.setQueryData(query.queryKey, (oldData) => { + if (!oldData) { + return oldData; + } + const index = oldData.conversations.findIndex((c) => c.conversationId === conversationId); + if (index === -1) { + return oldData; + } + const found = oldData.conversations[index]; + const updated = updater(found); + if (!updated || updated.pinned !== true) { + return { + ...oldData, + conversations: oldData.conversations.filter((_, i) => i !== index), + }; + } + const merged = + updated.isShared === undefined && found.isShared !== undefined + ? { ...updated, isShared: found.isShared } + : updated; + + /* The server returns pins newest-first, so a pin that just received a message has + to lead the section the same way it leads the chats list. */ + if (moveToTop) { + const rest = oldData.conversations.filter((_, i) => i !== index); + return { ...oldData, conversations: [merged, ...rest] }; + } + return { ...oldData, - conversations: oldData.conversations.filter((_, i) => i !== index), + conversations: oldData.conversations.map((c, i) => (i === index ? merged : c)), }; - } - const merged = - updated.isShared === undefined && found.isShared !== undefined - ? { ...updated, isShared: found.isShared } - : updated; - return { - ...oldData, - conversations: oldData.conversations.map((c, i) => (i === index ? merged : c)), - }; - }); + }); + } } // Update @@ -543,7 +560,7 @@ export function updateConvoInAllQueries( updater: (c: TConversation) => TConversation, moveToTop = false, ) { - updatePinnedConvosQuery(queryClient, conversationId, updater); + updatePinnedConvosQuery(queryClient, conversationId, updater, moveToTop); const queries = queryClient .getQueryCache() diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 03e9c433e8f..c80ef765ac1 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -60,6 +60,7 @@ export const excludedKeys = new Set([ 'isTemporary', 'messages', 'isArchived', + 'pinned', 'tags', 'user', '__v', diff --git a/packages/data-schemas/src/schema/convo.ts b/packages/data-schemas/src/schema/convo.ts index fb49b85ebbb..682eb3c011b 100644 --- a/packages/data-schemas/src/schema/convo.ts +++ b/packages/data-schemas/src/schema/convo.ts @@ -62,6 +62,9 @@ convoSchema.index({ conversationId: 1, user: 1, tenantId: 1 }, { unique: true }) convoSchema.index({ user: 1, chatProjectId: 1, updatedAt: -1, _id: -1 }); convoSchema.index({ user: 1, chatProjectId: 1, createdAt: -1, _id: -1 }); +/** The sidebar's pinned section filters on user + pinned and pages by `updatedAt`. */ +convoSchema.index({ user: 1, pinned: 1, updatedAt: -1, _id: -1 }); + convoSchema.index({ user: 1, isTemporary: 1, expiredAt: 1 }); // index for MeiliSearch sync operations convoSchema.index({ _meiliIndex: 1, isTemporary: 1, expiredAt: 1 }); From 1b88ff968c881db8a46794c807cfdf6e2fe50769 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:49:46 +0200 Subject: [PATCH 03/15] fix: keep the pinned cache reconciled across the other convo mutations Second review pass on the independent pinned query. - Fall back to the pins already loaded in the chats pages when the dedicated request fails. Pins are stripped from the date groups, so an error otherwise emptied the section and hid them everywhere. - Restore default focus and reconnect refetching, matching the conversations query. A pin changed in another tab is only reconciled by a refetch, since that tab's mutation never touched this cache. - Invalidate the pinned list from the mutations that can produce or alter a pinned chat without going through pin itself: duplicate, fork, import, project assignment, and shared-link deletion. --- .../UnifiedSidebar/ConversationsSection.tsx | 14 +++++++------- client/src/data-provider/Projects/mutations.ts | 6 ++++-- client/src/data-provider/mutations.ts | 8 ++++++++ client/src/data-provider/queries.ts | 5 +++-- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/client/src/components/UnifiedSidebar/ConversationsSection.tsx b/client/src/components/UnifiedSidebar/ConversationsSection.tsx index 2fb076835d0..1ec22915057 100644 --- a/client/src/components/UnifiedSidebar/ConversationsSection.tsx +++ b/client/src/components/UnifiedSidebar/ConversationsSection.tsx @@ -88,13 +88,13 @@ const ConversationsSection = memo(() => { { enabled: isAuthenticated }, ); - const pinnedConversations = useMemo( - () => - (pinnedData?.conversations ?? []).filter((convo): convo is TConversation => - Boolean(convo?.pinned === true), - ), - [pinnedData?.conversations], - ); + /* `groupConversationsByDate` strips pins from the chats groups, so if the dedicated + request fails there is nowhere else for them to show. Fall back to whatever pins the + loaded chats pages already carry rather than emptying the section. */ + const pinnedConversations = useMemo(() => { + const source = pinnedData?.conversations ?? conversations; + return source.filter((convo): convo is TConversation => Boolean(convo?.pinned === true)); + }, [pinnedData?.conversations, conversations]); const toggleNav = useCallback(() => { if (isSmallScreen) { diff --git a/client/src/data-provider/Projects/mutations.ts b/client/src/data-provider/Projects/mutations.ts index a02f177d975..11a403ffd88 100644 --- a/client/src/data-provider/Projects/mutations.ts +++ b/client/src/data-provider/Projects/mutations.ts @@ -1,7 +1,6 @@ import { useRecoilCallback } from 'recoil'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; import { dataService, QueryKeys } from 'librechat-data-provider'; -import type { UseMutationResult } from '@tanstack/react-query'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import type { TChatProject, TConversation, @@ -11,6 +10,7 @@ import type { TAssignConversationToProjectRequest, TAssignConversationToProjectResponse, } from 'librechat-data-provider'; +import type { UseMutationResult } from '@tanstack/react-query'; import store from '~/store'; export const useCreateProjectMutation = (): UseMutationResult< @@ -116,6 +116,8 @@ export const useAssignConversationToProjectMutation = (): UseMutationResult< }); queryClient.invalidateQueries([QueryKeys.projects]); queryClient.invalidateQueries([QueryKeys.allConversations]); + /** The pinned row carries `chatProjectId` for its options menu. */ + queryClient.invalidateQueries([QueryKeys.pinnedConversations]); queryClient.invalidateQueries([QueryKeys.projectConversations]); }, }, diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index 417110dc728..3b5f3eadc97 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -387,6 +387,8 @@ export const useDeleteSharedLinkMutation = ( from the links that are actually left, settle it. Every cached page refetches: the affected conversation is as likely to sit on page three as on page one. */ queryClient.invalidateQueries({ queryKey: [QueryKeys.allConversations] }); + /** The pinned section renders the same badge from its own cache. */ + queryClient.invalidateQueries({ queryKey: [QueryKeys.pinnedConversations] }); }, onSuccess: (data, variables) => { @@ -709,6 +711,8 @@ export const useDuplicateConversationMutation = ( queryKey: [QueryKeys.allConversations], refetchPage: (_, index) => index === 0, }); + /** A duplicated, forked or imported chat can arrive already pinned. */ + queryClient.invalidateQueries([QueryKeys.pinnedConversations]); queryClient.invalidateQueries([QueryKeys.projectConversations]); queryClient.invalidateQueries([QueryKeys.projects]); if (duplicatedConversation.chatProjectId) { @@ -757,6 +761,8 @@ export const useForkConvoMutation = ( queryKey: [QueryKeys.allConversations], refetchPage: (_, index) => index === 0, }); + /** A duplicated, forked or imported chat can arrive already pinned. */ + queryClient.invalidateQueries([QueryKeys.pinnedConversations]); queryClient.invalidateQueries([QueryKeys.projectConversations]); queryClient.invalidateQueries([QueryKeys.projects]); if (forkedConversation.chatProjectId) { @@ -831,6 +837,8 @@ export const useUploadConversationsMutation = ( onSuccess: (data, variables, context) => { /* TODO: optimize to return imported conversations and add manually */ queryClient.invalidateQueries([QueryKeys.allConversations]); + /** An imported chat can carry `pinned: true`. */ + queryClient.invalidateQueries([QueryKeys.pinnedConversations]); if (onSuccess) { onSuccess(data, variables, context); } diff --git a/client/src/data-provider/queries.ts b/client/src/data-provider/queries.ts index e34eaa56e0f..61333a0d3c9 100644 --- a/client/src/data-provider/queries.ts +++ b/client/src/data-provider/queries.ts @@ -147,10 +147,11 @@ export const usePinnedConversationsQuery = ( return { conversations, nextCursor: null }; }, { + /* Left on the React Query defaults for focus and reconnect, matching the + conversations query: a pin changed in another tab is only reconciled by a + refetch, since the mutation that made it never touched this cache. */ staleTime: 5 * 60 * 1000, cacheTime: 30 * 60 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, ...config, }, ); From ceb2a3a979f2e593c69ecd32fe5cb9ddda342bac Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 15 Aug 2026 08:09:10 +0200 Subject: [PATCH 04/15] fix: invalidate pins on tag and project-deletion changes Third review pass, same class as the last: the pinned query is keyed by the active bookmark filter, so changing a chat's tags can move it in or out of that filtered set, and deleting a project unsets chatProjectId on its chats, pinned ones included. --- client/src/data-provider/Projects/mutations.ts | 2 ++ client/src/data-provider/mutations.ts | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/client/src/data-provider/Projects/mutations.ts b/client/src/data-provider/Projects/mutations.ts index 11a403ffd88..d301fea848e 100644 --- a/client/src/data-provider/Projects/mutations.ts +++ b/client/src/data-provider/Projects/mutations.ts @@ -74,6 +74,8 @@ export const useDeleteProjectMutation = (): UseMutationResult< queryClient.removeQueries([QueryKeys.project, projectId], { type: 'inactive' }); queryClient.invalidateQueries([QueryKeys.projects]); queryClient.invalidateQueries([QueryKeys.allConversations]); + /** Deleting a project unsets chatProjectId on its chats, pinned ones included. */ + queryClient.invalidateQueries([QueryKeys.pinnedConversations]); }, }); }; diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index 3b5f3eadc97..04a29eddf69 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -46,6 +46,7 @@ export const useTagConversationMutation = ( conversationId: string, options?: t.updateTagsInConvoOptions, ): UseMutationResult => { + const queryClient = useQueryClient(); const query = useConversationTagsQuery(); const { updateTagsInConversation } = useUpdateTagsInConvo(); return useMutation( @@ -53,6 +54,9 @@ export const useTagConversationMutation = ( dataService.addTagToConversation(conversationId, payload), { onSuccess: (updatedTags, ...rest) => { + /** The pinned query is keyed by the active bookmark filter, so changing a + * chat's tags can move it in or out of that filtered set. */ + queryClient.invalidateQueries([QueryKeys.pinnedConversations]); query.refetch(); updateTagsInConversation(conversationId, updatedTags); options?.onSuccess?.(updatedTags, ...rest); From 987dda61edc44900edfc5a053654408e2bd7d57e Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:24:30 +0200 Subject: [PATCH 05/15] fix: cancel in-flight pinned fetches when deleting a conversation Deletion cancelled the regular and archived queries but not the pinned one, so a pinned GET issued before the delete could resolve after the row was stripped and write the deleted conversation back, leaving a row that navigates to a missing chat. Restoring default focus and reconnect refetching in the previous commit made those in-flight fetches more likely, so this widened rather than appeared. Cancelled on mutate, and invalidated on success since cancelling a race is best effort. --- client/src/data-provider/mutations.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index 04a29eddf69..aa9e5920f04 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -603,6 +603,9 @@ export const useDeleteConversationMutation = ( onMutate: async () => { await queryClient.cancelQueries([QueryKeys.allConversations]); await queryClient.cancelQueries([QueryKeys.archivedConversations]); + /** A pinned GET already in flight would otherwise resolve after the row is + * stripped below and write the deleted conversation back into that cache. */ + await queryClient.cancelQueries([QueryKeys.pinnedConversations]); // could store old state if needed for rollback }, onError: () => { @@ -678,6 +681,8 @@ export const useDeleteConversationMutation = ( queryKey: [QueryKeys.archivedConversations], refetchPage: (_, index) => index === 0, }); + /** Cancelling races is best effort, so reconcile the pinned list afterwards too. */ + queryClient.invalidateQueries([QueryKeys.pinnedConversations]); queryClient.invalidateQueries([QueryKeys.projectConversations]); queryClient.invalidateQueries([QueryKeys.projects]); queryClient.invalidateQueries([QueryKeys.conversationTags]); From 34de556f14b4b1adc66de97b8fd79e10469ada9d Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 15 Aug 2026 16:55:42 +0200 Subject: [PATCH 06/15] test: make the SSE query-cache mock key-aware The conversation cache helpers now run a second, pinned-keyed findAll pass. This mock ignored its key argument and always returned an allConversations entry, so those pinned writes were attributed to allConversations and the write-count assertions saw three instead of two. --- AGENT_PROMPT.md | 32 +++++++++++++++++++ .../SSE/__tests__/useResumableSSE.spec.ts | 20 +++++++++--- 2 files changed, 48 insertions(+), 4 deletions(-) create mode 100644 AGENT_PROMPT.md diff --git a/AGENT_PROMPT.md b/AGENT_PROMPT.md new file mode 100644 index 00000000000..c5fe974d534 --- /dev/null +++ b/AGENT_PROMPT.md @@ -0,0 +1,32 @@ +Fix pinned chats missing from the sidebar until you scroll. + +Worktree: /home/berry13/.paseo/worktrees/2cter3r2/fix-pinned-section-always-fetch +Branch: fix/pinned-section-always-fetch (stacked on feat/pinned-chats-section) +Stay in this worktree. Do not reset, stash, or start a new one. + +## Bug +PinnedSection filters `pinned` off the paginated Chats list (`GET /api/convos`, 25 per page, newest `updatedAt` first). Pins are not hoisted. After 25 newer chats exist, a reload hides the pin until Chats fetches the next page. + +Reproduced: pin "Initial Greeting", insert 30 newer unpinned chats, reload. No Pinned section. Scroll Chats. Pin appears. + +## Do this +Give Pinned its own fetch so every pin shows on first paint, without scrolling Chats. + +Preferred: `GET /api/convos?pinned=true` (or equivalent) that returns only that user's pinned conversations, plus a dedicated frontend query used by PinnedSection. Pin/unpin must refresh that query. Keep pins out of the Chats date groups. + +## Touch +- `packages/data-schemas/src/methods/conversation.ts` (`getConvosByCursor`) +- `api/server/routes/convos.js` +- `client/src/data-provider/queries.ts` and list params +- `client/src/components/Conversations/PinnedSection.tsx` +- `client/src/components/UnifiedSidebar/ConversationsSection.tsx` + +The Pinned section UI is already in this tree (uncommitted). Do not redesign it. + +## Done when +- Reload with a pin past page 1 still shows the Pinned section immediately +- Unpin still removes it; pin still adds it +- `npx eslint` on touched JS/TS is clean +- Unit tests cover the pinned list filter / query + +Do not commit the dummy PINREPRO chats. Do not mention AI in commits. diff --git a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts index cc198a27a08..7d2ea6ccf44 100644 --- a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts +++ b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts @@ -49,7 +49,7 @@ const mockGetQueryData = jest.fn(); const mockFetchQuery = jest.fn(); const mockInvalidateQueries = jest.fn(); const mockRemoveQueries = jest.fn(); -const mockFindAll = jest.fn((): Array<{ queryKey: unknown[] }> => []); +const mockFindAll = jest.fn((_queryKey?: unknown): Array<{ queryKey: unknown[] }> => []); const mockQueryClient = { setQueryData: mockSetQueryData, getQueryData: mockGetQueryData, @@ -482,7 +482,11 @@ describe('useResumableSSE', () => { }); it('invalidates the stream conversation id on 404 for a new conversation', async () => { - mockFindAll.mockReturnValue([{ queryKey: [QueryKeys.allConversations] }]); + /* Key-aware: the conversation cache helpers now run a second, pinned-keyed pass, + and a fixed return value would attribute those writes to allConversations. */ + mockFindAll.mockImplementation((queryKey?: unknown) => [ + { queryKey: [(queryKey as unknown[])[0]] }, + ]); const submission = buildSubmission({ conversation: {}, userMessage: { @@ -545,7 +549,11 @@ describe('useResumableSSE', () => { }); it('reconciles conversations via refetch instead of removing them on a resume 404', async () => { - mockFindAll.mockReturnValue([{ queryKey: [QueryKeys.allConversations] }]); + /* Key-aware: the conversation cache helpers now run a second, pinned-keyed pass, + and a fixed return value would attribute those writes to allConversations. */ + mockFindAll.mockImplementation((queryKey?: unknown) => [ + { queryKey: [(queryKey as unknown[])[0]] }, + ]); // A deduped start returns status: 'resumed', so the client subscribes with resume=true. (request.post as jest.Mock).mockResolvedValue({ streamId: 'stream-123', status: 'resumed' }); const submission = buildSubmission({ @@ -3792,7 +3800,11 @@ describe('useResumableSSE', () => { }); it('removes the optimistic sidebar row when a new conversation errors before created', async () => { - mockFindAll.mockReturnValue([{ queryKey: [QueryKeys.allConversations] }]); + /* Key-aware: the conversation cache helpers now run a second, pinned-keyed pass, + and a fixed return value would attribute those writes to allConversations. */ + mockFindAll.mockImplementation((queryKey?: unknown) => [ + { queryKey: [(queryKey as unknown[])[0]] }, + ]); const submission = buildSubmission({ conversation: {}, userMessage: { From 431ce233de85ae45e99790cc848fc185daa83a34 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:44:35 +0200 Subject: [PATCH 07/15] fix: keep pins in sync through upsert and pin-only pages Root-level SSE updates and resumable settlement call upsert rather than update, so the independently cached pinned row never moved or refreshed. An all-pin first page also left the chats virtual list empty, so onRowsRendered never asked for the next cursor. --- .../Conversations/Conversations.tsx | 17 ++++ .../__tests__/Conversations.test.tsx | 65 +++++++++++++++ .../__tests__/pinnedConversations.test.tsx | 79 ++++++++++++++++++- client/src/utils/convos.ts | 15 ++++ 4 files changed, 175 insertions(+), 1 deletion(-) diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index fb27f325648..feb270e3f5e 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -189,6 +189,23 @@ const Conversations: FC = ({ [filteredConversations], ); + /* Pins are stripped from the date groups. An all-pin page leaves the + virtual list with no rows, so onRowsRendered never fires and later + unpinned chats stay unreachable. Keep paging while the parent still + has another cursor; loadMoreConversations no-ops when it does not. */ + useEffect(() => { + if (!isChatsExpanded || isLoading || isSearchLoading || groupedConversations.length > 0) { + return; + } + loadMoreConversations(); + }, [ + isChatsExpanded, + isLoading, + isSearchLoading, + groupedConversations.length, + loadMoreConversations, + ]); + const flattenedItems = useMemo(() => { const items: FlattenedItem[] = []; // Only include favorites row if FavoritesList will render content diff --git a/client/src/components/Conversations/__tests__/Conversations.test.tsx b/client/src/components/Conversations/__tests__/Conversations.test.tsx index 636542cf6e6..ea1402e1a9a 100644 --- a/client/src/components/Conversations/__tests__/Conversations.test.tsx +++ b/client/src/components/Conversations/__tests__/Conversations.test.tsx @@ -237,3 +237,68 @@ describe('Conversations: pinned chats live in PinnedSection', () => { expect(queryByRole('button', { name: 'com_ui_new_chat' })).not.toBeInTheDocument(); }); }); + +describe('Conversations: all-pin pages still paginate', () => { + const containerRef = createRef(); + + beforeEach(() => { + mockCapturedCache = null; + mockFavoritesState.favorites = []; + mockFavoritesState.isLoading = false; + mockShowMarketplace = false; + }); + + const renderList = ({ + conversations, + loadMoreConversations, + isChatsExpanded = true, + isLoading = false, + }: { + conversations: TConversation[]; + loadMoreConversations: () => void; + isChatsExpanded?: boolean; + isLoading?: boolean; + }) => + render( + + + , + ); + + it('requests another page when grouping leaves the chats list empty', () => { + const loadMoreConversations = jest.fn(); + renderList({ conversations: [pinnedConvo], loadMoreConversations }); + expect(loadMoreConversations).toHaveBeenCalled(); + }); + + it('does not request another page while chats are collapsed', () => { + const loadMoreConversations = jest.fn(); + renderList({ + conversations: [pinnedConvo], + loadMoreConversations, + isChatsExpanded: false, + }); + expect(loadMoreConversations).not.toHaveBeenCalled(); + }); + + it('does not request another page while a fetch is already in flight', () => { + const loadMoreConversations = jest.fn(); + renderList({ + conversations: [pinnedConvo], + loadMoreConversations, + isLoading: true, + }); + expect(loadMoreConversations).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/data-provider/__tests__/pinnedConversations.test.tsx b/client/src/data-provider/__tests__/pinnedConversations.test.tsx index 95ee7cae779..130fc06a94e 100644 --- a/client/src/data-provider/__tests__/pinnedConversations.test.tsx +++ b/client/src/data-provider/__tests__/pinnedConversations.test.tsx @@ -5,7 +5,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import type { ConversationListResponse, TConversation } from 'librechat-data-provider'; import type { ReactNode } from 'react'; import { pinnedConversationsPageSize, usePinnedConversationsQuery } from '../queries'; -import { removeConvoFromAllQueries, updateConvoInAllQueries } from '~/utils/convos'; +import { + removeConvoFromAllQueries, + updateConvoInAllQueries, + upsertConvoInAllQueries, +} from '~/utils/convos'; import { usePinConversationMutation } from '../mutations'; jest.mock('librechat-data-provider', () => { @@ -239,4 +243,77 @@ describe('pinned list cache synchronization', () => { expect(readPinnedCache(queryClient)?.conversations).toEqual([pinnedConvo]); }); + + /** Root-level SSE updates and resumable settlement call upsert rather than + * update, so the independently cached pin has to follow that path too. */ + it('updates an existing pin when the conversation is upserted', () => { + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.pinnedConversations, { tags: undefined }], + listResponse([pinnedConvo]), + ); + + upsertConvoInAllQueries(queryClient, { + ...pinnedConvo, + title: 'Settled title', + }); + + expect(readPinnedCache(queryClient)?.conversations).toEqual([ + expect.objectContaining({ ...pinnedConvo, title: 'Settled title' }), + ]); + }); + + it('moves an upserted pin to the top of the pinned cache', () => { + const other = { ...pinnedConvo, conversationId: 'convo-other' } as TConversation; + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.pinnedConversations, { tags: undefined }], + listResponse([other, pinnedConvo]), + ); + + upsertConvoInAllQueries(queryClient, { + ...pinnedConvo, + title: 'Replied', + }); + + expect(readPinnedCache(queryClient)?.conversations.map((c) => c.conversationId)).toEqual([ + 'convo-pinned', + 'convo-other', + ]); + }); + + it('does not insert a conversation that is not already in the pinned cache', () => { + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.pinnedConversations, { tags: undefined }], + listResponse([pinnedConvo]), + ); + + upsertConvoInAllQueries(queryClient, { + conversationId: 'convo-new', + title: 'Brand new', + endpoint: 'openAI', + pinned: true, + } as TConversation); + + expect(readPinnedCache(queryClient)?.conversations).toEqual([pinnedConvo]); + }); + + it('keeps the cached pinned flag when the upsert payload omits it', () => { + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.pinnedConversations, { tags: undefined }], + listResponse([pinnedConvo]), + ); + + upsertConvoInAllQueries(queryClient, { + conversationId: pinnedConvo.conversationId, + title: 'Root turn', + endpoint: 'openAI', + } as TConversation); + + expect(readPinnedCache(queryClient)?.conversations).toEqual([ + expect.objectContaining({ ...pinnedConvo, title: 'Root turn' }), + ]); + }); }); diff --git a/client/src/utils/convos.ts b/client/src/utils/convos.ts index 4113800d9eb..dc83c350a96 100644 --- a/client/src/utils/convos.ts +++ b/client/src/utils/convos.ts @@ -403,6 +403,21 @@ export function upsertConvoInAllQueries( return; } + /* Root-level SSE updates and resumable settlement go through upsert, not + update. Merge into any already-cached pin so that path cannot leave the + section at the old title or position. Do not insert: a new chat is not + pinned until the pin mutation refetches. */ + updatePinnedConvosQuery( + queryClient, + nextConvo.conversationId, + (found) => ({ + ...found, + ...nextConvo, + updatedAt: nextConvo.updatedAt ?? (moveToTop ? new Date().toISOString() : found.updatedAt), + }), + moveToTop, + ); + const queries = queryClient .getQueryCache() .findAll([QueryKeys.allConversations], { exact: false }); From 077edf5757d84de06fd98af6e562638066cb7bf5 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 15 Aug 2026 21:58:53 +0200 Subject: [PATCH 08/15] fix: keep pins current through SSE recovery and project delete Resumable SSE reconciliation invalidated conversation and allConversations only, so an independently cached pin kept stale title and order. Deleting a project-backed pin that lived only in that cache also skipped the project query, because the mutation never read chatProjectId there. --- .../__tests__/pinnedConversations.test.tsx | 35 ++++++++++++++++++- client/src/data-provider/mutations.ts | 21 +++++++++++ .../SSE/__tests__/useResumableSSE.spec.ts | 24 +++++++++++++ client/src/hooks/SSE/useResumableSSE.ts | 17 +++++++++ 4 files changed, 96 insertions(+), 1 deletion(-) diff --git a/client/src/data-provider/__tests__/pinnedConversations.test.tsx b/client/src/data-provider/__tests__/pinnedConversations.test.tsx index 130fc06a94e..2eefccb17f7 100644 --- a/client/src/data-provider/__tests__/pinnedConversations.test.tsx +++ b/client/src/data-provider/__tests__/pinnedConversations.test.tsx @@ -10,7 +10,7 @@ import { updateConvoInAllQueries, upsertConvoInAllQueries, } from '~/utils/convos'; -import { usePinConversationMutation } from '../mutations'; +import { useDeleteConversationMutation, usePinConversationMutation } from '../mutations'; jest.mock('librechat-data-provider', () => { const actual = jest.requireActual('librechat-data-provider'); @@ -20,6 +20,7 @@ jest.mock('librechat-data-provider', () => { ...actual.dataService, listConversations: jest.fn(), pinConversation: jest.fn(), + deleteConversation: jest.fn(), }, }; }); @@ -30,6 +31,9 @@ const listConversations = dataService.listConversations as jest.MockedFunction< const pinConversation = dataService.pinConversation as jest.MockedFunction< typeof dataService.pinConversation >; +const deleteConversation = dataService.deleteConversation as jest.MockedFunction< + typeof dataService.deleteConversation +>; const pinnedConvo = { conversationId: 'convo-pinned', @@ -317,3 +321,32 @@ describe('pinned list cache synchronization', () => { ]); }); }); + +describe('delete mutation project lookup', () => { + const projectId = 'project-pinned'; + + it('invalidates the project when the deleted pin is only in the pinned cache', async () => { + deleteConversation.mockResolvedValue({ + acknowledged: true, + deletedCount: 1, + messages: { acknowledged: true, deletedCount: 0 }, + }); + const queryClient = createQueryClient(); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + queryClient.setQueryData( + [QueryKeys.pinnedConversations, { tags: undefined }], + listResponse([{ ...pinnedConvo, chatProjectId: projectId }]), + ); + + const { result } = renderHook(() => useDeleteConversationMutation(), { + wrapper: createWrapper(queryClient), + }); + + await act(async () => { + result.current.mutate({ conversationId: pinnedConvo.conversationId }); + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(invalidateSpy).toHaveBeenCalledWith([QueryKeys.project, projectId]); + }); +}); diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index aa9e5920f04..ece3c55e23e 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -636,6 +636,27 @@ export const useDeleteConversationMutation = ( } } + /** A project-backed pin can be absent from the loaded chats and + * project pages. The pinned cache is the remaining source for + * `chatProjectId` so the project workspace can drop its stale count. */ + if (!deletedProjectId && vars.conversationId) { + const pinnedQueries = queryClient + .getQueryCache() + .findAll([QueryKeys.pinnedConversations], { exact: false }); + for (const query of pinnedQueries) { + const data = queryClient.getQueryData<{ conversations?: t.TConversation[] }>( + query.queryKey, + ); + const found = data?.conversations?.find( + (conversation) => conversation.conversationId === vars.conversationId, + ); + if (found?.chatProjectId) { + deletedProjectId = found.chatProjectId; + break; + } + } + } + if (vars.conversationId) { removeConvoFromAllQueries(queryClient, vars.conversationId); clearDeletedConversationMessagesCache(queryClient, vars.conversationId); diff --git a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts index 7d2ea6ccf44..c7643a13822 100644 --- a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts +++ b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts @@ -593,6 +593,9 @@ describe('useResumableSSE', () => { expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKeys.allConversations], }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKeys.pinnedConversations], + }); unmount(); }); @@ -1531,6 +1534,9 @@ describe('useResumableSSE', () => { expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKeys.allConversations], }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKeys.pinnedConversations], + }); /** The settled response carries no epoch, so it cannot authorize clearing * whichever conversation/generation may now own this pane's arm. */ expect(mockSetDrainAfterAbort).not.toHaveBeenCalled(); @@ -2175,6 +2181,12 @@ describe('useResumableSSE', () => { queryKey: [QueryKeys.messages, CONV_ID], refetchType: 'none', }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKeys.allConversations], + }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKeys.pinnedConversations], + }); expect(mockSettleAppliedSteerParts).toHaveBeenCalledWith(CONV_ID, persisted); expect(mockSetRunEnd).toHaveBeenCalledWith( expect.objectContaining({ conversationId: CONV_ID, outcome: 'completed' }), @@ -2646,6 +2658,12 @@ describe('useResumableSSE', () => { queryKey: [QueryKeys.messages, CONV_ID], refetchType: 'all', }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKeys.allConversations], + }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKeys.pinnedConversations], + }); expect(mockErrorHandler).not.toHaveBeenCalled(); expect(mockSetRunEnd).not.toHaveBeenCalled(); expect(mockSetIsSubmitting).not.toHaveBeenCalledWith(false); @@ -2696,6 +2714,12 @@ describe('useResumableSSE', () => { queryKey: [QueryKeys.messages, CONV_ID], refetchType: 'all', }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKeys.allConversations], + }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKeys.pinnedConversations], + }); expect(mockErrorHandler).not.toHaveBeenCalled(); expect(mockSetRunEnd).not.toHaveBeenCalled(); expect(mockSetIsSubmitting).toHaveBeenCalledWith(false); diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts index 249da43cdfe..b0c4998eb77 100644 --- a/client/src/hooks/SSE/useResumableSSE.ts +++ b/client/src/hooks/SSE/useResumableSSE.ts @@ -2265,6 +2265,10 @@ export default function useResumableSSE( if (!isCurrentSubscription()) { return; } + await queryClient.invalidateQueries({ queryKey: [QueryKeys.pinnedConversations] }); + if (!isCurrentSubscription()) { + return; + } } catch (error) { if (!isCurrentSubscription()) { return; @@ -2646,6 +2650,7 @@ export default function useResumableSSE( // existed (the winner died before persisting). Don't guess: reconcile against // the server so a real conversation stays and a phantom is dropped. queryClient.invalidateQueries({ queryKey: [QueryKeys.allConversations] }); + queryClient.invalidateQueries({ queryKey: [QueryKeys.pinnedConversations] }); } else { // Fresh optimistic stream that never started: prune immediately. removeConvoFromAllQueries(queryClient, currentStreamId); @@ -3603,6 +3608,10 @@ export default function useResumableSSE( if (!isCurrentEffect()) { return; } + await queryClient.invalidateQueries({ queryKey: [QueryKeys.pinnedConversations] }); + if (!isCurrentEffect()) { + return; + } } catch (error) { if (!isCurrentEffect()) { return; @@ -3669,6 +3678,10 @@ export default function useResumableSSE( if (!isCurrentEffect()) { return; } + await queryClient.invalidateQueries({ queryKey: [QueryKeys.pinnedConversations] }); + if (!isCurrentEffect()) { + return; + } } catch (error) { if (!isCurrentEffect()) { return; @@ -3811,6 +3824,10 @@ export default function useResumableSSE( if (!isCurrentEffect()) { return; } + await queryClient.invalidateQueries({ queryKey: [QueryKeys.pinnedConversations] }); + if (!isCurrentEffect()) { + return; + } } catch (error) { if (!isCurrentEffect()) { return; From 6c84cacda49ce2e37542a5cc948bad52ed379f46 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:25:22 +0200 Subject: [PATCH 09/15] fix: keep pins current after bookmark edits and failed pages Renaming or deleting a bookmark rewrote tags on conversations but left the tag-keyed pinned cache pointing at the old filter. An all-pin page whose next fetch failed also retried forever because the empty-list effect had no memory of the attempt. Unpinning a pin that only lived in the dedicated cache removed it from Pinned without inserting it into Chats, and later cursor pages cannot recover a row whose updatedAt just jumped ahead of the current cursor. --- .../Conversations/Conversations.tsx | 11 +- .../__tests__/Conversations.test.tsx | 42 +++++++ .../__tests__/pinnedConversations.test.tsx | 107 +++++++++++++++++- client/src/data-provider/mutations.ts | 14 +++ 4 files changed, 171 insertions(+), 3 deletions(-) diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index feb270e3f5e..d725ab6e265 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -191,18 +191,25 @@ const Conversations: FC = ({ /* Pins are stripped from the date groups. An all-pin page leaves the virtual list with no rows, so onRowsRendered never fires and later - unpinned chats stay unreachable. Keep paging while the parent still - has another cursor; loadMoreConversations no-ops when it does not. */ + unpinned chats stay unreachable. Ask for another page only when the + conversations input actually changes; a failed fetchNextPage leaves + the same array and must not loop. */ + const paginatedFromRef = useRef | null>(null); useEffect(() => { if (!isChatsExpanded || isLoading || isSearchLoading || groupedConversations.length > 0) { return; } + if (paginatedFromRef.current === rawConversations) { + return; + } + paginatedFromRef.current = rawConversations; loadMoreConversations(); }, [ isChatsExpanded, isLoading, isSearchLoading, groupedConversations.length, + rawConversations, loadMoreConversations, ]); diff --git a/client/src/components/Conversations/__tests__/Conversations.test.tsx b/client/src/components/Conversations/__tests__/Conversations.test.tsx index ea1402e1a9a..86486db9d58 100644 --- a/client/src/components/Conversations/__tests__/Conversations.test.tsx +++ b/client/src/components/Conversations/__tests__/Conversations.test.tsx @@ -301,4 +301,46 @@ describe('Conversations: all-pin pages still paginate', () => { }); expect(loadMoreConversations).not.toHaveBeenCalled(); }); + + it('does not retry when an empty-page fetch fails without new data', () => { + const loadMoreConversations = jest.fn(); + const conversations = [pinnedConvo]; + const { rerender } = renderList({ conversations, loadMoreConversations }); + expect(loadMoreConversations).toHaveBeenCalledTimes(1); + + rerender( + + + , + ); + rerender( + + + , + ); + + expect(loadMoreConversations).toHaveBeenCalledTimes(1); + }); }); diff --git a/client/src/data-provider/__tests__/pinnedConversations.test.tsx b/client/src/data-provider/__tests__/pinnedConversations.test.tsx index 2eefccb17f7..1d65fcf0812 100644 --- a/client/src/data-provider/__tests__/pinnedConversations.test.tsx +++ b/client/src/data-provider/__tests__/pinnedConversations.test.tsx @@ -10,7 +10,12 @@ import { updateConvoInAllQueries, upsertConvoInAllQueries, } from '~/utils/convos'; -import { useDeleteConversationMutation, usePinConversationMutation } from '../mutations'; +import { + useConversationTagMutation, + useDeleteConversationMutation, + useDeleteConversationTagMutation, + usePinConversationMutation, +} from '../mutations'; jest.mock('librechat-data-provider', () => { const actual = jest.requireActual('librechat-data-provider'); @@ -21,6 +26,8 @@ jest.mock('librechat-data-provider', () => { listConversations: jest.fn(), pinConversation: jest.fn(), deleteConversation: jest.fn(), + updateConversationTag: jest.fn(), + deleteConversationTag: jest.fn(), }, }; }); @@ -34,6 +41,12 @@ const pinConversation = dataService.pinConversation as jest.MockedFunction< const deleteConversation = dataService.deleteConversation as jest.MockedFunction< typeof dataService.deleteConversation >; +const updateConversationTag = dataService.updateConversationTag as jest.MockedFunction< + typeof dataService.updateConversationTag +>; +const deleteConversationTag = dataService.deleteConversationTag as jest.MockedFunction< + typeof dataService.deleteConversationTag +>; const pinnedConvo = { conversationId: 'convo-pinned', @@ -350,3 +363,95 @@ describe('delete mutation project lookup', () => { expect(invalidateSpy).toHaveBeenCalledWith([QueryKeys.project, projectId]); }); }); + +const tagResponse = { + tag: 'office', + count: 1, + position: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +}; + +describe('bookmark mutations invalidate the pinned cache', () => { + it('invalidates pins when a bookmark is renamed', async () => { + updateConversationTag.mockResolvedValue(tagResponse); + const queryClient = createQueryClient(); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook( + () => useConversationTagMutation({ context: 'test', tag: 'work' }), + { wrapper: createWrapper(queryClient) }, + ); + + await act(async () => { + result.current.mutate({ tag: 'office' }); + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(invalidateSpy).toHaveBeenCalledWith([QueryKeys.pinnedConversations]); + }); + + it('invalidates pins when a bookmark is deleted', async () => { + deleteConversationTag.mockResolvedValue({ ...tagResponse, tag: 'work' }); + const queryClient = createQueryClient(); + const invalidateSpy = jest.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useDeleteConversationTagMutation(), { + wrapper: createWrapper(queryClient), + }); + + await act(async () => { + result.current.mutate('work'); + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(invalidateSpy).toHaveBeenCalledWith([QueryKeys.pinnedConversations]); + }); +}); + +describe('unpinning a pin that is not on a loaded chats page', () => { + it('inserts the unpinned conversation at the top of the chats list', async () => { + const unpinned = { ...pinnedConvo, pinned: false } as TConversation; + pinConversation.mockResolvedValue(unpinned); + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.pinnedConversations, { tags: undefined }], + listResponse([pinnedConvo]), + ); + queryClient.setQueryData([QueryKeys.allConversations], { + pages: [ + { + conversations: [ + { + conversationId: 'other-recent', + title: 'Recent', + endpoint: 'openAI', + } as TConversation, + ], + nextCursor: 'cursor-2', + }, + ], + pageParams: [undefined], + }); + + const { result } = renderHook(() => usePinConversationMutation(), { + wrapper: createWrapper(queryClient), + }); + + await act(async () => { + result.current.mutate({ + conversationId: pinnedConvo.conversationId as string, + pinned: false, + }); + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const chats = queryClient.getQueryData<{ + pages: { conversations: TConversation[] }[]; + }>([QueryKeys.allConversations]); + expect( + chats?.pages[0].conversations.map((conversation) => conversation.conversationId), + ).toEqual(['convo-pinned', 'other-recent']); + expect(chats?.pages[0].conversations[0].pinned).toBe(false); + }); +}); diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index ece3c55e23e..875e80b1edf 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -169,6 +169,14 @@ export const usePinConversationMutation = ( { onSuccess: (data, vars, context) => { updateConvoInAllQueries(queryClient, vars.conversationId, () => data); + /** An older pin may exist only in the dedicated pinned cache. Unpinning + * it has to put the returned row onto the chats list; later pages + * cannot recover a conversation whose updatedAt just jumped ahead of + * the current cursor. addConvoToAllQueries no-ops if it is already + * present. */ + if (data.pinned !== true) { + addConvoToAllQueries(queryClient, data); + } /** The pinned section has its own fetch, so a new pin is only visible once * that list is refetched; unpins are already dropped from its cache above. */ queryClient.invalidateQueries([QueryKeys.pinnedConversations]); @@ -489,6 +497,10 @@ export const useConversationTagMutation = ({ : dataService.createConversationTag(payload), { onSuccess: (...args) => { + /** Renaming a selected bookmark rewrites that tag on every matching + * conversation. The pinned query is keyed by the old filter until it + * is invalidated. */ + queryClient.invalidateQueries([QueryKeys.pinnedConversations]); onMutationSuccess(...args); onSuccess?.(...args); }, @@ -580,6 +592,8 @@ export const useDeleteConversationTagMutation = ( }); deleteTagInAllConversations(tagToDelete); + /** Deleting a selected bookmark empties that tag-keyed pinned set. */ + queryClient.invalidateQueries([QueryKeys.pinnedConversations]); onSuccess?.(_data, tagToDelete, context); }, ..._options, From da104d9c73a9b4e523f327e6da15096932744345 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:41:13 +0200 Subject: [PATCH 10/15] fix: keep pins visible after a failed refetch A failed pinned refetch left React Query holding the previous list, so the nullish fallback never ran and a newly pinned chat vanished from both sections. Unpinning an older pin also inserted it into every cached chats variant, including bookmark and search results it would not match. Drop the checked-in agent task prompt. --- AGENT_PROMPT.md | 32 ---------- .../UnifiedSidebar/ConversationsSection.tsx | 17 ++--- .../__tests__/pinnedConversations.test.tsx | 45 +++++++++++++ client/src/utils/convos.spec.ts | 61 ++++++++++++++++++ client/src/utils/convos.ts | 64 ++++++++++++++++++- 5 files changed, 177 insertions(+), 42 deletions(-) delete mode 100644 AGENT_PROMPT.md diff --git a/AGENT_PROMPT.md b/AGENT_PROMPT.md deleted file mode 100644 index c5fe974d534..00000000000 --- a/AGENT_PROMPT.md +++ /dev/null @@ -1,32 +0,0 @@ -Fix pinned chats missing from the sidebar until you scroll. - -Worktree: /home/berry13/.paseo/worktrees/2cter3r2/fix-pinned-section-always-fetch -Branch: fix/pinned-section-always-fetch (stacked on feat/pinned-chats-section) -Stay in this worktree. Do not reset, stash, or start a new one. - -## Bug -PinnedSection filters `pinned` off the paginated Chats list (`GET /api/convos`, 25 per page, newest `updatedAt` first). Pins are not hoisted. After 25 newer chats exist, a reload hides the pin until Chats fetches the next page. - -Reproduced: pin "Initial Greeting", insert 30 newer unpinned chats, reload. No Pinned section. Scroll Chats. Pin appears. - -## Do this -Give Pinned its own fetch so every pin shows on first paint, without scrolling Chats. - -Preferred: `GET /api/convos?pinned=true` (or equivalent) that returns only that user's pinned conversations, plus a dedicated frontend query used by PinnedSection. Pin/unpin must refresh that query. Keep pins out of the Chats date groups. - -## Touch -- `packages/data-schemas/src/methods/conversation.ts` (`getConvosByCursor`) -- `api/server/routes/convos.js` -- `client/src/data-provider/queries.ts` and list params -- `client/src/components/Conversations/PinnedSection.tsx` -- `client/src/components/UnifiedSidebar/ConversationsSection.tsx` - -The Pinned section UI is already in this tree (uncommitted). Do not redesign it. - -## Done when -- Reload with a pin past page 1 still shows the Pinned section immediately -- Unpin still removes it; pin still adds it -- `npx eslint` on touched JS/TS is clean -- Unit tests cover the pinned list filter / query - -Do not commit the dummy PINREPRO chats. Do not mention AI in commits. diff --git a/client/src/components/UnifiedSidebar/ConversationsSection.tsx b/client/src/components/UnifiedSidebar/ConversationsSection.tsx index 1ec22915057..7e0945581ad 100644 --- a/client/src/components/UnifiedSidebar/ConversationsSection.tsx +++ b/client/src/components/UnifiedSidebar/ConversationsSection.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef import { useMediaQuery } from '@librechat/client'; import { useSetRecoilState, useRecoilValue } from 'recoil'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; -import type { ConversationListResponse, TConversation } from 'librechat-data-provider'; +import type { ConversationListResponse } from 'librechat-data-provider'; import type { InfiniteQueryObserverResult } from '@tanstack/react-query'; import type { List } from 'react-virtualized'; import { @@ -22,6 +22,7 @@ import PinnedSection from '~/components/Conversations/PinnedSection'; import FavoritesList from '~/components/Nav/Favorites/FavoritesList'; import { Conversations } from '~/components/Conversations'; import SearchBar from '~/components/Nav/SearchBar'; +import { collectPinnedConversations } from '~/utils'; import store from '~/store'; const BookmarkNav = lazy(() => import('~/components/Nav/Bookmarks/BookmarkNav')); @@ -88,13 +89,13 @@ const ConversationsSection = memo(() => { { enabled: isAuthenticated }, ); - /* `groupConversationsByDate` strips pins from the chats groups, so if the dedicated - request fails there is nowhere else for them to show. Fall back to whatever pins the - loaded chats pages already carry rather than emptying the section. */ - const pinnedConversations = useMemo(() => { - const source = pinnedData?.conversations ?? conversations; - return source.filter((convo): convo is TConversation => Boolean(convo?.pinned === true)); - }, [pinnedData?.conversations, conversations]); + /* `groupConversationsByDate` strips pins from the chats groups. A failed + refetch keeps the previous dedicated result, so merge in pins from the + live chats cache rather than hiding a newly pinned row. */ + const pinnedConversations = useMemo( + () => collectPinnedConversations(pinnedData?.conversations, conversations), + [pinnedData?.conversations, conversations], + ); const toggleNav = useCallback(() => { if (isSmallScreen) { diff --git a/client/src/data-provider/__tests__/pinnedConversations.test.tsx b/client/src/data-provider/__tests__/pinnedConversations.test.tsx index 1d65fcf0812..296d5b05b62 100644 --- a/client/src/data-provider/__tests__/pinnedConversations.test.tsx +++ b/client/src/data-provider/__tests__/pinnedConversations.test.tsx @@ -454,4 +454,49 @@ describe('unpinning a pin that is not on a loaded chats page', () => { ).toEqual(['convo-pinned', 'other-recent']); expect(chats?.pages[0].conversations[0].pinned).toBe(false); }); + + it('does not insert the unpinned chat into an unrelated bookmark cache', async () => { + const unpinned = { ...pinnedConvo, pinned: false, tags: [] } as TConversation; + pinConversation.mockResolvedValue(unpinned); + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.pinnedConversations, { tags: undefined }], + listResponse([pinnedConvo]), + ); + queryClient.setQueryData([QueryKeys.allConversations, { tags: ['work'] }], { + pages: [ + { + conversations: [ + { + conversationId: 'work-chat', + title: 'Work', + endpoint: 'openAI', + tags: ['work'], + } as TConversation, + ], + nextCursor: null, + }, + ], + pageParams: [undefined], + }); + + const { result } = renderHook(() => usePinConversationMutation(), { + wrapper: createWrapper(queryClient), + }); + + await act(async () => { + result.current.mutate({ + conversationId: pinnedConvo.conversationId as string, + pinned: false, + }); + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const filtered = queryClient.getQueryData<{ + pages: { conversations: TConversation[] }[]; + }>([QueryKeys.allConversations, { tags: ['work'] }]); + expect( + filtered?.pages[0].conversations.map((conversation) => conversation.conversationId), + ).toEqual(['work-chat']); + }); }); diff --git a/client/src/utils/convos.spec.ts b/client/src/utils/convos.spec.ts index a820ee4873d..7478986ffbe 100644 --- a/client/src/utils/convos.spec.ts +++ b/client/src/utils/convos.spec.ts @@ -10,6 +10,7 @@ import { groupConversationsByDate, updateConvoFieldsInfinite, addConvoToAllQueries, + collectPinnedConversations, upsertConvoInAllQueries, updateConvoInAllQueries, removeConvoFromAllQueries, @@ -211,6 +212,32 @@ describe('Conversation Utilities', () => { }); }); + describe('collectPinnedConversations', () => { + const dedicated = { + conversationId: 'old-pin', + title: 'Old pin', + pinned: true, + } as TConversation; + const newlyPinned = { + conversationId: 'new-pin', + title: 'Just pinned', + pinned: true, + } as TConversation; + + it('keeps dedicated pins and adds a pin that only lives on the chats cache', () => { + const merged = collectPinnedConversations([dedicated], [newlyPinned]); + expect(merged.map((conversation) => conversation.conversationId)).toEqual([ + 'old-pin', + 'new-pin', + ]); + }); + + it('falls back to chats pins when the dedicated list is missing', () => { + const merged = collectPinnedConversations(undefined, [newlyPinned]); + expect(merged).toEqual([newlyPinned]); + }); + }); + describe('normalizeConversationData', () => { it('normalizes the number of items on each page after data removal', () => { // Create test data: @@ -608,6 +635,40 @@ describe('Conversation Utilities', () => { expect(data!.pages[0].conversations.filter((c) => c.conversationId === 'a').length).toBe(1); }); + it('addConvoToAllQueries does not insert into a bookmark filter the chat does not match', () => { + queryClient.setQueryData(['allConversations', { tags: ['work'] }], { + pages: [{ conversations: [convoA], nextCursor: null }], + pageParams: [], + }); + + addConvoToAllQueries(queryClient, convoB); + + const filtered = queryClient.getQueryData>([ + 'allConversations', + { tags: ['work'] }, + ]); + expect( + filtered!.pages[0].conversations.map((c: TConversation) => c.conversationId), + ).toEqual(['a']); + }); + + it('addConvoToAllQueries does not insert into a cached search result', () => { + queryClient.setQueryData(['allConversations', { search: 'unrelated' }], { + pages: [{ conversations: [convoA], nextCursor: null }], + pageParams: [], + }); + + addConvoToAllQueries(queryClient, convoB); + + const searched = queryClient.getQueryData>([ + 'allConversations', + { search: 'unrelated' }, + ]); + expect( + searched!.pages[0].conversations.map((c: TConversation) => c.conversationId), + ).toEqual(['a']); + }); + it('upsertConvoInAllQueries adds missing conversations to the top', () => { upsertConvoInAllQueries(queryClient, convoB); const data = queryClient.getQueryData>([ diff --git a/client/src/utils/convos.ts b/client/src/utils/convos.ts index dc83c350a96..96fda930bb9 100644 --- a/client/src/utils/convos.ts +++ b/client/src/utils/convos.ts @@ -171,6 +171,66 @@ function conversationMatchesProjectQuery( return conversation.chatProjectId === projectId; } +function getConversationListQueryParams(queryKey: readonly unknown[]): { + tags?: string[]; + search?: string; +} { + const params = queryKey[1]; + if (!params || typeof params !== 'object') { + return {}; + } + return params as { tags?: string[]; search?: string }; +} + +/** Inserts must not land in a bookmark or search cache the row would not + * appear in on the server. Search is not matchable client-side, so those + * variants are skipped. */ +function conversationMatchesListQuery( + queryKey: readonly unknown[], + conversation: Pick, +): boolean { + if (!conversationMatchesProjectQuery(queryKey, conversation)) { + return false; + } + const { tags, search } = getConversationListQueryParams(queryKey); + if (typeof search === 'string' && search.trim() !== '') { + return false; + } + if (Array.isArray(tags) && tags.length > 0) { + const conversationTags = conversation.tags; + if (!Array.isArray(conversationTags) || conversationTags.length === 0) { + return false; + } + return tags.some((tag) => conversationTags.includes(tag)); + } + return true; +} + +/** Dedicated pinned data wins for ids it already has. Pins that only live on + * the loaded chats pages are appended so a failed refetch of the dedicated + * query cannot hide a newly pinned row. */ +export function collectPinnedConversations( + dedicated: Array | undefined, + fromChats: Array, +): TConversation[] { + const byId = new Map(); + for (const conversation of dedicated ?? []) { + if (conversation?.conversationId && conversation.pinned === true) { + byId.set(conversation.conversationId, conversation); + } + } + for (const conversation of fromChats) { + if ( + conversation?.conversationId && + conversation.pinned === true && + !byId.has(conversation.conversationId) + ) { + byId.set(conversation.conversationId, conversation); + } + } + return [...byId.values()]; +} + /** * Reads the project id from the current URL's `?projectId` param — the source of * truth for a new chat's project scope (the conversation atom can lag behind it). @@ -366,7 +426,7 @@ export function addConvoToAllQueries(queryClient: QueryClient, newConvo: TConver .findAll([QueryKeys.allConversations], { exact: false }); for (const query of queries) { - if (!conversationMatchesProjectQuery(query.queryKey, newConvo)) { + if (!conversationMatchesListQuery(query.queryKey, newConvo)) { continue; } queryClient.setQueryData>(query.queryKey, (oldData) => { @@ -443,7 +503,7 @@ export function upsertConvoInAllQueries( const now = new Date().toISOString(); if (pageIdx === -1) { - if (!conversationMatchesProjectQuery(query.queryKey, nextConvo)) { + if (!conversationMatchesListQuery(query.queryKey, nextConvo)) { return oldData; } const firstPage = oldData.pages[0] ?? { conversations: [], nextCursor: null }; From f372c57dfb118ab12bf97705d5c56e64ed84ecdf Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:19:43 +0200 Subject: [PATCH 11/15] test: type the pinned conversation fixtures correctly The delete mutation takes a plain string conversationId, but reading it back off a TConversation fixture widens it to string | null. Hoist the id into its own constant so the call site passes the real string. Type the tag fixture as TConversationTag so it carries the required _id and user fields the mocked resolved value expects. --- .../__tests__/pinnedConversations.test.tsx | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/client/src/data-provider/__tests__/pinnedConversations.test.tsx b/client/src/data-provider/__tests__/pinnedConversations.test.tsx index 296d5b05b62..750362551f9 100644 --- a/client/src/data-provider/__tests__/pinnedConversations.test.tsx +++ b/client/src/data-provider/__tests__/pinnedConversations.test.tsx @@ -2,20 +2,24 @@ import { createElement } from 'react'; import { dataService, QueryKeys } from 'librechat-data-provider'; import { act, renderHook, waitFor } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import type { ConversationListResponse, TConversation } from 'librechat-data-provider'; +import type { + ConversationListResponse, + TConversationTag, + TConversation, +} from 'librechat-data-provider'; import type { ReactNode } from 'react'; -import { pinnedConversationsPageSize, usePinnedConversationsQuery } from '../queries'; -import { - removeConvoFromAllQueries, - updateConvoInAllQueries, - upsertConvoInAllQueries, -} from '~/utils/convos'; import { useConversationTagMutation, useDeleteConversationMutation, useDeleteConversationTagMutation, usePinConversationMutation, } from '../mutations'; +import { + removeConvoFromAllQueries, + updateConvoInAllQueries, + upsertConvoInAllQueries, +} from '~/utils/convos'; +import { pinnedConversationsPageSize, usePinnedConversationsQuery } from '../queries'; jest.mock('librechat-data-provider', () => { const actual = jest.requireActual('librechat-data-provider'); @@ -48,8 +52,10 @@ const deleteConversationTag = dataService.deleteConversationTag as jest.MockedFu typeof dataService.deleteConversationTag >; +const pinnedConversationId = 'convo-pinned'; + const pinnedConvo = { - conversationId: 'convo-pinned', + conversationId: pinnedConversationId, title: 'Initial Greeting', endpoint: 'openAI', pinned: true, @@ -356,7 +362,7 @@ describe('delete mutation project lookup', () => { }); await act(async () => { - result.current.mutate({ conversationId: pinnedConvo.conversationId }); + result.current.mutate({ conversationId: pinnedConversationId }); }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); @@ -364,7 +370,9 @@ describe('delete mutation project lookup', () => { }); }); -const tagResponse = { +const tagResponse: TConversationTag = { + _id: 'tag-office', + user: 'user-1', tag: 'office', count: 1, position: 0, From 3de648623a0ffc5e59372a7599664263c9bc8ca2 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 16 Aug 2026 01:23:10 +0200 Subject: [PATCH 12/15] style: sort the sidebar imports to the repo order The new pinned-section imports went in out of the longest-to-shortest order the import sorter enforces. --- client/src/components/UnifiedSidebar/ConversationsSection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/components/UnifiedSidebar/ConversationsSection.tsx b/client/src/components/UnifiedSidebar/ConversationsSection.tsx index 7e0945581ad..50ae1aaa6c3 100644 --- a/client/src/components/UnifiedSidebar/ConversationsSection.tsx +++ b/client/src/components/UnifiedSidebar/ConversationsSection.tsx @@ -2,8 +2,8 @@ import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef import { useMediaQuery } from '@librechat/client'; import { useSetRecoilState, useRecoilValue } from 'recoil'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; -import type { ConversationListResponse } from 'librechat-data-provider'; import type { InfiniteQueryObserverResult } from '@tanstack/react-query'; +import type { ConversationListResponse } from 'librechat-data-provider'; import type { List } from 'react-virtualized'; import { useConversationsInfiniteQuery, @@ -21,8 +21,8 @@ import ProjectsSection from '~/components/Conversations/ProjectsSection'; import PinnedSection from '~/components/Conversations/PinnedSection'; import FavoritesList from '~/components/Nav/Favorites/FavoritesList'; import { Conversations } from '~/components/Conversations'; -import SearchBar from '~/components/Nav/SearchBar'; import { collectPinnedConversations } from '~/utils'; +import SearchBar from '~/components/Nav/SearchBar'; import store from '~/store'; const BookmarkNav = lazy(() => import('~/components/Nav/Bookmarks/BookmarkNav')); From 4693f54a2350cc8b3fd89a2026feb98700a7357e Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 16 Aug 2026 15:51:51 +0200 Subject: [PATCH 13/15] fix: keep drained pins and empty chat caches from breaking the sidebar A pinned page failing partway through the drain rejected the whole query, so every pin already fetched was discarded and the section fell back to whatever the chats cache happened to hold. Publish the accumulated pins before rethrowing so the retry renders against the partial set. Unpinning a chat that only lives in the pinned cache reinserted it into the chats list by spreading the first page, which is absent once removal has filtered out the last loaded row. Rebuild that page instead, matching the upsert path. --- .../__tests__/pinnedConversations.test.tsx | 76 +++++++++++++++++++ client/src/data-provider/queries.ts | 30 ++++++-- client/src/utils/convos.ts | 7 +- 3 files changed, 104 insertions(+), 9 deletions(-) diff --git a/client/src/data-provider/__tests__/pinnedConversations.test.tsx b/client/src/data-provider/__tests__/pinnedConversations.test.tsx index 750362551f9..f15746b610b 100644 --- a/client/src/data-provider/__tests__/pinnedConversations.test.tsx +++ b/client/src/data-provider/__tests__/pinnedConversations.test.tsx @@ -166,6 +166,36 @@ describe('usePinnedConversationsQuery', () => { expect(result.current.data?.nextCursor).toBeNull(); }); + /** The drain rejects as a whole, so without publishing what it already has the + * section would fall back to an empty list for every pin past the first page. */ + it('keeps the pages already drained when a later one fails', async () => { + listConversations + .mockResolvedValueOnce({ conversations: [pinnedConvo], nextCursor: 'cursor-2' }) + .mockRejectedValueOnce(new Error('network')); + const queryClient = createQueryClient(); + + const { result } = renderHook(() => usePinnedConversationsQuery(), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(readPinnedCache(queryClient)?.conversations).toEqual([pinnedConvo]); + expect(readPinnedCache(queryClient)?.nextCursor).toBe('cursor-2'); + expect(result.current.data?.conversations).toEqual([pinnedConvo]); + }); + + it('reports the failure when the very first page fails', async () => { + listConversations.mockRejectedValueOnce(new Error('network')); + const queryClient = createQueryClient(); + + const { result } = renderHook(() => usePinnedConversationsQuery(), { + wrapper: createWrapper(queryClient), + }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(readPinnedCache(queryClient)).toBeUndefined(); + }); + /** The chats list beside it is filtered by the selected bookmarks; the pinned section * showed every pin regardless until the tags were threaded through. */ it('applies the active bookmark filter and keys the cache by it', async () => { @@ -463,6 +493,52 @@ describe('unpinning a pin that is not on a loaded chats page', () => { expect(chats?.pages[0].conversations[0].pinned).toBe(false); }); + /** Deleting the last loaded row drops every page, so the insert has to rebuild the + * first one instead of reading through an empty array. */ + it('rebuilds the first page when the chats cache has been emptied', async () => { + const unpinned = { ...pinnedConvo, pinned: false } as TConversation; + pinConversation.mockResolvedValue(unpinned); + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.pinnedConversations, { tags: undefined }], + listResponse([pinnedConvo]), + ); + queryClient.setQueryData([QueryKeys.allConversations], { + pages: [ + { + conversations: [ + { conversationId: 'only-chat', title: 'Only', endpoint: 'openAI' } as TConversation, + ], + nextCursor: null, + }, + ], + pageParams: [undefined], + }); + removeConvoFromAllQueries(queryClient, 'only-chat'); + expect( + queryClient.getQueryData<{ pages: unknown[] }>([QueryKeys.allConversations])?.pages, + ).toEqual([]); + + const { result } = renderHook(() => usePinConversationMutation(), { + wrapper: createWrapper(queryClient), + }); + + await act(async () => { + result.current.mutate({ + conversationId: pinnedConvo.conversationId as string, + pinned: false, + }); + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const chats = queryClient.getQueryData<{ + pages: { conversations: TConversation[] }[]; + }>([QueryKeys.allConversations]); + expect( + chats?.pages[0].conversations.map((conversation) => conversation.conversationId), + ).toEqual(['convo-pinned']); + }); + it('does not insert the unpinned chat into an unrelated bookmark cache', async () => { const unpinned = { ...pinnedConvo, pinned: false, tags: [] } as TConversation; pinConversation.mockResolvedValue(unpinned); diff --git a/client/src/data-provider/queries.ts b/client/src/data-provider/queries.ts index 61333a0d3c9..52ec9165471 100644 --- a/client/src/data-provider/queries.ts +++ b/client/src/data-provider/queries.ts @@ -126,20 +126,36 @@ export const usePinnedConversationsQuery = ( config?: UseQueryOptions, ): QueryObserverResult => { const { tags } = params; + const queryClient = useQueryClient(); + const queryKey = [QueryKeys.pinnedConversations, { tags }]; return useQuery( - [QueryKeys.pinnedConversations, { tags }], + queryKey, async () => { const conversations: ConversationListResponse['conversations'] = []; let cursor: string | undefined; do { - const page = await dataService.listConversations({ - pinned: true, - tags, - limit: pinnedConversationsPageSize, - cursor, - }); + let page: ConversationListResponse; + try { + page = await dataService.listConversations({ + pinned: true, + tags, + limit: pinnedConversationsPageSize, + cursor, + }); + } catch (error) { + /** A page failing partway through the drain must not throw away the pins + * already loaded: publish them so the retry, which starts the drain over, + * renders against the partial set instead of an empty section. */ + if (conversations.length > 0) { + queryClient.setQueryData(queryKey, { + conversations, + nextCursor: cursor ?? null, + }); + } + throw error; + } conversations.push(...page.conversations); cursor = page.nextCursor ?? undefined; } while (cursor); diff --git a/client/src/utils/convos.ts b/client/src/utils/convos.ts index 96fda930bb9..38970339b15 100644 --- a/client/src/utils/convos.ts +++ b/client/src/utils/convos.ts @@ -440,12 +440,15 @@ export function addConvoToAllQueries(queryClient: QueryClient, newConvo: TConver ) { return oldData; } + /** Removing the last loaded row leaves a cache with no pages at all, so the + * first page has to be recreated rather than spread from `undefined`. */ + const firstPage = oldData.pages[0] ?? { conversations: [], nextCursor: null }; return { ...oldData, pages: [ { - ...oldData.pages[0], - conversations: [newConvo, ...oldData.pages[0].conversations], + ...firstPage, + conversations: [newConvo, ...firstPage.conversations], }, ...oldData.pages.slice(1), ], From 5a4d087b64bd284595b2a92f0447b91456436711 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:08:19 +0200 Subject: [PATCH 14/15] fix: order fallback pins by their timestamp The merge kept dedicated rows in Map insertion order and appended the pins recovered from the chats cache after them. A chat pinned while the dedicated refetch is failing is the newest pin, so the server would return it first, yet it landed last and could sit below the section's visible 30vh. Sort the merged set newest-first so a fallback row takes the place the server would give it. --- client/src/utils/convos.spec.ts | 23 +++++++++++++++++++++++ client/src/utils/convos.ts | 10 +++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/client/src/utils/convos.spec.ts b/client/src/utils/convos.spec.ts index 7478986ffbe..b91d7b298f6 100644 --- a/client/src/utils/convos.spec.ts +++ b/client/src/utils/convos.spec.ts @@ -236,6 +236,29 @@ describe('Conversation Utilities', () => { const merged = collectPinnedConversations(undefined, [newlyPinned]); expect(merged).toEqual([newlyPinned]); }); + + /** A chat pinned while the dedicated refetch is failing is the newest pin, so the + * server would return it first; appending it would bury it below the fold. */ + it('orders a fallback row by its timestamp rather than after every dedicated pin', () => { + const older = { + conversationId: 'old-pin', + title: 'Old pin', + pinned: true, + updatedAt: '2026-03-01T12:00:00.000Z', + } as TConversation; + const newest = { + conversationId: 'new-pin', + title: 'Just pinned', + pinned: true, + updatedAt: '2026-08-16T12:00:00.000Z', + } as TConversation; + + const merged = collectPinnedConversations([older], [newest]); + expect(merged.map((conversation) => conversation.conversationId)).toEqual([ + 'new-pin', + 'old-pin', + ]); + }); }); describe('normalizeConversationData', () => { diff --git a/client/src/utils/convos.ts b/client/src/utils/convos.ts index 38970339b15..c8bb6645e9b 100644 --- a/client/src/utils/convos.ts +++ b/client/src/utils/convos.ts @@ -228,7 +228,15 @@ export function collectPinnedConversations( byId.set(conversation.conversationId, conversation); } } - return [...byId.values()]; + /** The server returns pins newest-first, so a row merged in from the chats cache + * has to take its place in that order: a chat pinned while the dedicated refetch + * is failing is the newest pin, and appending it would bury it below the fold. */ + return [...byId.values()].sort((a, b) => pinnedSortTime(b) - pinnedSortTime(a)); +} + +function pinnedSortTime(conversation: TConversation): number { + const timestamp = Date.parse(conversation.updatedAt ?? conversation.createdAt ?? ''); + return Number.isNaN(timestamp) ? 0 : timestamp; } /** From 3dd7654542f1ff91d33768ae650a2644c4f23ea3 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:26:36 +0200 Subject: [PATCH 15/15] fix: keep the shared badge and the move-to-top order on pins The pin response has no isShared: the flag is derived per list request by attachSharedFlags, which only runs for the list queries. Reinserting an unpinned chat into Chats therefore dropped its shared-link badge, because unlike an in-place update there is no existing row to carry the flag over from. Read it off the cached pin before the update removes that row. The chats cache refreshes updatedAt when it moves a conversation to the top, but the pinned cache only reordered, leaving the previous turn's timestamp on the row. Sorting the section newest-first then put it straight back. Refresh the timestamp there too, so the move survives the sort and both caches agree. --- .../__tests__/pinnedConversations.test.tsx | 68 +++++++++++++++++++ client/src/data-provider/mutations.ts | 15 +++- client/src/utils/convos.ts | 31 ++++++++- 3 files changed, 109 insertions(+), 5 deletions(-) diff --git a/client/src/data-provider/__tests__/pinnedConversations.test.tsx b/client/src/data-provider/__tests__/pinnedConversations.test.tsx index f15746b610b..a365c5780aa 100644 --- a/client/src/data-provider/__tests__/pinnedConversations.test.tsx +++ b/client/src/data-provider/__tests__/pinnedConversations.test.tsx @@ -18,6 +18,7 @@ import { removeConvoFromAllQueries, updateConvoInAllQueries, upsertConvoInAllQueries, + collectPinnedConversations, } from '~/utils/convos'; import { pinnedConversationsPageSize, usePinnedConversationsQuery } from '../queries'; @@ -282,6 +283,38 @@ describe('pinned list cache synchronization', () => { ]); }); + /** The SSE payload can still carry the previous turn's timestamp, and the section is + * sorted newest-first downstream, so the move has to refresh it or the sort undoes it. */ + it('refreshes the timestamp of a pin it moves to the top', () => { + const stale = '2026-03-01T12:00:00.000Z'; + const other = { + ...pinnedConvo, + conversationId: 'convo-other', + updatedAt: '2026-08-16T12:00:00.000Z', + } as TConversation; + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.pinnedConversations, { tags: undefined }], + listResponse([other, { ...pinnedConvo, updatedAt: stale } as TConversation]), + ); + + updateConvoInAllQueries( + queryClient, + pinnedConvo.conversationId as string, + (convo) => ({ ...convo, title: 'Replied', updatedAt: stale }), + true, + ); + + const moved = readPinnedCache(queryClient)?.conversations[0]; + expect(moved?.conversationId).toBe('convo-pinned'); + expect(Date.parse(moved?.updatedAt ?? '')).toBeGreaterThan(Date.parse(other.updatedAt ?? '')); + expect( + collectPinnedConversations(readPinnedCache(queryClient)?.conversations, []).map( + (c) => c.conversationId, + ), + ).toEqual(['convo-pinned', 'convo-other']); + }); + it('leaves the pinned cache untouched for an unrelated conversation', () => { const queryClient = createQueryClient(); queryClient.setQueryData( @@ -493,6 +526,41 @@ describe('unpinning a pin that is not on a loaded chats page', () => { expect(chats?.pages[0].conversations[0].pinned).toBe(false); }); + /** `isShared` is derived per list request, so the pin response never carries it. The + * reinserted row has no existing chats row to merge it from, so it has to come off + * the cached pin or the shared badge disappears until the next list refetch. */ + it('keeps the shared badge on the reinserted row', async () => { + const unpinned = { ...pinnedConvo, pinned: false } as TConversation; + pinConversation.mockResolvedValue(unpinned); + const queryClient = createQueryClient(); + queryClient.setQueryData( + [QueryKeys.pinnedConversations, { tags: undefined }], + listResponse([{ ...pinnedConvo, isShared: true } as TConversation]), + ); + queryClient.setQueryData([QueryKeys.allConversations], { + pages: [{ conversations: [], nextCursor: null }], + pageParams: [undefined], + }); + + const { result } = renderHook(() => usePinConversationMutation(), { + wrapper: createWrapper(queryClient), + }); + + await act(async () => { + result.current.mutate({ + conversationId: pinnedConvo.conversationId as string, + pinned: false, + }); + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + const chats = queryClient.getQueryData<{ + pages: { conversations: TConversation[] }[]; + }>([QueryKeys.allConversations]); + expect(chats?.pages[0].conversations[0].conversationId).toBe('convo-pinned'); + expect(chats?.pages[0].conversations[0].isShared).toBe(true); + }); + /** Deleting the last loaded row drops every page, so the insert has to rebuild the * first one instead of reading through an empty array. */ it('rebuilds the first page when the chats cache has been emptied', async () => { diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index 875e80b1edf..6b3ac6fc46a 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -11,6 +11,7 @@ import { logger, /* Conversations */ addConvoToAllQueries, + findPinnedConversation, findConversationInInfinite, updateConvoInAllQueries, removeConvoFromAllQueries, @@ -168,14 +169,22 @@ export const usePinConversationMutation = ( (payload: t.TPinConversationRequest) => dataService.pinConversation(payload), { onSuccess: (data, vars, context) => { - updateConvoInAllQueries(queryClient, vars.conversationId, () => data); + /** `isShared` is derived per list request and is absent from this response, so + * read it off the cached pin before the update drops that row: the reinsert + * below has no existing chats row to carry the badge over from. */ + const cachedPin = findPinnedConversation(queryClient, vars.conversationId); + const next = + data.isShared === undefined && cachedPin?.isShared !== undefined + ? { ...data, isShared: cachedPin.isShared } + : data; + updateConvoInAllQueries(queryClient, vars.conversationId, () => next); /** An older pin may exist only in the dedicated pinned cache. Unpinning * it has to put the returned row onto the chats list; later pages * cannot recover a conversation whose updatedAt just jumped ahead of * the current cursor. addConvoToAllQueries no-ops if it is already * present. */ - if (data.pinned !== true) { - addConvoToAllQueries(queryClient, data); + if (next.pinned !== true) { + addConvoToAllQueries(queryClient, next); } /** The pinned section has its own fetch, so a new pin is only visible once * that list is refetched; unpins are already dropped from its cache above. */ diff --git a/client/src/utils/convos.ts b/client/src/utils/convos.ts index c8bb6645e9b..f9648b619cc 100644 --- a/client/src/utils/convos.ts +++ b/client/src/utils/convos.ts @@ -585,6 +585,27 @@ export type PinnedConversationsData = { nextCursor?: string | null; }; +/** Reads a pin out of whichever cached bookmark variant holds it. Single-conversation + * responses omit server-derived fields like `isShared`, so callers that insert one + * elsewhere need the cached row to carry them over. */ +export function findPinnedConversation( + queryClient: QueryClient, + conversationId: string, +): TConversation | undefined { + const queries = queryClient + .getQueryCache() + .findAll([QueryKeys.pinnedConversations], { exact: false }); + + for (const query of queries) { + const data = queryClient.getQueryData(query.queryKey); + const found = data?.conversations.find((c) => c.conversationId === conversationId); + if (found) { + return found; + } + } + return undefined; +} + /** * The pinned sidebar section is fed by its own request rather than by the paginated * chats list, so every edit that reaches the chats cache has to reach this one too or @@ -625,10 +646,16 @@ function updatePinnedConvosQuery( : updated; /* The server returns pins newest-first, so a pin that just received a message has - to lead the section the same way it leads the chats list. */ + to lead the section the same way it leads the chats list. The SSE payload can + still carry the previous turn's `updatedAt`, so refresh it exactly as the chats + cache does: anything that sorts this list afterwards would otherwise read the + stale value and undo the move. */ if (moveToTop) { const rest = oldData.conversations.filter((_, i) => i !== index); - return { ...oldData, conversations: [merged, ...rest] }; + return { + ...oldData, + conversations: [{ ...merged, updatedAt: new Date().toISOString() }, ...rest], + }; } return {