+
+ setIsChatsExpanded(!isChatsExpanded)}
+ />
+
{isSearchLoading ? (
diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx
index 46e3be09eb..923397f623 100644
--- a/client/src/components/Conversations/Convo.tsx
+++ b/client/src/components/Conversations/Convo.tsx
@@ -171,6 +171,7 @@ export default function Conversation({
renameHandler: handleRename,
isActiveConvo,
conversationId,
+ chatProjectId: conversation.chatProjectId,
isPopoverActive,
setIsPopoverActive: handlePopoverOpenChange,
isShiftHeld: isActiveConvo ? isShiftHeld : false,
diff --git a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx
index 4286a2f588..7916de7770 100644
--- a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx
+++ b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx
@@ -3,12 +3,22 @@ import * as Ariakit from '@ariakit/react';
import { useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import { DropdownPopup, Spinner, useToastContext } from '@librechat/client';
-import { Ellipsis, Share2, CopyPlus, Archive, Pen, Trash } from 'lucide-react';
+import {
+ Ellipsis,
+ Share2,
+ CopyPlus,
+ Archive,
+ FolderInput,
+ FolderX,
+ Pen,
+ Trash,
+} from 'lucide-react';
import { QueryKeys, PermissionTypes, Permissions } from 'librechat-data-provider';
import type { MouseEvent } from 'react';
import type { TMessage } from 'librechat-data-provider';
import {
useDuplicateConversationMutation,
+ useAssignConversationToProjectMutation,
useDeleteConversationMutation,
useGetStartupConfig,
useArchiveConvoMutation,
@@ -17,11 +27,13 @@ import { useHasAccess, useLocalize, useNavigateToConvo, useNewConvo } from '~/ho
import { NotificationSeverity } from '~/common';
import { useChatContext } from '~/Providers';
import DeleteButton from './DeleteButton';
+import ProjectButton from './ProjectButton';
import ShareButton from './ShareButton';
import { cn } from '~/utils';
function ConvoOptions({
conversationId,
+ chatProjectId,
title,
retainView,
renameHandler,
@@ -31,6 +43,7 @@ function ConvoOptions({
isShiftHeld = false,
}: {
conversationId: string | null;
+ chatProjectId?: string | null;
title: string | null;
retainView: () => void;
renameHandler: (e: MouseEvent) => void;
@@ -53,8 +66,10 @@ function ConvoOptions({
const menuId = useId();
const shareButtonRef = useRef
(null);
const deleteButtonRef = useRef(null);
+ const projectButtonRef = useRef(null);
const [showShareDialog, setShowShareDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
+ const [showProjectDialog, setShowProjectDialog] = useState(false);
const [announcement, setAnnouncement] = useState('');
const canCreateSharedLinks = useHasAccess({
@@ -63,6 +78,7 @@ function ConvoOptions({
});
const archiveConvoMutation = useArchiveConvoMutation();
+ const assignConversationToProject = useAssignConversationToProjectMutation();
const deleteMutation = useDeleteConversationMutation({
onSuccess: () => {
@@ -121,6 +137,37 @@ function ConvoOptions({
setShowDeleteDialog(true);
}, []);
+ const projectHandler = useCallback(() => {
+ setShowProjectDialog(true);
+ }, []);
+
+ const removeProjectHandler = useCallback(() => {
+ const convoId = conversationId ?? '';
+ if (!convoId) {
+ return;
+ }
+ assignConversationToProject.mutate(
+ { conversationId: convoId, projectId: null },
+ {
+ onSuccess: () => {
+ setIsPopoverActive(false);
+ showToast({
+ message: localize('com_ui_project_updated'),
+ severity: NotificationSeverity.SUCCESS,
+ showIcon: true,
+ });
+ },
+ onError: () => {
+ showToast({
+ message: localize('com_ui_project_update_error'),
+ severity: NotificationSeverity.ERROR,
+ showIcon: true,
+ });
+ },
+ },
+ );
+ }, [assignConversationToProject, conversationId, localize, setIsPopoverActive, showToast]);
+
const handleInstantDelete = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
@@ -217,6 +264,27 @@ function ConvoOptions({
),
},
+ {
+ label: localize('com_ui_change_project'),
+ onClick: projectHandler,
+ icon: ,
+ ariaHasPopup: 'dialog' as const,
+ ariaControls: 'project-conversation-dialog',
+ hideOnClick: false,
+ ref: projectButtonRef,
+ render: (props) => ,
+ },
+ {
+ label: localize('com_ui_remove_from_project'),
+ onClick: removeProjectHandler,
+ show: Boolean(chatProjectId),
+ hideOnClick: false,
+ icon: assignConversationToProject.isLoading ? (
+
+ ) : (
+
+ ),
+ },
{
label: localize('com_ui_archive'),
onClick: handleArchiveClick,
@@ -250,6 +318,10 @@ function ConvoOptions({
handleArchiveClick,
canCreateSharedLinks,
handleDuplicateClick,
+ projectHandler,
+ removeProjectHandler,
+ chatProjectId,
+ assignConversationToProject.isLoading,
],
);
@@ -348,6 +420,16 @@ function ConvoOptions({
setShowDeleteDialog={setShowDeleteDialog}
/>
)}
+ {showProjectDialog && (
+
+ )}
>
);
}
@@ -356,6 +438,7 @@ export default memo(ConvoOptions, (prevProps, nextProps) => {
return (
prevProps.conversationId === nextProps.conversationId &&
prevProps.title === nextProps.title &&
+ prevProps.chatProjectId === nextProps.chatProjectId &&
prevProps.isPopoverActive === nextProps.isPopoverActive &&
prevProps.isActiveConvo === nextProps.isActiveConvo &&
prevProps.isShiftHeld === nextProps.isShiftHeld
diff --git a/client/src/components/Conversations/ConvoOptions/ProjectButton.tsx b/client/src/components/Conversations/ConvoOptions/ProjectButton.tsx
new file mode 100644
index 0000000000..a0860ef29b
--- /dev/null
+++ b/client/src/components/Conversations/ConvoOptions/ProjectButton.tsx
@@ -0,0 +1,157 @@
+import { useEffect, useMemo, useState, type RefObject } from 'react';
+import {
+ Button,
+ Spinner,
+ OGDialog,
+ OGDialogClose,
+ OGDialogTitle,
+ OGDialogHeader,
+ OGDialogContent,
+ useToastContext,
+} from '@librechat/client';
+import type { TChatProject } from 'librechat-data-provider';
+import { useAssignConversationToProjectMutation, useProjectsInfiniteQuery } from '~/data-provider';
+import { useLocalize } from '~/hooks';
+import { NotificationSeverity } from '~/common';
+
+type ProjectButtonProps = {
+ conversationId: string;
+ chatProjectId?: string | null;
+ showProjectDialog?: boolean;
+ setShowProjectDialog?: (value: boolean) => void;
+ triggerRef?: RefObject;
+ setMenuOpen?: (open: boolean) => void;
+};
+
+function ProjectConversationDialog({
+ conversationId,
+ chatProjectId,
+ setMenuOpen,
+ setShowProjectDialog,
+}: {
+ conversationId: string;
+ chatProjectId?: string | null;
+ setMenuOpen?: (open: boolean) => void;
+ setShowProjectDialog: (value: boolean) => void;
+}) {
+ const localize = useLocalize();
+ const { showToast } = useToastContext();
+ const [selectedProjectId, setSelectedProjectId] = useState(chatProjectId ?? '');
+ const assignConversation = useAssignConversationToProjectMutation();
+ const { data, fetchNextPage, isFetchingNextPage } = useProjectsInfiniteQuery({
+ sortBy: 'name',
+ sortDirection: 'asc',
+ });
+
+ const projects = useMemo(
+ () => data?.pages.flatMap((page) => page.projects) ?? [],
+ [data?.pages],
+ );
+ const hasNextPage = data?.pages[data.pages.length - 1]?.nextCursor != null;
+
+ useEffect(() => {
+ setSelectedProjectId(chatProjectId ?? '');
+ }, [chatProjectId]);
+
+ const saveProject = () => {
+ assignConversation.mutate(
+ {
+ conversationId,
+ projectId: selectedProjectId || null,
+ },
+ {
+ onSuccess: () => {
+ setShowProjectDialog(false);
+ setMenuOpen?.(false);
+ showToast({
+ message: localize('com_ui_project_updated'),
+ severity: NotificationSeverity.SUCCESS,
+ showIcon: true,
+ });
+ },
+ onError: () => {
+ showToast({
+ message: localize('com_ui_project_update_error'),
+ severity: NotificationSeverity.ERROR,
+ showIcon: true,
+ });
+ },
+ },
+ );
+ };
+
+ return (
+
+
+ {localize('com_ui_change_project')}
+
+
+ {hasNextPage && (
+
+ )}
+
+
+
+
+
+
+
+ );
+}
+
+export default function ProjectButton({
+ conversationId,
+ chatProjectId,
+ setMenuOpen,
+ showProjectDialog,
+ setShowProjectDialog,
+ triggerRef,
+}: ProjectButtonProps) {
+ if (showProjectDialog === undefined || setShowProjectDialog === undefined) {
+ return null;
+ }
+
+ if (!conversationId) {
+ return null;
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/client/src/components/Conversations/ConvoOptions/index.js b/client/src/components/Conversations/ConvoOptions/index.js
index c294bb45ce..44a5e2c9d3 100644
--- a/client/src/components/Conversations/ConvoOptions/index.js
+++ b/client/src/components/Conversations/ConvoOptions/index.js
@@ -1,4 +1,5 @@
export * from './DeleteButton';
+export { default as ProjectButton } from './ProjectButton';
export { default as ShareButton } from './ShareButton';
export { default as SharedLinkButton } from './SharedLinkButton';
export { default as ConvoOptions } from './ConvoOptions';
diff --git a/client/src/components/Conversations/ConvoOptions/index.ts b/client/src/components/Conversations/ConvoOptions/index.ts
index c294bb45ce..44a5e2c9d3 100644
--- a/client/src/components/Conversations/ConvoOptions/index.ts
+++ b/client/src/components/Conversations/ConvoOptions/index.ts
@@ -1,4 +1,5 @@
export * from './DeleteButton';
+export { default as ProjectButton } from './ProjectButton';
export { default as ShareButton } from './ShareButton';
export { default as SharedLinkButton } from './SharedLinkButton';
export { default as ConvoOptions } from './ConvoOptions';
diff --git a/client/src/components/Conversations/ProjectsSection.tsx b/client/src/components/Conversations/ProjectsSection.tsx
new file mode 100644
index 0000000000..4d5e87d3af
--- /dev/null
+++ b/client/src/components/Conversations/ProjectsSection.tsx
@@ -0,0 +1,516 @@
+import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
+import * as Ariakit from '@ariakit/react';
+import { useRecoilValue } from 'recoil';
+import { useNavigate, useLocation } from 'react-router-dom';
+import { useQueryClient } from '@tanstack/react-query';
+import {
+ ChevronDown,
+ ChevronRight,
+ Ellipsis,
+ Folder,
+ FolderPlus,
+ Folders,
+ Pencil,
+ Trash2,
+} from 'lucide-react';
+import { QueryKeys } from 'librechat-data-provider';
+import type { TChatProject, TConversation } from 'librechat-data-provider';
+import {
+ Button,
+ Input,
+ Spinner,
+ OGDialog,
+ OGDialogClose,
+ OGDialogTitle,
+ OGDialogHeader,
+ OGDialogContent,
+ TooltipAnchor,
+ DropdownPopup,
+ NewChatIcon,
+ useToastContext,
+} from '@librechat/client';
+import type { MenuItemProps } from '~/common';
+import {
+ useProjectsInfiniteQuery,
+ useConversationsInfiniteQuery,
+ useUpdateProjectMutation,
+ useDeleteProjectMutation,
+} from '~/data-provider';
+import { useLocalize, useLocalStorage, useNewConvo } from '~/hooks';
+import ProjectCreateDialog from '~/components/Projects/ProjectCreateDialog';
+import { clearMessagesCache, cn } from '~/utils';
+import { NotificationSeverity } from '~/common';
+import Convo from './Convo';
+import store from '~/store';
+
+const INLINE_CHAT_LIMIT = 8;
+
+const iconButtonClassName =
+ 'flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-text-secondary outline-none transition-colors hover:bg-surface-active-alt hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white';
+
+function ProjectRenameDialog({
+ open,
+ onOpenChange,
+ project,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ project: TChatProject;
+}) {
+ const localize = useLocalize();
+ const formId = useId();
+ const inputRef = useRef(null);
+ const [name, setName] = useState(project.name);
+ const updateProject = useUpdateProjectMutation();
+ const { showToast } = useToastContext();
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+ setName(project.name);
+ const frameId = requestAnimationFrame(() => inputRef.current?.focus());
+ return () => cancelAnimationFrame(frameId);
+ }, [open, project.name]);
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+ const trimmed = name.trim();
+ if (!trimmed || updateProject.isLoading) {
+ return;
+ }
+ updateProject.mutate(
+ { projectId: project._id, name: trimmed },
+ {
+ onSuccess: () => onOpenChange(false),
+ onError: () =>
+ showToast({
+ message: localize('com_ui_project_rename_error'),
+ severity: NotificationSeverity.ERROR,
+ showIcon: true,
+ }),
+ },
+ );
+ };
+
+ return (
+
+
+
+ {localize('com_ui_rename_project')}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function ProjectDeleteDialog({
+ open,
+ onOpenChange,
+ project,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ project: TChatProject;
+}) {
+ const localize = useLocalize();
+ const navigate = useNavigate();
+ const location = useLocation();
+ const deleteProject = useDeleteProjectMutation();
+ const { showToast } = useToastContext();
+
+ const confirmDelete = () => {
+ deleteProject.mutate(project._id, {
+ onSuccess: () => {
+ onOpenChange(false);
+ if (location.pathname === `/projects/${project._id}`) {
+ navigate('/projects');
+ }
+ },
+ onError: () =>
+ showToast({
+ message: localize('com_ui_project_delete_error'),
+ severity: NotificationSeverity.ERROR,
+ showIcon: true,
+ }),
+ });
+ };
+
+ return (
+
+
+
+ {localize('com_ui_delete_project')}
+
+
+ {localize('com_ui_delete_project_confirm', { name: project.name })}
+
+
+
+
+
+
+
+
+
+ );
+}
+
+const noop = () => {};
+
+function ProjectChatsInline({
+ projectId,
+ toggleNav,
+ onShowAll,
+}: {
+ projectId: string;
+ toggleNav: () => void;
+ onShowAll: () => void;
+}) {
+ const localize = useLocalize();
+ const { data, isLoading } = useConversationsInfiniteQuery(
+ { projectId, sortBy: 'updatedAt', sortDirection: 'desc' },
+ { staleTime: 30000, cacheTime: 300000 },
+ );
+
+ const conversations = useMemo(
+ () =>
+ (data?.pages.flatMap((page) => page.conversations) ?? []).filter(Boolean) as TConversation[],
+ [data?.pages],
+ );
+ const hasMore =
+ conversations.length > INLINE_CHAT_LIMIT ||
+ (data?.pages[data.pages.length - 1]?.nextCursor ?? null) != null;
+ const visible = conversations.slice(0, INLINE_CHAT_LIMIT);
+
+ if (isLoading && conversations.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ if (conversations.length === 0) {
+ return (
+
+ {localize('com_ui_no_project_chats')}
+
+ );
+ }
+
+ return (
+
+ {visible.map((convo) => (
+
+ ))}
+ {hasMore && (
+
+ )}
+
+ );
+}
+
+function ProjectItem({
+ project,
+ toggleNav,
+ defaultExpanded,
+}: {
+ project: TChatProject;
+ toggleNav: () => void;
+ defaultExpanded: boolean;
+}) {
+ const localize = useLocalize();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { newConversation } = useNewConvo();
+ const conversation = useRecoilValue(store.conversationByIndex(0));
+ const menuId = useId();
+ const [expanded, setExpanded] = useState(defaultExpanded);
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const [isRenameOpen, setIsRenameOpen] = useState(false);
+ const [isDeleteOpen, setIsDeleteOpen] = useState(false);
+
+ const openProject = useCallback(() => {
+ navigate(`/projects/${project._id}`);
+ toggleNav();
+ }, [navigate, project._id, toggleNav]);
+
+ const startChat = useCallback(() => {
+ clearMessagesCache(queryClient, conversation?.conversationId);
+ queryClient.invalidateQueries([QueryKeys.messages]);
+ newConversation({ template: { chatProjectId: project._id } });
+ toggleNav();
+ }, [conversation?.conversationId, newConversation, project._id, queryClient, toggleNav]);
+
+ const menuItems = useMemo(
+ () => [
+ {
+ id: `${menuId}-open`,
+ label: localize('com_ui_open_project'),
+ icon: ,
+ onClick: openProject,
+ },
+ {
+ id: `${menuId}-rename`,
+ label: localize('com_ui_rename'),
+ icon: ,
+ onClick: () => setIsRenameOpen(true),
+ },
+ {
+ id: `${menuId}-delete`,
+ label: localize('com_ui_delete'),
+ icon: ,
+ onClick: () => setIsDeleteOpen(true),
+ },
+ ],
+ [localize, menuId, openProject],
+ );
+
+ return (
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+ }
+ items={menuItems}
+ />
+
+
+ {expanded && (
+
+ )}
+
+
+
+ );
+}
+
+interface ProjectsSectionProps {
+ toggleNav: () => void;
+ isAuthenticated: boolean;
+}
+
+const ProjectsSection = ({ toggleNav, isAuthenticated }: ProjectsSectionProps) => {
+ const localize = useLocalize();
+ const navigate = useNavigate();
+ const [isExpanded, setIsExpanded] = useLocalStorage('projectsSectionExpanded', true);
+ const [isCreateOpen, setIsCreateOpen] = useState(false);
+ const conversation = useRecoilValue(store.conversationByIndex(0));
+ const activeProjectId = conversation?.chatProjectId ?? null;
+
+ const { data, isLoading } = useProjectsInfiniteQuery(
+ { sortBy: 'lastConversationAt', sortDirection: 'desc', limit: 25 },
+ { enabled: isAuthenticated, staleTime: 30000, cacheTime: 300000 },
+ );
+
+ const projects = useMemo(() => data?.pages.flatMap((page) => page.projects) ?? [], [data?.pages]);
+ const hasMore = (data?.pages[data.pages.length - 1]?.nextCursor ?? null) != null;
+
+ const openProjects = useCallback(() => {
+ navigate('/projects');
+ toggleNav();
+ }, [navigate, toggleNav]);
+
+ const renderProjectsBody = () => {
+ if (isLoading && projects.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ if (projects.length === 0) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {projects.map((project) => (
+
+ ))}
+ {hasMore && (
+ -
+
+
+ )}
+
+ );
+ };
+
+ if (!isAuthenticated) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+ }
+ />
+ setIsCreateOpen(true)}
+ >
+
+
+ }
+ />
+
+
+ {isExpanded &&
{renderProjectsBody()}
}
+
+
{
+ navigate(`/projects/${project._id}`);
+ toggleNav();
+ }}
+ />
+
+ );
+};
+
+ProjectsSection.displayName = 'ProjectsSection';
+
+export default memo(ProjectsSection);
diff --git a/client/src/components/Conversations/__tests__/Conversations.test.tsx b/client/src/components/Conversations/__tests__/Conversations.test.tsx
index a4f403fb7f..42d0a378ef 100644
--- a/client/src/components/Conversations/__tests__/Conversations.test.tsx
+++ b/client/src/components/Conversations/__tests__/Conversations.test.tsx
@@ -48,11 +48,15 @@ jest.mock('react-virtualized', () => {
});
jest.mock('~/store', () => {
- const { atom } = jest.requireActual('recoil');
+ const { atom, atomFamily } = jest.requireActual('recoil');
return {
__esModule: true,
default: {
search: atom({ key: 'test-conversations-search', default: { query: '' } }),
+ conversationByIndex: atomFamily({
+ key: 'test-conversations-convo-by-index',
+ default: null,
+ }),
},
};
});
@@ -70,12 +74,19 @@ jest.mock('~/hooks', () => ({
useFavorites: () => mockFavoritesState,
useLocalize: () => (key: string) => key,
useShowMarketplace: () => mockShowMarketplace,
+ useNewConvo: () => ({ newConversation: jest.fn() }),
TranslationKeys: {},
}));
+jest.mock('@tanstack/react-query', () => ({
+ useQueryClient: () => ({ invalidateQueries: jest.fn() }),
+}));
+
jest.mock('@librechat/client', () => ({
Spinner: () => ,
useMediaQuery: () => false,
+ TooltipAnchor: ({ render }: { render: React.ReactNode }) => render,
+ NewChatIcon: () => ,
}));
jest.mock('~/data-provider', () => ({
@@ -84,6 +95,7 @@ jest.mock('~/data-provider', () => ({
jest.mock('~/utils', () => ({
groupConversationsByDate: () => [],
+ clearMessagesCache: jest.fn(),
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}));
diff --git a/client/src/components/Projects/ProjectChatList.tsx b/client/src/components/Projects/ProjectChatList.tsx
new file mode 100644
index 0000000000..9d9309f051
--- /dev/null
+++ b/client/src/components/Projects/ProjectChatList.tsx
@@ -0,0 +1,249 @@
+import {
+ memo,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ type CSSProperties,
+ type FC,
+ type ReactNode,
+} from 'react';
+import throttle from 'lodash/throttle';
+import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtualized';
+import { Spinner } from '@librechat/client';
+import type { TConversation } from 'librechat-data-provider';
+import type { MeasuredCellParent } from '~/components/Conversations/Conversations';
+import { useGetEndpointsQuery } from '~/data-provider';
+import { useLocalize, useNavigateToConvo } from '~/hooks';
+import { groupConversationsByDate, cn } from '~/utils';
+import { DateLabel } from '~/components/Conversations/Conversations';
+import EndpointIcon from '~/components/Endpoints/EndpointIcon';
+
+type ChatSortField = 'updatedAt' | 'createdAt';
+
+type FlattenedItem =
+ | { type: 'date'; groupName: string }
+ | { type: 'convo'; convo: TConversation }
+ | { type: 'loading' }
+ | { type: 'empty' };
+
+interface ProjectChatListProps {
+ conversations: TConversation[];
+ isLoading: boolean;
+ isFetchingNextPage: boolean;
+ hasNextPage: boolean;
+ sortBy: ChatSortField;
+ emptyLabel: string;
+ loadMore: () => void;
+}
+
+interface MeasuredRowProps {
+ cache: CellMeasurerCache;
+ rowKey: string;
+ parent: MeasuredCellParent;
+ index: number;
+ style: CSSProperties;
+ children: ReactNode;
+}
+
+const MeasuredRow: FC = memo(
+ ({ cache, rowKey, parent, index, style, children }) => (
+
+ {({ registerChild }) => (
+ } style={style}>
+ {children}
+
+ )}
+
+ ),
+);
+
+MeasuredRow.displayName = 'ProjectWorkspaceMeasuredRow';
+
+const LoadingRow = memo(() => {
+ const localize = useLocalize();
+ return (
+
+
+ {localize('com_ui_loading')}
+
+ );
+});
+
+LoadingRow.displayName = 'ProjectWorkspaceLoadingRow';
+
+const ConversationRow = memo(({ conversation }: { conversation: TConversation }) => {
+ const { navigateToConvo } = useNavigateToConvo();
+ const localize = useLocalize();
+ const { data: endpointsConfig } = useGetEndpointsQuery();
+ const title = conversation.title || localize('com_ui_untitled');
+ const updatedAt = conversation.updatedAt || conversation.createdAt;
+ const formattedDate = updatedAt ? new Date(updatedAt).toLocaleString() : '';
+
+ return (
+
+ );
+});
+
+ConversationRow.displayName = 'ProjectWorkspaceConversationRow';
+
+const ProjectChatList = ({
+ conversations,
+ isLoading,
+ isFetchingNextPage,
+ hasNextPage,
+ sortBy,
+ emptyLabel,
+ loadMore,
+}: ProjectChatListProps) => {
+ const flattenedItems = useMemo(() => {
+ if (isLoading) {
+ return [{ type: 'loading' as const }];
+ }
+ if (!conversations.length) {
+ return [{ type: 'empty' as const }];
+ }
+
+ const items: FlattenedItem[] = [];
+ groupConversationsByDate(conversations, sortBy).forEach(([groupName, convos]) => {
+ items.push({ type: 'date', groupName });
+ convos.forEach((convo) => items.push({ type: 'convo', convo }));
+ });
+ if (isFetchingNextPage) {
+ items.push({ type: 'loading' });
+ }
+ return items;
+ }, [conversations, isFetchingNextPage, isLoading, sortBy]);
+
+ const flattenedItemsRef = useRef(flattenedItems);
+ flattenedItemsRef.current = flattenedItems;
+
+ const cache = useMemo(
+ () =>
+ new CellMeasurerCache({
+ fixedWidth: true,
+ defaultHeight: 52,
+ keyMapper: (index) => {
+ const item = flattenedItemsRef.current[index];
+ if (!item) {
+ return `project-workspace-unknown-${index}`;
+ }
+ if (item.type === 'date') {
+ return `project-workspace-date-${item.groupName}`;
+ }
+ if (item.type === 'convo') {
+ return `project-workspace-convo-${item.convo.conversationId}`;
+ }
+ return `project-workspace-${item.type}`;
+ },
+ }),
+ [],
+ );
+
+ const listRef = useRef(null);
+
+ useEffect(() => {
+ const frameId = requestAnimationFrame(() => {
+ cache.clearAll();
+ listRef.current?.recomputeRowHeights(0);
+ });
+ return () => cancelAnimationFrame(frameId);
+ }, [cache, conversations.length, sortBy]);
+
+ const throttledLoadMore = useMemo(() => throttle(loadMore, 300), [loadMore]);
+
+ const rowRenderer = useCallback(
+ ({ index, key, parent, style }) => {
+ const item = flattenedItems[index];
+ const rowProps = { cache, rowKey: key, parent, index, style };
+
+ if (item.type === 'loading') {
+ return (
+
+
+
+ );
+ }
+
+ if (item.type === 'empty') {
+ return (
+
+ {emptyLabel}
+
+ );
+ }
+
+ if (item.type === 'date') {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+ },
+ [cache, emptyLabel, flattenedItems],
+ );
+
+ const getRowHeight = useCallback(
+ ({ index }: { index: number }) => cache.getHeight(index, 0),
+ [cache],
+ );
+
+ const handleRowsRendered = useCallback(
+ ({ stopIndex }: { stopIndex: number }) => {
+ if (hasNextPage && stopIndex >= flattenedItems.length - 6) {
+ throttledLoadMore();
+ }
+ },
+ [flattenedItems.length, hasNextPage, throttledLoadMore],
+ );
+
+ return (
+
+
+ {({ width, height }) => (
+
+ )}
+
+
+ );
+};
+
+export default memo(ProjectChatList);
diff --git a/client/src/components/Projects/ProjectCreateDialog.tsx b/client/src/components/Projects/ProjectCreateDialog.tsx
new file mode 100644
index 0000000000..427e024660
--- /dev/null
+++ b/client/src/components/Projects/ProjectCreateDialog.tsx
@@ -0,0 +1,120 @@
+import {
+ useEffect,
+ useId,
+ useRef,
+ useState,
+ type FormEvent,
+ type MutableRefObject,
+ type ReactNode,
+} from 'react';
+import type { TChatProject } from 'librechat-data-provider';
+import {
+ Button,
+ Input,
+ Label,
+ OGDialog,
+ OGDialogTemplate,
+ Spinner,
+ useToastContext,
+} from '@librechat/client';
+import { useCreateProjectMutation } from '~/data-provider';
+import { useLocalize } from '~/hooks';
+
+type ProjectCreateDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onCreated?: (project: TChatProject) => void;
+ children?: ReactNode;
+ triggerRef?: MutableRefObject;
+};
+
+export default function ProjectCreateDialog({
+ open,
+ onOpenChange,
+ onCreated,
+ children,
+ triggerRef,
+}: ProjectCreateDialogProps) {
+ const localize = useLocalize();
+ const formId = useId();
+ const inputRef = useRef(null);
+ const [name, setName] = useState('');
+ const createProject = useCreateProjectMutation();
+ const { showToast } = useToastContext();
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+ const frameId = requestAnimationFrame(() => inputRef.current?.focus());
+ return () => cancelAnimationFrame(frameId);
+ }, [open]);
+
+ const handleOpenChange = (nextOpen: boolean) => {
+ onOpenChange(nextOpen);
+ if (!nextOpen && !createProject.isLoading) {
+ setName('');
+ }
+ };
+
+ const handleCreate = async (event: FormEvent) => {
+ event.preventDefault();
+ const trimmedName = name.trim();
+ if (!trimmedName || createProject.isLoading) {
+ return;
+ }
+
+ try {
+ const project = await createProject.mutateAsync({ name: trimmedName });
+ setName('');
+ onOpenChange(false);
+ onCreated?.(project);
+ } catch {
+ showToast({
+ message: localize('com_ui_project_create_error'),
+ status: 'error',
+ });
+ }
+ };
+
+ return (
+
+ {children}
+
+
+ setName(event.target.value)}
+ placeholder={localize('com_ui_project_name_placeholder')}
+ className="w-full bg-transparent text-text-primary placeholder:text-text-secondary focus-visible:ring-2 focus-visible:ring-ring-primary"
+ />
+
+ }
+ buttons={
+
+ }
+ />
+
+ );
+}
diff --git a/client/src/components/Projects/ProjectWorkspace.tsx b/client/src/components/Projects/ProjectWorkspace.tsx
new file mode 100644
index 0000000000..e904029dff
--- /dev/null
+++ b/client/src/components/Projects/ProjectWorkspace.tsx
@@ -0,0 +1,215 @@
+import { useCallback, useId, useMemo, useState } from 'react';
+import * as Ariakit from '@ariakit/react';
+import { useRecoilValue } from 'recoil';
+import { useQueryClient } from '@tanstack/react-query';
+import { ArrowLeft, ArrowUpDown, Check, Folder, Plus } from 'lucide-react';
+import { useNavigate, useParams } from 'react-router-dom';
+import { QueryKeys } from 'librechat-data-provider';
+import type { ConversationListResponse } from 'librechat-data-provider';
+import { Spinner, DropdownPopup } from '@librechat/client';
+import type { MenuItemProps, RenderProp } from '~/common';
+import { useConversationsInfiniteQuery, useProjectQuery } from '~/data-provider';
+import { useLocalize, useNewConvo } from '~/hooks';
+import { cn, clearMessagesCache } from '~/utils';
+import ProjectChatList from './ProjectChatList';
+import store from '~/store';
+
+type ChatSortField = 'updatedAt' | 'createdAt';
+
+function renderSortMenuItem(label: string, isSelected: boolean): RenderProp {
+ return function SortMenuItem({ className, ...props }) {
+ return (
+
+ {label}
+ {isSelected ? (
+
+ ) : (
+
+ )}
+
+ );
+ };
+}
+
+export default function ProjectWorkspace() {
+ const localize = useLocalize();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const { projectId = '' } = useParams();
+ const [sortBy, setSortBy] = useState('updatedAt');
+ const sortMenuId = useId();
+ const [isSortMenuOpen, setIsSortMenuOpen] = useState(false);
+ const { data: project, isLoading: isProjectLoading } = useProjectQuery(projectId);
+ const conversation = useRecoilValue(store.conversationByIndex(0));
+ const { newConversation } = useNewConvo();
+ const activeProjectId = project?._id;
+
+ const sortOptions = useMemo(
+ () => [
+ { value: 'updatedAt' as const, label: localize('com_ui_sort_updated') },
+ { value: 'createdAt' as const, label: localize('com_ui_sort_created') },
+ ],
+ [localize],
+ );
+ const selectedSortLabel =
+ sortOptions.find((option) => option.value === sortBy)?.label ?? localize('com_ui_sort_updated');
+ const sortMenuItems = useMemo(
+ () =>
+ sortOptions.map((option) => {
+ const isSelected = sortBy === option.value;
+ return {
+ id: `project-chat-sort-${option.value}`,
+ ariaLabel: option.label,
+ ariaChecked: isSelected,
+ onClick: () => setSortBy(option.value),
+ render: renderSortMenuItem(option.label, isSelected),
+ };
+ }),
+ [sortBy, sortOptions],
+ );
+
+ const {
+ data,
+ fetchNextPage,
+ isFetchingNextPage,
+ isLoading: isConversationsLoading,
+ } = useConversationsInfiniteQuery(
+ {
+ projectId: activeProjectId,
+ sortBy,
+ sortDirection: 'desc',
+ },
+ {
+ enabled: Boolean(activeProjectId),
+ staleTime: 30000,
+ cacheTime: 300000,
+ },
+ );
+
+ const conversations = useMemo(
+ () => data?.pages.flatMap((page) => page.conversations) ?? [],
+ [data?.pages],
+ );
+
+ const hasNextPage = useMemo(() => {
+ const pages = data?.pages;
+ if (!pages?.length) {
+ return false;
+ }
+ const lastPage: ConversationListResponse = pages[pages.length - 1];
+ return lastPage.nextCursor !== null;
+ }, [data?.pages]);
+
+ const startProjectChat = useCallback(() => {
+ if (!activeProjectId) {
+ return;
+ }
+ clearMessagesCache(queryClient, conversation?.conversationId);
+ queryClient.invalidateQueries([QueryKeys.messages]);
+ newConversation({ template: { chatProjectId: activeProjectId } });
+ }, [activeProjectId, conversation?.conversationId, newConversation, queryClient]);
+
+ if (isProjectLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!project) {
+ return (
+
+ {localize('com_ui_project_not_found')}
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {localize('com_ui_chats')}
+ {project.conversationCount}
+
+
+
+ {selectedSortLabel}
+
+ }
+ items={sortMenuItems}
+ />
+
+ fetchNextPage()}
+ />
+
+
+
+ );
+}
diff --git a/client/src/components/Projects/ProjectsView.tsx b/client/src/components/Projects/ProjectsView.tsx
new file mode 100644
index 0000000000..e493fa2689
--- /dev/null
+++ b/client/src/components/Projects/ProjectsView.tsx
@@ -0,0 +1,239 @@
+import { useDeferredValue, useEffect, useId, useMemo, useState } from 'react';
+import * as Ariakit from '@ariakit/react';
+import { ArrowUpDown, Check, Folder, Plus, Search } from 'lucide-react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import type { TChatProject } from 'librechat-data-provider';
+import { Input, Button, Spinner, DropdownPopup } from '@librechat/client';
+import type { MenuItemProps, RenderProp } from '~/common';
+import { useProjectsInfiniteQuery } from '~/data-provider';
+import { useLocalize } from '~/hooks';
+import { cn } from '~/utils';
+import ProjectCreateDialog from './ProjectCreateDialog';
+
+type ProjectSort = 'name' | 'createdAt' | 'lastConversationAt';
+
+function renderSortMenuItem(label: string, isSelected: boolean): RenderProp {
+ return function SortMenuItem({ className, ...props }) {
+ return (
+
+ {label}
+ {isSelected ? (
+
+ ) : (
+
+ )}
+
+ );
+ };
+}
+
+function formatActivity(project: TChatProject) {
+ const value = project.lastConversationAt ?? project.updatedAt ?? project.createdAt;
+ if (!value) {
+ return null;
+ }
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) {
+ return null;
+ }
+ return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
+}
+
+export default function ProjectsView() {
+ const localize = useLocalize();
+ const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [search, setSearch] = useState('');
+ const [sortBy, setSortBy] = useState('lastConversationAt');
+ const [isCreating, setIsCreating] = useState(searchParams.get('new') === '1');
+ const sortMenuId = useId();
+ const [isSortMenuOpen, setIsSortMenuOpen] = useState(false);
+ const deferredSearch = useDeferredValue(search);
+
+ const { data, fetchNextPage, isFetchingNextPage, isLoading } = useProjectsInfiniteQuery({
+ search: deferredSearch || undefined,
+ sortBy,
+ sortDirection: sortBy === 'name' ? 'asc' : 'desc',
+ });
+
+ const projects = useMemo(() => data?.pages.flatMap((page) => page.projects) ?? [], [data?.pages]);
+ const hasNextPage = data?.pages[data.pages.length - 1]?.nextCursor != null;
+ const sortOptions = useMemo(
+ () => [
+ { value: 'lastConversationAt' as const, label: localize('com_ui_latest_activity') },
+ { value: 'createdAt' as const, label: localize('com_ui_sort_created') },
+ { value: 'name' as const, label: localize('com_ui_name') },
+ ],
+ [localize],
+ );
+ const selectedSortLabel =
+ sortOptions.find((option) => option.value === sortBy)?.label ??
+ localize('com_ui_latest_activity');
+ const sortMenuItems = useMemo(
+ () =>
+ sortOptions.map((option) => {
+ const isSelected = sortBy === option.value;
+ return {
+ id: `project-sort-${option.value}`,
+ ariaLabel: option.label,
+ ariaChecked: isSelected,
+ onClick: () => setSortBy(option.value),
+ render: renderSortMenuItem(option.label, isSelected),
+ };
+ }),
+ [sortBy, sortOptions],
+ );
+
+ useEffect(() => {
+ if (searchParams.get('new') === '1') {
+ setIsCreating(true);
+ }
+ }, [searchParams]);
+
+ const handleCreateDialogChange = (open: boolean) => {
+ setIsCreating(open);
+ if (!open && searchParams.get('new') === '1') {
+ const nextParams = new URLSearchParams(searchParams);
+ nextParams.delete('new');
+ setSearchParams(nextParams, { replace: true });
+ }
+ };
+
+ return (
+
+
+
+
+ {localize('com_ui_projects')}
+
+
+
+ {localize('com_ui_sort_by')}
+
+
+
+
+ {selectedSortLabel}
+
+
+ }
+ items={sortMenuItems}
+ />
+
+
+
+
+
+
+
+
+ {localize('com_ui_your_projects')}
+
+
+
+
+
navigate(`/projects/${project._id}`)}
+ />
+
+ {isLoading ? (
+
+
+
+ ) : (
+
+ {projects.map((project) => {
+ const activity = formatActivity(project);
+ return (
+
+ );
+ })}
+
+ )}
+
+ {!isLoading && projects.length === 0 && (
+
+ {localize('com_ui_no_projects')}
+
+ )}
+
+ {hasNextPage && (
+
+ )}
+
+
+ );
+}
diff --git a/client/src/components/Projects/index.ts b/client/src/components/Projects/index.ts
new file mode 100644
index 0000000000..ce29a33470
--- /dev/null
+++ b/client/src/components/Projects/index.ts
@@ -0,0 +1,2 @@
+export { default as ProjectsView } from './ProjectsView';
+export { default as ProjectWorkspace } from './ProjectWorkspace';
diff --git a/client/src/components/UnifiedSidebar/ConversationsSection.tsx b/client/src/components/UnifiedSidebar/ConversationsSection.tsx
index 3d8183c4e2..74d4ed893c 100644
--- a/client/src/components/UnifiedSidebar/ConversationsSection.tsx
+++ b/client/src/components/UnifiedSidebar/ConversationsSection.tsx
@@ -14,6 +14,8 @@ import {
} from '~/hooks';
import { useConversationsInfiniteQuery, useTitleGeneration } from '~/data-provider';
import { Conversations } from '~/components/Conversations';
+import ProjectsSection from '~/components/Conversations/ProjectsSection';
+import FavoritesList from '~/components/Nav/Favorites/FavoritesList';
import SearchBar from '~/components/Nav/SearchBar';
import store from '~/store';
@@ -104,7 +106,7 @@ const ConversationsSection = memo(() => {
return (
@@ -116,6 +118,12 @@ const ConversationsSection = memo(() => {
)}
{search.enabled && }
+ {!search.query && (
+
+
+
+ )}
+ {!search.query && }
{
isSearchLoading={isSearchLoading}
isChatsExpanded={isChatsExpanded}
setIsChatsExpanded={setIsChatsExpanded}
+ showFavorites={false}
/>
diff --git a/client/src/data-provider/Projects/index.ts b/client/src/data-provider/Projects/index.ts
new file mode 100644
index 0000000000..d0720956a0
--- /dev/null
+++ b/client/src/data-provider/Projects/index.ts
@@ -0,0 +1,2 @@
+export * from './queries';
+export * from './mutations';
diff --git a/client/src/data-provider/Projects/mutations.ts b/client/src/data-provider/Projects/mutations.ts
new file mode 100644
index 0000000000..ab59af9cea
--- /dev/null
+++ b/client/src/data-provider/Projects/mutations.ts
@@ -0,0 +1,117 @@
+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 type {
+ TChatProject,
+ TConversation,
+ TCreateChatProjectRequest,
+ TDeleteChatProjectResponse,
+ TUpdateChatProjectRequest,
+ TAssignConversationToProjectRequest,
+ TAssignConversationToProjectResponse,
+} from 'librechat-data-provider';
+import store from '~/store';
+
+export const useCreateProjectMutation = (): UseMutationResult<
+ TChatProject,
+ unknown,
+ TCreateChatProjectRequest,
+ unknown
+> => {
+ const queryClient = useQueryClient();
+ return useMutation((payload: TCreateChatProjectRequest) => dataService.createProject(payload), {
+ onSuccess: () => {
+ queryClient.invalidateQueries([QueryKeys.projects]);
+ },
+ });
+};
+
+export const useUpdateProjectMutation = (): UseMutationResult<
+ TChatProject,
+ unknown,
+ TUpdateChatProjectRequest,
+ unknown
+> => {
+ const queryClient = useQueryClient();
+ return useMutation((payload: TUpdateChatProjectRequest) => dataService.updateProject(payload), {
+ onSuccess: (project) => {
+ queryClient.setQueryData([QueryKeys.project, project._id], project);
+ queryClient.invalidateQueries([QueryKeys.projects]);
+ },
+ });
+};
+
+export const useDeleteProjectMutation = (): UseMutationResult<
+ TDeleteChatProjectResponse,
+ unknown,
+ string,
+ unknown
+> => {
+ const queryClient = useQueryClient();
+ const clearActiveConversationProject = useRecoilCallback(
+ ({ snapshot, set }) =>
+ async (projectId: string) => {
+ const conversation = await snapshot.getPromise(store.conversationByIndex(0));
+ if (conversation?.conversationId && conversation.chatProjectId === projectId) {
+ set(store.updateConversationSelector(conversation.conversationId), {
+ ...conversation,
+ chatProjectId: null,
+ });
+ }
+ },
+ [],
+ );
+ return useMutation((projectId: string) => dataService.deleteProject(projectId), {
+ onSuccess: (_result, projectId) => {
+ clearActiveConversationProject(projectId);
+ queryClient.removeQueries([QueryKeys.project, projectId]);
+ queryClient.invalidateQueries([QueryKeys.projects]);
+ queryClient.invalidateQueries([QueryKeys.allConversations]);
+ },
+ });
+};
+
+export const useAssignConversationToProjectMutation = (): UseMutationResult<
+ TAssignConversationToProjectResponse,
+ unknown,
+ TAssignConversationToProjectRequest,
+ unknown
+> => {
+ const queryClient = useQueryClient();
+ const updateActiveConversation = useRecoilCallback(
+ ({ set }) =>
+ (conversation: TConversation) => {
+ if (!conversation.conversationId) {
+ return;
+ }
+ set(store.updateConversationSelector(conversation.conversationId), {
+ ...conversation,
+ chatProjectId: conversation.chatProjectId ?? null,
+ });
+ },
+ [],
+ );
+
+ return useMutation(
+ (payload: TAssignConversationToProjectRequest) =>
+ dataService.assignConversationToProject(payload),
+ {
+ onSuccess: (result) => {
+ updateActiveConversation(result.conversation);
+ queryClient.setQueryData(
+ [QueryKeys.conversation, result.conversation.conversationId],
+ result.conversation,
+ );
+ [result.previousProjectId, result.projectId].forEach((projectId) => {
+ if (projectId) {
+ queryClient.invalidateQueries([QueryKeys.project, projectId]);
+ }
+ });
+ queryClient.invalidateQueries([QueryKeys.projects]);
+ queryClient.invalidateQueries([QueryKeys.allConversations]);
+ queryClient.invalidateQueries([QueryKeys.projectConversations]);
+ },
+ },
+ );
+};
diff --git a/client/src/data-provider/Projects/queries.ts b/client/src/data-provider/Projects/queries.ts
new file mode 100644
index 0000000000..6a8a4ccefd
--- /dev/null
+++ b/client/src/data-provider/Projects/queries.ts
@@ -0,0 +1,49 @@
+import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
+import { dataService, QueryKeys } from 'librechat-data-provider';
+import type {
+ UseInfiniteQueryOptions,
+ QueryObserverResult,
+ UseQueryOptions,
+} from '@tanstack/react-query';
+import type { ProjectListParams, ProjectListResponse, TChatProject } from 'librechat-data-provider';
+
+export const useProjectsInfiniteQuery = (
+ params: ProjectListParams = {},
+ config?: UseInfiniteQueryOptions
,
+) => {
+ const { sortBy, sortDirection, search, limit } = params;
+
+ return useInfiniteQuery({
+ queryKey: [QueryKeys.projects, { sortBy, sortDirection, search, limit }],
+ queryFn: ({ pageParam }) =>
+ dataService.listProjects({
+ sortBy,
+ sortDirection,
+ search,
+ limit,
+ cursor: pageParam?.toString(),
+ }),
+ getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
+ keepPreviousData: true,
+ staleTime: 5 * 60 * 1000,
+ cacheTime: 30 * 60 * 1000,
+ ...config,
+ });
+};
+
+export const useProjectQuery = (
+ projectId?: string | null,
+ config?: UseQueryOptions,
+): QueryObserverResult => {
+ return useQuery(
+ [QueryKeys.project, projectId],
+ () => dataService.getProjectById(projectId ?? ''),
+ {
+ enabled: Boolean(projectId),
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ refetchOnMount: false,
+ ...config,
+ },
+ );
+};
diff --git a/client/src/data-provider/index.ts b/client/src/data-provider/index.ts
index 4a16792080..e4be6aee0b 100644
--- a/client/src/data-provider/index.ts
+++ b/client/src/data-provider/index.ts
@@ -7,6 +7,7 @@ export * from './Files';
export * from './Memories';
export * from './Messages';
export * from './Misc';
+export * from './Projects';
export * from './Tools';
export * from './connection';
export * from './Favorites';
diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts
index 6edb8f3552..dea5851431 100644
--- a/client/src/data-provider/mutations.ts
+++ b/client/src/data-provider/mutations.ts
@@ -11,6 +11,7 @@ import {
logger,
/* Conversations */
addConvoToAllQueries,
+ findConversationInInfinite,
updateConvoInAllQueries,
removeConvoFromAllQueries,
} from '~/utils';
@@ -34,6 +35,7 @@ export const useUpdateConversationMutation = (
const targetId = payload.conversationId || id;
queryClient.setQueryData([QueryKeys.conversation, targetId], updatedConvo);
updateConvoInAllQueries(queryClient, targetId, () => updatedConvo);
+ queryClient.invalidateQueries([QueryKeys.projectConversations]);
},
},
);
@@ -123,6 +125,9 @@ export const useArchiveConvoMutation = (
[QueryKeys.conversation, vars.conversationId],
isArchived ? null : _data,
);
+ if (_data.chatProjectId) {
+ queryClient.invalidateQueries([QueryKeys.project, _data.chatProjectId]);
+ }
onSuccess?.(_data, vars, context);
},
@@ -136,6 +141,8 @@ export const useArchiveConvoMutation = (
queryKey: archivedConvoQueryKey,
refetchPage: (_, index) => index === 0,
});
+ queryClient.invalidateQueries([QueryKeys.projectConversations]);
+ queryClient.invalidateQueries([QueryKeys.projects]);
},
..._options,
},
@@ -475,6 +482,30 @@ export const useDeleteConversationMutation = (
// TODO: CHECK THIS, no-op; restore if needed
},
onSuccess: (data, vars, context) => {
+ const deletedConversation = vars.conversationId
+ ? queryClient.getQueryData([QueryKeys.conversation, vars.conversationId])
+ : undefined;
+ let deletedProjectId = deletedConversation?.chatProjectId;
+ if (!deletedProjectId && vars.conversationId) {
+ const cacheKeys = [QueryKeys.allConversations, QueryKeys.projectConversations];
+ for (const cacheKey of cacheKeys) {
+ const queries = queryClient.getQueryCache().findAll([cacheKey], { exact: false });
+ for (const query of queries) {
+ const found = findConversationInInfinite(
+ queryClient.getQueryData>(query.queryKey),
+ vars.conversationId,
+ );
+ if (found?.chatProjectId) {
+ deletedProjectId = found.chatProjectId;
+ break;
+ }
+ }
+ if (deletedProjectId) {
+ break;
+ }
+ }
+ }
+
if (vars.conversationId) {
removeConvoFromAllQueries(queryClient, vars.conversationId);
}
@@ -519,6 +550,11 @@ export const useDeleteConversationMutation = (
queryKey: [QueryKeys.archivedConversations],
refetchPage: (_, index) => index === 0,
});
+ queryClient.invalidateQueries([QueryKeys.projectConversations]);
+ queryClient.invalidateQueries([QueryKeys.projects]);
+ if (deletedProjectId) {
+ queryClient.invalidateQueries([QueryKeys.project, deletedProjectId]);
+ }
options?.onSuccess?.(data, vars, context);
},
@@ -550,6 +586,11 @@ export const useDuplicateConversationMutation = (
queryKey: [QueryKeys.allConversations],
refetchPage: (_, index) => index === 0,
});
+ queryClient.invalidateQueries([QueryKeys.projectConversations]);
+ queryClient.invalidateQueries([QueryKeys.projects]);
+ if (duplicatedConversation.chatProjectId) {
+ queryClient.invalidateQueries([QueryKeys.project, duplicatedConversation.chatProjectId]);
+ }
if (duplicatedConversation.tags && duplicatedConversation.tags.length > 0) {
queryClient.setQueryData([QueryKeys.conversationTags], (oldTags) => {
@@ -593,6 +634,11 @@ export const useForkConvoMutation = (
queryKey: [QueryKeys.allConversations],
refetchPage: (_, index) => index === 0,
});
+ queryClient.invalidateQueries([QueryKeys.projectConversations]);
+ queryClient.invalidateQueries([QueryKeys.projects]);
+ if (forkedConversation.chatProjectId) {
+ queryClient.invalidateQueries([QueryKeys.project, forkedConversation.chatProjectId]);
+ }
if (forkedConversation.tags && forkedConversation.tags.length > 0) {
queryClient.setQueryData([QueryKeys.conversationTags], (oldTags) => {
diff --git a/client/src/data-provider/queries.ts b/client/src/data-provider/queries.ts
index 2b36a75231..de6c571c8b 100644
--- a/client/src/data-provider/queries.ts
+++ b/client/src/data-provider/queries.ts
@@ -86,12 +86,12 @@ export const useConversationsInfiniteQuery = (
params: ConversationListParams,
config?: UseInfiniteQueryOptions,
) => {
- const { isArchived, sortBy, sortDirection, tags, search } = params;
+ const { isArchived, sortBy, sortDirection, tags, search, projectId } = params;
return useInfiniteQuery({
queryKey: [
isArchived ? QueryKeys.archivedConversations : QueryKeys.allConversations,
- { isArchived, sortBy, sortDirection, tags, search },
+ { isArchived, sortBy, sortDirection, tags, search, projectId },
],
queryFn: ({ pageParam }) =>
dataService.listConversations({
@@ -100,6 +100,7 @@ export const useConversationsInfiniteQuery = (
sortDirection,
tags,
search,
+ projectId,
cursor: pageParam?.toString(),
}),
getNextPageParam: (lastPage) => lastPage?.nextCursor ?? undefined,
diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts
index 0d4c5e1fd5..1d4431940d 100644
--- a/client/src/hooks/Chat/useChatFunctions.ts
+++ b/client/src/hooks/Chat/useChatFunctions.ts
@@ -28,7 +28,7 @@ import type { SetterOrUpdater } from 'recoil';
import type { TAskFunction, ExtendedFile } from '~/common';
import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete';
import useGetSender from '~/hooks/Conversations/useGetSender';
-import { logger, createDualMessageContent } from '~/utils';
+import { logger, createDualMessageContent, getRouteChatProjectId } from '~/utils';
import store, { useGetEphemeralAgent } from '~/store';
import { startupConfigKey } from '~/data-provider';
import useUserKey from '~/hooks/Input/useUserKey';
@@ -174,6 +174,13 @@ export default function useChatFunctions({
});
}
+ const chatProjectId =
+ conversationId === Constants.NEW_CONVO
+ ? getRouteChatProjectId()
+ : (conversation?.chatProjectId ?? null);
+ const conversationForPayload =
+ chatProjectId != null ? { ...(conversation ?? {}), chatProjectId } : (conversation ?? {});
+
// construct the query message
// this is not a real messageId, it is used as placeholder before real messageId returned
const intermediateId = overrideUserMessageId ?? v4();
@@ -193,7 +200,8 @@ export default function useChatFunctions({
parentMessageId = Constants.NO_PARENT;
currentMessages = [];
conversationId = null;
- navigate('/c/new', { state: { focusChat: true } });
+ const projectSearch = chatProjectId ? `?projectId=${encodeURIComponent(chatProjectId)}` : '';
+ navigate(`/c/new${projectSearch}`, { state: { focusChat: true } });
}
const targetParentMessageId = isRegenerate ? messageId : latestMessage?.parentMessageId;
@@ -221,7 +229,7 @@ export default function useChatFunctions({
const convo = parseCompactConvo({
endpoint: endpoint as EndpointSchemaKey,
endpointType: endpointType as EndpointSchemaKey,
- conversation: conversation ?? {},
+ conversation: conversationForPayload,
defaultParamsEndpoint,
});
@@ -234,6 +242,7 @@ export default function useChatFunctions({
overrideUserMessageId,
},
convo,
+ chatProjectId ? { chatProjectId } : {},
) as TEndpointOption;
if (endpoint !== EModelEndpoint.agents) {
endpointOption.key = getExpiry();
@@ -371,6 +380,7 @@ export default function useChatFunctions({
const submission: TSubmission = {
conversation: {
...conversation,
+ ...(chatProjectId ? { chatProjectId } : {}),
conversationId,
},
endpointOption,
diff --git a/client/src/hooks/Input/useQueryParams.ts b/client/src/hooks/Input/useQueryParams.ts
index e37e899188..4c13ddff2f 100644
--- a/client/src/hooks/Input/useQueryParams.ts
+++ b/client/src/hooks/Input/useQueryParams.ts
@@ -23,6 +23,8 @@ import { startupConfigKey, useGetAgentByIdQuery } from '~/data-provider';
import { useChatContext, useChatFormContext } from '~/Providers';
import store from '~/store';
+const PROJECT_ID_SEARCH_PARAM = 'projectId';
+
const injectAgentIntoAgentsMap = (queryClient: QueryClient, agent: any) => {
const editCacheKey = [QueryKeys.agents, { requiredPermission: PermissionBits.EDIT }];
const editCache = queryClient.getQueryData(editCacheKey);
@@ -72,6 +74,15 @@ export default function useQueryParams({
const urlAgentId = searchParams.get('agent_id') || '';
const { data: urlAgent } = useGetAgentByIdQuery(urlAgentId);
+ const getPreservedSearchParams = useCallback(() => {
+ const preservedParams = new URLSearchParams();
+ const projectId = searchParams.get(PROJECT_ID_SEARCH_PARAM);
+ if (projectId) {
+ preservedParams.set(PROJECT_ID_SEARCH_PARAM, projectId);
+ }
+ return preservedParams;
+ }, [searchParams]);
+
/**
* Applies settings from URL query parameters to create a new conversation.
* Handles model spec lookup, endpoint normalization, and conversation switching logic.
@@ -167,7 +178,11 @@ export default function useQueryParams({
return;
}
- newConversation({ preset: newPreset, keepAddedConvos: true });
+ newConversation({
+ template: { chatProjectId: conversation?.chatProjectId ?? null },
+ preset: newPreset,
+ keepAddedConvos: true,
+ });
},
[
queryClient,
@@ -223,8 +238,8 @@ export default function useQueryParams({
}
})();
- setSearchParams(new URLSearchParams(), { replace: true });
- }, [methods, submitMessage, setSearchParams]);
+ setSearchParams(getPreservedSearchParams(), { replace: true });
+ }, [methods, submitMessage, setSearchParams, getPreservedSearchParams]);
useEffect(() => {
const processQueryParams = () => {
@@ -239,6 +254,7 @@ export default function useQueryParams({
delete queryParams.prompt;
delete queryParams.q;
delete queryParams.submit;
+ delete queryParams[PROJECT_ID_SEARCH_PARAM];
const validSettings = processValidSettings(queryParams);
return { decodedPrompt, validSettings, shouldAutoSubmit };
@@ -281,7 +297,7 @@ export default function useQueryParams({
// Defer URL cleanup until after submission completes (processSubmission handles it)
if (!pendingSubmitRef.current) {
- setSearchParams(new URLSearchParams(), { replace: true });
+ setSearchParams(getPreservedSearchParams(), { replace: true });
}
};
@@ -349,6 +365,7 @@ export default function useQueryParams({
newConversation,
submitMessage,
setSearchParams,
+ getPreservedSearchParams,
queryClient,
processSubmission,
areSettingsApplied,
diff --git a/client/src/hooks/Input/useSelectMention.ts b/client/src/hooks/Input/useSelectMention.ts
index c0996b29d2..62bd9fe13f 100644
--- a/client/src/hooks/Input/useSelectMention.ts
+++ b/client/src/hooks/Input/useSelectMention.ts
@@ -1,5 +1,6 @@
-import { useCallback } from 'react';
+import { useCallback, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
+import { useSearchParams } from 'react-router-dom';
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type {
TPreset,
@@ -39,6 +40,16 @@ export default function useSelectMention({
const getDefaultConversation = useDefaultConvo();
const modularChat = useRecoilValue(store.modularChat);
const availableTools = useRecoilValue(store.availableTools);
+ const [searchParams] = useSearchParams();
+ /**
+ * Project scope is read from React Router's search params (the same source the
+ * project chip reads), not window.location — new-chat params are written to the
+ * URL via raw history.pushState, so window.location can drift out of sync.
+ */
+ const routeChatProjectId = useMemo(() => {
+ const projectId = searchParams.get('projectId');
+ return projectId != null && /^[a-f\d]{24}$/i.test(projectId) ? projectId : null;
+ }, [searchParams]);
const onSelectSpec = useCallback(
(spec?: TModelSpec) => {
@@ -107,7 +118,10 @@ export default function useSelectMention({
logger.info('conversation', 'Switching conversation to new spec', conversation);
newConversation({
- template: { ...(template as Partial) },
+ template: {
+ ...(template as Partial),
+ chatProjectId: routeChatProjectId ?? conversation?.chatProjectId ?? null,
+ },
preset,
keepAddedConvos: isModular,
});
@@ -119,6 +133,7 @@ export default function useSelectMention({
newConversation,
endpointsConfig,
assistantsMap,
+ routeChatProjectId,
],
);
@@ -201,12 +216,22 @@ export default function useSelectMention({
logger.info('conversation', 'Switching conversation to new endpoint/model', template);
newConversation({
- template: { ...(template as Partial) },
+ template: {
+ ...(template as Partial),
+ chatProjectId: routeChatProjectId ?? conversation?.chatProjectId ?? null,
+ },
preset: { ...kwargs, spec: null, iconURL: null, modelLabel: null, endpoint: newEndpoint },
keepAddedConvos: isNewModular,
});
},
- [getConversation, getDefaultConversation, modularChat, newConversation, endpointsConfig],
+ [
+ getConversation,
+ getDefaultConversation,
+ modularChat,
+ newConversation,
+ endpointsConfig,
+ routeChatProjectId,
+ ],
);
const onSelectPreset = useCallback(
@@ -264,6 +289,7 @@ export default function useSelectMention({
logger.info('conversation', 'Switching conversation to new preset', template);
newConversation({
+ template: { chatProjectId: routeChatProjectId ?? conversation?.chatProjectId ?? null },
preset: newPreset,
keepAddedConvos: isModular,
disableParams,
@@ -276,6 +302,7 @@ export default function useSelectMention({
newConversation,
endpointsConfig,
getDefaultConversation,
+ routeChatProjectId,
],
);
diff --git a/client/src/hooks/Input/useTextarea.ts b/client/src/hooks/Input/useTextarea.ts
index d132b2299d..fe65208ba0 100644
--- a/client/src/hooks/Input/useTextarea.ts
+++ b/client/src/hooks/Input/useTextarea.ts
@@ -28,11 +28,13 @@ export default function useTextarea({
submitButtonRef,
setIsScrollable,
disabled = false,
+ placeholder,
}: {
textAreaRef: React.RefObject;
submitButtonRef: React.RefObject;
setIsScrollable: React.Dispatch>;
disabled?: boolean;
+ placeholder?: string;
}) {
const localize = useLocalize();
const getSender = useGetSender();
@@ -95,6 +97,10 @@ export default function useTextarea({
return localize('com_endpoint_message_not_appendable');
}
+ if (placeholder) {
+ return placeholder;
+ }
+
const sender =
isAssistant || isAgent
? getEntityName({ name: entityName, isAgent, localize })
@@ -105,17 +111,17 @@ export default function useTextarea({
})}`;
};
- const placeholder = getPlaceholderText();
+ const placeholderText = getPlaceholderText();
- if (textAreaRef.current?.getAttribute('placeholder') === placeholder) {
+ if (textAreaRef.current?.getAttribute('placeholder') === placeholderText) {
return;
}
const setPlaceholder = () => {
- const placeholder = getPlaceholderText();
+ const placeholderText = getPlaceholderText();
- if (textAreaRef.current?.getAttribute('placeholder') !== placeholder) {
- textAreaRef.current?.setAttribute('placeholder', placeholder);
+ if (textAreaRef.current?.getAttribute('placeholder') !== placeholderText) {
+ textAreaRef.current?.setAttribute('placeholder', placeholderText);
forceResize(textAreaRef.current);
}
};
@@ -137,6 +143,7 @@ export default function useTextarea({
conversation,
latestMessage,
isNotAppendable,
+ placeholder,
]);
const handleKeyDown = useCallback(
diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts
index f8e8f715c7..ef69c4a397 100644
--- a/client/src/hooks/SSE/useEventHandlers.ts
+++ b/client/src/hooks/SSE/useEventHandlers.ts
@@ -373,6 +373,10 @@ export default function useEventHandlers({
} else {
updateConvoInAllQueries(queryClient, update.conversationId!, (_c) => update, true);
}
+ if (update.chatProjectId) {
+ queryClient.invalidateQueries([QueryKeys.projects]);
+ queryClient.invalidateQueries([QueryKeys.project, update.chatProjectId]);
+ }
} else if (setConversation) {
setConversation((prevState) => {
update = tConvoUpdateSchema.parse({
@@ -448,6 +452,10 @@ export default function useEventHandlers({
} else {
updateConvoInAllQueries(queryClient, update.conversationId!, (_c) => update, true);
}
+ if (update.chatProjectId) {
+ queryClient.invalidateQueries([QueryKeys.projects]);
+ queryClient.invalidateQueries([QueryKeys.project, update.chatProjectId]);
+ }
}
} else if (setConversation) {
setConversation((prevState) => {
@@ -720,6 +728,11 @@ export default function useEventHandlers({
});
}
+ if (conversation.chatProjectId) {
+ queryClient.invalidateQueries([QueryKeys.projects]);
+ queryClient.invalidateQueries([QueryKeys.project, conversation.chatProjectId]);
+ }
+
if (location.pathname === `/c/${Constants.NEW_CONVO}`) {
preserveSubagentAtomsForNewConvoIdRef.current = conversation.conversationId;
navigate(`/c/${conversation.conversationId}`, { replace: true });
diff --git a/client/src/hooks/useNewConvo.ts b/client/src/hooks/useNewConvo.ts
index 0c5d6dd6db..dfaad2e94c 100644
--- a/client/src/hooks/useNewConvo.ts
+++ b/client/src/hooks/useNewConvo.ts
@@ -88,6 +88,11 @@ const useNewConvo = (index = 0) => {
const modelsConfig = modelsData ?? modelsQuery.data;
const { endpoint = null } = conversation;
const buildDefaultConversation = (endpoint === null || buildDefault) ?? false;
+ const hasExplicitChatProjectId = Object.prototype.hasOwnProperty.call(
+ conversation,
+ 'chatProjectId',
+ );
+ const explicitChatProjectId = conversation.chatProjectId;
const activePreset =
// use default preset only when it's defined,
// preset is not provided,
@@ -198,6 +203,12 @@ const useNewConvo = (index = 0) => {
models,
defaultParamsEndpoint,
});
+
+ if (hasExplicitChatProjectId) {
+ conversation.chatProjectId = explicitChatProjectId ?? null;
+ } else {
+ delete conversation.chatProjectId;
+ }
}
if (disableParams === true) {
@@ -227,26 +238,44 @@ const useNewConvo = (index = 0) => {
return;
}
- const searchParamsString = searchParams?.toString();
- const getParams = () => (searchParamsString ? `?${searchParamsString}` : '');
+ const getParams = (nextConversation: TConversation) => {
+ const nextParams = new URLSearchParams(searchParams);
+ nextParams.delete('projectId');
+ if (
+ nextConversation.conversationId === Constants.NEW_CONVO &&
+ nextConversation.chatProjectId
+ ) {
+ nextParams.set('projectId', nextConversation.chatProjectId);
+ }
+
+ const searchParamsString = nextParams.toString();
+ return searchParamsString ? `?${searchParamsString}` : '';
+ };
if (conversation.conversationId === Constants.NEW_CONVO && !modelsData) {
const appTitle = localStorage.getItem(LocalStorageKeys.APP_TITLE) ?? '';
if (appTitle) {
document.title = appTitle;
}
- const path = `/c/${Constants.NEW_CONVO}${getParams()}`;
+ const path = `/c/${Constants.NEW_CONVO}${getParams(conversation)}`;
navigate(path, { state: { focusChat: true } });
return;
}
- const path = `/c/${conversation.conversationId}${getParams()}`;
+ const path = `/c/${conversation.conversationId}${getParams(conversation)}`;
navigate(path, {
replace: true,
state: disableFocus ? {} : { focusChat: true },
});
},
- [endpointsConfig, defaultPreset, assistantsListMap, modelsQuery.data, hasAgentAccess],
+ [
+ endpointsConfig,
+ defaultPreset,
+ assistantsListMap,
+ modelsQuery.data,
+ hasAgentAccess,
+ searchParams,
+ ],
);
const newConversation = useCallback(
@@ -278,7 +307,7 @@ const useNewConvo = (index = 0) => {
isParamEndpoint(_preset?.endpoint ?? '', _preset?.endpointType ?? '');
const template =
paramEndpoint === true && templateConvoId && templateConvoId === Constants.NEW_CONVO
- ? { endpoint: _template.endpoint }
+ ? { endpoint: _template.endpoint, chatProjectId: _template.chatProjectId }
: _template;
const conversation = {
@@ -298,7 +327,8 @@ const useNewConvo = (index = 0) => {
startupConfig &&
(startupConfig.modelSpecs?.prioritize === true ||
(startupConfig.interface?.modelSelect ?? true) !== true ||
- (result?.last != null && Object.keys(_template).length === 0)) &&
+ (result?.last != null &&
+ Object.keys(_template).filter((key) => key !== 'chatProjectId').length === 0)) &&
defaultModelSpec
) {
preset = getModelSpecPreset(defaultModelSpec);
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index eacbcf30a0..59f0766e6e 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -750,6 +750,7 @@
"com_ui_agents_allow_share_public": "Allow sharing Agents publicly",
"com_ui_agents_allow_use": "Allow using Agents",
"com_ui_all": "all",
+ "com_ui_all_projects": "All projects",
"com_ui_all_proper": "All",
"com_ui_analyzing": "Analyzing",
"com_ui_analyzing_finished": "Finished analyzing",
@@ -840,6 +841,7 @@
"com_ui_cancel": "Cancel",
"com_ui_cancelled": "Cancelled",
"com_ui_category": "Category",
+ "com_ui_change_project": "Change project",
"com_ui_change_version": "Change Version",
"com_ui_chat": "Chat",
"com_ui_chat_history": "Chat History",
@@ -910,6 +912,7 @@
"com_ui_create_mcp_server": "Create MCP server",
"com_ui_create_memory": "Create Memory",
"com_ui_create_new_agent": "Create New Agent",
+ "com_ui_create_project": "Create project",
"com_ui_create_prompt": "Create Prompt",
"com_ui_create_prompt_page": "New Prompt Configuration Page",
"com_ui_create_skill": "Create Skill",
@@ -961,6 +964,8 @@
"com_ui_delete_memory": "Delete Memory",
"com_ui_delete_not_allowed": "Delete operation is not allowed",
"com_ui_delete_preset": "Delete Preset?",
+ "com_ui_delete_project": "Delete project?",
+ "com_ui_delete_project_confirm": "Delete \"{{name}}\"? The chats inside won't be deleted.",
"com_ui_delete_prompt": "Delete Prompt?",
"com_ui_delete_shared_link": "Delete shared link?",
"com_ui_delete_shared_link_heading": "Delete Shared Link",
@@ -1140,6 +1145,7 @@
"com_ui_last_used": "Last used",
"com_ui_late_night": "Happy late night",
"com_ui_latest": "latest",
+ "com_ui_latest_activity": "Latest activity",
"com_ui_latest_footer": "Every AI for Everyone.",
"com_ui_latest_version": "Latest version",
"com_ui_leave_blank_to_keep": "Leave blank to keep existing",
@@ -1149,6 +1155,7 @@
"com_ui_link_copied": "Link copied",
"com_ui_link_refreshed": "Link refreshed",
"com_ui_live": "live",
+ "com_ui_load_more": "Load more",
"com_ui_loading": "Loading...",
"com_ui_locked": "Locked",
"com_ui_logo": "{{0}} Logo",
@@ -1251,6 +1258,7 @@
"com_ui_model_selected": "{{0}} selected",
"com_ui_model_spec": "Model Spec",
"com_ui_more_info": "More info",
+ "com_ui_more_options": "More options",
"com_ui_my_prompts": "My Prompts",
"com_ui_my_skills": "My Skills",
"com_ui_n_files": "{{0}} files",
@@ -1259,7 +1267,9 @@
"com_ui_navigate_results": "Navigate results",
"com_ui_new": "New",
"com_ui_new_chat": "New chat",
+ "com_ui_new_chat_in_project": "New chat in {{name}}",
"com_ui_new_conversation_title": "New Conversation Title",
+ "com_ui_new_project": "New project",
"com_ui_next": "Next",
"com_ui_next_result": "Next result",
"com_ui_no": "No",
@@ -1279,6 +1289,8 @@
"com_ui_no_memories_match": "No memories match your search",
"com_ui_no_memories_title": "No memories yet",
"com_ui_no_personalization_available": "No personalization options are currently available",
+ "com_ui_no_project_chats": "No chats yet",
+ "com_ui_no_projects": "No projects yet",
"com_ui_no_prompts_title": "No prompts yet",
"com_ui_no_read_access": "You don't have permission to view memories",
"com_ui_no_results_found": "No results found",
@@ -1307,6 +1319,7 @@
"com_ui_omitted": "Omitted",
"com_ui_on": "On",
"com_ui_open_archived_chat_new_tab_title": "{{title}} (opens in new tab)",
+ "com_ui_open_project": "Open project",
"com_ui_open_source_chat_new_tab": "Open Source Chat in New Tab",
"com_ui_open_source_chat_new_tab_title": "Open Source Chat in New Tab - {{title}}",
"com_ui_open_var": "Open {{0}}",
@@ -1337,6 +1350,17 @@
"com_ui_privacy_policy": "Privacy policy",
"com_ui_privacy_policy_url": "Privacy Policy URL",
"com_ui_production": "Production",
+ "com_ui_project_chat_count": "{{count}} chats",
+ "com_ui_project_chat_count_single": "1 chat",
+ "com_ui_project_create_error": "Failed to create project",
+ "com_ui_project_delete_error": "Failed to delete project",
+ "com_ui_project_name": "Project name",
+ "com_ui_project_name_placeholder": "Copenhagen Trip",
+ "com_ui_project_not_found": "Project not found",
+ "com_ui_project_rename_error": "Failed to rename project",
+ "com_ui_project_update_error": "Failed to update project assignment",
+ "com_ui_project_updated": "Project assignment updated",
+ "com_ui_projects": "Projects",
"com_ui_prompt": "Prompt",
"com_ui_prompt_category_selector_aria": "Prompt's category selector",
"com_ui_prompt_delete_confirm": "Are you sure you want to delete the '{{0}}' prompt?",
@@ -1393,11 +1417,13 @@
"com_ui_remote_agents_allow_share_public": "Allow users to grant API access to agents to all users",
"com_ui_remote_agents_allow_use": "Allow users to create API keys and query agents remotely",
"com_ui_remove_agent_from_chain": "Remove {{0}} from chain",
+ "com_ui_remove_from_project": "Remove from project",
"com_ui_remove_skill_var": "Remove {{0}}",
"com_ui_remove_user": "Remove {{0}}",
"com_ui_rename": "Rename",
"com_ui_rename_conversation": "Rename Conversation",
"com_ui_rename_failed": "Failed to rename conversation",
+ "com_ui_rename_project": "Rename project",
"com_ui_requires_auth": "Requires Authentication",
"com_ui_reset": "Reset",
"com_ui_reset_adjustments": "Reset adjustments",
@@ -1454,6 +1480,7 @@
"com_ui_search_agent_category": "Search categories...",
"com_ui_search_default_placeholder": "Search by name or email (min 2 chars)",
"com_ui_search_people_placeholder": "Search for people or groups by name or email",
+ "com_ui_search_projects": "Search projects",
"com_ui_search_result_count": "{{count}} result found",
"com_ui_search_results_count": "{{count}} results found",
"com_ui_search_skills": "Search skills...",
@@ -1467,6 +1494,7 @@
"com_ui_select_model": "Select a model",
"com_ui_select_options": "Select options...",
"com_ui_select_or_create_prompt": "Select or Create a Prompt",
+ "com_ui_select_project": "Select project",
"com_ui_select_provider": "Select a provider",
"com_ui_select_provider_first": "Select a provider first",
"com_ui_select_region": "Select a region",
@@ -1574,6 +1602,11 @@
"com_ui_skills_load_error": "Failed to load skills",
"com_ui_skills_manual_invoked": "Manually invoked skills",
"com_ui_skills_queued": "Skills queued for next submission",
+ "com_ui_sort_by": "Sort by",
+ "com_ui_sort_chats_by": "Sort chats by",
+ "com_ui_sort_created": "Created",
+ "com_ui_sort_projects_by": "Sort projects by",
+ "com_ui_sort_updated": "Updated",
"com_ui_special": "special",
"com_ui_special_var_current_date": "Current Date",
"com_ui_special_var_current_datetime": "Current Date & Time",
@@ -1654,6 +1687,7 @@
"com_ui_unarchive": "Unarchive",
"com_ui_unarchive_conversation": "Unarchive conversation",
"com_ui_unarchive_error": "Failed to unarchive conversation",
+ "com_ui_unassigned": "Unassigned",
"com_ui_unavailable": "Unavailable",
"com_ui_unfavorite": "Remove from favorites",
"com_ui_unknown": "Unknown",
@@ -1743,6 +1777,7 @@
"com_ui_yes": "Yes",
"com_ui_you": "You",
"com_ui_your_api_key": "Your API Key",
+ "com_ui_your_projects": "Your projects",
"com_ui_zoom": "Zoom",
"com_ui_zoom_in": "Zoom in",
"com_ui_zoom_level": "Zoom level",
diff --git a/client/src/routes/ChatRoute.tsx b/client/src/routes/ChatRoute.tsx
index e7118fe45d..65b0be91af 100644
--- a/client/src/routes/ChatRoute.tsx
+++ b/client/src/routes/ChatRoute.tsx
@@ -1,5 +1,6 @@
-import { useEffect } from 'react';
+import { useEffect, useMemo } from 'react';
import { useRecoilCallback, useRecoilValue } from 'recoil';
+import { useQueryClient } from '@tanstack/react-query';
import { Spinner, useToastContext } from '@librechat/client';
import { useParams, useSearchParams } from 'react-router-dom';
import { Constants, EModelEndpoint } from 'librechat-data-provider';
@@ -13,6 +14,7 @@ import {
isNotFoundError,
isTemporaryConversation,
logger,
+ clearMessagesCache,
} from '~/utils';
import {
useAssistantListMap,
@@ -21,7 +23,12 @@ import {
useNewConvo,
useLocalize,
} from '~/hooks';
-import { useGetConvoIdQuery, useGetStartupConfig, useGetEndpointsQuery } from '~/data-provider';
+import {
+ useGetConvoIdQuery,
+ useGetStartupConfig,
+ useGetEndpointsQuery,
+ useProjectQuery,
+} from '~/data-provider';
import { ToolCallsMapProvider } from '~/Providers';
import ChatView from '~/components/Chat/ChatView';
import { NotificationSeverity } from '~/common';
@@ -29,9 +36,13 @@ import useAuthRedirect from './useAuthRedirect';
import temporaryStore from '~/store/temporary';
import store from '~/store';
+const isValidChatProjectId = (projectId: string | null): projectId is string =>
+ projectId != null && /^[a-f\d]{24}$/i.test(projectId);
+
export default function ChatRoute() {
const { data: startupConfig } = useGetStartupConfig();
const { isAuthenticated, user, roles } = useAuthRedirect();
+ const queryClient = useQueryClient();
const defaultTemporaryChat = useRecoilValue(temporaryStore.defaultTemporaryChat);
const setIsTemporary = useRecoilCallback(
@@ -46,11 +57,24 @@ export default function ChatRoute() {
const index = 0;
const [searchParams] = useSearchParams();
const { conversationId = '' } = useParams();
+ const projectIdParam = searchParams.get('projectId');
+ const chatProjectId = isValidChatProjectId(projectIdParam) ? projectIdParam : null;
useIdChangeEffect(conversationId);
const { hasSetConversation, conversation } = store.useCreateConversationAtom(index);
const { newConversation } = useNewConvo();
const { showToast } = useToastContext();
const localize = useLocalize();
+ const projectQuery = useProjectQuery(chatProjectId, {
+ enabled: isAuthenticated && Boolean(chatProjectId),
+ retry: false,
+ staleTime: 30000,
+ cacheTime: 300000,
+ });
+ const verifiedChatProjectId = projectQuery.data?._id === chatProjectId ? chatProjectId : null;
+ const projectTemplate = useMemo(
+ () => (verifiedChatProjectId ? { chatProjectId: verifiedChatProjectId } : {}),
+ [verifiedChatProjectId],
+ );
const modelsQuery = useGetModelsQuery({
enabled: isAuthenticated,
@@ -81,15 +105,27 @@ export default function ChatRoute() {
useEffect(() => {
// Wait for roles to load so hasAgentAccess has a definitive value in useNewConvo
const rolesLoaded = roles?.USER != null;
+ const isNewConvo = conversationId === Constants.NEW_CONVO;
+ const isDraftNewConvo = conversation?.conversationId === Constants.NEW_CONVO;
+ const draftProjectMismatch = verifiedChatProjectId
+ ? conversation?.chatProjectId !== verifiedChatProjectId
+ : conversation?.chatProjectId != null;
+ const newConvoNeedsInit =
+ isNewConvo && (!conversation || (isDraftNewConvo && draftProjectMismatch));
const shouldSetConvo =
- (startupConfig && rolesLoaded && !hasSetConversation.current && !modelsQuery.data?.initial) ??
+ (startupConfig &&
+ rolesLoaded &&
+ (!hasSetConversation.current || newConvoNeedsInit) &&
+ !modelsQuery.data?.initial) ??
false;
/* Early exit if startupConfig is not loaded and conversation is already set and only initial models have loaded */
if (!shouldSetConvo) {
return;
}
- const isNewConvo = conversationId === Constants.NEW_CONVO;
+ if (isNewConvo && chatProjectId && projectQuery.isLoading) {
+ return;
+ }
const getNewConvoPreset = () => {
const result = getDefaultModelSpec(startupConfig);
@@ -98,7 +134,7 @@ export default function ChatRoute() {
const queryParams: Record = {};
searchParams.forEach((value, key) => {
- if (key !== 'prompt' && key !== 'q' && key !== 'submit') {
+ if (key !== 'prompt' && key !== 'q' && key !== 'submit' && key !== 'projectId') {
queryParams[key] = value;
}
});
@@ -114,9 +150,10 @@ export default function ChatRoute() {
const preset = getNewConvoPreset();
logger.log('conversation', 'ChatRoute, new convo effect', conversation);
+ clearMessagesCache(queryClient, conversation?.conversationId);
newConversation({
modelsData: modelsQuery.data,
- template: conversation ? conversation : undefined,
+ template: projectTemplate,
...(preset ? { preset } : {}),
});
@@ -161,9 +198,10 @@ export default function ChatRoute() {
const preset = getNewConvoPreset();
logger.log('conversation', 'ChatRoute new convo, assistants effect', conversation);
+ clearMessagesCache(queryClient, conversation?.conversationId);
newConversation({
modelsData: modelsQuery.data,
- template: conversation ? conversation : undefined,
+ template: projectTemplate,
...(preset ? { preset } : {}),
});
hasSetConversation.current = true;
@@ -189,6 +227,13 @@ export default function ChatRoute() {
endpointsQuery.data,
modelsQuery.data,
assistantListMap,
+ chatProjectId,
+ projectQuery.data?._id,
+ projectQuery.isLoading,
+ projectTemplate,
+ queryClient,
+ conversation?.chatProjectId,
+ conversation?.conversationId,
]);
if (endpointsQuery.isLoading || modelsQuery.isLoading) {
@@ -218,7 +263,7 @@ export default function ChatRoute() {
return (
-
+
);
}
diff --git a/client/src/routes/index.tsx b/client/src/routes/index.tsx
index 3637622546..ebcab40c14 100644
--- a/client/src/routes/index.tsx
+++ b/client/src/routes/index.tsx
@@ -41,6 +41,16 @@ const loadSkillsView = () =>
Component: m.default,
}));
+const loadProjectsView = () =>
+ import('~/components/Projects').then((m) => ({
+ Component: m.ProjectsView,
+ }));
+
+const loadProjectWorkspace = () =>
+ import('~/components/Projects').then((m) => ({
+ Component: m.ProjectWorkspace,
+ }));
+
const baseEl = document.querySelector('base');
const baseHref = baseEl?.getAttribute('href') || '/';
@@ -152,6 +162,14 @@ export const router = createBrowserRouter(
path: 'skills/:skillId/edit',
lazy: loadSkillsView,
},
+ {
+ path: 'projects',
+ lazy: loadProjectsView,
+ },
+ {
+ path: 'projects/:projectId',
+ lazy: loadProjectWorkspace,
+ },
{
path: 'agents',
element: (
diff --git a/client/src/store/families.ts b/client/src/store/families.ts
index 5d19331f59..54a2294fb5 100644
--- a/client/src/store/families.ts
+++ b/client/src/store/families.ts
@@ -93,6 +93,9 @@ const conversationByIndex = atomFamily({
if (shouldUpdateParams) {
const newParams = createChatSearchParams(newValue);
+ if (newValue.chatProjectId) {
+ newParams.set('projectId', newValue.chatProjectId);
+ }
const searchParams = createSearchParams(newParams);
const url = `${window.location.pathname}?${searchParams.toString()}`;
window.history.pushState({}, '', url);
diff --git a/client/src/utils/convos.ts b/client/src/utils/convos.ts
index 5b0f6b7a55..2fe26f7e38 100644
--- a/client/src/utils/convos.ts
+++ b/client/src/utils/convos.ts
@@ -78,6 +78,7 @@ const dateGroupsSet = new Set([
export const groupConversationsByDate = (
conversations: Array,
+ dateField: 'updatedAt' | 'createdAt' = 'updatedAt',
): GroupedConversations => {
if (!Array.isArray(conversations)) {
return [];
@@ -93,8 +94,9 @@ export const groupConversationsByDate = (
seenConversationIds.add(conversation.conversationId);
let date: Date;
- if (conversation.updatedAt) {
- date = parseISO(conversation.updatedAt);
+ const dateValue = conversation[dateField] ?? conversation.updatedAt ?? conversation.createdAt;
+ if (dateValue) {
+ date = parseISO(dateValue);
} else {
date = now;
}
@@ -131,7 +133,8 @@ export const groupConversationsByDate = (
sortedGroups.forEach((conversations) => {
conversations.sort(
(a: TConversation, b: TConversation) =>
- new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
+ new Date(b[dateField] ?? b.updatedAt ?? 0).getTime() -
+ new Date(a[dateField] ?? a.updatedAt ?? 0).getTime(),
);
});
return Array.from(sortedGroups, ([key, value]) => [key, value]);
@@ -142,6 +145,40 @@ export type ConversationCursorData = {
nextCursor?: string | null;
};
+function getConversationQueryProjectId(queryKey: readonly unknown[]): string | undefined {
+ const params = queryKey[1];
+ if (!params || typeof params !== 'object') {
+ return undefined;
+ }
+ return (params as { projectId?: string }).projectId;
+}
+
+function conversationMatchesProjectQuery(
+ queryKey: readonly unknown[],
+ conversation: Pick,
+): boolean {
+ const projectId = getConversationQueryProjectId(queryKey);
+ if (!projectId) {
+ return true;
+ }
+ if (projectId === 'unassigned') {
+ return !conversation.chatProjectId;
+ }
+ return conversation.chatProjectId === projectId;
+}
+
+/**
+ * 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).
+ */
+export function getRouteChatProjectId(): string | null {
+ if (typeof window === 'undefined') {
+ return null;
+ }
+ const projectId = new URLSearchParams(window.location.search).get('projectId');
+ return projectId != null && /^[a-f\d]{24}$/i.test(projectId) ? projectId : null;
+}
+
// === InfiniteData helpers for cursor-based convo queries ===
export function findConversationInInfinite(
@@ -208,6 +245,9 @@ export function addConversationToAllConversationsQueries(
.findAll([QueryKeys.allConversations], { exact: false });
for (const query of queries) {
+ if (!conversationMatchesProjectQuery(query.queryKey, newConversation)) {
+ continue;
+ }
queryClient.setQueryData>(query.queryKey, (old) => {
if (
!old ||
@@ -322,6 +362,9 @@ export function addConvoToAllQueries(queryClient: QueryClient, newConvo: TConver
.findAll([QueryKeys.allConversations], { exact: false });
for (const query of queries) {
+ if (!conversationMatchesProjectQuery(query.queryKey, newConvo)) {
+ continue;
+ }
queryClient.setQueryData>(query.queryKey, (oldData) => {
if (!oldData) {
return oldData;
@@ -381,6 +424,9 @@ export function upsertConvoInAllQueries(
const now = new Date().toISOString();
if (pageIdx === -1) {
+ if (!conversationMatchesProjectQuery(query.queryKey, nextConvo)) {
+ return oldData;
+ }
const firstPage = oldData.pages[0] ?? { conversations: [], nextCursor: null };
return {
...oldData,
@@ -404,6 +450,10 @@ export function upsertConvoInAllQueries(
updatedAt: nextConvo.updatedAt ?? (moveToTop ? now : found.updatedAt),
};
+ if (!conversationMatchesProjectQuery(query.queryKey, updated)) {
+ return removeConvoFromInfinitePages(oldData, updated.conversationId ?? '');
+ }
+
if (!moveToTop || (pageIdx === 0 && convoIdx === 0)) {
return {
...oldData,
@@ -480,6 +530,10 @@ export function updateConvoInAllQueries(
? { ...updater(found), updatedAt: new Date().toISOString() }
: updater(found);
+ if (!conversationMatchesProjectQuery(query.queryKey, updated)) {
+ return removeConvoFromInfinitePages(oldData, conversationId);
+ }
+
// If not moving to top, or already at top of page 0, update in place
if (!moveToTop || (pageIdx === 0 && convoIdx === 0)) {
return {
diff --git a/e2e/specs/mock/projects.spec.ts b/e2e/specs/mock/projects.spec.ts
new file mode 100644
index 0000000000..5462dd06fe
--- /dev/null
+++ b/e2e/specs/mock/projects.spec.ts
@@ -0,0 +1,110 @@
+import { expect, test } from '@playwright/test';
+import type { Page } from '@playwright/test';
+import { MOCK_ENDPOINTS, mockReply, selectMockEndpoint, sendMessage } from './helpers';
+
+/**
+ * Creates a project from the all-projects page and returns its id.
+ * Project creation navigates to the project workspace (`/projects/:id`).
+ */
+async function createProject(page: Page, name: string): Promise {
+ await page.goto('/projects', { timeout: 10000 });
+ await page.getByRole('button', { name: 'New project' }).first().click();
+
+ const dialog = page.getByRole('dialog');
+ await dialog.getByRole('textbox', { name: 'Project name' }).fill(name);
+ await dialog.getByRole('button', { name: 'Create project' }).click();
+
+ await expect(page.getByRole('heading', { name })).toBeVisible();
+ const projectId = new URL(page.url()).pathname.split('/projects/')[1];
+ expect(projectId).toBeTruthy();
+ return projectId;
+}
+
+const uniqueName = (prefix: string) => `${prefix} ${Date.now()}-${Math.floor(Math.random() * 1e4)}`;
+
+test.describe('chat projects', () => {
+ test('creates a project and lists it on the all-projects page', async ({ page }) => {
+ test.setTimeout(60000);
+ const name = uniqueName('E2E Project');
+
+ await createProject(page, name);
+
+ await page.goto('/projects', { timeout: 10000 });
+ await expect(page.getByRole('button', { name }).first()).toBeVisible();
+ });
+
+ test('starts a project-scoped chat and files it under the project', async ({ page }) => {
+ test.setTimeout(120000);
+ const name = uniqueName('E2E Project');
+ const projectId = await createProject(page, name);
+
+ // The workspace exposes a composer entry to start a chat in the project.
+ await expect(page.getByRole('button', { name: `New chat in ${name}` }).first()).toBeVisible();
+
+ // Open the project-scoped new-chat landing directly.
+ await page.goto(`/c/new?projectId=${projectId}`, { timeout: 10000 });
+
+ // The interactive project chip is present, and the composer is scoped.
+ await expect(page.getByRole('button', { name: 'Remove from project' })).toBeVisible();
+ const input = page.getByRole('textbox', { name: 'Message input' });
+ await expect(input).toHaveAttribute('placeholder', new RegExp(name));
+
+ // Switch to a mock endpoint so the message streams without a real API key;
+ // the project scope must be retained across the model switch.
+ await selectMockEndpoint(page, MOCK_ENDPOINTS[0]);
+ await expect(page.getByRole('button', { name: 'Remove from project' })).toBeVisible();
+
+ // Send the message; it streams a reply and the new chat opens at /c/:id.
+ const response = await sendMessage(page, 'hello from a project');
+ expect(response.ok()).toBeTruthy();
+ await expect(mockReply(page)).toBeVisible({ timeout: 20000 });
+ await expect(page).toHaveURL(/\/c\/(?!new)/, { timeout: 15000 });
+
+ // Expand the project in the sidebar and confirm the chat is filed under it.
+ const projectRow = page.getByRole('button', { name }).first();
+ if ((await projectRow.getAttribute('aria-expanded')) !== 'true') {
+ await projectRow.click();
+ }
+ await expect(
+ page.getByTestId(`project-chats-${projectId}`).getByTestId('convo-item').first(),
+ ).toBeVisible();
+ });
+
+ test('removes the project scope via the chip ×', async ({ page }) => {
+ test.setTimeout(60000);
+ const name = uniqueName('E2E Project');
+ const projectId = await createProject(page, name);
+
+ await page.goto(`/c/new?projectId=${projectId}`, { timeout: 10000 });
+ await expect(page.getByRole('button', { name: 'Remove from project' })).toBeVisible();
+
+ await page.getByRole('button', { name: 'Remove from project' }).click();
+
+ // Chip disappears and the URL drops the project scope.
+ await expect(page.getByRole('button', { name: 'Remove from project' })).toBeHidden();
+ await expect(page).toHaveURL((url) => !url.searchParams.has('projectId'));
+ });
+
+ test('switches the project via the chip combobox', async ({ page }) => {
+ test.setTimeout(90000);
+ const nameA = uniqueName('E2E Project A');
+ const nameB = uniqueName('E2E Project B');
+ await createProject(page, nameA);
+ const projectIdA = new URL(page.url()).pathname.split('/projects/')[1];
+ await createProject(page, nameB);
+ const projectIdB = new URL(page.url()).pathname.split('/projects/')[1];
+
+ await page.goto(`/c/new?projectId=${projectIdA}`, { timeout: 10000 });
+ await expect(page.getByRole('button', { name: 'Remove from project' })).toBeVisible();
+
+ // Open the combobox and pick the other project.
+ await page.locator('#project-landing-select').click();
+ await page.getByRole('option', { name: nameB }).click();
+
+ await expect(page).toHaveURL(new RegExp(`projectId=${projectIdB}`));
+ await expect(page.getByRole('textbox', { name: 'Message input' })).toHaveAttribute(
+ 'placeholder',
+ new RegExp(nameB),
+ );
+ });
+});
diff --git a/packages/api/src/db/utils.ts b/packages/api/src/db/utils.ts
index b53478d045..aa7b802c91 100644
--- a/packages/api/src/db/utils.ts
+++ b/packages/api/src/db/utils.ts
@@ -42,6 +42,7 @@ export async function ensureRequiredCollectionsExist(db: mongo.Db): Promise(['name', 'createdAt', 'lastConversationAt']);
+
+interface ProjectUser {
+ id: string;
+ _id?: {
+ toString(): string;
+ };
+}
+
+interface ProjectRequest extends Request {
+ user?: ProjectUser;
+}
+
+type ProjectHandlerDependencies = Pick<
+ ChatProjectMethods,
+ | 'listChatProjects'
+ | 'createChatProject'
+ | 'getChatProject'
+ | 'updateChatProject'
+ | 'deleteChatProject'
+ | 'assignConversationToProject'
+>;
+
+const getUserId = (req: ProjectRequest): string => req.user?.id ?? req.user?._id?.toString() ?? '';
+
+const queryString = (value: Request['query'][string]): string | undefined => {
+ if (typeof value === 'string') {
+ return value;
+ }
+ if (Array.isArray(value)) {
+ return queryString(value[0]);
+ }
+ return undefined;
+};
+
+const normalizeString = (value: string | null | undefined): string =>
+ typeof value === 'string' ? value.trim() : '';
+
+const normalizeLimit = (value: Request['query'][string]): number => {
+ const limit = parseInt(queryString(value) ?? '', 10);
+ if (!Number.isFinite(limit)) {
+ return 25;
+ }
+ return Math.min(Math.max(limit, 1), 100);
+};
+
+const normalizeSortBy = (value: Request['query'][string]): ChatProjectSortBy | undefined => {
+ const sortBy = queryString(value);
+ return PROJECT_SORT_FIELDS.has(sortBy as ChatProjectSortBy)
+ ? (sortBy as ChatProjectSortBy)
+ : undefined;
+};
+
+const normalizeSortDirection = (
+ value: Request['query'][string],
+): ChatProjectSortDirection | undefined => {
+ const sortDirection = queryString(value);
+ return sortDirection === 'asc' || sortDirection === 'desc' ? sortDirection : undefined;
+};
+
+const createProjectInput = (req: ProjectRequest): CreateChatProjectInput | null => {
+ const name = normalizeString(req.body?.name);
+ if (!name) {
+ return null;
+ }
+
+ return {
+ name,
+ description: typeof req.body?.description === 'string' ? req.body.description : '',
+ };
+};
+
+export function createProjectHandlers(deps: ProjectHandlerDependencies) {
+ async function listProjects(req: ProjectRequest, res: Response) {
+ try {
+ const result = await deps.listChatProjects(getUserId(req), {
+ cursor: queryString(req.query.cursor),
+ limit: normalizeLimit(req.query.limit),
+ sortBy: normalizeSortBy(req.query.sortBy),
+ sortDirection: normalizeSortDirection(req.query.sortDirection),
+ search: queryString(req.query.search),
+ });
+ return res.status(200).json(result);
+ } catch (error) {
+ logger.error('[projects] Error listing projects', error);
+ return res.status(500).json({ error: 'Error listing projects' });
+ }
+ }
+
+ async function createProject(req: ProjectRequest, res: Response) {
+ const input = createProjectInput(req);
+ if (!input) {
+ return res.status(400).json({ error: 'name is required' });
+ }
+
+ try {
+ const project = await deps.createChatProject(getUserId(req), input);
+ return res.status(201).json(project);
+ } catch (error) {
+ logger.error('[projects] Error creating project', error);
+ return res.status(500).json({ error: 'Error creating project' });
+ }
+ }
+
+ async function assignConversationToProject(req: ProjectRequest, res: Response) {
+ const { conversationId } = req.params;
+ const projectId = req.body?.projectId ?? null;
+
+ if (projectId !== null && typeof projectId !== 'string') {
+ return res.status(400).json({ error: 'projectId must be a string or null' });
+ }
+
+ try {
+ const result = await deps.assignConversationToProject(
+ getUserId(req),
+ conversationId,
+ projectId,
+ );
+ if (!result) {
+ return res.status(404).json({ error: CONVERSATION_NOT_FOUND });
+ }
+ return res.status(200).json(result);
+ } catch (error) {
+ if (error instanceof Error && error.message === PROJECT_NOT_FOUND) {
+ return res.status(404).json({ error: PROJECT_NOT_FOUND });
+ }
+ logger.error('[projects] Error assigning conversation to project', error);
+ return res.status(500).json({ error: 'Error assigning conversation to project' });
+ }
+ }
+
+ async function getProject(req: ProjectRequest, res: Response) {
+ const { projectId } = req.params;
+ if (!isValidObjectIdString(projectId)) {
+ return res.status(404).json({ error: PROJECT_NOT_FOUND });
+ }
+
+ try {
+ const project = await deps.getChatProject(getUserId(req), projectId);
+ if (!project) {
+ return res.status(404).json({ error: PROJECT_NOT_FOUND });
+ }
+ return res.status(200).json(project);
+ } catch (error) {
+ logger.error('[projects] Error getting project', error);
+ return res.status(500).json({ error: 'Error getting project' });
+ }
+ }
+
+ async function updateProject(req: ProjectRequest, res: Response) {
+ const { projectId } = req.params;
+ if (!isValidObjectIdString(projectId)) {
+ return res.status(404).json({ error: PROJECT_NOT_FOUND });
+ }
+
+ const input: UpdateChatProjectInput = {};
+ if (req.body?.name !== undefined) {
+ const name = normalizeString(req.body.name);
+ if (!name) {
+ return res.status(400).json({ error: 'name is required' });
+ }
+ input.name = name;
+ }
+ if (req.body?.description !== undefined) {
+ input.description = typeof req.body.description === 'string' ? req.body.description : '';
+ }
+
+ try {
+ const project = await deps.updateChatProject(getUserId(req), projectId, input);
+ if (!project) {
+ return res.status(404).json({ error: PROJECT_NOT_FOUND });
+ }
+ return res.status(200).json(project);
+ } catch (error) {
+ logger.error('[projects] Error updating project', error);
+ return res.status(500).json({ error: 'Error updating project' });
+ }
+ }
+
+ async function deleteProject(req: ProjectRequest, res: Response) {
+ const { projectId } = req.params;
+ if (!isValidObjectIdString(projectId)) {
+ return res.status(404).json({ error: PROJECT_NOT_FOUND });
+ }
+
+ try {
+ const result = await deps.deleteChatProject(getUserId(req), projectId);
+ if (!result.deletedCount) {
+ return res.status(404).json({ error: PROJECT_NOT_FOUND });
+ }
+ return res.status(200).json(result);
+ } catch (error) {
+ logger.error('[projects] Error deleting project', error);
+ return res.status(500).json({ error: 'Error deleting project' });
+ }
+ }
+
+ return {
+ listProjects,
+ createProject,
+ assignConversationToProject,
+ getProject,
+ updateProject,
+ deleteProject,
+ };
+}
diff --git a/packages/api/src/projects/index.ts b/packages/api/src/projects/index.ts
new file mode 100644
index 0000000000..6c6f862d01
--- /dev/null
+++ b/packages/api/src/projects/index.ts
@@ -0,0 +1 @@
+export * from './handlers';
diff --git a/packages/client/src/components/ControlCombobox.tsx b/packages/client/src/components/ControlCombobox.tsx
index 8c6ca3d8bf..ca932fa2b5 100644
--- a/packages/client/src/components/ControlCombobox.tsx
+++ b/packages/client/src/components/ControlCombobox.tsx
@@ -24,6 +24,7 @@ interface ControlComboboxProps {
disabled?: boolean;
iconSide?: 'left' | 'right';
selectId?: string;
+ placement?: Ariakit.SelectStoreProps['placement'];
}
const ROW_HEIGHT = 36;
@@ -45,6 +46,7 @@ function ControlCombobox({
iconClassName,
iconSide = 'left',
selectId,
+ placement,
}: ControlComboboxProps) {
const [searchValue, setSearchValue] = useState('');
const buttonRef = useRef(null);
@@ -69,6 +71,7 @@ function ControlCombobox({
defaultItems: items.map(getItem),
value: selectedValue,
setValue,
+ placement,
});
const matches = useMemo(() => {
diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts
index f0ece48225..6cec5b0b51 100644
--- a/packages/data-provider/src/api-endpoints.ts
+++ b/packages/data-provider/src/api-endpoints.ts
@@ -126,6 +126,17 @@ export const forkConversation = () => `${conversationsRoot}/fork`;
export const duplicateConversation = () => `${conversationsRoot}/duplicate`;
+export const projectsRoot = `${BASE_URL}/api/projects`;
+
+export const projects = (params: q.ProjectListParams = {}) => {
+ return `${projectsRoot}${buildQuery(params)}`;
+};
+
+export const projectById = (id: string) => `${projectsRoot}/${encodeURIComponent(id)}`;
+
+export const projectConversation = (conversationId: string) =>
+ `${projectsRoot}/conversations/${encodeURIComponent(conversationId)}`;
+
export const search = (q: string, cursor?: string | null) =>
`${BASE_URL}/api/search?q=${q}${cursor ? `&cursor=${cursor}` : ''}`;
diff --git a/packages/data-provider/src/bedrock.ts b/packages/data-provider/src/bedrock.ts
index a52da9a26f..372c0d002b 100644
--- a/packages/data-provider/src/bedrock.ts
+++ b/packages/data-provider/src/bedrock.ts
@@ -212,6 +212,7 @@ function mergeBedrockAnthropicBetaHeaders(existing: unknown, generated: string[]
export const bedrockInputSchema = s.tConversationSchema
.pick({
/* LibreChat params; optionType: 'conversation' */
+ chatProjectId: true,
modelLabel: true,
promptPrefix: true,
resendFiles: true,
@@ -268,6 +269,7 @@ export type BedrockConverseInput = z.infer;
export const bedrockInputParser = s.tConversationSchema
.pick({
/* LibreChat params; optionType: 'conversation' */
+ chatProjectId: true,
modelLabel: true,
promptPrefix: true,
resendFiles: true,
@@ -297,6 +299,7 @@ export const bedrockInputParser = s.tConversationSchema
.catchall(z.any())
.transform((data) => {
const knownKeys = [
+ 'chatProjectId',
'modelLabel',
'promptPrefix',
'resendFiles',
diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts
index ec6995e94a..5d3404e348 100644
--- a/packages/data-provider/src/config.ts
+++ b/packages/data-provider/src/config.ts
@@ -63,6 +63,7 @@ export const excludedKeys = new Set([
'files',
'spec',
'disableParams',
+ 'chatProjectId',
]);
export enum SettingsViews {
diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts
index 43f06fed28..98e7a68453 100644
--- a/packages/data-provider/src/data-service.ts
+++ b/packages/data-provider/src/data-service.ts
@@ -794,6 +794,34 @@ export function archiveConversation(
return request.post(endpoints.archiveConversation(), { arg: payload });
}
+export function listProjects(params?: q.ProjectListParams): Promise {
+ return request.get(endpoints.projects(params ?? {}));
+}
+
+export function createProject(payload: t.TCreateChatProjectRequest): Promise {
+ return request.post(endpoints.projects(), payload);
+}
+
+export function getProjectById(projectId: string): Promise {
+ return request.get(endpoints.projectById(projectId));
+}
+
+export function updateProject(payload: t.TUpdateChatProjectRequest): Promise {
+ const { projectId, ...data } = payload;
+ return request.patch(endpoints.projectById(projectId), data);
+}
+
+export function deleteProject(projectId: string): Promise {
+ return request.delete(endpoints.projectById(projectId));
+}
+
+export function assignConversationToProject(
+ payload: t.TAssignConversationToProjectRequest,
+): Promise {
+ const { conversationId, projectId } = payload;
+ return request.put(endpoints.projectConversation(conversationId), { projectId });
+}
+
export function genTitle(payload: m.TGenTitleRequest): Promise {
return request.get(endpoints.genTitle(payload.conversationId));
}
diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts
index 36f4286617..7a66cfd80d 100644
--- a/packages/data-provider/src/keys.ts
+++ b/packages/data-provider/src/keys.ts
@@ -42,6 +42,9 @@ export enum QueryKeys {
promptGroups = 'promptGroups',
allPromptGroups = 'allPromptGroups',
promptGroup = 'promptGroup',
+ projects = 'projects',
+ project = 'project',
+ projectConversations = 'projectConversations',
categories = 'categories',
randomPrompts = 'randomPrompts',
agentCategories = 'agentCategories',
@@ -112,6 +115,10 @@ export enum MutationKeys {
enableTwoFactor = 'enableTwoFactor',
verifyTwoFactor = 'verifyTwoFactor',
updateMemoryPreferences = 'updateMemoryPreferences',
+ createProject = 'createProject',
+ updateProject = 'updateProject',
+ deleteProject = 'deleteProject',
+ assignConversationToProject = 'assignConversationToProject',
/* Skill mutations from the original UI PR — tree/node operations are
* phase 2 and currently stubbed in the data-service layer. */
createSkillNode = 'createSkillNode',
diff --git a/packages/data-provider/src/schemas.spec.ts b/packages/data-provider/src/schemas.spec.ts
index 1ce3a596fc..2e1f00532f 100644
--- a/packages/data-provider/src/schemas.spec.ts
+++ b/packages/data-provider/src/schemas.spec.ts
@@ -463,6 +463,14 @@ describe('googleSettings', () => {
});
expect(result.maxOutputTokens).toBeUndefined();
});
+
+ it('preserves chat project membership metadata', () => {
+ const result = compactGoogleSchema.parse({
+ model: 'gemini-2.5-pro',
+ chatProjectId: 'project-1',
+ });
+ expect(result.chatProjectId).toBe('project-1');
+ });
});
});
diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts
index 49ed6a0224..43598d1ff5 100644
--- a/packages/data-provider/src/schemas.ts
+++ b/packages/data-provider/src/schemas.ts
@@ -859,6 +859,7 @@ export const tConversationSchema = z.object({
examples: z.array(tExampleSchema).optional(),
/* DB */
tags: z.array(z.string()).optional(),
+ chatProjectId: z.string().nullable().optional(),
createdAt: z.string(),
updatedAt: z.string(),
/* Files */
@@ -914,6 +915,7 @@ export const tConversationSchema = z.object({
export const tPresetSchema = tConversationSchema
.omit({
conversationId: true,
+ chatProjectId: true,
createdAt: true,
updatedAt: true,
title: true,
@@ -1094,6 +1096,7 @@ export const tConversationTagSchema = z.object({
export type TConversationTag = z.infer;
export const googleBaseSchema = tConversationSchema.pick({
+ chatProjectId: true,
model: true,
modelLabel: true,
promptPrefix: true,
@@ -1165,6 +1168,7 @@ export function removeNullishValues>(
}
const assistantBaseSchema = tConversationSchema.pick({
+ chatProjectId: true,
model: true,
assistant_id: true,
instructions: true,
@@ -1200,6 +1204,7 @@ export const assistantSchema = assistantBaseSchema
}));
const compactAssistantBaseSchema = tConversationSchema.pick({
+ chatProjectId: true,
model: true,
assistant_id: true,
instructions: true,
@@ -1215,6 +1220,7 @@ export const compactAssistantSchema = compactAssistantBaseSchema
.catch(() => ({}));
export const agentsBaseSchema = tConversationSchema.pick({
+ chatProjectId: true,
model: true,
modelLabel: true,
temperature: true,
@@ -1268,6 +1274,7 @@ export const agentsSchema = agentsBaseSchema
}));
export const openAIBaseSchema = tConversationSchema.pick({
+ chatProjectId: true,
model: true,
modelLabel: true,
chatGptLabel: true,
@@ -1324,6 +1331,7 @@ export const compactGoogleSchema = googleBaseSchema
.catch(() => ({}));
export const anthropicBaseSchema = tConversationSchema.pick({
+ chatProjectId: true,
model: true,
modelLabel: true,
promptPrefix: true,
@@ -1365,6 +1373,7 @@ export const tBannerSchema = z.object({
export type TBanner = z.infer;
export const compactAgentsBaseSchema = tConversationSchema.pick({
+ chatProjectId: true,
spec: true,
// model: true,
iconURL: true,
diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts
index 83819f17ba..f882ba9578 100644
--- a/packages/data-provider/src/types.ts
+++ b/packages/data-provider/src/types.ts
@@ -71,6 +71,7 @@ export type TEndpointOption = Pick<
| 'file_ids'
// System field
| 'system'
+ | 'chatProjectId'
// Google examples
| 'examples'
// Context
@@ -287,6 +288,43 @@ export type TUpdateConversationRequest = {
export type TUpdateConversationResponse = TConversation;
+export type TChatProject = {
+ _id: string;
+ name: string;
+ description?: string;
+ user?: string;
+ conversationCount: number;
+ lastConversationAt?: string | null;
+ lastConversationId?: string | null;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type TCreateChatProjectRequest = {
+ name: string;
+ description?: string;
+};
+
+export type TUpdateChatProjectRequest = Partial & {
+ projectId: string;
+};
+
+export type TDeleteChatProjectResponse = {
+ deletedCount: number;
+ modifiedCount: number;
+};
+
+export type TAssignConversationToProjectRequest = {
+ conversationId: string;
+ projectId: string | null;
+};
+
+export type TAssignConversationToProjectResponse = {
+ conversation: TConversation;
+ previousProjectId: string | null;
+ projectId: string | null;
+};
+
export type TDeleteConversationRequest = {
conversationId?: string;
thread_id?: string;
diff --git a/packages/data-provider/src/types/queries.ts b/packages/data-provider/src/types/queries.ts
index b9ad609d4d..adbddcd41c 100644
--- a/packages/data-provider/src/types/queries.ts
+++ b/packages/data-provider/src/types/queries.ts
@@ -19,11 +19,12 @@ export type ConversationListParams = {
sortDirection?: 'asc' | 'desc';
tags?: string[];
search?: string;
+ projectId?: string;
};
export type MinimalConversation = Pick<
s.TConversation,
- 'conversationId' | 'endpoint' | 'title' | 'createdAt' | 'updatedAt' | 'user'
+ 'conversationId' | 'endpoint' | 'title' | 'createdAt' | 'updatedAt' | 'user' | 'chatProjectId'
>;
export type ConversationListResponse = {
@@ -37,6 +38,21 @@ export type ConversationUpdater = (
conversation: s.TConversation,
) => ConversationData;
+export type ProjectListParams = {
+ cursor?: string;
+ limit?: number;
+ sortBy?: 'name' | 'createdAt' | 'lastConversationAt';
+ sortDirection?: 'asc' | 'desc';
+ search?: string;
+};
+
+export type ProjectListResponse = {
+ projects: t.TChatProject[];
+ nextCursor: string | null;
+};
+
+export type ProjectData = InfiniteData;
+
/* Messages */
export type MessagesListParams = {
cursor?: string | null;
diff --git a/packages/data-schemas/src/methods/chatProject.spec.ts b/packages/data-schemas/src/methods/chatProject.spec.ts
new file mode 100644
index 0000000000..5563f90b6c
--- /dev/null
+++ b/packages/data-schemas/src/methods/chatProject.spec.ts
@@ -0,0 +1,293 @@
+import mongoose from 'mongoose';
+import { MongoMemoryServer } from 'mongodb-memory-server';
+import { createModels } from '~/models';
+import type { IChatProject, IConversation } from '~/types';
+import { createChatProjectMethods, type ChatProjectMethods } from './chatProject';
+
+jest.mock('~/config/winston', () => ({
+ error: jest.fn(),
+ warn: jest.fn(),
+ info: jest.fn(),
+ debug: jest.fn(),
+}));
+
+let mongoServer: InstanceType;
+let ChatProject: mongoose.Model;
+let Conversation: mongoose.Model;
+let methods: ChatProjectMethods;
+let modelsToCleanup: string[] = [];
+
+beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ const mongoUri = mongoServer.getUri();
+
+ const models = createModels(mongoose);
+ modelsToCleanup = Object.keys(models);
+ Object.assign(mongoose.models, models);
+
+ ChatProject = mongoose.models.ChatProject as mongoose.Model;
+ Conversation = mongoose.models.Conversation as mongoose.Model;
+ methods = createChatProjectMethods(mongoose);
+
+ await mongoose.connect(mongoUri);
+});
+
+afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+
+ for (const modelName of modelsToCleanup) {
+ if (mongoose.models[modelName]) {
+ delete mongoose.models[modelName];
+ }
+ }
+});
+
+afterEach(async () => {
+ await ChatProject.deleteMany({});
+ await Conversation.deleteMany({});
+});
+
+async function createConversation(user: string, conversationId: string, title: string) {
+ return await Conversation.create({
+ conversationId,
+ title,
+ user,
+ endpoint: 'openAI',
+ });
+}
+
+describe('ChatProject methods', () => {
+ const user = 'user-1';
+ const otherUser = 'user-2';
+
+ it('creates, reads, updates, and lists private projects', async () => {
+ const project = await methods.createChatProject(user, {
+ name: 'Customer Alpha',
+ description: 'Support work',
+ });
+
+ expect(project.name).toBe('Customer Alpha');
+ expect(project.conversationCount).toBe(0);
+
+ const readProject = await methods.getChatProject(user, project._id!.toString());
+ expect(readProject?.description).toBe('Support work');
+
+ const updatedProject = await methods.updateChatProject(user, project._id!.toString(), {
+ name: 'Customer Alpha Updated',
+ });
+ expect(updatedProject?.name).toBe('Customer Alpha Updated');
+
+ const list = await methods.listChatProjects(user, { sortBy: 'name', sortDirection: 'asc' });
+ expect(list.projects).toHaveLength(1);
+ expect(list.projects[0].name).toBe('Customer Alpha Updated');
+ });
+
+ it('paginates projects deterministically when latest activity is null', async () => {
+ const staleProject = await methods.createChatProject(user, { name: 'Stale' });
+ await methods.createChatProject(user, { name: 'Quiet A' });
+ await methods.createChatProject(user, { name: 'Quiet B' });
+ const recentProject = await methods.createChatProject(user, { name: 'Recent' });
+
+ await ChatProject.updateOne(
+ { _id: staleProject._id },
+ { $set: { lastConversationAt: new Date('2026-01-01T00:00:00.000Z') } },
+ );
+ await ChatProject.updateOne(
+ { _id: recentProject._id },
+ { $set: { lastConversationAt: new Date('2026-02-01T00:00:00.000Z') } },
+ );
+
+ const firstPage = await methods.listChatProjects(user, {
+ sortBy: 'lastConversationAt',
+ sortDirection: 'desc',
+ limit: 3,
+ });
+ const secondPage = await methods.listChatProjects(user, {
+ sortBy: 'lastConversationAt',
+ sortDirection: 'desc',
+ limit: 3,
+ cursor: firstPage.nextCursor,
+ });
+ const names = [...firstPage.projects, ...secondPage.projects].map((project) => project.name);
+
+ expect(firstPage.projects[0].name).toBe('Recent');
+ expect(firstPage.projects[1].name).toBe('Stale');
+ expect(firstPage.nextCursor).toBeTruthy();
+ expect(secondPage.projects.every((project) => project.lastConversationAt == null)).toBe(true);
+ expect(names).toEqual(expect.arrayContaining(['Recent', 'Stale', 'Quiet A', 'Quiet B']));
+ expect(new Set(names).size).toBe(4);
+
+ const invalidCursor = Buffer.from(
+ JSON.stringify({ primary: 'not-a-date', id: recentProject._id!.toString() }),
+ ).toString('base64');
+ const invalidCursorPage = await methods.listChatProjects(user, {
+ sortBy: 'lastConversationAt',
+ sortDirection: 'desc',
+ limit: 1,
+ cursor: invalidCursor,
+ });
+
+ expect(invalidCursorPage.projects[0].name).toBe('Recent');
+ });
+
+ it('paginates chat-less projects when a page ends on the last dated project', async () => {
+ const staleProject = await methods.createChatProject(user, { name: 'Stale' });
+ await methods.createChatProject(user, { name: 'Quiet A' });
+ await methods.createChatProject(user, { name: 'Quiet B' });
+ const recentProject = await methods.createChatProject(user, { name: 'Recent' });
+
+ await ChatProject.updateOne(
+ { _id: staleProject._id },
+ { $set: { lastConversationAt: new Date('2026-01-01T00:00:00.000Z') } },
+ );
+ await ChatProject.updateOne(
+ { _id: recentProject._id },
+ { $set: { lastConversationAt: new Date('2026-02-01T00:00:00.000Z') } },
+ );
+
+ // limit equals the number of dated projects, so the cursor lands on a dated
+ // project; the null (chat-less) projects must still appear on the next page.
+ const firstPage = await methods.listChatProjects(user, {
+ sortBy: 'lastConversationAt',
+ sortDirection: 'desc',
+ limit: 2,
+ });
+ const secondPage = await methods.listChatProjects(user, {
+ sortBy: 'lastConversationAt',
+ sortDirection: 'desc',
+ limit: 2,
+ cursor: firstPage.nextCursor,
+ });
+
+ expect(firstPage.projects.map((project) => project.name)).toEqual(['Recent', 'Stale']);
+ expect(firstPage.nextCursor).toBeTruthy();
+ expect(secondPage.projects.map((project) => project.name).sort()).toEqual([
+ 'Quiet A',
+ 'Quiet B',
+ ]);
+ expect(secondPage.projects.every((project) => project.lastConversationAt == null)).toBe(true);
+ });
+
+ it('assigns many conversations to one project and updates stats', async () => {
+ const project = await methods.createChatProject(user, { name: 'Customer Alpha' });
+ await createConversation(user, 'convo-1', 'First');
+ await createConversation(user, 'convo-2', 'Second');
+
+ await methods.assignConversationToProject(user, 'convo-1', project._id!.toString());
+ await methods.assignConversationToProject(user, 'convo-2', project._id!.toString());
+
+ const conversations = await Conversation.find({
+ user,
+ chatProjectId: project._id!.toString(),
+ }).lean();
+ const refreshedProject = await methods.getChatProject(user, project._id!.toString());
+
+ expect(conversations).toHaveLength(2);
+ expect(refreshedProject?.conversationCount).toBe(2);
+ expect(refreshedProject?.lastConversationId).toBeDefined();
+ });
+
+ it('excludes retention-hidden conversations from project stats', async () => {
+ const project = await methods.createChatProject(user, { name: 'Visible Stats' });
+ const chatProjectId = project._id!.toString();
+ const visibleDate = new Date('2026-01-01T00:00:00.000Z');
+ const hiddenDate = new Date('2026-02-01T00:00:00.000Z');
+
+ await Conversation.collection.insertMany([
+ {
+ conversationId: 'visible-convo',
+ title: 'Visible',
+ user,
+ endpoint: 'openAI',
+ chatProjectId,
+ isTemporary: false,
+ expiredAt: null,
+ createdAt: visibleDate,
+ updatedAt: visibleDate,
+ },
+ {
+ conversationId: 'temporary-convo',
+ title: 'Temporary',
+ user,
+ endpoint: 'openAI',
+ chatProjectId,
+ isTemporary: true,
+ expiredAt: new Date('2027-03-01T00:00:00.000Z'),
+ createdAt: hiddenDate,
+ updatedAt: hiddenDate,
+ },
+ {
+ conversationId: 'expired-convo',
+ title: 'Expired',
+ user,
+ endpoint: 'openAI',
+ chatProjectId,
+ isTemporary: false,
+ expiredAt: new Date('2025-12-01T00:00:00.000Z'),
+ createdAt: hiddenDate,
+ updatedAt: hiddenDate,
+ },
+ ]);
+
+ const refreshedProject = await methods.refreshChatProjectStats(user, chatProjectId);
+
+ expect(refreshedProject?.conversationCount).toBe(1);
+ expect(refreshedProject?.lastConversationId).toBe('visible-convo');
+ expect(refreshedProject?.lastConversationAt?.toISOString()).toBe(visibleDate.toISOString());
+ });
+
+ it('enforces one project per chat when moving conversations', async () => {
+ const firstProject = await methods.createChatProject(user, { name: 'First' });
+ const secondProject = await methods.createChatProject(user, { name: 'Second' });
+ await createConversation(user, 'convo-1', 'First');
+
+ await methods.assignConversationToProject(user, 'convo-1', firstProject._id!.toString());
+ await methods.assignConversationToProject(user, 'convo-1', secondProject._id!.toString());
+
+ const movedConversation = await Conversation.findOne({
+ user,
+ conversationId: 'convo-1',
+ }).lean();
+ const refreshedFirst = await methods.getChatProject(user, firstProject._id!.toString());
+ const refreshedSecond = await methods.getChatProject(user, secondProject._id!.toString());
+
+ expect(movedConversation?.chatProjectId).toBe(secondProject._id!.toString());
+ expect(refreshedFirst?.conversationCount).toBe(0);
+ expect(refreshedSecond?.conversationCount).toBe(1);
+ });
+
+ it('deleting a project unassigns chats instead of deleting them', async () => {
+ const project = await methods.createChatProject(user, { name: 'Delete me' });
+ await createConversation(user, 'convo-1', 'First');
+ await methods.assignConversationToProject(user, 'convo-1', project._id!.toString());
+
+ const result = await methods.deleteChatProject(user, project._id!.toString());
+ const conversation = await Conversation.findOne({
+ user,
+ conversationId: 'convo-1',
+ }).lean();
+
+ expect(result.deletedCount).toBe(1);
+ expect(result.modifiedCount).toBe(1);
+ expect(conversation).not.toBeNull();
+ expect(conversation?.chatProjectId).toBeUndefined();
+ });
+
+ it('isolates projects and assignments by user', async () => {
+ const project = await methods.createChatProject(user, { name: 'Mine' });
+ await createConversation(otherUser, 'convo-1', 'Theirs');
+
+ const otherRead = await methods.getChatProject(otherUser, project._id!.toString());
+ const assignment = await methods.assignConversationToProject(
+ user,
+ 'convo-1',
+ project._id!.toString(),
+ );
+ const deleteResult = await methods.deleteChatProject(otherUser, project._id!.toString());
+
+ expect(otherRead).toBeNull();
+ expect(assignment).toBeNull();
+ expect(deleteResult.deletedCount).toBe(0);
+ });
+});
diff --git a/packages/data-schemas/src/methods/chatProject.ts b/packages/data-schemas/src/methods/chatProject.ts
new file mode 100644
index 0000000000..038a6a979a
--- /dev/null
+++ b/packages/data-schemas/src/methods/chatProject.ts
@@ -0,0 +1,463 @@
+import type { FilterQuery, Model, SortOrder, Types } from 'mongoose';
+import logger from '~/config/winston';
+import { isValidObjectIdString } from '~/utils/objectId';
+import { buildRetentionVisibilityFilter } from '~/utils/retention';
+import { escapeRegExp } from '~/utils/string';
+import type { IChatProject, IChatProjectDocument, IConversation } from '~/types';
+
+export type ChatProjectSortBy = 'name' | 'createdAt' | 'lastConversationAt';
+export type ChatProjectSortDirection = 'asc' | 'desc';
+
+export type CreateChatProjectInput = {
+ name: string;
+ description?: string | null;
+};
+
+export type UpdateChatProjectInput = Partial;
+
+export type ListChatProjectsOptions = {
+ cursor?: string | null;
+ limit?: number;
+ sortBy?: ChatProjectSortBy;
+ sortDirection?: ChatProjectSortDirection;
+ search?: string;
+};
+
+export type ListChatProjectsResult = {
+ projects: IChatProject[];
+ nextCursor: string | null;
+};
+
+export type DeleteChatProjectResult = {
+ deletedCount: number;
+ modifiedCount: number;
+};
+
+export type AssignConversationToProjectResult = {
+ conversation: IConversation;
+ previousProjectId: string | null;
+ projectId: string | null;
+};
+
+export interface ChatProjectMethods {
+ createChatProject(user: string, input: CreateChatProjectInput): Promise;
+ getChatProject(user: string, projectId: string): Promise;
+ listChatProjects(
+ user: string,
+ options?: ListChatProjectsOptions,
+ ): Promise;
+ updateChatProject(
+ user: string,
+ projectId: string,
+ input: UpdateChatProjectInput,
+ ): Promise;
+ deleteChatProject(user: string, projectId: string): Promise;
+ assignConversationToProject(
+ user: string,
+ conversationId: string,
+ projectId: string | null,
+ ): Promise;
+ refreshChatProjectStats(user: string, projectId: string): Promise;
+}
+
+type ProjectCursor = {
+ primary: string | null;
+ id: string;
+};
+
+type ProjectLean = IChatProject & { _id: Types.ObjectId };
+
+const VALID_SORT_FIELDS = new Set(['name', 'createdAt', 'lastConversationAt']);
+
+function normalizeSortBy(sortBy?: string): ChatProjectSortBy {
+ return VALID_SORT_FIELDS.has(sortBy as ChatProjectSortBy)
+ ? (sortBy as ChatProjectSortBy)
+ : 'lastConversationAt';
+}
+
+function normalizeSortDirection(sortDirection?: string): ChatProjectSortDirection {
+ return sortDirection === 'asc' ? 'asc' : 'desc';
+}
+
+function normalizeLimit(limit?: number): number {
+ if (!Number.isFinite(limit) || !limit) {
+ return 25;
+ }
+ return Math.min(Math.max(Math.floor(limit), 1), 100);
+}
+
+function sanitizeProjectInput(input: CreateChatProjectInput): CreateChatProjectInput {
+ return {
+ name: input.name.trim().slice(0, 100),
+ description: input.description?.trim().slice(0, 1000) ?? '',
+ };
+}
+
+function parseCursor(cursor?: string | null): ProjectCursor | null {
+ if (!cursor) {
+ return null;
+ }
+
+ try {
+ const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString()) as ProjectCursor;
+ if (!decoded.id || !isValidObjectIdString(decoded.id)) {
+ return null;
+ }
+ return {
+ primary: decoded.primary ?? null,
+ id: decoded.id,
+ };
+ } catch {
+ logger.warn('[listChatProjects] Invalid cursor format, starting from beginning');
+ return null;
+ }
+}
+
+function encodeCursor(project: ProjectLean, sortBy: ChatProjectSortBy): string {
+ let primary: string | null = null;
+ if (sortBy === 'name') {
+ primary = project.name;
+ } else {
+ const date = project[sortBy];
+ primary = date instanceof Date ? date.toISOString() : null;
+ }
+ return Buffer.from(JSON.stringify({ primary, id: project._id.toString() })).toString('base64');
+}
+
+function cursorPrimaryValue(
+ primary: string | null,
+ sortBy: ChatProjectSortBy,
+): string | Date | null | undefined {
+ if (primary == null) {
+ return sortBy === 'lastConversationAt' ? null : undefined;
+ }
+
+ if (sortBy === 'name') {
+ return primary;
+ }
+
+ const date = new Date(primary);
+ return Number.isNaN(date.getTime()) ? undefined : date;
+}
+
+function createCursorFilter(
+ mongoose: typeof import('mongoose'),
+ cursor: ProjectCursor | null,
+ sortBy: ChatProjectSortBy,
+ sortDirection: ChatProjectSortDirection,
+): FilterQuery | null {
+ if (!cursor) {
+ return null;
+ }
+
+ const op = sortDirection === 'asc' ? '$gt' : '$lt';
+ const id = new mongoose.Types.ObjectId(cursor.id);
+ const primary = cursorPrimaryValue(cursor.primary, sortBy);
+
+ if (primary === undefined) {
+ logger.warn('[listChatProjects] Invalid cursor primary value, starting from beginning');
+ return null;
+ }
+
+ const branches: FilterQuery[] = [
+ { [sortBy]: { [op]: primary } },
+ { [sortBy]: primary, _id: { [op]: id } },
+ ];
+
+ /**
+ * Projects with no conversations have `lastConversationAt: null`, which sorts
+ * after all dated projects in descending order. A `$lt: ` predicate does
+ * not match null, so once the cursor moves past the dated projects we must
+ * include the null bucket explicitly, otherwise empty projects never paginate.
+ */
+ if (sortBy === 'lastConversationAt' && sortDirection === 'desc' && primary instanceof Date) {
+ branches.push({ lastConversationAt: null } as FilterQuery);
+ }
+
+ return { $or: branches } as FilterQuery;
+}
+
+function visibleProjectConversationFilter(
+ user: string,
+ projectId: string,
+): FilterQuery {
+ return {
+ $and: [
+ { user, chatProjectId: projectId },
+ { $or: [{ isArchived: false }, { isArchived: { $exists: false } }] },
+ buildRetentionVisibilityFilter(),
+ ],
+ } as FilterQuery;
+}
+
+export async function refreshChatProjectStatsForUser(
+ mongoose: typeof import('mongoose'),
+ user: string,
+ projectId: string,
+): Promise {
+ if (!isValidObjectIdString(projectId)) {
+ return null;
+ }
+
+ const ChatProject = mongoose.models.ChatProject as Model;
+ const Conversation = mongoose.models.Conversation as Model;
+ const projectFilter = { _id: new mongoose.Types.ObjectId(projectId), user };
+ const conversationFilter = visibleProjectConversationFilter(user, projectId);
+
+ const [conversationCount, latestConversation] = await Promise.all([
+ Conversation.countDocuments(conversationFilter),
+ Conversation.findOne(conversationFilter)
+ .select('conversationId updatedAt createdAt')
+ .sort({ updatedAt: -1, _id: -1 })
+ .lean(),
+ ]);
+
+ return await ChatProject.findOneAndUpdate(
+ projectFilter,
+ {
+ $set: {
+ conversationCount,
+ lastConversationAt: latestConversation?.updatedAt ?? latestConversation?.createdAt ?? null,
+ lastConversationId: latestConversation?.conversationId ?? null,
+ },
+ },
+ { new: true },
+ ).lean();
+}
+
+export async function updateChatProjectLastConversationForUser(
+ mongoose: typeof import('mongoose'),
+ user: string,
+ projectId: string,
+ conversation: Pick,
+ incrementCount = false,
+): Promise {
+ if (!isValidObjectIdString(projectId) || !conversation.conversationId) {
+ return;
+ }
+
+ const lastConversationAt = conversation.updatedAt ?? conversation.createdAt ?? new Date();
+ const update: Record = {
+ $set: {
+ lastConversationAt,
+ lastConversationId: conversation.conversationId,
+ },
+ };
+ if (incrementCount) {
+ update.$inc = { conversationCount: 1 };
+ }
+
+ const ChatProject = mongoose.models.ChatProject as Model;
+ await ChatProject.updateOne({ _id: new mongoose.Types.ObjectId(projectId), user }, update);
+}
+
+export function createChatProjectMethods(mongoose: typeof import('mongoose')): ChatProjectMethods {
+ async function createChatProject(
+ user: string,
+ input: CreateChatProjectInput,
+ ): Promise {
+ const ChatProject = mongoose.models.ChatProject as Model;
+ const sanitized = sanitizeProjectInput(input);
+ if (!sanitized.name) {
+ throw new Error('Project name is required');
+ }
+
+ const project = await ChatProject.create({
+ ...sanitized,
+ user,
+ conversationCount: 0,
+ lastConversationAt: null,
+ lastConversationId: null,
+ });
+ return project.toObject() as IChatProject;
+ }
+
+ async function getChatProject(user: string, projectId: string): Promise {
+ if (!isValidObjectIdString(projectId)) {
+ return null;
+ }
+
+ const ChatProject = mongoose.models.ChatProject as Model;
+ return await ChatProject.findOne({
+ _id: new mongoose.Types.ObjectId(projectId),
+ user,
+ }).lean();
+ }
+
+ async function listChatProjects(
+ user: string,
+ options: ListChatProjectsOptions = {},
+ ): Promise {
+ const ChatProject = mongoose.models.ChatProject as Model;
+ const limit = normalizeLimit(options.limit);
+ const sortBy = normalizeSortBy(options.sortBy);
+ const sortDirection = normalizeSortDirection(options.sortDirection);
+ const sortOrder: SortOrder = sortDirection === 'asc' ? 1 : -1;
+ const filters: FilterQuery[] = [{ user }];
+
+ if (options.search?.trim()) {
+ filters.push({ name: { $regex: escapeRegExp(options.search.trim()), $options: 'i' } });
+ }
+
+ const cursorFilter = createCursorFilter(
+ mongoose,
+ parseCursor(options.cursor),
+ sortBy,
+ sortDirection,
+ );
+ if (cursorFilter) {
+ filters.push(cursorFilter);
+ }
+
+ const query =
+ filters.length === 1 ? filters[0] : ({ $and: filters } as FilterQuery);
+ const projects = await ChatProject.find(query)
+ .sort({ [sortBy]: sortOrder, _id: sortOrder })
+ .limit(limit + 1)
+ .lean();
+
+ let nextCursor: string | null = null;
+ if (projects.length > limit) {
+ projects.pop();
+ const lastProject = projects[projects.length - 1];
+ if (lastProject) {
+ nextCursor = encodeCursor(lastProject, sortBy);
+ }
+ }
+
+ return { projects, nextCursor };
+ }
+
+ async function updateChatProject(
+ user: string,
+ projectId: string,
+ input: UpdateChatProjectInput,
+ ): Promise {
+ if (!isValidObjectIdString(projectId)) {
+ return null;
+ }
+
+ const ChatProject = mongoose.models.ChatProject as Model;
+ const update: Partial> = {};
+ if (typeof input.name === 'string') {
+ const name = input.name.trim().slice(0, 100);
+ if (!name) {
+ throw new Error('Project name is required');
+ }
+ update.name = name;
+ }
+ if (input.description !== undefined) {
+ update.description = input.description?.trim().slice(0, 1000) ?? '';
+ }
+
+ return await ChatProject.findOneAndUpdate(
+ { _id: new mongoose.Types.ObjectId(projectId), user },
+ { $set: update },
+ { new: true, runValidators: true },
+ ).lean();
+ }
+
+ async function deleteChatProject(
+ user: string,
+ projectId: string,
+ ): Promise {
+ if (!isValidObjectIdString(projectId)) {
+ return { deletedCount: 0, modifiedCount: 0 };
+ }
+
+ const ChatProject = mongoose.models.ChatProject as Model;
+ const Conversation = mongoose.models.Conversation as Model;
+ const projectFilter = { _id: new mongoose.Types.ObjectId(projectId), user };
+ const project = await ChatProject.findOne(projectFilter).select('_id').lean();
+ if (!project) {
+ return { deletedCount: 0, modifiedCount: 0 };
+ }
+
+ const [conversationResult, deleteResult] = await Promise.all([
+ Conversation.updateMany(
+ { user, chatProjectId: projectId },
+ { $unset: { chatProjectId: '' } },
+ ),
+ ChatProject.deleteOne(projectFilter),
+ ]);
+
+ return {
+ deletedCount: deleteResult.deletedCount ?? 0,
+ modifiedCount: conversationResult.modifiedCount ?? 0,
+ };
+ }
+
+ async function assignConversationToProject(
+ user: string,
+ conversationId: string,
+ projectId: string | null,
+ ): Promise {
+ const ChatProject = mongoose.models.ChatProject as Model;
+ const Conversation = mongoose.models.Conversation as Model;
+ const conversation = await Conversation.findOne({ user, conversationId }).lean();
+ if (!conversation) {
+ return null;
+ }
+
+ const normalizedProjectId = projectId || null;
+ if (normalizedProjectId) {
+ if (!isValidObjectIdString(normalizedProjectId)) {
+ throw new Error('Project not found');
+ }
+ const project = await ChatProject.findOne({
+ _id: new mongoose.Types.ObjectId(normalizedProjectId),
+ user,
+ })
+ .select('_id')
+ .lean();
+ if (!project) {
+ throw new Error('Project not found');
+ }
+ }
+
+ const previousProjectId = conversation.chatProjectId ?? null;
+ const update =
+ normalizedProjectId == null
+ ? { $unset: { chatProjectId: '' } }
+ : { $set: { chatProjectId: normalizedProjectId } };
+ const updatedConversation = await Conversation.findOneAndUpdate(
+ { user, conversationId },
+ update,
+ { new: true },
+ ).lean();
+
+ if (!updatedConversation) {
+ return null;
+ }
+
+ const projectIds = new Set(
+ [previousProjectId, normalizedProjectId].filter((id): id is string => Boolean(id)),
+ );
+ await Promise.all(
+ [...projectIds].map((id) => refreshChatProjectStatsForUser(mongoose, user, id)),
+ );
+
+ return {
+ conversation: updatedConversation,
+ previousProjectId,
+ projectId: normalizedProjectId,
+ };
+ }
+
+ async function refreshChatProjectStats(
+ user: string,
+ projectId: string,
+ ): Promise {
+ return await refreshChatProjectStatsForUser(mongoose, user, projectId);
+ }
+
+ return {
+ createChatProject,
+ getChatProject,
+ listChatProjects,
+ updateChatProject,
+ deleteChatProject,
+ assignConversationToProject,
+ refreshChatProjectStats,
+ };
+}
diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts
index 5646d7dfff..94a677b113 100644
--- a/packages/data-schemas/src/methods/conversation.spec.ts
+++ b/packages/data-schemas/src/methods/conversation.spec.ts
@@ -1,7 +1,7 @@
import mongoose from 'mongoose';
import { v4 as uuidv4 } from 'uuid';
import { EModelEndpoint, RetentionMode } from 'librechat-data-provider';
-import type { IConversation } from '../types';
+import type { IChatProject, IConversation } from '../types';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { ConversationMethods, createConversationMethods } from './conversation';
import { tenantStorage, runAsSystem } from '~/config/tenantContext';
@@ -16,6 +16,7 @@ jest.mock('~/config/winston', () => ({
let mongoServer: InstanceType;
let Conversation: mongoose.Model;
+let ChatProject: mongoose.Model;
let modelsToCleanup: string[] = [];
// Mock message methods (same as original test mocking ./Message)
@@ -32,6 +33,7 @@ beforeAll(async () => {
modelsToCleanup = Object.keys(models);
Object.assign(mongoose.models, models);
Conversation = mongoose.models.Conversation as mongoose.Model;
+ ChatProject = mongoose.models.ChatProject as mongoose.Model;
methods = createConversationMethods(mongoose, { getMessages, deleteMessages });
@@ -91,6 +93,7 @@ describe('Conversation Operations', () => {
beforeEach(async () => {
// Clear database
await Conversation.deleteMany({});
+ await ChatProject.deleteMany({});
// Reset mocks
jest.clearAllMocks();
@@ -153,6 +156,155 @@ describe('Conversation Operations', () => {
expect(result?.conversationId).toBe(newConversationId);
});
+ it('refreshes project stats when archiving a project conversation', async () => {
+ const project = await ChatProject.create({
+ user: mockCtx.userId,
+ name: 'Project Stats',
+ conversationCount: 0,
+ lastConversationAt: null,
+ lastConversationId: null,
+ });
+ const firstConversationId = uuidv4();
+ const secondConversationId = uuidv4();
+ const chatProjectId = project._id!.toString();
+
+ await saveConvo(mockCtx, {
+ conversationId: firstConversationId,
+ title: 'First',
+ endpoint: EModelEndpoint.openAI,
+ chatProjectId,
+ });
+ await saveConvo(mockCtx, {
+ conversationId: secondConversationId,
+ title: 'Second',
+ endpoint: EModelEndpoint.openAI,
+ chatProjectId,
+ });
+
+ await saveConvo(mockCtx, {
+ conversationId: secondConversationId,
+ isArchived: true,
+ });
+
+ const refreshedProject = await ChatProject.findById(project._id).lean();
+ expect(refreshedProject?.conversationCount).toBe(1);
+ expect(refreshedProject?.lastConversationId).toBe(firstConversationId);
+ });
+
+ it('bulkSaveConvos keeps owned project ids and strips orphan ones', async () => {
+ const project = await ChatProject.create({
+ user: mockCtx.userId,
+ name: 'Bulk Project',
+ conversationCount: 0,
+ lastConversationAt: null,
+ lastConversationId: null,
+ });
+ const ownedId = project._id!.toString();
+ const orphanId = new mongoose.Types.ObjectId().toString();
+ const ownedConvoId = uuidv4();
+ const orphanConvoId = uuidv4();
+
+ await methods.bulkSaveConvos([
+ {
+ conversationId: ownedConvoId,
+ user: mockCtx.userId,
+ endpoint: EModelEndpoint.openAI,
+ chatProjectId: ownedId,
+ },
+ {
+ conversationId: orphanConvoId,
+ user: mockCtx.userId,
+ endpoint: EModelEndpoint.openAI,
+ chatProjectId: orphanId,
+ },
+ ]);
+
+ const ownedConvo = await Conversation.findOne({
+ conversationId: ownedConvoId,
+ }).lean();
+ const orphanConvo = await Conversation.findOne({
+ conversationId: orphanConvoId,
+ }).lean();
+
+ expect(ownedConvo?.chatProjectId).toBe(ownedId);
+ expect(orphanConvo?.chatProjectId == null).toBe(true);
+
+ const refreshedProject = await ChatProject.findById(project._id).lean();
+ expect(refreshedProject?.conversationCount).toBe(1);
+ });
+
+ it('refreshes both projects when a save moves a conversation between them', async () => {
+ const projectA = await ChatProject.create({
+ user: mockCtx.userId,
+ name: 'Project A',
+ conversationCount: 0,
+ });
+ const projectB = await ChatProject.create({
+ user: mockCtx.userId,
+ name: 'Project B',
+ conversationCount: 0,
+ });
+ const conversationId = uuidv4();
+ const projectAId = projectA._id!.toString();
+ const projectBId = projectB._id!.toString();
+
+ await saveConvo(mockCtx, {
+ conversationId,
+ title: 'Moving',
+ endpoint: EModelEndpoint.openAI,
+ chatProjectId: projectAId,
+ });
+ // A stale tab re-submits with the new project id, moving the chat A -> B.
+ await saveConvo(mockCtx, {
+ conversationId,
+ endpoint: EModelEndpoint.openAI,
+ chatProjectId: projectBId,
+ });
+
+ const refreshedA = await ChatProject.findById(projectA._id).lean();
+ const refreshedB = await ChatProject.findById(projectB._id).lean();
+ expect(refreshedA?.conversationCount).toBe(0);
+ expect(refreshedA?.lastConversationId == null).toBe(true);
+ expect(refreshedB?.conversationCount).toBe(1);
+ expect(refreshedB?.lastConversationId).toBe(conversationId);
+ });
+
+ it('bulkSaveConvos refreshes the project a conversation leaves', async () => {
+ const projectA = await ChatProject.create({
+ user: mockCtx.userId,
+ name: 'Bulk Project A',
+ conversationCount: 0,
+ });
+ const projectB = await ChatProject.create({
+ user: mockCtx.userId,
+ name: 'Bulk Project B',
+ conversationCount: 0,
+ });
+ const conversationId = uuidv4();
+ const projectAId = projectA._id!.toString();
+ const projectBId = projectB._id!.toString();
+
+ await saveConvo(mockCtx, {
+ conversationId,
+ title: 'Bulk Moving',
+ endpoint: EModelEndpoint.openAI,
+ chatProjectId: projectAId,
+ });
+ await methods.bulkSaveConvos([
+ {
+ conversationId,
+ user: mockCtx.userId,
+ endpoint: EModelEndpoint.openAI,
+ chatProjectId: projectBId,
+ },
+ ]);
+
+ const refreshedA = await ChatProject.findById(projectA._id).lean();
+ const refreshedB = await ChatProject.findById(projectB._id).lean();
+ expect(refreshedA?.conversationCount).toBe(0);
+ expect(refreshedB?.conversationCount).toBe(1);
+ });
+
it('should not create a conversation when noUpsert is true and conversation does not exist', async () => {
const nonExistentId = uuidv4();
const result = await saveConvo(
@@ -1170,5 +1322,34 @@ describe('Conversation Operations', () => {
expect(doc?.title).toBe('Updated');
expect(doc?.tenantId).toBe('real-tenant');
});
+
+ it('bulkSaveConvos refreshes stats for cloned project conversations', async () => {
+ const project = await ChatProject.create({
+ user: 'user123',
+ name: 'Bulk Project',
+ conversationCount: 0,
+ lastConversationAt: null,
+ lastConversationId: null,
+ });
+ const conversationId = uuidv4();
+ const createdAt = new Date('2026-01-01T00:00:00.000Z');
+
+ await methods.bulkSaveConvos([
+ {
+ conversationId,
+ user: 'user123',
+ title: 'Cloned Project Chat',
+ endpoint: EModelEndpoint.openAI,
+ chatProjectId: project._id!.toString(),
+ createdAt,
+ updatedAt: createdAt,
+ },
+ ]);
+
+ const refreshedProject = await ChatProject.findById(project._id).lean();
+ expect(refreshedProject?.conversationCount).toBe(1);
+ expect(refreshedProject?.lastConversationId).toBe(conversationId);
+ expect(refreshedProject?.lastConversationAt?.toISOString()).toBe(createdAt.toISOString());
+ });
});
});
diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts
index c46487e519..e65843a1a5 100644
--- a/packages/data-schemas/src/methods/conversation.ts
+++ b/packages/data-schemas/src/methods/conversation.ts
@@ -1,10 +1,15 @@
import type { FilterQuery, Model, SortOrder } from 'mongoose';
import { RetentionMode } from 'librechat-data-provider';
+import { isValidObjectIdString } from '~/utils/objectId';
import { createTempChatExpirationDate } from '~/utils/tempChatRetention';
import { buildRetentionVisibilityFilter, createFallbackRetentionDate } from '~/utils/retention';
import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite';
import logger from '~/config/winston';
-import type { AppConfig, IConversation } from '~/types';
+import type { AppConfig, IChatProjectDocument, IConversation } from '~/types';
+import {
+ refreshChatProjectStatsForUser,
+ updateChatProjectLastConversationForUser,
+} from './chatProject';
import type { MessageMethods } from './message';
import type { DeleteResult } from 'mongoose';
@@ -36,6 +41,7 @@ export interface ConversationMethods {
search?: string;
sortBy?: string;
sortDirection?: string;
+ projectId?: string;
},
): Promise<{ conversations: IConversation[]; nextCursor: string | null }>;
getConvosQueried(
@@ -208,6 +214,38 @@ export function createConversationMethods(
const messages = await getMessages({ conversationId, user: userId }, '_id');
const update: Record = { ...convo, messages, user: userId };
+ const unsetFields: Record = { ...(metadata?.unsetFields ?? {}) };
+
+ if (Object.prototype.hasOwnProperty.call(update, 'chatProjectId') && update.chatProjectId) {
+ const chatProjectId = typeof update.chatProjectId === 'string' ? update.chatProjectId : '';
+ let isValidChatProject = isValidObjectIdString(chatProjectId);
+
+ if (isValidChatProject) {
+ const ChatProject = mongoose.models.ChatProject as Model;
+ const project = await ChatProject.exists({
+ _id: new mongoose.Types.ObjectId(chatProjectId),
+ user: userId,
+ });
+ isValidChatProject = project != null;
+ }
+
+ if (!isValidChatProject) {
+ delete update.chatProjectId;
+ unsetFields.chatProjectId = 1;
+ }
+ }
+
+ const mayChangeProjectMembership =
+ Object.prototype.hasOwnProperty.call(update, 'chatProjectId') ||
+ Object.prototype.hasOwnProperty.call(unsetFields, 'chatProjectId');
+ let previousChatProjectId: string | null = null;
+ if (mayChangeProjectMembership) {
+ const existing = await Conversation.findOne(
+ { conversationId, user: userId },
+ 'chatProjectId',
+ ).lean<{ chatProjectId?: string | null } | null>();
+ previousChatProjectId = existing?.chatProjectId ?? null;
+ }
if (newConversationId) {
update.conversationId = newConversationId;
@@ -248,22 +286,35 @@ export function createConversationMethods(
}
const updateOperation: Record = { $set: update };
- if (metadata?.unsetFields && Object.keys(metadata.unsetFields).length > 0) {
- updateOperation.$unset = metadata.unsetFields;
+ if (Object.keys(unsetFields).length > 0) {
+ updateOperation.$unset = unsetFields;
}
if (createdAtOnInsert) {
updateOperation.$setOnInsert = { createdAt: createdAtOnInsert };
}
- const conversation = await Conversation.findOneAndUpdate(
+ const conversationResult = (await Conversation.findOneAndUpdate(
{ conversationId, user: userId },
updateOperation,
{
new: true,
upsert: metadata?.noUpsert !== true,
+ includeResultMetadata: true,
...(createdAtOnInsert ? { timestamps: false } : {}),
},
- );
+ )) as unknown as {
+ value:
+ | (IConversation & {
+ _id: unknown;
+ $isDefault: (path: string) => boolean;
+ toObject: () => IConversation;
+ })
+ | null;
+ lastErrorObject?: {
+ updatedExisting?: boolean;
+ };
+ };
+ const conversation = conversationResult.value;
if (!conversation) {
logger.debug('[saveConvo] Conversation not found, skipping update');
@@ -283,6 +334,60 @@ export function createConversationMethods(
conversation.isTemporary = false;
}
+ const newChatProjectId = conversation.chatProjectId ?? null;
+ const projectMembershipChanged = previousChatProjectId !== newChatProjectId;
+
+ /**
+ * A chat that moved between projects (e.g. a stale tab re-submitting an
+ * older project id) must fully recompute the stats of the project it left;
+ * the incremental path only ever touches the project it now belongs to.
+ */
+ if (projectMembershipChanged && previousChatProjectId) {
+ await refreshChatProjectStatsForUser(mongoose, userId, previousChatProjectId);
+ }
+
+ if (conversation.chatProjectId) {
+ const isRetentionVisibilityUpdate =
+ typeof update.isTemporary === 'boolean' ||
+ Object.prototype.hasOwnProperty.call(convo, 'expiredAt') ||
+ Object.prototype.hasOwnProperty.call(unsetFields, 'isTemporary') ||
+ Object.prototype.hasOwnProperty.call(unsetFields, 'expiredAt');
+ /**
+ * Saving a conversation that is itself archived or retention-hidden (e.g.
+ * renaming or title generation on an archived project chat) must recompute
+ * stats rather than take the incremental fast path, otherwise the project's
+ * lastConversationAt/Id would point at a chat the project workspace hides.
+ */
+ const isConversationHidden =
+ conversation.isArchived === true ||
+ conversation.isTemporary === true ||
+ (conversation.expiredAt != null &&
+ new Date(conversation.expiredAt).getTime() <= Date.now());
+ /**
+ * A move into this project (projectMembershipChanged) also needs a full
+ * refresh: the incremental path only bumps the count for brand-new inserts,
+ * so a pre-existing chat joining the project would otherwise be uncounted.
+ */
+ const shouldRefreshProjectStats =
+ projectMembershipChanged ||
+ typeof update.isArchived === 'boolean' ||
+ Object.prototype.hasOwnProperty.call(unsetFields, 'isArchived') ||
+ isRetentionVisibilityUpdate ||
+ isConversationHidden;
+
+ if (shouldRefreshProjectStats) {
+ await refreshChatProjectStatsForUser(mongoose, userId, conversation.chatProjectId);
+ } else {
+ await updateChatProjectLastConversationForUser(
+ mongoose,
+ userId,
+ conversation.chatProjectId,
+ conversation,
+ conversationResult.lastErrorObject?.updatedExisting === false,
+ );
+ }
+ }
+
return conversation.toObject();
} catch (error) {
logger.error('[saveConvo] Error saving conversation', error);
@@ -299,19 +404,113 @@ export function createConversationMethods(
async function bulkSaveConvos(conversations: Array>) {
try {
const Conversation = mongoose.models.Conversation as Model;
- const bulkOps = conversations.map((convo) => ({
- updateOne: {
- filter: {
- conversationId: convo.conversationId,
+ const ChatProject = mongoose.models.ChatProject as Model;
+
+ /**
+ * Validate project ownership before persisting (mirrors saveConvo). Bulk
+ * paths like import/duplicate/fork can carry a chatProjectId that does not
+ * belong to the user; persisting it would create an orphan assignment that
+ * is hidden from both the project and the unassigned filter.
+ */
+ const candidatePairs = new Map();
+ for (const convo of conversations) {
+ if (
+ typeof convo.user === 'string' &&
+ typeof convo.chatProjectId === 'string' &&
+ isValidObjectIdString(convo.chatProjectId)
+ ) {
+ candidatePairs.set(`${convo.user}:${convo.chatProjectId}`, {
user: convo.user,
+ projectId: convo.chatProjectId,
+ });
+ }
+ }
+
+ const ownedProjects = new Set();
+ if (candidatePairs.size > 0) {
+ const owned = await ChatProject.find({
+ $or: [...candidatePairs.values()].map(({ user, projectId }) => ({
+ _id: new mongoose.Types.ObjectId(projectId),
+ user,
+ })),
+ })
+ .select('_id user')
+ .lean string }; user: string }>>();
+ for (const project of owned) {
+ ownedProjects.add(`${project.user}:${project._id.toString()}`);
+ }
+ }
+
+ /**
+ * Capture each conversation's existing project so a bulk move (import that
+ * overwrites an existing (user, conversationId), duplicate/fork) also refreshes
+ * the project it leaves, not just the one it joins. One batched read keeps this
+ * O(1) in round-trips regardless of batch size.
+ */
+ const previousProjectByConversation = new Map();
+ const conversationPairs = conversations
+ .filter((c) => typeof c.user === 'string' && typeof c.conversationId === 'string')
+ .map((c) => ({ user: c.user as string, conversationId: c.conversationId as string }));
+ if (conversationPairs.length > 0) {
+ const existing = await Conversation.find(
+ { $or: conversationPairs },
+ 'user conversationId chatProjectId',
+ ).lean>();
+ for (const doc of existing) {
+ if (doc.chatProjectId) {
+ previousProjectByConversation.set(
+ `${doc.user}:${doc.conversationId}`,
+ doc.chatProjectId,
+ );
+ }
+ }
+ }
+
+ const affectedProjectStats = new Map();
+ const bulkOps = conversations.map((convo) => {
+ const sanitized = { ...convo };
+ if (typeof sanitized.user === 'string' && typeof sanitized.chatProjectId === 'string') {
+ if (ownedProjects.has(`${sanitized.user}:${sanitized.chatProjectId}`)) {
+ affectedProjectStats.set(`${sanitized.user}:${sanitized.chatProjectId}`, {
+ user: sanitized.user,
+ projectId: sanitized.chatProjectId,
+ });
+ } else {
+ sanitized.chatProjectId = null;
+ }
+ }
+ if (typeof sanitized.user === 'string' && typeof sanitized.conversationId === 'string') {
+ const previousProjectId = previousProjectByConversation.get(
+ `${sanitized.user}:${sanitized.conversationId}`,
+ );
+ const newProjectId =
+ typeof sanitized.chatProjectId === 'string' ? sanitized.chatProjectId : null;
+ if (previousProjectId && previousProjectId !== newProjectId) {
+ affectedProjectStats.set(`${sanitized.user}:${previousProjectId}`, {
+ user: sanitized.user,
+ projectId: previousProjectId,
+ });
+ }
+ }
+ return {
+ updateOne: {
+ filter: {
+ conversationId: sanitized.conversationId,
+ user: sanitized.user,
+ },
+ update: sanitized,
+ upsert: true,
+ timestamps: false,
},
- update: convo,
- upsert: true,
- timestamps: false,
- },
- }));
+ };
+ });
const result = await tenantSafeBulkWrite(Conversation, bulkOps);
+ await Promise.all(
+ [...affectedProjectStats.values()].map(({ user, projectId }) =>
+ refreshChatProjectStatsForUser(mongoose, user, projectId),
+ ),
+ );
return result;
} catch (error) {
logger.error('[bulkSaveConvos] Error saving conversations in bulk', error);
@@ -332,6 +531,7 @@ export function createConversationMethods(
search,
sortBy = 'updatedAt',
sortDirection = 'desc',
+ projectId,
}: {
cursor?: string | null;
limit?: number;
@@ -340,6 +540,7 @@ export function createConversationMethods(
search?: string;
sortBy?: string;
sortDirection?: string;
+ projectId?: string;
} = {},
) {
const Conversation = mongoose.models.Conversation as Model;
@@ -356,6 +557,14 @@ export function createConversationMethods(
filters.push({ tags: { $in: tags } } as FilterQuery);
}
+ if (projectId === 'unassigned') {
+ filters.push({
+ $or: [{ chatProjectId: null }, { chatProjectId: { $exists: false } }],
+ } as FilterQuery);
+ } else if (projectId) {
+ filters.push({ chatProjectId: projectId } as FilterQuery);
+ }
+
filters.push(getVisibleConversationRetentionFilter());
if (search) {
@@ -431,7 +640,7 @@ export function createConversationMethods(
const convos = await Conversation.find(query)
.select(
- 'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL',
+ 'conversationId endpoint title createdAt updatedAt user model agent_id assistant_id spec iconURL chatProjectId',
)
.sort(sortObj)
.limit(limit + 1)
@@ -538,8 +747,15 @@ export function createConversationMethods(
const Conversation = mongoose.models.Conversation as Model;
const { deleteMessages } = getMessageMethods();
const userFilter = { ...filter, user };
- const conversations = await Conversation.find(userFilter).select('conversationId');
+ const conversations = await Conversation.find(userFilter).select(
+ 'conversationId chatProjectId',
+ );
const conversationIds = conversations.map((c) => c.conversationId);
+ const projectIds = new Set(
+ conversations
+ .map((conversation) => conversation.chatProjectId)
+ .filter((projectId): projectId is string => Boolean(projectId)),
+ );
if (!conversationIds.length) {
throw new Error('Conversation not found or already deleted.');
@@ -552,6 +768,12 @@ export function createConversationMethods(
user,
});
+ await Promise.all(
+ [...projectIds].map((projectId) =>
+ refreshChatProjectStatsForUser(mongoose, user, projectId),
+ ),
+ );
+
return { ...deleteConvoResult, messages: deleteMessagesResult };
} catch (error) {
logger.error('[deleteConvos] Error deleting conversations and messages', error);
diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts
index 28d3effab7..0663a7a819 100644
--- a/packages/data-schemas/src/methods/index.ts
+++ b/packages/data-schemas/src/methods/index.ts
@@ -32,6 +32,17 @@ import { createPresetMethods, type PresetMethods } from './preset';
import { createConversationTagMethods, type ConversationTagMethods } from './conversationTag';
import { createMessageMethods, type MessageMethods } from './message';
import { createConversationMethods, type ConversationMethods } from './conversation';
+import { createChatProjectMethods, type ChatProjectMethods } from './chatProject';
+export type {
+ AssignConversationToProjectResult,
+ ChatProjectSortBy,
+ ChatProjectSortDirection,
+ CreateChatProjectInput,
+ DeleteChatProjectResult,
+ ListChatProjectsOptions,
+ ListChatProjectsResult,
+ UpdateChatProjectInput,
+} from './chatProject';
/* Tier 3 — Complex (heavier injection) */
import {
createTxMethods,
@@ -92,6 +103,7 @@ export type AllMethods = UserMethods &
ConversationTagMethods &
MessageMethods &
ConversationMethods &
+ ChatProjectMethods &
TxMethods &
TransactionMethods &
SpendTokensMethods &
@@ -218,6 +230,7 @@ export function createMethods(
...createConversationTagMethods(mongoose),
...messageMethods,
...conversationMethods,
+ ...createChatProjectMethods(mongoose),
/* Tier 3 */
...txMethods,
...transactionMethods,
@@ -257,6 +270,7 @@ export type {
ConversationTagMethods,
MessageMethods,
ConversationMethods,
+ ChatProjectMethods,
TxMethods,
TransactionMethods,
SpendTokensMethods,
diff --git a/packages/data-schemas/src/models/chatProject.ts b/packages/data-schemas/src/models/chatProject.ts
new file mode 100644
index 0000000000..4a4dc41b5f
--- /dev/null
+++ b/packages/data-schemas/src/models/chatProject.ts
@@ -0,0 +1,11 @@
+import chatProjectSchema from '~/schema/chatProject';
+import { applyTenantIsolation } from '~/models/plugins/tenantIsolation';
+import type { IChatProjectDocument } from '~/types';
+
+export function createChatProjectModel(mongoose: typeof import('mongoose')) {
+ applyTenantIsolation(chatProjectSchema);
+ return (
+ mongoose.models.ChatProject ||
+ mongoose.model('ChatProject', chatProjectSchema, 'chatprojects')
+ );
+}
diff --git a/packages/data-schemas/src/models/index.ts b/packages/data-schemas/src/models/index.ts
index dcd88f58d2..797a658143 100644
--- a/packages/data-schemas/src/models/index.ts
+++ b/packages/data-schemas/src/models/index.ts
@@ -3,6 +3,7 @@ import { createTokenModel } from './token';
import { createSessionModel } from './session';
import { createBalanceModel } from './balance';
import { createConversationModel } from './convo';
+import { createChatProjectModel } from './chatProject';
import { createMessageModel } from './message';
import { createAgentModel } from './agent';
import { createAgentApiKeyModel } from './agentApiKey';
@@ -41,6 +42,7 @@ export function createModels(mongoose: typeof import('mongoose')) {
Session: createSessionModel(mongoose),
Balance: createBalanceModel(mongoose),
Conversation: createConversationModel(mongoose),
+ ChatProject: createChatProjectModel(mongoose),
Message: createMessageModel(mongoose),
Agent: createAgentModel(mongoose),
AgentApiKey: createAgentApiKeyModel(mongoose),
diff --git a/packages/data-schemas/src/schema/chatProject.ts b/packages/data-schemas/src/schema/chatProject.ts
new file mode 100644
index 0000000000..4cd9edccee
--- /dev/null
+++ b/packages/data-schemas/src/schema/chatProject.ts
@@ -0,0 +1,50 @@
+import { Schema } from 'mongoose';
+import type { IChatProjectDocument } from '~/types';
+
+const chatProjectSchema = new Schema(
+ {
+ name: {
+ type: String,
+ required: true,
+ trim: true,
+ maxlength: 100,
+ index: true,
+ },
+ description: {
+ type: String,
+ default: '',
+ trim: true,
+ maxlength: 1000,
+ },
+ user: {
+ type: String,
+ required: true,
+ index: true,
+ },
+ conversationCount: {
+ type: Number,
+ default: 0,
+ min: 0,
+ },
+ lastConversationAt: {
+ type: Date,
+ default: null,
+ index: true,
+ },
+ lastConversationId: {
+ type: String,
+ default: null,
+ },
+ tenantId: {
+ type: String,
+ index: true,
+ },
+ },
+ { timestamps: true },
+);
+
+chatProjectSchema.index({ user: 1, name: 1, _id: 1 });
+chatProjectSchema.index({ user: 1, createdAt: -1, _id: -1 });
+chatProjectSchema.index({ user: 1, lastConversationAt: -1, _id: -1 });
+
+export default chatProjectSchema;
diff --git a/packages/data-schemas/src/schema/convo.ts b/packages/data-schemas/src/schema/convo.ts
index 24915af8a5..aad324fa7a 100644
--- a/packages/data-schemas/src/schema/convo.ts
+++ b/packages/data-schemas/src/schema/convo.ts
@@ -34,6 +34,11 @@ const convoSchema: Schema = new Schema(
default: [],
meiliIndex: true,
},
+ chatProjectId: {
+ type: String,
+ default: null,
+ index: true,
+ },
files: {
type: [String],
},
@@ -51,6 +56,8 @@ const convoSchema: Schema = new Schema(
convoSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 });
convoSchema.index({ createdAt: 1, updatedAt: 1 });
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 });
convoSchema.index({ user: 1, isTemporary: 1, expiredAt: 1 });
// index for MeiliSearch sync operations
diff --git a/packages/data-schemas/src/schema/index.ts b/packages/data-schemas/src/schema/index.ts
index 2a5eff658b..3a796077cb 100644
--- a/packages/data-schemas/src/schema/index.ts
+++ b/packages/data-schemas/src/schema/index.ts
@@ -6,6 +6,7 @@ export { default as assistantSchema } from './assistant';
export { default as balanceSchema } from './balance';
export { default as bannerSchema } from './banner';
export { default as categoriesSchema } from './categories';
+export { default as chatProjectSchema } from './chatProject';
export { default as conversationTagSchema } from './conversationTag';
export { default as convoSchema } from './convo';
export { default as fileSchema } from './file';
diff --git a/packages/data-schemas/src/types/chatProject.ts b/packages/data-schemas/src/types/chatProject.ts
new file mode 100644
index 0000000000..8486ec6f2b
--- /dev/null
+++ b/packages/data-schemas/src/types/chatProject.ts
@@ -0,0 +1,16 @@
+import type { Document, Types } from 'mongoose';
+
+export interface IChatProject {
+ _id?: Types.ObjectId;
+ name: string;
+ description?: string;
+ user: string;
+ conversationCount: number;
+ lastConversationAt?: Date | null;
+ lastConversationId?: string | null;
+ createdAt?: Date;
+ updatedAt?: Date;
+ tenantId?: string;
+}
+
+export interface IChatProjectDocument extends Omit, Document {}
diff --git a/packages/data-schemas/src/types/convo.ts b/packages/data-schemas/src/types/convo.ts
index 5cd828e9d6..abced64424 100644
--- a/packages/data-schemas/src/types/convo.ts
+++ b/packages/data-schemas/src/types/convo.ts
@@ -42,6 +42,7 @@ export interface IConversation extends Document {
greeting?: string;
spec?: string;
tags?: string[];
+ chatProjectId?: string | null;
tools?: string[];
maxContextTokens?: number;
max_tokens?: number;
diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts
index f0b1fee5ec..e00a016a64 100644
--- a/packages/data-schemas/src/types/index.ts
+++ b/packages/data-schemas/src/types/index.ts
@@ -5,6 +5,7 @@ export * from './app';
export * from './user';
export * from './token';
export * from './convo';
+export * from './chatProject';
export * from './session';
export * from './balance';
export * from './banner';