mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-24 08:36:33 +01:00
🎨 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:
parent
04e65bb21a
commit
733a9364c0
88 changed files with 1310 additions and 1593 deletions
|
|
@ -1,2 +1,3 @@
|
|||
#!/bin/sh
|
||||
[ -n "$CI" ] && exit 0
|
||||
npx lint-staged --config ./.husky/lint-staged.config.js
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
60
client/src/Providers/__tests__/ActivePanelContext.spec.tsx
Normal file
60
client/src/Providers/__tests__/ActivePanelContext.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
31
client/src/Providers/__tests__/ChatContext.spec.tsx
Normal file
31
client/src/Providers/__tests__/ChatContext.spec.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,2 @@
|
|||
export { default as PresetsMenu } from './PresetsMenu';
|
||||
export { default as OpenSidebar } from './OpenSidebar';
|
||||
export { default as HeaderNewChat } from './HeaderNewChat';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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' : '',
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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" />}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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" />}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +1 @@
|
|||
export { default as SidePanelGroup } from './SidePanelGroup';
|
||||
export { default as SideNav } from './Nav';
|
||||
|
|
|
|||
170
client/src/components/UnifiedSidebar/ConversationsSection.tsx
Normal file
170
client/src/components/UnifiedSidebar/ConversationsSection.tsx
Normal 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;
|
||||
169
client/src/components/UnifiedSidebar/ExpandedPanel.tsx
Normal file
169
client/src/components/UnifiedSidebar/ExpandedPanel.tsx
Normal 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);
|
||||
65
client/src/components/UnifiedSidebar/Sidebar.tsx
Normal file
65
client/src/components/UnifiedSidebar/Sidebar.tsx
Normal 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);
|
||||
206
client/src/components/UnifiedSidebar/UnifiedSidebar.tsx
Normal file
206
client/src/components/UnifiedSidebar/UnifiedSidebar.tsx
Normal 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);
|
||||
2
client/src/components/UnifiedSidebar/index.ts
Normal file
2
client/src/components/UnifiedSidebar/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as UnifiedSidebar } from './UnifiedSidebar';
|
||||
export { default as ConversationsSection } from './ConversationsSection';
|
||||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
|||
65
client/src/hooks/Nav/useUnifiedSidebarLinks.ts
Normal file
65
client/src/hooks/Nav/useUnifiedSidebarLinks.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue