diff --git a/api/server/routes/__test-utils__/convos-route-mocks.js b/api/server/routes/__test-utils__/convos-route-mocks.js index 769d2b61d8..06f6982195 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 f18eef9c5f..1dbe1cd158 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 9e86a1b0a5..c52b502a71 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 b702ceeb2e..264968c95d 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -121,17 +121,6 @@ const ChatsHeader: FC = memo(({ isExpanded, onToggle, trailing 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 ( @@ -151,8 +140,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' }; @@ -204,16 +191,35 @@ const Conversations: FC = ({ [rawConversations], ); - const pinnedConversations = useMemo( - () => filteredConversations.filter((c) => c.pinned), - [filteredConversations], - ); - const groupedConversations = useMemo( () => groupConversationsByDate(filteredConversations), [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. 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, + ]); + const flattenedItems = useMemo(() => { const items: FlattenedItem[] = []; // Only include favorites row if FavoritesList will render content @@ -222,13 +228,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 }))); @@ -239,14 +238,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); @@ -266,12 +258,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'}`; @@ -364,33 +350,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 ( @@ -414,7 +375,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 0000000000..717bf43ddf --- /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 0bfd556d88..86486db9d5 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(); }); @@ -247,3 +237,110 @@ describe('Conversations – pinned header', () => { 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(); + }); + + 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/components/Conversations/__tests__/PinnedSection.spec.tsx b/client/src/components/Conversations/__tests__/PinnedSection.spec.tsx new file mode 100644 index 0000000000..b7460df8b3 --- /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 8264db2964..64d9c74469 100644 --- a/client/src/components/UnifiedSidebar/ConversationsSection.tsx +++ b/client/src/components/UnifiedSidebar/ConversationsSection.tsx @@ -5,6 +5,11 @@ import { PermissionTypes, Permissions } 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,10 +17,11 @@ 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 { collectPinnedConversations } from '~/utils'; import SearchBar from '~/components/Nav/SearchBar'; import store from '~/store'; @@ -75,6 +81,22 @@ 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. + * The bookmark filter still applies, matching the chats list beside it. */ + const { data: pinnedData } = usePinnedConversationsQuery( + { tags: tags.length === 0 ? undefined : tags }, + { enabled: isAuthenticated }, + ); + + /* `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) { setSidebarExpanded(false); @@ -127,6 +149,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/Projects/mutations.ts b/client/src/data-provider/Projects/mutations.ts index a02f177d97..d301fea848 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< @@ -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]); }, }); }; @@ -116,6 +118,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/__tests__/pinnedConversations.test.tsx b/client/src/data-provider/__tests__/pinnedConversations.test.tsx new file mode 100644 index 0000000000..a365c5780a --- /dev/null +++ b/client/src/data-provider/__tests__/pinnedConversations.test.tsx @@ -0,0 +1,654 @@ +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, + TConversationTag, + TConversation, +} from 'librechat-data-provider'; +import type { ReactNode } from 'react'; +import { + useConversationTagMutation, + useDeleteConversationMutation, + useDeleteConversationTagMutation, + usePinConversationMutation, +} from '../mutations'; +import { + removeConvoFromAllQueries, + updateConvoInAllQueries, + upsertConvoInAllQueries, + collectPinnedConversations, +} from '~/utils/convos'; +import { pinnedConversationsPageSize, usePinnedConversationsQuery } from '../queries'; + +jest.mock('librechat-data-provider', () => { + const actual = jest.requireActual('librechat-data-provider'); + return { + ...actual, + dataService: { + ...actual.dataService, + listConversations: jest.fn(), + pinConversation: jest.fn(), + deleteConversation: jest.fn(), + updateConversationTag: jest.fn(), + deleteConversationTag: jest.fn(), + }, + }; +}); + +const listConversations = dataService.listConversations as jest.MockedFunction< + typeof dataService.listConversations +>; +const pinConversation = dataService.pinConversation as jest.MockedFunction< + typeof dataService.pinConversation +>; +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 pinnedConversationId = 'convo-pinned'; + +const pinnedConvo = { + conversationId: pinnedConversationId, + 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, + { tags: undefined }, + ]); + +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, + tags: undefined, + limit: pinnedConversationsPageSize, + cursor: undefined, + }); + 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.isFetching).toBe(false)); + expect(result.current.data).toBeUndefined(); + 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])); + }); + + /** `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 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 () => { + 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, { tags: undefined }], + 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, { tags: undefined }], + 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, { 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', + ]); + }); + + /** 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( + [QueryKeys.pinnedConversations, { tags: undefined }], + listResponse([pinnedConvo]), + ); + + updateConvoInAllQueries(queryClient, 'some-other-convo', (convo) => ({ + ...convo, + title: 'Renamed', + })); + + 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' }), + ]); + }); +}); + +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: pinnedConversationId }); + }); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + expect(invalidateSpy).toHaveBeenCalledWith([QueryKeys.project, projectId]); + }); +}); + +const tagResponse: TConversationTag = { + _id: 'tag-office', + user: 'user-1', + 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); + }); + + /** `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 () => { + 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); + 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/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index f2048ed6e2..6b3ac6fc46 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, @@ -46,6 +47,7 @@ export const useTagConversationMutation = ( conversationId: string, options?: t.updateTagsInConvoOptions, ): UseMutationResult => { + const queryClient = useQueryClient(); const query = useConversationTagsQuery(); const { updateTagsInConversation } = useUpdateTagsInConvo(); return useMutation( @@ -53,6 +55,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); @@ -142,6 +147,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]); }, @@ -161,7 +169,26 @@ 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 (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. */ + queryClient.invalidateQueries([QueryKeys.pinnedConversations]); onSuccess?.(data, vars, context); }, onError, @@ -381,6 +408,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) => { @@ -477,6 +506,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); }, @@ -568,6 +601,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, @@ -591,6 +626,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: () => { @@ -621,6 +659,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); @@ -666,6 +725,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]); @@ -703,6 +764,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) { @@ -751,6 +814,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) { @@ -825,6 +890,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 de6c571c8b..52ec916547 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,68 @@ export const useConversationsInfiniteQuery = ( }); }; +/** + * 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 pinnedConversationsPageSize = 100; + +export const usePinnedConversationsQuery = ( + params: Pick = {}, + config?: UseQueryOptions, +): QueryObserverResult => { + const { tags } = params; + const queryClient = useQueryClient(); + const queryKey = [QueryKeys.pinnedConversations, { tags }]; + + return useQuery( + queryKey, + async () => { + const conversations: ConversationListResponse['conversations'] = []; + let cursor: string | undefined; + + do { + 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); + + 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, + ...config, + }, + ); +}; + export const useMessagesInfiniteQuery = ( params: MessagesListParams, config?: UseInfiniteQueryOptions, diff --git a/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts b/client/src/hooks/SSE/__tests__/useResumableSSE.spec.ts index cc198a27a0..c7643a1382 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({ @@ -585,6 +593,9 @@ describe('useResumableSSE', () => { expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: [QueryKeys.allConversations], }); + expect(mockInvalidateQueries).toHaveBeenCalledWith({ + queryKey: [QueryKeys.pinnedConversations], + }); unmount(); }); @@ -1523,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(); @@ -2167,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' }), @@ -2638,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); @@ -2688,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); @@ -3792,7 +3824,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: { diff --git a/client/src/hooks/SSE/useResumableSSE.ts b/client/src/hooks/SSE/useResumableSSE.ts index 249da43cdf..b0c4998eb7 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; diff --git a/client/src/utils/convos.spec.ts b/client/src/utils/convos.spec.ts index a820ee4873..b91d7b298f 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,55 @@ 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]); + }); + + /** 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', () => { it('normalizes the number of items on each page after data removal', () => { // Create test data: @@ -608,6 +658,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 456f7b2d93..f9648b619c 100644 --- a/client/src/utils/convos.ts +++ b/client/src/utils/convos.ts @@ -171,6 +171,74 @@ 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); + } + } + /** 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; +} + /** * 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 +434,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) => { @@ -380,12 +448,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), ], @@ -403,6 +474,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 }); @@ -428,7 +514,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 }; @@ -494,6 +580,92 @@ export function upsertConvoInAllQueries( } } +export type PinnedConversationsData = { + conversations: TConversation[]; + 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 + * 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, + moveToTop = false, +) { + /* 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. 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, updatedAt: new Date().toISOString() }, ...rest], + }; + } + + return { + ...oldData, + conversations: oldData.conversations.map((c, i) => (i === index ? merged : c)), + }; + }); + } +} + // Update export function updateConvoInAllQueries( queryClient: QueryClient, @@ -501,6 +673,8 @@ export function updateConvoInAllQueries( updater: (c: TConversation) => TConversation, moveToTop = false, ) { + updatePinnedConvosQuery(queryClient, conversationId, updater, moveToTop); + const queries = queryClient .getQueryCache() .findAll([QueryKeys.allConversations], { exact: false }); @@ -588,6 +762,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/config.ts b/packages/data-provider/src/config.ts index 7b848d4fe8..e4b0841b3b 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -64,6 +64,7 @@ export const excludedKeys = new Set([ 'isTemporary', 'messages', 'isArchived', + 'pinned', 'tags', 'user', '__v', diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index aec853c863..cf043f7ce8 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 4c74903261..fdbee5e57e 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 ebe7c4ecc1..4c2e2c9f28 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 143d247bbb..169f4cadea 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 d4056ef5ff..2340e93676 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); } diff --git a/packages/data-schemas/src/schema/convo.ts b/packages/data-schemas/src/schema/convo.ts index fb49b85ebb..682eb3c011 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 });