🎨 style: Theming and Consistency Improvements for Agent Marketplace

style: AccessRolesPicker to use DropdownPopup, theming, import order, localization

refactor: Update localization keys for Agent Marketplace in NewChat component, remove duplicate key

style: Adjust layout and font size in NewChat component for Agent Marketplace button

style: theming in AgentGrid

style: Update theming and text colors across Agent components for improved consistency

chore: import order

style: Replace Dialog with OGDialog and update content components in AgentDetail

refactor: Simplify AgentDetail component by removing dropdown menu and replacing it with a copy link button

style: Enhance scrollbar visibility and layout in AgentMarketplace and CategoryTabs components

style: Adjust layout in AgentMarketplace component by removing unnecessary padding from the container

style: Refactor layout in AgentMarketplace component by reorganizing hero section and sticky wrapper for improved structure with collapsible header effect

style: Improve responsiveness and layout in AgentMarketplace component by adjusting header visibility and modifying container styles based on screen size

fix: Update localization key for no categories message in CategoryTabs component and corresponding test

style: Add className prop to OpenSidebar component for improved styling flexibility and update Header to utilize it for responsive design

style: Enhance layout and scrolling behavior in CategoryTabs component by adding scroll snap properties and adjusting class names for improved user experience

style: Update AgentGrid component layout and skeleton structure for improved visual consistency and responsiveness
This commit is contained in:
Danny Avila 2025-07-24 18:41:28 -04:00
parent 949682ef0f
commit 75324e1c7e
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
13 changed files with 331 additions and 298 deletions

View file

@ -48,7 +48,7 @@ export default function Header() {
: 'pointer-events-none translate-x-[-100px] opacity-0' : 'pointer-events-none translate-x-[-100px] opacity-0'
}`} }`}
> >
<OpenSidebar setNavVisible={setNavVisible} /> <OpenSidebar setNavVisible={setNavVisible} className="max-md:hidden" />
<HeaderNewChat /> <HeaderNewChat />
</div> </div>
<div <div

View file

@ -1,10 +1,13 @@
import { TooltipAnchor, Button, Sidebar } from '@librechat/client'; import { TooltipAnchor, Button, Sidebar } from '@librechat/client';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function OpenSidebar({ export default function OpenSidebar({
setNavVisible, setNavVisible,
className,
}: { }: {
setNavVisible: React.Dispatch<React.SetStateAction<boolean>>; setNavVisible: React.Dispatch<React.SetStateAction<boolean>>;
className?: string;
}) { }) {
const localize = useLocalize(); const localize = useLocalize();
return ( return (
@ -16,7 +19,10 @@ export default function OpenSidebar({
variant="outline" variant="outline"
data-testid="open-sidebar-button" data-testid="open-sidebar-button"
aria-label={localize('com_nav_open_sidebar')} aria-label={localize('com_nav_open_sidebar')}
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover" className={cn(
'rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover',
className,
)}
onClick={() => onClick={() =>
setNavVisible((prev) => { setNavVisible((prev) => {
localStorage.setItem('navVisible', JSON.stringify(!prev)); localStorage.setItem('navVisible', JSON.stringify(!prev));

View file

@ -113,20 +113,20 @@ export default function NewChat({
{/* Agent Marketplace button - separate row like ChatGPT */} {/* Agent Marketplace button - separate row like ChatGPT */}
{showAgentMarketplace && ( {showAgentMarketplace && (
<div className="flex px-2 pb-4 pt-2 md:px-3"> <div className="flex">
<TooltipAnchor <TooltipAnchor
description={localize('com_nav_agents_marketplace')} description={localize('com_agents_marketplace')}
render={ render={
<Button <Button
variant="outline" variant="outline"
data-testid="nav-agents-marketplace-button" data-testid="nav-agents-marketplace-button"
aria-label={localize('com_nav_agents_marketplace')} aria-label={localize('com_agents_marketplace')}
className="flex w-full items-center justify-start gap-3 rounded-xl border-none bg-transparent p-3 text-left hover:bg-surface-hover" className="flex w-full items-center justify-start gap-3 rounded-xl border-none bg-transparent p-3 text-left hover:bg-surface-hover"
onClick={handleAgentMarketplace} onClick={handleAgentMarketplace}
> >
<LayoutGrid className="h-5 w-5 flex-shrink-0" /> <LayoutGrid className="h-5 w-5 flex-shrink-0" />
<span className="truncate text-base font-medium"> <span className="truncate text-sm font-medium">
{localize('com_nav_agents_marketplace')} {localize('com_agents_marketplace')}
</span> </span>
</Button> </Button>
} }

View file

@ -24,7 +24,7 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
'group relative flex overflow-hidden rounded-2xl', 'group relative flex overflow-hidden rounded-2xl',
'cursor-pointer transition-colors duration-200', 'cursor-pointer transition-colors duration-200',
'aspect-[5/2.5] w-full', 'aspect-[5/2.5] w-full',
'bg-gray-50 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600', 'bg-surface-tertiary hover:bg-surface-hover-alt',
className, className,
)} )}
onClick={onClick} onClick={onClick}
@ -51,7 +51,7 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
{/* Agent info section - right side, responsive */} {/* Agent info section - right side, responsive */}
<div className="flex min-w-0 flex-1 flex-col justify-center"> <div className="flex min-w-0 flex-1 flex-col justify-center">
{/* Agent name - responsive text sizing */} {/* Agent name - responsive text sizing */}
<h3 className="mb-1 line-clamp-1 text-base font-bold text-gray-900 dark:text-white sm:mb-2 sm:text-lg"> <h3 className="mb-1 line-clamp-1 text-base font-bold text-text-primary sm:mb-2 sm:text-lg">
{agent.name} {agent.name}
</h3> </h3>
@ -59,13 +59,15 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
<p <p
id={`agent-${agent.id}-description`} id={`agent-${agent.id}-description`}
className={cn( className={cn(
'mb-1 line-clamp-2 text-xs leading-relaxed text-gray-600 dark:text-gray-300', 'mb-1 line-clamp-2 text-xs leading-relaxed text-text-secondary',
'sm:mb-2 sm:text-sm', 'sm:mb-2 sm:text-sm',
)} )}
aria-label={`Description: ${agent.description || localize('com_agents_no_description')}`} aria-label={`Description: ${agent.description || localize('com_agents_no_description')}`}
> >
{agent.description || ( {agent.description || (
<span className="italic text-gray-400">{localize('com_agents_no_description')}</span> <span className="italic text-text-secondary">
{localize('com_agents_no_description')}
</span>
)} )}
</p> </p>
@ -75,7 +77,7 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
if (displayName) { if (displayName) {
return ( return (
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 sm:text-sm"> <div className="flex items-center text-xs text-text-tertiary sm:text-sm">
<span className="font-light">{localize('com_agents_created_by')}</span> <span className="font-light">{localize('com_agents_created_by')}</span>
<span className="ml-1 font-bold">{displayName}</span> <span className="ml-1 font-bold">{displayName}</span>
</div> </div>

View file

@ -1,6 +1,7 @@
import React, { useRef, useState, useEffect } from 'react'; import React, { useRef } from 'react';
import { Link } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { Dialog, DialogContent, Button, DotsIcon, useToastContext } from '@librechat/client'; import { OGDialog, OGDialogContent, Button, useToastContext } from '@librechat/client';
import { import {
QueryKeys, QueryKeys,
Constants, Constants,
@ -37,26 +38,7 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
const { conversation, newConversation } = useChatContext(); const { conversation, newConversation } = useChatContext();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const dialogRef = useRef<HTMLDivElement>(null); const dialogRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const [dropdownOpen, setDropdownOpen] = useState(false);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
// Close dropdown when clicking outside the dropdown menu
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownOpen &&
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setDropdownOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [dropdownOpen]);
/** /**
* Navigate to chat with the selected agent * Navigate to chat with the selected agent
@ -143,63 +125,46 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
}; };
return ( return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}> <OGDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent ref={dialogRef} className="max-h-[90vh] overflow-y-auto py-8 sm:max-w-[450px]"> <OGDialogContent
{/* Context menu - top right */} ref={dialogRef}
<div ref={dropdownRef} className="absolute right-12 top-5 z-50"> className="max-h-[90vh] overflow-y-auto py-8 sm:max-w-[450px]"
<Button >
variant="ghost" {/* Copy link button - positioned next to close button */}
size="icon" <Button
className="h-8 w-8 rounded-lg text-text-secondary hover:bg-surface-hover hover:text-text-primary dark:hover:bg-surface-hover" variant="ghost"
aria-label={localize('com_agents_more_options')} size="icon"
aria-expanded={dropdownOpen} className="absolute right-11 top-4 h-4 w-4 rounded-sm p-0 opacity-70 ring-ring-primary ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-haspopup="menu" aria-label={localize('com_agents_copy_link')}
onClick={(e) => { onClick={handleCopyLink}
e.stopPropagation(); title={localize('com_agents_copy_link')}
setDropdownOpen(!dropdownOpen); >
}} <Link />
> </Button>
<DotsIcon className="h-4 w-4" />
</Button>
{/* Simple dropdown menu */}
{dropdownOpen && (
<div className="absolute right-0 top-10 z-[9999] w-48 rounded-xl border border-border-light bg-surface-primary py-1 shadow-lg dark:bg-surface-secondary dark:shadow-2xl">
<button
onClick={(e) => {
e.stopPropagation();
setDropdownOpen(false);
handleCopyLink();
}}
className="w-full px-3 py-2 text-left text-sm text-text-primary transition-colors hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
>
{localize('com_agents_copy_link')}
</button>
</div>
)}
</div>
{/* Agent avatar - top center */} {/* Agent avatar - top center */}
<div className="mt-6 flex justify-center">{renderAgentAvatar(agent, { size: 'xl' })}</div> <div className="mt-6 flex justify-center">{renderAgentAvatar(agent, { size: 'xl' })}</div>
{/* Agent name - center aligned below image */} {/* Agent name - center aligned below image */}
<div className="mt-3 text-center"> <div className="mt-3 text-center">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white"> <h2 className="text-2xl font-bold text-text-primary">
{agent?.name || localize('com_agents_loading')} {agent?.name || localize('com_agents_loading')}
</h2> </h2>
</div> </div>
{/* Contact info - center aligned below name */} {/* Contact info - center aligned below name */}
{agent?.support_contact && formatContact() && ( {agent?.support_contact && formatContact() && (
<div className="mt-1 text-center text-sm text-gray-600 dark:text-gray-400"> <div className="mt-1 text-center text-sm text-text-secondary">
{localize('com_agents_contact')}: {formatContact()} {localize('com_agents_contact')}: {formatContact()}
</div> </div>
)} )}
{/* Agent description - below contact */} {/* Agent description - below contact */}
<div className="mt-4 whitespace-pre-wrap px-6 text-center text-base text-gray-700 dark:text-gray-300"> <div className="mt-4 whitespace-pre-wrap px-6 text-center text-base text-text-primary">
{agent?.description || ( {agent?.description || (
<span className="italic text-gray-400">{localize('com_agents_no_description')}</span> <span className="italic text-text-tertiary">
{localize('com_agents_no_description')}
</span>
)} )}
</div> </div>
@ -209,8 +174,8 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
{localize('com_agents_start_chat')} {localize('com_agents_start_chat')}
</Button> </Button>
</div> </div>
</DialogContent> </OGDialogContent>
</Dialog> </OGDialog>
); );
}; };

View file

@ -3,8 +3,7 @@ import { Button, Spinner } from '@librechat/client';
import { PERMISSION_BITS } from 'librechat-data-provider'; import { PERMISSION_BITS } from 'librechat-data-provider';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents'; import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
import { useAgentCategories } from '~/hooks/Agents'; import { useAgentCategories, useLocalize } from '~/hooks';
import useLocalize from '~/hooks/useLocalize';
import { useHasData } from './SmartLoader'; import { useHasData } from './SmartLoader';
import ErrorDisplay from './ErrorDisplay'; import ErrorDisplay from './ErrorDisplay';
import AgentCard from './AgentCard'; import AgentCard from './AgentCard';
@ -124,8 +123,8 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
const loadingSkeleton = ( const loadingSkeleton = (
<div className="space-y-6"> <div className="space-y-6">
<div className="mb-4"> <div className="mb-4">
<div className="mb-2 h-6 w-48 animate-pulse rounded-md bg-gray-200 dark:bg-gray-700"></div> <div className="mb-2 h-6 w-48 animate-pulse rounded-md bg-surface-tertiary"></div>
<div className="h-4 w-64 animate-pulse rounded-md bg-gray-200 dark:bg-gray-700"></div> <div className="h-4 w-64 animate-pulse rounded-md bg-surface-tertiary"></div>
</div> </div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{Array(6) {Array(6)
@ -134,15 +133,21 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
<div <div
key={index} key={index}
className={cn( className={cn(
'flex h-[250px] animate-pulse flex-col overflow-hidden rounded-lg', 'flex animate-pulse overflow-hidden rounded-2xl',
'bg-gray-200 dark:bg-gray-800', 'aspect-[5/2.5] w-full',
'bg-surface-tertiary',
)} )}
> >
<div className="h-40 bg-gray-300 dark:bg-gray-700"></div> <div className="flex h-full gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3">
<div className="flex-1 p-5"> {/* Avatar skeleton */}
<div className="mb-3 h-4 w-3/4 rounded bg-gray-300 dark:bg-gray-700"></div> <div className="flex flex-shrink-0 items-center">
<div className="mb-2 h-3 w-full rounded bg-gray-300 dark:bg-gray-700"></div> <div className="h-10 w-10 rounded-full bg-surface-secondary sm:h-12 sm:w-12"></div>
<div className="h-3 w-2/3 rounded bg-gray-300 dark:bg-gray-700"></div> </div>
{/* Content skeleton */}
<div className="flex flex-1 flex-col justify-center space-y-2">
<div className="h-4 w-3/4 rounded bg-surface-secondary"></div>
<div className="h-3 w-full rounded bg-surface-secondary"></div>
</div>
</div> </div>
</div> </div>
))} ))}
@ -178,7 +183,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
{searchQuery && ( {searchQuery && (
<div className="mb-4"> <div className="mb-4">
<h2 <h2
className="text-xl font-bold text-gray-900 dark:text-white" className="text-xl font-bold text-text-primary"
id={`category-heading-${category}`} id={`category-heading-${category}`}
aria-label={`${getGridTitle()}, ${currentAgents.length || 0} agents available`} aria-label={`${getGridTitle()}, ${currentAgents.length || 0} agents available`}
> >
@ -190,7 +195,7 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
{/* Handle empty results with enhanced accessibility */} {/* Handle empty results with enhanced accessibility */}
{(!currentAgents || currentAgents.length === 0) && !isLoading && !isFetching ? ( {(!currentAgents || currentAgents.length === 0) && !isLoading && !isFetching ? (
<div <div
className="py-12 text-center text-gray-500" className="py-12 text-center text-text-secondary"
role="status" role="status"
aria-live="polite" aria-live="polite"
aria-label={ aria-label={
@ -258,11 +263,9 @@ const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAg
variant="outline" variant="outline"
onClick={handleLoadMore} onClick={handleLoadMore}
className={cn( className={cn(
'min-w-[160px] border-2 border-gray-300 bg-white px-6 py-3 font-medium text-gray-700', 'min-w-[160px] border-2 border-border-medium bg-surface-primary px-6 py-3 font-medium text-text-primary',
'shadow-sm transition-all duration-200 hover:border-gray-400 hover:bg-gray-50', 'shadow-sm transition-all duration-200 hover:border-border-heavy hover:bg-surface-hover',
'hover:shadow-md focus:ring-2 focus:ring-blue-500 focus:ring-offset-2', 'hover:shadow-md focus:ring-2 focus:ring-ring focus:ring-offset-2',
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200',
'dark:hover:border-gray-500 dark:hover:bg-gray-700 dark:focus:ring-blue-400',
)} )}
aria-label={localize('com_agents_load_more_label', { aria-label={localize('com_agents_load_more_label', {
category: getCategoryDisplayName(category), category: getCategoryDisplayName(category),

View file

@ -1,9 +1,9 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { useRecoilState } from 'recoil';
import { useOutletContext } from 'react-router-dom'; import { useOutletContext } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useSetRecoilState, useRecoilValue } from 'recoil';
import { TooltipAnchor, Button, NewChatIcon } from '@librechat/client';
import { useSearchParams, useParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
import { TooltipAnchor, Button, NewChatIcon, useMediaQuery } from '@librechat/client';
import { PermissionTypes, Permissions, QueryKeys, Constants } from 'librechat-data-provider'; import { PermissionTypes, Permissions, QueryKeys, Constants } from 'librechat-data-provider';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import type { ContextType } from '~/common'; import type { ContextType } from '~/common';
@ -17,6 +17,7 @@ import CategoryTabs from './CategoryTabs';
import AgentDetail from './AgentDetail'; import AgentDetail from './AgentDetail';
import SearchBar from './SearchBar'; import SearchBar from './SearchBar';
import AgentGrid from './AgentGrid'; import AgentGrid from './AgentGrid';
import { cn } from '~/utils';
import store from '~/store'; import store from '~/store';
interface AgentMarketplaceProps { interface AgentMarketplaceProps {
@ -33,13 +34,14 @@ interface AgentMarketplaceProps {
const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) => { const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) => {
const localize = useLocalize(); const localize = useLocalize();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient();
const { conversation, newConversation } = useChatContext();
const [searchParams, setSearchParams] = useSearchParams();
const { category } = useParams(); const { category } = useParams();
const setHideSidePanel = useSetRecoilState(store.hideSidePanel); const queryClient = useQueryClient();
const hideSidePanel = useRecoilValue(store.hideSidePanel); const [searchParams, setSearchParams] = useSearchParams();
const { conversation, newConversation } = useChatContext();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { navVisible, setNavVisible } = useOutletContext<ContextType>(); const { navVisible, setNavVisible } = useOutletContext<ContextType>();
const [hideSidePanel, setHideSidePanel] = useRecoilState(store.hideSidePanel);
// Get URL parameters (default to 'promoted' instead of 'all') // Get URL parameters (default to 'promoted' instead of 'all')
const activeTab = category || 'promoted'; const activeTab = category || 'promoted';
@ -203,123 +205,145 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
fullPanelCollapse={fullCollapse} fullPanelCollapse={fullCollapse}
defaultCollapsed={defaultCollapsed} defaultCollapsed={defaultCollapsed}
> >
<main className="flex h-full flex-col overflow-y-auto" role="main"> <main className="flex h-full flex-col overflow-hidden" role="main">
{/* Simplified header for agents marketplace - only show nav controls when needed */} {/* Scrollable container */}
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800"> <div className="scrollbar-gutter-stable flex h-full flex-col overflow-y-auto overflow-x-hidden">
<div className="mx-1 flex items-center gap-2"> {/* Simplified header for agents marketplace - only show nav controls when needed */}
{!navVisible && <OpenSidebar setNavVisible={setNavVisible} />} {!isSmallScreen && (
{!navVisible && ( <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">
<TooltipAnchor <div className="mx-1 flex items-center gap-2">
description={localize('com_ui_new_chat')} {!navVisible ? (
render={ <>
<Button <OpenSidebar setNavVisible={setNavVisible} />
size="icon" <TooltipAnchor
variant="outline" description={localize('com_ui_new_chat')}
data-testid="agents-new-chat-button" render={
aria-label={localize('com_ui_new_chat')} <Button
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden" size="icon"
onClick={handleNewChat} variant="outline"
> data-testid="agents-new-chat-button"
<NewChatIcon /> aria-label={localize('com_ui_new_chat')}
</Button> className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
} onClick={handleNewChat}
/> >
)} <NewChatIcon />
</div> </Button>
</div> }
<div className="container mx-auto max-w-4xl px-4 py-8"> />
{/* Hero Section - ChatGPT Style */} </>
<div className="mb-8 mt-12 text-center"> ) : (
<h1 className="mb-3 text-5xl font-bold tracking-tight text-gray-900 dark:text-white"> // Invisible placeholder to maintain height
{localize('com_agents_marketplace')} <div className="h-10 w-10" />
</h1> )}
<p className="mx-auto mb-6 max-w-2xl text-lg text-gray-600 dark:text-gray-300"> </div>
{localize('com_agents_marketplace_subtitle')}
</p>
{/* Search bar */}
<div className="mx-auto max-w-2xl">
<SearchBar value={searchQuery} onSearch={handleSearch} />
</div>
</div>
{/* Category tabs */}
<CategoryTabs
categories={categoriesQuery.data || []}
activeTab={activeTab}
isLoading={categoriesQuery.isLoading}
onChange={handleTabChange}
/>
{/* Category header - only show when not searching */}
{!searchQuery && (
<div className="mb-6">
{(() => {
// Get category data for display
const getCategoryData = () => {
if (activeTab === 'promoted') {
return {
name: localize('com_agents_top_picks'),
description: localize('com_agents_recommended'),
};
}
if (activeTab === 'all') {
return {
name: 'All Agents',
description: 'Browse all shared agents across all categories',
};
}
// Find the category in the API data
const categoryData = categoriesQuery.data?.find(
(cat) => cat.value === activeTab,
);
if (categoryData) {
return {
name: categoryData.label,
description: categoryData.description || '',
};
}
// Fallback for unknown categories
return {
name: activeTab.charAt(0).toUpperCase() + activeTab.slice(1),
description: '',
};
};
const { name, description } = getCategoryData();
return (
<div className="text-left">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{name}
</h2>
{description && (
<p className="mt-2 text-gray-600 dark:text-gray-300">{description}</p>
)}
</div>
);
})()}
</div> </div>
)} )}
{/* Agent grid */} {/* Hero Section - scrolls away */}
<AgentGrid <div className="container mx-auto max-w-4xl">
category={activeTab} <div className={cn('mb-8 text-center', isSmallScreen ? 'mt-6' : 'mt-12')}>
searchQuery={searchQuery} <h1 className="mb-3 text-3xl font-bold tracking-tight text-text-primary md:text-5xl">
onSelectAgent={handleAgentSelect} {localize('com_agents_marketplace')}
/> </h1>
</div> <p className="mx-auto mb-6 max-w-2xl text-lg text-text-secondary">
{localize('com_agents_marketplace_subtitle')}
</p>
</div>
</div>
{/* Agent detail dialog */} {/* Sticky wrapper for search bar and categories */}
{isDetailOpen && selectedAgent && ( <div
<AgentDetail className={cn(
agent={selectedAgent} 'sticky z-10 bg-presentation pb-4',
isOpen={isDetailOpen} isSmallScreen ? 'top-0' : 'top-14',
onClose={handleDetailClose} )}
/> >
)} <div className="container mx-auto max-w-4xl px-4">
{/* Search bar */}
<div className="mx-auto max-w-2xl pb-6">
<SearchBar value={searchQuery} onSearch={handleSearch} />
</div>
{/* Category tabs */}
<CategoryTabs
categories={categoriesQuery.data || []}
activeTab={activeTab}
isLoading={categoriesQuery.isLoading}
onChange={handleTabChange}
/>
</div>
</div>
{/* Scrollable content area */}
<div className="container mx-auto max-w-4xl px-4 pb-8">
{/* Category header - only show when not searching */}
{!searchQuery && (
<div className="mb-6 mt-6">
{(() => {
// Get category data for display
const getCategoryData = () => {
if (activeTab === 'promoted') {
return {
name: localize('com_agents_top_picks'),
description: localize('com_agents_recommended'),
};
}
if (activeTab === 'all') {
return {
name: 'All Agents',
description: 'Browse all shared agents across all categories',
};
}
// Find the category in the API data
const categoryData = categoriesQuery.data?.find(
(cat) => cat.value === activeTab,
);
if (categoryData) {
return {
name: categoryData.label,
description: categoryData.description || '',
};
}
// Fallback for unknown categories
return {
name: activeTab.charAt(0).toUpperCase() + activeTab.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
category={activeTab}
searchQuery={searchQuery}
onSelectAgent={handleAgentSelect}
/>
</div>
{/* Agent detail dialog */}
{isDetailOpen && selectedAgent && (
<AgentDetail
agent={selectedAgent}
isOpen={isDetailOpen}
onClose={handleDetailClose}
/>
)}
</div>
</main> </main>
</SidePanelGroup> </SidePanelGroup>
</SidePanelProvider> </SidePanelProvider>

View file

@ -1,7 +1,5 @@
import React from 'react'; import React from 'react';
import type t from 'librechat-data-provider'; import type t from 'librechat-data-provider';
import useLocalize from '~/hooks/useLocalize'; import useLocalize from '~/hooks/useLocalize';
import { SmartLoader } from './SmartLoader'; import { SmartLoader } from './SmartLoader';
import { cn } from '~/utils'; import { cn } from '~/utils';
@ -50,16 +48,14 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
// Loading skeleton component // Loading skeleton component
const loadingSkeleton = ( const loadingSkeleton = (
<div className="mb-8"> <div className="w-full pb-2">
<div className="flex justify-center"> <div className="no-scrollbar flex gap-1.5 overflow-x-auto px-4">
<div className="flex flex-wrap items-center justify-center gap-6"> {[...Array(6)].map((_, i) => (
{[...Array(6)].map((_, i) => ( <div
<div key={i}
key={i} className="h-[36px] min-w-[80px] animate-pulse rounded-md bg-surface-tertiary"
className="h-6 min-w-[60px] animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
/> ))}
))}
</div>
</div> </div>
</div> </div>
); );
@ -106,52 +102,54 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
// Early return if no categories available // Early return if no categories available
if (!isLoading && (!categories || categories.length === 0)) { if (!isLoading && (!categories || categories.length === 0)) {
return ( return (
<div className="mb-8 text-center text-gray-500">{localize('com_agents_no_categories')}</div> <div className="text-center text-text-secondary">{localize('com_ui_no_categories')}</div>
); );
} }
// Main tabs content // Main tabs content
const tabsContent = ( const tabsContent = (
<div className="mb-8"> <div className="relative w-full pb-2">
<div className="flex justify-center"> <div
{/* Accessible tab navigation with proper ARIA attributes */} className="no-scrollbar flex gap-1.5 overflow-x-auto overscroll-x-contain px-4 [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
<div role="tablist"
className="flex flex-wrap items-center justify-center gap-6" aria-label={localize('com_agents_category_tabs_label')}
role="tablist" aria-orientation="horizontal"
aria-label={localize('com_agents_category_tabs_label')} style={{
aria-orientation="horizontal" scrollSnapType: 'x mandatory',
> WebkitOverflowScrolling: 'touch',
{categories.map((category, index) => ( }}
<button >
key={category.value} {categories.map((category, index) => (
id={`category-tab-${category.value}`} <button
onClick={() => onChange(category.value)} key={category.value}
onKeyDown={(e) => handleKeyDown(e, category.value)} id={`category-tab-${category.value}`}
className={cn( onClick={() => onChange(category.value)}
'relative px-4 py-2 text-sm font-medium transition-colors duration-200', onKeyDown={(e) => handleKeyDown(e, category.value)}
'focus:bg-gray-100 focus:outline-none dark:focus:bg-gray-800', className={cn(
'hover:text-gray-900 dark:hover:text-white', 'relative mt-1 cursor-pointer select-none whitespace-nowrap rounded-md px-3 py-2',
activeTab === category.value activeTab === category.value
? 'text-gray-900 dark:text-white' ? 'bg-surface-tertiary text-text-primary'
: 'text-gray-600 dark:text-gray-400', : 'bg-surface-secondary text-text-secondary hover:bg-surface-hover hover:text-text-primary',
)} )}
role="tab" style={{
aria-selected={activeTab === category.value} scrollSnapAlign: 'start',
aria-controls={`tabpanel-${category.value}`} }}
tabIndex={activeTab === category.value ? 0 : -1} role="tab"
aria-label={`${getCategoryDisplayName(category)} tab (${index + 1} of ${categories.length})`} aria-selected={activeTab === category.value}
> aria-controls={`tabpanel-${category.value}`}
<span className="truncate">{getCategoryDisplayName(category)}</span> tabIndex={activeTab === category.value ? 0 : -1}
{/* Underline for active tab */} aria-label={`${getCategoryDisplayName(category)} tab (${index + 1} of ${categories.length})`}
{activeTab === category.value && ( >
<div {getCategoryDisplayName(category)}
className="absolute bottom-0 left-0 right-0 h-0.5 rounded-full bg-gray-900 dark:bg-white" {/* Underline for active tab */}
aria-hidden="true" {activeTab === category.value && (
/> <div
)} className="absolute bottom-0 left-0 right-0 h-0.5 bg-text-primary"
</button> aria-hidden="true"
))} />
</div> )}
</button>
))}
</div> </div>
</div> </div>
); );

View file

@ -73,7 +73,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ value, onSearch, className = '' }
value={searchTerm} value={searchTerm}
onChange={handleChange} onChange={handleChange}
placeholder={localize('com_agents_search_placeholder')} placeholder={localize('com_agents_search_placeholder')}
className="h-14 rounded-2xl border-2 border-gray-200 bg-white pl-12 pr-12 text-lg text-gray-900 shadow-lg placeholder:text-gray-500 focus:border-gray-300 focus:ring-0 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-400 dark:focus:border-gray-500" className="h-14 rounded-2xl border-2 border-border-medium bg-transparent pl-12 pr-12 text-lg text-text-primary shadow-lg placeholder:text-text-tertiary focus:border-border-heavy focus:ring-0"
aria-label={localize('com_agents_search_aria')} aria-label={localize('com_agents_search_aria')}
aria-describedby="search-instructions search-results-count" aria-describedby="search-instructions search-results-count"
autoComplete="off" autoComplete="off"
@ -82,7 +82,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ value, onSearch, className = '' }
{/* Search icon with proper accessibility */} {/* Search icon with proper accessibility */}
<div className="absolute inset-y-0 left-0 flex items-center pl-4" aria-hidden="true"> <div className="absolute inset-y-0 left-0 flex items-center pl-4" aria-hidden="true">
<Search className="h-6 w-6 text-gray-400" /> <Search className="h-6 w-6 text-text-tertiary" />
</div> </div>
{/* Hidden instructions for screen readers */} {/* Hidden instructions for screen readers */}
@ -95,7 +95,7 @@ const SearchBar: React.FC<SearchBarProps> = ({ value, onSearch, className = '' }
<button <button
type="button" type="button"
onClick={handleClear} onClick={handleClear}
className="group absolute right-3 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-gray-400 transition-colors duration-150 hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-gray-500 dark:hover:bg-gray-400" className="group absolute right-3 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-text-tertiary transition-colors duration-150 hover:bg-text-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
aria-label={localize('com_agents_clear_search')} aria-label={localize('com_agents_clear_search')}
title={localize('com_agents_clear_search')} title={localize('com_agents_clear_search')}
> >

View file

@ -1,9 +1,13 @@
import React from 'react'; import React from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronDown } from 'lucide-react';
import { DropdownPopup } from '@librechat/client';
import { ACCESS_ROLE_IDS } from 'librechat-data-provider'; import { ACCESS_ROLE_IDS } from 'librechat-data-provider';
import type { AccessRole } from 'librechat-data-provider';
import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query'; import { useGetAccessRolesQuery } from 'librechat-data-provider/react-query';
import SelectDropDownPop from '~/components/Input/ModelSelect/SelectDropDownPop'; import type { AccessRole } from 'librechat-data-provider';
import type * as t from '~/common';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface AccessRolesPickerProps { interface AccessRolesPickerProps {
resourceType?: string; resourceType?: string;
@ -19,6 +23,7 @@ export default function AccessRolesPicker({
className = '', className = '',
}: AccessRolesPickerProps) { }: AccessRolesPickerProps) {
const localize = useLocalize(); const localize = useLocalize();
const [isOpen, setIsOpen] = React.useState(false);
// Fetch access roles from API // Fetch access roles from API
const { data: accessRoles, isLoading: rolesLoading } = useGetAccessRolesQuery(resourceType); const { data: accessRoles, isLoading: rolesLoading } = useGetAccessRolesQuery(resourceType);
@ -56,43 +61,61 @@ export default function AccessRolesPicker({
// Find the currently selected role // Find the currently selected role
const selectedRole = accessRoles?.find((role) => role.accessRoleId === selectedRoleId); const selectedRole = accessRoles?.find((role) => role.accessRoleId === selectedRoleId);
const selectedRoleInfo = selectedRole ? getLocalizedRoleInfo(selectedRole.accessRoleId) : null;
if (rolesLoading || !accessRoles) { if (rolesLoading || !accessRoles) {
return ( return (
<div className={className}> <div className={className}>
<div className="flex items-center justify-center py-2"> <div className="flex items-center justify-center py-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600"></div> <div className="h-4 w-4 animate-spin rounded-full border-2 border-border-light border-t-blue-600"></div>
<span className="ml-2 text-sm text-gray-500">Loading roles...</span> <span className="ml-2 text-sm text-text-secondary">{localize('com_ui_loading')}</span>
</div> </div>
</div> </div>
); );
} }
const dropdownItems: t.MenuItemProps[] = accessRoles.map((role: AccessRole) => {
const localizedInfo = getLocalizedRoleInfo(role.accessRoleId);
return {
id: role.accessRoleId,
label: localizedInfo.name,
onClick: () => {
onRoleChange(role.accessRoleId);
setIsOpen(false);
},
render: (props) => (
<button {...props}>
<div className="flex flex-col items-start gap-0.5 text-left">
<span className="font-medium text-text-primary">{localizedInfo.name}</span>
<span className="text-xs text-text-secondary">{localizedInfo.description}</span>
</div>
</button>
),
};
});
return ( return (
<div className={className}> <div className={className}>
<SelectDropDownPop <DropdownPopup
availableValues={accessRoles.map((role: AccessRole) => { menuId="access-roles-menu"
const localizedInfo = getLocalizedRoleInfo(role.accessRoleId); isOpen={isOpen}
return { setIsOpen={setIsOpen}
value: role.accessRoleId, trigger={
label: localizedInfo.name, <Ariakit.MenuButton
description: localizedInfo.description, aria-label={selectedRoleInfo?.description || 'Select role'}
}; className={cn(
})} 'flex items-center justify-between gap-2 rounded-lg border border-border-light bg-surface-primary px-3 py-2 text-sm transition-colors hover:bg-surface-hover focus:outline-none focus:ring-2 focus:ring-ring-primary',
showLabel={false} 'min-w-[200px]',
value={ )}
selectedRole >
? (() => { <span className="font-medium">
const localizedInfo = getLocalizedRoleInfo(selectedRole.accessRoleId); {selectedRoleInfo?.name || localize('com_ui_select')}
return { </span>
value: selectedRole.accessRoleId, <ChevronDown className="h-4 w-4 text-text-secondary" />
label: localizedInfo.name, </Ariakit.MenuButton>
description: localizedInfo.description,
};
})()
: null
} }
setValue={onRoleChange} items={dropdownItems}
className="w-[280px]"
/> />
</div> </div>
); );

View file

@ -10,7 +10,7 @@ jest.mock('~/hooks/useLocalize', () => () => (key: string) => {
const mockTranslations: Record<string, string> = { const mockTranslations: Record<string, string> = {
com_agents_top_picks: 'Top Picks', com_agents_top_picks: 'Top Picks',
com_agents_all: 'All', com_agents_all: 'All',
com_agents_no_categories: 'No categories available', com_ui_no_categories: 'No categories available',
com_agents_category_tabs_label: 'Agent Categories', com_agents_category_tabs_label: 'Agent Categories',
com_ui_agent_category_general: 'General', com_ui_agent_category_general: 'General',
com_ui_agent_category_hr: 'HR', com_ui_agent_category_hr: 'HR',
@ -83,7 +83,7 @@ describe('CategoryTabs', () => {
); );
const generalTab = screen.getByText('General').closest('button'); const generalTab = screen.getByText('General').closest('button');
expect(generalTab).toHaveClass('text-gray-900'); expect(generalTab).toHaveClass('bg-surface-tertiary');
// Should have active underline // Should have active underline
const underline = generalTab?.querySelector('.absolute.bottom-0'); const underline = generalTab?.querySelector('.absolute.bottom-0');
@ -149,7 +149,8 @@ describe('CategoryTabs', () => {
); );
const generalTab = screen.getByText('General').closest('button'); const generalTab = screen.getByText('General').closest('button');
expect(generalTab).toHaveClass('text-gray-600'); expect(generalTab).toHaveClass('bg-surface-secondary');
expect(generalTab).toHaveClass('text-text-secondary');
// Should not have active underline // Should not have active underline
const underline = generalTab?.querySelector('.absolute.bottom-0'); const underline = generalTab?.querySelector('.absolute.bottom-0');

View file

@ -935,6 +935,7 @@
"com_ui_no": "No", "com_ui_no": "No",
"com_ui_no_backup_codes": "No backup codes available. Please generate new ones", "com_ui_no_backup_codes": "No backup codes available. Please generate new ones",
"com_ui_no_bookmarks": "it seems like you have no bookmarks yet. Click on a chat and add a new one", "com_ui_no_bookmarks": "it seems like you have no bookmarks yet. Click on a chat and add a new one",
"com_ui_no_categories": "No categories available",
"com_ui_no_category": "No category", "com_ui_no_category": "No category",
"com_ui_no_data": "something needs to go here. was empty", "com_ui_no_data": "something needs to go here. was empty",
"com_ui_no_personalization_available": "No personalization options are currently available", "com_ui_no_personalization_available": "No personalization options are currently available",
@ -1246,7 +1247,6 @@
"com_agents_none_in_category": "No agents found in this category", "com_agents_none_in_category": "No agents found in this category",
"com_agents_no_results": "No agents found. Try another search term.", "com_agents_no_results": "No agents found. Try another search term.",
"com_agents_results_for": "Results for '{{query}}'", "com_agents_results_for": "Results for '{{query}}'",
"com_nav_agents_marketplace": "Agent Marketplace",
"com_agents_marketplace_subtitle": "Discover and use powerful AI agents to enhance your workflows and productivity", "com_agents_marketplace_subtitle": "Discover and use powerful AI agents to enhance your workflows and productivity",
"com_ui_agent_name_is_required": "Agent name is required", "com_ui_agent_name_is_required": "Agent name is required",
"com_agents_missing_name": "Please type in a name before creating an agent." "com_agents_missing_name": "Please type in a name before creating an agent."

View file

@ -2801,6 +2801,17 @@ html {
padding: 12px; padding: 12px;
} }
/* Hide scrollbar for Chrome, Safari and Opera */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.no-scrollbar {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.sharepoint-picker-bg{ .sharepoint-picker-bg{
background-color: #F5F5F5; background-color: #F5F5F5;
} }