mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 18:13:27 +00:00
🗂️ 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:
parent
1153b70898
commit
0b995065bc
24 changed files with 1210 additions and 474 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 /> }));
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
151
client/src/components/Projects/ProjectChatOptions.tsx
Normal file
151
client/src/components/Projects/ProjectChatOptions.tsx
Normal 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);
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
73
client/src/components/Projects/ProjectDeleteDialog.tsx
Normal file
73
client/src/components/Projects/ProjectDeleteDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
client/src/components/Projects/ProjectEditDialog.tsx
Normal file
138
client/src/components/Projects/ProjectEditDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
30
client/src/components/Projects/ProjectsNavBar.tsx
Normal file
30
client/src/components/Projects/ProjectsNavBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue