mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-31 08:56:48 +00:00
📌 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:
parent
0b995065bc
commit
8a946290f6
24 changed files with 1632 additions and 101 deletions
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue