📌 fix: Fetch Pinned Chats Independently of the Chats List (#14860)

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.
This commit is contained in:
Marco Beretta 2026-08-16 17:28:33 +02:00 committed by GitHub
parent 0b995065bc
commit 8a946290f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1632 additions and 101 deletions

View file

@ -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(),

View file

@ -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';

View file

@ -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,

View file

@ -121,17 +121,6 @@ const ChatsHeader: FC<ChatsHeaderProps> = memo(({ isExpanded, onToggle, trailing
ChatsHeader.displayName = 'ChatsHeader';
const PinnedHeader: FC = memo(() => {
const localize = useLocalize();
return (
<h2 className="pl-1 pt-1 text-text-secondary" style={{ fontSize: '0.7rem' }}>
{localize('com_ui_pinned')}
</h2>
);
});
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<ConversationsProps> = ({
[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<Array<TConversation | null> | 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<ConversationsProps> = ({
}
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<ConversationsProps> = ({
}
}
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<ConversationsProps> = ({
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<ConversationsProps> = ({
);
}
if (item.type === 'pinned-header') {
return (
<MeasuredRow key={key} {...rowProps}>
<PinnedHeader />
</MeasuredRow>
);
}
if (item.type === 'pinned-convo') {
const isGenerating = activeJobIds.has(item.convo.conversationId ?? '');
return (
<MeasuredRow key={key} {...rowProps}>
<Convo
conversation={item.convo}
retainView={moveToTop}
toggleNav={toggleNav}
isGenerating={isGenerating}
/>
</MeasuredRow>
);
}
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 (
<MeasuredRow key={key} {...rowProps}>
<DateLabel groupName={item.groupName} isFirst={index === firstHeaderIndex} />
@ -414,7 +375,7 @@ const Conversations: FC<ConversationsProps> = ({
return null;
},
[cache, flattenedItems, moveToTop, toggleNav, isSmallScreen, pinnedConversations, activeJobIds],
[cache, flattenedItems, moveToTop, toggleNav, isSmallScreen, activeJobIds],
);
const getRowHeight = useCallback(

View file

@ -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 (
<div
className="flex flex-col px-3 text-sm"
role="region"
aria-label={localize('com_ui_pinned')}
>
<div className="flex h-8 w-full items-center pr-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="group flex min-w-0 flex-1 items-center gap-1 rounded-lg px-1 py-2 text-xs font-bold text-text-secondary outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-text-primary"
type="button"
aria-expanded={isExpanded}
>
<span className="select-none truncate">{localize('com_ui_pinned')}</span>
<ChevronDown
className={cn(
'h-3 w-3 shrink-0 transition-transform duration-200',
isExpanded ? '' : '-rotate-90',
)}
aria-hidden="true"
/>
</button>
</div>
{isExpanded && (
<div className="scrollbar-gutter-stable max-h-[30vh] overflow-y-auto">
<ul className="m-0 list-none p-0">
{conversations.map((convo) => (
<li key={convo.conversationId} className="list-none">
<Convo
conversation={convo}
retainView={noop}
toggleNav={toggleNav}
isGenerating={activeJobIds.has(convo.conversationId ?? '')}
/>
</li>
))}
</ul>
</div>
)}
</div>
);
};
PinnedSection.displayName = 'PinnedSection';
export default memo(PinnedSection);

View file

@ -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<List>();
beforeEach(() => {
@ -227,18 +227,8 @@ describe('Conversations pinned header', () => {
</RecoilRoot>,
);
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<List>();
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(
<RecoilRoot>
<Conversations
conversations={conversations}
moveToTop={jest.fn()}
toggleNav={jest.fn()}
containerRef={containerRef}
loadMoreConversations={loadMoreConversations}
isLoading={isLoading}
isSearchLoading={false}
isChatsExpanded={isChatsExpanded}
setIsChatsExpanded={jest.fn()}
showFavorites={false}
/>
</RecoilRoot>,
);
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(
<RecoilRoot>
<Conversations
conversations={conversations}
moveToTop={jest.fn()}
toggleNav={jest.fn()}
containerRef={containerRef}
loadMoreConversations={loadMoreConversations}
isLoading={true}
isSearchLoading={false}
isChatsExpanded={true}
setIsChatsExpanded={jest.fn()}
showFavorites={false}
/>
</RecoilRoot>,
);
rerender(
<RecoilRoot>
<Conversations
conversations={conversations}
moveToTop={jest.fn()}
toggleNav={jest.fn()}
containerRef={containerRef}
loadMoreConversations={loadMoreConversations}
isLoading={false}
isSearchLoading={false}
isChatsExpanded={true}
setIsChatsExpanded={jest.fn()}
showFavorites={false}
/>
</RecoilRoot>,
);
expect(loadMoreConversations).toHaveBeenCalledTimes(1);
});
});

View file

@ -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 }) => (
<div data-testid="pinned-convo">{conversation.title}</div>
),
}));
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(<PinnedSection conversations={[]} toggleNav={jest.fn()} />);
expect(container).toBeEmptyDOMElement();
});
it('renders a collapsible Pinned header matching Chats and Projects', () => {
render(<PinnedSection conversations={[pinnedConvo]} toggleNav={jest.fn()} />);
const toggle = screen.getByRole('button', { name: 'com_ui_pinned' });
expect(toggle).toHaveAttribute('aria-expanded', 'true');
});
it('renders pinned conversations when expanded', () => {
render(
<PinnedSection conversations={[pinnedConvo, anotherPinnedConvo]} toggleNav={jest.fn()} />,
);
expect(screen.getByText('Pinned Chat')).toBeInTheDocument();
expect(screen.getByText('Another Pin')).toBeInTheDocument();
});
it('hides pinned conversations when collapsed', () => {
mockIsExpanded = false;
render(<PinnedSection conversations={[pinnedConvo]} toggleNav={jest.fn()} />);
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(<PinnedSection conversations={[pinnedConvo]} toggleNav={jest.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'com_ui_pinned' }));
expect(mockSetExpanded).toHaveBeenCalledWith(false);
});
});

View file

@ -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(() => {
</div>
)}
{!search.query && <ProjectsSection toggleNav={toggleNav} isAuthenticated={isAuthenticated} />}
{!search.query && <PinnedSection conversations={pinnedConversations} toggleNav={toggleNav} />}
<div className="flex min-h-0 flex-grow flex-col overflow-hidden">
<Conversations
conversations={conversations}

View file

@ -66,6 +66,9 @@ jest.mock('~/data-provider', () => ({
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: () => <div data-testid="projects-stub" />,
}));
jest.mock('~/components/Conversations/PinnedSection', () => ({
__esModule: true,
default: () => <div data-testid="pinned-stub" />,
}));
jest.mock('~/components/Nav/SearchBar', () => ({
__esModule: true,
default: () => <div data-testid="searchbar-stub" />,
@ -153,6 +161,22 @@ const renderSection = () =>
</QueryClientProvider>,
);
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(() => ({

View file

@ -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]);
},
},

View file

@ -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<ConversationListResponse>([
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']);
});
});

View file

@ -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<t.TTagConversationResponse, unknown, t.TTagConversationRequest, unknown> => {
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);
}

View file

@ -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<ConversationListParams, 'tags'> = {},
config?: UseQueryOptions<ConversationListResponse>,
): QueryObserverResult<ConversationListResponse> => {
const { tags } = params;
const queryClient = useQueryClient();
const queryKey = [QueryKeys.pinnedConversations, { tags }];
return useQuery<ConversationListResponse>(
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<ConversationListResponse>(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<MessagesListResponse, unknown>,

View file

@ -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: {

View file

@ -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;

View file

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

View file

@ -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<TConversation, 'chatProjectId' | 'tags'>,
): 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<TConversation | null | undefined> | undefined,
fromChats: Array<TConversation | null | undefined>,
): TConversation[] {
const byId = new Map<string, TConversation>();
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<InfiniteData<ConversationCursorData>>(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<PinnedConversationsData>(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<PinnedConversationsData>(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 });

View file

@ -64,6 +64,7 @@ export const excludedKeys = new Set([
'isTemporary',
'messages',
'isArchived',
'pinned',
'tags',
'user',
'__v',

View file

@ -5,6 +5,7 @@ export enum QueryKeys {
sharedLinks = 'sharedLinks',
allConversations = 'allConversations',
archivedConversations = 'archivedConversations',
pinnedConversations = 'pinnedConversations',
searchConversations = 'searchConversations',
conversation = 'conversation',
searchEnabled = 'searchEnabled',

View file

@ -134,6 +134,7 @@ export const useClearConversationsMutation = (): UseMutationResult<unknown> => {
return useMutation(() => dataService.clearAllConversations(), {
onSuccess: () => {
queryClient.invalidateQueries([QueryKeys.allConversations]);
queryClient.invalidateQueries([QueryKeys.pinnedConversations]);
queryClient.invalidateQueries([QueryKeys.conversationTags]);
},
});

View file

@ -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 = {

View file

@ -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 elses 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();

View file

@ -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<IConversation>);
}
if (pinned) {
filters.push({ pinned: true } as FilterQuery<IConversation>);
}
if (Array.isArray(tags) && tags.length > 0) {
filters.push({ tags: { $in: tags } } as FilterQuery<IConversation>);
}

View file

@ -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 });