🎨 refactor: Redesign Sidebar with Unified Icon Strip Layout (#12013)

* fix: Graceful SidePanelContext handling when ChatContext unavailable

The UnifiedSidebar component is rendered at the Root level before ChatContext
is provided (which happens only in ChatRoute). This caused an error when
useSidePanelContext tried to call useChatContext before it was available.

Changes:
- Made SidePanelProvider gracefully handle missing ChatContext with try/catch
- Changed useSidePanelContext to return a safe default instead of throwing
- Prevents render error on application load and improves robustness

* fix: Provide default context value for ChatContext to prevent setFilesLoading errors

The ChatContext was initialized with an empty object as default, causing 'setFilesLoading is not a function' errors when components tried to call functions from the context. This fix provides a proper default context with no-op functions for all expected properties.

Fixes FileRow component errors that occurred when navigating to sections with file upload functionality (Agent Builder, Attach Files, etc.).

* fix: Move ChatFormProvider to Root to fix Prompts sidebar rendering

The ChatFormProvider was only wrapping ChatView, but the sidebar (including Prompts) renders separately and needs access to the ChatFormContext. ChatGroupItem uses useSubmitMessage which calls useChatFormContext, causing a React error when Prompts were accessed.

This fix moves the ChatFormProvider to the Root component to wrap both the sidebar and the main chat view, ensuring the form context is available throughout the entire application.

* fix: Active section switching and dead code cleanup

Sync ActivePanelProvider state when defaultActive prop changes so
clicking a collapsed-bar icon actually switches the expanded section.
Remove the now-unused hideSidePanel atom and its Settings toggle.

* style: Redesign sidebar layout with optimized spacing and positioning

- Remove duplicate new chat button from sidebar, keep it in main header
- Reposition account settings to bottom of expanded sidebar
- Simplify collapsed bar padding and alignment
- Clean up unused framer-motion imports from Header
- Optimize vertical space usage in expanded panel
- Align search bar icon color with sidebar theme

* fix: Chat history not showing in sidebar

Add h-full to ConversationsSection outer div so it fills the Nav
content panel, giving react-virtualized's AutoSizer a measurable
height. Change Nav content panel from overflow-y-auto to
overflow-hidden since the virtualized list handles its own scrolling.

* refactor: Move nav icons to fixed icon strip alongside sidebar toggle

Extract section icons from the Nav content panel into the
ExpandedPanel icon strip, matching the CollapsedBar layout. Both
states now share identical button styling and 50px width, eliminating
layout shift on toggle. Nav.tsx simplified to content-only rendering.
Set text-text-primary on Nav content for consistent child text color.

* refactor: sidebar components and remove unused NewChat component

* refactor: streamline sidebar components and introduce NewChat button

* refactor: enhance sidebar functionality with expanded state management and improved layout

* fix: re-implement sidebar resizing functionality with mouse events

* feat: enhance sidebar layout responsiveness on mobile

* refactor: remove unused components and streamline sidebar functionality

* feat: enhance sidebar behavior with responsive transformations for small screens

* feat: add new chat button for small screens with message cache clearing

* feat: improve state management in sidebar and marketplace components

* feat: enhance scrolling behavior in AgentPanel and Nav components

* fix: normalize sidebar panel font sizes and default panel selection

Set text-sm as base font size on the shared Nav container so all
panels render consistently. Guard against empty localStorage value
when restoring the active sidebar panel.

* fix: adjust avatar size and class for collapsed state in AccountSettings component

* style: adjust padding and class names in Nav, Parameters, and ConversationsSection components

* fix: close mobile sidebar on pinned favorite selection

* refactor: remove unused key in translation file

* fix: Address review findings for unified sidebar

- Restore ChatFormProvider per-ChatView to fix multi-conversation input isolation;
  add separate ChatFormProvider in UnifiedSidebar for Prompts panel access
- Add inert attribute on mobile sidebar (when collapsed) and main content
  (when sidebar overlay is open) to prevent keyboard focus leaking
- Replace unsafe `as unknown as TChatContext` cast with null-based context
  that throws descriptively when used outside a provider
- Throttle mousemove resize handler with requestAnimationFrame to prevent
  React state updates at 120Hz during sidebar drag
- Unify active panel state: remove split between activeSection in
  UnifiedSidebar and internal state in ActivePanelContext; single source
  of truth with localStorage sync on every write
- Delete orphaned SidePanelProvider/useSidePanelContext (no consumers
  after SidePanel.tsx removal)
- Add data-testid="new-chat-button" to NewChat component
- Add includeHidePanel option to useSideNavLinks; remove no-op hidePanel
  callback and post-hoc filter in useUnifiedSidebarLinks
- Close sidebar on first mobile visit when localStorage has no prior state
- Remove unnecessary min-width/max-width CSS transitions (only width needed)
- Remove dead SideNav re-export from SidePanel/index.ts
- Remove duplicate aria-label from Sidebar nav element
- Fix trailing blank line in mobile.css

* style: fix prettier formatting in Root.tsx

* fix: Address remaining review findings and re-render isolation

- Extract useChatHelpers(0) into SidebarChatProvider child component so
  Recoil atom subscriptions (streaming tokens, latestMessage, etc.) only
  re-render the active panel — not the sidebar shell, resize logic, or
  icon strip (Finding 4)
- Fix prompt pre-fill when sidebar form context differs from chat form:
  useSubmitMessage now reads the actual textarea DOM value via
  mainTextareaId as fallback for the currentText newline check (Finding 1)
- Add id="close-sidebar-button" and data-testid to ExpandedPanel toggle
  so OpenSidebar focus management works after expand (Finding 10/N3)
- Replace Dispatch<SetStateAction<number>> prop with
  onResizeKeyboard(direction) callback on Sidebar (Finding 13)
- Fix first-mobile-visit sidebar flash: atom default now checks
  window.matchMedia at init time instead of defaulting to true then
  correcting in a useEffect; removes eslint-disable suppression (N1/N2)
- Add tests for ActivePanelContext and ChatContext (Finding 8)

* refactor: remove no-op memo from SidebarChatProvider

memo(SidebarChatProvider) provided no memoization because its only prop
(children) is inline JSX — a new reference on every parent render.
The streaming isolation works through Recoil subscription scoping, not
memo. Clarified in the JSDoc comment.

* fix: add shebang to pre-commit hook for Windows compatibility

Git on Windows cannot spawn hook scripts without a shebang line,
causing 'cannot spawn .husky/pre-commit: No such file or directory'.

* style: fix sidebar panel styling inconsistencies

- Remove inner overflow-y-auto from AgentPanel form to eliminate double
  scrollbars when nested inside Nav.tsx's scroll container
- Add consistent padding (px-3 py-2) to Nav.tsx panel container
- Remove hardcoded 150px cell widths from Files PanelTable; widen date
  column from 25% to 35% so dates are no longer cut off
- Compact pagination row with flex-wrap and smaller text
- Add px-1 padding to Parameters panel for consistency
- Change overflow-x-visible to overflow-x-hidden on Files and Bookmarks
  panels to prevent horizontal overflow

* fix: Restore panel styling regressions in unified sidebar

- Nav.tsx wrapper: remove extra px-3 padding, add hide-scrollbar class,
  restore py-1 to match old ResizablePanel wrapper
- AgentPanel: restore scrollbar-gutter-stable and mx-1 margin (was px-1)
- Parameters/Panel: restore p-3 padding (was px-1 pt-1)
- Files/Panel: restore overflow-x-visible (was hidden, clipping content)
- Files/PanelTable: restore 75/25 column widths, restore 150px cell
  width constraints, restore pagination text-sm and gap-2
- Bookmarks/Panel: restore overflow-x-visible

* style: initial improvements post-sidenav change

* style: update text size in DynamicTextarea for improved readability

* style: update FilterPrompts alignment in PromptsAccordion for better layout consistency

* style: adjust component heights and padding for consistency across SidePanel elements

- Updated height from 40px to 36px in AgentSelect for uniformity
- Changed button size class from "bg-transparent" to "size-9" in BookmarkTable, MCPBuilderPanel, and MemoryPanel
- Added padding class "py-1.5" in DynamicDropdown for improved spacing
- Reduced height from 10 to 9 in DynamicInput and DynamicTags for a cohesive look
- Adjusted padding in Parameters/Panel for better layout

* style: standardize button sizes and icon dimensions across chat components

- Updated button size class from 'size-10' to 'size-9' in multiple components for consistency.
- Adjusted icon sizes from 'icon-lg' to 'icon-md' in various locations to maintain uniformity.
- Modified header height for better alignment with design specifications.

* style: enhance layout consistency and component structure across various panels

- Updated ActivePanelContext to utilize useCallback and useMemo for improved performance.
- Adjusted padding and layout in multiple SidePanel components for better visual alignment.
- Standardized icon sizes and button dimensions in AddMultiConvo and other components.
- Improved overall spacing and structure in PromptsAccordion, MemoryPanel, and FilesPanel for a cohesive design.

* style: standardize component heights and text sizes across various panels

- Adjusted button heights from 10px to 9px in multiple components for consistency.
- Updated text sizes from 'text-sm' to 'text-xs' in DynamicCheckbox, DynamicCombobox, DynamicDropdown, DynamicInput, DynamicSlider, DynamicSwitch, DynamicTags, and DynamicTextarea for improved readability.
- Added portal prop to account settings menu for better rendering behavior.

* refactor: optimize Tooltip component structure for performance

- Introduced a new memoized TooltipPopup component to prevent unnecessary re-renders of the TooltipAnchor when the tooltip mounts/unmounts.
- Updated TooltipAnchor to utilize the new TooltipPopup, improving separation of concerns and enhancing performance.
- Maintained existing functionality while improving code clarity and maintainability.

* refactor: improve sidebar transition handling for better responsiveness

- Enhanced the transition properties in the UnifiedSidebar component to include min-width and max-width adjustments, ensuring smoother resizing behavior.
- Cleaned up import statements by removing unnecessary lines for better code clarity.

* fix: prevent text selection during sidebar resizing

- Added functionality to disable text selection on the body while the sidebar is being resized, enhancing user experience during interactions.
- Restored text selection capability once resizing is complete, ensuring normal behavior resumes.

* fix: ensure Header component is always rendered in ChatView

- Removed conditional rendering of the Header component to ensure it is always displayed, improving the consistency of the chat interface.

* refactor: add NewChatButton to ExpandedPanel for improved user interaction

- Introduced a NewChatButton component in the ExpandedPanel, allowing users to initiate new conversations easily.
- Implemented functionality to clear message cache and invalidate queries upon button click, enhancing performance and user experience.
- Restored import statements for OpenSidebar and PresetsMenu in Header component for better organization.

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2026-03-22 06:15:20 +01:00 committed by GitHub
parent 04e65bb21a
commit 733a9364c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 1310 additions and 1593 deletions

View file

@ -1,2 +1,3 @@
#!/bin/sh
[ -n "$CI" ] && exit 0
npx lint-staged --config ./.husky/lint-staged.config.js

View file

@ -1,31 +1,31 @@
import { createContext, useContext, useState, ReactNode } from 'react';
import { createContext, useCallback, useContext, useMemo, useState, ReactNode } from 'react';
const STORAGE_KEY = 'side:active-panel';
const DEFAULT_PANEL = 'conversations';
function getInitialActivePanel(): string {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? saved : DEFAULT_PANEL;
}
interface ActivePanelContextType {
active: string | undefined;
active: string;
setActive: (id: string) => void;
}
const ActivePanelContext = createContext<ActivePanelContextType | undefined>(undefined);
export function ActivePanelProvider({
children,
defaultActive,
}: {
children: ReactNode;
defaultActive?: string;
}) {
const [active, _setActive] = useState<string | undefined>(defaultActive);
export function ActivePanelProvider({ children }: { children: ReactNode }) {
const [active, _setActive] = useState<string>(getInitialActivePanel);
const setActive = (id: string) => {
localStorage.setItem('side:active-panel', id);
const setActive = useCallback((id: string) => {
localStorage.setItem(STORAGE_KEY, id);
_setActive(id);
};
}, []);
return (
<ActivePanelContext.Provider value={{ active, setActive }}>
{children}
</ActivePanelContext.Provider>
);
const value = useMemo(() => ({ active, setActive }), [active, setActive]);
return <ActivePanelContext.Provider value={value}>{children}</ActivePanelContext.Provider>;
}
export function useActivePanel() {

View file

@ -2,5 +2,11 @@ import { createContext, useContext } from 'react';
import useChatHelpers from '~/hooks/Chat/useChatHelpers';
type TChatContext = ReturnType<typeof useChatHelpers>;
export const ChatContext = createContext<TChatContext>({} as TChatContext);
export const useChatContext = () => useContext(ChatContext);
export const ChatContext = createContext<TChatContext | null>(null);
export const useChatContext = () => {
const ctx = useContext(ChatContext);
if (!ctx) {
throw new Error('useChatContext must be used within a ChatContext.Provider');
}
return ctx;
};

View file

@ -1,31 +0,0 @@
import React, { createContext, useContext, useMemo } from 'react';
import type { EModelEndpoint } from 'librechat-data-provider';
import { useChatContext } from './ChatContext';
interface SidePanelContextValue {
endpoint?: EModelEndpoint | null;
}
const SidePanelContext = createContext<SidePanelContextValue | undefined>(undefined);
export function SidePanelProvider({ children }: { children: React.ReactNode }) {
const { conversation } = useChatContext();
/** Context value only created when endpoint changes */
const contextValue = useMemo<SidePanelContextValue>(
() => ({
endpoint: conversation?.endpoint,
}),
[conversation?.endpoint],
);
return <SidePanelContext.Provider value={contextValue}>{children}</SidePanelContext.Provider>;
}
export function useSidePanelContext() {
const context = useContext(SidePanelContext);
if (!context) {
throw new Error('useSidePanelContext must be used within SidePanelProvider');
}
return context;
}

View file

@ -0,0 +1,60 @@
import React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { ActivePanelProvider, useActivePanel } from '~/Providers/ActivePanelContext';
const STORAGE_KEY = 'side:active-panel';
function TestConsumer() {
const { active, setActive } = useActivePanel();
return (
<div>
<span data-testid="active">{active}</span>
<button data-testid="switch-btn" onClick={() => setActive('bookmarks')} />
</div>
);
}
describe('ActivePanelContext', () => {
beforeEach(() => {
localStorage.clear();
});
it('defaults to conversations when no localStorage value exists', () => {
render(
<ActivePanelProvider>
<TestConsumer />
</ActivePanelProvider>,
);
expect(screen.getByTestId('active')).toHaveTextContent('conversations');
});
it('reads initial value from localStorage', () => {
localStorage.setItem(STORAGE_KEY, 'memories');
render(
<ActivePanelProvider>
<TestConsumer />
</ActivePanelProvider>,
);
expect(screen.getByTestId('active')).toHaveTextContent('memories');
});
it('setActive updates state and writes to localStorage', () => {
render(
<ActivePanelProvider>
<TestConsumer />
</ActivePanelProvider>,
);
fireEvent.click(screen.getByTestId('switch-btn'));
expect(screen.getByTestId('active')).toHaveTextContent('bookmarks');
expect(localStorage.getItem(STORAGE_KEY)).toBe('bookmarks');
});
it('throws when useActivePanel is called outside provider', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<TestConsumer />)).toThrow(
'useActivePanel must be used within an ActivePanelProvider',
);
spy.mockRestore();
});
});

View file

@ -0,0 +1,31 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { ChatContext, useChatContext } from '~/Providers/ChatContext';
function TestConsumer() {
const ctx = useChatContext();
return <span data-testid="index">{ctx.index}</span>;
}
describe('ChatContext', () => {
it('throws when useChatContext is called outside a provider', () => {
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});
expect(() => render(<TestConsumer />)).toThrow(
'useChatContext must be used within a ChatContext.Provider',
);
spy.mockRestore();
});
it('provides context value when wrapped in provider', () => {
const mockHelpers = { index: 0 } as ReturnType<
typeof import('~/hooks/Chat/useChatHelpers').default
>;
render(
<ChatContext.Provider value={mockHelpers}>
<TestConsumer />
</ChatContext.Provider>,
);
expect(screen.getByTestId('index')).toHaveTextContent('0');
});
});

View file

@ -22,7 +22,6 @@ export * from './ToolCallsMapContext';
export * from './SetConvoContext';
export * from './SearchContext';
export * from './BadgeRowContext';
export * from './SidePanelContext';
export * from './DragDropContext';
export * from './ArtifactsContext';
export * from './PromptGroupsContext';

View file

@ -132,13 +132,6 @@ export type NavLink = {
id: string;
};
export interface NavProps {
isCollapsed: boolean;
links: NavLink[];
resize?: (size: number) => void;
defaultActive?: string;
}
export interface DataColumnMeta {
meta:
| {
@ -561,11 +554,6 @@ export interface ModelItemProps {
className?: string;
}
export type ContextType = {
navVisible: boolean;
setNavVisible: React.Dispatch<React.SetStateAction<boolean>>;
};
export interface SwitcherProps {
endpoint?: t.EModelEndpoint | null;
endpointKeyProvided: boolean;

View file

@ -1,23 +1,17 @@
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { useRecoilState } from 'recoil';
import { useOutletContext } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
import { TooltipAnchor, Button, NewChatIcon, useMediaQuery } from '@librechat/client';
import { PermissionTypes, Permissions, QueryKeys } from 'librechat-data-provider';
import { useMediaQuery } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type t from 'librechat-data-provider';
import type { ContextType } from '~/common';
import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/hooks';
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
import MarketplaceAdminSettings from './MarketplaceAdminSettings';
import { SidePanelProvider, useChatContext } from '~/Providers';
import { SidePanelGroup } from '~/components/SidePanel';
import { OpenSidebar } from '~/components/Chat/Menus';
import { cn, clearMessagesCache } from '~/utils';
import { NewChat } from '~/components/Nav';
import { cn } from '~/utils';
import CategoryTabs from './CategoryTabs';
import SearchBar from './SearchBar';
import AgentGrid from './AgentGrid';
import store from '~/store';
interface AgentMarketplaceProps {
className?: string;
@ -34,13 +28,9 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
const localize = useLocalize();
const navigate = useNavigate();
const { category } = useParams();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const { conversation, newConversation } = useChatContext();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
const [hideSidePanel, setHideSidePanel] = useRecoilState(store.hideSidePanel);
// Get URL parameters
const searchQuery = searchParams.get('q') || '';
@ -59,15 +49,6 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
// Set page title
useDocumentTitle(`${localize('com_agents_marketplace')} | LibreChat`);
// Ensure right sidebar is always visible in marketplace
useEffect(() => {
setHideSidePanel(false);
// Also try to force expand via localStorage
localStorage.setItem('hideSidePanel', 'false');
localStorage.setItem('fullPanelCollapse', 'false');
}, [setHideSidePanel, hideSidePanel]);
// Ensure endpoints config is loaded first (required for agent queries)
useGetEndpointsQuery();
@ -193,33 +174,6 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
}
};
/**
* Handle new chat button click
*/
const handleNewChat = (e: React.MouseEvent<HTMLButtonElement>) => {
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
window.open('/c/new', '_blank');
return;
}
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
};
// Layout configuration for SidePanelGroup
const defaultLayout = useMemo(() => {
const resizableLayout = localStorage.getItem('react-resizable-panels:layout');
return typeof resizableLayout === 'string' ? JSON.parse(resizableLayout) : undefined;
}, []);
const defaultCollapsed = useMemo(() => {
const collapsedPanels = localStorage.getItem('react-resizable-panels:collapsed');
return typeof collapsedPanels === 'string' ? JSON.parse(collapsedPanels) : true;
}, []);
const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []);
const hasAccessToMarketplace = useHasAccess({
permissionType: PermissionTypes.MARKETPLACE,
permission: Permissions.USE,
@ -241,99 +195,144 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
}
return (
<div className={`relative flex w-full grow overflow-hidden bg-presentation ${className}`}>
<SidePanelProvider>
<SidePanelGroup
defaultLayout={defaultLayout}
fullPanelCollapse={fullCollapse}
defaultCollapsed={defaultCollapsed}
>
<main className="flex h-full flex-col overflow-hidden" role="main">
{/* Scrollable container */}
<div
ref={scrollContainerRef}
className="scrollbar-gutter-stable relative flex h-full flex-col overflow-y-auto overflow-x-hidden"
>
{/* Simplified header for agents marketplace - only show nav controls when needed */}
{!isSmallScreen && (
<div className="sticky top-0 z-20 flex items-center justify-between bg-surface-secondary p-2 font-semibold text-text-primary md:h-14">
<div className="mx-1 flex items-center gap-2">
{!navVisible ? (
<>
<OpenSidebar setNavVisible={setNavVisible} />
<TooltipAnchor
description={localize('com_ui_new_chat')}
render={
<Button
size="icon"
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-active-alt max-md:hidden"
onClick={handleNewChat}
>
<NewChatIcon />
</Button>
}
/>
</>
) : (
// Invisible placeholder to maintain height
<div className="h-10 w-10" />
)}
</div>
</div>
)}
{/* Hero Section - scrolls away */}
{!isSmallScreen && (
<div className="container mx-auto max-w-4xl">
<div className={cn('mb-8 text-center', 'mt-12')}>
<h1 className="mb-3 text-3xl font-bold tracking-tight text-text-primary md:text-5xl">
{localize('com_agents_marketplace')}
</h1>
<p className="mx-auto mb-6 max-w-2xl text-lg text-text-secondary">
{localize('com_agents_marketplace_subtitle')}
</p>
</div>
</div>
)}
{/* Sticky wrapper for search bar and categories */}
<div
className={cn(
'sticky z-10 bg-presentation pb-4',
isSmallScreen ? 'top-0' : 'top-14',
)}
>
<div className="container mx-auto max-w-4xl px-4">
{/* Search bar */}
<div className="mx-auto flex max-w-2xl gap-2 pb-6">
<SearchBar value={searchQuery} onSearch={handleSearch} />
{/* TODO: Remove this once we have a better way to handle admin settings */}
{/* Admin Settings */}
<MarketplaceAdminSettings />
</div>
{/* Category tabs */}
<CategoryTabs
categories={categoriesQuery.data || []}
activeTab={displayCategory}
isLoading={categoriesQuery.isLoading}
onChange={handleTabChange}
/>
<SidePanelGroup>
<main className="flex h-full flex-col overflow-hidden" role="main">
{/* Scrollable container */}
<div
ref={scrollContainerRef}
className="scrollbar-gutter-stable relative flex h-full flex-col overflow-y-auto overflow-x-hidden"
>
{/* Simplified header for agents marketplace - only show nav controls when needed */}
{!isSmallScreen && (
<div className="sticky top-0 z-20 flex items-center justify-between bg-surface-secondary p-2 font-semibold text-text-primary md:h-14">
<NewChat className="border border-border-light bg-surface-secondary p-2" />
</div>
)}
{/* Hero Section - scrolls away */}
{!isSmallScreen && (
<div className="container mx-auto max-w-4xl">
<div className={cn('mb-8 text-center', 'mt-12')}>
<h1 className="mb-3 text-3xl font-bold tracking-tight text-text-primary md:text-5xl">
{localize('com_agents_marketplace')}
</h1>
<p className="mx-auto mb-6 max-w-2xl text-lg text-text-secondary">
{localize('com_agents_marketplace_subtitle')}
</p>
</div>
</div>
{/* Scrollable content area */}
<div className="container mx-auto max-w-4xl px-4 pb-8">
{/* Two-pane animated container wrapping category header + grid */}
<div className="relative overflow-hidden">
{/* Current content pane */}
)}
{/* Sticky wrapper for search bar and categories */}
<div
className={cn('sticky z-10 bg-presentation pb-4', isSmallScreen ? 'top-0' : 'top-14')}
>
<div className="container mx-auto max-w-4xl px-4">
{/* Search bar */}
<div className="mx-auto flex max-w-2xl gap-2 pb-6">
<SearchBar value={searchQuery} onSearch={handleSearch} />
{/* TODO: Remove this once we have a better way to handle admin settings */}
{/* Admin Settings */}
<MarketplaceAdminSettings />
</div>
{/* Category tabs */}
<CategoryTabs
categories={categoriesQuery.data || []}
activeTab={displayCategory}
isLoading={categoriesQuery.isLoading}
onChange={handleTabChange}
/>
</div>
</div>
{/* Scrollable content area */}
<div className="container mx-auto max-w-4xl px-4 pb-8">
{/* Two-pane animated container wrapping category header + grid */}
<div className="relative overflow-hidden">
{/* Current content pane */}
<div
className={cn(
isTransitioning &&
(animationDirection === 'right'
? 'motion-safe:animate-slide-out-left'
: 'motion-safe:animate-slide-out-right'),
)}
key={`pane-current-${displayCategory}`}
>
{/* Category header - only show when not searching */}
{!searchQuery && (
<div className="mb-6 mt-6">
{(() => {
// Get category data for display
const getCategoryData = () => {
if (displayCategory === 'promoted') {
return {
name: localize('com_agents_top_picks'),
description: localize('com_agents_recommended'),
};
}
if (displayCategory === 'all') {
return {
name: localize('com_agents_all'),
description: localize('com_agents_all_description'),
};
}
// Find the category in the API data
const categoryData = categoriesQuery.data?.find(
(cat) => cat.value === displayCategory,
);
if (categoryData) {
return {
name: categoryData.label?.startsWith('com_')
? localize(categoryData.label as TranslationKeys)
: categoryData.label,
description: categoryData.description?.startsWith('com_')
? localize(categoryData.description as TranslationKeys)
: categoryData.description || '',
};
}
// Fallback for unknown categories
return {
name:
displayCategory.charAt(0).toUpperCase() + displayCategory.slice(1),
description: '',
};
};
const { name, description } = getCategoryData();
return (
<div className="text-left">
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
{description && (
<p className="mt-2 text-text-secondary">{description}</p>
)}
</div>
);
})()}
</div>
)}
{/* Agent grid */}
<AgentGrid
key={`grid-${displayCategory}`}
category={displayCategory}
searchQuery={searchQuery}
onSelectAgent={handleAgentSelect}
scrollElementRef={scrollContainerRef}
/>
</div>
{/* Next content pane, only during transition */}
{isTransitioning && nextCategory && (
<div
className={cn(
isTransitioning &&
(animationDirection === 'right'
? 'motion-safe:animate-slide-out-left'
: 'motion-safe:animate-slide-out-right'),
'absolute inset-0',
animationDirection === 'right'
? 'motion-safe:animate-slide-in-right'
: 'motion-safe:animate-slide-in-left',
)}
key={`pane-current-${displayCategory}`}
key={`pane-next-${nextCategory}-${animationDirection}`}
>
{/* Category header - only show when not searching */}
{!searchQuery && (
@ -341,13 +340,13 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
{(() => {
// Get category data for display
const getCategoryData = () => {
if (displayCategory === 'promoted') {
if (nextCategory === 'promoted') {
return {
name: localize('com_agents_top_picks'),
description: localize('com_agents_recommended'),
};
}
if (displayCategory === 'all') {
if (nextCategory === 'all') {
return {
name: localize('com_agents_all'),
description: localize('com_agents_all_description'),
@ -356,7 +355,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
// Find the category in the API data
const categoryData = categoriesQuery.data?.find(
(cat) => cat.value === displayCategory,
(cat) => cat.value === nextCategory,
);
if (categoryData) {
return {
@ -364,7 +363,9 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
? localize(categoryData.label as TranslationKeys)
: categoryData.label,
description: categoryData.description?.startsWith('com_')
? localize(categoryData.description as TranslationKeys)
? localize(
categoryData.description as Parameters<typeof localize>[0],
)
: categoryData.description || '',
};
}
@ -372,7 +373,8 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
// Fallback for unknown categories
return {
name:
displayCategory.charAt(0).toUpperCase() + displayCategory.slice(1),
(nextCategory || '').charAt(0).toUpperCase() +
(nextCategory || '').slice(1),
description: '',
};
};
@ -393,102 +395,21 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
{/* Agent grid */}
<AgentGrid
key={`grid-${displayCategory}`}
category={displayCategory}
key={`grid-${nextCategory}`}
category={nextCategory}
searchQuery={searchQuery}
onSelectAgent={handleAgentSelect}
scrollElementRef={scrollContainerRef}
/>
</div>
)}
{/* Next content pane, only during transition */}
{isTransitioning && nextCategory && (
<div
className={cn(
'absolute inset-0',
animationDirection === 'right'
? 'motion-safe:animate-slide-in-right'
: 'motion-safe:animate-slide-in-left',
)}
key={`pane-next-${nextCategory}-${animationDirection}`}
>
{/* Category header - only show when not searching */}
{!searchQuery && (
<div className="mb-6 mt-6">
{(() => {
// Get category data for display
const getCategoryData = () => {
if (nextCategory === 'promoted') {
return {
name: localize('com_agents_top_picks'),
description: localize('com_agents_recommended'),
};
}
if (nextCategory === 'all') {
return {
name: localize('com_agents_all'),
description: localize('com_agents_all_description'),
};
}
// Find the category in the API data
const categoryData = categoriesQuery.data?.find(
(cat) => cat.value === nextCategory,
);
if (categoryData) {
return {
name: categoryData.label?.startsWith('com_')
? localize(categoryData.label as TranslationKeys)
: categoryData.label,
description: categoryData.description?.startsWith('com_')
? localize(
categoryData.description as Parameters<typeof localize>[0],
)
: categoryData.description || '',
};
}
// Fallback for unknown categories
return {
name:
(nextCategory || '').charAt(0).toUpperCase() +
(nextCategory || '').slice(1),
description: '',
};
};
const { name, description } = getCategoryData();
return (
<div className="text-left">
<h2 className="text-2xl font-bold text-text-primary">{name}</h2>
{description && (
<p className="mt-2 text-text-secondary">{description}</p>
)}
</div>
);
})()}
</div>
)}
{/* Agent grid */}
<AgentGrid
key={`grid-${nextCategory}`}
category={nextCategory}
searchQuery={searchQuery}
onSelectAgent={handleAgentSelect}
scrollElementRef={scrollContainerRef}
/>
</div>
)}
{/* Note: Using Tailwind keyframes for slide in/out animations */}
</div>
{/* Note: Using Tailwind keyframes for slide in/out animations */}
</div>
</div>
</main>
</SidePanelGroup>
</SidePanelProvider>
</div>
</main>
</SidePanelGroup>
</div>
);
};

View file

@ -13,5 +13,5 @@ interface MarketplaceProviderProps {
export const MarketplaceProvider: React.FC<MarketplaceProviderProps> = ({ children }) => {
const chatHelpers = useChatHelpers(0, 'new');
return <ChatContext.Provider value={chatHelpers as any}>{children}</ChatContext.Provider>;
return <ChatContext.Provider value={chatHelpers}>{children}</ChatContext.Provider>;
};

View file

@ -44,9 +44,9 @@ function AddMultiConvo() {
aria-label={localize('com_ui_add_multi_conversation')}
onClick={clickHandler}
data-testid="add-multi-convo-button"
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-presentation 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"
className="inline-flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-presentation 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"
>
<PlusCircle className="icon-lg" aria-hidden="true" />
<PlusCircle className="icon-sm" aria-hidden="true" />
</TooltipAnchor>
);
}

View file

@ -6,7 +6,7 @@ import { useParams } from 'react-router-dom';
import { Constants, buildTree } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import type { ChatFormValues } from '~/common';
import { ChatContext, AddedChatContext, useFileMapContext, ChatFormProvider } from '~/Providers';
import { ChatContext, AddedChatContext, ChatFormProvider, useFileMapContext } from '~/Providers';
import { useAddedResponse, useResumeOnLoad, useAdaptiveSSE, useChatHelpers } from '~/hooks';
import ConversationStarters from './Input/ConversationStarters';
import { useGetMessagesByConvoId } from '~/data-provider';
@ -34,6 +34,10 @@ function ChatView({ index = 0 }: { index?: number }) {
const rootSubmission = useRecoilValue(store.submissionByIndex(index));
const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding);
const methods = useForm<ChatFormValues>({
defaultValues: { text: '' },
});
const fileMap = useFileMapContext();
const { data: messagesTree = null, isLoading } = useGetMessagesByConvoId(conversationId ?? '', {
@ -56,10 +60,6 @@ function ChatView({ index = 0 }: { index?: number }) {
// Wait for messages to load before resuming to avoid race condition
useResumeOnLoad(conversationId, chatHelpers.getMessages, index, !isLoading);
const methods = useForm<ChatFormValues>({
defaultValues: { text: '' },
});
let content: JSX.Element | null | undefined;
const isLandingPage =
(!messagesTree || messagesTree.length === 0) &&
@ -82,7 +82,7 @@ function ChatView({ index = 0 }: { index?: number }) {
<AddedChatContext.Provider value={addedChatHelpers}>
<Presentation>
<div className="relative flex h-full w-full flex-col">
{!isLoading && <Header />}
<Header />
<>
<div
className={cn(

View file

@ -81,10 +81,10 @@ export default function ExportAndShareMenu({
<Ariakit.MenuButton
id="export-menu-button"
aria-label="Export options"
className="inline-flex size-10 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-presentation 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"
className="inline-flex size-9 flex-shrink-0 items-center justify-center rounded-xl border border-border-light bg-presentation 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-lg text-text-primary"
className="icon-md text-text-primary"
aria-hidden="true"
focusable="false"
/>

View file

@ -1,24 +1,23 @@
import { memo, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { useMediaQuery } from '@librechat/client';
import { useOutletContext } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion';
import { getConfigDefaults, PermissionTypes, Permissions } from 'librechat-data-provider';
import type { ContextType } from '~/common';
import { PresetsMenu, HeaderNewChat, OpenSidebar } from './Menus';
import ModelSelector from './Menus/Endpoints/ModelSelector';
import { useGetStartupConfig } from '~/data-provider';
import ExportAndShareMenu from './ExportAndShareMenu';
import { OpenSidebar, PresetsMenu } from './Menus';
import BookmarkMenu from './Menus/BookmarkMenu';
import { TemporaryChat } from './TemporaryChat';
import AddMultiConvo from './AddMultiConvo';
import { useHasAccess } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
const defaultInterface = getConfigDefaults().interface;
function Header() {
const { data: startupConfig } = useGetStartupConfig();
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
const navVisible = useRecoilValue(store.sidebarExpanded);
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
@ -43,30 +42,15 @@ function Header() {
const isSmallScreen = useMediaQuery('(max-width: 768px)');
return (
<div className="via-presentation/70 md:from-presentation/80 md:via-presentation/50 2xl:from-presentation/0 absolute top-0 z-10 flex h-14 w-full items-center justify-between bg-gradient-to-b from-presentation to-transparent p-2 font-semibold text-text-primary 2xl:via-transparent">
<div className="via-presentation/70 md:from-presentation/80 md:via-presentation/50 2xl:from-presentation/0 absolute top-0 z-10 flex h-[52px] w-full items-center justify-between bg-gradient-to-b from-presentation to-transparent p-2 font-semibold text-text-primary 2xl:via-transparent">
<div className="hide-scrollbar flex w-full items-center justify-between gap-2 overflow-x-auto">
<div className="mx-1 flex items-center">
<AnimatePresence initial={false}>
{!navVisible && (
<motion.div
className="flex items-center gap-2"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
key="header-buttons"
>
<OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" />
<HeaderNewChat />
</motion.div>
)}
</AnimatePresence>
<OpenSidebar className="md:hidden" />
{!(navVisible && isSmallScreen) && (
<div
className={cn(
'flex items-center gap-2',
'flex items-center gap-2 pl-2',
!isSmallScreen ? 'transition-all duration-200 ease-in-out' : '',
!navVisible && !isSmallScreen ? 'pl-2' : '',
)}
>
<ModelSelector startupConfig={startupConfig} />

View file

@ -157,9 +157,9 @@ const BookmarkMenu: FC = () => {
return <Spinner aria-label="Spinner" />;
}
if (hasBookmarks) {
return <BookmarkFilledIcon className="icon-lg" aria-hidden="true" />;
return <BookmarkFilledIcon className="icon-md" aria-hidden="true" />;
}
return <BookmarkIcon className="icon-lg" aria-hidden="true" />;
return <BookmarkIcon className="icon-md" aria-hidden="true" />;
};
return (
@ -181,7 +181,7 @@ const BookmarkMenu: FC = () => {
aria-label={buttonAriaLabel}
aria-pressed={hasBookmarks}
className={cn(
'mt-text-sm flex size-10 flex-shrink-0 items-center justify-center gap-2 rounded-xl border border-border-light bg-presentation text-sm transition-colors duration-200 hover:bg-surface-hover',
'mt-text-sm flex size-9 flex-shrink-0 items-center justify-center gap-2 rounded-xl border border-border-light bg-presentation text-sm transition-colors duration-200 hover:bg-surface-hover',
isMenuOpen ? 'bg-surface-hover' : '',
)}
data-testid="bookmark-menu"

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-presentation px-3 py-2 text-sm text-text-primary hover:bg-surface-active-alt"
className="my-1 flex h-9 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,42 +0,0 @@
import { QueryKeys } from 'librechat-data-provider';
import { useRecoilValue } from 'recoil';
import { useQueryClient } from '@tanstack/react-query';
import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client';
import { useNewConvo, useLocalize } from '~/hooks';
import { clearMessagesCache } from '~/utils';
import store from '~/store';
export default function HeaderNewChat() {
const localize = useLocalize();
const queryClient = useQueryClient();
const { newConversation } = useNewConvo();
const conversation = useRecoilValue(store.conversationByIndex(0));
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
window.open('/c/new', '_blank');
return;
}
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
};
return (
<TooltipAnchor
description={localize('com_ui_new_chat')}
render={
<Button
size="icon"
variant="outline"
data-testid="wide-header-new-chat-button"
aria-label={localize('com_ui_new_chat')}
className="rounded-xl bg-presentation duration-0 hover:bg-surface-active-alt max-md:hidden"
onClick={clickHandler}
>
<NewChatIcon />
</Button>
}
/>
);
}

View file

@ -1,32 +1,21 @@
import { startTransition } from 'react';
import { useSetRecoilState } from 'recoil';
import { TooltipAnchor, Button, Sidebar } from '@librechat/client';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import store from '~/store';
/** Element ID for the close sidebar button - used for focus management */
export const CLOSE_SIDEBAR_ID = 'close-sidebar-button';
/** Element ID for the open sidebar button - used for focus management */
export const OPEN_SIDEBAR_ID = 'open-sidebar-button';
export default function OpenSidebar({
setNavVisible,
className,
}: {
setNavVisible: React.Dispatch<React.SetStateAction<boolean>>;
className?: string;
}) {
export default function OpenSidebar({ className }: { className?: string }) {
const localize = useLocalize();
const setSidebarExpanded = useSetRecoilState(store.sidebarExpanded);
const handleClick = () => {
// 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;
});
setSidebarExpanded(true);
});
// Delay focus until after the sidebar animation completes (200ms)
setTimeout(() => {
document.getElementById(CLOSE_SIDEBAR_ID)?.focus();
}, 250);
@ -50,7 +39,7 @@ export default function OpenSidebar({
)}
onClick={handleClick}
>
<Sidebar aria-hidden="true" />
<Sidebar className="icon-md" aria-hidden="true" />
</Button>
}
/>

View file

@ -59,10 +59,10 @@ const PresetsMenu: FC = () => {
id="presets-button"
data-testid="presets-button"
aria-label={localize('com_endpoint_examples')}
className="rounded-xl bg-presentation p-2 duration-0 hover:bg-surface-active-alt"
className="h-9 w-9 shrink-0 rounded-xl bg-presentation duration-0 hover:bg-surface-active-alt"
// 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" />
<BookCopy className="icon-md" aria-hidden="true" />
</Button>
}
></TooltipAnchor>

View file

@ -1,3 +1,2 @@
export { default as PresetsMenu } from './PresetsMenu';
export { default as OpenSidebar } from './OpenSidebar';
export { default as HeaderNewChat } from './HeaderNewChat';

View file

@ -1,10 +1,10 @@
import { useRecoilValue } from 'recoil';
import { useEffect, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { FileSources, LocalStorageKeys } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import { useDeleteFilesMutation } from '~/data-provider';
import DragDropWrapper from '~/components/Chat/Input/Files/DragDropWrapper';
import { EditorProvider, SidePanelProvider, ArtifactsProvider } from '~/Providers';
import { EditorProvider, ArtifactsProvider } from '~/Providers';
import { useDeleteFilesMutation } from '~/data-provider';
import Artifacts from '~/components/Artifacts/Artifacts';
import { SidePanelGroup } from '~/components/SidePanel';
import { useSetFilesToDelete } from '~/hooks';
@ -47,20 +47,6 @@ export default function Presentation({ children }: { children: React.ReactNode }
mutateAsync({ files });
}, [mutateAsync]);
const defaultLayout = useMemo(() => {
const resizableLayout = localStorage.getItem('react-resizable-panels:layout');
return typeof resizableLayout === 'string' ? JSON.parse(resizableLayout) : undefined;
}, []);
const defaultCollapsed = useMemo(() => {
const collapsedPanels = localStorage.getItem('react-resizable-panels:collapsed');
return typeof collapsedPanels === 'string' ? JSON.parse(collapsedPanels) : true;
}, []);
const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []);
/**
* Memoize artifacts JSX to prevent recreating it on every render
* This is critical for performance - prevents entire artifact tree from re-rendering
*/
const artifactsElement = useMemo(() => {
if (artifactsVisibility === true && Object.keys(artifacts ?? {}).length > 0) {
return (
@ -76,18 +62,11 @@ export default function Presentation({ children }: { children: React.ReactNode }
return (
<DragDropWrapper className="relative flex w-full grow overflow-hidden bg-presentation">
<SidePanelProvider>
<SidePanelGroup
defaultLayout={defaultLayout}
fullPanelCollapse={fullCollapse}
defaultCollapsed={defaultCollapsed}
artifacts={artifactsElement}
>
<main className="flex h-full flex-col overflow-y-auto" role="main">
{children}
</main>
</SidePanelGroup>
</SidePanelProvider>
<SidePanelGroup artifacts={artifactsElement}>
<main className="flex h-full flex-col overflow-y-auto" role="main">
{children}
</main>
</SidePanelGroup>
</DragDropWrapper>
);
}

View file

@ -37,13 +37,13 @@ export function TemporaryChat() {
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',
'inline-flex size-9 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'
: 'bg-presentation shadow-sm hover:bg-surface-active-alt',
)}
>
<MessageCircleDashed className="icon-lg" aria-hidden="true" />
<MessageCircleDashed className="icon-md" aria-hidden="true" />
</button>
}
/>

View file

@ -48,7 +48,7 @@ const MeasuredRow: FC<MeasuredRowProps> = memo(
({ cache, rowKey, parent, index, style, children }) => (
<CellMeasurer cache={cache} columnIndex={0} key={rowKey} parent={parent} rowIndex={index}>
{({ registerChild }) => (
<div ref={registerChild as React.LegacyRef<HTMLDivElement>} style={style}>
<div ref={registerChild as React.LegacyRef<HTMLDivElement>} style={style} className="px-3">
{children}
</div>
)}
@ -386,7 +386,7 @@ const Conversations: FC<ConversationsProps> = ({
aria-label="Conversations"
onRowsRendered={handleRowsRendered}
tabIndex={-1}
style={{ outline: 'none', scrollbarGutter: 'stable' }}
style={{ outline: 'none' }}
containerRole="rowgroup"
/>
)}

View file

@ -8,7 +8,7 @@ import { useAuthContext } from '~/hooks/AuthContext';
import { useLocalize } from '~/hooks';
import Settings from './Settings';
function AccountSettings() {
function AccountSettings({ collapsed = false }: { collapsed?: boolean }) {
const localize = useLocalize();
const { user, isAuthenticated, logout } = useAuthContext();
const { data: startupConfig } = useGetStartupConfig();
@ -25,25 +25,35 @@ 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-active-alt aria-[expanded=true]:bg-surface-active-alt"
className={
collapsed
? 'flex h-9 w-9 items-center justify-center rounded-lg transition-colors hover:bg-surface-active-alt aria-[expanded=true]:bg-surface-active-alt'
: '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={collapsed ? 'size-7 flex-shrink-0' : '-ml-0.9 -mt-0.8 h-8 w-8 flex-shrink-0'}
>
<div className="relative flex">
<Avatar user={user} size={32} />
<Avatar user={user} size={collapsed ? 28 : 32} />
</div>
</div>
<div
className="mt-2 grow overflow-hidden text-ellipsis whitespace-nowrap text-left text-text-primary"
style={{ marginTop: '0', marginLeft: '0' }}
>
{user?.name ?? user?.username ?? localize('com_nav_user')}
</div>
{!collapsed && (
<div
className="mt-2 grow overflow-hidden text-ellipsis whitespace-nowrap text-left text-text-primary"
style={{ marginTop: '0', marginLeft: '0' }}
>
{user?.name ?? user?.username ?? localize('com_nav_user')}
</div>
)}
</Menu.MenuButton>
<Menu.Menu
portal
className="account-settings-popover popover-ui z-[125] w-[305px] rounded-lg md:w-[244px]"
placement={collapsed ? 'right-end' : undefined}
style={{
transformOrigin: 'bottom',
translate: '0 -4px',
transformOrigin: collapsed ? 'left bottom' : 'bottom',
translate: collapsed ? '4px 0' : '0 -4px',
}}
>
<div className="text-token-text-secondary ml-3 mr-2 py-2 text-sm" role="note">

View file

@ -108,8 +108,8 @@ const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags }: BookmarkNavProps)
aria-pressed={tags.length > 0}
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',
'size-9 border-none text-text-primary hover:bg-accent hover:text-accent-foreground',
'rounded-lg border-none p-2 hover:bg-surface-active-alt',
'outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white',
isMenuOpen ? 'bg-surface-hover' : '',
)}

View file

@ -137,7 +137,7 @@ export default function FavoritesList({
const agentsMap = useAgentsMapContext();
const { data: endpointsConfig = {} as t.TEndpointsConfig } = useGetEndpointsQuery();
const { onSelectEndpoint } = useSelectMention({
const { onSelectEndpoint: _onSelectEndpoint } = useSelectMention({
modelSpecs: [],
assistantsMap,
endpointsConfig,
@ -146,6 +146,16 @@ export default function FavoritesList({
returnHandlers: true,
});
const onSelectEndpoint = useCallback(
(...args: Parameters<NonNullable<typeof _onSelectEndpoint>>) => {
_onSelectEndpoint?.(...args);
if (isSmallScreen && toggleNav) {
toggleNav();
}
},
[_onSelectEndpoint, isSmallScreen, toggleNav],
);
const marketplaceRef = useRef<HTMLDivElement>(null);
const listContainerRef = useRef<HTMLDivElement>(null);

View file

@ -1,90 +0,0 @@
import React from 'react';
import { useRecoilValue } from 'recoil';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import type { Dispatch, SetStateAction } from 'react';
import { useLocalize, useNewConvo } from '~/hooks';
import { clearMessagesCache } from '~/utils';
import store from '~/store';
export default function MobileNav({
setNavVisible,
navVisible,
}: {
navVisible: boolean;
setNavVisible: Dispatch<SetStateAction<boolean>>;
}) {
const localize = useLocalize();
const queryClient = useQueryClient();
const { newConversation } = useNewConvo();
const conversation = useRecoilValue(store.conversationByIndex(0));
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-presentation pl-1 dark:text-white md:hidden">
<button
type="button"
data-testid="mobile-header-new-chat-button"
aria-label={
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-active-alt"
onClick={() =>
setNavVisible((prev) => {
localStorage.setItem('navVisible', JSON.stringify(!prev));
return !prev;
})
}
>
<span className="sr-only">
{navVisible ? localize('com_nav_close_sidebar') : localize('com_nav_open_sidebar')}
</span>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="icon-md"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M3 8C3 7.44772 3.44772 7 4 7H20C20.5523 7 21 7.44772 21 8C21 8.55228 20.5523 9 20 9H4C3.44772 9 3 8.55228 3 8ZM3 16C3 15.4477 3.44772 15 4 15H14C14.5523 15 15 15.4477 15 16C15 16.5523 14.5523 17 14 17H4C3.44772 17 3 16.5523 3 16Z"
fill="currentColor"
/>
</svg>
</button>
<h1 className="flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-center text-sm font-normal">
{title ?? localize('com_ui_new_chat')}
</h1>
<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-active-alt"
onClick={() => {
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
}}
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="icon-md"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M16.7929 2.79289C18.0118 1.57394 19.9882 1.57394 21.2071 2.79289C22.4261 4.01184 22.4261 5.98815 21.2071 7.20711L12.7071 15.7071C12.5196 15.8946 12.2652 16 12 16H9C8.44772 16 8 15.5523 8 15V12C8 11.7348 8.10536 11.4804 8.29289 11.2929L16.7929 2.79289ZM19.7929 4.20711C19.355 3.7692 18.645 3.7692 18.2071 4.2071L10 12.4142V14H11.5858L19.7929 5.79289C20.2308 5.35499 20.2308 4.64501 19.7929 4.20711ZM6 5C5.44772 5 5 5.44771 5 6V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V14C19 13.4477 19.4477 13 20 13C20.5523 13 21 13.4477 21 14V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V6C3 4.34314 4.34315 3 6 3H10C10.5523 3 11 3.44771 11 4C11 4.55228 10.5523 5 10 5H6Z"
fill="currentColor"
/>
</svg>
</button>
</div>
);
}

View file

@ -1,307 +0,0 @@
import {
useCallback,
useEffect,
useState,
useMemo,
memo,
lazy,
Suspense,
useRef,
startTransition,
} from 'react';
import { useRecoilValue } from 'recoil';
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';
import type { ConversationListResponse } from 'librechat-data-provider';
import type { List } from 'react-virtualized';
import {
useLocalize,
useHasAccess,
useAuthContext,
useLocalStorage,
useNavScrolling,
} from '~/hooks';
import { useConversationsInfiniteQuery, useTitleGeneration } from '~/data-provider';
import { Conversations } from '~/components/Conversations';
import SearchBar from './SearchBar';
import NewChat from './NewChat';
import { cn } from '~/utils';
import store from '~/store';
const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav'));
const AccountSettings = lazy(() => import('./AccountSettings'));
export const NAV_WIDTH = {
MOBILE: 320,
DESKTOP: 260,
} as const;
const SearchBarSkeleton = memo(() => (
<div className={cn('flex h-10 items-center py-2')}>
<Skeleton className="h-10 w-full rounded-lg" />
</div>
));
SearchBarSkeleton.displayName = 'SearchBarSkeleton';
const NavMask = memo(
({ navVisible, toggleNavVisible }: { navVisible: boolean; toggleNavVisible: () => void }) => (
<div
id="mobile-nav-mask-toggle"
role="button"
tabIndex={0}
className={`nav-mask transition-opacity duration-200 ease-in-out ${navVisible ? 'active opacity-100' : 'opacity-0'}`}
onClick={toggleNavVisible}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
toggleNavVisible();
}
}}
aria-label="Toggle navigation"
/>
),
);
const MemoNewChat = memo(NewChat);
const Nav = memo(
({
navVisible,
setNavVisible,
}: {
navVisible: boolean;
setNavVisible: React.Dispatch<React.SetStateAction<boolean>>;
}) => {
const localize = useLocalize();
const { isAuthenticated } = useAuthContext();
useTitleGeneration(isAuthenticated);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const [newUser, setNewUser] = useLocalStorage('newUser', true);
const [isChatsExpanded, setIsChatsExpanded] = useLocalStorage('chatsExpanded', true);
const [showLoading, setShowLoading] = useState(false);
const [tags, setTags] = useState<string[]>([]);
const hasAccessToBookmarks = useHasAccess({
permissionType: PermissionTypes.BOOKMARKS,
permission: Permissions.USE,
});
const search = useRecoilValue(store.search);
const { data, fetchNextPage, isFetchingNextPage, isLoading, isFetching, refetch } =
useConversationsInfiniteQuery(
{
tags: tags.length === 0 ? undefined : tags,
search: search.debouncedQuery || undefined,
},
{
enabled: isAuthenticated,
staleTime: 30000,
cacheTime: 300000,
},
);
const computedHasNextPage = useMemo(() => {
if (data?.pages && data.pages.length > 0) {
const lastPage: ConversationListResponse = data.pages[data.pages.length - 1];
return lastPage.nextCursor !== null;
}
return false;
}, [data?.pages]);
const outerContainerRef = useRef<HTMLDivElement>(null);
const conversationsRef = useRef<List | null>(null);
const { moveToTop } = useNavScrolling<ConversationListResponse>({
setShowLoading,
fetchNextPage: async (options?) => {
if (computedHasNextPage) {
return fetchNextPage(options);
}
return Promise.resolve(
{} as InfiniteQueryObserverResult<ConversationListResponse, unknown>,
);
},
isFetchingNext: isFetchingNextPage,
});
const conversations = useMemo(() => {
return data ? data.pages.flatMap((page) => page.conversations) : [];
}, [data]);
const toggleNavVisible = useCallback(() => {
// 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);
}
});
}, [newUser, setNavVisible, setNewUser]);
const itemToggleNav = useCallback(() => {
if (isSmallScreen) {
toggleNavVisible();
}
}, [isSmallScreen, toggleNavVisible]);
useEffect(() => {
if (isSmallScreen) {
const savedNavVisible = localStorage.getItem('navVisible');
if (savedNavVisible === null) {
toggleNavVisible();
}
}
}, [isSmallScreen, toggleNavVisible]);
useEffect(() => {
refetch();
}, [tags, refetch]);
const loadMoreConversations = useCallback(() => {
if (isFetchingNextPage || !computedHasNextPage) {
return;
}
fetchNextPage();
}, [isFetchingNextPage, computedHasNextPage, fetchNextPage]);
const subHeaders = useMemo(
() => (
<>
{search.enabled === null && <SearchBarSkeleton />}
{search.enabled === true && <SearchBar isSmallScreen={isSmallScreen} />}
</>
),
[search.enabled, isSmallScreen],
);
const headerButtons = useMemo(
() => (
<>
{hasAccessToBookmarks && (
<>
<div className="mt-1.5" />
<Suspense fallback={null}>
<BookmarkNav tags={tags} setTags={setTags} />
</Suspense>
</>
)}
</>
),
[hasAccessToBookmarks, tags],
);
const [isSearchLoading, setIsSearchLoading] = useState(
!!search.query && (search.isTyping || isLoading || isFetching),
);
useEffect(() => {
if (search.isTyping) {
setIsSearchLoading(true);
} else if (!isLoading && !isFetching) {
setIsSearchLoading(false);
} else if (!!search.query && (isLoading || isFetching)) {
setIsSearchLoading(true);
}
}, [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}
{...{ inert: !navVisible ? '' : undefined }}
>
<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-[110] 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 (
<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>
);
},
);
Nav.displayName = 'Nav';
export default Nav;

View file

@ -1,105 +1,45 @@
import React, { useCallback } from 'react';
import { Link } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { TooltipAnchor, NewChatIcon, MobileSidebar, Sidebar, Button } from '@librechat/client';
import { CLOSE_SIDEBAR_ID, OPEN_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar';
import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client';
import { useLocalize, useNewConvo } from '~/hooks';
import { clearMessagesCache } from '~/utils';
import { clearMessagesCache, cn } from '~/utils';
import store from '~/store';
export default function NewChat({
index = 0,
toggleNav,
subHeaders,
isSmallScreen,
headerButtons,
}: {
index?: number;
toggleNav: () => void;
isSmallScreen?: boolean;
subHeaders?: React.ReactNode;
headerButtons?: React.ReactNode;
}) {
const queryClient = useQueryClient();
/** Note: this component needs an explicit index passed if using more than one */
const { newConversation: newConvo } = useNewConvo(index);
export default function NewChat({ className }: { className?: string }) {
const localize = useLocalize();
const { conversation } = store.useCreateConversationAtom(index);
const queryClient = useQueryClient();
const { newConversation } = useNewConvo();
const conversation = useRecoilValue(store.conversationByIndex(0));
const handleToggleNav = useCallback(() => {
toggleNav();
// Delay focus until after the sidebar animation completes (200ms)
setTimeout(() => {
document.getElementById(OPEN_SIDEBAR_ID)?.focus();
}, 250);
}, [toggleNav]);
const clickHandler: React.MouseEventHandler<HTMLAnchorElement> = useCallback(
(e) => {
// Let browser handle modified/non-left clicks (new tab, context menu, etc.)
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) {
return;
}
e.preventDefault();
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConvo();
if (isSmallScreen) {
toggleNav();
}
},
[queryClient, conversation, newConvo, toggleNav, isSmallScreen],
);
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = (e) => {
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
window.open('/c/new', '_blank');
return;
}
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
};
return (
<>
<div className="flex items-center justify-between px-0.5 py-[2px] md:py-2">
<TooltipAnchor
description={localize('com_nav_close_sidebar')}
render={
<Button
id={CLOSE_SIDEBAR_ID}
size="icon"
variant="outline"
data-testid="close-sidebar-button"
aria-label={localize('com_nav_close_sidebar')}
aria-expanded={true}
className="rounded-full border-none bg-transparent duration-0 hover:bg-surface-active-alt focus-visible:ring-inset focus-visible:ring-black focus-visible:ring-offset-0 dark:focus-visible:ring-white md:rounded-xl"
onClick={handleToggleNav}
>
<Sidebar aria-hidden="true" className="max-md:hidden" />
<MobileSidebar
aria-hidden="true"
className="icon-lg m-1 inline-flex items-center justify-center md:hidden"
/>
</Button>
}
/>
<div className="flex gap-0.5">
{headerButtons}
<TooltipAnchor
description={localize('com_ui_new_chat')}
render={
<Button
asChild
size="icon"
variant="outline"
data-testid="nav-new-chat-button"
aria-label={localize('com_ui_new_chat')}
className="rounded-full border-none bg-transparent duration-0 hover:bg-surface-active-alt focus-visible:ring-inset focus-visible:ring-black focus-visible:ring-offset-0 dark:focus-visible:ring-white md:rounded-xl"
>
<Link to="/c/new" state={{ focusChat: true }} onClick={clickHandler}>
<NewChatIcon className="icon-lg text-text-primary" />
</Link>
</Button>
}
/>
</div>
</div>
{subHeaders != null ? subHeaders : null}
</>
<TooltipAnchor
description={localize('com_ui_new_chat')}
render={
<Button
size="icon"
variant="outline"
data-testid="new-chat-button"
aria-label={localize('com_ui_new_chat')}
className={cn(
'size-9 rounded-xl bg-presentation duration-0 hover:bg-surface-active-alt max-md:hidden',
className,
)}
onClick={clickHandler}
>
<NewChatIcon />
</Button>
}
/>
);
}

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-active-alt hover:bg-surface-active-alt"
className="group relative flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-3 rounded-lg border-2 border-transparent px-3 py-1.5 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

@ -22,13 +22,6 @@ const toggleSwitchConfigs = [
hoverCardText: undefined,
key: 'autoScroll',
},
{
stateAtom: store.hideSidePanel,
localizationKey: 'com_nav_hide_panel',
switchId: 'hideSidePanel',
hoverCardText: undefined,
key: 'hideSidePanel',
},
{
stateAtom: store.keepScreenAwake,
localizationKey: 'com_nav_keep_screen_awake',

View file

@ -1,7 +1,5 @@
export * from './ExportConversation';
export * from './SettingsTabs/';
export { default as MobileNav } from './MobileNav';
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

@ -6,9 +6,12 @@ import { usePromptGroupsContext } from '~/Providers';
export default function PromptsAccordion() {
const groupsNav = usePromptGroupsContext();
return (
<div className="flex h-full w-full flex-col">
<PromptSidePanel className="mt-2 space-y-2 lg:w-full xl:w-full" {...groupsNav}>
<FilterPrompts className="items-center justify-center" />
<div className="flex h-auto w-full flex-col px-3 pb-3">
<PromptSidePanel
className="h-auto space-y-2 md:mr-0 md:min-w-0 lg:w-full xl:w-full"
{...groupsNav}
>
<FilterPrompts className="items-stretch" />
<div className="flex w-full flex-row items-center justify-end">
<AutoSendPrompt className="text-xs dark:text-white" />
</div>

View file

@ -206,7 +206,7 @@ export default function ActionsInput({
<div className="mb-1 flex flex-wrap items-center justify-between gap-4">
<label
htmlFor="schemaInput"
className="text-token-text-primary whitespace-nowrap font-medium"
className="text-token-text-primary whitespace-nowrap text-sm font-medium"
>
{localize('com_ui_schema')}
</label>
@ -248,7 +248,7 @@ export default function ActionsInput({
{!!data && (
<div className="my-2">
<div className="flex items-center">
<label className="text-token-text-primary block font-medium">
<label className="text-token-text-primary block text-sm font-medium">
{localize('com_assistants_available_actions')}
</label>
</div>
@ -258,7 +258,7 @@ export default function ActionsInput({
<div className="relative my-1">
<ActionCallback action_id={action?.action_id} />
<div className="mb-1.5 flex items-center">
<label className="text-token-text-primary block font-medium">
<label className="text-token-text-primary block text-sm font-medium">
{localize('com_ui_privacy_policy_url')}
</label>
</div>

View file

@ -94,7 +94,7 @@ const AgentChain: React.FC<AgentChainProps> = ({ field, currentAgentId }) => {
</div>
<div className="space-y-1">
{/* Current fixed agent */}
<div className="flex h-10 items-center justify-between rounded-md border border-border-medium bg-surface-primary-contrast px-3 py-2">
<div className="flex h-9 items-center justify-between rounded-md border border-border-medium bg-surface-primary-contrast px-3 py-2">
<div className="flex items-center gap-2">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<MessageIcon
@ -115,7 +115,7 @@ const AgentChain: React.FC<AgentChainProps> = ({ field, currentAgentId }) => {
{<Link2 className="mx-auto text-text-secondary" size={14} />}
{agentIds.map((agentId, idx) => (
<React.Fragment key={agentId}>
<div className="flex h-10 items-center gap-2 rounded-md border border-border-medium bg-surface-tertiary pr-2">
<div className="flex h-9 items-center gap-2 rounded-md border border-border-medium bg-surface-tertiary pr-2">
<ControlCombobox
isCollapsed={false}
ariaLabel={localize('com_ui_agent_var', { 0: localize('com_ui_select') })}
@ -170,7 +170,7 @@ const AgentChain: React.FC<AgentChainProps> = ({ field, currentAgentId }) => {
selectPlaceholder={localize('com_ui_agent_var', { 0: localize('com_ui_add') })}
searchPlaceholder={localize('com_ui_agent_var', { 0: localize('com_ui_search') })}
items={selectableAgents}
className="h-10 w-full border-dashed border-border-heavy text-center text-text-secondary hover:text-text-primary"
className="h-9 w-full border-dashed border-border-heavy text-center text-text-secondary hover:text-text-primary"
containerClassName="px-0"
SelectIcon={<PlusCircle size={16} className="text-text-secondary" />}
/>

View file

@ -143,7 +143,7 @@ const AgentHandoffs: React.FC<AgentHandoffsProps> = ({ field, currentAgentId })
return (
<React.Fragment key={idx}>
<div className="space-y-1">
<div className="flex h-10 items-center gap-2 rounded-md border border-border-medium bg-surface-tertiary pr-2">
<div className="flex h-9 items-center gap-2 rounded-md border border-border-medium bg-surface-tertiary pr-2">
<ControlCombobox
isCollapsed={false}
ariaLabel={localize('com_ui_agent_var', { 0: localize('com_ui_select') })}
@ -268,7 +268,7 @@ const AgentHandoffs: React.FC<AgentHandoffsProps> = ({ field, currentAgentId })
selectPlaceholder={localize('com_ui_agent_handoff_add')}
searchPlaceholder={localize('com_ui_agent_var', { 0: localize('com_ui_search') })}
items={selectableAgents}
className="h-10 w-full border-dashed border-border-heavy text-center text-text-secondary hover:text-text-primary"
className="h-9 w-full border-dashed border-border-heavy text-center text-text-secondary hover:text-text-primary"
containerClassName="px-0"
SelectIcon={<PlusCircle size={16} className="text-text-secondary" />}
/>

View file

@ -30,7 +30,7 @@ import AgentTool from './AgentTool';
import CodeForm from './Code/Form';
import MCPTools from './MCPTools';
const labelClass = 'mb-2 text-token-text-primary block font-medium';
const labelClass = 'mb-2 text-token-text-primary block text-sm font-medium';
const inputClass = cn(
defaultTextProps,
'flex w-full px-3 py-2 border-border-light bg-surface-secondary focus-visible:ring-2 focus-visible:ring-ring-primary',
@ -180,7 +180,7 @@ export default function AgentConfig() {
return (
<>
<div className="h-auto bg-white px-4 pt-3 dark:bg-transparent">
<div className="h-auto pt-1">
{/* Avatar & Name */}
<div className="mb-4">
<AgentAvatar avatar={agent?.['avatar'] ?? null} />
@ -265,7 +265,7 @@ export default function AgentConfig() {
<button
type="button"
onClick={() => setActivePanel(Panel.model)}
className="btn btn-neutral border-token-border-light relative h-10 w-full rounded-lg font-medium"
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
aria-haspopup="true"
aria-expanded="false"
>
@ -290,7 +290,7 @@ export default function AgentConfig() {
contextEnabled ||
webSearchEnabled) && (
<div className="mb-4 flex w-full flex-col items-start gap-3">
<label className="text-token-text-primary block font-medium">
<label className="text-token-text-primary block text-sm font-medium">
{localize('com_assistants_capabilities')}
</label>
{/* Code Execution */}
@ -393,7 +393,7 @@ export default function AgentConfig() {
<div className="mb-4">
<div className="mb-1.5 flex items-center gap-2">
<span>
<label className="text-token-text-primary block font-medium">
<label className="text-token-text-primary block text-sm font-medium">
{localize('com_ui_support_contact')}
</label>
</span>

View file

@ -480,10 +480,10 @@ export default function AgentPanel() {
<FormProvider {...methods}>
<form
onSubmit={handleSubmit(onSubmit)}
className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 overflow-y-hidden overflow-x-visible"
className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 px-3 pb-3"
aria-label="Agent configuration form"
>
<div className="mx-1 mt-2 flex w-full flex-wrap gap-2">
<div className="flex w-full flex-wrap gap-2">
<div className="w-full">
<AgentSelect
createMutation={create}

View file

@ -11,14 +11,14 @@ export default function AgentPanelSkeleton() {
</div>
{/* Name */}
<Skeleton className="mb-2 h-5 w-1/5 rounded-lg" />
<Skeleton className="mb-1 h-[40px] w-full rounded-lg" />
<Skeleton className="mb-1 h-9 w-full rounded-lg" />
<Skeleton className="h-3 w-1/4 rounded-lg" />
</div>
{/* Description */}
<div className="mb-4">
<Skeleton className="mb-2 h-5 w-1/4 rounded-lg" />
<Skeleton className="h-[40px] w-full rounded-lg" />
<Skeleton className="h-9 w-full rounded-lg" />
</div>
{/* Instructions */}
@ -30,7 +30,7 @@ export default function AgentPanelSkeleton() {
{/* Model and Provider */}
<div className="mb-6">
<Skeleton className="mb-2 h-5 w-1/4 rounded-lg" />
<Skeleton className="h-[40px] w-full rounded-lg" />
<Skeleton className="h-9 w-full rounded-lg" />
</div>
{/* Capabilities */}

View file

@ -215,7 +215,7 @@ function AgentSelect({
]
}
className={cn(
'z-50 flex h-[40px] w-full flex-none items-center justify-center truncate rounded-md bg-transparent font-bold',
'z-50 flex h-9 w-full flex-none items-center justify-center truncate rounded-md bg-transparent font-bold',
)}
ariaLabel={localize('com_ui_agent')}
isCollapsed={false}

View file

@ -45,7 +45,7 @@ export default function Artifacts() {
<div className="w-full">
<div className="mb-1.5 flex items-center gap-2">
<span>
<label className="text-token-text-primary block font-medium">
<label className="text-token-text-primary block text-sm font-medium">
{localize('com_ui_artifacts')}
</label>
</span>
@ -98,7 +98,7 @@ function SwitchItem({
<HoverCard openDelay={50}>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className={disabled ? 'text-text-tertiary' : ''}>{label}</div>
<div className={disabled ? 'text-sm text-text-tertiary' : 'text-sm'}>{label}</div>
<HoverCardTrigger>
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
</HoverCardTrigger>

View file

@ -75,7 +75,7 @@ export default function Action({ authType = '', isToolAuthenticated = false }) {
id="execute-code-label"
htmlFor="execute-code-checkbox"
className={cn(
'form-check-label text-token-text-primary',
'form-check-label text-token-text-primary text-sm',
(runCodeIsEnabled || isToolAuthenticated) && 'cursor-pointer',
)}
>

View file

@ -77,7 +77,7 @@ function Files({
<button
type="button"
disabled={isEphemeralAgent(agent_id) || codeChecked === false}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg text-sm font-medium"
onClick={handleButtonClick}
>
<div className="flex w-full items-center justify-center gap-1">

View file

@ -20,7 +20,7 @@ export default function CodeForm({
<div className="mb-1.5 flex items-center gap-2">
<div className="flex flex-row items-center gap-1">
<div className="flex items-center gap-1">
<span className="text-token-text-primary block font-medium">
<span className="text-token-text-primary block text-sm font-medium">
{localize('com_agents_code_interpreter_title')}
</span>
<span className="text-xs text-text-secondary">

View file

@ -100,7 +100,7 @@ function FileContext({
},
];
const menuTrigger = (
<Ariakit.MenuButton className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium">
<Ariakit.MenuButton className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg text-sm font-medium">
<div className="flex w-full items-center justify-center gap-1">
<AttachmentIcon className="text-token-text-primary h-4 w-4" />
{localize('com_ui_upload_file_context')}
@ -113,7 +113,7 @@ function FileContext({
<div className="mb-2 flex items-center gap-2">
<HoverCardTrigger asChild>
<span className="flex items-center gap-2">
<label className="text-token-text-primary block font-medium">
<label className="text-token-text-primary block text-sm font-medium">
{localize('com_agents_file_context_label')}
</label>
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
@ -157,7 +157,7 @@ function FileContext({
<button
type="button"
disabled={isEphemeralAgent(agent_id)}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg text-sm font-medium"
onClick={handleLocalFileClick}
>
<div className="flex w-full items-center justify-center gap-1">

View file

@ -114,7 +114,7 @@ function FileSearch({
const menuTrigger = (
<Ariakit.MenuButton
disabled={disabledUploadButton}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg text-sm font-medium"
>
<div className="flex w-full items-center justify-center gap-1">
<AttachmentIcon className="text-token-text-primary h-4 w-4" />
@ -127,7 +127,7 @@ function FileSearch({
<div className="w-full">
<div className="mb-1.5 flex items-center gap-2">
<span>
<label className="text-token-text-primary block font-medium">
<label className="text-token-text-primary block text-sm font-medium">
{localize('com_assistants_file_search')}
</label>
</span>
@ -158,7 +158,7 @@ function FileSearch({
<button
type="button"
disabled={disabledUploadButton}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg text-sm font-medium"
onClick={handleButtonClick}
>
<div className="flex w-full items-center justify-center gap-1">

View file

@ -40,7 +40,7 @@ function FileSearchCheckbox() {
<label
id="file-search-label"
htmlFor="file-search-checkbox"
className="form-check-label text-token-text-primary cursor-pointer"
className="form-check-label text-token-text-primary cursor-pointer text-sm"
>
{localize('com_agents_enable_file_search')}
</label>

View file

@ -44,7 +44,10 @@ export default function Instructions() {
return (
<div className="mb-4">
<div className="mb-2 flex items-center">
<label className="text-token-text-primary flex-grow font-medium" htmlFor="instructions">
<label
className="text-token-text-primary flex-grow text-sm font-medium"
htmlFor="instructions"
>
{localize('com_ui_instructions')}
</label>
<div className="ml-auto" title="Add variables to instructions">

View file

@ -26,7 +26,7 @@ export default function MCPTools({
}
return (
<div className="mb-4">
<label className="text-token-text-primary mb-2 block font-medium">
<label className="text-token-text-primary mb-2 block text-sm font-medium">
{localize('com_ui_mcp_servers')}
</label>
<div>

View file

@ -97,7 +97,7 @@ export default function ModelPanel({
};
return (
<div className="mx-1 mb-1 flex h-full min-h-[50vh] w-full flex-col gap-2 text-sm">
<div className="mb-1 flex h-full min-h-[50vh] w-full flex-col gap-2 text-sm">
<div className="model-panel relative flex flex-col items-center px-16 py-4 text-center">
<div className="absolute left-0 top-4">
<button
@ -116,12 +116,12 @@ export default function ModelPanel({
<div className="mb-2 mt-2 text-xl font-medium">{localize('com_ui_model_parameters')}</div>
</div>
<div className="p-2">
<div>
{/* Endpoint aka Provider for Agents */}
<div className="mb-4">
<label
id="provider-label"
className="text-token-text-primary model-panel-label mb-2 block font-medium"
className="text-token-text-primary model-panel-label mb-2 block text-sm font-medium"
htmlFor="provider"
>
{localize('com_ui_provider')} <span className="text-red-500">*</span>
@ -172,7 +172,7 @@ export default function ModelPanel({
<label
id="model-label"
className={cn(
'text-token-text-primary model-panel-label mb-2 block font-medium',
'text-token-text-primary model-panel-label mb-2 block text-sm font-medium',
!provider && 'text-gray-500 dark:text-gray-400',
)}
htmlFor="model"
@ -218,7 +218,7 @@ export default function ModelPanel({
</div>
{/* Model Parameters */}
{parameters && (
<div className="h-auto max-w-full overflow-x-hidden p-2">
<div className="h-auto max-w-full">
<div className="grid grid-cols-2 gap-4">
{/* This is the parent element containing all settings */}
{/* Below is an example of an applied dynamic setting, each be contained by a div with the column span specified */}

View file

@ -83,7 +83,7 @@ export default function Action({
id="web-search-label"
htmlFor="web-search-checkbox"
className={cn(
'form-check-label text-token-text-primary',
'form-check-label text-token-text-primary text-sm',
(webSearchIsEnabled || isToolAuthenticated) && 'cursor-pointer',
)}
>

View file

@ -17,7 +17,7 @@ export default function SearchForm() {
<div className="mb-1.5 flex items-center gap-2">
<div className="flex flex-row items-center gap-1">
<div className="flex items-center gap-1">
<span className="text-token-text-primary block font-medium">
<span className="text-token-text-primary block text-sm font-medium">
{localize('com_ui_web_search')}
</span>
</div>

View file

@ -55,7 +55,7 @@ const BookmarkTable = () => {
return (
<BookmarkContext.Provider value={{ bookmarks }}>
<div role="region" aria-label={localize('com_ui_bookmarks')} className="mt-2 space-y-3">
<div role="region" aria-label={localize('com_ui_bookmarks')} className="space-y-2 px-3 pb-3">
{/* Header: Filter + Create Button */}
<div className="flex items-center gap-2">
<FilterInput
@ -74,7 +74,7 @@ const BookmarkTable = () => {
<Button
variant="outline"
size="icon"
className="shrink-0 bg-transparent"
className="size-9 shrink-0 bg-transparent"
aria-label={localize('com_ui_bookmarks_new')}
onClick={() => setCreateOpen(true)}
>

View file

@ -7,7 +7,7 @@ export default function FilesPanel() {
const { data: files = [] } = useGetFiles<TFile[]>();
return (
<div className="h-auto max-w-full overflow-x-visible">
<div className="h-auto w-full px-3 pb-3">
<DataTable columns={columns} data={files} />
</div>
);

View file

@ -39,12 +39,13 @@ export const columns: ColumnDef<TFile | undefined>[] = [
return (
<Button
variant="ghost"
className="hover:bg-surface-hover"
size="sm"
className="h-auto px-1 py-0.5 hover:bg-surface-hover"
onClick={() => column.toggleSorting(column.getIsSorted() === 'asc')}
aria-label={localize('com_ui_date')}
>
{localize('com_ui_date')}
<ArrowUpDown className="ml-2 h-4 w-4" />
<ArrowUpDown className="ml-1 h-3 w-3" />
</Button>
);
},

View file

@ -11,7 +11,7 @@ export default function PanelFileCell({ row }: { row: Row<TFile | undefined> })
{file?.type?.startsWith('image') === true ? (
<ImagePreview
url={file.filepath}
className="h-10 w-10 flex-shrink-0"
className="h-8 w-8 flex-shrink-0"
source={file.source}
alt={file.filename}
/>

View file

@ -205,7 +205,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
const filenameFilter = table.getColumn('filename')?.getFilterValue() as string;
return (
<div role="region" aria-label={localize('com_files_table')} className="mt-2 space-y-2">
<div role="region" aria-label={localize('com_files_table')} className="space-y-2">
<FilterInput
inputId="filename-filter"
label={localize('com_files_filter')}
@ -214,8 +214,8 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
/>
<div className="rounded-lg border border-border-light bg-transparent shadow-sm transition-colors">
<div className="overflow-x-auto">
<Table>
<div className="overflow-hidden">
<Table className="table-fixed">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="border-b border-border-light">
@ -223,9 +223,9 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
<TableHead
key={header.id}
style={{ width: index === 0 ? '75%' : '25%' }}
className="bg-surface-secondary py-3 text-left text-sm font-medium text-text-secondary"
className="bg-surface-secondary py-2 text-sm font-medium text-text-secondary"
>
<div className="px-4">
<div className={index === 0 ? 'px-2' : 'flex justify-end px-1'}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
@ -249,8 +249,8 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
return (
<TableCell
style={{
width: '150px',
maxWidth: '150px',
width: isFilenameCell ? '75%' : '25%',
maxWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
@ -313,11 +313,12 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
</div>
</div>
<div className="flex items-center justify-between">
<div className="space-y-2">
<Button
ref={manageFilesRef}
variant="outline"
size="sm"
className="w-full"
onClick={() => setShowFilesModal(true)}
aria-label={localize('com_sidepanel_manage_files')}
>
@ -325,7 +326,11 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
<span className="ml-2">{localize('com_sidepanel_manage_files')}</span>
</Button>
<div className="flex items-center gap-2" role="navigation" aria-label="Pagination">
<div
className="flex items-center justify-between"
role="navigation"
aria-label="Pagination"
>
<Button
variant="outline"
size="sm"

View file

@ -36,8 +36,8 @@ export default function MCPBuilderPanel() {
}, [availableMCPServers, searchQuery]);
return (
<div className="flex h-full w-full flex-col overflow-visible">
<div role="region" aria-label={localize('com_ui_mcp_servers')} className="mt-2 space-y-2">
<div className="flex h-auto w-full flex-col px-3 pb-3">
<div role="region" aria-label={localize('com_ui_mcp_servers')} className="space-y-2">
{/* Toolbar: Search + Add Button */}
<div className="flex items-center gap-2">
<FilterInput
@ -62,7 +62,7 @@ export default function MCPBuilderPanel() {
ref={addButtonRef}
variant="outline"
size="icon"
className="shrink-0 bg-transparent"
className="size-9 shrink-0 bg-transparent"
onClick={() => setShowDialog(true)}
aria-label={localize('com_ui_add_mcp')}
>

View file

@ -121,8 +121,8 @@ export default function MemoryPanel() {
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">
<div className="flex h-auto w-full flex-col px-3 pb-3">
<div role="region" aria-label={localize('com_ui_memories')} className="space-y-2">
{/* Header: Filter + Create Button */}
<div className="flex items-center gap-2">
<FilterInput
@ -142,7 +142,7 @@ export default function MemoryPanel() {
<Button
variant="outline"
size="icon"
className="shrink-0 bg-transparent"
className="size-9 shrink-0 bg-transparent"
aria-label={localize('com_ui_create_memory')}
onClick={() => setCreateDialogOpen(true)}
>
@ -169,7 +169,7 @@ export default function MemoryPanel() {
{/* Memory Toggle */}
{hasOptOutAccess && (
<div className="flex items-center gap-2 text-xs">
<div className="ml-auto flex items-center gap-2 text-xs">
<span className="text-text-secondary">{localize('com_ui_use_memory')}</span>
<Switch
checked={referenceSavedMemories}

View file

@ -1,115 +1,13 @@
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import {
AccordionContent,
AccordionItem,
TooltipAnchor,
Accordion,
Button,
} from '@librechat/client';
import type { NavLink, NavProps } from '~/common';
import { ActivePanelProvider, useActivePanel } from '~/Providers';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
function NavContent({ links, isCollapsed, resize }: Omit<NavProps, 'defaultActive'>) {
const localize = useLocalize();
const { active, setActive } = useActivePanel();
const getVariant = (link: NavLink) => (link.id === active ? 'default' : 'ghost');
import type { NavLink } from '~/common';
import { useActivePanel } from '~/Providers';
export default function Nav({ links }: { links: NavLink[] }) {
const { active } = useActivePanel();
return (
<div
data-collapsed={isCollapsed}
className="bg-token-sidebar-surface-primary hide-scrollbar group flex-shrink-0 overflow-x-hidden"
>
<div className="h-full">
<div className="flex h-full min-h-0 flex-col">
<div className="flex h-full min-h-0 flex-col opacity-100 transition-opacity">
<div className="scrollbar-trigger relative h-full w-full flex-1 items-start border-white/20">
<div className="flex h-full w-full flex-col gap-1 px-3 py-2.5 group-[[data-collapsed=true]]:items-center group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
{links.map((link, index) => {
const variant = getVariant(link);
return isCollapsed ? (
<TooltipAnchor
description={localize(link.title)}
side="left"
key={`nav-link-${index}`}
render={
<Button
variant="ghost"
size="icon"
onClick={(e) => {
if (link.onClick) {
link.onClick(e);
setActive('');
return;
}
setActive(link.id);
resize && resize(25);
}}
>
<link.icon className="h-4 w-4 text-text-secondary" />
<span className="sr-only">{localize(link.title)}</span>
</Button>
}
/>
) : (
<Accordion
key={index}
type="single"
value={active}
onValueChange={setActive}
collapsible
>
<AccordionItem value={link.id} className="w-full border-none">
<AccordionPrimitive.Header asChild>
<AccordionPrimitive.Trigger asChild>
<Button
variant="outline"
size="sm"
className="w-full justify-start bg-transparent text-text-secondary data-[state=open]:bg-surface-secondary data-[state=open]:text-text-primary"
onClick={(e) => {
if (link.onClick) {
link.onClick(e);
setActive('');
}
}}
>
<link.icon className="mr-2 h-4 w-4" aria-hidden="true" />
{localize(link.title)}
{link.label != null && link.label && (
<span
className={cn(
'ml-auto opacity-100 transition-all duration-300 ease-in-out',
variant === 'default' ? 'text-text-primary' : '',
)}
>
{link.label}
</span>
)}
</Button>
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
<AccordionContent className="bg-token-sidebar-surface-primary w-full text-text-primary">
{link.Component && <link.Component />}
</AccordionContent>
</AccordionItem>
</Accordion>
);
})}
</div>
</div>
</div>
</div>
</div>
<div className="flex h-full min-h-0 flex-col overflow-y-auto overflow-x-hidden pt-2 text-text-primary">
{links.map((link) =>
link.id === active && link.Component ? <link.Component key={link.id} /> : null,
)}
</div>
);
}
export default function Nav({ links, isCollapsed, resize, defaultActive }: NavProps) {
return (
<ActivePanelProvider defaultActive={defaultActive}>
<NavContent links={links} isCollapsed={isCollapsed} resize={resize} />
</ActivePanelProvider>
);
}

View file

@ -60,7 +60,7 @@ function DynamicCheckbox({
<div className="flex justify-start gap-4">
<Label
htmlFor={`${settingKey}-dynamic-checkbox`}
className="text-left text-sm font-medium"
className="text-left text-xs font-medium"
>
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
{showDefault && (

View file

@ -82,7 +82,7 @@ function DynamicCombobox({
<div className="flex w-full justify-between">
<Label
htmlFor={`${settingKey}-dynamic-combobox`}
className="text-left text-sm font-medium"
className="text-left text-xs font-medium"
>
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}
{showDefault && (

View file

@ -76,7 +76,7 @@ function DynamicDropdown({
<div className="flex w-full justify-between">
<Label
htmlFor={`${settingKey}-dynamic-dropdown`}
className="text-left text-sm font-medium"
className="text-left text-xs font-medium"
>
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}
{showDefault && (
@ -95,6 +95,7 @@ function DynamicDropdown({
setValue={handleChange}
availableValues={options}
containerClassName="w-full"
className="py-1.5"
id={`${settingKey}-dynamic-dropdown`}
placeholder={
placeholderCode

View file

@ -61,7 +61,7 @@ function DynamicInput({
<div className="flex w-full justify-between">
<Label
htmlFor={`${settingKey}-dynamic-input`}
className="text-left text-sm font-medium"
className="text-left text-xs font-medium"
>
{labelCode ? localize(label as TranslationKeys) || label : label || settingKey}{' '}
{showDefault && (
@ -82,7 +82,7 @@ function DynamicInput({
onChange={handleInputChange}
placeholder={placeholderText}
className={cn(
'flex h-10 max-h-10 w-full resize-none border-none bg-surface-secondary px-3 py-2',
'flex h-9 max-h-9 w-full resize-none rounded-lg border border-border-light bg-surface-secondary px-3 py-2',
)}
/>
</HoverCardTrigger>

View file

@ -160,7 +160,7 @@ function DynamicSlider({
<div className="flex w-full items-center justify-between">
<Label
htmlFor={`${settingKey}-dynamic-setting`}
className="break-words text-left text-sm font-medium"
className="break-words text-left text-xs font-medium"
>
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
{showDefault && (

View file

@ -50,7 +50,7 @@ function DynamicSwitch({
<div className="flex justify-between">
<Label
htmlFor={`${settingKey}-dynamic-switch`}
className="break-words text-left text-sm font-medium"
className="break-words text-left text-xs font-medium"
>
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
{showDefault && (

View file

@ -110,7 +110,7 @@ function DynamicTags({
<div className="flex w-full justify-between">
<Label
htmlFor={`${settingKey}-dynamic-input`}
className="text-left text-sm font-medium"
className="text-left text-xs font-medium"
>
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
{showDefault && (
@ -125,7 +125,7 @@ function DynamicTags({
</Label>
</div>
<div>
<div className="mb-2 flex flex-wrap break-all rounded-lg bg-surface-secondary">
<div className="mb-2 flex flex-wrap break-all rounded-lg border border-border-light bg-surface-secondary">
{currentTags && currentTags.length > 0 && (
<div className="flex w-full gap-1 p-1">
{currentTags.map((tag: string, index: number) => (
@ -165,7 +165,7 @@ function DynamicTags({
? (localize(placeholder as TranslationKeys) ?? placeholder)
: placeholder
}
className={cn('flex h-10 max-h-10 border-none bg-surface-secondary px-3 py-2')}
className={cn('flex h-9 max-h-9 border-none bg-surface-secondary px-3 py-2')}
/>
</div>
</div>

View file

@ -1,7 +1,7 @@
import { OptionTypes } from 'librechat-data-provider';
import { Label, TextareaAutosize, HoverCard, HoverCardTrigger } from '@librechat/client';
import type { DynamicSettingProps } from 'librechat-data-provider';
import { useLocalize, useDebouncedInput, useParameterEffects, TranslationKeys } from '~/hooks';
import { Label, TextareaAutosize, HoverCard, HoverCardTrigger } from '@librechat/client';
import { useChatContext } from '~/Providers';
import OptionHover from './OptionHover';
import { ESide } from '~/common';
@ -56,7 +56,7 @@ function DynamicTextarea({
<div className="flex w-full justify-between">
<Label
htmlFor={`${settingKey}-dynamic-textarea`}
className="text-left text-sm font-medium"
className="text-left text-xs font-medium"
>
{labelCode ? (localize(label as TranslationKeys) ?? label) : label || settingKey}{' '}
{showDefault && (
@ -82,8 +82,7 @@ function DynamicTextarea({
: placeholder
}
className={cn(
// TODO: configurable max height
'flex max-h-[138px] min-h-[100px] w-full resize-none rounded-lg bg-surface-secondary px-3 py-2 focus:outline-none',
'flex max-h-[138px] min-h-[100px] w-full resize-none rounded-lg border border-border-light bg-surface-secondary px-3 py-2 text-sm focus:outline-none',
)}
/>
</HoverCardTrigger>

View file

@ -142,7 +142,7 @@ export default function Parameters() {
}
return (
<div className="h-auto max-w-full overflow-x-hidden p-3">
<div className="h-auto max-w-full px-3 pb-3 pt-2">
<div className="grid grid-cols-2 gap-4">
{' '}
{/* This is the parent element containing all settings */}

View file

@ -1,194 +0,0 @@
import { useState, useCallback, useMemo, memo } from 'react';
import { getEndpointField } from 'librechat-data-provider';
import { useUserKeyQuery } from 'librechat-data-provider/react-query';
import { ResizableHandleAlt, ResizablePanel, useMediaQuery } from '@librechat/client';
import type { TEndpointsConfig, TInterfaceConfig } from 'librechat-data-provider';
import type { ImperativePanelHandle } from 'react-resizable-panels';
import useSideNavLinks from '~/hooks/Nav/useSideNavLinks';
import { useLocalStorage, useLocalize } from '~/hooks';
import { useGetEndpointsQuery } from '~/data-provider';
import NavToggle from '~/components/Nav/NavToggle';
import { useSidePanelContext } from '~/Providers';
import { cn } from '~/utils';
import Nav from './Nav';
const defaultMinSize = 20;
const SidePanel = ({
defaultSize,
panelRef,
navCollapsedSize = 3,
hasArtifacts,
minSize,
setMinSize,
collapsedSize,
setCollapsedSize,
isCollapsed,
setIsCollapsed,
fullCollapse,
setFullCollapse,
interfaceConfig,
}: {
defaultSize?: number;
hasArtifacts: boolean;
navCollapsedSize?: number;
minSize: number;
setMinSize: React.Dispatch<React.SetStateAction<number>>;
collapsedSize: number;
setCollapsedSize: React.Dispatch<React.SetStateAction<number>>;
isCollapsed: boolean;
setIsCollapsed: React.Dispatch<React.SetStateAction<boolean>>;
fullCollapse: boolean;
setFullCollapse: React.Dispatch<React.SetStateAction<boolean>>;
panelRef: React.RefObject<ImperativePanelHandle>;
interfaceConfig: TInterfaceConfig;
}) => {
const localize = useLocalize();
const { endpoint } = useSidePanelContext();
const [isHovering, setIsHovering] = useState(false);
const [newUser, setNewUser] = useLocalStorage('newUser', true);
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
const isSmallScreen = useMediaQuery('(max-width: 767px)');
const { data: keyExpiry = { expiresAt: undefined } } = useUserKeyQuery(endpoint ?? '');
const defaultActive = useMemo(() => {
const activePanel = localStorage.getItem('side:active-panel');
return typeof activePanel === 'string' ? activePanel : undefined;
}, []);
const endpointType = useMemo(
() => getEndpointField(endpointsConfig, endpoint, 'type'),
[endpoint, endpointsConfig],
);
const userProvidesKey = useMemo(
() => !!(endpointsConfig?.[endpoint ?? '']?.userProvide ?? false),
[endpointsConfig, endpoint],
);
const keyProvided = useMemo(
() => (userProvidesKey ? !!(keyExpiry.expiresAt ?? '') : true),
[keyExpiry.expiresAt, userProvidesKey],
);
const hidePanel = useCallback(() => {
setIsCollapsed(true);
setCollapsedSize(0);
setMinSize(defaultMinSize);
setFullCollapse(true);
localStorage.setItem('fullPanelCollapse', 'true');
panelRef.current?.collapse();
}, [panelRef, setMinSize, setIsCollapsed, setFullCollapse, setCollapsedSize]);
const Links = useSideNavLinks({
endpoint,
hidePanel,
keyProvided,
endpointType,
interfaceConfig,
endpointsConfig,
});
const toggleNavVisible = useCallback(() => {
if (newUser) {
setNewUser(false);
}
setIsCollapsed((prev: boolean) => {
if (prev) {
setMinSize(defaultMinSize);
setCollapsedSize(navCollapsedSize);
setFullCollapse(false);
localStorage.setItem('fullPanelCollapse', 'false');
}
return !prev;
});
if (!isCollapsed) {
panelRef.current?.collapse();
} else {
panelRef.current?.expand();
}
}, [
newUser,
panelRef,
setNewUser,
setMinSize,
isCollapsed,
setIsCollapsed,
setFullCollapse,
setCollapsedSize,
navCollapsedSize,
]);
return (
<>
<div
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
className="relative flex w-px items-center justify-center"
>
<NavToggle
navVisible={!isCollapsed}
isHovering={isHovering}
onToggle={toggleNavVisible}
setIsHovering={setIsHovering}
className={cn(
'fixed top-1/2',
(isCollapsed && (minSize === 0 || collapsedSize === 0)) || fullCollapse
? 'mr-9'
: 'mr-16',
)}
translateX={false}
side="right"
/>
</div>
{(!isCollapsed || minSize > 0) && !isSmallScreen && !fullCollapse && (
<ResizableHandleAlt withHandle className="bg-transparent text-text-primary" />
)}
<ResizablePanel
tagName="nav"
id="controls-nav"
order={hasArtifacts ? 3 : 2}
aria-label={localize('com_ui_controls')}
role="navigation"
collapsedSize={collapsedSize}
defaultSize={defaultSize}
collapsible={true}
minSize={minSize}
maxSize={40}
ref={panelRef}
style={{
overflowY: 'auto',
transition: 'width 0.2s ease, visibility 0s linear 0.2s',
}}
onExpand={() => {
if (isCollapsed && (fullCollapse || collapsedSize === 0)) {
return;
}
setIsCollapsed(false);
localStorage.setItem('react-resizable-panels:collapsed', 'false');
}}
onCollapse={() => {
setIsCollapsed(true);
localStorage.setItem('react-resizable-panels:collapsed', 'true');
}}
className={cn(
'sidenav hide-scrollbar border-l border-border-light bg-background py-1 transition-opacity',
isCollapsed ? 'min-w-[50px]' : 'min-w-[340px] sm:min-w-[352px]',
(isSmallScreen && isCollapsed && (minSize === 0 || collapsedSize === 0)) || fullCollapse
? 'hidden min-w-0'
: 'opacity-100',
)}
>
<Nav
resize={panelRef.current?.resize}
isCollapsed={isCollapsed}
defaultActive={defaultActive}
links={Links}
/>
</ResizablePanel>
</>
);
};
export default memo(SidePanel);

View file

@ -1,163 +1,70 @@
import { useState, useRef, useCallback, useEffect, useMemo, memo } from 'react';
import { useState, useEffect, useMemo, memo } from 'react';
import throttle from 'lodash/throttle';
import { useRecoilValue } from 'recoil';
import { getConfigDefaults } from 'librechat-data-provider';
import { ResizablePanel, ResizablePanelGroup, useMediaQuery } from '@librechat/client';
import type { ImperativePanelHandle } from 'react-resizable-panels';
import { useGetStartupConfig } from '~/data-provider';
import ArtifactsPanel from './ArtifactsPanel';
import { normalizeLayout, cn } from '~/utils';
import SidePanel from './SidePanel';
import store from '~/store';
import { normalizeLayout } from '~/utils';
interface SidePanelProps {
defaultLayout?: number[] | undefined;
defaultCollapsed?: boolean;
navCollapsedSize?: number;
fullPanelCollapse?: boolean;
artifacts?: React.ReactNode;
children: React.ReactNode;
}
const defaultMinSize = 20;
const defaultInterface = getConfigDefaults().interface;
const SidePanelGroup = memo(({ artifacts, children }: SidePanelProps) => {
const [shouldRenderArtifacts, setShouldRenderArtifacts] = useState(artifacts != null);
const isSmallScreen = useMediaQuery('(max-width: 767px)');
const SidePanelGroup = memo(
({
defaultLayout = [97, 3],
defaultCollapsed = false,
fullPanelCollapse = false,
navCollapsedSize = 3,
artifacts,
children,
}: SidePanelProps) => {
const { data: startupConfig } = useGetStartupConfig();
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],
);
const currentLayout = useMemo(() => {
if (artifacts == null) {
return [100];
}
return normalizeLayout([50, 50]);
}, [artifacts]);
const panelRef = useRef<ImperativePanelHandle>(null);
const [minSize, setMinSize] = useState(defaultMinSize);
const [isCollapsed, setIsCollapsed] = useState(defaultCollapsed);
const [fullCollapse, setFullCollapse] = useState(fullPanelCollapse);
const [collapsedSize, setCollapsedSize] = useState(navCollapsedSize);
const [shouldRenderArtifacts, setShouldRenderArtifacts] = useState(artifacts != null);
const throttledSaveLayout = useMemo(
() =>
throttle((sizes: number[]) => {
const normalizedSizes = normalizeLayout(sizes);
localStorage.setItem('react-resizable-panels:layout', JSON.stringify(normalizedSizes));
}, 350),
[],
);
const isSmallScreen = useMediaQuery('(max-width: 767px)');
const hideSidePanel = useRecoilValue(store.hideSidePanel);
useEffect(() => () => throttledSaveLayout.cancel(), [throttledSaveLayout]);
const calculateLayout = useCallback(() => {
if (artifacts == null) {
const navSize = defaultLayout.length === 2 ? defaultLayout[1] : defaultLayout[2];
return [100 - navSize, navSize];
} else {
const navSize = 0;
const remainingSpace = 100 - navSize;
const newMainSize = Math.floor(remainingSpace / 2);
const artifactsSize = remainingSpace - newMainSize;
return [newMainSize, artifactsSize, navSize];
}
}, [artifacts, defaultLayout]);
const minSizeMain = useMemo(() => (artifacts != null ? 15 : 30), [artifacts]);
const currentLayout = useMemo(() => normalizeLayout(calculateLayout()), [calculateLayout]);
const throttledSaveLayout = useMemo(
() =>
throttle((sizes: number[]) => {
const normalizedSizes = normalizeLayout(sizes);
localStorage.setItem('react-resizable-panels:layout', JSON.stringify(normalizedSizes));
}, 350),
[],
);
useEffect(() => {
if (isSmallScreen) {
setIsCollapsed(true);
setCollapsedSize(0);
setMinSize(defaultMinSize);
setFullCollapse(true);
localStorage.setItem('fullPanelCollapse', 'true');
panelRef.current?.collapse();
return;
} else {
setIsCollapsed(defaultCollapsed);
setCollapsedSize(navCollapsedSize);
setMinSize(defaultMinSize);
}
}, [isSmallScreen, defaultCollapsed, navCollapsedSize, fullPanelCollapse]);
const minSizeMain = useMemo(() => (artifacts != null ? 15 : 30), [artifacts]);
/** Memoized close button handler to prevent re-creating it */
const handleClosePanel = useCallback(() => {
setIsCollapsed(() => {
localStorage.setItem('fullPanelCollapse', 'true');
setFullCollapse(true);
setCollapsedSize(0);
setMinSize(0);
return false;
});
panelRef.current?.collapse();
}, []);
return (
<>
<ResizablePanelGroup
direction="horizontal"
onLayout={(sizes) => throttledSaveLayout(sizes)}
className="relative h-full w-full flex-1 overflow-auto bg-presentation"
return (
<>
<ResizablePanelGroup
direction="horizontal"
onLayout={(sizes) => throttledSaveLayout(sizes)}
className="relative h-full w-full flex-1 overflow-auto bg-presentation"
>
<ResizablePanel
defaultSize={currentLayout[0]}
minSize={minSizeMain}
order={1}
id="messages-view"
>
<ResizablePanel
defaultSize={currentLayout[0]}
minSize={minSizeMain}
order={1}
id="messages-view"
>
{children}
</ResizablePanel>
{children}
</ResizablePanel>
{!isSmallScreen && (
<ArtifactsPanel
artifacts={artifacts}
currentLayout={currentLayout}
minSizeMain={minSizeMain}
shouldRender={shouldRenderArtifacts}
onRenderChange={setShouldRenderArtifacts}
/>
)}
{!hideSidePanel && interfaceConfig.sidePanel === true && (
<SidePanel
panelRef={panelRef}
minSize={minSize}
setMinSize={setMinSize}
isCollapsed={isCollapsed}
setIsCollapsed={setIsCollapsed}
collapsedSize={collapsedSize}
setCollapsedSize={setCollapsedSize}
fullCollapse={fullCollapse}
setFullCollapse={setFullCollapse}
interfaceConfig={interfaceConfig}
hasArtifacts={shouldRenderArtifacts}
defaultSize={currentLayout[currentLayout.length - 1]}
/>
)}
</ResizablePanelGroup>
{artifacts != null && isSmallScreen && (
<div className="fixed inset-0 z-[100]">{artifacts}</div>
)}
{!hideSidePanel && interfaceConfig.sidePanel === true && (
<button
onClick={handleClosePanel}
aria-label="Close right side panel"
className={cn('sidenav-mask', !isCollapsed ? 'active' : '')}
{!isSmallScreen && (
<ArtifactsPanel
artifacts={artifacts}
currentLayout={currentLayout}
minSizeMain={minSizeMain}
shouldRender={shouldRenderArtifacts}
onRenderChange={setShouldRenderArtifacts}
/>
)}
</>
);
},
);
</ResizablePanelGroup>
{artifacts != null && isSmallScreen && (
<div className="fixed inset-0 z-[100]">{artifacts}</div>
)}
</>
);
});
SidePanelGroup.displayName = 'SidePanelGroup';

View file

@ -1,2 +1 @@
export { default as SidePanelGroup } from './SidePanelGroup';
export { default as SideNav } from './Nav';

View file

@ -0,0 +1,170 @@
import { useCallback, useEffect, useState, useMemo, memo, lazy, Suspense, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { useMediaQuery, NewChatIcon } from '@librechat/client';
import { PermissionTypes, Permissions, QueryKeys } from 'librechat-data-provider';
import type { InfiniteQueryObserverResult } from '@tanstack/react-query';
import type { ConversationListResponse } from 'librechat-data-provider';
import type { List } from 'react-virtualized';
import {
useLocalize,
useNewConvo,
useHasAccess,
useAuthContext,
useLocalStorage,
useNavScrolling,
} from '~/hooks';
import { useConversationsInfiniteQuery, useTitleGeneration } from '~/data-provider';
import { Conversations } from '~/components/Conversations';
import SearchBar from '~/components/Nav/SearchBar';
import { clearMessagesCache } from '~/utils';
import store from '~/store';
const BookmarkNav = lazy(() => import('~/components/Nav/Bookmarks/BookmarkNav'));
const ConversationsSection = memo(() => {
const localize = useLocalize();
const queryClient = useQueryClient();
const { newConversation } = useNewConvo();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const setSidebarExpanded = useSetRecoilState(store.sidebarExpanded);
const conversation = useRecoilValue(store.conversationByIndex(0));
const { isAuthenticated } = useAuthContext();
useTitleGeneration(isAuthenticated);
const [isChatsExpanded, setIsChatsExpanded] = useLocalStorage('chatsExpanded', true);
const [showLoading, setShowLoading] = useState(false);
const [tags, setTags] = useState<string[]>([]);
const hasAccessToBookmarks = useHasAccess({
permissionType: PermissionTypes.BOOKMARKS,
permission: Permissions.USE,
});
const search = useRecoilValue(store.search);
const { data, fetchNextPage, isFetchingNextPage, isLoading, isFetching } =
useConversationsInfiniteQuery(
{
tags: tags.length === 0 ? undefined : tags,
search: search.debouncedQuery || undefined,
},
{
enabled: isAuthenticated,
staleTime: 30000,
cacheTime: 300000,
},
);
const computedHasNextPage = useMemo(() => {
if (data?.pages && data.pages.length > 0) {
const lastPage: ConversationListResponse = data.pages[data.pages.length - 1];
return lastPage.nextCursor !== null;
}
return false;
}, [data?.pages]);
const conversationsRef = useRef<List | null>(null);
const { moveToTop } = useNavScrolling<ConversationListResponse>({
setShowLoading,
fetchNextPage: async (options?) => {
if (computedHasNextPage) {
return fetchNextPage(options);
}
return Promise.resolve({} as InfiniteQueryObserverResult<ConversationListResponse, unknown>);
},
isFetchingNext: isFetchingNextPage,
});
const conversations = useMemo(() => {
return data ? data.pages.flatMap((page) => page.conversations) : [];
}, [data]);
const toggleNav = useCallback(() => {
if (isSmallScreen) {
setSidebarExpanded(false);
}
}, [isSmallScreen, setSidebarExpanded]);
const loadMoreConversations = useCallback(() => {
if (isFetchingNextPage || !computedHasNextPage) {
return;
}
fetchNextPage();
}, [isFetchingNextPage, computedHasNextPage, fetchNextPage]);
const [isSearchLoading, setIsSearchLoading] = useState(
!!search.query && (search.isTyping || isLoading || isFetching),
);
useEffect(() => {
if (search.isTyping) {
setIsSearchLoading(true);
} else if (!isLoading && !isFetching) {
setIsSearchLoading(false);
} else if (!!search.query && (isLoading || isFetching)) {
setIsSearchLoading(true);
}
}, [search.query, search.isTyping, isLoading, isFetching]);
return (
<div
className="flex h-full min-h-0 flex-col overflow-hidden pb-3"
role="region"
aria-label={localize('com_ui_chat_history')}
>
<div className="flex items-center gap-0.5 px-3">
{hasAccessToBookmarks && (
<Suspense fallback={null}>
<BookmarkNav tags={tags} setTags={setTags} />
</Suspense>
)}
<SearchBar isSmallScreen={isSmallScreen} />
</div>
{isSmallScreen && (
<div
role="button"
tabIndex={0}
aria-label={localize('com_ui_new_chat')}
className="flex w-full cursor-pointer items-center rounded-lg px-2.5 py-2 text-sm text-text-primary outline-none hover:bg-surface-active-alt focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white"
onClick={() => {
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
setSidebarExpanded(false);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
setSidebarExpanded(false);
}
}}
>
<NewChatIcon className="mr-2 h-5 w-5" />
<span className="truncate">{localize('com_ui_new_chat')}</span>
</div>
)}
<div className="flex min-h-0 flex-grow flex-col overflow-hidden">
<Conversations
conversations={conversations}
moveToTop={moveToTop}
toggleNav={toggleNav}
containerRef={conversationsRef}
loadMoreConversations={loadMoreConversations}
isLoading={isFetchingNextPage || showLoading || isLoading}
isSearchLoading={isSearchLoading}
isChatsExpanded={isChatsExpanded}
setIsChatsExpanded={setIsChatsExpanded}
/>
</div>
</div>
);
});
ConversationsSection.displayName = 'ConversationsSection';
export default ConversationsSection;

View file

@ -0,0 +1,169 @@
import { memo, useCallback, lazy, Suspense } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useRecoilValue } from 'recoil';
import { QueryKeys } from 'librechat-data-provider';
import { Skeleton, Sidebar, Button, TooltipAnchor, NewChatIcon } from '@librechat/client';
import type { NavLink } from '~/common';
import { CLOSE_SIDEBAR_ID } from '~/components/Chat/Menus/OpenSidebar';
import { useActivePanel } from '~/Providers';
import { useLocalize, useNewConvo } from '~/hooks';
import { clearMessagesCache, cn } from '~/utils';
import store from '~/store';
const AccountSettings = lazy(() => import('~/components/Nav/AccountSettings'));
const NewChatButton = memo(function NewChatButton() {
const localize = useLocalize();
const queryClient = useQueryClient();
const { newConversation } = useNewConvo();
const conversation = useRecoilValue(store.conversationByIndex(0));
const handleClick = useCallback(
(e: React.MouseEvent<HTMLAnchorElement>) => {
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
return;
}
e.preventDefault();
clearMessagesCache(queryClient, conversation?.conversationId);
queryClient.invalidateQueries([QueryKeys.messages]);
newConversation();
},
[queryClient, conversation?.conversationId, newConversation],
);
return (
<TooltipAnchor
side="right"
description={localize('com_ui_new_chat')}
render={
<a
href="/c/new"
data-testid="new-chat-button"
aria-label={localize('com_ui_new_chat')}
className="flex h-9 w-9 items-center justify-center rounded-lg transition-colors hover:bg-surface-hover"
onClick={handleClick}
>
<div className="flex size-6 items-center justify-center rounded-full bg-text-primary">
<NewChatIcon className="size-3.5 text-white dark:text-black" />
</div>
</a>
}
/>
);
});
const NavIconButton = memo(function NavIconButton({
link,
isActive,
expanded,
setActive,
onExpand,
}: {
link: NavLink;
isActive: boolean;
expanded: boolean;
setActive: (id: string) => void;
onExpand?: () => void;
}) {
const localize = useLocalize();
const handleClick = useCallback(
(e: React.MouseEvent<HTMLButtonElement>) => {
if (link.onClick) {
link.onClick(e);
return;
}
if (!isActive) {
setActive(link.id);
}
if (!expanded) {
onExpand?.();
}
},
[link, isActive, setActive, expanded, onExpand],
);
return (
<TooltipAnchor
description={localize(link.title)}
side="right"
render={
<Button
size="icon"
variant="ghost"
aria-label={localize(link.title)}
aria-pressed={isActive}
className={cn(
'h-9 w-9 rounded-lg',
isActive ? 'bg-surface-active-alt text-text-primary' : 'text-text-secondary',
)}
onClick={handleClick}
>
<link.icon className="h-4 w-4" aria-hidden="true" />
</Button>
}
/>
);
});
function ExpandedPanel({
links,
expanded = true,
onCollapse,
onExpand,
}: {
links: NavLink[];
expanded?: boolean;
onCollapse?: () => void;
onExpand?: () => void;
}) {
const localize = useLocalize();
const { active, setActive } = useActivePanel();
const toggleLabel = expanded ? 'com_nav_close_sidebar' : 'com_nav_open_sidebar';
const toggleClick = expanded ? onCollapse : onExpand;
return (
<div className="flex h-full flex-shrink-0 flex-col gap-2 border-r border-border-light bg-surface-primary-alt px-2 py-2">
<TooltipAnchor
side="right"
description={localize(toggleLabel)}
render={
<Button
id={expanded ? CLOSE_SIDEBAR_ID : undefined}
data-testid={expanded ? 'close-sidebar-button' : 'open-sidebar-button'}
size="icon"
variant="ghost"
aria-label={localize(toggleLabel)}
aria-expanded={expanded}
className="h-9 w-9 rounded-lg"
onClick={toggleClick}
>
<Sidebar aria-hidden="true" className="h-5 w-5 text-text-primary" />
</Button>
}
/>
<NewChatButton />
<div className="flex flex-col gap-1 overflow-y-auto">
{links.map((link) => (
<NavIconButton
key={link.id}
link={link}
isActive={link.id === active}
expanded={expanded ?? true}
setActive={setActive}
onExpand={onExpand}
/>
))}
</div>
<div className="mt-auto">
<Suspense fallback={<Skeleton className="h-9 w-9 rounded-lg" />}>
<AccountSettings collapsed />
</Suspense>
</div>
</div>
);
}
export default memo(ExpandedPanel);

View file

@ -0,0 +1,65 @@
import { memo } from 'react';
import type { NavLink } from '~/common';
import SidePanelNav from '~/components/SidePanel/Nav';
import ExpandedPanel from './ExpandedPanel';
import { cn } from '~/utils';
function Sidebar({
links,
expanded,
onCollapse,
onExpand,
onResizeStart,
onResizeKeyboard,
}: {
links: NavLink[];
expanded: boolean;
onCollapse: () => void;
onExpand: () => void;
onResizeStart: (e: React.MouseEvent) => void;
onResizeKeyboard: (direction: 'shrink' | 'grow') => void;
}) {
return (
<>
<div className="flex h-full w-full overflow-hidden">
<ExpandedPanel
links={links}
expanded={expanded}
onCollapse={onCollapse}
onExpand={onExpand}
/>
<nav
className={cn(
'min-h-0 flex-1 overflow-hidden bg-surface-primary-alt',
expanded ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
style={{ transition: expanded ? 'opacity 200ms ease 80ms' : 'opacity 150ms ease' }}
aria-hidden={!expanded}
>
<SidePanelNav links={links} />
</nav>
</div>
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar"
tabIndex={expanded ? 0 : -1}
className={cn(
'absolute right-0 top-0 z-10 h-full w-1 cursor-col-resize transition-colors hover:bg-border-medium active:bg-border-heavy',
expanded ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
style={{ transition: expanded ? 'opacity 200ms ease 80ms' : 'opacity 150ms ease' }}
onMouseDown={onResizeStart}
onKeyDown={(e) => {
if (e.key === 'ArrowLeft') {
onResizeKeyboard('shrink');
} else if (e.key === 'ArrowRight') {
onResizeKeyboard('grow');
}
}}
/>
</>
);
}
export default memo(Sidebar);

View file

@ -0,0 +1,206 @@
import { useCallback, useState, useEffect, useRef, memo, startTransition } from 'react';
import type { ReactNode } from 'react';
import { useRecoilState } from 'recoil';
import { useForm } from 'react-hook-form';
import { useMediaQuery } from '@librechat/client';
import type { ChatFormValues } from '~/common';
import { ChatContext, ChatFormProvider, ActivePanelProvider } from '~/Providers';
import useUnifiedSidebarLinks from '~/hooks/Nav/useUnifiedSidebarLinks';
import { useChatHelpers, useLocalize } from '~/hooks';
import SidePanelNav from '~/components/SidePanel/Nav';
import ExpandedPanel from './ExpandedPanel';
import Sidebar from './Sidebar';
import { cn } from '~/utils';
import store from '~/store';
const COLLAPSED_WIDTH = 52;
const EXPANDED_MIN = 360;
const TRANSITION_MS = 300;
const EASING = 'cubic-bezier(0.2, 0, 0, 1)';
function getInitialWidth(): number {
const saved = localStorage.getItem('side:width');
return saved ? Math.max(Number(saved), EXPANDED_MIN) : EXPANDED_MIN;
}
/**
* Isolates useChatHelpers Recoil subscriptions from the sidebar layout.
* Atom changes (e.g. during streaming) only re-render this component
* and the active panel not the sidebar shell, resize logic, or icon strip.
* This works because Recoil subscriptions don't propagate to parent components.
*/
function SidebarChatProvider({ children }: { children: ReactNode }) {
const chatHelpers = useChatHelpers(0);
const sidebarFormMethods = useForm<ChatFormValues>({ defaultValues: { text: '' } });
return (
<ChatFormProvider {...sidebarFormMethods}>
<ChatContext.Provider value={chatHelpers}>{children}</ChatContext.Provider>
</ChatFormProvider>
);
}
function UnifiedSidebar() {
const localize = useLocalize();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const [expanded, setExpanded] = useRecoilState(store.sidebarExpanded);
const [sidebarWidth, setSidebarWidth] = useState(getInitialWidth);
const [isResizing, setIsResizing] = useState(false);
const resizeHandlers = useRef<{ move: (e: MouseEvent) => void; up: () => void } | null>(null);
const links = useUnifiedSidebarLinks();
const handleCollapse = useCallback(() => {
startTransition(() => {
setExpanded(false);
});
}, [setExpanded]);
const handleExpand = useCallback(() => {
startTransition(() => {
setExpanded(true);
});
}, [setExpanded]);
const handleResizeStart = useCallback(() => {
setIsResizing(true);
document.body.style.userSelect = 'none';
const maxWidth = window.innerWidth * 0.4;
let rafId: number | null = null;
const move = (e: MouseEvent) => {
if (rafId != null) {
return;
}
rafId = requestAnimationFrame(() => {
rafId = null;
const next = Math.max(EXPANDED_MIN, Math.min(e.clientX, maxWidth));
setSidebarWidth(next);
});
};
const up = () => {
if (rafId != null) {
cancelAnimationFrame(rafId);
rafId = null;
}
document.body.style.userSelect = '';
setIsResizing(false);
resizeHandlers.current = null;
setSidebarWidth((w) => {
localStorage.setItem('side:width', String(Math.round(w)));
return w;
});
document.removeEventListener('mousemove', move);
document.removeEventListener('mouseup', up);
};
resizeHandlers.current = { move, up };
document.addEventListener('mousemove', move);
document.addEventListener('mouseup', up);
}, []);
const handleResizeKeyboard = useCallback((direction: 'shrink' | 'grow') => {
setSidebarWidth((w) => {
const next =
direction === 'shrink'
? Math.max(w - 20, EXPANDED_MIN)
: Math.min(w + 20, window.innerWidth * 0.4);
localStorage.setItem('side:width', String(Math.round(next)));
return next;
});
}, []);
useEffect(() => {
return () => {
if (resizeHandlers.current) {
document.removeEventListener('mousemove', resizeHandlers.current.move);
document.removeEventListener('mouseup', resizeHandlers.current.up);
}
};
}, []);
useEffect(() => {
if (!isSmallScreen || !expanded) {
return;
}
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
handleCollapse();
}
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [isSmallScreen, expanded, handleCollapse]);
if (isSmallScreen) {
return (
<>
<div
className={cn(
'fixed left-0 top-0 z-[110] flex h-full bg-surface-primary-alt',
expanded ? 'translate-x-0' : '-translate-x-full',
)}
style={{
width: 'min(85vw, 380px)',
transition: `transform ${TRANSITION_MS}ms ${EASING}`,
}}
{...{ inert: !expanded ? '' : undefined }}
>
<SidebarChatProvider>
<ActivePanelProvider>
<ExpandedPanel links={links} onCollapse={handleCollapse} />
<nav className="min-h-0 flex-1 overflow-hidden bg-surface-primary-alt">
<SidePanelNav links={links} />
</nav>
</ActivePanelProvider>
</SidebarChatProvider>
</div>
<div
className={cn(
'fixed inset-0 z-[109] bg-black/50',
expanded ? 'pointer-events-auto opacity-100' : 'pointer-events-none opacity-0',
)}
style={{ transition: `opacity ${TRANSITION_MS}ms ${EASING}` }}
role="presentation"
>
<button
className="h-full w-full"
onClick={handleCollapse}
aria-label={localize('com_nav_close_sidebar')}
tabIndex={expanded ? 0 : -1}
/>
</div>
</>
);
}
return (
<SidebarChatProvider>
<ActivePanelProvider>
<aside
className="relative flex h-full flex-shrink-0 overflow-hidden"
style={{
width: expanded ? sidebarWidth : COLLAPSED_WIDTH,
minWidth: expanded ? EXPANDED_MIN : COLLAPSED_WIDTH,
maxWidth: expanded ? '40%' : COLLAPSED_WIDTH,
transition: isResizing
? 'none'
: `width ${TRANSITION_MS}ms ${EASING}, min-width ${TRANSITION_MS}ms ${EASING}, max-width ${TRANSITION_MS}ms ${EASING}`,
}}
aria-label={localize('com_nav_control_panel')}
>
<Sidebar
links={links}
expanded={expanded}
onCollapse={handleCollapse}
onExpand={handleExpand}
onResizeStart={handleResizeStart}
onResizeKeyboard={handleResizeKeyboard}
/>
</aside>
</ActivePanelProvider>
</SidebarChatProvider>
);
}
export default memo(UnifiedSidebar);

View file

@ -0,0 +1,2 @@
export { default as UnifiedSidebar } from './UnifiedSidebar';
export { default as ConversationsSection } from './ConversationsSection';

View file

@ -3,6 +3,7 @@ import { useRecoilValue, useSetRecoilState } from 'recoil';
import { replaceSpecialVars } from 'librechat-data-provider';
import { useChatContext, useChatFormContext, useAddedChatContext } from '~/Providers';
import { useAuthContext } from '~/hooks/AuthContext';
import { mainTextareaId } from '~/common';
import store from '~/store';
export default function useSubmitMessage() {
@ -49,7 +50,8 @@ export default function useSubmitMessage() {
return;
}
const currentText = methods.getValues('text');
const textarea = document.getElementById(mainTextareaId) as HTMLTextAreaElement | null;
const currentText = textarea?.value ?? methods.getValues('text');
const newText = currentText.trim().length > 1 ? `\n${parsedText}` : parsedText;
setActivePrompt(newText);
},

View file

@ -28,13 +28,15 @@ export default function useSideNavLinks({
endpointType,
interfaceConfig,
endpointsConfig,
includeHidePanel = true,
}: {
hidePanel: () => void;
hidePanel?: () => void;
keyProvided: boolean;
endpoint?: EModelEndpoint | null;
endpointType?: EModelEndpoint | null;
interfaceConfig: Partial<TInterfaceConfig>;
endpointsConfig: TEndpointsConfig;
includeHidePanel?: boolean;
}) {
const hasAccessToPrompts = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
@ -172,13 +174,15 @@ export default function useSideNavLinks({
});
}
links.push({
title: 'com_sidepanel_hide_panel',
label: '',
icon: ArrowRightToLine,
onClick: hidePanel,
id: 'hide-panel',
});
if (includeHidePanel && hidePanel) {
links.push({
title: 'com_sidepanel_hide_panel',
label: '',
icon: ArrowRightToLine,
onClick: hidePanel,
id: 'hide-panel',
});
}
return links;
}, [
@ -196,6 +200,7 @@ export default function useSideNavLinks({
availableMCPServers,
hasAccessToUseMCPSettings,
hasAccessToCreateMCP,
includeHidePanel,
hidePanel,
]);

View file

@ -0,0 +1,65 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { MessageSquare } from 'lucide-react';
import { useUserKeyQuery } from 'librechat-data-provider/react-query';
import { getConfigDefaults, getEndpointField } from 'librechat-data-provider';
import type { TEndpointsConfig } from 'librechat-data-provider';
import type { NavLink } from '~/common';
import ConversationsSection from '~/components/UnifiedSidebar/ConversationsSection';
import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
import useSideNavLinks from '~/hooks/Nav/useSideNavLinks';
import store from '~/store';
const defaultInterface = getConfigDefaults().interface;
export default function useUnifiedSidebarLinks() {
const conversation = useRecoilValue(store.conversationByIndex(0));
const endpoint = conversation?.endpoint;
const { data: startupConfig } = useGetStartupConfig();
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],
);
const endpointType = useMemo(
() => getEndpointField(endpointsConfig, endpoint, 'type'),
[endpoint, endpointsConfig],
);
const userProvidesKey = useMemo(
() => !!(endpointsConfig?.[endpoint ?? '']?.userProvide ?? false),
[endpointsConfig, endpoint],
);
const { data: keyExpiry = { expiresAt: undefined } } = useUserKeyQuery(endpoint ?? '');
const keyProvided = useMemo(
() => (userProvidesKey ? !!(keyExpiry.expiresAt ?? '') : true),
[keyExpiry.expiresAt, userProvidesKey],
);
const sideNavLinks = useSideNavLinks({
keyProvided,
endpoint,
endpointType,
interfaceConfig,
endpointsConfig,
includeHidePanel: false,
});
const links = useMemo(() => {
const conversationLink: NavLink = {
title: 'com_ui_chat_history',
label: '',
icon: MessageSquare,
id: 'conversations',
Component: ConversationsSection,
};
return [conversationLink, ...sideNavLinks];
}, [sideNavLinks]);
return links;
}

View file

@ -486,7 +486,6 @@
"com_nav_font_size_xl": "Extra Large",
"com_nav_font_size_xs": "Extra Small",
"com_nav_help_faq": "Help & FAQ",
"com_nav_hide_panel": "Hide right-most side panel",
"com_nav_info_balance": "Balance shows how many token credits you have left to use. Token credits translate to monetary value (e.g., 1000 credits = $0.001 USD)",
"com_nav_info_code_artifacts": "Enables the display of experimental code artifacts next to the chat",
"com_nav_info_code_artifacts_agent": "Enables the use of code artifacts for this agent. By default, additional instructions specific to the use of artifacts are added, unless \"Custom Prompt Mode\" is enabled.",
@ -852,7 +851,6 @@
"com_ui_continue": "Continue",
"com_ui_continue_oauth": "Continue with OAuth",
"com_ui_control_bar": "Control bar",
"com_ui_controls": "Controls",
"com_ui_conversation": "conversation",
"com_ui_conversation_label": "{{title}} conversation",
"com_ui_conversation_not_found": "Conversation not found",

View file

@ -131,46 +131,6 @@
width: 100px;
}
@media (max-width: 767px) {
.sidenav {
/* width: calc(100% - 10px) ; */
transition: all 0.15s;
position: fixed;
z-index: 66;
top: 0;
max-width: 320px;
/* max-width: 260px; */
bottom: 0;
right: 0
/* opacity: 0; */
}
.sidenav-mask {
position: fixed;
z-index: 65;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(7, 7, 7, 0.4);
padding-left: 420px;
padding-top: 12px;
opacity: 0;
transition: all 0.5s;
pointer-events: none;
}
.sidenav-mask.active {
opacity: 1;
pointer-events: auto;
}
.sidenav.active {
position: fixed;
}
}
@keyframes tuning {
0% { transform: rotate(30deg); }
25% { transform: rotate(110deg); }

View file

@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useState, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { Outlet } from 'react-router-dom';
import { useMediaQuery } from '@librechat/client';
import type { ContextType } from '~/common';
import {
useSearchEnabled,
useAssistantsMap,
@ -9,6 +9,7 @@ import {
useAgentsMap,
useFileMap,
} from '~/hooks';
import store from '~/store';
import {
PromptGroupsProvider,
AssistantsMapContext,
@ -17,7 +18,7 @@ import {
FileMapContext,
} from '~/Providers';
import { useUserTermsQuery, useGetStartupConfig } from '~/data-provider';
import { Nav, MobileNav, NAV_WIDTH } from '~/components/Nav';
import { UnifiedSidebar } from '~/components/UnifiedSidebar';
import { TermsAndConditionsModal } from '~/components/ui';
import { useHealthCheck } from '~/data-provider';
import { Banner } from '~/components/Banners';
@ -25,15 +26,11 @@ import { Banner } from '~/components/Banners';
export default function Root() {
const [showTerms, setShowTerms] = useState(false);
const [bannerHeight, setBannerHeight] = useState(0);
const [navVisible, setNavVisible] = useState(() => {
const savedNavVisible = localStorage.getItem('navVisible');
return savedNavVisible !== null ? JSON.parse(savedNavVisible) : true;
});
const { isAuthenticated, logout } = useAuthContext();
const sidebarExpanded = useRecoilValue(store.sidebarExpanded);
const isSmallScreen = useMediaQuery('(max-width: 768px)');
// Global health check - runs once per authenticated session
const { isAuthenticated, logout } = useAuthContext();
useHealthCheck(isAuthenticated);
const assistantsMap = useAssistantsMap({ isAuthenticated });
@ -75,23 +72,17 @@ export default function Root() {
<Banner onHeightChange={setBannerHeight} />
<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} />
<UnifiedSidebar />
<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
}
{...{ inert: navVisible && isSmallScreen ? '' : undefined }}
style={{
transform:
isSmallScreen && sidebarExpanded ? 'translateX(min(85vw, 380px))' : 'none',
transition: 'transform 300ms cubic-bezier(0.2, 0, 0, 1)',
}}
{...{ inert: isSmallScreen && sidebarExpanded ? '' : undefined }}
>
<MobileNav navVisible={navVisible} setNavVisible={setNavVisible} />
<Outlet context={{ navVisible, setNavVisible } satisfies ContextType} />
<Outlet />
</div>
</div>
</div>

View file

@ -17,7 +17,10 @@ const staticAtoms = {
const localStorageAtoms = {
// General settings
autoScroll: atomWithLocalStorage('autoScroll', false),
hideSidePanel: atomWithLocalStorage('hideSidePanel', false),
sidebarExpanded: atomWithLocalStorage(
'unifiedSidebarExpanded',
typeof window !== 'undefined' && window.matchMedia('(max-width: 768px)').matches ? false : true,
),
enableUserMsgMarkdown: atomWithLocalStorage<boolean>(
LocalStorageKeys.ENABLE_USER_MSG_MARKDOWN,
true,

View file

@ -108,7 +108,7 @@ function ControlCombobox({
'flex items-center justify-center gap-2 rounded-full bg-surface-secondary',
'text-text-primary hover:bg-surface-tertiary',
'border border-border-light',
isCollapsed ? 'h-10 w-10' : 'h-10 w-full rounded-xl px-3 py-2 text-sm',
isCollapsed ? 'h-9 w-9' : 'h-9 w-full rounded-xl px-3 py-2 text-sm',
className,
)}
>

View file

@ -33,7 +33,7 @@ const FilterInput = React.forwardRef<HTMLInputElement, FilterInputProps>(
placeholder=" "
aria-label={label}
className={cn(
'peer 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',
'peer flex h-9 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,
)}
{...props}

View file

@ -1,6 +1,6 @@
import DOMPurify from 'dompurify';
import * as Ariakit from '@ariakit/react';
import { forwardRef, useId, useMemo } from 'react';
import { memo, forwardRef, useCallback, useMemo } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { cn } from '~/utils';
import './Tooltip.css';
@ -13,13 +13,21 @@ interface TooltipAnchorProps extends Ariakit.TooltipAnchorProps {
side?: 'top' | 'bottom' | 'left' | 'right';
}
export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(function TooltipAnchor(
{ description, side = 'top', className, role, enableHTML = false, ...props },
ref,
) {
const tooltip = Ariakit.useTooltipStore({ placement: side });
const mounted = Ariakit.useStoreState(tooltip, (state) => state.mounted);
const placement = Ariakit.useStoreState(tooltip, (state) => state.placement);
/**
* Isolated component that subscribes to tooltip store state independently,
* so the anchor element never re-renders when the tooltip mounts/unmounts.
*/
const TooltipPopup = memo(function TooltipPopup({
store,
description,
enableHTML,
}: {
store: Ariakit.TooltipStore;
description: string;
enableHTML: boolean;
}) {
const mounted = Ariakit.useStoreState(store, (state) => state.mounted);
const placement = Ariakit.useStoreState(store, (state) => state.placement);
const sanitizer = useMemo(() => {
const instance = DOMPurify();
@ -65,12 +73,52 @@ export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(func
}
}, [placement]);
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (role === 'button' && event.key === 'Enter') {
event.preventDefault();
(event.target as HTMLDivElement).click();
}
};
return (
<AnimatePresence>
{mounted === true && (
<Ariakit.Tooltip
gutter={4}
alwaysVisible
className="tooltip"
render={
<motion.div
initial={{ opacity: 0, x, y }}
animate={{ opacity: 1, x: 0, y: 0 }}
exit={{ opacity: 0, x, y }}
/>
}
>
<Ariakit.TooltipArrow />
{enableHTML ? (
<div
dangerouslySetInnerHTML={{
__html: sanitizedHTML,
}}
/>
) : (
description
)}
</Ariakit.Tooltip>
)}
</AnimatePresence>
);
});
export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(function TooltipAnchor(
{ description, side = 'top', className, role, enableHTML = false, ...props },
ref,
) {
const tooltip = Ariakit.useTooltipStore({ placement: side });
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (role === 'button' && event.key === 'Enter') {
event.preventDefault();
(event.target as HTMLDivElement).click();
}
},
[role],
);
return (
<Ariakit.TooltipProvider store={tooltip} hideTimeout={0}>
@ -81,33 +129,7 @@ export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(func
onKeyDown={handleKeyDown}
className={cn('cursor-pointer', className)}
/>
<AnimatePresence>
{mounted === true && (
<Ariakit.Tooltip
gutter={4}
alwaysVisible
className="tooltip"
render={
<motion.div
initial={{ opacity: 0, x, y }}
animate={{ opacity: 1, x: 0, y: 0 }}
exit={{ opacity: 0, x, y }}
/>
}
>
<Ariakit.TooltipArrow />
{enableHTML ? (
<div
dangerouslySetInnerHTML={{
__html: sanitizedHTML,
}}
/>
) : (
description
)}
</Ariakit.Tooltip>
)}
</AnimatePresence>
<TooltipPopup store={tooltip} description={description} enableHTML={enableHTML} />
</Ariakit.TooltipProvider>
);
});

View file

@ -2,14 +2,13 @@ import { cn } from '~/utils';
export default function NewChatIcon({ className = '' }: { className?: string }) {
return (
<svg
width="24"
height="24"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={cn('text-black dark:text-white', className)}
aria-hidden="true"
aria-hidden="true"
>
<path
fillRule="evenodd"