mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
style: Align project menus and composer
This commit is contained in:
parent
c66b700a5b
commit
6741b70f26
8 changed files with 321 additions and 218 deletions
|
|
@ -3,11 +3,18 @@ import { useRecoilValue } from 'recoil';
|
|||
import { useForm } from 'react-hook-form';
|
||||
import { Spinner } from '@librechat/client';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Folder } from 'lucide-react';
|
||||
import { Constants, buildTree } from 'librechat-data-provider';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import type { TChatProject, TMessage } from 'librechat-data-provider';
|
||||
import type { ChatFormValues } from '~/common';
|
||||
import { ChatContext, AddedChatContext, ChatFormProvider, useFileMapContext } from '~/Providers';
|
||||
import { useAddedResponse, useResumeOnLoad, useAdaptiveSSE, useChatHelpers } from '~/hooks';
|
||||
import {
|
||||
useAddedResponse,
|
||||
useResumeOnLoad,
|
||||
useAdaptiveSSE,
|
||||
useChatHelpers,
|
||||
useLocalize,
|
||||
} from '~/hooks';
|
||||
import ConversationStarters from './Input/ConversationStarters';
|
||||
import { useGetMessagesByConvoId } from '~/data-provider';
|
||||
import MessagesView from './Messages/MessagesView';
|
||||
|
|
@ -29,8 +36,27 @@ function LoadingSpinner() {
|
|||
);
|
||||
}
|
||||
|
||||
function ChatView({ index = 0 }: { index?: number }) {
|
||||
function ProjectLanding({ project }: { project: TChatProject }) {
|
||||
return (
|
||||
<div className="flex h-full max-h-full transform-gpu flex-col items-center justify-center pb-16 transition-all duration-200 sm:max-h-0">
|
||||
<div className="flex max-w-2xl flex-col items-center gap-3 px-4 text-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<Folder className="h-9 w-9 shrink-0 text-text-secondary" aria-hidden="true" />
|
||||
<h1 className="min-w-0 truncate text-2xl font-medium text-text-primary sm:text-4xl">
|
||||
{project.name}
|
||||
</h1>
|
||||
</div>
|
||||
{project.description && (
|
||||
<p className="max-w-md text-sm text-text-secondary">{project.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatView({ index = 0, project }: { index?: number; project?: TChatProject }) {
|
||||
const { conversationId } = useParams();
|
||||
const localize = useLocalize();
|
||||
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
|
||||
const isSubmitting = useRecoilValue(store.isSubmittingFamily(index));
|
||||
const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding);
|
||||
|
|
@ -70,6 +96,7 @@ function ChatView({ index = 0 }: { index?: number }) {
|
|||
(!messagesTree || messagesTree.length === 0) &&
|
||||
(conversationId === Constants.NEW_CONVO || !conversationId);
|
||||
const isNavigating = (!messagesTree || messagesTree.length === 0) && conversationId != null;
|
||||
const isProjectLandingPage = isLandingPage && project != null;
|
||||
|
||||
if (isLoading && conversationId !== Constants.NEW_CONVO) {
|
||||
content = <LoadingSpinner />;
|
||||
|
|
@ -77,10 +104,17 @@ function ChatView({ index = 0 }: { index?: number }) {
|
|||
content = <LoadingSpinner />;
|
||||
} else if (!isLandingPage) {
|
||||
content = <MessagesView messagesTree={messagesTree} />;
|
||||
} else if (isProjectLandingPage && project) {
|
||||
content = <ProjectLanding project={project} />;
|
||||
} else {
|
||||
content = <Landing centerFormOnLanding={centerFormOnLanding} />;
|
||||
}
|
||||
|
||||
const chatFormPlaceholder =
|
||||
isProjectLandingPage && project
|
||||
? localize('com_ui_new_chat_in_project', { name: project.name })
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<ChatFormProvider {...methods}>
|
||||
<ChatContext.Provider value={chatHelpers}>
|
||||
|
|
@ -104,7 +138,7 @@ function ChatView({ index = 0 }: { index?: number }) {
|
|||
isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl',
|
||||
)}
|
||||
>
|
||||
<ChatForm index={index} />
|
||||
<ChatForm index={index} placeholder={chatFormPlaceholder} />
|
||||
{isLandingPage ? <ConversationStarters /> : <Footer />}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import store from '~/store';
|
|||
|
||||
interface ChatFormProps {
|
||||
index: number;
|
||||
placeholder?: string;
|
||||
/** From ChatContext — individual values so memo can compare them */
|
||||
files: Map<string, ExtendedFile>;
|
||||
setFiles: FileSetter;
|
||||
|
|
@ -55,6 +56,7 @@ interface ChatFormProps {
|
|||
|
||||
const ChatForm = memo(function ChatForm({
|
||||
index,
|
||||
placeholder,
|
||||
files,
|
||||
setFiles,
|
||||
conversation,
|
||||
|
|
@ -177,6 +179,7 @@ const ChatForm = memo(function ChatForm({
|
|||
submitButtonRef,
|
||||
setIsScrollable,
|
||||
disabled: disableInputs,
|
||||
placeholder,
|
||||
});
|
||||
|
||||
useQueryParams({ textAreaRef });
|
||||
|
|
@ -413,7 +416,7 @@ 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 }: { index?: number }) {
|
||||
function ChatFormWrapper({ index = 0, placeholder }: { index?: number; placeholder?: string }) {
|
||||
const {
|
||||
files,
|
||||
setFiles,
|
||||
|
|
@ -465,6 +468,7 @@ function ChatFormWrapper({ index = 0 }: { index?: number }) {
|
|||
return (
|
||||
<ChatForm
|
||||
index={index}
|
||||
placeholder={placeholder}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
conversation={stableConversation}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,8 @@
|
|||
import { memo, type FC, type ReactNode } from 'react';
|
||||
import { memo, useId, useMemo, useState, type FC, type ReactNode } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { Check, Clock3, Ellipsis, Folder, FolderPlus, SquarePen, ChevronDown } from 'lucide-react';
|
||||
import {
|
||||
TooltipAnchor,
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
} from '@librechat/client';
|
||||
import { DropdownPopup, TooltipAnchor } from '@librechat/client';
|
||||
import type { MenuItemProps, RenderProp } from '~/common';
|
||||
import type { SidebarChatSort, SidebarOrganizationMode } from './types';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
|
@ -33,14 +26,50 @@ const headerIconButtonClassName =
|
|||
|
||||
const menuIconClassName = 'h-4 w-4 text-text-secondary';
|
||||
|
||||
const SelectionMark = memo(({ isSelected }: { isSelected: boolean }) => {
|
||||
if (!isSelected) {
|
||||
return <span className="ml-auto h-4 w-4" aria-hidden="true" />;
|
||||
}
|
||||
return <Check className="ml-auto h-4 w-4 text-text-primary" aria-hidden="true" />;
|
||||
});
|
||||
function renderSelectedMenuItem(label: string, icon: ReactNode, isSelected: boolean): RenderProp {
|
||||
return function SelectedMenuItem({ className, ...props }) {
|
||||
return (
|
||||
<div {...props} className={cn(className, 'justify-between gap-5')}>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="mr-0 flex h-4 w-4 shrink-0 items-center justify-center" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
<span className="truncate">{label}</span>
|
||||
</span>
|
||||
{isSelected ? (
|
||||
<Check className="h-4 w-4 shrink-0 text-text-primary" aria-hidden="true" />
|
||||
) : (
|
||||
<span className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
SelectionMark.displayName = 'ChatsHeaderSelectionMark';
|
||||
function createSelectedMenuItem<T extends string>({
|
||||
id,
|
||||
value,
|
||||
label,
|
||||
icon,
|
||||
selectedValue,
|
||||
onSelect,
|
||||
}: {
|
||||
id: string;
|
||||
value: T;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
selectedValue: T;
|
||||
onSelect: (value: T) => void;
|
||||
}): MenuItemProps {
|
||||
const isSelected = selectedValue === value;
|
||||
return {
|
||||
id,
|
||||
ariaLabel: label,
|
||||
ariaChecked: isSelected,
|
||||
onClick: () => onSelect(value),
|
||||
render: renderSelectedMenuItem(label, icon, isSelected),
|
||||
};
|
||||
}
|
||||
|
||||
const ChatsHeader: FC<ChatsHeaderProps> = ({
|
||||
isExpanded,
|
||||
|
|
@ -53,45 +82,68 @@ const ChatsHeader: FC<ChatsHeaderProps> = ({
|
|||
onNewChat,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const menuId = useId();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const organizationItems: Array<{
|
||||
value: SidebarOrganizationMode;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
}> = [
|
||||
{
|
||||
value: 'byProject',
|
||||
label: localize('com_ui_sidebar_mode_by_project'),
|
||||
icon: <Folder className={menuIconClassName} aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: 'recentProjects',
|
||||
label: localize('com_ui_sidebar_mode_recent_projects'),
|
||||
icon: <Folder className={menuIconClassName} aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: 'chronological',
|
||||
label: localize('com_ui_sidebar_mode_chronological_list'),
|
||||
icon: <Clock3 className={menuIconClassName} aria-hidden="true" />,
|
||||
},
|
||||
];
|
||||
|
||||
const sortItems: Array<{
|
||||
value: SidebarChatSort;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
}> = [
|
||||
{
|
||||
value: 'createdAt',
|
||||
label: localize('com_ui_sort_created'),
|
||||
icon: <Clock3 className={menuIconClassName} aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
value: 'updatedAt',
|
||||
label: localize('com_ui_sort_updated'),
|
||||
icon: <SquarePen className={menuIconClassName} aria-hidden="true" />,
|
||||
},
|
||||
];
|
||||
const dropdownItems = useMemo<MenuItemProps[]>(
|
||||
() => [
|
||||
{
|
||||
id: 'organize-sidebar',
|
||||
label: localize('com_ui_sidebar_organization_label'),
|
||||
icon: <Folder className={menuIconClassName} aria-hidden="true" />,
|
||||
subItems: [
|
||||
createSelectedMenuItem<SidebarOrganizationMode>({
|
||||
id: 'organize-by-project',
|
||||
value: 'byProject',
|
||||
label: localize('com_ui_sidebar_mode_by_project'),
|
||||
icon: <Folder className={menuIconClassName} aria-hidden="true" />,
|
||||
selectedValue: organizationMode,
|
||||
onSelect: onOrganizationModeChange,
|
||||
}),
|
||||
createSelectedMenuItem<SidebarOrganizationMode>({
|
||||
id: 'organize-recent-projects',
|
||||
value: 'recentProjects',
|
||||
label: localize('com_ui_sidebar_mode_recent_projects'),
|
||||
icon: <Folder className={menuIconClassName} aria-hidden="true" />,
|
||||
selectedValue: organizationMode,
|
||||
onSelect: onOrganizationModeChange,
|
||||
}),
|
||||
createSelectedMenuItem<SidebarOrganizationMode>({
|
||||
id: 'organize-chronological',
|
||||
value: 'chronological',
|
||||
label: localize('com_ui_sidebar_mode_chronological_list'),
|
||||
icon: <Clock3 className={menuIconClassName} aria-hidden="true" />,
|
||||
selectedValue: organizationMode,
|
||||
onSelect: onOrganizationModeChange,
|
||||
}),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sort-chats',
|
||||
label: localize('com_ui_sort_by'),
|
||||
icon: <Clock3 className={menuIconClassName} aria-hidden="true" />,
|
||||
subItems: [
|
||||
createSelectedMenuItem<SidebarChatSort>({
|
||||
id: 'sort-created',
|
||||
value: 'createdAt',
|
||||
label: localize('com_ui_sort_created'),
|
||||
icon: <Clock3 className={menuIconClassName} aria-hidden="true" />,
|
||||
selectedValue: chatSortBy,
|
||||
onSelect: onChatSortByChange,
|
||||
}),
|
||||
createSelectedMenuItem<SidebarChatSort>({
|
||||
id: 'sort-updated',
|
||||
value: 'updatedAt',
|
||||
label: localize('com_ui_sort_updated'),
|
||||
icon: <SquarePen className={menuIconClassName} aria-hidden="true" />,
|
||||
selectedValue: chatSortBy,
|
||||
onSelect: onChatSortByChange,
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
[chatSortBy, localize, onChatSortByChange, onOrganizationModeChange, organizationMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-8 w-full items-center gap-0.5">
|
||||
|
|
@ -111,60 +163,34 @@ const ChatsHeader: FC<ChatsHeaderProps> = ({
|
|||
/>
|
||||
</button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localize('com_nav_convo_menu_options')}
|
||||
title={localize('com_nav_convo_menu_options')}
|
||||
className={headerIconButtonClassName}
|
||||
>
|
||||
<Ellipsis className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Folder className={menuIconClassName} aria-hidden="true" />
|
||||
<span>{localize('com_ui_sidebar_organization_label')}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-56">
|
||||
{organizationItems.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.value}
|
||||
role="menuitemradio"
|
||||
aria-checked={organizationMode === item.value}
|
||||
onSelect={() => onOrganizationModeChange(item.value)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
<SelectionMark isSelected={organizationMode === item.value} />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Clock3 className={menuIconClassName} aria-hidden="true" />
|
||||
<span>{localize('com_ui_sort_by')}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="w-44">
|
||||
{sortItems.map((item) => (
|
||||
<DropdownMenuItem
|
||||
key={item.value}
|
||||
role="menuitemradio"
|
||||
aria-checked={chatSortBy === item.value}
|
||||
onSelect={() => onChatSortByChange(item.value)}
|
||||
>
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
<SelectionMark isSelected={chatSortBy === item.value} />
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<DropdownPopup
|
||||
portal={true}
|
||||
focusLoop={true}
|
||||
unmountOnHide={true}
|
||||
menuId={menuId}
|
||||
isOpen={isMenuOpen}
|
||||
setIsOpen={setIsMenuOpen}
|
||||
className="z-[125] min-w-56"
|
||||
iconClassName="mr-0 text-text-secondary"
|
||||
trigger={
|
||||
<TooltipAnchor
|
||||
description={localize('com_nav_convo_menu_options')}
|
||||
render={
|
||||
<Ariakit.MenuButton
|
||||
id="chats-header-menu-button"
|
||||
aria-label={localize('com_nav_convo_menu_options')}
|
||||
className={cn(
|
||||
headerIconButtonClassName,
|
||||
isMenuOpen && 'bg-surface-hover text-text-primary',
|
||||
)}
|
||||
>
|
||||
<Ellipsis className="h-4 w-4" aria-hidden="true" />
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
/>
|
||||
}
|
||||
items={dropdownItems}
|
||||
/>
|
||||
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_new_project')}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,39 @@
|
|||
import { useCallback, useMemo, useState, type FormEvent } from 'react';
|
||||
import { ArrowLeft, ArrowUpDown, Check, Folder, Plus, Send } from 'lucide-react';
|
||||
import { useCallback, useId, useMemo, useState } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ArrowLeft, ArrowUpDown, Check, Folder, Plus } from 'lucide-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Spinner,
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from '@librechat/client';
|
||||
import type { ConversationListResponse } from 'librechat-data-provider';
|
||||
import { Button, Spinner, DropdownPopup } from '@librechat/client';
|
||||
import type { MenuItemProps, RenderProp } from '~/common';
|
||||
import { useConversationsInfiniteQuery, useProjectQuery } from '~/data-provider';
|
||||
import { useLocalize, useNewConvo } from '~/hooks';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import ProjectChatList from './ProjectChatList';
|
||||
|
||||
type ChatSortField = 'updatedAt' | 'createdAt';
|
||||
|
||||
function renderSortMenuItem(label: string, isSelected: boolean): RenderProp {
|
||||
return function SortMenuItem({ className, ...props }) {
|
||||
return (
|
||||
<div {...props} className={cn(className, 'justify-between gap-5')}>
|
||||
<span className="truncate">{label}</span>
|
||||
{isSelected ? (
|
||||
<Check className="h-4 w-4 shrink-0 text-text-primary" aria-hidden="true" />
|
||||
) : (
|
||||
<span className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default function ProjectWorkspace() {
|
||||
const localize = useLocalize();
|
||||
const navigate = useNavigate();
|
||||
const { projectId = '' } = useParams();
|
||||
const { newConversation } = useNewConvo();
|
||||
const [sortBy, setSortBy] = useState<ChatSortField>('updatedAt');
|
||||
const sortMenuId = useId();
|
||||
const [isSortMenuOpen, setIsSortMenuOpen] = useState(false);
|
||||
const { data: project, isLoading: isProjectLoading } = useProjectQuery(projectId);
|
||||
const activeProjectId = project?._id;
|
||||
const sortOptions = useMemo(
|
||||
|
|
@ -33,6 +45,20 @@ export default function ProjectWorkspace() {
|
|||
);
|
||||
const selectedSortLabel =
|
||||
sortOptions.find((option) => option.value === sortBy)?.label ?? localize('com_ui_sort_updated');
|
||||
const sortMenuItems = useMemo<MenuItemProps[]>(
|
||||
() =>
|
||||
sortOptions.map((option) => {
|
||||
const isSelected = sortBy === option.value;
|
||||
return {
|
||||
id: `project-chat-sort-${option.value}`,
|
||||
ariaLabel: option.label,
|
||||
ariaChecked: isSelected,
|
||||
onClick: () => setSortBy(option.value),
|
||||
render: renderSortMenuItem(option.label, isSelected),
|
||||
};
|
||||
}),
|
||||
[sortBy, sortOptions],
|
||||
);
|
||||
|
||||
const {
|
||||
data,
|
||||
|
|
@ -66,19 +92,14 @@ export default function ProjectWorkspace() {
|
|||
return lastPage.nextCursor !== null;
|
||||
}, [data?.pages]);
|
||||
|
||||
const startProjectChat = useCallback(
|
||||
(event?: FormEvent<HTMLFormElement>) => {
|
||||
event?.preventDefault();
|
||||
if (!activeProjectId) {
|
||||
return;
|
||||
}
|
||||
newConversation({ template: { chatProjectId: activeProjectId } });
|
||||
navigate(`/c/new?projectId=${encodeURIComponent(activeProjectId)}`, {
|
||||
state: { focusChat: true },
|
||||
});
|
||||
},
|
||||
[activeProjectId, navigate, newConversation],
|
||||
);
|
||||
const startProjectChat = useCallback(() => {
|
||||
if (!activeProjectId) {
|
||||
return;
|
||||
}
|
||||
navigate(`/c/new?projectId=${encodeURIComponent(activeProjectId)}`, {
|
||||
state: { focusChat: true },
|
||||
});
|
||||
}, [activeProjectId, navigate]);
|
||||
|
||||
if (isProjectLoading) {
|
||||
return (
|
||||
|
|
@ -126,30 +147,6 @@ export default function ProjectWorkspace() {
|
|||
</Button>
|
||||
</header>
|
||||
|
||||
<form
|
||||
onSubmit={startProjectChat}
|
||||
className="rounded-2xl border border-border-medium bg-surface-primary p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex min-h-24 items-start gap-3">
|
||||
<Plus className="mt-1 h-5 w-5 shrink-0 text-text-secondary" aria-hidden="true" />
|
||||
<input
|
||||
className="min-w-0 flex-1 bg-transparent text-lg outline-none placeholder:text-text-tertiary"
|
||||
placeholder={localize('com_ui_new_chat_in_project', { name: project.name })}
|
||||
readOnly
|
||||
onFocus={() => startProjectChat()}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="submit"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
aria-label={localize('com_ui_new_chat')}
|
||||
>
|
||||
<Send className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section className="flex min-h-[360px] flex-1 flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="inline-flex rounded-lg border border-border-light p-1">
|
||||
|
|
@ -160,30 +157,28 @@ export default function ProjectWorkspace() {
|
|||
{localize('com_ui_chats')}
|
||||
</button>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-border-medium bg-transparent"
|
||||
<DropdownPopup
|
||||
portal={true}
|
||||
focusLoop={true}
|
||||
unmountOnHide={true}
|
||||
menuId={sortMenuId}
|
||||
isOpen={isSortMenuOpen}
|
||||
setIsOpen={setIsSortMenuOpen}
|
||||
className="z-[125] min-w-44"
|
||||
trigger={
|
||||
<Ariakit.MenuButton
|
||||
aria-label={localize('com_ui_sort_chats_by')}
|
||||
className={cn(
|
||||
'inline-flex h-9 items-center justify-center gap-2 whitespace-nowrap rounded-lg border border-border-medium bg-transparent px-3 text-sm font-medium text-text-primary ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
|
||||
isSortMenuOpen && 'bg-surface-hover text-text-primary',
|
||||
)}
|
||||
>
|
||||
<ArrowUpDown className="h-4 w-4 text-text-secondary" aria-hidden="true" />
|
||||
{selectedSortLabel}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-44">
|
||||
{sortOptions.map((option) => (
|
||||
<DropdownMenuItem key={option.value} onSelect={() => setSortBy(option.value)}>
|
||||
<span>{option.label}</span>
|
||||
{sortBy === option.value && (
|
||||
<Check className="ml-auto h-4 w-4 text-text-primary" aria-hidden="true" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={sortMenuItems}
|
||||
/>
|
||||
</div>
|
||||
<ProjectChatList
|
||||
conversations={conversations}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,10 @@
|
|||
import { useDeferredValue, useMemo, useState, type FormEvent } from 'react';
|
||||
import { useDeferredValue, useId, useMemo, useState, type FormEvent } from 'react';
|
||||
import * as Ariakit from '@ariakit/react';
|
||||
import { ArrowUpDown, Check, Folder, Plus, Search } from 'lucide-react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Input,
|
||||
Button,
|
||||
Spinner,
|
||||
DropdownMenu,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
useToastContext,
|
||||
} from '@librechat/client';
|
||||
import type { TChatProject } from 'librechat-data-provider';
|
||||
import { Input, Button, Spinner, DropdownPopup, useToastContext } from '@librechat/client';
|
||||
import type { MenuItemProps, RenderProp } from '~/common';
|
||||
import { useCreateProjectMutation, useProjectsInfiniteQuery } from '~/data-provider';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
|
|
@ -19,6 +12,21 @@ import { cn } from '~/utils';
|
|||
|
||||
type ProjectSort = 'name' | 'createdAt' | 'lastConversationAt';
|
||||
|
||||
function renderSortMenuItem(label: string, isSelected: boolean): RenderProp {
|
||||
return function SortMenuItem({ className, ...props }) {
|
||||
return (
|
||||
<div {...props} className={cn(className, 'justify-between gap-5')}>
|
||||
<span className="truncate">{label}</span>
|
||||
{isSelected ? (
|
||||
<Check className="h-4 w-4 shrink-0 text-text-primary" aria-hidden="true" />
|
||||
) : (
|
||||
<span className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function formatActivity(project: TChatProject, fallback: string) {
|
||||
const value = project.lastConversationAt ?? project.updatedAt ?? project.createdAt;
|
||||
return value ? new Date(value).toLocaleString() : fallback;
|
||||
|
|
@ -31,6 +39,8 @@ export default function ProjectsView() {
|
|||
const [search, setSearch] = useState('');
|
||||
const [sortBy, setSortBy] = useState<ProjectSort>('lastConversationAt');
|
||||
const [isCreating, setIsCreating] = useState(searchParams.get('new') === '1');
|
||||
const sortMenuId = useId();
|
||||
const [isSortMenuOpen, setIsSortMenuOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const deferredSearch = useDeferredValue(search);
|
||||
const createProject = useCreateProjectMutation();
|
||||
|
|
@ -55,6 +65,20 @@ export default function ProjectsView() {
|
|||
const selectedSortLabel =
|
||||
sortOptions.find((option) => option.value === sortBy)?.label ??
|
||||
localize('com_ui_latest_activity');
|
||||
const sortMenuItems = useMemo<MenuItemProps[]>(
|
||||
() =>
|
||||
sortOptions.map((option) => {
|
||||
const isSelected = sortBy === option.value;
|
||||
return {
|
||||
id: `project-sort-${option.value}`,
|
||||
ariaLabel: option.label,
|
||||
ariaChecked: isSelected,
|
||||
onClick: () => setSortBy(option.value),
|
||||
render: renderSortMenuItem(option.label, isSelected),
|
||||
};
|
||||
}),
|
||||
[sortBy, sortOptions],
|
||||
);
|
||||
|
||||
const handleCreate = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
|
@ -103,13 +127,21 @@ export default function ProjectsView() {
|
|||
className="border-border-medium bg-transparent pl-9 text-text-primary placeholder:text-text-secondary focus-visible:ring-2 focus-visible:ring-ring-primary"
|
||||
/>
|
||||
</label>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 justify-between border-border-medium bg-transparent px-3 sm:w-56"
|
||||
<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-transparent px-3 text-sm font-medium text-text-primary ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 sm:w-56',
|
||||
isSortMenuOpen && 'bg-surface-hover text-text-primary',
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<ArrowUpDown
|
||||
|
|
@ -118,19 +150,10 @@ export default function ProjectsView() {
|
|||
/>
|
||||
<span className="truncate">{selectedSortLabel}</span>
|
||||
</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56">
|
||||
{sortOptions.map((option) => (
|
||||
<DropdownMenuItem key={option.value} onSelect={() => setSortBy(option.value)}>
|
||||
<span>{option.label}</span>
|
||||
{sortBy === option.value && (
|
||||
<Check className="ml-auto h-4 w-4 text-text-primary" aria-hidden="true" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Ariakit.MenuButton>
|
||||
}
|
||||
items={sortMenuItems}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isCreating && (
|
||||
|
|
|
|||
|
|
@ -193,7 +193,10 @@ export default function useChatFunctions({
|
|||
parentMessageId = Constants.NO_PARENT;
|
||||
currentMessages = [];
|
||||
conversationId = null;
|
||||
navigate('/c/new', { state: { focusChat: true } });
|
||||
const projectSearch = conversation?.chatProjectId
|
||||
? `?projectId=${encodeURIComponent(conversation.chatProjectId)}`
|
||||
: '';
|
||||
navigate(`/c/new${projectSearch}`, { state: { focusChat: true } });
|
||||
}
|
||||
|
||||
const targetParentMessageId = isRegenerate ? messageId : latestMessage?.parentMessageId;
|
||||
|
|
|
|||
|
|
@ -28,11 +28,13 @@ export default function useTextarea({
|
|||
submitButtonRef,
|
||||
setIsScrollable,
|
||||
disabled = false,
|
||||
placeholder,
|
||||
}: {
|
||||
textAreaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
submitButtonRef: React.RefObject<HTMLButtonElement>;
|
||||
setIsScrollable: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const getSender = useGetSender();
|
||||
|
|
@ -95,6 +97,10 @@ export default function useTextarea({
|
|||
return localize('com_endpoint_message_not_appendable');
|
||||
}
|
||||
|
||||
if (placeholder) {
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
const sender =
|
||||
isAssistant || isAgent
|
||||
? getEntityName({ name: entityName, isAgent, localize })
|
||||
|
|
@ -137,6 +143,7 @@ export default function useTextarea({
|
|||
conversation,
|
||||
latestMessage,
|
||||
isNotAppendable,
|
||||
placeholder,
|
||||
]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
|
|
|
|||
|
|
@ -98,15 +98,24 @@ export default function ChatRoute() {
|
|||
useEffect(() => {
|
||||
// Wait for roles to load so hasAgentAccess has a definitive value in useNewConvo
|
||||
const rolesLoaded = roles?.USER != null;
|
||||
const isNewConvo = conversationId === Constants.NEW_CONVO;
|
||||
const newConvoNeedsInit =
|
||||
isNewConvo &&
|
||||
(conversation?.conversationId !== Constants.NEW_CONVO ||
|
||||
(verifiedChatProjectId
|
||||
? conversation?.chatProjectId !== verifiedChatProjectId
|
||||
: conversation?.chatProjectId != null));
|
||||
const shouldSetConvo =
|
||||
(startupConfig && rolesLoaded && !hasSetConversation.current && !modelsQuery.data?.initial) ??
|
||||
(startupConfig &&
|
||||
rolesLoaded &&
|
||||
(!hasSetConversation.current || newConvoNeedsInit) &&
|
||||
!modelsQuery.data?.initial) ??
|
||||
false;
|
||||
/* Early exit if startupConfig is not loaded and conversation is already set and only initial models have loaded */
|
||||
if (!shouldSetConvo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isNewConvo = conversationId === Constants.NEW_CONVO;
|
||||
if (isNewConvo && chatProjectId && projectQuery.isLoading) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -218,6 +227,8 @@ export default function ChatRoute() {
|
|||
chatProjectId,
|
||||
projectQuery.data?._id,
|
||||
projectQuery.isLoading,
|
||||
conversation?.chatProjectId,
|
||||
conversation?.conversationId,
|
||||
]);
|
||||
|
||||
if (endpointsQuery.isLoading || modelsQuery.isLoading) {
|
||||
|
|
@ -247,7 +258,7 @@ export default function ChatRoute() {
|
|||
|
||||
return (
|
||||
<ToolCallsMapProvider conversationId={conversation.conversationId ?? ''}>
|
||||
<ChatView index={index} />
|
||||
<ChatView index={index} project={verifiedChatProjectId ? projectQuery.data : undefined} />
|
||||
</ToolCallsMapProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue