🗂️ feat: Rework the Projects Dashboard, Sidebar and Scoped Composer (#14866)

* style: Redesign the Projects Dashboard and Sidebar

Give /projects a sticky navbar, quieter search/sort toolbar, and
folder-style cards. Drop the create-dialog close control, restyle
the sidebar Projects row, and align the workspace with the same
layout language.

* feat: Edit a Project Name and Description

Add a shared edit dialog so a project can be renamed and given a
description from the workspace or the sidebar menu. The create flow
already stored descriptions; this is the matching update path.

* feat: Delete a Project from the Workspace

Extract the project delete confirmation into a shared dialog and
expose it on the workspace header so deleting no longer requires
the sidebar menu.

* feat: Add Edit and Delete Actions to Project Cards

Give dashboard cards a more-options menu that opens the same edit
and delete dialogs as the workspace, so those actions are not
workspace-only.

* feat: Match Project Descriptions When Searching Projects

Project search only matched the name, so a project found by its
description was invisible in the sidebar and the projects dashboard.
Match the escaped search term against name or description.

* feat: Let Consumers Place and Size the ControlCombobox Popover

The popover always matched the trigger width, sat 4px from it and used
the same enter animation, which is wrong for a pill-shaped trigger in a
composer and for a full-width field in a dialog.

Add popoverClassName, matchTriggerWidth, gutter and portal so a consumer
can opt out of each. All four keep the current behaviour by default, so
existing comboboxes are unchanged; portal in particular stays true, as
turning it off inside a scrollable dialog would clip the list.

* fix: Re-render Conversation Rows When Pinned State Changes

areConversationListItemFieldsEqual left pinned out of its comparison, so
a row memoised on it kept rendering the stale pin state until some other
tracked field changed.

* fix: Keep the Project Scope When Starting a Chat From a Project

Starting a chat from the project workspace set chatProjectId on the
draft but left the URL on the unscoped route, so a reload or a refresh
of the route dropped the project. Navigate to the project-scoped new
chat URL alongside the draft.

* style: Move the Project Chip Into the Composer

The chip floated above the composer as a separate row, which read as an
unrelated control and pushed the conversation starters down. Render it
inside the composer border as the first row instead, and pass the
project through ChatForm so the memoised form still controls it.

The remove button no longer fades in on hover, since a control that only
appears on hover is unreachable by touch. Its popover opens upward with
a matching bottom-origin animation that honours reduced motion.

* feat: Rebuild the Change Project Dialog on the Searchable Combobox

The dialog used a bare select, so picking a project meant scrolling an
unsearchable native list capped at the default page of 25. Use the
searchable ControlCombobox, request the full first page, and disable
Save until the selection actually differs from the current project.

The combobox opts out of portalling so its search field sits inside the
dialog's focus trap and can be typed in, and the dialog is
overflow-visible so the list is not clipped.

Unassigning now lives on the menu's own Remove From Project action, so
the dialog no longer needs an empty option. Returning focus to the menu
button rather than the menu item fixes focus being lost on close, since
the item unmounts with the menu.

* feat: Add an Overflow Menu to Project Workspace Chats

Chats in a project workspace could only be opened. Managing one meant
finding it again in the sidebar, so add the same actions to the row:
change project, remove from project, and delete.

The row becomes a card matching the project cards, and the endpoint icon
is rendered at landing size rather than in a tinted tile. Its memo
comparison now uses areConversationListItemFieldsEqual, since the render
props comparison ignored fields the row displays.

* feat: Rework the Projects Sidebar Section

The section header duplicated the projects count and the New Project
action already on the dashboard, and spent a row on a chevron button
separate from its label. Collapse it to a single label toggle with an
All Projects action, and drop the per-project count that was hidden on
hover anyway.

The new chat action becomes a real link to the project-scoped URL, so it
can be middle-clicked and opened in a new tab, and modified clicks fall
through to the browser. On the new chat route it commits ?projectId
synchronously, because a deferred search param update lets ChatRoute see
a project-scoped draft on an unscoped URL and wipe it.

Row actions stay visible on devices without hover, where an action that
appears on hover cannot be reached.

* style: Widen the Projects Dashboard and Workspace Layout

The dashboard and the workspace sat on bg-surface-primary at different
max widths, so moving between them shifted the content and the shade did
not match the rest of the app. Put both on bg-presentation at max-w-6xl.

Project and chat cards gain a border, since colour alone separated them
from the background and that separation is thin in light mode. The
translucent blurred headers become opaque, and the scale-on-press
transforms are dropped.

In the workspace the edit and delete actions move out of the heading row
into their own group, so a long project name no longer pushes them
around.

* fix: Reopen the Change Project Dialog From the Conversation Menu

Closing the Ariakit menu in the same handler that opens the dialog made
the menu's own dismissal land on the freshly mounted Radix dialog, which
closed it again before paint, so Change project did nothing.

Leave the menu close to the dialog, which already receives setMenuOpen
and closes it once the assignment succeeds, matching the share and
delete handlers beside it.

* fix: Keep the Project Chat Menu Mounted While its Dialogs Open

Hiding the Ariakit menu in the same handler that opens Change project or
Delete restores focus to the menu trigger, which the dialog mounting
alongside it reads as an outside interaction and closes on, so the
action could do nothing.

Both dialogs already receive setIsMenuOpen and close the menu once they
finish, so leave the close to them, as the conversation menu does.

* perf: Fetch a Project's Chats Only Once its Row is Expanded

Collapse hides its children with CSS and inert rather than unmounting
them, so every project row's chat query ran on sidebar load, up to one
request per project, even with the whole section collapsed.

Gate the query on the row's expanded state. React Query keeps what it
already fetched, so reopening a row is still instant.

* refactor: Move the Bottom Popover Animation Into the Shared Primitive

ControlCombobox owns its popover animations in AnimatePopover.css, so
the upward variant it needs belongs there rather than in the
application stylesheet, where the control's appearance would diverge
from the package that ships it.

The app already loads the package stylesheet, so the animation resolves
the same way the existing variants do.

* fix: Stop Project Names and Descriptions Being Silently Truncated

The dialogs accepted any length and reported success, while the
persistence layer trimmed names to 100 and descriptions to 1000
characters, so reopening a project revealed text had been dropped with
no warning.

Share both limits from data-provider and cap the inputs at them, so the
fields stop where the server would have cut them and the rule has one
definition instead of the three it had.

* fix: Do Not Report the Loaded Page Size as the Project Total

The dashboard counted the projects fetched so far, so an account with
more than one page read as exactly one page's worth and the supposed
total grew with each Load more.

Show the loaded count as a lower bound while another page exists.

* chore: Satisfy the Static Checks for the Projects Rework

Sort the delete dialog's imports to the repository order, and drop the
three English keys this branch orphaned: the sidebar menu now says Edit
project, the dashboard labels its own sort control, and the change
project dialog no longer offers an Unassigned option now that removal
lives on the menu.

* fix: Highlight Only the Route Project in the Sidebar

A leftover conversation project was still lighting a second row after
opening another project's workspace. Prefer the workspace route, and
only fall back to the conversation project outside that view.
This commit is contained in:
Marco Beretta 2026-08-16 17:06:18 +02:00 • committed by GitHub
parent 1153b70898
commit 0b995065bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 1210 additions and 474 deletions

View file

@ -17,7 +17,6 @@ import {
import { ChatContext, AddedChatContext, ChatFormProvider, useFileMapContext } from '~/Providers';
import ConversationStarters from './Input/ConversationStarters';
import { useGetMessagesByConvoId } from '~/data-provider';
import ProjectLandingChip from './ProjectLandingChip';
import MessagesView from './Messages/MessagesView';
import Presentation from './Presentation';
import ChatForm from './Input/ChatForm';
@ -143,9 +142,12 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl',
)}
>
{isProjectLandingPage && project && <ProjectLandingChip project={project} />}
{isLandingPage && <ConversationStarters />}
<ChatForm index={index} placeholder={chatFormPlaceholder} />
<ChatForm
index={index}
placeholder={chatFormPlaceholder}
project={isProjectLandingPage ? project : undefined}
/>
{!isLandingPage && <Footer />}
</div>
</div>

View file

@ -3,7 +3,7 @@ import { useWatch } from 'react-hook-form';
import { TextareaAutosize } from '@librechat/client';
import { useRecoilState, useRecoilValue, useRecoilCallback } from 'recoil';
import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider';
import type { TMessage, TConversation } from 'librechat-data-provider';
import type { TChatProject, TMessage, TConversation } from 'librechat-data-provider';
import type { ExtendedFile, FileSetter, ConvoGenerator } from '~/common';
import type { QueuedMessageContext } from '~/hooks/Chat/useSteering';
import {
@ -28,6 +28,7 @@ import useAskAnswerMode from '~/hooks/Input/useAskAnswerMode';
import AskUserQuestionPopover from './AskUserQuestionPopover';
import InterruptSteerButton from './InterruptSteerButton';
import DuringRunSendButton from './DuringRunSendButton';
import ProjectLandingChip from '../ProjectLandingChip';
import { useGetStartupConfig } from '~/data-provider';
import { mainTextareaId, BadgeItem } from '~/common';
import PendingSteerChips from './PendingSteerChips';
@ -54,7 +55,8 @@ import store from '~/store';
interface ChatFormProps {
index: number;
placeholder?: string;
/** From ChatContext — individual values so memo can compare them */
project?: TChatProject;
/** From ChatContext: individual values so memo can compare them */
files: Map<string, ExtendedFile>;
setFiles: FileSetter;
conversation: TConversation | null;
@ -68,6 +70,7 @@ interface ChatFormProps {
const ChatForm = memo(function ChatForm({
index,
placeholder,
project,
files,
setFiles,
conversation,
@ -556,6 +559,7 @@ const ChatForm = memo(function ChatForm({
: 'border-border-light bg-surface-chat',
)}
>
{project ? <ProjectLandingChip project={project} /> : null}
<TextareaHeader addedConvo={addedConvo} setAddedConvo={setAddedConvo} />
<PendingManualSkillsChips conversationId={conversationId} />
{quotesEnabled && <PendingQuoteChips conversationId={conversationId} />}
@ -732,7 +736,15 @@ ChatForm.displayName = 'ChatForm';
* to the memo'd ChatForm. This prevents ChatForm from re-rendering on every
* streaming chunk — it only re-renders when the specific values it uses change.
*/
function ChatFormWrapper({ index = 0, placeholder }: { index?: number; placeholder?: string }) {
function ChatFormWrapper({
index = 0,
placeholder,
project,
}: {
index?: number;
placeholder?: string;
project?: TChatProject;
}) {
const {
files,
setFiles,
@ -792,6 +804,7 @@ function ChatFormWrapper({ index = 0, placeholder }: { index?: number; placehold
<ChatForm
index={index}
placeholder={placeholder}
project={project}
files={files}
setFiles={setFiles}
conversation={stableConversation}

View file

@ -9,10 +9,10 @@ import { useChatContext } from '~/Providers';
import { useLocalize } from '~/hooks';
/**
* Subtle project indicator shown on a project-scoped new-chat landing.
* Clicking the chip switches the project (searchable combobox); the trailing
* `×` removes the project scope. Both update the conversation draft and the
* `?projectId` search param in place, so the typed message and model are kept.
* Project scope control rendered inside the composer on a project-scoped
* new-chat landing. The trigger switches projects; the trailing × drops the
* scope. Both update the draft and `?projectId` in place so the typed message
* and model are kept.
*/
export default function ProjectLandingChip({ project }: { project: TChatProject }) {
const localize = useLocalize();
@ -53,7 +53,7 @@ export default function ProjectLandingChip({ project }: { project: TChatProject
);
return (
<div className="group mb-2.5 flex items-center gap-1 px-1">
<div className="flex items-center gap-0.5 px-2.5 pt-2">
<ControlCombobox
selectId="project-landing-select"
selectedValue={project._id}
@ -69,9 +69,12 @@ export default function ProjectLandingChip({ project }: { project: TChatProject
searchPlaceholder={localize('com_ui_search_projects')}
isCollapsed={false}
showCarat={true}
placement="top"
containerClassName="w-auto px-0"
className="h-8 w-auto min-w-[11rem] gap-1.5 rounded-full px-2.5 text-sm font-medium text-text-secondary hover:text-text-primary"
placement="top-start"
gutter={12}
matchTriggerWidth={false}
containerClassName="w-auto min-w-0 px-0"
className="h-8 w-auto min-w-[7.5rem] max-w-[14rem] gap-1.5 rounded-full border-0 bg-transparent px-2.5 text-sm font-medium text-text-secondary hover:bg-surface-hover hover:text-text-primary"
popoverClassName="animate-popover-bottom min-w-64 rounded-2xl shadow-xl"
/>
<TooltipAnchor
description={localize('com_ui_remove_from_project')}
@ -80,7 +83,7 @@ export default function ProjectLandingChip({ project }: { project: TChatProject
type="button"
aria-label={localize('com_ui_remove_from_project')}
onClick={() => applyProject(null)}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-text-secondary opacity-0 outline-none transition-all hover:bg-surface-hover hover:text-text-primary focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-ring-primary group-hover:opacity-100"
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-text-secondary outline-none transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-text-primary"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>

View file

@ -42,7 +42,6 @@ jest.mock('../Presentation', () => ({
jest.mock('../Header', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../Footer', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../Landing', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../ProjectLandingChip', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../Messages/MessagesView', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../Input/ChatForm', () => ({ __esModule: true, default: () => <div /> }));
jest.mock('../Input/ConversationStarters', () => ({ __esModule: true, default: () => <div /> }));

View file

@ -69,6 +69,7 @@ function ConvoOptions({
const { newConversation } = useNewConvo();
const menuId = useId();
const menuButtonRef = useRef<HTMLButtonElement>(null);
const shareButtonRef = useRef<HTMLButtonElement>(null);
const deleteButtonRef = useRef<HTMLButtonElement>(null);
const projectButtonRef = useRef<HTMLButtonElement>(null);
@ -427,6 +428,7 @@ function ConvoOptions({
setIsOpen={setIsPopoverActive}
trigger={
<Ariakit.MenuButton
ref={menuButtonRef}
id={`conversation-menu-${conversationId}`}
aria-label={localize('com_nav_convo_menu_options')}
aria-expanded={isPopoverActive}
@ -470,7 +472,7 @@ function ConvoOptions({
conversationId={conversationId ?? ''}
chatProjectId={chatProjectId}
setMenuOpen={setIsPopoverActive}
triggerRef={projectButtonRef}
triggerRef={menuButtonRef}
showProjectDialog={showProjectDialog}
setShowProjectDialog={setShowProjectDialog}
/>

View file

@ -1,18 +1,22 @@
import { useEffect, useMemo, useState, type RefObject } from 'react';
import { useMemo, useState, type RefObject } from 'react';
import { Folder } from 'lucide-react';
import {
Button,
Spinner,
ControlCombobox,
Label,
OGDialog,
OGDialogClose,
OGDialogTitle,
OGDialogHeader,
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
Spinner,
useToastContext,
} from '@librechat/client';
import type { TChatProject } from 'librechat-data-provider';
import type { OptionWithIcon } from '~/common';
import { useAssignConversationToProjectMutation, useProjectsInfiniteQuery } from '~/data-provider';
import { useLocalize } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks';
type ProjectButtonProps = {
conversationId: string;
@ -41,6 +45,7 @@ function ProjectConversationDialog({
const { data, fetchNextPage, isFetchingNextPage } = useProjectsInfiniteQuery({
sortBy: 'name',
sortDirection: 'asc',
limit: 100,
});
const projects = useMemo<TChatProject[]>(
@ -48,16 +53,29 @@ function ProjectConversationDialog({
[data?.pages],
);
const hasNextPage = data?.pages[data.pages.length - 1]?.nextCursor != null;
useEffect(() => {
setSelectedProjectId(chatProjectId ?? '');
}, [chatProjectId]);
const selectedProject = projects.find((project) => project._id === selectedProjectId);
const projectItems = useMemo<OptionWithIcon[]>(
() =>
projects.map((project) => ({
label: project.name,
value: project._id,
icon: <Folder className="h-4 w-4 text-text-secondary" aria-hidden="true" />,
})),
[projects],
);
const canSave =
Boolean(selectedProjectId) &&
selectedProjectId !== (chatProjectId ?? '') &&
!assignConversation.isLoading;
const saveProject = () => {
if (!canSave) {
return;
}
assignConversation.mutate(
{
conversationId,
projectId: selectedProjectId || null,
projectId: selectedProjectId,
},
{
onSuccess: () => {
@ -83,44 +101,53 @@ function ProjectConversationDialog({
return (
<OGDialogContent
id="project-conversation-dialog"
className="w-11/12 max-w-md"
className="w-11/12 max-w-md overflow-visible"
showCloseButton={false}
>
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_change_project')}</OGDialogTitle>
</OGDialogHeader>
<label className="flex flex-col gap-2 text-sm text-text-primary">
{localize('com_ui_select_project')}
<select
value={selectedProjectId}
onChange={(event) => setSelectedProjectId(event.target.value)}
className="h-10 rounded-lg border border-border-light bg-surface-primary px-3 text-sm outline-none focus:ring-2 focus:ring-ring-primary"
>
<option value="">{localize('com_ui_unassigned')}</option>
{projects.map((project) => (
<option key={project._id} value={project._id}>
{project.name}
</option>
))}
</select>
</label>
{hasNextPage && (
<button
type="button"
className="mt-3 text-sm text-text-secondary hover:text-text-primary"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? localize('com_ui_loading') : localize('com_ui_load_more')}
</button>
)}
<div className="flex justify-end gap-4 pt-4">
<div className="flex flex-col gap-2">
<Label htmlFor="change-project-select" className="text-sm font-medium text-text-primary">
{localize('com_ui_select_project')}
</Label>
<ControlCombobox
selectId="change-project-select"
selectedValue={selectedProjectId}
displayValue={selectedProject?.name}
items={projectItems}
setValue={setSelectedProjectId}
SelectIcon={<Folder className="h-4 w-4 text-text-secondary" aria-hidden="true" />}
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 ? (
<Button
type="button"
variant="link"
className="h-auto justify-start px-0 text-sm"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? localize('com_ui_loading') : localize('com_ui_load_more')}
</Button>
) : null}
</div>
<div className="flex justify-end gap-2 pt-4">
<OGDialogClose asChild>
<Button aria-label="cancel" variant="outline">
<Button type="button" variant="outline">
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button onClick={saveProject} disabled={assignConversation.isLoading}>
<Button type="button" variant="submit" onClick={saveProject} disabled={!canSave}>
{assignConversation.isLoading ? <Spinner /> : localize('com_ui_save')}
</Button>
</div>

View file

@ -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<HTMLInputElement | null>(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<HTMLFormElement>) => {
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 (
<OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogContent className="w-11/12 max-w-md" showCloseButton={false}>
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_rename_project')}</OGDialogTitle>
</OGDialogHeader>
<form id={formId} onSubmit={handleSubmit}>
<Input
ref={inputRef}
value={name}
onChange={(event) => setName(event.target.value)}
aria-label={localize('com_ui_project_name')}
className="w-full bg-transparent text-text-primary placeholder:text-text-secondary focus-visible:ring-2 focus-visible:ring-ring-primary"
/>
</form>
<div className="flex justify-end gap-4 pt-4">
<OGDialogClose asChild>
<Button aria-label="cancel" variant="outline">
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button
type="submit"
form={formId}
variant="submit"
disabled={!name.trim() || updateProject.isLoading}
>
{updateProject.isLoading ? <Spinner className="size-4" /> : localize('com_ui_save')}
</Button>
</div>
</OGDialogContent>
</OGDialog>
);
}
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 (
<OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogContent className="w-11/12 max-w-md" showCloseButton={false}>
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_delete_project')}</OGDialogTitle>
</OGDialogHeader>
<div className="text-sm text-text-secondary">
{localize('com_ui_delete_project_confirm', { name: project.name })}
</div>
<div className="flex justify-end gap-4 pt-4">
<OGDialogClose asChild>
<Button aria-label="cancel" variant="outline">
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteProject.isLoading}>
{deleteProject.isLoading ? <Spinner className="size-4" /> : localize('com_ui_delete')}
</Button>
</div>
</OGDialogContent>
</OGDialog>
);
}
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<TConversation[]>(
@ -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<HTMLAnchorElement>) => {
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<MenuItemProps[]>(
() => [
@ -316,7 +202,7 @@ const ProjectItem = memo(
},
{
id: `${menuId}-rename`,
label: localize('com_ui_rename'),
label: localize('com_ui_edit_project'),
icon: <Pencil className="size-4 text-text-secondary" aria-hidden="true" />,
onClick: () => setIsRenameOpen(true),
},
@ -332,36 +218,54 @@ const ProjectItem = memo(
return (
<li className="list-none">
<div className="group/project-row relative flex h-9 items-center rounded-lg text-sm text-text-primary transition-colors hover:bg-surface-active-alt">
<div
className={cn(
'group/project-row relative flex h-9 items-center rounded-lg text-sm text-text-primary hover:bg-surface-hover',
isActive && 'bg-surface-active-alt hover:bg-surface-active-alt',
!isActive && isMenuOpen && 'bg-surface-hover',
)}
>
<button
type="button"
onMouseDown={(event) => 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"
>
<ChevronRight
className={cn(
'h-3.5 w-3.5 shrink-0 text-text-secondary transition-transform duration-200',
'h-3.5 w-3.5 shrink-0 text-text-tertiary transition-transform duration-200',
expanded && 'rotate-90',
)}
aria-hidden="true"
/>
<Folder className="h-4 w-4 shrink-0 text-text-secondary" aria-hidden="true" />
<span className="truncate">{project.name}</span>
<span className="min-w-0 truncate">{project.name}</span>
</button>
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 rounded-md bg-surface-active-alt opacity-0 transition-opacity group-focus-within/project-row:opacity-100 group-hover/project-row:opacity-100 has-[[data-state=open]]:opacity-100">
<div
className={cn(
'absolute right-1 top-1/2 flex -translate-y-1/2 items-center',
isMenuOpen
? 'opacity-100'
: [
'[@media(hover:hover)]:opacity-0 [@media(hover:hover)]:transition-opacity',
'group-hover/project-row:opacity-100',
'has-[:focus-visible]:opacity-100',
],
)}
>
<TooltipAnchor
description={localize('com_ui_new_chat_in_project', { name: project.name })}
render={
<button
type="button"
<a
href={projectChatPath}
aria-label={localize('com_ui_new_chat_in_project', { name: project.name })}
className={iconButtonClassName}
onClick={startChat}
>
<NewChatIcon className="h-4 w-4" />
</button>
</a>
}
/>
<DropdownPopup
@ -376,10 +280,7 @@ const ProjectItem = memo(
trigger={
<Ariakit.MenuButton
aria-label={localize('com_ui_more_options')}
className={cn(
iconButtonClassName,
isMenuOpen && 'bg-surface-active-alt text-text-primary',
)}
className={cn(iconButtonClassName, isMenuOpen && 'text-text-primary')}
>
<Ellipsis className="h-4 w-4" aria-hidden="true" />
</Ariakit.MenuButton>
@ -388,14 +289,15 @@ const ProjectItem = memo(
/>
</div>
</div>
{expanded && (
<Collapse open={expanded} className="pl-2">
<ProjectChatsInline
projectId={project._id}
expanded={expanded}
toggleNav={toggleNav}
onShowAll={openProject}
/>
)}
<ProjectRenameDialog open={isRenameOpen} onOpenChange={setIsRenameOpen} project={project} />
</Collapse>
<ProjectEditDialog open={isRenameOpen} onOpenChange={setIsRenameOpen} project={project} />
<ProjectDeleteDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen} project={project} />
</li>
);
@ -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"
>
<FolderPlus className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="truncate">{localize('com_ui_new_project')}</span>
@ -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')}
</Button>
@ -503,14 +414,14 @@ const ProjectsSection = ({ toggleNav, isAuthenticated }: ProjectsSectionProps) =
return (
<div className="flex flex-col px-3 text-sm">
<div className="flex h-8 w-full items-center gap-0.5 pr-2">
<div className="flex h-8 w-full items-center pr-2">
<button
type="button"
onClick={() => {
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}
>
<span className="select-none truncate">{localize('com_ui_projects')}</span>
@ -528,33 +439,20 @@ const ProjectsSection = ({ toggleNav, isAuthenticated }: ProjectsSectionProps) =
<button
type="button"
aria-label={localize('com_ui_all_projects')}
className={iconButtonClassName}
className={cn(iconButtonClassName, 'hover:bg-surface-hover')}
onClick={openProjects}
>
<Folders className="h-4 w-4" aria-hidden="true" />
</button>
}
/>
<TooltipAnchor
description={localize('com_ui_new_project')}
render={
<button
type="button"
aria-label={localize('com_ui_new_project')}
className={iconButtonClassName}
onClick={() => setIsCreateOpen(true)}
>
<FolderPlus className="h-4 w-4" aria-hidden="true" />
</button>
}
/>
</div>
{isExpanded && (
<div className="scrollbar-gutter-stable max-h-[42vh] overflow-y-auto">
<Collapse open={isExpanded}>
<div className="scrollbar-gutter-stable max-h-[42vh] overflow-y-auto pt-0.5">
{renderProjectsBody()}
</div>
)}
</Collapse>
<ProjectCreateDialog
open={isCreateOpen}

View file

@ -31,6 +31,7 @@ export function areConversationListItemFieldsEqual(
prevConversation.conversationId === nextConversation.conversationId &&
prevConversation.title === nextConversation.title &&
prevConversation.chatProjectId === nextConversation.chatProjectId &&
prevConversation.pinned === nextConversation.pinned &&
prevConversation.isShared === nextConversation.isShared &&
prevConversation.createdAt === nextConversation.createdAt &&
prevConversation.updatedAt === nextConversation.updatedAt

View file

@ -4,6 +4,7 @@ import {
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type FC,
type ReactNode,
@ -14,10 +15,11 @@ import { AutoSizer, CellMeasurer, CellMeasurerCache, List } from 'react-virtuali
import type { TConversation } from 'librechat-data-provider';
import type { MeasuredCellParent } from '~/components/Conversations/Conversations';
import ConversationEndpointIcon from '~/components/Conversations/ConversationEndpointIcon';
import { areConversationRenderPropsEqual } from '~/components/Conversations/utils';
import { areConversationListItemFieldsEqual } from '~/components/Conversations/utils';
import { DateLabel } from '~/components/Conversations/Conversations';
import { useLocalize, useNavigateToConvo } from '~/hooks';
import { groupConversationsByDate, cn } from '~/utils';
import { cn, groupConversationsByDate } from '~/utils';
import ProjectChatOptions from './ProjectChatOptions';
import { useActiveJobs } from '~/data-provider';
type ChatSortField = 'updatedAt' | 'createdAt';
@ -77,33 +79,56 @@ const ConversationRow = memo(
({ conversation, isGenerating }: { conversation: TConversation; isGenerating: boolean }) => {
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 (
<button
type="button"
className="flex w-full items-center gap-3 border-b border-border-light py-3 text-left outline-none transition-colors hover:bg-surface-hover focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring-primary"
onClick={() => navigateToConvo(conversation)}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center">
<ConversationEndpointIcon conversation={conversation} size={24} context="menu-item" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-text-primary">{title}</span>
<span className="block truncate text-xs text-text-secondary">{formattedDate}</span>
</span>
{isGenerating && (
<Spinner
className="h-4 w-4 shrink-0 text-text-primary"
aria-label={localize('com_ui_generating')}
/>
<article
className={cn(
'group/project-chat mb-2 flex items-center rounded-2xl border border-border-light bg-surface-secondary',
'transition-colors hover:bg-surface-hover',
isMenuOpen && 'bg-surface-hover',
)}
</button>
>
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-3 rounded-2xl px-3.5 py-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-text-primary"
onClick={() => navigateToConvo(conversation)}
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center">
<ConversationEndpointIcon conversation={conversation} size={40} context="landing" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-text-primary">{title}</span>
<span className="block truncate text-xs tabular-nums text-text-secondary">
{formattedDate}
</span>
</span>
{isGenerating ? (
<Spinner
className="h-4 w-4 shrink-0 text-text-primary"
aria-label={localize('com_ui_generating')}
/>
) : null}
</button>
{conversationId ? (
<div className="pr-2">
<ProjectChatOptions
conversation={conversation}
isMenuOpen={isMenuOpen}
setIsMenuOpen={setIsMenuOpen}
/>
</div>
) : null}
</article>
);
},
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 (
<MeasuredRow key={key} {...rowProps}>
<div className="py-12 text-center text-sm text-text-secondary">{emptyLabel}</div>
<div className="px-3 py-14 text-center text-sm text-text-secondary">{emptyLabel}</div>
</MeasuredRow>
);
}
@ -202,7 +227,9 @@ const ProjectChatList = ({
if (item.type === 'date') {
return (
<MeasuredRow key={key} {...rowProps}>
<DateLabel groupName={item.groupName} />
<div className="pb-3">
<DateLabel groupName={item.groupName} />
</div>
</MeasuredRow>
);
}
@ -234,9 +261,7 @@ const ProjectChatList = ({
);
return (
<div
className={cn('min-h-[280px] flex-1 overflow-hidden rounded-lg border border-border-light')}
>
<div className="min-h-[280px] flex-1 overflow-hidden">
<AutoSizer>
{({ width, height }) => (
<List

View file

@ -0,0 +1,151 @@
import { memo, useId, useMemo, useRef, useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { Ellipsis, FolderInput, FolderX, Trash2 } from 'lucide-react';
import { DropdownPopup, Spinner, useToastContext } from '@librechat/client';
import type { TConversation } from 'librechat-data-provider';
import type { MenuItemProps } from '~/common';
import ProjectButton from '~/components/Conversations/ConvoOptions/ProjectButton';
import DeleteButton from '~/components/Conversations/ConvoOptions/DeleteButton';
import { useAssignConversationToProjectMutation } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
type ProjectChatOptionsProps = {
conversation: TConversation;
isMenuOpen: boolean;
setIsMenuOpen: (open: boolean) => void;
};
const noop = () => {};
function ProjectChatOptions({ conversation, isMenuOpen, setIsMenuOpen }: ProjectChatOptionsProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const menuId = useId();
const menuButtonRef = useRef<HTMLButtonElement>(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<MenuItemProps[]>(() => {
if (!conversationId) {
return [];
}
return [
{
label: localize('com_ui_change_project'),
onClick: () => setShowProjectDialog(true),
icon: <FolderInput className="size-4 text-text-secondary" aria-hidden="true" />,
/** 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) => <button {...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 ? (
<Spinner className="size-4" />
) : (
<FolderX className="size-4 text-text-secondary" aria-hidden="true" />
),
},
{
label: localize('com_ui_delete'),
onClick: () => setShowDeleteDialog(true),
hideOnClick: false,
render: (props) => <button {...props} />,
icon: <Trash2 className="size-4 text-text-secondary" aria-hidden="true" />,
},
];
}, [
assignConversationToProject,
chatProjectId,
conversationId,
localize,
setIsMenuOpen,
showToast,
]);
return (
<>
<DropdownPopup
portal={true}
focusLoop={true}
unmountOnHide={true}
menuId={menuId}
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
className="z-[125] min-w-44"
iconClassName="mr-2 text-text-secondary"
trigger={
<Ariakit.MenuButton
ref={menuButtonRef}
aria-label={localize('com_nav_convo_menu_options')}
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-text-secondary outline-none transition-colors',
'hover:bg-surface-hover hover:text-text-primary',
'focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-text-primary',
isMenuOpen && 'bg-surface-hover text-text-primary',
)}
>
<Ellipsis className="h-4 w-4" aria-hidden="true" />
</Ariakit.MenuButton>
}
items={menuItems}
/>
{showProjectDialog ? (
<ProjectButton
conversationId={conversationId}
chatProjectId={chatProjectId}
setMenuOpen={setIsMenuOpen}
triggerRef={menuButtonRef}
showProjectDialog={showProjectDialog}
setShowProjectDialog={setShowProjectDialog}
/>
) : null}
{showDeleteDialog ? (
<DeleteButton
title={conversation.title ?? ''}
retainView={noop}
triggerRef={menuButtonRef}
setMenuOpen={setIsMenuOpen}
showDeleteDialog={showDeleteDialog}
conversationId={conversationId}
setShowDeleteDialog={setShowDeleteDialog}
/>
) : null}
</>
);
}
export default memo(ProjectChatOptions);

View file

@ -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<HTMLInputElement | null>(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}
<OGDialogTemplate
title={localize('com_ui_create_project')}
showCloseButton={true}
className="w-11/12 max-w-lg bg-surface-dialog text-text-primary"
showCloseButton={false}
className="w-11/12 max-w-md"
main={
<form id={formId} onSubmit={handleCreate} className="space-y-2">
<Label htmlFor={`${formId}-name`} className="text-sm font-medium text-text-primary">
{localize('com_ui_project_name')}
</Label>
<Input
id={`${formId}-name`}
ref={inputRef}
value={name}
onChange={(event) => 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"
/>
<form id={formId} onSubmit={handleCreate} className="flex flex-col gap-4">
<div className="space-y-2">
<Label htmlFor={`${formId}-name`} className="text-sm font-medium text-text-primary">
{localize('com_ui_project_name')}
</Label>
<Input
id={`${formId}-name`}
ref={inputRef}
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={localize('com_ui_project_name_placeholder')}
maxLength={MAX_CHAT_PROJECT_NAME_LENGTH}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label
htmlFor={`${formId}-description`}
className="text-sm font-medium text-text-primary"
>
{localize('com_ui_description')}{' '}
<span className="font-normal text-text-secondary">
{localize('com_ui_optional')}
</span>
</Label>
<Textarea
id={`${formId}-description`}
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={3}
maxLength={MAX_CHAT_PROJECT_DESCRIPTION_LENGTH}
className="min-h-[4.5rem] bg-transparent"
/>
</div>
</form>
}
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 ? (
<Spinner className="size-4" />

View file

@ -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 (
<OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogContent className="w-11/12 max-w-md" showCloseButton={false}>
<OGDialogHeader>
<OGDialogTitle>{localize('com_ui_delete_project')}</OGDialogTitle>
</OGDialogHeader>
<div className="text-sm text-text-secondary">
{localize('com_ui_delete_project_confirm', { name: project.name })}
</div>
<div className="flex justify-end gap-4 pt-4">
<OGDialogClose asChild>
<Button aria-label={localize('com_ui_cancel')} variant="outline">
{localize('com_ui_cancel')}
</Button>
</OGDialogClose>
<Button variant="destructive" onClick={confirmDelete} disabled={deleteProject.isLoading}>
{deleteProject.isLoading ? <Spinner className="size-4" /> : localize('com_ui_delete')}
</Button>
</div>
</OGDialogContent>
</OGDialog>
);
}

View file

@ -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<HTMLInputElement | null>(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<HTMLFormElement>) => {
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 (
<OGDialog open={open} onOpenChange={onOpenChange}>
<OGDialogTemplate
title={localize('com_ui_edit_project')}
showCloseButton={false}
className="w-11/12 max-w-md"
main={
<form id={formId} onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="space-y-2">
<Label htmlFor={`${formId}-name`} className="text-sm font-medium text-text-primary">
{localize('com_ui_project_name')}
</Label>
<Input
id={`${formId}-name`}
ref={inputRef}
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={MAX_CHAT_PROJECT_NAME_LENGTH}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label
htmlFor={`${formId}-description`}
className="text-sm font-medium text-text-primary"
>
{localize('com_ui_description')}{' '}
<span className="font-normal text-text-secondary">
{localize('com_ui_optional')}
</span>
</Label>
<Textarea
id={`${formId}-description`}
value={description}
onChange={(event) => setDescription(event.target.value)}
rows={3}
maxLength={MAX_CHAT_PROJECT_DESCRIPTION_LENGTH}
className="min-h-[4.5rem] bg-transparent"
/>
</div>
</form>
}
buttons={
<Button
type="submit"
form={formId}
variant="submit"
disabled={!trimmedName || isUnchanged || updateProject.isLoading}
className="active:scale-[0.96]"
>
{updateProject.isLoading ? <Spinner className="size-4" /> : localize('com_ui_save')}
</Button>
}
/>
</OGDialog>
);
}

View file

@ -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<ChatSortField>('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 (
<div className="flex h-full items-center justify-center">
<div className="flex h-full items-center justify-center bg-presentation">
<Spinner className="text-text-primary" />
</div>
);
@ -119,62 +126,116 @@ export default function ProjectWorkspace() {
if (!project) {
return (
<div className="flex h-full items-center justify-center text-sm text-text-secondary">
{localize('com_ui_project_not_found')}
<div className="flex h-full flex-col items-center justify-center gap-3 bg-presentation px-6 text-center">
<p className="text-sm text-text-secondary">{localize('com_ui_project_not_found')}</p>
<Button type="button" variant="outline" size="sm" onClick={() => navigate('/projects')}>
{localize('com_ui_all_projects')}
</Button>
</div>
);
}
return (
<main className="flex h-full min-h-0 flex-col overflow-y-auto bg-surface-primary text-text-primary">
<div className="mx-auto flex w-full max-w-3xl flex-1 flex-col px-4 pb-10 pt-4 md:px-6 lg:pt-8">
<button
type="button"
onClick={() => 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"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_all_projects')}
</button>
<main className="flex h-full min-h-0 flex-col overflow-y-auto bg-presentation text-text-primary">
<header className="sticky top-0 z-10 border-b border-border-light bg-presentation">
<div className="mx-auto flex h-14 w-full max-w-6xl items-center gap-2 px-4 md:h-16 md:px-6">
{isSmallScreen ? <OpenSidebar className="size-9 shrink-0" /> : null}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => navigate('/projects')}
className="-ml-1.5 text-text-secondary hover:text-text-primary"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_all_projects')}
</Button>
</div>
</header>
<header className="mt-5 flex items-start gap-3">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl bg-surface-secondary text-text-secondary">
<div className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-4 pb-10 pt-6 md:px-6 md:pt-8">
<div className="flex items-start gap-3.5">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-surface-secondary text-text-secondary">
<Folder className="h-6 w-6" aria-hidden="true" />
</span>
<div className="min-w-0 flex-1 pt-0.5">
<h1 className="truncate text-2xl font-semibold tracking-tight text-text-primary">
<h1 className="truncate text-balance text-2xl font-semibold tracking-tight text-text-primary">
{project.name}
</h1>
{project.description ? (
<p className="mt-0.5 line-clamp-2 text-sm text-text-secondary">
<p className="mt-1 text-pretty text-sm leading-relaxed text-text-secondary">
{project.description}
</p>
) : null}
) : (
<button
type="button"
onClick={() => 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')}
</button>
)}
</div>
</header>
<div className="flex shrink-0 items-center gap-0.5 pt-1">
<TooltipAnchor
description={localize('com_ui_edit_project')}
render={
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 text-text-secondary hover:text-text-primary"
aria-label={localize('com_ui_edit_project')}
onClick={() => setIsEditOpen(true)}
>
<Pencil className="h-4 w-4" aria-hidden="true" />
</Button>
}
/>
<TooltipAnchor
description={localize('com_ui_delete_project_action')}
render={
<Button
type="button"
variant="ghost"
size="icon"
className="size-8 shrink-0 text-text-secondary hover:text-text-destructive"
aria-label={localize('com_ui_delete_project_action')}
onClick={() => setIsDeleteOpen(true)}
>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</Button>
}
/>
</div>
</div>
<ProjectEditDialog open={isEditOpen} onOpenChange={setIsEditOpen} project={project} />
<ProjectDeleteDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen} project={project} />
<button
type="button"
onClick={startProjectChat}
className={cn(
'mt-6 flex w-full items-center gap-3 rounded-[26px] border border-border-medium bg-surface-secondary px-3.5 py-3 text-left shadow-sm transition-colors',
'hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
'mt-7 flex w-full items-center gap-3 rounded-2xl border border-border-light bg-surface-secondary px-3.5 py-3 text-left',
'transition-colors duration-150 hover:bg-surface-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary',
)}
aria-label={localize('com_ui_new_chat_in_project', { name: project.name })}
>
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-surface-tertiary text-text-primary">
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-surface-tertiary text-text-primary">
<Plus className="h-4 w-4" aria-hidden="true" />
</span>
<span className="min-w-0 flex-1 truncate text-text-secondary">
<span className="min-w-0 flex-1 truncate text-sm text-text-primary">
{localize('com_ui_new_chat_in_project', { name: project.name })}
</span>
</button>
<section className="mt-8 flex min-h-0 flex-1 flex-col">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="mb-3 flex items-center justify-between gap-3">
<h2 className="flex items-baseline gap-2 text-sm font-medium text-text-primary">
{localize('com_ui_chats')}
<span className="text-text-secondary">{project.conversationCount}</span>
<span className="tabular-nums text-text-secondary">{project.conversationCount}</span>
</h2>
<DropdownPopup
portal={true}
@ -188,7 +249,7 @@ export default function ProjectWorkspace() {
<Ariakit.MenuButton
aria-label={localize('com_ui_sort_chats_by')}
className={cn(
'inline-flex h-8 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-2.5 text-sm font-medium 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',
'inline-flex h-8 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-2.5 text-sm font-medium text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary',
isSortMenuOpen && 'bg-surface-hover text-text-primary',
)}
>

View file

@ -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 (
<header className="sticky top-0 z-10 border-b border-border-light bg-presentation">
<div className="mx-auto flex h-14 w-full max-w-6xl items-center justify-between gap-3 px-4 md:h-16 md:px-6">
<div className="flex min-w-0 items-center gap-2.5">
{isSmallScreen ? <OpenSidebar className="size-9 shrink-0" /> : null}
<h1 className="truncate text-balance text-lg font-semibold tracking-tight text-text-primary md:text-xl">
{localize('com_ui_projects')}
</h1>
</div>
<Button type="button" variant="default" size="sm" onClick={onCreate} className="shrink-0">
<Plus className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_new_project')}
</Button>
</div>
</header>
);
}

View file

@ -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<MenuItemProps[]>(
() => [
{
id: `${menuId}-edit`,
label: localize('com_ui_edit_project'),
icon: <Pencil className="size-4 text-text-secondary" aria-hidden="true" />,
onClick: () => setIsEditOpen(true),
},
{
id: `${menuId}-delete`,
label: localize('com_ui_delete'),
icon: <Trash2 className="size-4 text-text-secondary" aria-hidden="true" />,
onClick: () => setIsDeleteOpen(true),
},
],
[localize, menuId],
);
return (
<article
className={cn(
'group/project relative flex min-h-[9.5rem] flex-col rounded-2xl border border-border-light bg-surface-secondary',
'transition-colors duration-150 ease-out hover:bg-surface-hover',
'motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-1 motion-safe:fill-mode-both',
)}
style={{ animationDelay: `${Math.min(index, 8) * 40}ms` }}
>
<button
type="button"
className="flex min-h-[9.5rem] flex-1 flex-col rounded-2xl p-4 pr-12 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-text-primary"
onClick={() => onOpen(project._id)}
>
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-surface-tertiary text-text-secondary transition-colors group-hover/project:text-text-primary">
<Folder className="h-5 w-5" aria-hidden="true" />
</span>
<span className="mt-3 truncate text-base font-semibold tracking-tight text-text-primary">
{project.name}
</span>
{project.description ? (
<span className="mt-1 line-clamp-2 text-pretty text-sm leading-relaxed text-text-secondary">
{project.description}
</span>
) : null}
<span className="mt-auto flex items-center gap-2 pt-4 text-xs tabular-nums text-text-secondary">
<span>
{project.conversationCount === 1
? localize('com_ui_project_chat_count_single')
: localize('com_ui_project_chat_count', {
count: project.conversationCount,
})}
</span>
{activity ? (
<>
<span aria-hidden="true">·</span>
<time dateTime={project.lastConversationAt ?? project.updatedAt ?? project.createdAt}>
{activity}
</time>
</>
) : null}
</span>
</button>
<div className="absolute right-2 top-2">
<DropdownPopup
portal={true}
focusLoop={true}
unmountOnHide={true}
menuId={menuId}
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
className="z-[125] min-w-44"
iconClassName="mr-2 text-text-secondary"
trigger={
<Ariakit.MenuButton
aria-label={localize('com_ui_more_options')}
className={cn(
'flex h-8 w-8 items-center justify-center rounded-lg text-text-secondary outline-none transition-colors',
'hover:bg-surface-tertiary hover:text-text-primary',
'focus-visible:ring-2 focus-visible:ring-text-primary',
isMenuOpen && 'bg-surface-tertiary text-text-primary',
)}
>
<Ellipsis className="h-4 w-4" aria-hidden="true" />
</Ariakit.MenuButton>
}
items={menuItems}
/>
</div>
<ProjectEditDialog open={isEditOpen} onOpenChange={setIsEditOpen} project={project} />
<ProjectDeleteDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen} project={project} />
</article>
);
}
function ProjectGridSkeleton() {
return (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3" aria-hidden="true">
{Array.from({ length: 6 }, (_, index) => (
<div
key={index}
className="flex min-h-[9.5rem] flex-col rounded-2xl bg-surface-secondary p-4"
>
<Skeleton className="h-11 w-11 rounded-xl" />
<Skeleton className="mt-3 h-5 w-2/3" />
<Skeleton className="mt-2 h-4 w-full" />
<Skeleton className="mt-auto h-3 w-24" />
</div>
))}
</div>
);
}
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 (
<main className="flex h-full min-h-0 flex-col overflow-auto bg-surface-primary text-text-primary">
<div className="container mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 px-4 py-8 md:px-6 lg:pt-12">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2.5">
{isSmallScreen ? <OpenSidebar /> : null}
<h1 className="text-2xl font-bold tracking-tight text-text-primary md:text-3xl">
{localize('com_ui_projects')}
</h1>
</div>
<div className="flex items-center gap-2">
<span className="hidden text-sm text-text-secondary sm:inline">
{localize('com_ui_sort_by')}
</span>
<DropdownPopup
portal={true}
focusLoop={true}
unmountOnHide={true}
menuId={sortMenuId}
isOpen={isSortMenuOpen}
setIsOpen={setIsSortMenuOpen}
className="z-[125] min-w-56"
trigger={
<Ariakit.MenuButton
aria-label={localize('com_ui_sort_projects_by')}
className={cn(
'inline-flex h-10 items-center justify-between gap-2 whitespace-nowrap rounded-lg border border-border-medium bg-surface-secondary px-3 text-sm font-medium text-text-primary transition-colors hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary disabled:pointer-events-none disabled:opacity-50 sm:w-44',
isSortMenuOpen && 'bg-surface-hover text-text-primary',
)}
>
<span className="flex min-w-0 items-center gap-2">
<ArrowUpDown
className="h-4 w-4 shrink-0 text-text-secondary"
aria-hidden="true"
/>
<span className="truncate">{selectedSortLabel}</span>
</span>
</Ariakit.MenuButton>
}
items={sortMenuItems}
/>
<Button type="button" variant="submit" size="sm" onClick={() => setIsCreating(true)}>
<Plus className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_new_project')}
</Button>
</div>
</div>
<main className="flex h-full min-h-0 flex-col overflow-auto bg-presentation text-text-primary">
<ProjectsNavBar onCreate={() => setIsCreating(true)} />
<div className="flex flex-col gap-5">
<div className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-4 pb-10 pt-6 md:px-6 md:pt-8">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<label className="relative min-w-0 flex-1">
<span className="sr-only">{localize('com_ui_search_projects')}</span>
<Search
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary"
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-text-tertiary"
aria-hidden="true"
/>
<Input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder={localize('com_ui_search_projects')}
className="border-border-medium bg-surface-secondary pl-9 text-text-primary placeholder:text-text-secondary focus-visible:ring-2 focus-visible:ring-ring-primary"
className="h-11 rounded-xl bg-surface-secondary pl-10"
/>
</label>
<div className="flex items-center">
<span className="rounded-full bg-surface-active-alt px-4 py-2 text-sm font-medium text-text-primary">
{localize('com_ui_your_projects')}
</span>
</div>
<DropdownPopup
portal={true}
focusLoop={true}
unmountOnHide={true}
menuId={sortMenuId}
isOpen={isSortMenuOpen}
setIsOpen={setIsSortMenuOpen}
className="z-[125] min-w-56"
trigger={
<Ariakit.MenuButton
aria-label={localize('com_ui_sort_projects_by')}
className={cn(
'inline-flex h-11 shrink-0 items-center justify-center gap-2 rounded-xl px-3 text-sm font-medium text-text-secondary transition-colors',
'hover:bg-surface-hover hover:text-text-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-text-primary',
isSortMenuOpen && 'bg-surface-hover text-text-primary',
)}
>
<ArrowUpDown className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="truncate">{selectedSortLabel}</span>
</Ariakit.MenuButton>
}
items={sortMenuItems}
/>
</div>
<div className="mt-8 flex items-baseline justify-between gap-3">
<h2 className="text-sm font-medium text-text-primary">
{localize('com_ui_your_projects')}
</h2>
{!isLoading && projects.length > 0 ? (
<p className="text-sm tabular-nums text-text-secondary">{projectCountLabel}</p>
) : null}
</div>
<ProjectCreateDialog
@ -176,62 +311,54 @@ export default function ProjectsView() {
onCreated={(project) => navigate(`/projects/${project._id}`)}
/>
{isLoading ? (
<div className="flex flex-1 items-center justify-center">
<Spinner className="text-text-primary" />
</div>
) : (
<div className="grid gap-3 md:grid-cols-2 md:gap-4">
{projects.map((project) => {
const activity = formatActivity(project);
return (
<button
<div className="mt-4 flex flex-1 flex-col">
{isLoading && <ProjectGridSkeleton />}
{!isLoading && projects.length > 0 && (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{projects.map((project, index) => (
<ProjectCard
key={project._id}
type="button"
className={cn(
'group/project flex min-h-[8.5rem] flex-col rounded-xl border border-border-medium bg-surface-secondary p-4 text-left transition-colors',
'hover:border-border-heavy hover:bg-surface-tertiary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary',
)}
onClick={() => navigate(`/projects/${project._id}`)}
>
<span className="flex min-w-0 items-center gap-2">
<Folder className="h-4 w-4 shrink-0 text-text-secondary" aria-hidden="true" />
<span className="truncate text-base font-semibold text-text-primary">
{project.name}
</span>
</span>
{project.description ? (
<span className="mt-2 line-clamp-2 text-sm leading-relaxed text-text-secondary">
{project.description}
</span>
) : null}
<span className="mt-auto flex items-center justify-between gap-2 pt-4 text-xs text-text-secondary">
<span>
{project.conversationCount === 1
? localize('com_ui_project_chat_count_single')
: localize('com_ui_project_chat_count', {
count: project.conversationCount,
})}
</span>
{activity ? <span className="shrink-0 truncate">{activity}</span> : null}
</span>
</button>
);
})}
</div>
)}
{!isLoading && projects.length === 0 && (
<div className="rounded-lg border border-border-medium bg-transparent py-16 text-center text-sm text-text-secondary">
{localize('com_ui_no_projects')}
</div>
)}
project={project}
index={index}
onOpen={(projectId) => navigate(`/projects/${projectId}`)}
/>
))}
</div>
)}
{!isLoading && projects.length === 0 && (
<div className="flex flex-1 flex-col items-center justify-center rounded-2xl bg-surface-secondary px-6 py-16 text-center">
<span className="flex h-14 w-14 items-center justify-center rounded-2xl bg-surface-tertiary text-text-secondary">
<FolderPlus className="h-7 w-7" aria-hidden="true" />
</span>
<h3 className="mt-4 text-balance text-base font-semibold text-text-primary">
{search ? localize('com_ui_no_matching_projects') : localize('com_ui_no_projects')}
</h3>
{!search ? (
<>
<p className="mt-1 max-w-sm text-pretty text-sm text-text-secondary">
{localize('com_ui_add_first_project')}
</p>
<Button
type="button"
variant="default"
size="sm"
className="mt-5"
onClick={() => setIsCreating(true)}
>
<FolderPlus className="h-4 w-4" aria-hidden="true" />
{localize('com_ui_new_project')}
</Button>
</>
) : null}
</div>
)}
</div>
{hasNextPage && (
<Button
type="button"
variant="outline"
className="mx-auto"
className="mx-auto mt-8"
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>

View file

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

View file

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

View file

@ -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(
<ControlCombobox
selectedValue="a"
displayValue="Option A"
items={items}
setValue={() => 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(
<OGDialog open>
<OGDialogContent>
<OGDialogTitle>Change project</OGDialogTitle>
<ControlCombobox
selectedValue="a"
displayValue="Option A"
items={items}
setValue={() => undefined}
ariaLabel="Test combobox"
searchPlaceholder="Search projects"
isCollapsed={false}
showCarat
portal={portal}
/>
</OGDialogContent>
</OGDialog>,
);
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();
});
});

View file

@ -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<HTMLButtonElement>(null);
@ -161,12 +175,18 @@ function ControlCombobox({
</Ariakit.Select>
<Ariakit.SelectPopover
store={select}
gutter={4}
portal
gutter={gutter}
portal={portal}
className={cn(
'animate-popover overflow-hidden rounded-xl border border-border-light bg-surface-secondary shadow-lg',
'overflow-hidden rounded-xl border border-border-light bg-surface-secondary shadow-lg',
popoverClassName ?? 'animate-popover',
)}
style={{ zIndex: popoverZIndex, width: isCollapsed ? '300px' : (buttonWidth ?? '300px') }}
style={{
zIndex: popoverZIndex,
...(matchTriggerWidth
? { width: isCollapsed ? '300px' : (buttonWidth ?? '300px') }
: { minWidth: '16rem' }),
}}
>
<div className="py-1.5">
<div className="relative">

View file

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

View file

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

View file

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

View file

@ -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 { buildRetentionVisibilityFilter } from '~/utils/retention';
import { escapeRegExp } from '~/utils/string';
import type { IChatProject, IChatProjectDocument, IConversation } from '~/types';
import { buildRetentionVisibilityFilter } from '~/utils/retention';
import { isValidObjectIdString } from '~/utils/objectId';
import { escapeRegExp } from '~/utils/string';
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<IChatProjectDocument>[] = [{ 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<IChatProjectDocument>;
const update: Partial<Pick<IChatProject, 'name' | 'description'>> = {};
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(