🪄 refactor: UI Polish and Admin Dialog Unification (#11108)

* refactor(OpenSidebar): removed useless classNames

* style(Header): update hover styles across various components for improved UI consistency

* style(Nav): update hover styles in AccountSettings and SearchBar for improved UI consistency

* style: update button classes for consistent hover effects and improved UI responsiveness

* style(Nav, OpenSidebar, Header, Convo): improve UI responsiveness and animation transitions

* style(PresetsMenu, NewChat): update icon sizes and improve component styling for better UI consistency

* style(Nav, Root): enhance sidebar mobile animations and responsiveness for better UI experience

* style(ExportAndShareMenu, BookmarkMenu): update icon sizes for improved UI consistency

* style: remove transition duration from button classes for improved UI responsiveness

* style(CustomMenu, ModelSelector): update background colors for improved UI consistency and responsiveness

* style(ExportAndShareMenu): update icon color for improved UI consistency

* style(TemporaryChat): refine button styles for improved UI consistency and responsiveness

* style(BookmarkNav): refactor to use DropdownPopup and remove BookmarkNavItems for improved UI consistency and functionality

* style(CustomMenu, EndpointItem): enhance UI elements for improved consistency and accessibility

* style(EndpointItem): adjust gap in icon container for improved layout consistency

* style(CustomMenu, EndpointItem): update focus ring color for improved UI consistency

* style(EndpointItem): update icon color for improved UI consistency in dark theme

* style: update focus styles for improved accessibility and consistency across components

* refactor(Nav): extract sidebar width to NAV_WIDTH constant

Centralize mobile (320px) and desktop (260px) sidebar widths in a single
exported constant to avoid magic numbers and ensure consistency.

* fix(BookmarkNav): memoize handlers used in useMemo

Wrap handleTagClick and handleClear in useCallback and add them to the
dropdownItems useMemo dependency array to prevent stale closures.

* feat: introduce FilterInput component and replace existing inputs with it across multiple components

* feat(DataTable): replace custom input with FilterInput component for improved filtering

* fix: Nested dialog overlay stacking issue

Fixes overlay appearing behind content when opening nested dialogs.
Introduced dynamic z-index calculation based on dialog depth using React context.

- First dialog: overlay z-50, content z-100
- Nested dialogs increment by 60: overlay z-110/content z-160, etc.

Preserves a11y escape key handling from #10975 and #10851.

Regression from #11008 (afb67fcf1) which increased content z-index
without adjusting overlay z-index for nested dialog scenarios.

* Refactor admin settings components to use a unified AdminSettingsDialog

- Removed redundant code from AdminSettings, MCPAdminSettings, and Memories AdminSettings components.
- Introduced AdminSettingsDialog component to handle permission management for different sections.
- Updated permission handling logic to use a consistent structure across components.
- Enhanced role selection and permission confirmation features in the new dialog.
- Improved UI consistency and maintainability by centralizing dialog functionality.

* refactor(Memory): memory management UI components and replace MemoryViewer with MemoryPanel

* refactor(Memory): enhance UI components for Memory dialogs and improve input styling

* refactor(Bookmarks): improve bookmark management UI with enhanced styling

* refactor(translations): remove redundant filter input and bookmark count entries

* refactor(Convo): integrate useShiftKey hook for enhanced keyboard interaction and improve UI responsiveness
This commit is contained in:
Marco Beretta 2025-12-28 17:01:25 +01:00 committed by GitHub
parent c21733930c
commit 5181356bef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
71 changed files with 2115 additions and 2191 deletions

View file

@ -268,7 +268,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
variant="outline"
data-testid="agents-new-chat-button"
aria-label={localize('com_ui_new_chat')}
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-active-alt max-md:hidden"
onClick={handleNewChat}
>
<NewChatIcon />

View file

@ -1,75 +1,20 @@
import { useMemo, useEffect, useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { ShieldEllipsis } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
import {
Button,
Switch,
OGDialog,
DropdownPopup,
OGDialogTitle,
OGDialogContent,
OGDialogTrigger,
useToastContext,
} from '@librechat/client';
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import { Button, useToastContext } from '@librechat/client';
import { AdminSettingsDialog } from '~/components/ui';
import { useUpdateMarketplacePermissionsMutation } from '~/data-provider';
import { useLocalize, useAuthContext } from '~/hooks';
import { useLocalize } from '~/hooks';
import type { PermissionConfig } from '~/components/ui';
type FormValues = {
[Permissions.USE]: boolean;
};
type LabelControllerProps = {
label: string;
marketplacePerm: Permissions.USE;
control: Control<FormValues, unknown, FormValues>;
setValue: UseFormSetValue<FormValues>;
getValues: UseFormGetValues<FormValues>;
};
const LabelController: React.FC<LabelControllerProps> = ({
control,
marketplacePerm,
label,
getValues,
setValue,
}) => (
<div className="mb-4 flex items-center justify-between gap-2">
<button
className="cursor-pointer select-none"
type="button"
onClick={() =>
setValue(marketplacePerm, !getValues(marketplacePerm), {
shouldDirty: true,
})
}
tabIndex={0}
>
{label}
</button>
<Controller
name={marketplacePerm}
control={control}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onCheckedChange={field.onChange}
value={field.value.toString()}
aria-label={label}
/>
)}
/>
</div>
);
const permissions: PermissionConfig[] = [
{ permission: Permissions.USE, labelKey: 'com_ui_marketplace_allow_use' },
];
const MarketplaceAdminSettings = () => {
const localize = useLocalize();
const { showToast } = useToastContext();
const { user, roles } = useAuthContext();
const { mutate, isLoading } = useUpdateMarketplacePermissionsMutation({
const mutation = useUpdateMarketplacePermissionsMutation({
onSuccess: () => {
showToast({ status: 'success', message: localize('com_ui_saved') });
},
@ -78,133 +23,27 @@ const MarketplaceAdminSettings = () => {
},
});
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
const defaultValues = useMemo(() => {
const rolePerms = roles?.[selectedRole]?.permissions;
if (rolePerms) {
return rolePerms[PermissionTypes.MARKETPLACE];
}
return roleDefaults[selectedRole].permissions[PermissionTypes.MARKETPLACE];
}, [roles, selectedRole]);
const {
reset,
control,
setValue,
getValues,
handleSubmit,
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues,
});
useEffect(() => {
const value = roles?.[selectedRole]?.permissions?.[PermissionTypes.MARKETPLACE];
if (value) {
reset(value);
} else {
reset(roleDefaults[selectedRole].permissions[PermissionTypes.MARKETPLACE]);
}
}, [roles, selectedRole, reset]);
if (user?.role !== SystemRoles.ADMIN) {
return null;
}
const labelControllerData: {
marketplacePerm: Permissions.USE;
label: string;
}[] = [
{
marketplacePerm: Permissions.USE,
label: localize('com_ui_marketplace_allow_use'),
},
];
const onSubmit = (data: FormValues) => {
mutate({ roleName: selectedRole, updates: data });
};
const roleDropdownItems = [
{
label: SystemRoles.USER,
onClick: () => {
setSelectedRole(SystemRoles.USER);
},
},
{
label: SystemRoles.ADMIN,
onClick: () => {
setSelectedRole(SystemRoles.ADMIN);
},
},
];
const trigger = (
<Button
variant="outline"
className="relative h-12 rounded-xl border-border-medium font-medium"
aria-label={localize('com_ui_admin_settings')}
>
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
</Button>
);
return (
<OGDialog>
<OGDialogTrigger asChild>
<Button
variant="outline"
className="relative h-12 rounded-xl border-border-medium font-medium"
>
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
</Button>
</OGDialogTrigger>
<OGDialogContent className="w-11/12 max-w-md border-border-light bg-surface-primary text-text-primary">
<OGDialogTitle>
{localize('com_ui_admin_settings_section', { section: localize('com_ui_marketplace') })}
</OGDialogTitle>
<div className="p-2">
{/* Role selection dropdown */}
<div className="flex items-center gap-2">
<span className="font-medium">{localize('com_ui_role_select')}:</span>
<DropdownPopup
unmountOnHide={true}
menuId="role-dropdown"
isOpen={isRoleMenuOpen}
setIsOpen={setIsRoleMenuOpen}
trigger={
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
{selectedRole}
</Ariakit.MenuButton>
}
items={roleDropdownItems}
itemClassName="items-center justify-center"
sameWidth={true}
/>
</div>
{/* Permissions form */}
<form onSubmit={handleSubmit(onSubmit)}>
<div className="py-5">
{labelControllerData.map(({ marketplacePerm, label }) => (
<div key={marketplacePerm}>
<LabelController
control={control}
marketplacePerm={marketplacePerm}
label={label}
getValues={getValues}
setValue={setValue}
/>
</div>
))}
</div>
<div className="flex justify-end">
<button
type="button"
onClick={handleSubmit(onSubmit)}
disabled={isSubmitting || isLoading}
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600"
>
{localize('com_ui_save')}
</button>
</div>
</form>
</div>
</OGDialogContent>
</OGDialog>
<AdminSettingsDialog
permissionType={PermissionTypes.MARKETPLACE}
sectionKey="com_ui_marketplace"
permissions={permissions}
menuId="marketplace-role-dropdown"
mutation={mutation}
trigger={trigger}
dialogContentClassName="w-11/12 max-w-md border-border-light bg-surface-primary text-text-primary"
showAdminWarning={false}
/>
);
};

View file

@ -91,7 +91,7 @@ const BookmarkEditDialog = ({
<OGDialogTemplate
title={bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')}
showCloseButton={false}
className="w-11/12 md:max-w-2xl"
className="w-11/12 md:max-w-lg"
main={
<BookmarkForm
tags={tags}

View file

@ -85,16 +85,11 @@ const BookmarkForm = ({
};
return (
<form
ref={formRef}
className="mt-6"
aria-label="Bookmark form"
method="POST"
onSubmit={handleSubmit(onSubmit)}
>
<div className="flex w-full flex-col items-center gap-2">
<div className="grid w-full items-center gap-2">
<Label htmlFor="bookmark-tag" className="text-left text-sm font-medium">
<form ref={formRef} aria-label="Bookmark form" method="POST" onSubmit={handleSubmit(onSubmit)}>
<div className="space-y-4">
{/* Tag name input */}
<div className="space-y-2">
<Label htmlFor="bookmark-tag" className="text-sm font-medium text-text-primary">
{localize('com_ui_bookmarks_title')}
</Label>
<Input
@ -118,24 +113,24 @@ const BookmarkForm = ({
);
},
})}
className="w-full"
aria-invalid={!!errors.tag}
placeholder={
bookmark ? localize('com_ui_bookmarks_edit') : localize('com_ui_bookmarks_new')
}
placeholder={localize('com_ui_enter_name')}
aria-describedby={errors.tag ? 'bookmark-tag-error' : undefined}
/>
{errors.tag && (
<span id="bookmark-tag-error" role="alert" className="text-sm font-bold text-red-500">
<span id="bookmark-tag-error" role="alert" className="text-sm text-red-500">
{errors.tag.message}
</span>
)}
</div>
<div className="mt-4 grid w-full items-center gap-2">
{/* Description textarea */}
<div className="space-y-2">
<Label
id="bookmark-description-label"
htmlFor="bookmark-description"
className="text-left text-sm font-medium"
className="text-sm font-medium text-text-primary"
>
{localize('com_ui_bookmarks_description')}
</Label>
@ -151,14 +146,20 @@ const BookmarkForm = ({
})}
id="bookmark-description"
disabled={false}
placeholder={localize('com_ui_enter_description')}
className={cn(
'flex h-10 max-h-[250px] min-h-[100px] w-full resize-none rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background focus-visible:outline-none',
'min-h-[100px] w-full resize-none rounded-lg border border-border-light',
'bg-transparent px-3 py-2 text-sm text-text-primary',
'placeholder:text-text-tertiary',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-heavy',
)}
aria-labelledby="bookmark-description-label"
/>
</div>
{/* Add to conversation checkbox */}
{conversationId != null && conversationId && (
<div className="mt-2 flex w-full items-center">
<div className="flex items-center gap-2">
<Controller
name="addToConversation"
control={control}
@ -167,7 +168,7 @@ const BookmarkForm = ({
{...field}
checked={field.value}
onCheckedChange={field.onChange}
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
className="size-4 cursor-pointer"
value={field.value?.toString()}
aria-label={localize('com_ui_bookmarks_add_to_conversation')}
/>
@ -176,16 +177,14 @@ const BookmarkForm = ({
<button
type="button"
aria-label={localize('com_ui_bookmarks_add_to_conversation')}
className="form-check-label w-full cursor-pointer text-text-primary"
className="cursor-pointer text-sm text-text-primary"
onClick={() =>
setValue('addToConversation', !(getValues('addToConversation') ?? false), {
shouldDirty: true,
})
}
>
<div className="flex select-none items-center">
{localize('com_ui_bookmarks_add_to_conversation')}
</div>
{localize('com_ui_bookmarks_add_to_conversation')}
</button>
</div>
)}

View file

@ -84,7 +84,7 @@ export default function ExportAndShareMenu({
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<Share2
className="icon-md text-text-secondary"
className="icon-lg text-text-primary"
aria-hidden="true"
focusable="false"
/>

View file

@ -45,10 +45,10 @@ export default function Header() {
{!navVisible && (
<motion.div
className="flex items-center gap-2"
initial={{ width: 0, opacity: 0 }}
animate={{ width: 'auto', opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
key="header-buttons"
>
<OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" />

View file

@ -22,7 +22,7 @@ const AttachFile = ({ disabled }: { disabled?: boolean | null }) => {
aria-label={localize('com_sidepanel_attach_files')}
disabled={isUploadDisabled}
className={cn(
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50',
)}
onKeyDownCapture={(e) => {
if (!inputRef.current) {

View file

@ -234,7 +234,7 @@ const AttachFileMenu = ({
id="attach-file-menu-button"
aria-label="Attach File Options"
className={cn(
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
'flex size-9 items-center justify-center rounded-full p-1 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50',
isPopoverActive && 'bg-surface-hover',
)}
>

View file

@ -1,5 +1,4 @@
import { useState } from 'react';
import { Search } from 'lucide-react';
import { useSetRecoilState } from 'recoil';
import {
flexRender,
@ -17,7 +16,6 @@ import type {
} from '@tanstack/react-table';
import { FileContext } from 'librechat-data-provider';
import {
Input,
Table,
Button,
Spinner,
@ -26,6 +24,7 @@ import {
TableCell,
TableHead,
TrashIcon,
FilterInput,
TableHeader,
useMediaQuery,
} from '@librechat/client';
@ -115,23 +114,13 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
)}
{!isSmallScreen && <span className="ml-2">{localize('com_ui_delete')}</span>}
</Button>
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 z-10 h-4 w-4 -translate-y-1/2 text-text-secondary" />
<Input
id="files-filter"
placeholder=" "
value={(table.getColumn('filename')?.getFilterValue() as string | undefined) ?? ''}
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
className="peer w-full pl-10 text-sm focus-visible:ring-2 focus-visible:ring-ring"
aria-label={localize('com_files_filter_input')}
/>
<label
htmlFor="files-filter"
className="pointer-events-none absolute left-10 top-1/2 -translate-y-1/2 text-sm text-text-secondary transition-all duration-200 peer-focus:top-0 peer-focus:bg-background peer-focus:px-1 peer-focus:text-xs peer-[:not(:placeholder-shown)]:top-0 peer-[:not(:placeholder-shown)]:bg-background peer-[:not(:placeholder-shown)]:px-1 peer-[:not(:placeholder-shown)]:text-xs"
>
{localize('com_files_filter')}
</label>
</div>
<FilterInput
inputId="files-filter"
label={localize('com_files_filter')}
value={(table.getColumn('filename')?.getFilterValue() as string | undefined) ?? ''}
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
containerClassName="flex-1"
/>
<div className="relative focus-within:z-[100]">
<ColumnVisibilityDropdown
table={table}

View file

@ -311,7 +311,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
id="tools-dropdown-button"
aria-label="Tools Options"
className={cn(
'flex size-9 items-center justify-center rounded-full p-1 transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50',
'flex size-9 items-center justify-center rounded-full p-1 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50',
isPopoverActive && 'bg-surface-hover',
)}
>

View file

@ -147,9 +147,9 @@ const BookmarkMenu: FC = () => {
return <Spinner aria-label="Spinner" />;
}
if ((tags?.length ?? 0) > 0) {
return <BookmarkFilledIcon className="icon-sm" aria-label="Filled Bookmark" />;
return <BookmarkFilledIcon className="icon-lg" aria-label="Filled Bookmark" />;
}
return <BookmarkIcon className="icon-sm" aria-label="Bookmark" />;
return <BookmarkIcon className="icon-lg" aria-label="Bookmark" />;
};
return (

View file

@ -48,8 +48,8 @@ export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(func
!parent &&
'flex h-10 w-full items-center justify-center gap-2 rounded-xl border border-border-light px-3 py-2 text-sm text-text-primary',
menuStore.useState('open')
? 'bg-surface-tertiary hover:bg-surface-tertiary'
: 'bg-surface-secondary hover:bg-surface-tertiary',
? 'bg-surface-active-alt hover:bg-surface-active-alt'
: 'bg-presentation hover:bg-surface-active-alt',
props.className,
)}
render={parent ? <CustomMenuItem render={trigger} /> : trigger}
@ -66,7 +66,7 @@ export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(func
className={cn(
`${parent ? 'animate-popover-left ml-3' : 'animate-popover'} outline-none! z-50 flex max-h-[min(450px,var(--popover-available-height))] w-full`,
'w-[var(--menu-width,auto)] min-w-[300px] flex-col overflow-auto rounded-xl border border-border-light',
'bg-surface-secondary px-3 py-2 text-sm text-text-primary shadow-lg',
'bg-presentation px-3 py-2 text-sm text-text-primary shadow-lg',
'max-w-[calc(100vw-4rem)] sm:max-h-[calc(65vh)] sm:max-w-[400px]',
searchable && 'p-0',
)}
@ -80,13 +80,13 @@ export const CustomMenu = React.forwardRef<HTMLDivElement, CustomMenuProps>(func
autoSelect
render={combobox}
className={cn(
'peer mt-1 h-10 w-full rounded-lg border-none bg-transparent px-2 text-base',
'peer flex h-10 w-full items-center justify-center rounded-lg border-none bg-transparent px-2 text-base',
'sm:h-8 sm:text-sm',
'focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-white',
'focus:outline-none focus:ring-0 focus-visible:ring-2 focus-visible:ring-primary',
)}
/>
{comboboxLabel && (
<label className="pointer-events-none absolute left-2.5 top-2.5 text-sm text-text-secondary transition-all duration-200 peer-[:not(:placeholder-shown)]:-top-1.5 peer-[:not(:placeholder-shown)]:left-1.5 peer-[:not(:placeholder-shown)]:bg-surface-secondary peer-[:not(:placeholder-shown)]:text-xs">
<label className="pointer-events-none absolute left-2.5 top-2.5 text-sm text-text-secondary transition-all duration-200 peer-[:not(:placeholder-shown)]:-top-1.5 peer-[:not(:placeholder-shown)]:left-1.5 peer-[:not(:placeholder-shown)]:bg-presentation peer-[:not(:placeholder-shown)]:text-xs sm:top-1.5">
{comboboxLabel}
</label>
)}
@ -168,7 +168,7 @@ export const CustomMenuItem = React.forwardRef<HTMLDivElement, CustomMenuItemPro
blurOnHoverEnd: false,
...props,
className: cn(
'relative flex cursor-default items-center gap-2 rounded-lg p-2 outline-none! scroll-m-1 scroll-mt-[calc(var(--combobox-height,0px)+var(--label-height,4px))] aria-disabled:opacity-25 data-[active-item]:bg-black/[0.075] data-[active-item]:text-black dark:data-[active-item]:bg-white/10 dark:data-[active-item]:text-white sm:py-1 sm:text-sm min-w-0 w-full before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:bg-transparent before:rounded-full data-[active-item]:before:bg-black dark:data-[active-item]:before:bg-white',
'relative flex cursor-default items-center gap-2 rounded-lg px-2 py-1 outline-none! scroll-m-1 scroll-mt-[calc(var(--combobox-height,0px)+var(--label-height,4px))] aria-disabled:opacity-25 data-[active-item]:bg-black/[0.075] data-[active-item]:text-black dark:data-[active-item]:bg-white/10 dark:data-[active-item]:text-white sm:text-sm min-w-0 w-full before:absolute before:left-0 before:top-1 before:bottom-1 before:w-0.5 before:bg-transparent before:rounded-full data-[active-item]:before:bg-black dark:data-[active-item]:before:bg-white',
props.className,
),
};

View file

@ -65,7 +65,7 @@ function ModelSelectorContent() {
description={localize('com_ui_select_model')}
render={
<button
className="my-1 flex h-10 w-full max-w-[70vw] items-center justify-center gap-2 rounded-xl border border-border-light bg-surface-secondary px-3 py-2 text-sm text-text-primary hover:bg-surface-tertiary"
className="my-1 flex h-10 w-full max-w-[70vw] items-center justify-center gap-2 rounded-xl border border-border-light bg-presentation px-3 py-2 text-sm text-text-primary hover:bg-surface-active-alt"
aria-label={localize('com_ui_select_model')}
>
{selectedIcon && React.isValidElement(selectedIcon) && (

View file

@ -1,6 +1,6 @@
import { useMemo } from 'react';
import { SettingsIcon } from 'lucide-react';
import { Spinner } from '@librechat/client';
import { Spinner, TooltipAnchor } from '@librechat/client';
import { CheckCircle2, MousePointerClick, SettingsIcon } from 'lucide-react';
import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import type { TModelSpec } from 'librechat-data-provider';
import type { Endpoint } from '~/common';
@ -28,29 +28,53 @@ const SettingsButton = ({
}) => {
const localize = useLocalize();
const text = localize('com_endpoint_config_key');
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
if (!endpoint.value) {
return;
}
e.stopPropagation();
handleOpenKeyDialog(endpoint.value as EModelEndpoint, e);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
if (endpoint.value) {
handleOpenKeyDialog(endpoint.value as EModelEndpoint, e as unknown as React.MouseEvent);
}
}
};
return (
<button
type="button"
id={`endpoint-${endpoint.value}-settings`}
onClick={(e) => {
if (!endpoint.value) {
return;
}
e.stopPropagation();
handleOpenKeyDialog(endpoint.value as EModelEndpoint, e);
}}
onClick={handleClick}
onKeyDown={handleKeyDown}
className={cn(
'flex items-center overflow-visible text-text-primary transition-all duration-300 ease-in-out',
'group/button rounded-md px-1 hover:bg-surface-secondary focus:bg-surface-secondary',
'group/button flex items-center gap-1.5 rounded-md px-1.5',
'text-text-secondary transition-colors duration-150',
'hover:bg-surface-tertiary hover:text-text-primary',
'focus-visible:bg-surface-tertiary focus-visible:text-text-primary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1',
className,
)}
aria-label={`${text} ${endpoint.label}`}
>
<div className="flex w-[28px] items-center gap-1 whitespace-nowrap transition-all duration-300 ease-in-out group-hover:w-auto group-focus/button:w-auto">
<SettingsIcon className="h-4 w-4 flex-shrink-0" aria-hidden="true" />
<span className="max-w-0 overflow-hidden whitespace-nowrap opacity-0 transition-all duration-300 ease-in-out group-hover:max-w-[100px] group-hover:opacity-100 group-focus/button:max-w-[100px] group-focus/button:opacity-100">
{text}
</span>
</div>
<SettingsIcon className="size-4 shrink-0" aria-hidden="true" />
<span
aria-hidden="true"
className={cn(
'grid overflow-hidden transition-[grid-template-columns,opacity] duration-150 ease-out',
'grid-cols-[0fr] opacity-0',
'group-hover/button:grid-cols-[1fr] group-hover/button:opacity-100',
'group-focus-visible/button:grid-cols-[1fr] group-focus-visible/button:opacity-100',
)}
>
<span className="min-w-0 truncate pr-0.5">{text}</span>
</span>
</button>
);
};
@ -88,21 +112,17 @@ export function EndpointItem({ endpoint, endpointIndex }: EndpointItemProps) {
[endpointRequiresUserKey, endpoint.value],
);
const isAssistantsNotLoaded =
isAssistantsEndpoint(endpoint.value) && endpoint.models === undefined;
const renderIconLabel = () => (
<div className="flex items-center gap-2">
<div className="flex min-w-0 items-center gap-2">
{endpoint.icon && (
<div className="flex flex-shrink-0 items-center justify-center overflow-hidden">
<div className="flex shrink-0 items-center justify-center" aria-hidden="true">
{endpoint.icon}
</div>
)}
<span
className={cn(
'truncate text-left',
isUserProvided ? 'group-hover:w-24 group-focus:w-24' : '',
)}
>
{endpoint.label}
</span>
<span className="truncate text-left">{endpoint.label}</span>
</div>
);
@ -124,17 +144,14 @@ export function EndpointItem({ endpoint, endpointIndex }: EndpointItemProps) {
<Menu
id={`endpoint-${endpoint.value}-menu`}
key={`endpoint-${endpoint.value}-item`}
className="transition-opacity duration-200 ease-in-out"
defaultOpen={endpoint.value === selectedEndpoint}
searchValue={searchValue}
onSearch={(value) => setEndpointSearchValue(endpoint.value, value)}
combobox={<input placeholder=" " />}
comboboxLabel={placeholder}
onClick={() => handleSelectEndpoint(endpoint)}
label={
<div
onClick={() => handleSelectEndpoint(endpoint)}
className="group flex w-full flex-shrink cursor-pointer items-center justify-between rounded-xl px-1 py-1 text-sm"
>
<div className="group flex w-full min-w-0 items-center justify-between gap-1.5 py-1 text-sm">
{renderIconLabel()}
{isUserProvided && (
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
@ -143,8 +160,12 @@ export function EndpointItem({ endpoint, endpointIndex }: EndpointItemProps) {
}
>
{isAssistantsEndpoint(endpoint.value) && endpoint.models === undefined ? (
<div className="flex items-center justify-center p-2">
<Spinner />
<div
className="flex items-center justify-center p-2"
role="status"
aria-label={localize('com_ui_loading')}
>
<Spinner aria-hidden="true" />
</div>
) : (
<>
@ -179,32 +200,27 @@ export function EndpointItem({ endpoint, endpointIndex }: EndpointItemProps) {
id={`endpoint-${endpoint.value}-menu`}
key={`endpoint-${endpoint.value}-item`}
onClick={() => handleSelectEndpoint(endpoint)}
className="flex h-8 w-full cursor-pointer items-center justify-between rounded-xl px-3 py-2 text-sm"
className="group flex w-full cursor-pointer items-center justify-between gap-1.5 py-2 text-sm"
>
<div className="group flex w-full min-w-0 items-center justify-between">
{renderIconLabel()}
<div className="flex items-center gap-2">
{endpointRequiresUserKey(endpoint.value) && (
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
)}
{selectedEndpoint === endpoint.value && (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="block"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
fill="currentColor"
/>
</svg>
)}
</div>
{renderIconLabel()}
<div className="flex shrink-0 items-center gap-2">
{endpointRequiresUserKey(endpoint.value) && (
<SettingsButton endpoint={endpoint} handleOpenKeyDialog={handleOpenKeyDialog} />
)}
{isAssistantsNotLoaded && (
<TooltipAnchor
description={localize('com_ui_click_to_view_var', { 0: endpoint.label })}
side="top"
render={
<span className="flex items-center">
<MousePointerClick className="size-4 text-text-secondary" aria-hidden="true" />
</span>
}
/>
)}
{selectedEndpoint === endpoint.value && !isAssistantsNotLoaded && (
<CheckCircle2 className="size-4 shrink-0 text-text-primary" aria-hidden="true" />
)}
</div>
</MenuItem>
);

View file

@ -29,7 +29,7 @@ export default function HeaderNewChat() {
variant="outline"
data-testid="wide-header-new-chat-button"
aria-label={localize('com_ui_new_chat')}
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
className="rounded-xl duration-0 hover:bg-surface-active-alt max-md:hidden"
onClick={clickHandler}
>
<NewChatIcon />

View file

@ -1,3 +1,4 @@
import { startTransition } from 'react';
import { TooltipAnchor, Button, Sidebar } from '@librechat/client';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -17,9 +18,13 @@ export default function OpenSidebar({
const localize = useLocalize();
const handleClick = () => {
setNavVisible((prev) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));
return !prev;
// Use startTransition to mark this as a non-urgent update
// This prevents blocking the main thread during the cascade of re-renders
startTransition(() => {
setNavVisible((prev) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));
return !prev;
});
});
// Delay focus until after the sidebar animation completes (200ms)
setTimeout(() => {
@ -39,10 +44,7 @@ export default function OpenSidebar({
aria-label={localize('com_nav_open_sidebar')}
aria-expanded={false}
aria-controls="chat-history-nav"
className={cn(
'rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover',
className,
)}
className={cn('rounded-xl duration-0 hover:bg-surface-active-alt', className)}
onClick={handleClick}
>
<Sidebar aria-hidden="true" />

View file

@ -49,16 +49,22 @@ const PresetsMenu: FC = () => {
<Trigger asChild>
<TooltipAnchor
ref={presetsMenuTriggerRef}
id="presets-button"
aria-label={localize('com_endpoint_examples')}
description={localize('com_endpoint_examples')}
tabIndex={0}
role="button"
data-testid="presets-button"
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<BookCopy size={16} aria-hidden="true" />
</TooltipAnchor>
render={
<Button
size="icon"
variant="outline"
tabIndex={0}
id="presets-button"
data-testid="presets-button"
aria-label={localize('com_endpoint_examples')}
className="rounded-xl p-2 duration-0 hover:bg-surface-active-alt max-md:hidden"
// className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-transparent text-text-primary transition-all ease-in-out hover:bg-surface-tertiary disabled:pointer-events-none disabled:opacity-50 radix-state-open:bg-surface-tertiary"
>
<BookCopy className="icon-lg" aria-hidden="true" />
</Button>
}
></TooltipAnchor>
</Trigger>
<Portal>
<div
@ -74,7 +80,7 @@ const PresetsMenu: FC = () => {
<Content
side="bottom"
align="center"
className="mt-2 max-h-[495px] overflow-x-hidden rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white md:min-w-[400px]"
className="mt-2 max-h-[495px] overflow-x-hidden rounded-lg border border-border-light bg-surface-secondary text-text-primary shadow-lg md:min-w-[400px]"
>
<PresetItems
presets={presetsQuery.data}

View file

@ -216,7 +216,7 @@ function FeedbackButtons({
function buttonClasses(isActive: boolean, isLast: boolean) {
return cn(
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
'hover-button rounded-lg p-1.5 text-text-secondary-alt',
'hover:text-text-primary hover:bg-surface-hover',
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',

View file

@ -227,7 +227,7 @@ export default function Fork({
});
const buttonStyle = cn(
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
'hover-button rounded-lg p-1.5 text-text-secondary-alt',
'hover:text-text-primary hover:bg-surface-hover',
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',

View file

@ -82,7 +82,7 @@ const HoverButton = memo(
className = '',
}: HoverButtonProps) => {
const buttonStyle = cn(
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
'hover-button rounded-lg p-1.5 text-text-secondary-alt',
'hover:text-text-primary hover:bg-surface-hover',
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
!isLast && 'md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100',

View file

@ -24,7 +24,7 @@ export default function SiblingSwitch({
};
const buttonStyle = cn(
'hover-button rounded-lg p-1.5 text-text-secondary-alt transition-colors duration-200',
'hover-button rounded-lg p-1.5 text-text-secondary-alt',
'hover:text-text-primary hover:bg-surface-hover',
'md:group-hover:visible md:group-focus-within:visible md:group-[.final-completion]:visible',
'focus-visible:ring-2 focus-visible:ring-black dark:focus-visible:ring-white focus-visible:outline-none',

View file

@ -14,8 +14,6 @@ export function TemporaryChat() {
const temporaryBadge = {
id: 'temporary',
icon: MessageCircleDashed,
label: 'com_ui_temporary' as const,
atom: store.isTemporary,
isAvailable: true,
};
@ -37,26 +35,20 @@ export function TemporaryChat() {
return (
<div className="relative flex flex-wrap items-center gap-2">
<TooltipAnchor
description={localize(temporaryBadge.label)}
description={localize('com_ui_temporary')}
render={
<button
onClick={handleBadgeToggle}
aria-label={localize(temporaryBadge.label)}
aria-label={localize('com_ui_temporary')}
aria-pressed={isTemporary}
className={cn(
'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light text-text-primary transition-all ease-in-out hover:bg-surface-tertiary',
'inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light text-text-primary transition-all ease-in-out',
isTemporary
? 'bg-surface-active shadow-md'
: 'bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md',
'active:shadow-inner',
? 'bg-surface-active'
: 'bg-presentation shadow-sm hover:bg-surface-active-alt',
)}
>
{temporaryBadge.icon && (
<temporaryBadge.icon
className={cn('relative h-5 w-5 md:h-4 md:w-4', !temporaryBadge.label && 'mx-auto')}
aria-hidden="true"
/>
)}
<MessageCircleDashed className="icon-lg" aria-hidden="true" />
</button>
}
/>

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useRecoilValue } from 'recoil';
import { useParams } from 'react-router-dom';
import { Constants } from 'librechat-data-provider';
@ -6,7 +6,7 @@ import { useToastContext, useMediaQuery } from '@librechat/client';
import type { TConversation } from 'librechat-data-provider';
import { useUpdateConversationMutation } from '~/data-provider';
import EndpointIcon from '~/components/Endpoints/EndpointIcon';
import { useNavigateToConvo, useLocalize } from '~/hooks';
import { useNavigateToConvo, useLocalize, useShiftKey } from '~/hooks';
import { useGetEndpointsQuery } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { ConvoOptions } from './ConvoOptions';
@ -37,11 +37,14 @@ export default function Conversation({
const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? '');
const activeConvos = useRecoilValue(store.allConversationsSelector);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const isShiftHeld = useShiftKey();
const { conversationId, title = '' } = conversation;
const [titleInput, setTitleInput] = useState(title || '');
const [renaming, setRenaming] = useState(false);
const [isPopoverActive, setIsPopoverActive] = useState(false);
// Lazy-load ConvoOptions to avoid running heavy hooks for all conversations
const [hasInteracted, setHasInteracted] = useState(false);
const previousTitle = useRef(title);
@ -100,6 +103,12 @@ export default function Conversation({
setRenaming(false);
};
const handleMouseEnter = useCallback(() => {
if (!hasInteracted) {
setHasInteracted(true);
}
}, [hasInteracted]);
const handleNavigation = (ctrlOrMetaKey: boolean) => {
if (ctrlOrMetaKey) {
toggleNav();
@ -148,6 +157,8 @@ export default function Conversation({
aria-label={localize('com_ui_conversation_label', {
title: title || localize('com_ui_untitled'),
})}
onMouseEnter={handleMouseEnter}
onFocus={handleMouseEnter}
onClick={(e) => {
if (renaming) {
return;
@ -223,14 +234,16 @@ export default function Conversation({
className={cn(
'mr-2 flex origin-left',
isPopoverActive || isActiveConvo
? 'pointer-events-auto max-w-[28px] scale-x-100 opacity-100'
: 'pointer-events-none max-w-0 scale-x-0 opacity-0 group-focus-within:pointer-events-auto group-focus-within:max-w-[28px] group-focus-within:scale-x-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:max-w-[28px] group-hover:scale-x-100 group-hover:opacity-100',
? 'pointer-events-auto scale-x-100 opacity-100'
: 'pointer-events-none max-w-0 scale-x-0 opacity-0 group-focus-within:pointer-events-auto group-focus-within:max-w-[60px] group-focus-within:scale-x-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:max-w-[60px] group-hover:scale-x-100 group-hover:opacity-100',
(isPopoverActive || isActiveConvo) && (isShiftHeld ? 'max-w-[60px]' : 'max-w-[28px]'),
)}
// Removing aria-hidden to fix accessibility issue: ARIA hidden element must not be focusable or contain focusable elements
// but not sure what its original purpose was, so leaving the property commented out until it can be cleared safe to delete.
// aria-hidden={!(isPopoverActive || isActiveConvo)}
>
{!renaming && <ConvoOptions {...convoOptionsProps} />}
{/* Only render ConvoOptions when user interacts (hover/focus) or for active conversation */}
{!renaming && (hasInteracted || isActiveConvo) && <ConvoOptions {...convoOptionsProps} />}
</div>
</div>
);

View file

@ -1,15 +1,19 @@
import { useState, useId, useRef, memo, useCallback, useMemo } from 'react';
import * as Ariakit from '@ariakit/react';
import { useParams, useNavigate } from 'react-router-dom';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { DropdownPopup, Spinner, useToastContext } from '@librechat/client';
import { Ellipsis, Share2, CopyPlus, Archive, Pen, Trash } from 'lucide-react';
import type { MouseEvent } from 'react';
import type { TMessage } from 'librechat-data-provider';
import {
useDuplicateConversationMutation,
useDeleteConversationMutation,
useGetStartupConfig,
useArchiveConvoMutation,
} from '~/data-provider';
import { useLocalize, useNavigateToConvo, useNewConvo } from '~/hooks';
import { useLocalize, useNavigateToConvo, useNewConvo, useShiftKey } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { useChatContext } from '~/Providers';
import DeleteButton from './DeleteButton';
@ -34,6 +38,8 @@ function ConvoOptions({
isActiveConvo: boolean;
}) {
const localize = useLocalize();
const queryClient = useQueryClient();
const isShiftHeld = useShiftKey();
const { index } = useChatContext();
const { data: startupConfig } = useGetStartupConfig();
const { navigateToConvo } = useNavigateToConvo(index);
@ -52,6 +58,28 @@ function ConvoOptions({
const archiveConvoMutation = useArchiveConvoMutation();
const deleteMutation = useDeleteConversationMutation({
onSuccess: () => {
if (currentConvoId === conversationId || currentConvoId === 'new') {
newConversation();
navigate('/c/new', { replace: true });
}
retainView();
showToast({
message: localize('com_ui_convo_delete_success'),
severity: NotificationSeverity.SUCCESS,
showIcon: true,
});
},
onError: () => {
showToast({
message: localize('com_ui_convo_delete_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
});
const duplicateConversation = useDuplicateConversationMutation({
onSuccess: (data) => {
navigateToConvo(data.conversation);
@ -77,6 +105,7 @@ function ConvoOptions({
const isDuplicateLoading = duplicateConversation.isLoading;
const isArchiveLoading = archiveConvoMutation.isLoading;
const isDeleteLoading = deleteMutation.isLoading;
const shareHandler = useCallback(() => {
setShowShareDialog(true);
@ -86,47 +115,66 @@ function ConvoOptions({
setShowDeleteDialog(true);
}, []);
const handleArchiveClick = useCallback(async () => {
const convoId = conversationId ?? '';
if (!convoId) {
return;
}
const handleInstantDelete = useCallback(
(e: MouseEvent) => {
e.stopPropagation();
const convoId = conversationId ?? '';
if (!convoId) {
return;
}
const messages = queryClient.getQueryData<TMessage[]>([QueryKeys.messages, convoId]);
const thread_id = messages?.[messages.length - 1]?.thread_id;
const endpoint = messages?.[messages.length - 1]?.endpoint;
deleteMutation.mutate({ conversationId: convoId, thread_id, endpoint, source: 'button' });
},
[conversationId, deleteMutation, queryClient],
);
archiveConvoMutation.mutate(
{ conversationId: convoId, isArchived: true },
{
onSuccess: () => {
setAnnouncement(localize('com_ui_convo_archived'));
setTimeout(() => {
setAnnouncement('');
}, 10000);
if (currentConvoId === convoId || currentConvoId === 'new') {
newConversation();
navigate('/c/new', { replace: true });
}
retainView();
setIsPopoverActive(false);
const handleArchiveClick = useCallback(
async (e?: MouseEvent) => {
e?.stopPropagation();
const convoId = conversationId ?? '';
if (!convoId) {
return;
}
archiveConvoMutation.mutate(
{ conversationId: convoId, isArchived: true },
{
onSuccess: () => {
setAnnouncement(localize('com_ui_convo_archived'));
setTimeout(() => {
setAnnouncement('');
}, 10000);
if (currentConvoId === convoId || currentConvoId === 'new') {
newConversation();
navigate('/c/new', { replace: true });
}
retainView();
setIsPopoverActive(false);
},
onError: () => {
showToast({
message: localize('com_ui_archive_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
},
onError: () => {
showToast({
message: localize('com_ui_archive_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
},
},
);
}, [
conversationId,
currentConvoId,
archiveConvoMutation,
navigate,
newConversation,
retainView,
setIsPopoverActive,
showToast,
localize,
]);
);
},
[
conversationId,
currentConvoId,
archiveConvoMutation,
navigate,
newConversation,
retainView,
setIsPopoverActive,
showToast,
localize,
],
);
const handleDuplicateClick = useCallback(() => {
duplicateConversation.mutate({
@ -198,6 +246,44 @@ function ConvoOptions({
],
);
const buttonClassName = cn(
'inline-flex h-7 w-7 items-center justify-center rounded-md border-none p-0 text-sm font-medium ring-ring-primary transition-all duration-200 ease-in-out focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50',
isActiveConvo === true || isPopoverActive
? 'opacity-100'
: 'opacity-0 focus:opacity-100 group-focus-within:opacity-100 group-hover:opacity-100 data-[open]:opacity-100',
);
if (isShiftHeld) {
return (
<div className="flex items-center gap-0.5">
<button
aria-label={localize('com_ui_archive')}
className={cn(buttonClassName, 'hover:bg-surface-hover')}
onClick={handleArchiveClick}
disabled={isArchiveLoading}
>
{isArchiveLoading ? (
<Spinner className="size-4" />
) : (
<Archive className="icon-md text-text-secondary" aria-hidden={true} />
)}
</button>
<button
aria-label={localize('com_ui_delete')}
className={cn(buttonClassName, 'hover:bg-surface-hover')}
onClick={handleInstantDelete}
disabled={isDeleteLoading}
>
{isDeleteLoading ? (
<Spinner className="size-4" />
) : (
<Trash className="icon-md text-text-secondary" aria-hidden={true} />
)}
</button>
</div>
);
}
return (
<>
<span className="sr-only" aria-live="polite" aria-atomic="true">

View file

@ -25,7 +25,7 @@ function AccountSettings() {
ref={accountSettingsButtonRef}
aria-label={localize('com_nav_account_settings')}
data-testid="nav-user"
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-surface-hover aria-[expanded=true]:bg-surface-hover"
className="mt-text-sm flex h-auto w-full items-center gap-2 rounded-xl p-2 text-sm transition-all duration-200 ease-in-out hover:bg-surface-active-alt aria-[expanded=true]:bg-surface-active-alt"
>
<div className="-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0">
<div className="relative flex">
@ -40,7 +40,7 @@ function AccountSettings() {
</div>
</Select.Select>
<Select.SelectPopover
className="popover-ui w-[305px] rounded-lg md:w-[235px]"
className="popover-ui w-[305px] rounded-lg md:w-[244px]"
style={{
transformOrigin: 'bottom',
translate: '0 -4px',

View file

@ -1,11 +1,11 @@
import { useMemo } from 'react';
import type { FC } from 'react';
import { TooltipAnchor } from '@librechat/client';
import { Menu, MenuButton, MenuItems } from '@headlessui/react';
import { useState, useId, useMemo, useCallback } from 'react';
import * as Ariakit from '@ariakit/react';
import { CrossCircledIcon } from '@radix-ui/react-icons';
import { DropdownPopup, TooltipAnchor } from '@librechat/client';
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
import { BookmarkContext } from '~/Providers/BookmarkContext';
import type * as t from '~/common';
import type { FC } from 'react';
import { useGetConversationTags } from '~/data-provider';
import BookmarkNavItems from './BookmarkNavItems';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
@ -16,56 +16,105 @@ type BookmarkNavProps = {
const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags }: BookmarkNavProps) => {
const localize = useLocalize();
const menuId = useId();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const { data } = useGetConversationTags();
const label = useMemo(
() => (tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')),
[tags, localize],
);
const bookmarks = useMemo(() => data?.filter((tag) => tag.count > 0) ?? [], [data]);
const handleTagClick = useCallback(
(tag: string) => {
if (tags.includes(tag)) {
setTags(tags.filter((t) => t !== tag));
} else {
setTags([...tags, tag]);
}
},
[tags, setTags],
);
const handleClear = useCallback(() => {
setTags([]);
}, [setTags]);
const dropdownItems: t.MenuItemProps[] = useMemo(() => {
const items: t.MenuItemProps[] = [
{
id: 'clear-all',
label: localize('com_ui_clear_all'),
icon: <CrossCircledIcon className="size-4" />,
hideOnClick: false,
onClick: handleClear,
},
];
if (bookmarks.length === 0) {
items.push({
id: 'no-bookmarks',
label: localize('com_ui_no_bookmarks'),
icon: '🤔',
disabled: true,
});
} else {
for (const bookmark of bookmarks) {
const isSelected = tags.includes(bookmark.tag);
items.push({
id: bookmark.tag,
label: bookmark.tag,
hideOnClick: false,
icon: isSelected ? (
<BookmarkFilledIcon className="size-4" />
) : (
<BookmarkIcon className="size-4" />
),
onClick: () => handleTagClick(bookmark.tag),
});
}
}
return items;
}, [bookmarks, tags, localize, handleTagClick, handleClear]);
return (
<Menu as="div" className="group relative">
{({ open }) => (
<>
<TooltipAnchor
description={label}
render={
<MenuButton
id="bookmark-menu-button"
aria-label={localize('com_ui_bookmarks')}
className={cn(
'flex items-center justify-center',
'size-10 border-none text-text-primary hover:bg-accent hover:text-accent-foreground',
'rounded-full border-none p-2 hover:bg-surface-hover md:rounded-xl',
open ? 'bg-surface-hover' : '',
)}
data-testid="bookmark-menu"
>
{tags.length > 0 ? (
<BookmarkFilledIcon aria-hidden="true" className="icon-lg text-text-primary" />
) : (
<BookmarkIcon aria-hidden="true" className="icon-lg text-text-primary" />
)}
</MenuButton>
}
/>
<MenuItems
anchor="bottom"
className="absolute left-0 top-full z-[100] mt-1 w-60 translate-y-0 overflow-hidden rounded-lg bg-surface-secondary p-1.5 shadow-lg outline-none"
>
{data && (
<BookmarkContext.Provider value={{ bookmarks: data.filter((tag) => tag.count > 0) }}>
<BookmarkNavItems
// List of selected tags(string)
tags={tags}
// When a user selects a tag, this `setTags` function is called to refetch the list of conversations for the selected tag
setTags={setTags}
/>
</BookmarkContext.Provider>
)}
</MenuItems>
</>
)}
</Menu>
<DropdownPopup
portal={true}
menuId={menuId}
focusLoop={true}
isOpen={isMenuOpen}
unmountOnHide={true}
setIsOpen={setIsMenuOpen}
keyPrefix="bookmark-nav-"
trigger={
<TooltipAnchor
description={label}
render={
<Ariakit.MenuButton
id="bookmark-nav-menu-button"
aria-label={localize('com_ui_bookmarks')}
className={cn(
'flex items-center justify-center',
'size-10 border-none text-text-primary hover:bg-accent hover:text-accent-foreground',
'rounded-full border-none p-2 hover:bg-surface-active-alt md:rounded-xl',
isMenuOpen ? 'bg-surface-hover' : '',
)}
data-testid="bookmark-menu"
>
{tags.length > 0 ? (
<BookmarkFilledIcon aria-hidden="true" className="icon-lg text-text-primary" />
) : (
<BookmarkIcon aria-hidden="true" className="icon-lg text-text-primary" />
)}
</Ariakit.MenuButton>
}
/>
}
items={dropdownItems}
/>
);
};

View file

@ -1,76 +0,0 @@
import { type FC } from 'react';
import { CrossCircledIcon } from '@radix-ui/react-icons';
import { useBookmarkContext } from '~/Providers/BookmarkContext';
import { BookmarkItems, BookmarkItem } from '~/components/Bookmarks';
import { useLocalize } from '~/hooks';
const BookmarkNavItems: FC<{
tags: string[];
setTags: (tags: string[]) => void;
}> = ({ tags = [], setTags }) => {
const { bookmarks } = useBookmarkContext();
const localize = useLocalize();
const getUpdatedSelected = (tag: string) => {
if (tags.some((selectedTag) => selectedTag === tag)) {
return tags.filter((selectedTag) => selectedTag !== tag);
} else {
return [...tags, tag];
}
};
const handleSubmit = (tag?: string) => {
if (tag === undefined) {
return;
}
const updatedSelected = getUpdatedSelected(tag);
setTags(updatedSelected);
return;
};
const clear = () => {
setTags([]);
return;
};
if (bookmarks.length === 0) {
return (
<div className="flex flex-col">
<BookmarkItem
tag={localize('com_ui_clear_all')}
data-testid="bookmark-item-clear"
handleSubmit={clear}
selected={false}
icon={<CrossCircledIcon aria-hidden="true" className="size-4" />}
/>
<BookmarkItem
tag={localize('com_ui_no_bookmarks')}
data-testid="bookmark-item-no-bookmarks"
handleSubmit={() => Promise.resolve()}
selected={false}
icon={'🤔'}
/>
</div>
);
}
return (
<div className="flex flex-col">
<BookmarkItems
tags={tags}
handleSubmit={handleSubmit}
header={
<BookmarkItem
tag={localize('com_ui_clear_all')}
data-testid="bookmark-item-clear"
handleSubmit={clear}
selected={false}
icon={<CrossCircledIcon aria-hidden="true" className="size-4" />}
/>
}
/>
</div>
);
};
export default BookmarkNavItems;

View file

@ -21,7 +21,7 @@ export default function MobileNav({
const { title = 'New Chat' } = conversation || {};
return (
<div className="bg-token-main-surface-primary sticky top-0 z-10 flex min-h-[40px] items-center justify-center bg-white pl-1 dark:bg-gray-800 dark:text-white md:hidden">
<div className="bg-token-main-surface-primary sticky top-0 z-10 flex min-h-[40px] items-center justify-center bg-presentation pl-1 dark:text-white md:hidden">
<button
type="button"
data-testid="mobile-header-new-chat-button"
@ -29,7 +29,7 @@ export default function MobileNav({
navVisible ? localize('com_nav_close_sidebar') : localize('com_nav_open_sidebar')
}
aria-live="polite"
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover"
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-active-alt"
onClick={() =>
setNavVisible((prev) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));
@ -62,7 +62,7 @@ export default function MobileNav({
<button
type="button"
aria-label={localize('com_ui_new_chat')}
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-hover"
className="m-1 inline-flex size-10 items-center justify-center rounded-full hover:bg-surface-active-alt"
onClick={() => {
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);

View file

@ -1,6 +1,16 @@
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
import {
useCallback,
useEffect,
useState,
useMemo,
memo,
lazy,
Suspense,
useRef,
startTransition,
} from 'react';
import { useRecoilValue } from 'recoil';
import { AnimatePresence, motion } from 'framer-motion';
import { motion } from 'framer-motion';
import { Skeleton, useMediaQuery } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
@ -23,8 +33,10 @@ import store from '~/store';
const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav'));
const AccountSettings = lazy(() => import('./AccountSettings'));
const NAV_WIDTH_DESKTOP = '260px';
const NAV_WIDTH_MOBILE = '320px';
export const NAV_WIDTH = {
MOBILE: 320,
DESKTOP: 260,
} as const;
const SearchBarSkeleton = memo(() => (
<div className={cn('flex h-10 items-center py-2')}>
@ -66,7 +78,6 @@ const Nav = memo(
const { isAuthenticated } = useAuthContext();
useTitleGeneration(isAuthenticated);
const [navWidth, setNavWidth] = useState(NAV_WIDTH_DESKTOP);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const [newUser, setNewUser] = useLocalStorage('newUser', true);
const [isChatsExpanded, setIsChatsExpanded] = useLocalStorage('chatsExpanded', true);
@ -122,13 +133,17 @@ const Nav = memo(
}, [data]);
const toggleNavVisible = useCallback(() => {
setNavVisible((prev: boolean) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));
return !prev;
// Use startTransition to mark this as a non-urgent update
// This prevents blocking the main thread during the cascade of re-renders
startTransition(() => {
setNavVisible((prev: boolean) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));
return !prev;
});
if (newUser) {
setNewUser(false);
}
});
if (newUser) {
setNewUser(false);
}
}, [newUser, setNavVisible, setNewUser]);
const itemToggleNav = useCallback(() => {
@ -143,9 +158,6 @@ const Nav = memo(
if (savedNavVisible === null) {
toggleNavVisible();
}
setNavWidth(NAV_WIDTH_MOBILE);
} else {
setNavWidth(NAV_WIDTH_DESKTOP);
}
}, [isSmallScreen, toggleNavVisible]);
@ -201,61 +213,90 @@ const Nav = memo(
}
}, [search.query, search.isTyping, isLoading, isFetching]);
// Always render sidebar to avoid mount/unmount costs
// Use transform for GPU-accelerated animation (no layout thrashing)
const sidebarWidth = isSmallScreen ? NAV_WIDTH.MOBILE : NAV_WIDTH.DESKTOP;
// Sidebar content (shared between mobile and desktop)
const sidebarContent = (
<div className="flex h-full flex-col">
<nav
id="chat-history-nav"
aria-label={localize('com_ui_chat_history')}
className="flex h-full flex-col px-2 pb-3.5"
aria-hidden={!navVisible}
>
<div className="flex flex-1 flex-col overflow-hidden" ref={outerContainerRef}>
<MemoNewChat
subHeaders={subHeaders}
toggleNav={toggleNavVisible}
headerButtons={headerButtons}
isSmallScreen={isSmallScreen}
/>
<div className="flex min-h-0 flex-grow flex-col overflow-hidden">
<Conversations
conversations={conversations}
moveToTop={moveToTop}
toggleNav={itemToggleNav}
containerRef={conversationsRef}
loadMoreConversations={loadMoreConversations}
isLoading={isFetchingNextPage || showLoading || isLoading}
isSearchLoading={isSearchLoading}
isChatsExpanded={isChatsExpanded}
setIsChatsExpanded={setIsChatsExpanded}
/>
</div>
</div>
<Suspense fallback={<Skeleton className="mt-1 h-12 w-full rounded-xl" />}>
<AccountSettings />
</Suspense>
</nav>
</div>
);
// Mobile: Fixed positioned sidebar that slides over content
// Uses CSS transitions (not Framer Motion) to sync perfectly with content animation
if (isSmallScreen) {
return (
<>
<div
data-testid="nav"
className={cn(
'nav fixed left-0 top-0 z-[70] h-full bg-surface-primary-alt',
navVisible && 'active',
)}
style={{
width: sidebarWidth,
transform: navVisible ? 'translateX(0)' : `translateX(-${sidebarWidth}px)`,
transition: 'transform 0.2s ease-out',
}}
>
{sidebarContent}
</div>
<NavMask navVisible={navVisible} toggleNavVisible={toggleNavVisible} />
</>
);
}
// Desktop: Inline sidebar with width transition
return (
<>
<AnimatePresence initial={false}>
{navVisible && (
<motion.div
data-testid="nav"
className={cn(
'nav active max-w-[320px] flex-shrink-0 overflow-x-hidden bg-surface-primary-alt',
'md:max-w-[260px]',
)}
initial={{ width: 0 }}
animate={{ width: navWidth }}
exit={{ width: 0 }}
transition={{ duration: 0.2 }}
key="nav"
>
<div className="h-full w-[320px] md:w-[260px]">
<div className="flex h-full flex-col">
<nav
id="chat-history-nav"
aria-label={localize('com_ui_chat_history')}
className="flex h-full flex-col px-2 pb-3.5"
>
<div className="flex flex-1 flex-col overflow-hidden" ref={outerContainerRef}>
<MemoNewChat
subHeaders={subHeaders}
toggleNav={toggleNavVisible}
headerButtons={headerButtons}
isSmallScreen={isSmallScreen}
/>
<div className="flex min-h-0 flex-grow flex-col overflow-hidden">
<Conversations
conversations={conversations}
moveToTop={moveToTop}
toggleNav={itemToggleNav}
containerRef={conversationsRef}
loadMoreConversations={loadMoreConversations}
isLoading={isFetchingNextPage || showLoading || isLoading}
isSearchLoading={isSearchLoading}
isChatsExpanded={isChatsExpanded}
setIsChatsExpanded={setIsChatsExpanded}
/>
</div>
</div>
<Suspense fallback={<Skeleton className="mt-1 h-12 w-full rounded-xl" />}>
<AccountSettings />
</Suspense>
</nav>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{isSmallScreen && <NavMask navVisible={navVisible} toggleNavVisible={toggleNavVisible} />}
</>
<div
className="flex-shrink-0 overflow-hidden"
style={{ width: navVisible ? sidebarWidth : 0, transition: 'width 0.2s ease-out' }}
>
<motion.div
data-testid="nav"
className={cn('nav h-full bg-surface-primary-alt', navVisible && 'active')}
style={{ width: sidebarWidth }}
initial={false}
animate={{
x: navVisible ? 0 : -sidebarWidth,
}}
transition={{ duration: 0.2, ease: 'easeOut' }}
>
{sidebarContent}
</motion.div>
</div>
);
},
);

View file

@ -66,13 +66,13 @@ export default function NewChat({
data-testid="close-sidebar-button"
aria-label={localize('com_nav_close_sidebar')}
aria-expanded={true}
className="rounded-full border-none bg-transparent p-2 hover:bg-surface-hover md:rounded-xl"
className="rounded-full border-none bg-transparent duration-0 hover:bg-surface-active-alt md:rounded-xl"
onClick={handleToggleNav}
>
<Sidebar aria-hidden="true" className="max-md:hidden" />
<MobileSidebar
aria-hidden="true"
className="m-1 inline-flex size-10 items-center justify-center md:hidden"
className="icon-lg m-1 inline-flex items-center justify-center md:hidden"
/>
</Button>
}
@ -88,7 +88,7 @@ export default function NewChat({
variant="outline"
data-testid="nav-new-chat-button"
aria-label={localize('com_ui_new_chat')}
className="rounded-full border-none bg-transparent p-2 hover:bg-surface-hover md:rounded-xl"
className="rounded-full border-none bg-transparent duration-0 hover:bg-surface-active-alt md:rounded-xl"
onClick={clickHandler}
>
<NewChatIcon className="icon-lg text-text-primary" />

View file

@ -109,7 +109,7 @@ const SearchBar = forwardRef((props: SearchBarProps, ref: React.Ref<HTMLDivEleme
return (
<div
ref={ref}
className="group relative my-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-2 border-transparent px-3 py-2 text-text-primary focus-within:border-ring-primary focus-within:bg-surface-hover hover:bg-surface-hover"
className="group relative my-1 flex h-10 cursor-pointer items-center gap-3 rounded-lg border-2 border-transparent px-3 py-2 text-text-primary focus-within:border-ring-primary focus-within:bg-surface-active-alt hover:bg-surface-active-alt"
>
<Search
aria-hidden="true"

View file

@ -1,7 +1,7 @@
export * from './ExportConversation';
export * from './SettingsTabs/';
export { default as MobileNav } from './MobileNav';
export { default as Nav } from './Nav';
export { default as Nav, NAV_WIDTH } from './Nav';
export { default as NavLink } from './NavLink';
export { default as NewChat } from './NewChat';
export { default as SearchBar } from './SearchBar';

View file

@ -1,74 +1,27 @@
import { useMemo, useEffect, useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { ExternalLink } from 'lucide-react';
import { useState } from 'react';
import { ShieldEllipsis } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
import {
OGDialog,
OGDialogTitle,
OGDialogContent,
OGDialogTrigger,
Button,
Switch,
DropdownPopup,
OGDialogTemplate,
useToastContext,
} from '@librechat/client';
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import { OGDialog, OGDialogTemplate, Button, useToastContext } from '@librechat/client';
import { AdminSettingsDialog } from '~/components/ui';
import { useUpdatePromptPermissionsMutation } from '~/data-provider';
import { useLocalize, useAuthContext } from '~/hooks';
import { useLocalize } from '~/hooks';
import type { PermissionConfig } from '~/components/ui';
type FormValues = Record<Permissions, boolean>;
type LabelControllerProps = {
label: string;
promptPerm: Permissions;
control: Control<FormValues, unknown, FormValues>;
setValue: UseFormSetValue<FormValues>;
getValues: UseFormGetValues<FormValues>;
confirmChange?: (newValue: boolean, onChange: (value: boolean) => void) => void;
};
const LabelController: React.FC<LabelControllerProps> = ({
control,
promptPerm,
label,
confirmChange,
}) => (
<div className="mb-4 flex items-center justify-between gap-2">
{label}
<Controller
name={promptPerm}
control={control}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onCheckedChange={(val) => {
if (val === false && confirmChange) {
confirmChange(val, field.onChange);
} else {
field.onChange(val);
}
}}
value={field.value.toString()}
aria-label={label}
/>
)}
/>
</div>
);
const permissions: PermissionConfig[] = [
{ permission: Permissions.SHARED_GLOBAL, labelKey: 'com_ui_prompts_allow_share' },
{ permission: Permissions.CREATE, labelKey: 'com_ui_prompts_allow_create' },
{ permission: Permissions.USE, labelKey: 'com_ui_prompts_allow_use' },
];
const AdminSettings = () => {
const localize = useLocalize();
const { user, roles } = useAuthContext();
const { showToast } = useToastContext();
const [confirmAdminUseChange, setConfirmAdminUseChange] = useState<{
newValue: boolean;
callback: (value: boolean) => void;
} | null>(null);
const { mutate, isLoading } = useUpdatePromptPermissionsMutation({
const mutation = useUpdatePromptPermissionsMutation({
onSuccess: () => {
showToast({ status: 'success', message: localize('com_ui_saved') });
},
@ -77,188 +30,68 @@ const AdminSettings = () => {
},
});
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
const defaultValues = useMemo(() => {
if (roles?.[selectedRole]?.permissions) {
return roles[selectedRole]?.permissions[PermissionTypes.PROMPTS];
}
return roleDefaults[selectedRole].permissions[PermissionTypes.PROMPTS];
}, [roles, selectedRole]);
const {
reset,
control,
setValue,
getValues,
handleSubmit,
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues,
});
useEffect(() => {
reset(roles?.[selectedRole]?.permissions?.[PermissionTypes.PROMPTS]);
}, [roles, selectedRole, reset]);
if (user?.role !== SystemRoles.ADMIN) {
return null;
}
const labelControllerData = [
{
promptPerm: Permissions.SHARED_GLOBAL,
label: localize('com_ui_prompts_allow_share'),
},
{
promptPerm: Permissions.CREATE,
label: localize('com_ui_prompts_allow_create'),
},
{
promptPerm: Permissions.USE,
label: localize('com_ui_prompts_allow_use'),
},
];
const onSubmit = (data: FormValues) => {
mutate({ roleName: selectedRole, updates: data });
const handlePermissionConfirm = (
_permission: Permissions,
newValue: boolean,
onChange: (value: boolean) => void,
) => {
setConfirmAdminUseChange({ newValue, callback: onChange });
};
const roleDropdownItems = [
{
label: SystemRoles.USER,
onClick: () => {
setSelectedRole(SystemRoles.USER);
},
},
{
label: SystemRoles.ADMIN,
onClick: () => {
setSelectedRole(SystemRoles.ADMIN);
},
},
];
const trigger = (
<Button
size="sm"
variant="outline"
className="mr-2 h-10 w-fit gap-1 border transition-all dark:bg-transparent dark:hover:bg-surface-tertiary sm:m-0"
>
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
<span className="hidden sm:flex">{localize('com_ui_admin')}</span>
</Button>
);
const confirmDialog = (
<OGDialog
open={confirmAdminUseChange !== null}
onOpenChange={(open) => {
if (!open) {
setConfirmAdminUseChange(null);
}
}}
>
<OGDialogTemplate
showCloseButton={true}
title={localize('com_ui_confirm_change')}
className="w-11/12 max-w-lg"
main={<p className="mb-4">{localize('com_ui_confirm_admin_use_change')}</p>}
selection={{
selectHandler: () => {
if (confirmAdminUseChange) {
confirmAdminUseChange.callback(confirmAdminUseChange.newValue);
}
setConfirmAdminUseChange(null);
},
selectClasses:
'bg-surface-destructive hover:bg-surface-destructive-hover text-white transition-colors duration-200',
selectText: localize('com_ui_confirm_action'),
isLoading: false,
}}
/>
</OGDialog>
);
return (
<>
<OGDialog>
<OGDialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="mr-2 h-10 w-fit gap-1 border transition-all dark:bg-transparent dark:hover:bg-surface-tertiary sm:m-0"
>
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
<span className="hidden sm:flex">{localize('com_ui_admin')}</span>
</Button>
</OGDialogTrigger>
<OGDialogContent className="max-w-lg border-border-light bg-surface-primary text-text-primary lg:w-1/4">
<OGDialogTitle>
{localize('com_ui_admin_settings_section', { section: localize('com_ui_prompts') })}
</OGDialogTitle>
<div className="p-2">
{/* Role selection dropdown */}
<div className="flex items-center gap-2">
<span className="font-medium">{localize('com_ui_role_select')}:</span>
<DropdownPopup
unmountOnHide={true}
menuId="prompt-role-dropdown"
isOpen={isRoleMenuOpen}
setIsOpen={setIsRoleMenuOpen}
trigger={
<Ariakit.MenuButton className="inline-flex w-1/5 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
{selectedRole}
</Ariakit.MenuButton>
}
items={roleDropdownItems}
itemClassName="items-center justify-center"
sameWidth={true}
/>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="py-5">
{labelControllerData.map(({ promptPerm, label }) => (
<div key={promptPerm}>
<LabelController
control={control}
promptPerm={promptPerm}
label={label}
getValues={getValues}
setValue={setValue}
{...(selectedRole === SystemRoles.ADMIN && promptPerm === Permissions.USE
? {
confirmChange: (
newValue: boolean,
onChange: (value: boolean) => void,
) => setConfirmAdminUseChange({ newValue, callback: onChange }),
}
: {})}
/>
{selectedRole === SystemRoles.ADMIN && promptPerm === Permissions.USE && (
<>
<div className="mb-2 max-w-full whitespace-normal break-words text-sm text-red-600">
<span>{localize('com_ui_admin_access_warning')}</span>
{'\n'}
<a
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
target="_blank"
rel="noreferrer"
className="inline-flex items-center text-blue-500 underline"
>
{localize('com_ui_more_info')}
<ExternalLink size={16} className="ml-1" aria-hidden="true" />
</a>
</div>
</>
)}
</div>
))}
</div>
<div className="flex justify-end">
<Button
type="submit"
disabled={isSubmitting || isLoading}
variant="submit"
aria-label={localize('com_ui_save')}
>
{localize('com_ui_save')}
</Button>
</div>
</form>
</div>
</OGDialogContent>
</OGDialog>
<OGDialog
open={confirmAdminUseChange !== null}
onOpenChange={(open) => {
if (!open) {
setConfirmAdminUseChange(null);
}
}}
>
<OGDialogTemplate
showCloseButton={true}
title={localize('com_ui_confirm_change')}
className="w-11/12 max-w-lg"
main={<p className="mb-4">{localize('com_ui_confirm_admin_use_change')}</p>}
selection={{
selectHandler: () => {
if (confirmAdminUseChange) {
confirmAdminUseChange.callback(confirmAdminUseChange.newValue);
}
setConfirmAdminUseChange(null);
},
selectClasses:
'bg-surface-destructive hover:bg-surface-destructive-hover text-white transition-colors duration-200',
selectText: localize('com_ui_confirm_action'),
isLoading: false,
}}
/>
</OGDialog>
</>
<AdminSettingsDialog
permissionType={PermissionTypes.PROMPTS}
sectionKey="com_ui_prompts"
permissions={permissions}
menuId="prompt-role-dropdown"
mutation={mutation}
trigger={trigger}
dialogContentClassName="max-w-lg border-border-light bg-surface-primary text-text-primary lg:w-1/4"
onPermissionConfirm={handlePermissionConfirm}
confirmPermissions={[Permissions.USE]}
extraContent={confirmDialog}
/>
);
};

View file

@ -2,7 +2,7 @@ import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'
import { useRecoilState } from 'recoil';
import { ListFilter, User, Share2 } from 'lucide-react';
import { SystemCategories } from 'librechat-data-provider';
import { Dropdown, AnimatedSearchInput } from '@librechat/client';
import { Dropdown, FilterInput } from '@librechat/client';
import type { Option } from '~/common';
import { useLocalize, useCategories, useDebounce } from '~/hooks';
import { usePromptGroupsContext } from '~/Providers';
@ -77,8 +77,6 @@ export default function FilterPrompts({ className = '' }: { className?: string }
setSearchTerm(e.target.value);
}, []);
const isSearching = searchTerm !== debouncedSearchTerm;
const resultCount = promptGroups?.length ?? 0;
const searchResultsAnnouncement = useMemo(() => {
if (!debouncedSearchTerm.trim()) {
@ -102,11 +100,12 @@ export default function FilterPrompts({ className = '' }: { className?: string }
ariaLabel={localize('com_ui_filter_prompts')}
iconOnly
/>
<AnimatedSearchInput
<FilterInput
inputId="prompts-filter"
label={localize('com_ui_filter_prompts_name')}
value={searchTerm}
onChange={handleSearchChange}
isSearching={isSearching}
placeholder={localize('com_ui_filter_prompts_name')}
containerClassName="flex-1"
/>
</div>
);

View file

@ -238,13 +238,16 @@ export default function GenericGrantAccessDialog({
})}
type="button"
disabled={disabled}
className="h-full"
>
<div className="flex min-w-[32px] items-center justify-center gap-2 text-blue-500">
<span className="flex h-6 w-6 items-center justify-center">
<Share2Icon className="icon-md h-4 w-4" aria-hidden="true" />
</span>
{totalCurrentShares > 0 && (
<Label className="text-sm font-medium text-text-secondary">{totalCurrentShares}</Label>
<Label className="cursor-pointer text-sm font-medium text-text-secondary">
{totalCurrentShares}
</Label>
)}
</div>
</Button>

View file

@ -1,73 +1,21 @@
import { useMemo, useEffect, useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { ShieldEllipsis } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
import {
Button,
Switch,
OGDialog,
DropdownPopup,
OGDialogTitle,
OGDialogContent,
OGDialogTrigger,
useToastContext,
} from '@librechat/client';
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import { useToastContext } from '@librechat/client';
import { AdminSettingsDialog } from '~/components/ui';
import { useUpdateAgentPermissionsMutation } from '~/data-provider';
import { useLocalize, useAuthContext } from '~/hooks';
import { useLocalize } from '~/hooks';
import type { PermissionConfig } from '~/components/ui';
type FormValues = Record<Permissions, boolean>;
type LabelControllerProps = {
label: string;
agentPerm: Permissions;
control: Control<FormValues, unknown, FormValues>;
setValue: UseFormSetValue<FormValues>;
getValues: UseFormGetValues<FormValues>;
};
const LabelController: React.FC<LabelControllerProps> = ({
control,
agentPerm,
label,
getValues,
setValue,
}) => (
<div className="mb-4 flex items-center justify-between gap-2">
<button
className="cursor-pointer select-none"
type="button"
onClick={() =>
setValue(agentPerm, !getValues(agentPerm), {
shouldDirty: true,
})
}
tabIndex={0}
>
{label}
</button>
<Controller
name={agentPerm}
control={control}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onCheckedChange={field.onChange}
value={field.value.toString()}
aria-label={label}
/>
)}
/>
</div>
);
const permissions: PermissionConfig[] = [
{ permission: Permissions.SHARED_GLOBAL, labelKey: 'com_ui_agents_allow_share' },
{ permission: Permissions.CREATE, labelKey: 'com_ui_agents_allow_create' },
{ permission: Permissions.USE, labelKey: 'com_ui_agents_allow_use' },
];
const AdminSettings = () => {
const localize = useLocalize();
const { showToast } = useToastContext();
const { user, roles } = useAuthContext();
const { mutate, isLoading } = useUpdateAgentPermissionsMutation({
const mutation = useUpdateAgentPermissionsMutation({
onSuccess: () => {
showToast({ status: 'success', message: localize('com_ui_saved') });
},
@ -76,157 +24,14 @@ const AdminSettings = () => {
},
});
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
const defaultValues = useMemo(() => {
const rolePerms = roles?.[selectedRole]?.permissions;
if (rolePerms) {
return rolePerms[PermissionTypes.AGENTS];
}
return roleDefaults[selectedRole].permissions[PermissionTypes.AGENTS];
}, [roles, selectedRole]);
const {
reset,
control,
setValue,
getValues,
handleSubmit,
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues,
});
useEffect(() => {
const value = roles?.[selectedRole]?.permissions?.[PermissionTypes.AGENTS];
if (value) {
reset(value);
} else {
reset(roleDefaults[selectedRole].permissions[PermissionTypes.AGENTS]);
}
}, [roles, selectedRole, reset]);
if (user?.role !== SystemRoles.ADMIN) {
return null;
}
const labelControllerData = [
{
agentPerm: Permissions.SHARED_GLOBAL,
label: localize('com_ui_agents_allow_share'),
},
{
agentPerm: Permissions.CREATE,
label: localize('com_ui_agents_allow_create'),
},
{
agentPerm: Permissions.USE,
label: localize('com_ui_agents_allow_use'),
},
];
const onSubmit = (data: FormValues) => {
mutate({ roleName: selectedRole, updates: data });
};
const roleDropdownItems = [
{
label: SystemRoles.USER,
onClick: () => {
setSelectedRole(SystemRoles.USER);
},
},
{
label: SystemRoles.ADMIN,
onClick: () => {
setSelectedRole(SystemRoles.ADMIN);
},
},
];
return (
<OGDialog>
<OGDialogTrigger asChild>
<Button
size={'sm'}
variant={'outline'}
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-primary"
aria-label={localize('com_ui_admin_settings')}
>
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
{localize('com_ui_admin_settings')}
</Button>
</OGDialogTrigger>
<OGDialogContent className="border-border-light bg-surface-primary text-text-primary lg:w-1/4">
<OGDialogTitle>
{localize('com_ui_admin_settings_section', { section: localize('com_ui_agents') })}
</OGDialogTitle>
<div className="p-2">
{/* Role selection dropdown */}
<div className="flex items-center gap-2">
<span className="font-medium">{localize('com_ui_role_select')}:</span>
<DropdownPopup
unmountOnHide={true}
menuId="role-dropdown"
isOpen={isRoleMenuOpen}
setIsOpen={setIsRoleMenuOpen}
trigger={
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
{selectedRole}
</Ariakit.MenuButton>
}
items={roleDropdownItems}
itemClassName="items-center justify-center"
sameWidth={true}
/>
</div>
{/* Permissions form */}
<form onSubmit={handleSubmit(onSubmit)}>
<div className="py-5">
{labelControllerData.map(({ agentPerm, label }) => (
<div key={agentPerm}>
<LabelController
control={control}
agentPerm={agentPerm}
label={label}
getValues={getValues}
setValue={setValue}
/>
{selectedRole === SystemRoles.ADMIN && agentPerm === Permissions.USE && (
<>
<div className="mb-2 max-w-full whitespace-normal break-words text-sm text-red-600">
<span>{localize('com_ui_admin_access_warning')}</span>
{'\n'}
<a
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
target="_blank"
rel="noreferrer"
className="text-blue-500 underline"
>
{localize('com_ui_more_info')}
</a>
</div>
</>
)}
</div>
))}
</div>
<div className="flex justify-end">
<button
type="button"
onClick={handleSubmit(onSubmit)}
disabled={isSubmitting || isLoading}
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring-primary"
>
{localize('com_ui_save')}
</button>
</div>
</form>
</div>
</OGDialogContent>
</OGDialog>
<AdminSettingsDialog
permissionType={PermissionTypes.AGENTS}
sectionKey="com_ui_agents"
permissions={permissions}
menuId="agent-role-dropdown"
mutation={mutation}
/>
);
};

View file

@ -0,0 +1,111 @@
import React, { useRef } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import { GripVertical } from 'lucide-react';
import type { TConversationTag } from 'librechat-data-provider';
import { TooltipAnchor, useToastContext } from '@librechat/client';
import { useConversationTagMutation } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import BookmarkCardActions from './BookmarkCardActions';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface BookmarkCardProps {
bookmark: TConversationTag;
position: number;
moveRow: (dragIndex: number, hoverIndex: number) => void;
}
interface DragItem {
index: number;
id: string;
type: string;
}
export default function BookmarkCard({ bookmark, position, moveRow }: BookmarkCardProps) {
const ref = useRef<HTMLDivElement>(null);
const localize = useLocalize();
const { showToast } = useToastContext();
const mutation = useConversationTagMutation({
context: 'BookmarkCard',
tag: bookmark.tag,
});
const handleDrop = (item: DragItem) => {
mutation.mutate(
{ ...bookmark, position: item.index },
{
onSuccess: () => {
showToast({
message: localize('com_ui_bookmarks_update_success'),
severity: NotificationSeverity.SUCCESS,
});
},
onError: () => {
showToast({
message: localize('com_ui_bookmarks_update_error'),
severity: NotificationSeverity.ERROR,
});
},
},
);
};
const [, drop] = useDrop({
accept: 'bookmark',
drop: handleDrop,
hover(item: DragItem) {
if (!ref.current || item.index === position) {
return;
}
moveRow(item.index, position);
item.index = position;
},
});
const [{ isDragging }, drag] = useDrag({
type: 'bookmark',
item: { index: position },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
drag(drop(ref));
return (
<div
ref={ref}
className={cn(
'flex cursor-move items-center gap-2 rounded-lg px-3 py-2.5',
'border border-border-light bg-transparent',
'hover:bg-surface-secondary',
isDragging && 'opacity-50',
)}
>
{/* Drag handle */}
<GripVertical className="size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
{/* Tag name */}
<span className="min-w-0 flex-1 truncate text-sm font-semibold text-text-primary">
{bookmark.tag}
</span>
{/* Count badge */}
<TooltipAnchor
description={`${bookmark.count} ${localize(bookmark.count === 1 ? 'com_ui_conversation' : 'com_ui_conversations')}`}
side="top"
render={
<span className="shrink-0 rounded-full bg-surface-tertiary px-2 py-0.5 text-xs text-text-secondary">
{bookmark.count}
</span>
}
/>
{/* Actions */}
<div className="shrink-0">
<BookmarkCardActions bookmark={bookmark} />
</div>
</div>
);
}

View file

@ -0,0 +1,118 @@
import { useState, useRef, useCallback } from 'react';
import { Pencil, Trash2 } from 'lucide-react';
import type { TConversationTag } from 'librechat-data-provider';
import {
Button,
OGDialog,
OGDialogTrigger,
OGDialogTemplate,
TooltipAnchor,
useToastContext,
} from '@librechat/client';
import { useDeleteConversationTagMutation } from '~/data-provider';
import { BookmarkEditDialog } from '~/components/Bookmarks';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks';
interface BookmarkCardActionsProps {
bookmark: TConversationTag;
}
export default function BookmarkCardActions({ bookmark }: BookmarkCardActionsProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const editTriggerRef = useRef<HTMLButtonElement>(null);
const deleteTriggerRef = useRef<HTMLButtonElement>(null);
const deleteBookmarkMutation = useDeleteConversationTagMutation({
onSuccess: () => {
showToast({
message: localize('com_ui_bookmarks_delete_success'),
});
setDeleteOpen(false);
},
onError: () => {
showToast({
message: localize('com_ui_bookmarks_delete_error'),
severity: NotificationSeverity.ERROR,
});
},
});
const confirmDelete = useCallback(async () => {
await deleteBookmarkMutation.mutateAsync(bookmark.tag);
}, [bookmark.tag, deleteBookmarkMutation]);
return (
<div className="flex items-center gap-1">
{/* Edit button */}
<BookmarkEditDialog
context="BookmarkCardActions"
bookmark={bookmark}
open={editOpen}
setOpen={setEditOpen}
triggerRef={editTriggerRef}
>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_edit')}
side="top"
render={
<Button
ref={editTriggerRef}
variant="ghost"
size="icon"
className="size-7"
aria-label={localize('com_ui_bookmarks_edit')}
onClick={() => setEditOpen(true)}
>
<Pencil className="size-4" aria-hidden="true" />
</Button>
}
/>
</OGDialogTrigger>
</BookmarkEditDialog>
{/* Delete button */}
<OGDialog open={deleteOpen} onOpenChange={setDeleteOpen} triggerRef={deleteTriggerRef}>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_delete')}
side="top"
render={
<Button
ref={deleteTriggerRef}
variant="ghost"
size="icon"
className="size-7"
aria-label={localize('com_ui_bookmarks_delete')}
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-4" aria-hidden="true" />
</Button>
}
/>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_bookmarks_delete')}
className="max-w-[450px]"
main={
<p className="text-left text-sm text-text-secondary">
{localize('com_ui_bookmark_delete_confirm')} <strong>{bookmark.tag}</strong>
</p>
}
selection={{
selectHandler: confirmDelete,
selectClasses:
'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
</div>
);
}

View file

@ -0,0 +1,21 @@
import { Bookmark } from 'lucide-react';
import { useLocalize } from '~/hooks';
interface BookmarkEmptyStateProps {
isFiltered?: boolean;
}
export default function BookmarkEmptyState({ isFiltered = false }: BookmarkEmptyStateProps) {
const localize = useLocalize();
return (
<div className="flex flex-col items-center justify-center py-8 text-center">
<div className="mb-3 rounded-full bg-surface-secondary p-3">
<Bookmark className="size-6 text-text-tertiary" aria-hidden="true" />
</div>
<p className="text-sm text-text-secondary">
{isFiltered ? localize('com_ui_no_bookmarks_match') : localize('com_ui_no_bookmarks')}
</p>
</div>
);
}

View file

@ -0,0 +1,32 @@
import type { TConversationTag } from 'librechat-data-provider';
import BookmarkEmptyState from './BookmarkEmptyState';
import BookmarkCard from './BookmarkCard';
import { useLocalize } from '~/hooks';
interface BookmarkListProps {
bookmarks: TConversationTag[];
moveRow: (dragIndex: number, hoverIndex: number) => void;
isFiltered?: boolean;
}
export default function BookmarkList({
bookmarks,
moveRow,
isFiltered = false,
}: BookmarkListProps) {
const localize = useLocalize();
if (bookmarks.length === 0) {
return <BookmarkEmptyState isFiltered={isFiltered} />;
}
return (
<div className="space-y-2" role="list" aria-label={localize('com_ui_bookmarks')}>
{bookmarks.map((bookmark) => (
<div key={bookmark._id} role="listitem">
<BookmarkCard bookmark={bookmark} position={bookmark.position} moveRow={moveRow} />
</div>
))}
</div>
);
}

View file

@ -1,22 +1,14 @@
import React, { useCallback, useEffect, useState } from 'react';
import { BookmarkPlusIcon } from 'lucide-react';
import {
Table,
Input,
Button,
TableRow,
TableHead,
TableBody,
TableCell,
TableHeader,
OGDialogTrigger,
} from '@librechat/client';
import { Plus } from 'lucide-react';
import { Button, FilterInput, OGDialogTrigger, TooltipAnchor } from '@librechat/client';
import type { ConversationTagsResponse, TConversationTag } from 'librechat-data-provider';
import { BookmarkContext, useBookmarkContext } from '~/Providers/BookmarkContext';
import { BookmarkEditDialog } from '~/components/Bookmarks';
import BookmarkTableRow from './BookmarkTableRow';
import BookmarkList from './BookmarkList';
import { useLocalize } from '~/hooks';
const pageSize = 10;
const removeDuplicates = (bookmarks: TConversationTag[]) => {
const seen = new Set();
return bookmarks.filter((bookmark) => {
@ -31,8 +23,7 @@ const BookmarkTable = () => {
const [rows, setRows] = useState<ConversationTagsResponse>([]);
const [pageIndex, setPageIndex] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
const [open, setOpen] = useState(false);
const pageSize = 10;
const [createOpen, setCreateOpen] = useState(false);
const { bookmarks = [] } = useBookmarkContext();
@ -41,6 +32,11 @@ const BookmarkTable = () => {
setRows(_bookmarks);
}, [bookmarks]);
// Reset page when search changes
useEffect(() => {
setPageIndex(0);
}, [searchQuery]);
const moveRow = useCallback((dragIndex: number, hoverIndex: number) => {
setRows((prevTags: TConversationTag[]) => {
const updatedRows = [...prevTags];
@ -50,86 +46,60 @@ const BookmarkTable = () => {
});
}, []);
const renderRow = useCallback(
(row: TConversationTag) => (
<BookmarkTableRow key={row._id} moveRow={moveRow} row={row} position={row.position} />
),
[moveRow],
);
const filteredRows = rows.filter(
(row) => row.tag && row.tag.toLowerCase().includes(searchQuery.toLowerCase()),
);
const currentRows = filteredRows.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
const totalPages = Math.ceil(filteredRows.length / pageSize);
return (
<BookmarkContext.Provider value={{ bookmarks }}>
<div role="region" aria-label={localize('com_ui_bookmarks')} className="mt-2 space-y-2">
<div className="relative flex items-center gap-4">
<Input
id="bookmarks-filter"
placeholder=" "
<div role="region" aria-label={localize('com_ui_bookmarks')} className="mt-2 space-y-3">
{/* Header: Filter + Create Button */}
<div className="flex items-center gap-2">
<FilterInput
inputId="bookmarks-filter"
label={localize('com_ui_bookmarks_filter')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
aria-label={localize('com_ui_bookmarks_filter')}
className="peer"
containerClassName="flex-1"
/>
<label
htmlFor="bookmarks-filter"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-text-secondary transition-all duration-200 peer-focus:top-0 peer-focus:bg-background peer-focus:px-1 peer-focus:text-xs peer-[:not(:placeholder-shown)]:top-0 peer-[:not(:placeholder-shown)]:bg-background peer-[:not(:placeholder-shown)]:px-1 peer-[:not(:placeholder-shown)]:text-xs"
<BookmarkEditDialog context="BookmarkTable" open={createOpen} setOpen={setCreateOpen}>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_bookmarks_new')}
side="bottom"
render={
<Button
variant="outline"
size="icon"
className="shrink-0 bg-transparent"
aria-label={localize('com_ui_bookmarks_new')}
onClick={() => setCreateOpen(true)}
>
<Plus className="size-4" aria-hidden="true" />
</Button>
}
/>
</OGDialogTrigger>
</BookmarkEditDialog>
</div>
{/* Bookmark List */}
<BookmarkList
bookmarks={currentRows}
moveRow={moveRow}
isFiltered={searchQuery.length > 0}
/>
{/* Pagination */}
{filteredRows.length > pageSize && (
<div
className="flex items-center justify-end gap-2"
role="navigation"
aria-label="Pagination"
>
{localize('com_ui_bookmarks_filter')}
</label>
</div>
<div className="rounded-lg border border-border-light bg-transparent shadow-sm transition-colors">
<Table className="w-full table-fixed">
<TableHeader>
<TableRow className="border-b border-border-light">
<TableHead className="w-[70%] bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary">
<div>{localize('com_ui_bookmarks_title')}</div>
</TableHead>
<TableHead className="w-[30%] bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary">
<div>{localize('com_ui_bookmarks_count')}</div>
</TableHead>
<TableHead className="w-[40%] bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary">
<div>{localize('com_assistants_actions')}</div>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentRows.length ? (
currentRows.map(renderRow)
) : (
<TableRow>
<TableCell colSpan={3} className="h-24 text-center text-sm text-text-secondary">
{localize('com_ui_no_bookmarks')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-between">
<div className="flex justify-between gap-2">
<BookmarkEditDialog context="BookmarkPanel" open={open} setOpen={setOpen}>
<OGDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="w-full gap-2 text-sm"
aria-label={localize('com_ui_bookmarks_new')}
onClick={() => setOpen(!open)}
>
<BookmarkPlusIcon className="size-4" aria-hidden="true" />
<div className="break-all">{localize('com_ui_bookmarks_new')}</div>
</Button>
</OGDialogTrigger>
</BookmarkEditDialog>
</div>
<div className="flex items-center gap-2" role="navigation" aria-label="Pagination">
<Button
variant="outline"
size="sm"
@ -139,24 +109,20 @@ const BookmarkTable = () => {
>
{localize('com_ui_prev')}
</Button>
<div aria-live="polite" className="text-sm">
{`${pageIndex + 1} / ${Math.ceil(filteredRows.length / pageSize)}`}
<div className="whitespace-nowrap text-sm" aria-live="polite">
{pageIndex + 1} / {totalPages}
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
setPageIndex((prev) =>
(prev + 1) * pageSize < filteredRows.length ? prev + 1 : prev,
)
}
disabled={(pageIndex + 1) * pageSize >= filteredRows.length}
onClick={() => setPageIndex((prev) => (prev + 1 < totalPages ? prev + 1 : prev))}
disabled={pageIndex + 1 >= totalPages}
aria-label={localize('com_ui_next')}
>
{localize('com_ui_next')}
</Button>
</div>
</div>
)}
</div>
</BookmarkContext.Provider>
);

View file

@ -1,90 +0,0 @@
import React, { useRef } from 'react';
import { useDrag, useDrop } from 'react-dnd';
import type { TConversationTag } from 'librechat-data-provider';
import { TableRow, TableCell, useToastContext } from '@librechat/client';
import { DeleteBookmarkButton, EditBookmarkButton } from '~/components/Bookmarks';
import { useConversationTagMutation } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { useLocalize } from '~/hooks';
interface BookmarkTableRowProps {
row: TConversationTag;
moveRow: (dragIndex: number, hoverIndex: number) => void;
position: number;
}
interface DragItem {
index: number;
id: string;
type: string;
}
const BookmarkTableRow: React.FC<BookmarkTableRowProps> = ({ row, moveRow, position }) => {
const ref = useRef<HTMLTableRowElement>(null);
const mutation = useConversationTagMutation({ context: 'BookmarkTableRow', tag: row.tag });
const localize = useLocalize();
const { showToast } = useToastContext();
const handleDrop = (item: DragItem) => {
mutation.mutate(
{ ...row, position: item.index },
{
onSuccess: () => {
showToast({
message: localize('com_ui_bookmarks_update_success'),
severity: NotificationSeverity.SUCCESS,
});
},
onError: () => {
showToast({
message: localize('com_ui_bookmarks_update_error'),
severity: NotificationSeverity.ERROR,
});
},
},
);
};
const [, drop] = useDrop({
accept: 'bookmark',
drop: handleDrop,
hover(item: DragItem) {
if (!ref.current || item.index === position) {
return;
}
moveRow(item.index, position);
item.index = position;
},
});
const [{ isDragging }, drag] = useDrag({
type: 'bookmark',
item: { index: position },
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
});
drag(drop(ref));
return (
<TableRow
ref={ref}
className="cursor-move hover:bg-surface-secondary"
style={{ opacity: isDragging ? 0.5 : 1 }}
>
<TableCell className="w-[70%] px-4 py-4">
<div className="overflow-hidden text-ellipsis whitespace-nowrap">{row.tag}</div>
</TableCell>
<TableCell className="w-[10%] px-12 py-4">{row.count}</TableCell>
<TableCell className="w-[20%] px-4 py-4">
<div className="flex gap-2">
<EditBookmarkButton bookmark={row} tabIndex={0} />
<DeleteBookmarkButton bookmark={row.tag} tabIndex={0} />
</div>
</TableCell>
</TableRow>
);
};
export default BookmarkTableRow;

View file

@ -0,0 +1,6 @@
export { default as BookmarkPanel } from './BookmarkPanel';
export { default as BookmarkTable } from './BookmarkTable';
export { default as BookmarkList } from './BookmarkList';
export { default as BookmarkCard } from './BookmarkCard';
export { default as BookmarkCardActions } from './BookmarkCardActions';
export { default as BookmarkEmptyState } from './BookmarkEmptyState';

View file

@ -1,14 +1,14 @@
import { useState, useCallback, useMemo, useRef } from 'react';
import { ArrowUpLeft } from 'lucide-react';
import {
Button,
Input,
Table,
Button,
TableRow,
TableHead,
TableBody,
TableCell,
TableHead,
FilterInput,
TableHeader,
TableRow,
useToastContext,
} from '@librechat/client';
import {
@ -182,22 +182,12 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
return (
<div role="region" aria-label={localize('com_files_table')} className="mt-2 space-y-2">
<div className="relative flex items-center gap-4">
<Input
id="filename-filter"
placeholder=" "
value={filenameFilter ?? ''}
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
aria-label={localize('com_files_filter')}
className="peer"
/>
<label
htmlFor="filename-filter"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-text-secondary transition-all duration-200 peer-focus:top-0 peer-focus:bg-background peer-focus:px-1 peer-focus:text-xs peer-[:not(:placeholder-shown)]:top-0 peer-[:not(:placeholder-shown)]:bg-background peer-[:not(:placeholder-shown)]:px-1 peer-[:not(:placeholder-shown)]:text-xs"
>
{localize('com_files_filter')}
</label>
</div>
<FilterInput
inputId="filename-filter"
label={localize('com_files_filter')}
value={filenameFilter ?? ''}
onChange={(event) => table.getColumn('filename')?.setFilterValue(event.target.value)}
/>
<div className="rounded-lg border border-border-light bg-transparent shadow-sm transition-colors">
<div className="overflow-x-auto">

View file

@ -1,72 +1,21 @@
import { useMemo, useEffect, useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { ShieldEllipsis } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
import {
Button,
Switch,
OGDialog,
DropdownPopup,
OGDialogTitle,
OGDialogContent,
OGDialogTrigger,
useToastContext,
} from '@librechat/client';
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import { useToastContext } from '@librechat/client';
import { AdminSettingsDialog } from '~/components/ui';
import { useUpdateMCPServersPermissionsMutation } from '~/data-provider';
import { useLocalize, useAuthContext } from '~/hooks';
import { useLocalize } from '~/hooks';
import type { PermissionConfig } from '~/components/ui';
type FormValues = Record<Permissions, boolean>;
type LabelControllerProps = {
label: string;
mcpServersPerm: Permissions;
control: Control<FormValues, unknown, FormValues>;
setValue: UseFormSetValue<FormValues>;
getValues: UseFormGetValues<FormValues>;
};
const LabelController: React.FC<LabelControllerProps> = ({
control,
mcpServersPerm,
label,
getValues,
setValue,
}) => (
<div className="mb-4 flex items-center justify-between gap-2">
<button
className="cursor-pointer select-none"
type="button"
onClick={() =>
setValue(mcpServersPerm, !getValues(mcpServersPerm), {
shouldDirty: true,
})
}
tabIndex={0}
>
{label}
</button>
<Controller
name={mcpServersPerm}
control={control}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onCheckedChange={field.onChange}
value={field.value?.toString()}
/>
)}
/>
</div>
);
const permissions: PermissionConfig[] = [
{ permission: Permissions.USE, labelKey: 'com_ui_mcp_servers_allow_use' },
{ permission: Permissions.CREATE, labelKey: 'com_ui_mcp_servers_allow_create' },
{ permission: Permissions.SHARE, labelKey: 'com_ui_mcp_servers_allow_share' },
];
const MCPAdminSettings = () => {
const localize = useLocalize();
const { showToast } = useToastContext();
const { user, roles } = useAuthContext();
const { mutate, isLoading } = useUpdateMCPServersPermissionsMutation({
const mutation = useUpdateMCPServersPermissionsMutation({
onSuccess: () => {
showToast({ status: 'success', message: localize('com_ui_saved') });
},
@ -75,157 +24,14 @@ const MCPAdminSettings = () => {
},
});
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
const defaultValues = useMemo(() => {
const rolePerms = roles?.[selectedRole]?.permissions;
if (rolePerms) {
return rolePerms[PermissionTypes.MCP_SERVERS];
}
return roleDefaults[selectedRole].permissions[PermissionTypes.MCP_SERVERS];
}, [roles, selectedRole]);
const {
reset,
control,
setValue,
getValues,
handleSubmit,
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues,
});
useEffect(() => {
const value = roles?.[selectedRole]?.permissions?.[PermissionTypes.MCP_SERVERS];
if (value) {
reset(value);
} else {
reset(roleDefaults[selectedRole].permissions[PermissionTypes.MCP_SERVERS]);
}
}, [roles, selectedRole, reset]);
if (user?.role !== SystemRoles.ADMIN) {
return null;
}
const labelControllerData = [
{
mcpServersPerm: Permissions.USE,
label: localize('com_ui_mcp_servers_allow_use'),
},
{
mcpServersPerm: Permissions.CREATE,
label: localize('com_ui_mcp_servers_allow_create'),
},
{
mcpServersPerm: Permissions.SHARE,
label: localize('com_ui_mcp_servers_allow_share'),
},
];
const onSubmit = (data: FormValues) => {
mutate({ roleName: selectedRole, updates: data });
};
const roleDropdownItems = [
{
label: SystemRoles.USER,
onClick: () => {
setSelectedRole(SystemRoles.USER);
},
},
{
label: SystemRoles.ADMIN,
onClick: () => {
setSelectedRole(SystemRoles.ADMIN);
},
},
];
return (
<OGDialog>
<OGDialogTrigger asChild>
<Button
size={'sm'}
variant={'outline'}
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-primary"
aria-label={localize('com_ui_admin_settings')}
>
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
{localize('com_ui_admin_settings')}
</Button>
</OGDialogTrigger>
<OGDialogContent className="border-border-light bg-surface-primary text-text-primary lg:w-1/4">
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
'com_ui_mcp_servers',
)}`}</OGDialogTitle>
<div className="p-2">
{/* Role selection dropdown */}
<div className="flex items-center gap-2">
<span className="font-medium">{localize('com_ui_role_select')}:</span>
<DropdownPopup
unmountOnHide={true}
menuId="role-dropdown"
isOpen={isRoleMenuOpen}
setIsOpen={setIsRoleMenuOpen}
trigger={
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
{selectedRole}
</Ariakit.MenuButton>
}
items={roleDropdownItems}
itemClassName="items-center justify-center"
sameWidth={true}
/>
</div>
{/* Permissions form */}
<form onSubmit={handleSubmit(onSubmit)}>
<div className="py-5">
{labelControllerData.map(({ mcpServersPerm, label }) => (
<div key={mcpServersPerm}>
<LabelController
control={control}
mcpServersPerm={mcpServersPerm}
label={label}
getValues={getValues}
setValue={setValue}
/>
{selectedRole === SystemRoles.ADMIN && mcpServersPerm === Permissions.USE && (
<>
<div className="mb-2 max-w-full whitespace-normal break-words text-sm text-red-600">
<span>{localize('com_ui_admin_access_warning')}</span>
{'\n'}
<a
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
target="_blank"
rel="noreferrer"
className="text-blue-500 underline"
>
{localize('com_ui_more_info')}
</a>
</div>
</>
)}
</div>
))}
</div>
<div className="flex justify-end">
<button
type="button"
onClick={handleSubmit(onSubmit)}
disabled={isSubmitting || isLoading}
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-primary"
>
{localize('com_ui_save')}
</button>
</div>
</form>
</div>
</OGDialogContent>
</OGDialog>
<AdminSettingsDialog
permissionType={PermissionTypes.MCP_SERVERS}
sectionKey="com_ui_mcp_servers"
permissions={permissions}
menuId="mcp-role-dropdown"
mutation={mutation}
/>
);
};

View file

@ -1,7 +1,7 @@
import { useState, useRef, useMemo } from 'react';
import { Plus, Search } from 'lucide-react';
import { Plus } from 'lucide-react';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { Button, Spinner, OGDialogTrigger, Input } from '@librechat/client';
import { Button, Spinner, FilterInput, OGDialogTrigger } from '@librechat/client';
import { useLocalize, useMCPServerManager, useHasAccess } from '~/hooks';
import MCPServerList from './MCPServerList';
import MCPServerDialog from './MCPServerDialog';
@ -42,16 +42,12 @@ export default function MCPBuilderPanel() {
<MCPAdminSettings />
{/* Search Input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-text-secondary" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={localize('com_ui_filter_mcp_servers')}
className="pl-9"
aria-label={localize('com_ui_filter_mcp_servers')}
/>
</div>
<FilterInput
inputId="mcp-filter"
label={localize('com_ui_filter_mcp_servers')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{hasCreateAccess && (
<MCPServerDialog open={showDialog} onOpenChange={setShowDialog} triggerRef={addButtonRef}>

View file

@ -1,56 +1,23 @@
import * as Ariakit from '@ariakit/react';
import { useMemo, useEffect, useState } from 'react';
import { ShieldEllipsis } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
import {
OGDialog,
OGDialogTitle,
OGDialogContent,
OGDialogTrigger,
Button,
Switch,
DropdownPopup,
useToastContext,
} from '@librechat/client';
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import { useToastContext } from '@librechat/client';
import { AdminSettingsDialog } from '~/components/ui';
import { useUpdateMemoryPermissionsMutation } from '~/data-provider';
import { useLocalize, useAuthContext } from '~/hooks';
import { useLocalize } from '~/hooks';
import type { PermissionConfig } from '~/components/ui';
type FormValues = Record<Permissions, boolean>;
type LabelControllerProps = {
label: string;
memoryPerm: Permissions;
control: Control<FormValues, unknown, FormValues>;
setValue: UseFormSetValue<FormValues>;
getValues: UseFormGetValues<FormValues>;
};
const LabelController: React.FC<LabelControllerProps> = ({ control, memoryPerm, label }) => (
<div className="mb-4 flex items-center justify-between gap-2">
{label}
<Controller
name={memoryPerm}
control={control}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onCheckedChange={field.onChange}
value={field.value.toString()}
aria-label={label}
/>
)}
/>
</div>
);
const permissions: PermissionConfig[] = [
{ permission: Permissions.USE, labelKey: 'com_ui_memories_allow_use' },
{ permission: Permissions.CREATE, labelKey: 'com_ui_memories_allow_create' },
{ permission: Permissions.UPDATE, labelKey: 'com_ui_memories_allow_update' },
{ permission: Permissions.READ, labelKey: 'com_ui_memories_allow_read' },
{ permission: Permissions.OPT_OUT, labelKey: 'com_ui_memories_allow_opt_out' },
];
const AdminSettings = () => {
const localize = useLocalize();
const { user, roles } = useAuthContext();
const { showToast } = useToastContext();
const { mutate, isLoading } = useUpdateMemoryPermissionsMutation({
const mutation = useUpdateMemoryPermissionsMutation({
onSuccess: () => {
showToast({ status: 'success', message: localize('com_ui_saved') });
},
@ -59,162 +26,14 @@ const AdminSettings = () => {
},
});
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
const defaultValues = useMemo(() => {
if (roles?.[selectedRole]?.permissions) {
return roles?.[selectedRole]?.permissions?.[PermissionTypes.MEMORIES];
}
return roleDefaults[selectedRole].permissions[PermissionTypes.MEMORIES];
}, [roles, selectedRole]);
const {
reset,
control,
setValue,
getValues,
handleSubmit,
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues,
});
useEffect(() => {
if (roles?.[selectedRole]?.permissions?.[PermissionTypes.MEMORIES]) {
reset(roles?.[selectedRole]?.permissions?.[PermissionTypes.MEMORIES]);
} else {
reset(roleDefaults[selectedRole].permissions[PermissionTypes.MEMORIES]);
}
}, [roles, selectedRole, reset]);
if (user?.role !== SystemRoles.ADMIN) {
return null;
}
const labelControllerData = [
{
memoryPerm: Permissions.USE,
label: localize('com_ui_memories_allow_use'),
},
{
memoryPerm: Permissions.CREATE,
label: localize('com_ui_memories_allow_create'),
},
{
memoryPerm: Permissions.UPDATE,
label: localize('com_ui_memories_allow_update'),
},
{
memoryPerm: Permissions.READ,
label: localize('com_ui_memories_allow_read'),
},
{
memoryPerm: Permissions.OPT_OUT,
label: localize('com_ui_memories_allow_opt_out'),
},
];
const onSubmit = (data: FormValues) => {
mutate({ roleName: selectedRole, updates: data });
};
const roleDropdownItems = [
{
label: SystemRoles.USER,
onClick: () => {
setSelectedRole(SystemRoles.USER);
},
},
{
label: SystemRoles.ADMIN,
onClick: () => {
setSelectedRole(SystemRoles.ADMIN);
},
},
];
return (
<OGDialog>
<OGDialogTrigger asChild>
<Button
size={'sm'}
variant={'outline'}
className="btn btn-neutral border-token-border-light relative h-9 w-full gap-1 rounded-lg font-medium focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-primary"
aria-label={localize('com_ui_admin_settings')}
>
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
{localize('com_ui_admin_settings')}
</Button>
</OGDialogTrigger>
<OGDialogContent className="border-border-light bg-surface-primary text-text-primary lg:w-1/4">
<OGDialogTitle>
{localize('com_ui_admin_settings_section', { section: localize('com_ui_memories') })}
</OGDialogTitle>
<div className="p-2">
{/* Role selection dropdown */}
<div className="flex items-center gap-2">
<span className="font-medium">{localize('com_ui_role_select')}:</span>
<DropdownPopup
unmountOnHide={true}
menuId="memory-role-dropdown"
isOpen={isRoleMenuOpen}
setIsOpen={setIsRoleMenuOpen}
trigger={
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
{selectedRole}
</Ariakit.MenuButton>
}
items={roleDropdownItems}
itemClassName="items-center justify-center"
sameWidth={true}
/>
</div>
{/* Permissions form */}
<form onSubmit={handleSubmit(onSubmit)}>
<div className="py-5">
{labelControllerData.map(({ memoryPerm, label }) => (
<div key={memoryPerm}>
<LabelController
control={control}
memoryPerm={memoryPerm}
label={label}
getValues={getValues}
setValue={setValue}
/>
{selectedRole === SystemRoles.ADMIN && memoryPerm === Permissions.USE && (
<>
<div className="mb-2 max-w-full whitespace-normal break-words text-sm text-red-600">
<span>{localize('com_ui_admin_access_warning')}</span>
{'\n'}
<a
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
target="_blank"
rel="noreferrer"
className="text-blue-500 underline"
>
{localize('com_ui_more_info')}
</a>
</div>
</>
)}
</div>
))}
</div>
<div className="flex justify-end">
<button
type="submit"
disabled={isSubmitting || isLoading}
className="btn rounded bg-green-500 font-bold text-white transition-all hover:bg-green-600 focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-primary"
>
{localize('com_ui_save')}
</button>
</div>
</form>
</div>
</OGDialogContent>
</OGDialog>
<AdminSettingsDialog
permissionType={PermissionTypes.MEMORIES}
sectionKey="com_ui_memories"
permissions={permissions}
menuId="memory-role-dropdown"
mutation={mutation}
/>
);
};

View file

@ -0,0 +1,57 @@
import type { TUserMemory } from 'librechat-data-provider';
import MemoryCardActions from './MemoryCardActions';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface MemoryCardProps {
memory: TUserMemory;
hasUpdateAccess: boolean;
}
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
};
export default function MemoryCard({ memory, hasUpdateAccess }: MemoryCardProps) {
const localize = useLocalize();
return (
<div
className={cn(
'rounded-lg px-3 py-2.5',
'border border-border-light bg-transparent',
'hover:bg-surface-secondary',
)}
>
{/* Row 1: Key + Token count + Actions */}
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-text-primary">{memory.key}</span>
{memory.tokenCount !== undefined && (
<span className="shrink-0 text-xs text-text-secondary">
{memory.tokenCount}{' '}
{localize(memory.tokenCount === 1 ? 'com_ui_token' : 'com_ui_tokens')}
</span>
)}
{hasUpdateAccess && (
<div className="ml-auto shrink-0">
<MemoryCardActions memory={memory} />
</div>
)}
</div>
{/* Row 2: Value + Date */}
<div className="mt-1 flex items-baseline gap-2">
<p className="min-w-0 flex-1 truncate text-sm text-text-primary" title={memory.value}>
{memory.value}
</p>
<span className="shrink-0 text-xs text-text-secondary">
{formatDate(memory.updated_at)}
</span>
</div>
</div>
);
}

View file

@ -0,0 +1,124 @@
import { useState, useRef } from 'react';
import { Pencil } from 'lucide-react';
import { Trans } from 'react-i18next';
import {
Label,
Spinner,
OGDialog,
TrashIcon,
TooltipAnchor,
OGDialogTrigger,
OGDialogTemplate,
useToastContext,
} from '@librechat/client';
import type { TUserMemory } from 'librechat-data-provider';
import { useDeleteMemoryMutation } from '~/data-provider';
import MemoryEditDialog from './MemoryEditDialog';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface MemoryCardActionsProps {
memory: TUserMemory;
}
export default function MemoryCardActions({ memory }: MemoryCardActionsProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const { mutate: deleteMemory, isLoading: isDeleting } = useDeleteMemoryMutation();
const buttonBaseClass = cn(
'flex size-7 items-center justify-center rounded-md',
'transition-colors duration-150',
'text-text-secondary hover:text-text-primary',
'hover:bg-surface-tertiary',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
);
const confirmDelete = () => {
deleteMemory(memory.key, {
onSuccess: () => {
showToast({ message: localize('com_ui_deleted'), status: 'success' });
setDeleteOpen(false);
},
onError: () => {
showToast({ message: localize('com_ui_error'), status: 'error' });
},
});
};
return (
<div className="flex items-center gap-0.5">
{/* Edit Button */}
<MemoryEditDialog
open={editOpen}
memory={memory}
onOpenChange={setEditOpen}
triggerRef={triggerRef as React.MutableRefObject<HTMLButtonElement | null>}
>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_edit_memory')}
side="top"
render={
<button
ref={triggerRef}
className={buttonBaseClass}
aria-label={localize('com_ui_edit')}
onClick={() => setEditOpen(true)}
>
<Pencil className="size-3.5" aria-hidden="true" />
</button>
}
/>
</OGDialogTrigger>
</MemoryEditDialog>
{/* Delete Button */}
<OGDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_delete_memory')}
side="top"
render={
<button
className={buttonBaseClass}
aria-label={localize('com_ui_delete')}
onClick={() => setDeleteOpen(true)}
>
{isDeleting ? (
<Spinner className="size-3.5" />
) : (
<TrashIcon className="size-3.5" aria-hidden="true" />
)}
</button>
}
/>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_memory')}
className="w-11/12 max-w-lg"
main={
<Label className="text-left text-sm font-medium">
<Trans
i18nKey="com_ui_delete_confirm_strong"
values={{ title: memory.key }}
components={{ strong: <strong /> }}
/>
</Label>
}
selection={{
selectHandler: confirmDelete,
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
</div>
);
}

View file

@ -108,11 +108,11 @@ export default function MemoryCreateDialog({
<OGDialogTemplate
title={localize('com_ui_create_memory')}
showCloseButton={false}
className="max-h-[90vh] w-11/12 overflow-y-auto md:max-w-lg"
className="w-11/12 md:max-w-lg"
main={
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="memory-key" className="text-sm font-medium">
<Label htmlFor="memory-key" className="text-sm font-medium text-text-primary">
{localize('com_ui_key')}
</Label>
<Input
@ -123,9 +123,10 @@ export default function MemoryCreateDialog({
placeholder={localize('com_ui_enter_key')}
className="w-full"
/>
<p className="text-xs text-text-secondary">{localize('com_ui_memory_key_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="memory-value" className="text-sm font-medium">
<Label htmlFor="memory-value" className="text-sm font-medium text-text-primary">
{localize('com_ui_value')}
</Label>
<textarea
@ -134,8 +135,8 @@ export default function MemoryCreateDialog({
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyPress}
placeholder={localize('com_ui_enter_value')}
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
rows={3}
className="min-h-[100px] w-full resize-none rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm text-text-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-heavy"
rows={4}
/>
</div>
</div>

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import {
OGDialog,
@ -12,6 +12,7 @@ import {
import type { TUserMemory } from 'librechat-data-provider';
import { useUpdateMemoryMutation, useMemoriesQuery } from '~/data-provider';
import { useLocalize, useHasAccess } from '~/hooks';
import MemoryUsageBadge from './MemoryUsageBadge';
interface MemoryEditDialogProps {
memory: TUserMemory | null;
@ -21,6 +22,16 @@ interface MemoryEditDialogProps {
triggerRef?: React.MutableRefObject<HTMLButtonElement | null>;
}
const formatDateTime = (dateString: string): string => {
return new Date(dateString).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
};
export default function MemoryEditDialog({
memory,
open,
@ -116,6 +127,16 @@ export default function MemoryEditDialog({
}
};
// Calculate memory-specific usage: available = tokenLimit - (totalTokens - thisMemoryTokens)
const memoryUsage = useMemo(() => {
if (!memory?.tokenCount || !memData?.tokenLimit) {
return null;
}
const availableForMemory = memData.tokenLimit - (memData.totalTokens ?? 0) + memory.tokenCount;
const percentage = Math.round((memory.tokenCount / availableForMemory) * 100);
return { availableForMemory, percentage };
}, [memory?.tokenCount, memData?.tokenLimit, memData?.totalTokens]);
return (
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
{children}
@ -125,38 +146,41 @@ export default function MemoryEditDialog({
className="w-11/12 md:max-w-lg"
main={
<div className="space-y-4">
{/* Memory metadata */}
{memory && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs text-text-secondary">
<div>
{localize('com_ui_date')}:{' '}
{new Date(memory.updated_at).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</div>
{/* Token Information */}
{memory.tokenCount !== undefined && (
<div>
{memory.tokenCount.toLocaleString()}
{memData?.tokenLimit && ` / ${memData.tokenLimit.toLocaleString()}`}{' '}
{localize(memory.tokenCount === 1 ? 'com_ui_token' : 'com_ui_tokens')}
</div>
)}
</div>
{/* Overall Memory Usage */}
{memData?.tokenLimit && memData?.usagePercentage !== null && (
<div className="text-xs text-text-secondary">
{localize('com_ui_usage')}: {memData.usagePercentage}%{' '}
</div>
<div className="flex items-center justify-between rounded-lg border border-border-light bg-surface-secondary px-3 py-2">
{/* Token count - Left */}
{memory.tokenCount !== undefined ? (
<span className="text-xs text-text-secondary">
{memory.tokenCount.toLocaleString()}{' '}
{localize(memory.tokenCount === 1 ? 'com_ui_token' : 'com_ui_tokens')}
</span>
) : (
<div />
)}
{/* Date - Center */}
<span className="text-xs text-text-secondary">
{formatDateTime(memory.updated_at)}
</span>
{/* Usage badge - Right (memory-specific) */}
{memoryUsage ? (
<MemoryUsageBadge
percentage={memoryUsage.percentage}
tokenLimit={memData?.tokenLimit ?? 0}
tooltipCurrent={memory.tokenCount}
tooltipMax={memoryUsage.availableForMemory}
/>
) : (
<div />
)}
</div>
)}
{/* Key input */}
<div className="space-y-2">
<Label htmlFor="memory-key" className="text-sm font-medium">
<Label htmlFor="memory-key" className="text-sm font-medium text-text-primary">
{localize('com_ui_key')}
</Label>
<Input
@ -169,8 +193,10 @@ export default function MemoryEditDialog({
disabled={!hasUpdateAccess}
/>
</div>
{/* Value textarea */}
<div className="space-y-2">
<Label htmlFor="memory-value" className="text-sm font-medium">
<Label htmlFor="memory-value" className="text-sm font-medium text-text-primary">
{localize('com_ui_value')}
</Label>
<textarea
@ -179,8 +205,8 @@ export default function MemoryEditDialog({
onChange={(e) => hasUpdateAccess && setValue(e.target.value)}
onKeyDown={handleKeyPress}
placeholder={localize('com_ui_enter_value')}
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
rows={3}
className="min-h-[100px] w-full resize-none rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm text-text-primary focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border-heavy disabled:cursor-not-allowed disabled:opacity-50"
rows={4}
disabled={!hasUpdateAccess}
/>
</div>

View file

@ -0,0 +1,28 @@
import { Brain } from 'lucide-react';
import { useLocalize } from '~/hooks';
interface MemoryEmptyStateProps {
isFiltered?: boolean;
}
export default function MemoryEmptyState({ isFiltered = false }: MemoryEmptyStateProps) {
const localize = useLocalize();
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-border-light bg-surface-primary p-6 text-center">
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-surface-tertiary">
<Brain className="size-5 text-text-secondary" aria-hidden="true" />
</div>
{isFiltered ? (
<p className="text-sm text-text-secondary">{localize('com_ui_no_memories_match')}</p>
) : (
<>
<p className="text-sm font-medium text-text-primary">
{localize('com_ui_no_memories_title')}
</p>
<p className="mt-0.5 text-xs text-text-secondary">{localize('com_ui_no_memories')}</p>
</>
)}
</div>
);
}

View file

@ -0,0 +1,32 @@
import type { TUserMemory } from 'librechat-data-provider';
import MemoryEmptyState from './MemoryEmptyState';
import MemoryCard from './MemoryCard';
import { useLocalize } from '~/hooks';
interface MemoryListProps {
memories: TUserMemory[];
hasUpdateAccess: boolean;
isFiltered?: boolean;
}
export default function MemoryList({
memories,
hasUpdateAccess,
isFiltered = false,
}: MemoryListProps) {
const localize = useLocalize();
if (memories.length === 0) {
return <MemoryEmptyState isFiltered={isFiltered} />;
}
return (
<div className="space-y-2" role="list" aria-label={localize('com_ui_memories')}>
{memories.map((memory) => (
<div key={memory.key} role="listitem">
<MemoryCard memory={memory} hasUpdateAccess={hasUpdateAccess} />
</div>
))}
</div>
);
}

View file

@ -0,0 +1,229 @@
import { useMemo, useState, useEffect } from 'react';
import { Plus } from 'lucide-react';
import { matchSorter } from 'match-sorter';
import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider';
import {
Button,
Switch,
Spinner,
FilterInput,
TooltipAnchor,
OGDialogTrigger,
useToastContext,
} from '@librechat/client';
import type { TUserMemory } from 'librechat-data-provider';
import {
useUpdateMemoryPreferencesMutation,
useMemoriesQuery,
useGetUserQuery,
} from '~/data-provider';
import { useLocalize, useAuthContext, useHasAccess } from '~/hooks';
import MemoryCreateDialog from './MemoryCreateDialog';
import MemoryUsageBadge from './MemoryUsageBadge';
import AdminSettings from './AdminSettings';
import MemoryList from './MemoryList';
const pageSize = 10;
export default function MemoryPanel() {
const localize = useLocalize();
const { user } = useAuthContext();
const { data: userData } = useGetUserQuery();
const { data: memData, isLoading } = useMemoriesQuery();
const { showToast } = useToastContext();
const [pageIndex, setPageIndex] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [referenceSavedMemories, setReferenceSavedMemories] = useState(true);
const updateMemoryPreferencesMutation = useUpdateMemoryPreferencesMutation({
onSuccess: () => {
showToast({
message: localize('com_ui_preferences_updated'),
status: 'success',
});
},
onError: () => {
showToast({
message: localize('com_ui_error_updating_preferences'),
status: 'error',
});
setReferenceSavedMemories((prev) => !prev);
},
});
useEffect(() => {
if (userData?.personalization?.memories !== undefined) {
setReferenceSavedMemories(userData.personalization.memories);
}
}, [userData?.personalization?.memories]);
const handleMemoryToggle = (checked: boolean) => {
setReferenceSavedMemories(checked);
updateMemoryPreferencesMutation.mutate({ memories: checked });
};
const hasReadAccess = useHasAccess({
permissionType: PermissionTypes.MEMORIES,
permission: Permissions.READ,
});
const hasUpdateAccess = useHasAccess({
permissionType: PermissionTypes.MEMORIES,
permission: Permissions.UPDATE,
});
const hasCreateAccess = useHasAccess({
permissionType: PermissionTypes.MEMORIES,
permission: Permissions.CREATE,
});
const hasOptOutAccess = useHasAccess({
permissionType: PermissionTypes.MEMORIES,
permission: Permissions.OPT_OUT,
});
const memories: TUserMemory[] = useMemo(() => memData?.memories ?? [], [memData]);
const filteredMemories = useMemo(() => {
return matchSorter(memories, searchQuery, {
keys: ['key', 'value'],
});
}, [memories, searchQuery]);
const currentRows = useMemo(() => {
return filteredMemories.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
}, [filteredMemories, pageIndex]);
// Reset page when search changes
useEffect(() => {
setPageIndex(0);
}, [searchQuery]);
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center p-4">
<Spinner />
</div>
);
}
if (!hasReadAccess) {
return (
<div className="flex h-full w-full items-center justify-center p-4">
<div className="text-center">
<p className="text-sm text-text-secondary">{localize('com_ui_no_read_access')}</p>
</div>
</div>
);
}
const totalPages = Math.ceil(filteredMemories.length / pageSize);
return (
<div className="flex h-full w-full flex-col">
<div role="region" aria-label={localize('com_ui_memories')} className="mt-2 space-y-3">
{/* Header: Filter + Create Button */}
<div className="flex items-center gap-2">
<FilterInput
inputId="memory-search"
label={localize('com_ui_memories_filter')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
containerClassName="flex-1"
/>
{hasCreateAccess && (
<MemoryCreateDialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_create_memory')}
side="bottom"
render={
<Button
variant="outline"
size="icon"
className="shrink-0 bg-transparent"
aria-label={localize('com_ui_create_memory')}
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="size-4" aria-hidden="true" />
</Button>
}
/>
</OGDialogTrigger>
</MemoryCreateDialog>
)}
</div>
{/* Controls: Usage Badge + Memory Toggle */}
{(memData?.tokenLimit != null || hasOptOutAccess) && (
<div className="flex items-center justify-between">
{/* Usage Badge */}
{memData?.tokenLimit != null && (
<MemoryUsageBadge
percentage={memData.usagePercentage ?? 0}
tokenLimit={memData.tokenLimit}
totalTokens={memData.totalTokens}
/>
)}
{/* Memory Toggle */}
{hasOptOutAccess && (
<div className="flex items-center gap-2 text-xs">
<span className="text-text-secondary">{localize('com_ui_use_memory')}</span>
<Switch
checked={referenceSavedMemories}
onCheckedChange={handleMemoryToggle}
aria-label={localize('com_ui_use_memory')}
disabled={updateMemoryPreferencesMutation.isLoading}
/>
</div>
)}
</div>
)}
{/* Memory List */}
<MemoryList
memories={currentRows}
hasUpdateAccess={hasUpdateAccess}
isFiltered={searchQuery.length > 0}
/>
{/* Footer: Admin Settings + Pagination */}
{(user?.role === SystemRoles.ADMIN || filteredMemories.length > pageSize) && (
<div className="flex items-center justify-between gap-2">
{/* Admin Settings - Left */}
{user?.role === SystemRoles.ADMIN ? <AdminSettings /> : <div />}
{/* Pagination - Right */}
{filteredMemories.length > pageSize && (
<div className="flex items-center gap-2" role="navigation" aria-label="Pagination">
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex((prev) => Math.max(prev - 1, 0))}
disabled={pageIndex === 0}
aria-label={localize('com_ui_prev')}
>
{localize('com_ui_prev')}
</Button>
<div className="whitespace-nowrap text-sm" aria-live="polite">
{pageIndex + 1} / {totalPages}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex((prev) => (prev + 1 < totalPages ? prev + 1 : prev))}
disabled={pageIndex + 1 >= totalPages}
aria-label={localize('com_ui_next')}
>
{localize('com_ui_next')}
</Button>
</div>
)}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,63 @@
import { TooltipAnchor } from '@librechat/client';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface MemoryUsageBadgeProps {
percentage: number;
tokenLimit: number;
totalTokens?: number;
/** Custom current value for tooltip (overrides totalTokens) */
tooltipCurrent?: number;
/** Custom max value for tooltip (overrides tokenLimit) */
tooltipMax?: number;
}
const getStatusColor = (pct: number): string => {
if (pct > 90) {
return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400';
}
if (pct > 75) {
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400';
}
return 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400';
};
export default function MemoryUsageBadge({
percentage,
tokenLimit,
totalTokens,
tooltipCurrent,
tooltipMax,
}: MemoryUsageBadgeProps) {
const localize = useLocalize();
const tokenLabel = localize('com_ui_tokens');
const current = tooltipCurrent ?? totalTokens;
const max = tooltipMax ?? tokenLimit;
const tooltipText =
current !== undefined
? `${current.toLocaleString()} / ${max.toLocaleString()} ${tokenLabel}`
: `${max.toLocaleString()} ${tokenLabel}`;
return (
<TooltipAnchor
description={tooltipText}
side="top"
render={
<div
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1',
'text-xs font-medium',
getStatusColor(percentage),
)}
role="status"
aria-label={`${localize('com_ui_usage')}: ${percentage}%`}
>
<span>{percentage}%</span>
<span className="opacity-70">{localize('com_ui_used').toLowerCase()}</span>
</div>
}
/>
);
}

View file

@ -1,434 +0,0 @@
/* Memories */
import { useMemo, useState, useRef, useEffect } from 'react';
import { Plus } from 'lucide-react';
import { Trans } from 'react-i18next';
import { matchSorter } from 'match-sorter';
import { SystemRoles, PermissionTypes, Permissions } from 'librechat-data-provider';
import {
Table,
Input,
Label,
Button,
Switch,
Spinner,
TableRow,
OGDialog,
EditIcon,
TableHead,
TableBody,
TrashIcon,
TableCell,
TableHeader,
TooltipAnchor,
useToastContext,
OGDialogTrigger,
OGDialogTemplate,
} from '@librechat/client';
import type { TUserMemory } from 'librechat-data-provider';
import {
useUpdateMemoryPreferencesMutation,
useDeleteMemoryMutation,
useMemoriesQuery,
useGetUserQuery,
} from '~/data-provider';
import { useLocalize, useAuthContext, useHasAccess } from '~/hooks';
import MemoryCreateDialog from './MemoryCreateDialog';
import MemoryEditDialog from './MemoryEditDialog';
import AdminSettings from './AdminSettings';
import { cn } from '~/utils';
const EditMemoryButton = ({ memory }: { memory: TUserMemory }) => {
const localize = useLocalize();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLDivElement>(null);
return (
<MemoryEditDialog
open={open}
memory={memory}
onOpenChange={setOpen}
triggerRef={triggerRef as React.MutableRefObject<HTMLButtonElement | null>}
>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_edit_memory')}
render={
<Button
variant="ghost"
aria-label={localize('com_ui_bookmarks_edit')}
onClick={() => setOpen(!open)}
className="h-8 w-8 p-0"
>
<EditIcon />
</Button>
}
/>
</OGDialogTrigger>
</MemoryEditDialog>
);
};
const DeleteMemoryButton = ({ memory }: { memory: TUserMemory }) => {
const localize = useLocalize();
const { showToast } = useToastContext();
const [open, setOpen] = useState(false);
const { mutate: deleteMemory } = useDeleteMemoryMutation();
const [deletingKey, setDeletingKey] = useState<string | null>(null);
const confirmDelete = async () => {
setDeletingKey(memory.key);
deleteMemory(memory.key, {
onSuccess: () => {
showToast({
message: localize('com_ui_deleted'),
status: 'success',
});
setOpen(false);
},
onError: () =>
showToast({
message: localize('com_ui_error'),
status: 'error',
}),
onSettled: () => setDeletingKey(null),
});
};
return (
<OGDialog open={open} onOpenChange={setOpen}>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_delete_memory')}
render={
<Button
variant="ghost"
aria-label={localize('com_ui_delete')}
onClick={() => setOpen(!open)}
className="h-8 w-8 p-0"
>
{deletingKey === memory.key ? (
<Spinner className="size-4 animate-spin" />
) : (
<TrashIcon className="size-4" />
)}
</Button>
}
/>
</OGDialogTrigger>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_memory')}
className="w-11/12 max-w-lg"
main={
<Label className="text-left text-sm font-medium">
<Trans
i18nKey="com_ui_delete_confirm_strong"
values={{ title: memory.key }}
components={{ strong: <strong /> }}
/>
</Label>
}
selection={{
selectHandler: confirmDelete,
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
);
};
const pageSize = 10;
export default function MemoryViewer() {
const localize = useLocalize();
const { user } = useAuthContext();
const { data: userData } = useGetUserQuery();
const { data: memData, isLoading } = useMemoriesQuery();
const { showToast } = useToastContext();
const [pageIndex, setPageIndex] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [referenceSavedMemories, setReferenceSavedMemories] = useState(true);
const updateMemoryPreferencesMutation = useUpdateMemoryPreferencesMutation({
onSuccess: () => {
showToast({
message: localize('com_ui_preferences_updated'),
status: 'success',
});
},
onError: () => {
showToast({
message: localize('com_ui_error_updating_preferences'),
status: 'error',
});
setReferenceSavedMemories((prev) => !prev);
},
});
useEffect(() => {
if (userData?.personalization?.memories !== undefined) {
setReferenceSavedMemories(userData.personalization.memories);
}
}, [userData?.personalization?.memories]);
const handleMemoryToggle = (checked: boolean) => {
setReferenceSavedMemories(checked);
updateMemoryPreferencesMutation.mutate({ memories: checked });
};
const hasReadAccess = useHasAccess({
permissionType: PermissionTypes.MEMORIES,
permission: Permissions.READ,
});
const hasUpdateAccess = useHasAccess({
permissionType: PermissionTypes.MEMORIES,
permission: Permissions.UPDATE,
});
const hasCreateAccess = useHasAccess({
permissionType: PermissionTypes.MEMORIES,
permission: Permissions.CREATE,
});
const hasOptOutAccess = useHasAccess({
permissionType: PermissionTypes.MEMORIES,
permission: Permissions.OPT_OUT,
});
const memories: TUserMemory[] = useMemo(() => memData?.memories ?? [], [memData]);
const filteredMemories = useMemo(() => {
return matchSorter(memories, searchQuery, {
keys: ['key', 'value'],
});
}, [memories, searchQuery]);
const currentRows = useMemo(() => {
return filteredMemories.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
}, [filteredMemories, pageIndex]);
const getProgressBarColor = (percentage: number): string => {
if (percentage > 90) {
return 'stroke-red-500';
}
if (percentage > 75) {
return 'stroke-yellow-500';
}
return 'stroke-green-500';
};
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center p-4">
<Spinner />
</div>
);
}
if (!hasReadAccess) {
return (
<div className="flex h-full w-full items-center justify-center p-4">
<div className="text-center">
<p className="text-sm text-text-secondary">{localize('com_ui_no_read_access')}</p>
</div>
</div>
);
}
return (
<div className="flex h-full w-full flex-col">
<div role="region" aria-label={localize('com_ui_memories')} className="mt-2 space-y-2">
<div className="relative">
<Input
id="memory-search"
placeholder=" "
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
aria-label={localize('com_ui_memories_filter')}
className="peer"
/>
<Label
htmlFor="memory-search"
className="pointer-events-none absolute -top-1 left-3 w-auto origin-[0] translate-y-3 scale-100 rounded bg-background px-1 text-base text-text-secondary transition-transform duration-200 peer-placeholder-shown:translate-y-3 peer-placeholder-shown:scale-100 peer-focus:-translate-y-2 peer-focus:scale-75 peer-focus:text-text-primary peer-[:not(:placeholder-shown)]:-translate-y-2 peer-[:not(:placeholder-shown)]:scale-75"
>
{localize('com_ui_memories_filter')}
</Label>
</div>
{/* Memory Usage and Toggle Display */}
{(memData?.tokenLimit || hasOptOutAccess) && (
<div
className={cn(
'flex items-center rounded-lg',
memData?.tokenLimit != null && hasOptOutAccess ? 'justify-between' : 'justify-end',
)}
>
{/* Usage Display */}
{memData?.tokenLimit && (
<div className="flex items-center gap-2">
<div className="relative size-10">
<svg className="size-10 -rotate-90 transform">
<circle
cx="20"
cy="20"
r="16"
stroke="currentColor"
strokeWidth="3"
fill="none"
className="text-gray-200 dark:text-gray-700"
/>
<circle
cx="20"
cy="20"
r="16"
strokeWidth="3"
fill="none"
strokeDasharray={`${2 * Math.PI * 16}`}
strokeDashoffset={`${2 * Math.PI * 16 * (1 - (memData.usagePercentage ?? 0) / 100)}`}
className={`transition-all ${getProgressBarColor(memData.usagePercentage ?? 0)}`}
strokeLinecap="round"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xs font-medium">{memData.usagePercentage}%</span>
</div>
</div>
<div className="text-sm text-text-secondary">{localize('com_ui_usage')}</div>
</div>
)}
{/* Memory Toggle */}
{hasOptOutAccess && (
<div className="flex items-center gap-2 text-xs">
<span>{localize('com_ui_use_memory')}</span>
<Switch
checked={referenceSavedMemories}
onCheckedChange={handleMemoryToggle}
aria-label={localize('com_ui_use_memory')}
disabled={updateMemoryPreferencesMutation.isLoading}
/>
</div>
)}
</div>
)}
{/* Create Memory Button */}
{hasCreateAccess && (
<div className="flex w-full justify-end">
<MemoryCreateDialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<OGDialogTrigger asChild>
<Button
variant="outline"
className="w-full bg-transparent"
aria-label={localize('com_ui_create_memory')}
>
<Plus className="size-4" aria-hidden="true" />
{localize('com_ui_create_memory')}
</Button>
</OGDialogTrigger>
</MemoryCreateDialog>
</div>
)}
<div className="rounded-lg border border-border-light bg-transparent shadow-sm transition-colors">
<Table className="w-full table-fixed">
<TableHeader>
<TableRow className="border-b border-border-light hover:bg-surface-secondary">
<TableHead
className={`${
hasUpdateAccess ? 'w-[75%]' : 'w-[100%]'
} bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary`}
>
<div>{localize('com_ui_memory')}</div>
</TableHead>
{hasUpdateAccess && (
<TableHead className="w-[25%] bg-surface-secondary py-3 text-center text-sm font-medium text-text-secondary">
<div>{localize('com_assistants_actions')}</div>
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{currentRows.length ? (
currentRows.map((memory: TUserMemory, idx: number) => (
<TableRow
key={idx}
className="border-b border-border-light hover:bg-surface-secondary"
>
<TableCell className={`${hasUpdateAccess ? 'w-[75%]' : 'w-[100%]'} px-4 py-4`}>
<div
className="overflow-hidden text-ellipsis whitespace-nowrap text-sm text-text-primary"
title={memory.value}
>
{memory.value}
</div>
</TableCell>
{hasUpdateAccess && (
<TableCell className="w-[25%] px-4 py-4">
<div className="flex justify-center gap-2">
<EditMemoryButton memory={memory} />
<DeleteMemoryButton memory={memory} />
</div>
</TableCell>
)}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={hasUpdateAccess ? 2 : 1}
className="h-24 text-center text-sm text-text-secondary"
>
{localize('com_ui_no_memories')}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{/* Pagination controls */}
{filteredMemories.length > pageSize && (
<div
className="flex items-center justify-end gap-2"
role="navigation"
aria-label="Pagination"
>
<Button
variant="outline"
size="sm"
onClick={() => setPageIndex((prev) => Math.max(prev - 1, 0))}
disabled={pageIndex === 0}
aria-label={localize('com_ui_prev')}
>
{localize('com_ui_prev')}
</Button>
<div className="text-sm" aria-live="polite">
{`${pageIndex + 1} / ${Math.ceil(filteredMemories.length / pageSize)}`}
</div>
<Button
variant="outline"
size="sm"
onClick={() =>
setPageIndex((prev) =>
(prev + 1) * pageSize < filteredMemories.length ? prev + 1 : prev,
)
}
disabled={(pageIndex + 1) * pageSize >= filteredMemories.length}
aria-label={localize('com_ui_next')}
>
{localize('com_ui_next')}
</Button>
</div>
)}
{/* Admin Settings */}
{user?.role === SystemRoles.ADMIN && (
<div className="mt-4">
<AdminSettings />
</div>
)}
</div>
</div>
);
}

View file

@ -1,2 +1,10 @@
export { default as MemoryViewer } from './MemoryViewer';
export { default as MemoryPanel } from './MemoryPanel';
export { default as MemoryViewer } from './MemoryPanel'; // Backward compatibility alias
export { default as MemoryList } from './MemoryList';
export { default as MemoryCard } from './MemoryCard';
export { default as MemoryCardActions } from './MemoryCardActions';
export { default as MemoryUsageBadge } from './MemoryUsageBadge';
export { default as MemoryEmptyState } from './MemoryEmptyState';
export { default as MemoryEditDialog } from './MemoryEditDialog';
export { default as MemoryCreateDialog } from './MemoryCreateDialog';
export { default as AdminSettings } from './AdminSettings';

View file

@ -0,0 +1,275 @@
import { useMemo, useEffect, useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { ShieldEllipsis } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form';
import { Permissions, SystemRoles, roleDefaults, PermissionTypes } from 'librechat-data-provider';
import {
OGDialog,
OGDialogTitle,
OGDialogContent,
OGDialogTrigger,
Button,
Switch,
DropdownPopup,
} from '@librechat/client';
import type { Control, UseFormSetValue, UseFormGetValues } from 'react-hook-form';
import type { TranslationKeys } from '~/hooks/useLocalize';
import { useLocalize, useAuthContext } from '~/hooks';
type FormValues = Record<Permissions, boolean>;
export interface PermissionConfig {
permission: Permissions;
labelKey: TranslationKeys;
}
export interface AdminSettingsDialogProps {
/** The permission type from PermissionTypes enum */
permissionType: PermissionTypes;
/** Localization key for the section name (e.g., 'com_ui_memories', 'com_ui_agents') */
sectionKey: TranslationKeys;
/** Array of permission configurations to display */
permissions: PermissionConfig[];
/** Unique ID for the role dropdown menu */
menuId: string;
/** Mutation function and loading state from the permission update hook */
mutation: {
mutate: (data: { roleName: SystemRoles; updates: Record<Permissions, boolean> }) => void;
isLoading: boolean;
};
/** Whether to show the admin access warning when ADMIN role and USE permission is displayed (default: true) */
showAdminWarning?: boolean;
/** Custom trigger element. If not provided, uses default button with icon and text */
trigger?: React.ReactNode;
/** Additional className for the dialog content */
dialogContentClassName?: string;
/** Custom callback when a permission change requires confirmation */
onPermissionConfirm?: (
permission: Permissions,
newValue: boolean,
onChange: (value: boolean) => void,
) => void;
/** Permissions that require confirmation before changing (only applies when onPermissionConfirm is provided) */
confirmPermissions?: Permissions[];
/** Custom content to render after the permissions form (e.g., confirmation dialogs) */
extraContent?: React.ReactNode;
}
type LabelControllerProps = {
label: string;
permission: Permissions;
control: Control<FormValues, unknown, FormValues>;
setValue: UseFormSetValue<FormValues>;
getValues: UseFormGetValues<FormValues>;
onConfirm?: (newValue: boolean, onChange: (value: boolean) => void) => void;
};
const LabelController: React.FC<LabelControllerProps> = ({
control,
permission,
label,
onConfirm,
}) => (
<div className="mb-4 flex items-center justify-between gap-2">
{label}
<Controller
name={permission}
control={control}
render={({ field }) => (
<Switch
{...field}
checked={field.value}
onCheckedChange={(val) => {
if (val === false && onConfirm) {
onConfirm(val, field.onChange);
} else {
field.onChange(val);
}
}}
value={field.value?.toString()}
aria-label={label}
/>
)}
/>
</div>
);
const AdminSettingsDialog: React.FC<AdminSettingsDialogProps> = ({
permissionType,
sectionKey,
permissions,
menuId,
mutation,
showAdminWarning = true,
trigger,
dialogContentClassName,
onPermissionConfirm,
confirmPermissions = [],
extraContent,
}) => {
const localize = useLocalize();
const { user, roles } = useAuthContext();
const { mutate, isLoading } = mutation;
const [isRoleMenuOpen, setIsRoleMenuOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState<SystemRoles>(SystemRoles.USER);
const defaultValues = useMemo(() => {
if (roles?.[selectedRole]?.permissions) {
return roles[selectedRole]?.permissions[permissionType];
}
return roleDefaults[selectedRole].permissions[permissionType];
}, [roles, selectedRole, permissionType]);
const {
reset,
control,
setValue,
getValues,
handleSubmit,
formState: { isSubmitting },
} = useForm<FormValues>({
mode: 'onChange',
defaultValues,
});
useEffect(() => {
if (roles?.[selectedRole]?.permissions?.[permissionType]) {
reset(roles[selectedRole]?.permissions[permissionType]);
} else {
reset(roleDefaults[selectedRole].permissions[permissionType]);
}
}, [roles, selectedRole, reset, permissionType]);
if (user?.role !== SystemRoles.ADMIN) {
return null;
}
const onSubmit = (data: FormValues) => {
mutate({ roleName: selectedRole, updates: data });
};
const roleDropdownItems = [
{
label: SystemRoles.USER,
onClick: () => {
setSelectedRole(SystemRoles.USER);
},
},
{
label: SystemRoles.ADMIN,
onClick: () => {
setSelectedRole(SystemRoles.ADMIN);
},
},
];
const defaultTrigger = (
<Button
size="sm"
variant="outline"
className="relative h-9 w-full gap-2 rounded-lg border-border-light font-medium focus:outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-primary"
aria-label={localize('com_ui_admin_settings')}
>
<ShieldEllipsis className="size-5 cursor-pointer" aria-hidden="true" />
{localize('com_ui_admin_settings')}
</Button>
);
return (
<>
<OGDialog>
<OGDialogTrigger asChild>{trigger ?? defaultTrigger}</OGDialogTrigger>
<OGDialogContent
className={
dialogContentClassName ??
'border-border-light bg-surface-primary text-text-primary lg:w-1/4'
}
>
<OGDialogTitle>
{localize('com_ui_admin_settings_section', { section: localize(sectionKey) })}
</OGDialogTitle>
<div className="p-2">
{/* Role selection dropdown */}
<div className="flex items-center gap-2">
<span className="font-medium">{localize('com_ui_role_select')}:</span>
<DropdownPopup
unmountOnHide={true}
menuId={menuId}
isOpen={isRoleMenuOpen}
setIsOpen={setIsRoleMenuOpen}
trigger={
<Ariakit.MenuButton className="inline-flex w-1/4 items-center justify-center rounded-lg border border-border-light bg-transparent px-2 py-1 text-text-primary transition-all ease-in-out hover:bg-surface-tertiary">
{selectedRole}
</Ariakit.MenuButton>
}
items={roleDropdownItems}
itemClassName="items-center justify-center"
sameWidth={true}
/>
</div>
{/* Permissions form */}
<form onSubmit={handleSubmit(onSubmit)}>
<div className="py-5">
{permissions.map(({ permission, labelKey }) => {
const label = localize(labelKey);
const needsConfirm =
selectedRole === SystemRoles.ADMIN &&
confirmPermissions.includes(permission) &&
onPermissionConfirm;
return (
<div key={permission}>
<LabelController
control={control}
permission={permission}
label={label}
getValues={getValues}
setValue={setValue}
onConfirm={
needsConfirm
? (newValue, onChange) =>
onPermissionConfirm(permission, newValue, onChange)
: undefined
}
/>
{showAdminWarning &&
selectedRole === SystemRoles.ADMIN &&
permission === Permissions.USE && (
<div className="mb-2 max-w-full whitespace-normal break-words text-sm text-red-600">
<span>{localize('com_ui_admin_access_warning')}</span>
{'\n'}
<a
href="https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/interface"
target="_blank"
rel="noreferrer"
className="text-blue-500 underline"
>
{localize('com_ui_more_info')}
</a>
</div>
)}
</div>
);
})}
</div>
<div className="flex justify-end">
<Button
type="submit"
variant="submit"
disabled={isSubmitting || isLoading}
aria-label={localize('com_ui_save')}
>
{localize('com_ui_save')}
</Button>
</div>
</form>
</div>
</OGDialogContent>
</OGDialog>
{extraContent}
</>
);
};
export default AdminSettingsDialog;

View file

@ -1 +1,3 @@
export { default as TermsAndConditionsModal } from './TermsAndConditionsModal';
export { default as AdminSettingsDialog } from './AdminSettingsDialog';
export type { PermissionConfig, AdminSettingsDialogProps } from './AdminSettingsDialog';

View file

@ -1 +1,2 @@
export * from './useLazyEffect';
export { default as useShiftKey } from './useShiftKey';

View file

@ -0,0 +1,47 @@
import { useState, useEffect } from 'react';
/**
* Hook to track whether the shift key is currently being held down.
* Ignores shift when Alt is also pressed to avoid conflicts with
* accessibility keyboard shortcuts (Shift+Alt is used for screen readers).
* @returns boolean indicating if shift key is pressed (without Alt)
*/
export default function useShiftKey(): boolean {
const [isShiftHeld, setIsShiftHeld] = useState(false);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Only set shift if Alt is not pressed (Alt+Shift is used for a11y)
if (e.key === 'Shift' && !e.altKey) {
setIsShiftHeld(true);
}
// If Alt is pressed while shift is held, reset shift state
if (e.key === 'Alt' && e.shiftKey) {
setIsShiftHeld(false);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
setIsShiftHeld(false);
}
};
// Reset shift state when window loses focus
const handleBlur = () => {
setIsShiftHeld(false);
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
window.addEventListener('blur', handleBlur);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
window.removeEventListener('blur', handleBlur);
};
}, []);
return isShiftHeld;
}

View file

@ -14,10 +14,10 @@ import MCPBuilderPanel from '~/components/SidePanel/MCPBuilder/MCPBuilderPanel';
import type { NavLink } from '~/common';
import AgentPanelSwitch from '~/components/SidePanel/Agents/AgentPanelSwitch';
import BookmarkPanel from '~/components/SidePanel/Bookmarks/BookmarkPanel';
import MemoryViewer from '~/components/SidePanel/Memories/MemoryViewer';
import PanelSwitch from '~/components/SidePanel/Builder/PanelSwitch';
import PromptsAccordion from '~/components/Prompts/PromptsAccordion';
import Parameters from '~/components/SidePanel/Parameters/Panel';
import { MemoryPanel } from '~/components/SidePanel/Memories';
import FilesPanel from '~/components/SidePanel/Files/Panel';
import { useHasAccess, useMCPServerManager } from '~/hooks';
@ -122,7 +122,7 @@ export default function useSideNavLinks({
label: '',
icon: Database,
id: 'memories',
Component: MemoryViewer,
Component: MemoryPanel,
});
}

View file

@ -378,7 +378,6 @@
"com_files_downloading": "Downloading Files",
"com_files_filter": "Filter files...",
"com_files_filter_by": "Filter files by...",
"com_files_filter_input": "Filter listed files by name...",
"com_files_no_results": "No results.",
"com_files_number_selected": "{{0}} of {{1}} items(s) selected",
"com_files_preparing_download": "Preparing download...",
@ -752,7 +751,6 @@
"com_ui_bookmarks": "Bookmarks",
"com_ui_bookmarks_add": "Add Bookmarks",
"com_ui_bookmarks_add_to_conversation": "Add to current conversation",
"com_ui_bookmarks_count": "Count",
"com_ui_bookmarks_create_error": "There was an error creating the bookmark",
"com_ui_bookmarks_create_exists": "This bookmark already exists",
"com_ui_bookmarks_create_success": "Bookmark created successfully",
@ -786,6 +784,7 @@
"com_ui_clear_presets": "Clear Presets",
"com_ui_clear_search": "Clear search",
"com_ui_click_to_close": "Click to close",
"com_ui_click_to_view_var": "Click to view {{0}}",
"com_ui_client_id": "Client ID",
"com_ui_client_secret": "Client Secret",
"com_ui_close": "Close",
@ -813,6 +812,8 @@
"com_ui_continue_oauth": "Continue with OAuth",
"com_ui_control_bar": "Control bar",
"com_ui_controls": "Controls",
"com_ui_conversation": "conversation",
"com_ui_conversations": "conversations",
"com_ui_conversation_label": "{{title}} conversation",
"com_ui_convo_archived": "Conversation archived",
"com_ui_convo_delete_error": "Failed to delete conversation",
@ -923,6 +924,8 @@
"com_ui_enter_key": "Enter key",
"com_ui_enter_openapi_schema": "Enter your OpenAPI schema here",
"com_ui_enter_value": "Enter value",
"com_ui_enter_name": "Enter name",
"com_ui_enter_description": "Enter description (optional)",
"com_ui_error": "Error",
"com_ui_error_connection": "Error connecting to server, try refreshing the page.",
"com_ui_error_message_prefix": "Error Message:",
@ -958,9 +961,9 @@
"com_ui_file_token_limit": "File Token Limit",
"com_ui_file_token_limit_desc": "Set maximum token limit for file processing to control costs and resource usage",
"com_ui_files": "Files",
"com_ui_filter_mcp_servers": "Filter MCP servers by name",
"com_ui_filter_mcp_servers": "Filter MCP servers...",
"com_ui_filter_prompts": "Filter Prompts",
"com_ui_filter_prompts_name": "Filter prompts by name",
"com_ui_filter_prompts_name": "Filter prompts...",
"com_ui_final_touch": "Final touch",
"com_ui_finance": "Finance",
"com_ui_fork": "Fork",
@ -1100,6 +1103,7 @@
"com_ui_memory_error": "Memory Error",
"com_ui_memory_key_exists": "A memory with this key already exists. Please use a different key.",
"com_ui_memory_key_validation": "Memory key must only contain lowercase letters and underscores.",
"com_ui_memory_key_hint": "Use lowercase letters and underscores only",
"com_ui_memory_storage_full": "Memory Storage Full",
"com_ui_memory_updated": "Updated saved memory",
"com_ui_memory_updated_items": "Updated Memories",
@ -1124,7 +1128,8 @@
"com_ui_new_conversation_title": "New Conversation Title",
"com_ui_next": "Next",
"com_ui_no": "No",
"com_ui_no_bookmarks": "it seems like you have no bookmarks yet. Click on a chat and add a new one",
"com_ui_no_bookmarks": "No bookmarks yet",
"com_ui_no_bookmarks_match": "No bookmarks match your search",
"com_ui_no_categories": "No categories available",
"com_ui_no_category": "No category",
"com_ui_no_changes": "No changes were made",
@ -1132,6 +1137,8 @@
"com_ui_no_mcp_servers": "No MCP servers yet",
"com_ui_no_mcp_servers_match": "No MCP servers match your filter",
"com_ui_no_memories": "No memories. Create them manually or prompt the AI to remember something",
"com_ui_no_memories_title": "No memories yet",
"com_ui_no_memories_match": "No memories match your search",
"com_ui_no_personalization_available": "No personalization options are currently available",
"com_ui_no_read_access": "You don't have permission to view memories",
"com_ui_no_results_found": "No results found",

View file

@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { useMediaQuery } from '@librechat/client';
import type { ContextType } from '~/common';
import {
useSearchEnabled,
@ -16,8 +17,8 @@ import {
FileMapContext,
} from '~/Providers';
import { useUserTermsQuery, useGetStartupConfig } from '~/data-provider';
import { Nav, MobileNav, NAV_WIDTH } from '~/components/Nav';
import { TermsAndConditionsModal } from '~/components/ui';
import { Nav, MobileNav } from '~/components/Nav';
import { useHealthCheck } from '~/data-provider';
import { Banner } from '~/components/Banners';
@ -30,6 +31,7 @@ export default function Root() {
});
const { isAuthenticated, logout } = useAuthContext();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
// Global health check - runs once per authenticated session
useHealthCheck(isAuthenticated);
@ -74,7 +76,19 @@ export default function Root() {
<div className="flex" style={{ height: `calc(100dvh - ${bannerHeight}px)` }}>
<div className="relative z-0 flex h-full w-full overflow-hidden">
<Nav navVisible={navVisible} setNavVisible={setNavVisible} />
<div className="relative flex h-full max-w-full flex-1 flex-col overflow-hidden">
<div
className="relative flex h-full max-w-full flex-1 flex-col overflow-hidden"
style={
isSmallScreen
? {
transform: navVisible
? `translateX(${NAV_WIDTH.MOBILE}px)`
: 'translateX(0)',
transition: 'transform 0.2s ease-out',
}
: undefined
}
>
<MobileNav navVisible={navVisible} setNavVisible={setNavVisible} />
<Outlet context={{ navVisible, setNavVisible } satisfies ContextType} />
</div>

2
package-lock.json generated
View file

@ -50237,7 +50237,7 @@
},
"packages/client": {
"name": "@librechat/client",
"version": "0.4.2",
"version": "0.4.3",
"devDependencies": {
"@babel/core": "^7.28.5",
"@babel/preset-env": "^7.28.5",

View file

@ -1,6 +1,6 @@
{
"name": "@librechat/client",
"version": "0.4.2",
"version": "0.4.3",
"description": "React components for LibreChat",
"repository": {
"type": "git",

View file

@ -103,7 +103,7 @@ const Menu: React.FC<MenuProps> = ({
>
<Ariakit.MenuButton
className={cn(
'group flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
'group flex w-full cursor-pointer items-center justify-between gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
itemClassName,
)}
disabled={item.disabled}
@ -138,7 +138,7 @@ const Menu: React.FC<MenuProps> = ({
key={`${keyPrefix ?? ''}${index}-${item.id ?? ''}`}
id={item.id}
className={cn(
'group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none transition-colors duration-200 hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
'group flex w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-3.5 text-sm text-text-primary outline-none hover:bg-surface-hover focus:bg-surface-hover md:px-2.5 md:py-2',
itemClassName,
item.className,
)}

View file

@ -0,0 +1,54 @@
import * as React from 'react';
import { cn } from '~/utils';
export interface FilterInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'placeholder'> {
/** The label text shown in the floating label */
label: string;
/** Unique identifier for the input - used to link label */
inputId: string;
/** Container className for custom styling */
containerClassName?: string;
}
/**
* A standardized filter/search input component with a floating label
* that animates up when focused or has a value.
*
* @example
* <FilterInput
* inputId="bookmarks-filter"
* label={localize('com_ui_bookmarks_filter')}
* value={searchQuery}
* onChange={(e) => setSearchQuery(e.target.value)}
* />
*/
const FilterInput = React.forwardRef<HTMLInputElement, FilterInputProps>(
({ className, label, inputId, containerClassName, ...props }, ref) => {
return (
<div className={cn('relative', containerClassName)}>
<input
id={inputId}
ref={ref}
placeholder=" "
aria-label={label}
className={cn(
'peer flex h-10 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
/>
<label
htmlFor={inputId}
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm text-text-secondary transition-all duration-200 peer-focus:top-0 peer-focus:bg-background peer-focus:px-1 peer-focus:text-xs peer-[:not(:placeholder-shown)]:top-0 peer-[:not(:placeholder-shown)]:bg-background peer-[:not(:placeholder-shown)]:px-1 peer-[:not(:placeholder-shown)]:text-xs"
>
{label}
</label>
</div>
);
},
);
FilterInput.displayName = 'FilterInput';
export { FilterInput };

View file

@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(({ className, ...pr
return (
<input
className={cn(
'flex h-10 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
'flex h-10 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className ?? '',
)}
ref={ref}

View file

@ -3,13 +3,19 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import { cn } from '~/utils';
const DialogDepthContext = React.createContext(0);
interface OGDialogProps extends DialogPrimitive.DialogProps {
triggerRef?: React.RefObject<HTMLButtonElement | HTMLInputElement | HTMLDivElement | null>;
triggerRefs?: React.RefObject<HTMLButtonElement | HTMLInputElement | HTMLDivElement | null>[];
}
const Dialog = React.forwardRef<HTMLDivElement, OGDialogProps>(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
({ children, triggerRef, triggerRefs, onOpenChange, ...props }, ref) => {
const parentDepth = React.useContext(DialogDepthContext);
const currentDepth = parentDepth + 1;
const handleOpenChange = (open: boolean) => {
if (!open && triggerRef?.current) {
setTimeout(() => {
@ -29,9 +35,11 @@ const Dialog = React.forwardRef<HTMLDivElement, OGDialogProps>(
};
return (
<DialogPrimitive.Root {...props} onOpenChange={handleOpenChange}>
{children}
</DialogPrimitive.Root>
<DialogDepthContext.Provider value={currentDepth}>
<DialogPrimitive.Root {...props} onOpenChange={handleOpenChange}>
{children}
</DialogPrimitive.Root>
</DialogDepthContext.Provider>
);
},
);
@ -45,16 +53,22 @@ const DialogClose = DialogPrimitive.Close;
export const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
));
>(({ className, style, ...props }, ref) => {
const depth = React.useContext(DialogDepthContext);
const overlayZIndex = 50 + (depth - 1) * 60;
return (
<DialogPrimitive.Overlay
ref={ref}
style={{ ...style, zIndex: overlayZIndex }}
className={cn(
'fixed inset-0 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
);
});
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
type DialogContentProps = React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
@ -73,13 +87,16 @@ const DialogContent = React.forwardRef<
overlayClassName,
showCloseButton = true,
children,
style,
onEscapeKeyDown: propsOnEscapeKeyDown,
...props
},
ref,
) => {
const depth = React.useContext(DialogDepthContext);
const contentZIndex = 100 + (depth - 1) * 60;
/* Handle Escape key to prevent closing dialog if a tooltip or dropdown is open
/* Handle Escape key to prevent closing dialog if a tooltip or dropdown is open
(this is a workaround in order to achieve WCAG compliance which requires
that our tooltips be dismissable with Escape key) */
const handleEscapeKeyDown = React.useCallback(
@ -88,11 +105,11 @@ const DialogContent = React.forwardRef<
const dropdownMenus = document.querySelectorAll('[role="menu"]');
for (const tooltip of tooltips) {
const style = window.getComputedStyle(tooltip);
const computedStyle = window.getComputedStyle(tooltip);
if (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
parseFloat(style.opacity) > 0
computedStyle.display !== 'none' &&
computedStyle.visibility !== 'hidden' &&
parseFloat(computedStyle.opacity) > 0
) {
event.preventDefault();
return;
@ -100,11 +117,11 @@ const DialogContent = React.forwardRef<
}
for (const dropdownMenu of dropdownMenus) {
const style = window.getComputedStyle(dropdownMenu);
const computedStyle = window.getComputedStyle(dropdownMenu);
if (
style.display !== 'none' &&
style.visibility !== 'hidden' &&
parseFloat(style.opacity) > 0
computedStyle.display !== 'none' &&
computedStyle.visibility !== 'hidden' &&
parseFloat(computedStyle.opacity) > 0
) {
event.preventDefault();
return;
@ -121,9 +138,10 @@ const DialogContent = React.forwardRef<
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={ref}
style={{ ...style, zIndex: contentZIndex }}
onEscapeKeyDown={handleEscapeKeyDown}
className={cn(
'max-w-11/12 fixed left-[50%] top-[50%] z-[100] grid max-h-[90vh] w-full translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-2xl bg-background p-6 text-text-primary shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
'max-w-11/12 fixed left-[50%] top-[50%] grid max-h-[90vh] w-full translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-2xl bg-background p-6 text-text-primary shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
className,
)}
{...props}

View file

@ -9,6 +9,7 @@ export * from './DropdownMenu';
export * from './HoverCard';
export * from './Input';
export * from './InputNumber';
export * from './FilterInput';
export * from './Label';
export * from './OriginalDialog';
export * from './QuestionMark';