+
+
+ }
+ ariaLabel={localize('com_ui_select_project')}
+ searchPlaceholder={localize('com_ui_search_projects')}
+ selectPlaceholder={localize('com_ui_select_project')}
+ isCollapsed={false}
+ showCarat={true}
+ placement="bottom-start"
+ portal={false}
+ matchTriggerWidth={true}
+ containerClassName="w-full px-0"
+ className="h-10 w-full justify-start gap-2 rounded-xl border border-border-light bg-surface-tertiary px-3 text-sm text-text-primary hover:bg-surface-hover"
+ />
+ {hasNextPage ? (
+
+ ) : null}
+
+
-
-
+
{assignConversation.isLoading ? : localize('com_ui_save')}
diff --git a/client/src/components/Conversations/ProjectsSection.tsx b/client/src/components/Conversations/ProjectsSection.tsx
index 5df242591c7..ac804205352 100644
--- a/client/src/components/Conversations/ProjectsSection.tsx
+++ b/client/src/components/Conversations/ProjectsSection.tsx
@@ -1,9 +1,17 @@
-import { memo, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
+import { memo, useCallback, useId, useMemo, useState } from 'react';
+import { useRecoilValue } from 'recoil';
import * as Ariakit from '@ariakit/react';
-import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
-import { useRecoilCallback, useRecoilValue } from 'recoil';
-import { useNavigate, useLocation } from 'react-router-dom';
+import { Constants, QueryKeys } from 'librechat-data-provider';
+import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
+import {
+ Button,
+ Spinner,
+ TooltipAnchor,
+ DropdownPopup,
+ NewChatIcon,
+ buttonVariants,
+} from '@librechat/client';
import {
ChevronDown,
ChevronRight,
@@ -14,34 +22,20 @@ import {
Pencil,
Trash2,
} from 'lucide-react';
-import {
- Button,
- Input,
- Spinner,
- OGDialog,
- OGDialogClose,
- OGDialogTitle,
- OGDialogHeader,
- OGDialogContent,
- TooltipAnchor,
- DropdownPopup,
- NewChatIcon,
- buttonVariants,
- useToastContext,
-} from '@librechat/client';
import type { TChatProject, TConversation } from 'librechat-data-provider';
+import type { MouseEvent } from 'react';
import type { MenuItemProps } from '~/common';
import {
useProjectsInfiniteQuery,
useActiveJobs,
useConversationsInfiniteQuery,
- useUpdateProjectMutation,
- useDeleteProjectMutation,
} from '~/data-provider';
import ProjectCreateDialog from '~/components/Projects/ProjectCreateDialog';
+import ProjectDeleteDialog from '~/components/Projects/ProjectDeleteDialog';
+import ProjectEditDialog from '~/components/Projects/ProjectEditDialog';
import { useLocalize, useLocalStorage, useNewConvo } from '~/hooks';
import { clearMessagesCache, cn } from '~/utils';
-import { NotificationSeverity } from '~/common';
+import { Collapse } from '~/components/ui';
import Convo from './Convo';
import store from '~/store';
@@ -53,152 +47,18 @@ const iconButtonClassName = cn(
'shrink-0',
);
-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')}
-
-
-
-
-
- {localize('com_ui_cancel')}
-
-
-
- {updateProject.isLoading ? : localize('com_ui_save')}
-
-
-
-
- );
-}
-
-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 })}
-
-
-
-
- {localize('com_ui_cancel')}
-
-
-
- {deleteProject.isLoading ? : localize('com_ui_delete')}
-
-
-
-
- );
-}
-
const noop = () => {};
type ProjectChatsInlineProps = {
projectId: string;
+ expanded: boolean;
toggleNav: () => void;
onShowAll: () => void;
};
const ProjectChatsInline = memo(function ProjectChatsInline({
projectId,
+ expanded,
toggleNav,
onShowAll,
}: ProjectChatsInlineProps) {
@@ -208,9 +68,11 @@ const ProjectChatsInline = memo(function ProjectChatsInline({
() => new Set(activeJobsData?.activeJobIds ?? []),
[activeJobsData?.activeJobIds],
);
+ /** Collapse keeps its children mounted, so without this every project row in
+ * the sidebar would fetch its chats on load whether or not it is open. */
const { data, isLoading } = useConversationsInfiniteQuery(
{ projectId, sortBy: 'updatedAt', sortDirection: 'desc' },
- { staleTime: 30000, cacheTime: 300000 },
+ { staleTime: 30000, cacheTime: 300000, enabled: expanded },
);
const conversations = useMemo(
@@ -271,40 +133,64 @@ type ProjectItemProps = {
project: TChatProject;
toggleNav: () => void;
defaultExpanded: boolean;
+ isActive: boolean;
};
const ProjectItem = memo(
- function ProjectItem({ project, toggleNav, defaultExpanded }: ProjectItemProps) {
+ function ProjectItem({ project, toggleNav, defaultExpanded, isActive }: ProjectItemProps) {
const localize = useLocalize();
const navigate = useNavigate();
+ const location = useLocation();
+ const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const { newConversation } = useNewConvo();
- const getCurrentConversationId = useRecoilCallback(
- ({ snapshot }) =>
- async () => {
- const conversation = await snapshot.getPromise(store.conversationByIndex(0));
- return conversation?.conversationId;
- },
- [],
- );
+ const conversationId = useRecoilValue(store.conversationIdByIndex(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 projectChatPath = `/c/${Constants.NEW_CONVO}?projectId=${encodeURIComponent(project._id)}`;
const openProject = useCallback(() => {
navigate(`/projects/${project._id}`);
toggleNav();
}, [navigate, project._id, toggleNav]);
- const startChat = useCallback(async () => {
- const conversationId = await getCurrentConversationId();
- clearMessagesCache(queryClient, conversationId);
- queryClient.invalidateQueries([QueryKeys.messages]);
- newConversation({ template: { chatProjectId: project._id } });
- toggleNav();
- }, [getCurrentConversationId, newConversation, project._id, queryClient, toggleNav]);
+ const startChat = useCallback(
+ (event: MouseEvent) => {
+ if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
+ return;
+ }
+ event.preventDefault();
+ clearMessagesCache(queryClient, conversationId);
+ queryClient.invalidateQueries([QueryKeys.messages]);
+ /** `navigate()` defers search-param updates; ChatRoute then sees a
+ * project-scoped draft against an unscoped `/c/new` and wipes it.
+ * Commit `?projectId` in the same turn, the same way the landing chip does. */
+ if (location.pathname === `/c/${Constants.NEW_CONVO}`) {
+ const nextParams = new URLSearchParams(searchParams);
+ nextParams.set('projectId', project._id);
+ setSearchParams(nextParams, { replace: true, flushSync: true });
+ } else {
+ navigate(projectChatPath);
+ }
+ newConversation({ template: { chatProjectId: project._id } });
+ toggleNav();
+ },
+ [
+ conversationId,
+ location.pathname,
+ navigate,
+ newConversation,
+ project._id,
+ projectChatPath,
+ queryClient,
+ searchParams,
+ setSearchParams,
+ toggleNav,
+ ],
+ );
const menuItems = useMemo(
() => [
@@ -316,7 +202,7 @@ const ProjectItem = memo(
},
{
id: `${menuId}-rename`,
- label: localize('com_ui_rename'),
+ label: localize('com_ui_edit_project'),
icon: ,
onClick: () => setIsRenameOpen(true),
},
@@ -332,36 +218,54 @@ const ProjectItem = memo(
return (
-
+
event.preventDefault()}
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
aria-label={project.name}
- className="flex min-w-0 flex-1 items-center gap-1.5 rounded-lg py-1.5 pl-1.5 pr-14 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-text-primary"
+ className="flex min-w-0 flex-1 items-center gap-2 rounded-lg py-1.5 pl-1.5 pr-16 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-text-primary"
>
- {project.name}
+ {project.name}
-
+
-
+
}
/>
@@ -388,14 +289,15 @@ const ProjectItem = memo(
/>
- {expanded && (
+
- )}
-
+
+
);
@@ -403,8 +305,11 @@ const ProjectItem = memo(
(prevProps, nextProps) =>
prevProps.project._id === nextProps.project._id &&
prevProps.project.name === nextProps.project.name &&
+ prevProps.project.description === nextProps.project.description &&
+ prevProps.project.conversationCount === nextProps.project.conversationCount &&
prevProps.project.updatedAt === nextProps.project.updatedAt &&
prevProps.defaultExpanded === nextProps.defaultExpanded &&
+ prevProps.isActive === nextProps.isActive &&
prevProps.toggleNav === nextProps.toggleNav,
);
@@ -418,6 +323,7 @@ interface ProjectsSectionProps {
const ProjectsSection = ({ toggleNav, isAuthenticated }: ProjectsSectionProps) => {
const localize = useLocalize();
const navigate = useNavigate();
+ const location = useLocation();
const [storedExpanded, setStoredExpanded] = useLocalStorage('projectsSectionExpanded', true);
const [hasToggledSection, setHasToggledSection] = useLocalStorage(
'projectsSectionToggled',
@@ -425,7 +331,11 @@ const ProjectsSection = ({ toggleNav, isAuthenticated }: ProjectsSectionProps) =
);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const conversation = useRecoilValue(store.conversationByIndex(0));
- const activeProjectId = conversation?.chatProjectId ?? null;
+ const conversationProjectId = conversation?.chatProjectId ?? null;
+ /** A project workspace route wins so a leftover conversation scope cannot
+ * highlight a second row at the same time. */
+ const routeProjectId = /^\/projects\/([^/]+)$/.exec(location.pathname)?.[1] ?? null;
+ const highlightedProjectId = routeProjectId ?? conversationProjectId;
const { data, isLoading } = useProjectsInfiniteQuery(
{ sortBy: 'lastConversationAt', sortDirection: 'desc', limit: 25 },
@@ -437,8 +347,8 @@ const ProjectsSection = ({ toggleNav, isAuthenticated }: ProjectsSectionProps) =
/**
* Collapse the section by default for users with no projects who have never
- * toggled it, to keep the sidebar compact. An explicit toggle — or a collapse
- * set before this default existed (stored === false) — is always respected.
+ * toggled it, to keep the sidebar compact. An explicit toggle, or a collapse
+ * set before this default existed (stored === false), is always respected.
*/
const respectStoredExpanded = hasToggledSection || storedExpanded === false;
const isExpanded = respectStoredExpanded ? storedExpanded : isLoading || projects.length > 0;
@@ -463,7 +373,7 @@ const ProjectsSection = ({ toggleNav, isAuthenticated }: ProjectsSectionProps) =
type="button"
variant="ghost"
onClick={() => setIsCreateOpen(true)}
- className="flex h-auto w-full justify-start gap-2 rounded-lg px-2 py-1.5 text-sm font-normal text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary"
+ className="flex h-9 w-full justify-start gap-2 rounded-lg px-2 text-sm font-normal text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary"
>
{localize('com_ui_new_project')}
@@ -478,7 +388,8 @@ const ProjectsSection = ({ toggleNav, isAuthenticated }: ProjectsSectionProps) =
key={project._id}
project={project}
toggleNav={toggleNav}
- defaultExpanded={project._id === activeProjectId}
+ defaultExpanded={project._id === highlightedProjectId}
+ isActive={project._id === highlightedProjectId}
/>
))}
{hasMore && (
@@ -487,7 +398,7 @@ const ProjectsSection = ({ toggleNav, isAuthenticated }: ProjectsSectionProps) =
type="button"
variant="ghost"
onClick={openProjects}
- className="flex h-auto w-full justify-start gap-2 rounded-lg px-2 py-1.5 text-xs font-medium text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary"
+ className="flex h-8 w-full justify-start rounded-lg px-2 text-xs font-medium text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary"
>
{localize('com_ui_all_projects')}
@@ -503,14 +414,14 @@ const ProjectsSection = ({ toggleNav, isAuthenticated }: ProjectsSectionProps) =
return (
-
+
{
setStoredExpanded(!isExpanded);
setHasToggledSection(true);
}}
className="group flex min-w-0 flex-1 items-center gap-1 rounded-lg px-1 py-2 text-xs font-bold text-text-secondary outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-text-primary"
- type="button"
aria-expanded={isExpanded}
>
{localize('com_ui_projects')}
@@ -528,33 +439,20 @@ const ProjectsSection = ({ toggleNav, isAuthenticated }: ProjectsSectionProps) =
}
/>
- setIsCreateOpen(true)}
- >
-
-
- }
- />
- {isExpanded && (
-
+
+
{renderProjectsBody()}
- )}
+
{
const { navigateToConvo } = useNavigateToConvo();
const localize = useLocalize();
+ const conversationId = conversation.conversationId ?? '';
const title = conversation.title || localize('com_ui_untitled');
const updatedAt = conversation.updatedAt || conversation.createdAt;
const formattedDate = updatedAt ? new Date(updatedAt).toLocaleString() : '';
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
- navigateToConvo(conversation)}
- >
-
-
-
-
- {title}
- {formattedDate}
-
- {isGenerating && (
-
+
+ >
+ navigateToConvo(conversation)}
+ >
+
+
+
+
+ {title}
+
+ {formattedDate}
+
+
+ {isGenerating ? (
+
+ ) : null}
+
+ {conversationId ? (
+
+ ) : null}
+
);
},
- areConversationRenderPropsEqual,
+ (prevProps, nextProps) =>
+ areConversationListItemFieldsEqual(prevProps.conversation, nextProps.conversation) &&
+ prevProps.isGenerating === nextProps.isGenerating,
);
ConversationRow.displayName = 'ProjectWorkspaceConversationRow';
@@ -194,7 +219,7 @@ const ProjectChatList = ({
if (item.type === 'empty') {
return (
- {emptyLabel}
+ {emptyLabel}
);
}
@@ -202,7 +227,9 @@ const ProjectChatList = ({
if (item.type === 'date') {
return (
-
+
+
+
);
}
@@ -234,9 +261,7 @@ const ProjectChatList = ({
);
return (
-
+
{({ width, height }) => (
void;
+};
+
+const noop = () => {};
+
+function ProjectChatOptions({ conversation, isMenuOpen, setIsMenuOpen }: ProjectChatOptionsProps) {
+ const localize = useLocalize();
+ const { showToast } = useToastContext();
+ const menuId = useId();
+ const menuButtonRef = useRef(null);
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
+ const [showProjectDialog, setShowProjectDialog] = useState(false);
+
+ const conversationId = conversation.conversationId ?? '';
+ const chatProjectId = conversation.chatProjectId ?? null;
+ const assignConversationToProject = useAssignConversationToProjectMutation();
+
+ const menuItems = useMemo(() => {
+ if (!conversationId) {
+ return [];
+ }
+
+ return [
+ {
+ label: localize('com_ui_change_project'),
+ onClick: () => setShowProjectDialog(true),
+ icon: ,
+ /** Hiding the menu here restores focus to the trigger, which the dialog
+ * mounting alongside it reads as an outside interaction and closes on.
+ * Both dialogs receive setIsMenuOpen and close the menu themselves. */
+ hideOnClick: false,
+ render: (props) => ,
+ },
+ {
+ label: localize('com_ui_remove_from_project'),
+ show: Boolean(chatProjectId),
+ onClick: () => {
+ assignConversationToProject.mutate(
+ { conversationId, projectId: null },
+ {
+ onSuccess: () => {
+ setIsMenuOpen(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,
+ });
+ },
+ },
+ );
+ },
+ hideOnClick: false,
+ icon: assignConversationToProject.isLoading ? (
+
+ ) : (
+
+ ),
+ },
+ {
+ label: localize('com_ui_delete'),
+ onClick: () => setShowDeleteDialog(true),
+ hideOnClick: false,
+ render: (props) => ,
+ icon: ,
+ },
+ ];
+ }, [
+ assignConversationToProject,
+ chatProjectId,
+ conversationId,
+ localize,
+ setIsMenuOpen,
+ showToast,
+ ]);
+
+ return (
+ <>
+
+
+
+ }
+ items={menuItems}
+ />
+ {showProjectDialog ? (
+
+ ) : null}
+ {showDeleteDialog ? (
+
+ ) : null}
+ >
+ );
+}
+
+export default memo(ProjectChatOptions);
diff --git a/client/src/components/Projects/ProjectCreateDialog.tsx b/client/src/components/Projects/ProjectCreateDialog.tsx
index faf0fc572c2..9c60fb04032 100644
--- a/client/src/components/Projects/ProjectCreateDialog.tsx
+++ b/client/src/components/Projects/ProjectCreateDialog.tsx
@@ -7,6 +7,10 @@ import {
type MutableRefObject,
type ReactNode,
} from 'react';
+import {
+ MAX_CHAT_PROJECT_NAME_LENGTH,
+ MAX_CHAT_PROJECT_DESCRIPTION_LENGTH,
+} from 'librechat-data-provider';
import {
Button,
Input,
@@ -14,6 +18,7 @@ import {
OGDialog,
OGDialogTemplate,
Spinner,
+ Textarea,
useToastContext,
} from '@librechat/client';
import type { TChatProject } from 'librechat-data-provider';
@@ -39,6 +44,7 @@ export default function ProjectCreateDialog({
const formId = useId();
const inputRef = useRef(null);
const [name, setName] = useState('');
+ const [description, setDescription] = useState('');
const createProject = useCreateProjectMutation();
const { showToast } = useToastContext();
@@ -50,10 +56,15 @@ export default function ProjectCreateDialog({
return () => cancelAnimationFrame(frameId);
}, [open]);
+ const resetForm = () => {
+ setName('');
+ setDescription('');
+ };
+
const handleOpenChange = (nextOpen: boolean) => {
onOpenChange(nextOpen);
if (!nextOpen && !createProject.isLoading) {
- setName('');
+ resetForm();
}
};
@@ -65,8 +76,12 @@ export default function ProjectCreateDialog({
}
try {
- const project = await createProject.mutateAsync({ name: trimmedName });
- setName('');
+ const trimmedDescription = description.trim();
+ const project = await createProject.mutateAsync({
+ name: trimmedName,
+ ...(trimmedDescription ? { description: trimmedDescription } : {}),
+ });
+ resetForm();
onOpenChange(false);
onCreated?.(project);
} catch {
@@ -82,21 +97,43 @@ export default function ProjectCreateDialog({
{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={
@@ -106,6 +143,7 @@ export default function ProjectCreateDialog({
variant="submit"
disabled={!name.trim() || createProject.isLoading}
aria-label={localize('com_ui_create_project')}
+ className="active:scale-[0.96]"
>
{createProject.isLoading ? (
diff --git a/client/src/components/Projects/ProjectDeleteDialog.tsx b/client/src/components/Projects/ProjectDeleteDialog.tsx
new file mode 100644
index 00000000000..9c8ee49aa02
--- /dev/null
+++ b/client/src/components/Projects/ProjectDeleteDialog.tsx
@@ -0,0 +1,73 @@
+import { useNavigate, useLocation } from 'react-router-dom';
+import {
+ Button,
+ Spinner,
+ OGDialog,
+ OGDialogClose,
+ OGDialogTitle,
+ OGDialogHeader,
+ OGDialogContent,
+ useToastContext,
+} from '@librechat/client';
+import type { TChatProject } from 'librechat-data-provider';
+import { useDeleteProjectMutation } from '~/data-provider';
+import { NotificationSeverity } from '~/common';
+import { useLocalize } from '~/hooks';
+
+type ProjectDeleteDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ project: TChatProject;
+};
+
+export default function ProjectDeleteDialog({
+ open,
+ onOpenChange,
+ project,
+}: ProjectDeleteDialogProps) {
+ 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 })}
+
+
+
+
+ {localize('com_ui_cancel')}
+
+
+
+ {deleteProject.isLoading ? : localize('com_ui_delete')}
+
+
+
+
+ );
+}
diff --git a/client/src/components/Projects/ProjectEditDialog.tsx b/client/src/components/Projects/ProjectEditDialog.tsx
new file mode 100644
index 00000000000..829749fcdd8
--- /dev/null
+++ b/client/src/components/Projects/ProjectEditDialog.tsx
@@ -0,0 +1,138 @@
+import { useEffect, useId, useRef, useState, type FormEvent } from 'react';
+import {
+ MAX_CHAT_PROJECT_NAME_LENGTH,
+ MAX_CHAT_PROJECT_DESCRIPTION_LENGTH,
+} from 'librechat-data-provider';
+import {
+ Button,
+ Input,
+ Label,
+ OGDialog,
+ OGDialogTemplate,
+ Spinner,
+ Textarea,
+ useToastContext,
+} from '@librechat/client';
+import type { TChatProject } from 'librechat-data-provider';
+import { useUpdateProjectMutation } from '~/data-provider';
+import { NotificationSeverity } from '~/common';
+import { useLocalize } from '~/hooks';
+
+type ProjectEditDialogProps = {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ project: TChatProject;
+};
+
+export default function ProjectEditDialog({ open, onOpenChange, project }: ProjectEditDialogProps) {
+ const localize = useLocalize();
+ const formId = useId();
+ const inputRef = useRef(null);
+ const [name, setName] = useState(project.name);
+ const [description, setDescription] = useState(project.description ?? '');
+ const [wasOpen, setWasOpen] = useState(open);
+ const updateProject = useUpdateProjectMutation();
+ const { showToast } = useToastContext();
+
+ if (open !== wasOpen) {
+ setWasOpen(open);
+ if (open) {
+ setName(project.name);
+ setDescription(project.description ?? '');
+ }
+ }
+
+ useEffect(() => {
+ if (!open) {
+ return;
+ }
+ const frameId = requestAnimationFrame(() => inputRef.current?.focus());
+ return () => cancelAnimationFrame(frameId);
+ }, [open]);
+
+ const trimmedName = name.trim();
+ const trimmedDescription = description.trim();
+ const isUnchanged =
+ trimmedName === project.name && trimmedDescription === (project.description ?? '').trim();
+
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ if (!trimmedName || isUnchanged || updateProject.isLoading) {
+ return;
+ }
+
+ updateProject.mutate(
+ {
+ projectId: project._id,
+ name: trimmedName,
+ description: trimmedDescription,
+ },
+ {
+ onSuccess: () => onOpenChange(false),
+ onError: () =>
+ showToast({
+ message: localize('com_ui_project_rename_error'),
+ severity: NotificationSeverity.ERROR,
+ showIcon: true,
+ }),
+ },
+ );
+ };
+
+ return (
+
+
+
+
+ setName(event.target.value)}
+ maxLength={MAX_CHAT_PROJECT_NAME_LENGTH}
+ className="w-full"
+ />
+
+
+
+
+
+ }
+ buttons={
+
+ {updateProject.isLoading ? : localize('com_ui_save')}
+
+ }
+ />
+
+ );
+}
diff --git a/client/src/components/Projects/ProjectWorkspace.tsx b/client/src/components/Projects/ProjectWorkspace.tsx
index e904029dff4..2f5a0fdd276 100644
--- a/client/src/components/Projects/ProjectWorkspace.tsx
+++ b/client/src/components/Projects/ProjectWorkspace.tsx
@@ -1,14 +1,17 @@
import { useCallback, useId, useMemo, useState } from 'react';
-import * as Ariakit from '@ariakit/react';
import { useRecoilValue } from 'recoil';
+import * as Ariakit from '@ariakit/react';
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 { Constants, QueryKeys } from 'librechat-data-provider';
+import { ArrowLeft, ArrowUpDown, Check, Folder, Pencil, Plus, Trash2 } from 'lucide-react';
+import { Button, Spinner, DropdownPopup, TooltipAnchor, useMediaQuery } from '@librechat/client';
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 OpenSidebar from '~/components/Chat/Menus/OpenSidebar';
+import ProjectDeleteDialog from './ProjectDeleteDialog';
+import ProjectEditDialog from './ProjectEditDialog';
import { useLocalize, useNewConvo } from '~/hooks';
import { cn, clearMessagesCache } from '~/utils';
import ProjectChatList from './ProjectChatList';
@@ -37,12 +40,15 @@ export default function ProjectWorkspace() {
const queryClient = useQueryClient();
const { projectId = '' } = useParams();
const [sortBy, setSortBy] = useState('updatedAt');
+ const [isEditOpen, setIsEditOpen] = useState(false);
+ const [isDeleteOpen, setIsDeleteOpen] = useState(false);
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 isSmallScreen = useMediaQuery('(max-width: 768px)');
const sortOptions = useMemo(
() => [
@@ -106,12 +112,13 @@ export default function ProjectWorkspace() {
}
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
+ navigate(`/c/${Constants.NEW_CONVO}?projectId=${encodeURIComponent(activeProjectId)}`);
newConversation({ template: { chatProjectId: activeProjectId } });
- }, [activeProjectId, conversation?.conversationId, newConversation, queryClient]);
+ }, [activeProjectId, conversation?.conversationId, navigate, newConversation, queryClient]);
if (isProjectLoading) {
return (
-
+
);
@@ -119,62 +126,116 @@ export default function ProjectWorkspace() {
if (!project) {
return (
-
- {localize('com_ui_project_not_found')}
+
+
{localize('com_ui_project_not_found')}
+
navigate('/projects')}>
+ {localize('com_ui_all_projects')}
+
);
}
return (
-
-
-
navigate('/projects')}
- className="-ml-1.5 inline-flex w-fit items-center gap-1.5 rounded-lg px-2 py-1 text-sm text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
- >
-
- {localize('com_ui_all_projects')}
-
+
+
+
+ {isSmallScreen ?
: null}
+
navigate('/projects')}
+ className="-ml-1.5 text-text-secondary hover:text-text-primary"
+ >
+
+ {localize('com_ui_all_projects')}
+
+
+
-
-
+
+
+
-
+
{project.name}
{project.description ? (
-
+
{project.description}
- ) : null}
+ ) : (
+
setIsEditOpen(true)}
+ className="mt-1 text-sm text-text-tertiary transition-colors hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary"
+ >
+ {localize('com_ui_add_description')}
+
+ )}
-
+
+
setIsEditOpen(true)}
+ >
+
+
+ }
+ />
+ setIsDeleteOpen(true)}
+ >
+
+
+ }
+ />
+
+
+
+
+
-
+
-
+
{localize('com_ui_new_chat_in_project', { name: project.name })}
-
+
{localize('com_ui_chats')}
- {project.conversationCount}
+ {project.conversationCount}
diff --git a/client/src/components/Projects/ProjectsNavBar.tsx b/client/src/components/Projects/ProjectsNavBar.tsx
new file mode 100644
index 00000000000..54d6f224eaf
--- /dev/null
+++ b/client/src/components/Projects/ProjectsNavBar.tsx
@@ -0,0 +1,30 @@
+import { Plus } from 'lucide-react';
+import { Button, useMediaQuery } from '@librechat/client';
+import OpenSidebar from '~/components/Chat/Menus/OpenSidebar';
+import { useLocalize } from '~/hooks';
+
+type ProjectsNavBarProps = {
+ onCreate: () => void;
+};
+
+export default function ProjectsNavBar({ onCreate }: ProjectsNavBarProps) {
+ const localize = useLocalize();
+ const isSmallScreen = useMediaQuery('(max-width: 768px)');
+
+ return (
+
+ );
+}
diff --git a/client/src/components/Projects/ProjectsView.tsx b/client/src/components/Projects/ProjectsView.tsx
index 0cbbab14047..68942e9bc81 100644
--- a/client/src/components/Projects/ProjectsView.tsx
+++ b/client/src/components/Projects/ProjectsView.tsx
@@ -1,13 +1,24 @@
import { useDeferredValue, useEffect, useId, useMemo, useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { useNavigate, useSearchParams } from 'react-router-dom';
-import { ArrowUpDown, Check, Folder, Plus, Search } from 'lucide-react';
-import { Input, Button, Spinner, DropdownPopup, useMediaQuery } from '@librechat/client';
+import { Input, Button, Skeleton, DropdownPopup } from '@librechat/client';
+import {
+ ArrowUpDown,
+ Check,
+ Ellipsis,
+ Folder,
+ FolderPlus,
+ Pencil,
+ Search,
+ Trash2,
+} from 'lucide-react';
import type { TChatProject } from 'librechat-data-provider';
-import type { MenuItemProps, RenderProp } from '~/common';
-import OpenSidebar from '~/components/Chat/Menus/OpenSidebar';
+import type { LocalizeFunction, MenuItemProps, RenderProp } from '~/common';
import { useProjectsInfiniteQuery } from '~/data-provider';
import ProjectCreateDialog from './ProjectCreateDialog';
+import ProjectDeleteDialog from './ProjectDeleteDialog';
+import ProjectEditDialog from './ProjectEditDialog';
+import ProjectsNavBar from './ProjectsNavBar';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@@ -28,6 +39,16 @@ function renderSortMenuItem(label: string, isSelected: boolean): RenderProp {
};
}
+function getProjectCountLabel(count: number, hasMore: boolean, localize: LocalizeFunction) {
+ if (hasMore) {
+ return localize('com_ui_project_count_partial', { count });
+ }
+ if (count === 1) {
+ return localize('com_ui_project_count_single');
+ }
+ return localize('com_ui_project_count', { count });
+}
+
function formatActivity(project: TChatProject) {
const value = project.lastConversationAt ?? project.updatedAt ?? project.createdAt;
if (!value) {
@@ -40,6 +61,132 @@ function formatActivity(project: TChatProject) {
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
+function ProjectCard({
+ project,
+ index,
+ onOpen,
+}: {
+ project: TChatProject;
+ index: number;
+ onOpen: (projectId: string) => void;
+}) {
+ const localize = useLocalize();
+ const menuId = useId();
+ const [isMenuOpen, setIsMenuOpen] = useState(false);
+ const [isEditOpen, setIsEditOpen] = useState(false);
+ const [isDeleteOpen, setIsDeleteOpen] = useState(false);
+ const activity = formatActivity(project);
+ const menuItems = useMemo(
+ () => [
+ {
+ id: `${menuId}-edit`,
+ label: localize('com_ui_edit_project'),
+ icon: ,
+ onClick: () => setIsEditOpen(true),
+ },
+ {
+ id: `${menuId}-delete`,
+ label: localize('com_ui_delete'),
+ icon: ,
+ onClick: () => setIsDeleteOpen(true),
+ },
+ ],
+ [localize, menuId],
+ );
+
+ return (
+
+ onOpen(project._id)}
+ >
+
+
+
+
+ {project.name}
+
+ {project.description ? (
+
+ {project.description}
+
+ ) : null}
+
+
+ {project.conversationCount === 1
+ ? localize('com_ui_project_chat_count_single')
+ : localize('com_ui_project_chat_count', {
+ count: project.conversationCount,
+ })}
+
+ {activity ? (
+ <>
+ ·
+
+ >
+ ) : null}
+
+
+
+
+
+
+ }
+ items={menuItems}
+ />
+
+
+
+
+ );
+}
+
+function ProjectGridSkeleton() {
+ return (
+
+ {Array.from({ length: 6 }, (_, index) => (
+
+
+
+
+
+
+ ))}
+
+ );
+}
+
export default function ProjectsView() {
const localize = useLocalize();
const navigate = useNavigate();
@@ -50,7 +197,6 @@ export default function ProjectsView() {
const sortMenuId = useId();
const [isSortMenuOpen, setIsSortMenuOpen] = useState(false);
const deferredSearch = useDeferredValue(search);
- const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { data, fetchNextPage, isFetchingNextPage, isLoading } = useProjectsInfiniteQuery({
search: deferredSearch || undefined,
@@ -86,6 +232,10 @@ export default function ProjectsView() {
[sortBy, sortOptions],
);
+ /** `projects` only holds the pages fetched so far, so while another page
+ * exists this is a lower bound rather than the total. */
+ const projectCountLabel = getProjectCountLabel(projects.length, hasNextPage, localize);
+
useEffect(() => {
if (searchParams.get('new') === '1') {
setIsCreating(true);
@@ -102,72 +252,57 @@ export default function ProjectsView() {
};
return (
-
-
-
-
- {isSmallScreen ? : null}
-
- {localize('com_ui_projects')}
-
-
-
-
- {localize('com_ui_sort_by')}
-
-
-
-
- {selectedSortLabel}
-
-
- }
- items={sortMenuItems}
- />
- setIsCreating(true)}>
-
- {localize('com_ui_new_project')}
-
-
-
+
+ setIsCreating(true)} />
-
+
+
-
-
- {localize('com_ui_your_projects')}
-
-
+
+
+ {selectedSortLabel}
+
+ }
+ items={sortMenuItems}
+ />
+
+
+
+
+ {localize('com_ui_your_projects')}
+
+ {!isLoading && projects.length > 0 ? (
+
{projectCountLabel}
+ ) : null}
navigate(`/projects/${project._id}`)}
/>
- {isLoading ? (
-
-
-
- ) : (
-
- {projects.map((project) => {
- const activity = formatActivity(project);
- return (
-
+ {isLoading && }
+ {!isLoading && projects.length > 0 && (
+
+ {projects.map((project, index) => (
+
navigate(`/projects/${project._id}`)}
- >
-
-
-
- {project.name}
-
-
- {project.description ? (
-
- {project.description}
-
- ) : null}
-
-
- {project.conversationCount === 1
- ? localize('com_ui_project_chat_count_single')
- : localize('com_ui_project_chat_count', {
- count: project.conversationCount,
- })}
-
- {activity ? {activity} : null}
-
-
- );
- })}
-
- )}
-
- {!isLoading && projects.length === 0 && (
-
- {localize('com_ui_no_projects')}
-
- )}
+ project={project}
+ index={index}
+ onOpen={(projectId) => navigate(`/projects/${projectId}`)}
+ />
+ ))}
+
+ )}
+ {!isLoading && projects.length === 0 && (
+
+
+
+
+
+ {search ? localize('com_ui_no_matching_projects') : localize('com_ui_no_projects')}
+
+ {!search ? (
+ <>
+
+ {localize('com_ui_add_first_project')}
+
+
setIsCreating(true)}
+ >
+
+ {localize('com_ui_new_project')}
+
+ >
+ ) : null}
+
+ )}
+
{hasNextPage && (
fetchNextPage()}
disabled={isFetchingNextPage}
>
diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json
index 788c84ad2c4..7c00e96c86e 100644
--- a/client/src/locales/en/translation.json
+++ b/client/src/locales/en/translation.json
@@ -728,8 +728,10 @@
"com_ui_action_button": "Action Button",
"com_ui_active": "Active",
"com_ui_add": "Add",
+ "com_ui_add_description": "Add a description",
"com_ui_add_first_bookmark": "Click on a chat to add one",
"com_ui_add_first_mcp_server": "Create your first MCP server to get started",
+ "com_ui_add_first_project": "Create a project to keep related conversations together",
"com_ui_add_first_prompt": "Create your first prompt to get started",
"com_ui_add_labels": "Add Labels",
"com_ui_add_mcp": "Add MCP",
@@ -1134,6 +1136,7 @@
"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_action": "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_selected": "Delete selected",
@@ -1183,6 +1186,7 @@
"com_ui_edit_memory": "Edit Memory",
"com_ui_edit_message": "Edit message",
"com_ui_edit_preset_title": "Edit Preset - {{title}}",
+ "com_ui_edit_project": "Edit project",
"com_ui_edit_prompt_page": "Edit Prompt Page",
"com_ui_edit_skill": "Edit Skill",
"com_ui_editable_message": "Editable Message",
@@ -1597,6 +1601,7 @@
"com_ui_no_memories": "No memories. Create them manually or prompt the AI to remember something",
"com_ui_no_memories_match": "No memories match your search",
"com_ui_no_memories_title": "No memories yet",
+ "com_ui_no_matching_projects": "No projects match your search",
"com_ui_no_options": "No options available",
"com_ui_no_project_chats": "No chats yet",
"com_ui_no_projects": "No projects yet",
@@ -1673,6 +1678,9 @@
"com_ui_production": "Production",
"com_ui_project_chat_count": "{{count}} chats",
"com_ui_project_chat_count_single": "1 chat",
+ "com_ui_project_count": "{{count}} projects",
+ "com_ui_project_count_partial": "{{count}}+ projects",
+ "com_ui_project_count_single": "1 project",
"com_ui_project_create_error": "Failed to create project",
"com_ui_project_delete_error": "Failed to delete project",
"com_ui_project_name": "Project name",
@@ -1765,7 +1773,6 @@
"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",
@@ -2008,7 +2015,6 @@
"com_ui_skills_use_all_hint": "The agent can use every skill available to you, including skills added in the future.",
"com_ui_skip": "Skip",
"com_ui_something_else": "Something else...",
- "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",
@@ -2162,7 +2168,6 @@
"com_ui_unarchive": "Unarchive",
"com_ui_unarchive_conversation": "Unarchive conversation",
"com_ui_unarchive_error": "Failed to unarchive conversation",
- "com_ui_unassigned": "Unassigned",
"com_ui_unfavorite": "Remove from favorites",
"com_ui_unknown": "Unknown",
"com_ui_unknown_file_type": "Unknown file type",
diff --git a/packages/client/src/components/AnimatePopover.css b/packages/client/src/components/AnimatePopover.css
index 5a83bfa64f0..809e895bd1a 100644
--- a/packages/client/src/components/AnimatePopover.css
+++ b/packages/client/src/components/AnimatePopover.css
@@ -25,3 +25,25 @@
opacity: 1;
transform: scale(1) translateX(0);
}
+
+/* For a popover placed above its trigger, so it grows out of the trigger edge. */
+.animate-popover-bottom {
+ transform-origin: bottom left;
+ opacity: 0;
+ transition:
+ opacity 220ms cubic-bezier(0.22, 1, 0.36, 1),
+ transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
+ transform: translateY(0.5rem) scale(0.96);
+}
+
+.animate-popover-bottom[data-enter] {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .animate-popover-bottom {
+ transition: none;
+ transform: none;
+ }
+}
diff --git a/packages/client/src/components/ControlCombobox.spec.tsx b/packages/client/src/components/ControlCombobox.spec.tsx
index 1a659f0cd85..a7c765b2f3d 100644
--- a/packages/client/src/components/ControlCombobox.spec.tsx
+++ b/packages/client/src/components/ControlCombobox.spec.tsx
@@ -1,4 +1,5 @@
-import { act, render, screen } from '@testing-library/react';
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { OGDialog, OGDialogContent, OGDialogTitle } from './OriginalDialog';
import ControlCombobox from './ControlCombobox';
type CapturedObserver = {
@@ -196,4 +197,69 @@ describe('ControlCombobox popover sizing', () => {
expect(getPopoverWidth()).toBe('275px');
});
+
+ it('filters listed options when the search field is typed in', () => {
+ render(
+ undefined}
+ ariaLabel="Test combobox"
+ searchPlaceholder="Search projects"
+ isCollapsed={false}
+ showCarat
+ />,
+ );
+ openPopover();
+
+ const search = screen.getByPlaceholderText('Search projects');
+ fireEvent.change(search, { target: { value: 'Option B' } });
+
+ expect(screen.getByRole('option', { name: 'Option B' })).toBeInTheDocument();
+ expect(screen.queryByRole('option', { name: 'Option A' })).not.toBeInTheDocument();
+ });
+});
+
+describe('ControlCombobox portal placement', () => {
+ const renderInDialog = (portal: boolean) =>
+ render(
+
+
+ Change project
+ undefined}
+ ariaLabel="Test combobox"
+ searchPlaceholder="Search projects"
+ isCollapsed={false}
+ showCarat
+ portal={portal}
+ />
+
+ ,
+ );
+
+ it('portals to the document by default so existing dialogs keep their current placement', () => {
+ renderInDialog(true);
+ openPopover();
+
+ const dialog = screen.getByRole('dialog', { name: 'Change project' });
+ expect(dialog.contains(screen.getByPlaceholderText('Search projects'))).toBe(false);
+ });
+
+ it('keeps the popover inside the dialog when portal is false, so its search field stays typeable', () => {
+ renderInDialog(false);
+ openPopover();
+
+ const dialog = screen.getByRole('dialog', { name: 'Change project' });
+ const search = screen.getByPlaceholderText('Search projects');
+ expect(dialog.contains(search)).toBe(true);
+
+ fireEvent.change(search, { target: { value: 'Option B' } });
+ expect(screen.getByRole('option', { name: 'Option B' })).toBeInTheDocument();
+ expect(screen.queryByRole('option', { name: 'Option A' })).not.toBeInTheDocument();
+ });
});
diff --git a/packages/client/src/components/ControlCombobox.tsx b/packages/client/src/components/ControlCombobox.tsx
index 3286bdafc26..0233501cf67 100644
--- a/packages/client/src/components/ControlCombobox.tsx
+++ b/packages/client/src/components/ControlCombobox.tsx
@@ -27,6 +27,16 @@ interface ControlComboboxProps {
iconSide?: 'left' | 'right';
selectId?: string;
placement?: Ariakit.SelectStoreProps['placement'];
+ popoverClassName?: string;
+ matchTriggerWidth?: boolean;
+ gutter?: number;
+ /**
+ * Radix dialogs trap focus, so a portaled popover rendered outside the dialog
+ * cannot receive typing in its search field. Pass `false` from inside a dialog
+ * to keep the list in the dialog, and give that dialog `overflow-visible` so
+ * the popover is not clipped.
+ */
+ portal?: boolean;
}
const ROW_HEIGHT = 36;
@@ -49,6 +59,10 @@ function ControlCombobox({
iconSide = 'left',
selectId,
placement,
+ popoverClassName,
+ matchTriggerWidth = true,
+ gutter = 4,
+ portal = true,
}: ControlComboboxProps): JSX.Element {
const [searchValue, setSearchValue] = useState('');
const buttonRef = useRef(null);
@@ -161,12 +175,18 @@ function ControlCombobox({
diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts
index 03e9c433e8f..7b848d4fe8e 100644
--- a/packages/data-provider/src/config.ts
+++ b/packages/data-provider/src/config.ts
@@ -15,7 +15,11 @@ import { fileConfigSchema } from './file-config';
import { apiBaseUrl } from './api-endpoints';
import { FileSources } from './types/files';
import { MCPServersSchema } from './mcp';
-export { MAX_SUBAGENTS } from './limits';
+export {
+ MAX_SUBAGENTS,
+ MAX_CHAT_PROJECT_NAME_LENGTH,
+ MAX_CHAT_PROJECT_DESCRIPTION_LENGTH,
+} from './limits';
export const defaultSocialLogins = ['google', 'facebook', 'openid', 'github', 'discord', 'saml'];
diff --git a/packages/data-provider/src/limits.ts b/packages/data-provider/src/limits.ts
index 7dbabf6d2ce..db29ddac86a 100644
--- a/packages/data-provider/src/limits.ts
+++ b/packages/data-provider/src/limits.ts
@@ -1,2 +1,7 @@
/** Maximum number of explicit subagents per parent agent. UI + Zod schema share this. */
export const MAX_SUBAGENTS = 10;
+
+/** Chat project field limits. The dialogs and the persistence layer share these,
+ * so the inputs stop at the same point the server would otherwise truncate. */
+export const MAX_CHAT_PROJECT_NAME_LENGTH = 100;
+export const MAX_CHAT_PROJECT_DESCRIPTION_LENGTH = 1000;
diff --git a/packages/data-schemas/src/methods/chatProject.spec.ts b/packages/data-schemas/src/methods/chatProject.spec.ts
index 5563f90b6ca..7ba112dc741 100644
--- a/packages/data-schemas/src/methods/chatProject.spec.ts
+++ b/packages/data-schemas/src/methods/chatProject.spec.ts
@@ -1,8 +1,8 @@
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';
+import { createModels } from '~/models';
jest.mock('~/config/winston', () => ({
error: jest.fn(),
@@ -83,6 +83,26 @@ describe('ChatProject methods', () => {
expect(list.projects[0].name).toBe('Customer Alpha Updated');
});
+ it('filters projects by name or description search', async () => {
+ await methods.createChatProject(user, {
+ name: 'Customer Alpha',
+ description: 'Support work',
+ });
+ await methods.createChatProject(user, {
+ name: 'Internal Tools',
+ description: 'Overflow menu test',
+ });
+
+ const byName = await methods.listChatProjects(user, { search: 'alpha' });
+ expect(byName.projects.map((project) => project.name)).toEqual(['Customer Alpha']);
+
+ const byDescription = await methods.listChatProjects(user, { search: 'overflow' });
+ expect(byDescription.projects.map((project) => project.name)).toEqual(['Internal Tools']);
+
+ const noMatch = await methods.listChatProjects(user, { search: 'zzzz' });
+ expect(noMatch.projects).toHaveLength(0);
+ });
+
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' });
diff --git a/packages/data-schemas/src/methods/chatProject.ts b/packages/data-schemas/src/methods/chatProject.ts
index 038a6a979a1..2ebe09a9b3d 100644
--- a/packages/data-schemas/src/methods/chatProject.ts
+++ b/packages/data-schemas/src/methods/chatProject.ts
@@ -1,9 +1,13 @@
+import {
+ MAX_CHAT_PROJECT_NAME_LENGTH,
+ MAX_CHAT_PROJECT_DESCRIPTION_LENGTH,
+} from 'librechat-data-provider';
import type { FilterQuery, Model, SortOrder, Types } from 'mongoose';
-import logger from '~/config/winston';
-import { isValidObjectIdString } from '~/utils/objectId';
+import type { IChatProject, IChatProjectDocument, IConversation } from '~/types';
import { buildRetentionVisibilityFilter } from '~/utils/retention';
+import { isValidObjectIdString } from '~/utils/objectId';
import { escapeRegExp } from '~/utils/string';
-import type { IChatProject, IChatProjectDocument, IConversation } from '~/types';
+import logger from '~/config/winston';
export type ChatProjectSortBy = 'name' | 'createdAt' | 'lastConversationAt';
export type ChatProjectSortDirection = 'asc' | 'desc';
@@ -88,8 +92,8 @@ function normalizeLimit(limit?: number): number {
function sanitizeProjectInput(input: CreateChatProjectInput): CreateChatProjectInput {
return {
- name: input.name.trim().slice(0, 100),
- description: input.description?.trim().slice(0, 1000) ?? '',
+ name: input.name.trim().slice(0, MAX_CHAT_PROJECT_NAME_LENGTH),
+ description: input.description?.trim().slice(0, MAX_CHAT_PROJECT_DESCRIPTION_LENGTH) ?? '',
};
}
@@ -296,7 +300,8 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C
const filters: FilterQuery
[] = [{ user }];
if (options.search?.trim()) {
- filters.push({ name: { $regex: escapeRegExp(options.search.trim()), $options: 'i' } });
+ const searchRegex = { $regex: escapeRegExp(options.search.trim()), $options: 'i' };
+ filters.push({ $or: [{ name: searchRegex }, { description: searchRegex }] });
}
const cursorFilter = createCursorFilter(
@@ -340,14 +345,15 @@ export function createChatProjectMethods(mongoose: typeof import('mongoose')): C
const ChatProject = mongoose.models.ChatProject as Model;
const update: Partial> = {};
if (typeof input.name === 'string') {
- const name = input.name.trim().slice(0, 100);
+ const name = input.name.trim().slice(0, MAX_CHAT_PROJECT_NAME_LENGTH);
if (!name) {
throw new Error('Project name is required');
}
update.name = name;
}
if (input.description !== undefined) {
- update.description = input.description?.trim().slice(0, 1000) ?? '';
+ update.description =
+ input.description?.trim().slice(0, MAX_CHAT_PROJECT_DESCRIPTION_LENGTH) ?? '';
}
return await ChatProject.findOneAndUpdate(