🪄 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

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