Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion api/server/routes/__test-utils__/convos-route-mocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
30 changes: 30 additions & 0 deletions api/server/routes/__tests__/convos.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
2 changes: 2 additions & 0 deletions api/server/routes/convos.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -61,6 +62,7 @@ router.get('/', async (req, res) => {
cursor,
limit,
isArchived,
pinned,
tags,
search,
sortBy,
Expand Down
93 changes: 27 additions & 66 deletions client/src/components/Conversations/Conversations.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,17 +115,6 @@ const ChatsHeader: FC<ChatsHeaderProps> = memo(({ isExpanded, onToggle }) => {

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 (
Expand All @@ -145,8 +134,6 @@ DateLabel.displayName = 'DateLabel';

type FlattenedItem =
| { type: 'favorites' }
| { type: 'pinned-header' }
| { type: 'pinned-convo'; convo: TConversation }
| { type: 'header'; groupName: string }
| { type: 'convo'; convo: TConversation }
| { type: 'loading' };
Expand Down Expand Up @@ -197,16 +184,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();
Comment thread
berry-13 marked this conversation as resolved.
}, [
isChatsExpanded,
isLoading,
isSearchLoading,
groupedConversations.length,
rawConversations,
loadMoreConversations,
]);

const flattenedItems = useMemo(() => {
const items: FlattenedItem[] = [];
// Only include favorites row if FavoritesList will render content
Expand All @@ -215,13 +221,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 })));
Expand All @@ -232,14 +231,7 @@ const Conversations: FC<ConversationsProps> = ({
}
}
return items;
}, [
groupedConversations,
pinnedConversations,
isLoading,
isChatsExpanded,
shouldShowFavorites,
search.query,
]);
}, [groupedConversations, isLoading, isChatsExpanded, shouldShowFavorites]);
Comment thread
berry-13 marked this conversation as resolved.

// Store flattenedItems in a ref for keyMapper to access without recreating cache
const flattenedItemsRef = useRef(flattenedItems);
Expand All @@ -259,12 +251,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'}`;
Expand Down Expand Up @@ -357,33 +343,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} />
Expand All @@ -407,7 +368,7 @@ const Conversations: FC<ConversationsProps> = ({

return null;
},
[cache, flattenedItems, moveToTop, toggleNav, isSmallScreen, pinnedConversations, activeJobIds],
[cache, flattenedItems, moveToTop, toggleNav, isSmallScreen, activeJobIds],
);

const getRowHeight = useCallback(
Expand Down
75 changes: 75 additions & 0 deletions client/src/components/Conversations/PinnedSection.tsx
Original file line number Diff line number Diff line change
@@ -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
Comment thread
berry-13 marked this conversation as resolved.
conversation={convo}
retainView={noop}
Comment thread
berry-13 marked this conversation as resolved.
Comment thread
berry-13 marked this conversation as resolved.
toggleNav={toggleNav}
isGenerating={activeJobIds.has(convo.conversationId ?? '')}
/>
</li>
))}
</ul>
</div>
)}
</div>
);
};

PinnedSection.displayName = 'PinnedSection';

export default memo(PinnedSection);
Loading