mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-19 18:00:15 +01:00
🪟 style: Agent Marketplace UI Responsiveness, a11y, and Navigation (#9068)
* refactor: Agent Marketplace Button with access control * fix(agent-marketplace): update marketplace UI and access control * fix(agent-card): handle optional agent description for accessibility * fix(agent-card): remove unnecessary icon checks from tests * chore: remove unused keys --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
c78fd0fc83
commit
4ec7bcb60f
17 changed files with 164 additions and 162 deletions
|
|
@ -28,7 +28,7 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-label={localize('com_agents_agent_card_label', {
|
aria-label={localize('com_agents_agent_card_label', {
|
||||||
name: agent.name,
|
name: agent.name,
|
||||||
description: agent.description || localize('com_agents_no_description'),
|
description: agent.description ?? '',
|
||||||
})}
|
})}
|
||||||
aria-describedby={`agent-${agent.id}-description`}
|
aria-describedby={`agent-${agent.id}-description`}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
@ -47,51 +47,48 @@ const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' })
|
||||||
<div className="flex-shrink-0">{renderAgentAvatar(agent, { size: 'sm' })}</div>
|
<div className="flex-shrink-0">{renderAgentAvatar(agent, { size: 'sm' })}</div>
|
||||||
|
|
||||||
{/* Category tag */}
|
{/* Category tag */}
|
||||||
<div className="inline-flex items-center rounded-md border-border-xheavy bg-surface-active-alt px-2 py-1 text-xs font-medium">
|
|
||||||
{agent.category && (
|
{agent.category && (
|
||||||
|
<div className="inline-flex items-center rounded-md border-border-xheavy bg-surface-active-alt px-2 py-1 text-xs font-medium">
|
||||||
<Label className="line-clamp-1 font-normal">
|
<Label className="line-clamp-1 font-normal">
|
||||||
{agent.category.charAt(0).toUpperCase() + agent.category.slice(1)}
|
{agent.category.charAt(0).toUpperCase() + agent.category.slice(1)}
|
||||||
</Label>
|
</Label>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right column: Name, description, and other content */}
|
{/* Right column: Name, description, and other content */}
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
<div className="flex h-full min-w-0 flex-1 flex-col justify-between space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-1">
|
||||||
{/* Agent name */}
|
{/* Agent name */}
|
||||||
<Label className="mb-1 line-clamp-1 text-xl font-semibold text-text-primary">
|
<Label className="mb-1 line-clamp-1 text-xl font-semibold text-text-primary">
|
||||||
{agent.name}
|
{agent.name}
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
{/* Owner info */}
|
{/* Agent description */}
|
||||||
|
<p
|
||||||
|
id={`agent-${agent.id}-description`}
|
||||||
|
className="line-clamp-3 text-sm leading-relaxed text-text-primary"
|
||||||
|
{...(agent.description ? { 'aria-label': `Description: ${agent.description}` } : {})}
|
||||||
|
>
|
||||||
|
{agent.description ?? ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Owner info - moved to bottom right */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const displayName = getContactDisplayName(agent);
|
const displayName = getContactDisplayName(agent);
|
||||||
if (displayName) {
|
if (displayName) {
|
||||||
return (
|
return (
|
||||||
|
<div className="flex justify-end">
|
||||||
<div className="flex items-center text-sm text-text-secondary">
|
<div className="flex items-center text-sm text-text-secondary">
|
||||||
<Label className="mr-1">🔹</Label>
|
|
||||||
<Label>{displayName}</Label>
|
<Label>{displayName}</Label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent description */}
|
|
||||||
<p
|
|
||||||
id={`agent-${agent.id}-description`}
|
|
||||||
className="line-clamp-3 text-sm leading-relaxed text-text-primary"
|
|
||||||
aria-label={`Description: ${agent.description || localize('com_agents_no_description')}`}
|
|
||||||
>
|
|
||||||
{agent.description || (
|
|
||||||
<Label className="font-normal italic text-text-primary">
|
|
||||||
{localize('com_agents_no_description')}
|
|
||||||
</Label>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -126,10 +126,7 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OGDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
<OGDialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
<OGDialogContent
|
<OGDialogContent ref={dialogRef} className="max-h-[90vh] w-11/12 max-w-lg overflow-y-auto">
|
||||||
ref={dialogRef}
|
|
||||||
className="max-h-[90vh] overflow-y-auto py-8 sm:max-w-[450px]"
|
|
||||||
>
|
|
||||||
{/* Copy link button - positioned next to close button */}
|
{/* Copy link button - positioned next to close button */}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
@ -161,11 +158,7 @@ const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) =>
|
||||||
|
|
||||||
{/* Agent description - below contact */}
|
{/* Agent description - below contact */}
|
||||||
<div className="mt-4 whitespace-pre-wrap px-6 text-center text-base text-text-primary">
|
<div className="mt-4 whitespace-pre-wrap px-6 text-center text-base text-text-primary">
|
||||||
{agent?.description || (
|
{agent?.description}
|
||||||
<span className="italic text-text-tertiary">
|
|
||||||
{localize('com_agents_no_description')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action button */}
|
{/* Action button */}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
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 { useMediaQuery } from '@librechat/client';
|
||||||
import { SmartLoader } from './SmartLoader';
|
import { SmartLoader } from './SmartLoader';
|
||||||
|
import { useLocalize } from '~/hooks/';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -33,6 +34,7 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||||
onChange,
|
onChange,
|
||||||
}) => {
|
}) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||||
|
|
||||||
// Helper function to get category display name from database data
|
// Helper function to get category display name from database data
|
||||||
const getCategoryDisplayName = (category: t.TCategory) => {
|
const getCategoryDisplayName = (category: t.TCategory) => {
|
||||||
|
|
@ -120,10 +122,24 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||||
const tabsContent = (
|
const tabsContent = (
|
||||||
<div className="w-full pb-2">
|
<div className="w-full pb-2">
|
||||||
<div
|
<div
|
||||||
className="flex flex-wrap justify-center gap-1.5 px-4"
|
className={cn(
|
||||||
|
'px-4',
|
||||||
|
isSmallScreen
|
||||||
|
? 'scrollbar-hide flex gap-2 overflow-x-auto scroll-smooth'
|
||||||
|
: 'flex flex-wrap justify-center gap-1.5',
|
||||||
|
)}
|
||||||
role="tablist"
|
role="tablist"
|
||||||
aria-label={localize('com_agents_category_tabs_label')}
|
aria-label={localize('com_agents_category_tabs_label')}
|
||||||
aria-orientation="horizontal"
|
aria-orientation="horizontal"
|
||||||
|
style={
|
||||||
|
isSmallScreen
|
||||||
|
? {
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
msOverflowStyle: 'none',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{categories.map((category, index) => (
|
{categories.map((category, index) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -132,10 +148,11 @@ const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||||
onClick={() => onChange(category.value)}
|
onClick={() => onChange(category.value)}
|
||||||
onKeyDown={(e) => handleKeyDown(e, category.value)}
|
onKeyDown={(e) => handleKeyDown(e, category.value)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative cursor-pointer select-none whitespace-nowrap px-3 py-2 transition-colors',
|
'relative cursor-pointer select-none whitespace-nowrap px-3 py-2 transition-all duration-200',
|
||||||
|
isSmallScreen ? 'min-w-fit flex-shrink-0' : '',
|
||||||
activeTab === category.value
|
activeTab === category.value
|
||||||
? 'rounded-t-lg bg-surface-hover text-text-primary'
|
? 'rounded-t-lg bg-surface-hover text-text-primary'
|
||||||
: 'rounded-lg bg-surface-secondary text-text-secondary hover:bg-surface-hover hover:text-text-primary',
|
: 'rounded-lg bg-surface-secondary text-text-secondary hover:bg-surface-hover hover:text-text-primary active:scale-95',
|
||||||
)}
|
)}
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={activeTab === category.value}
|
aria-selected={activeTab === category.value}
|
||||||
|
|
|
||||||
|
|
@ -285,10 +285,6 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
className="scrollbar-gutter-stable relative flex h-full flex-col overflow-y-auto overflow-x-hidden"
|
className="scrollbar-gutter-stable relative flex h-full flex-col overflow-y-auto overflow-x-hidden"
|
||||||
>
|
>
|
||||||
{/* Admin Settings */}
|
|
||||||
<div className="absolute right-4 top-4 z-30">
|
|
||||||
<MarketplaceAdminSettings />
|
|
||||||
</div>
|
|
||||||
{/* Simplified header for agents marketplace - only show nav controls when needed */}
|
{/* Simplified header for agents marketplace - only show nav controls when needed */}
|
||||||
{!isSmallScreen && (
|
{!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="sticky top-0 z-20 flex items-center justify-between bg-surface-secondary p-2 font-semibold text-text-primary md:h-14">
|
||||||
|
|
@ -319,10 +315,10 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hero Section - scrolls away */}
|
{/* Hero Section - scrolls away */}
|
||||||
|
{!isSmallScreen && (
|
||||||
<div className="container mx-auto max-w-4xl">
|
<div className="container mx-auto max-w-4xl">
|
||||||
<div className={cn('mb-8 text-center', isSmallScreen ? 'mt-6' : 'mt-12')}>
|
<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">
|
<h1 className="mb-3 text-3xl font-bold tracking-tight text-text-primary md:text-5xl">
|
||||||
{localize('com_agents_marketplace')}
|
{localize('com_agents_marketplace')}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
@ -331,7 +327,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
{/* Sticky wrapper for search bar and categories */}
|
{/* Sticky wrapper for search bar and categories */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -341,8 +337,11 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||||
>
|
>
|
||||||
<div className="container mx-auto max-w-4xl px-4">
|
<div className="container mx-auto max-w-4xl px-4">
|
||||||
{/* Search bar */}
|
{/* Search bar */}
|
||||||
<div className="mx-auto max-w-2xl pb-6">
|
<div className="mx-auto flex max-w-2xl gap-2 pb-6">
|
||||||
<SearchBar value={searchQuery} onSearch={handleSearch} />
|
<SearchBar value={searchQuery} onSearch={handleSearch} />
|
||||||
|
{/* TODO: Remove this once we have a better way to handle admin settings */}
|
||||||
|
{/* Admin Settings */}
|
||||||
|
<MarketplaceAdminSettings />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Category tabs */}
|
{/* Category tabs */}
|
||||||
|
|
@ -354,7 +353,6 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable content area */}
|
{/* Scrollable content area */}
|
||||||
<div className="container mx-auto max-w-4xl px-4 pb-8">
|
<div className="container mx-auto max-w-4xl px-4 pb-8">
|
||||||
{/* Two-pane animated container wrapping category header + grid */}
|
{/* Two-pane animated container wrapping category header + grid */}
|
||||||
|
|
@ -510,7 +508,6 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
|
||||||
{/* Note: Using Tailwind keyframes for slide in/out animations */}
|
{/* Note: Using Tailwind keyframes for slide in/out animations */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Agent detail dialog */}
|
{/* Agent detail dialog */}
|
||||||
{isDetailOpen && selectedAgent && (
|
{isDetailOpen && selectedAgent && (
|
||||||
<AgentDetail
|
<AgentDetail
|
||||||
|
|
|
||||||
|
|
@ -146,14 +146,13 @@ const MarketplaceAdminSettings = () => {
|
||||||
<OGDialog>
|
<OGDialog>
|
||||||
<OGDialogTrigger asChild>
|
<OGDialogTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={'outline'}
|
variant="outline"
|
||||||
className="btn btn-neutral border-token-border-light relative gap-1 rounded-lg font-medium"
|
className="relative h-12 rounded-xl border-border-medium font-medium"
|
||||||
>
|
>
|
||||||
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
|
<ShieldEllipsis className="cursor-pointer" aria-hidden="true" />
|
||||||
{localize('com_ui_admin_settings')}
|
|
||||||
</Button>
|
</Button>
|
||||||
</OGDialogTrigger>
|
</OGDialogTrigger>
|
||||||
<OGDialogContent className="w-full border-border-light bg-surface-primary text-text-primary md:w-1/4">
|
<OGDialogContent className="w-11/12 max-w-md border-border-light bg-surface-primary text-text-primary">
|
||||||
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
|
<OGDialogTitle>{`${localize('com_ui_admin_settings')} - ${localize(
|
||||||
'com_ui_marketplace',
|
'com_ui_marketplace',
|
||||||
)}`}</OGDialogTitle>
|
)}`}</OGDialogTitle>
|
||||||
|
|
|
||||||
|
|
@ -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-border-medium bg-transparent pl-12 pr-12 text-lg text-text-primary shadow-md transition-[border-color,box-shadow] duration-200 placeholder:text-text-secondary focus:border-border-heavy focus:shadow-lg focus:ring-0"
|
className="h-12 rounded-xl border-border-medium bg-transparent pl-12 pr-12 text-lg text-text-primary shadow-md transition-[border-color,box-shadow] duration-200 placeholder:text-text-secondary focus:border-border-heavy focus:shadow-lg 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"
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@ const mockLocalize = jest.fn((key: string, options?: any) => {
|
||||||
com_agents_search_placeholder: 'Search agents...',
|
com_agents_search_placeholder: 'Search agents...',
|
||||||
com_agents_clear_search: 'Clear search',
|
com_agents_clear_search: 'Clear search',
|
||||||
com_agents_agent_card_label: `${options?.name} agent. ${options?.description}`,
|
com_agents_agent_card_label: `${options?.name} agent. ${options?.description}`,
|
||||||
com_agents_no_description: 'No description available',
|
|
||||||
com_agents_grid_announcement: `Showing ${options?.count} agents in ${options?.category} category`,
|
com_agents_grid_announcement: `Showing ${options?.count} agents in ${options?.category} category`,
|
||||||
com_agents_load_more_label: `Load more agents from ${options?.category} category`,
|
com_agents_load_more_label: `Load more agents from ${options?.category} category`,
|
||||||
com_agents_error_retry: 'Try Again',
|
com_agents_error_retry: 'Try Again',
|
||||||
|
|
@ -307,13 +306,6 @@ describe('Accessibility Improvements', () => {
|
||||||
expect(card).toHaveAttribute('role', 'button');
|
expect(card).toHaveAttribute('role', 'button');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles agents without descriptions', () => {
|
|
||||||
const agentWithoutDesc = { ...mockAgent, description: undefined };
|
|
||||||
render(<AgentCard agent={agentWithoutDesc as any as t.Agent} onClick={jest.fn()} />);
|
|
||||||
|
|
||||||
expect(screen.getByText('No description available')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('supports keyboard interaction', () => {
|
it('supports keyboard interaction', () => {
|
||||||
const onClick = jest.fn();
|
const onClick = jest.fn();
|
||||||
render(<AgentCard agent={mockAgent as t.Agent} onClick={onClick} />);
|
render(<AgentCard agent={mockAgent as t.Agent} onClick={onClick} />);
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ describe('AgentCard', () => {
|
||||||
|
|
||||||
expect(screen.getByText('Test Agent')).toBeInTheDocument();
|
expect(screen.getByText('Test Agent')).toBeInTheDocument();
|
||||||
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
|
expect(screen.getByText('A test agent for testing purposes')).toBeInTheDocument();
|
||||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Test Support')).toBeInTheDocument();
|
expect(screen.getByText('Test Support')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -152,7 +151,6 @@ describe('AgentCard', () => {
|
||||||
|
|
||||||
render(<AgentCard agent={agentWithAuthorName} onClick={mockOnClick} />);
|
render(<AgentCard agent={agentWithAuthorName} onClick={mockOnClick} />);
|
||||||
|
|
||||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -165,7 +163,6 @@ describe('AgentCard', () => {
|
||||||
|
|
||||||
render(<AgentCard agent={agentWithEmailOnly} onClick={mockOnClick} />);
|
render(<AgentCard agent={agentWithEmailOnly} onClick={mockOnClick} />);
|
||||||
|
|
||||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('contact@example.com')).toBeInTheDocument();
|
expect(screen.getByText('contact@example.com')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -178,7 +175,6 @@ describe('AgentCard', () => {
|
||||||
|
|
||||||
render(<AgentCard agent={agentWithBoth} onClick={mockOnClick} />);
|
render(<AgentCard agent={agentWithBoth} onClick={mockOnClick} />);
|
||||||
|
|
||||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
|
expect(screen.queryByText('John Doe')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
@ -195,7 +191,6 @@ describe('AgentCard', () => {
|
||||||
|
|
||||||
render(<AgentCard agent={agentWithNameAndEmail} onClick={mockOnClick} />);
|
render(<AgentCard agent={agentWithNameAndEmail} onClick={mockOnClick} />);
|
||||||
|
|
||||||
expect(screen.getByText('🔹')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
expect(screen.getByText('Support Team')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('support@example.com')).not.toBeInTheDocument();
|
expect(screen.queryByText('support@example.com')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,6 @@ describe('AgentDetail', () => {
|
||||||
renderWithProviders(<AgentDetail {...defaultProps} agent={null as any} />);
|
renderWithProviders(<AgentDetail {...defaultProps} agent={null as any} />);
|
||||||
|
|
||||||
expect(screen.getByText('com_agents_loading')).toBeInTheDocument();
|
expect(screen.getByText('com_agents_loading')).toBeInTheDocument();
|
||||||
expect(screen.getByText('com_agents_no_description')).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should render copy link button', () => {
|
it('should render copy link button', () => {
|
||||||
|
|
|
||||||
66
client/src/components/Nav/AgentMarketplaceButton.tsx
Normal file
66
client/src/components/Nav/AgentMarketplaceButton.tsx
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
import React, { useCallback, useContext } from 'react';
|
||||||
|
import { LayoutGrid } from 'lucide-react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||||
|
import { TooltipAnchor, Button } from '@librechat/client';
|
||||||
|
import { useLocalize, useHasAccess, AuthContext } from '~/hooks';
|
||||||
|
|
||||||
|
interface AgentMarketplaceButtonProps {
|
||||||
|
isSmallScreen?: boolean;
|
||||||
|
toggleNav: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AgentMarketplaceButton({
|
||||||
|
isSmallScreen,
|
||||||
|
toggleNav,
|
||||||
|
}: AgentMarketplaceButtonProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const localize = useLocalize();
|
||||||
|
const authContext = useContext(AuthContext);
|
||||||
|
|
||||||
|
const hasAccessToAgents = useHasAccess({
|
||||||
|
permissionType: PermissionTypes.AGENTS,
|
||||||
|
permission: Permissions.USE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasAccessToMarketplace = useHasAccess({
|
||||||
|
permissionType: PermissionTypes.MARKETPLACE,
|
||||||
|
permission: Permissions.USE,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleAgentMarketplace = useCallback(() => {
|
||||||
|
navigate('/agents');
|
||||||
|
if (isSmallScreen) {
|
||||||
|
toggleNav();
|
||||||
|
}
|
||||||
|
}, [navigate, isSmallScreen, toggleNav]);
|
||||||
|
|
||||||
|
// Check if auth is ready (avoid race conditions)
|
||||||
|
const authReady =
|
||||||
|
authContext?.isAuthenticated !== undefined &&
|
||||||
|
(authContext?.isAuthenticated === false || authContext?.user !== undefined);
|
||||||
|
|
||||||
|
// Show agent marketplace when marketplace permission is enabled, auth is ready, and user has access to agents
|
||||||
|
const showAgentMarketplace = authReady && hasAccessToAgents && hasAccessToMarketplace;
|
||||||
|
|
||||||
|
if (!showAgentMarketplace) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TooltipAnchor
|
||||||
|
description={localize('com_agents_marketplace')}
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
data-testid="nav-agents-marketplace-button"
|
||||||
|
aria-label={localize('com_agents_marketplace')}
|
||||||
|
className="rounded-full border-none bg-transparent p-2 hover:bg-surface-hover md:rounded-xl"
|
||||||
|
onClick={handleAgentMarketplace}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="icon-lg text-text-primary" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -45,16 +45,9 @@ const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags, isSmallScreen }: Boo
|
||||||
data-testid="bookmark-menu"
|
data-testid="bookmark-menu"
|
||||||
>
|
>
|
||||||
{tags.length > 0 ? (
|
{tags.length > 0 ? (
|
||||||
<BookmarkFilledIcon
|
<BookmarkFilledIcon className="icon-lg text-text-primary" aria-hidden="true" />
|
||||||
/** `isSmallScreen` is used because lazy loading is not influencing `md:` prefix for some reason */
|
|
||||||
className={cn('text-text-primary', isSmallScreen ? 'icon-md-heavy' : 'icon-lg')}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<BookmarkIcon
|
<BookmarkIcon className="icon-lg text-text-primary" aria-hidden="true" />
|
||||||
className={cn('text-text-primary', isSmallScreen ? 'icon-md-heavy' : 'icon-lg')}
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import store from '~/store';
|
||||||
|
|
||||||
const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav'));
|
const BookmarkNav = lazy(() => import('./Bookmarks/BookmarkNav'));
|
||||||
const AccountSettings = lazy(() => import('./AccountSettings'));
|
const AccountSettings = lazy(() => import('./AccountSettings'));
|
||||||
|
const AgentMarketplaceButton = lazy(() => import('./AgentMarketplaceButton'));
|
||||||
|
|
||||||
const NAV_WIDTH_DESKTOP = '260px';
|
const NAV_WIDTH_DESKTOP = '260px';
|
||||||
const NAV_WIDTH_MOBILE = '320px';
|
const NAV_WIDTH_MOBILE = '320px';
|
||||||
|
|
@ -155,16 +156,22 @@ const Nav = memo(
|
||||||
);
|
);
|
||||||
|
|
||||||
const headerButtons = useMemo(
|
const headerButtons = useMemo(
|
||||||
() =>
|
() => (
|
||||||
hasAccessToBookmarks && (
|
<>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<AgentMarketplaceButton isSmallScreen={isSmallScreen} toggleNav={toggleNavVisible} />
|
||||||
|
</Suspense>
|
||||||
|
{hasAccessToBookmarks && (
|
||||||
<>
|
<>
|
||||||
<div className="mt-1.5" />
|
<div className="mt-1.5" />
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<BookmarkNav tags={tags} setTags={setTags} isSmallScreen={isSmallScreen} />
|
<BookmarkNav tags={tags} setTags={setTags} isSmallScreen={isSmallScreen} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
),
|
),
|
||||||
[hasAccessToBookmarks, tags, isSmallScreen],
|
[hasAccessToBookmarks, tags, isSmallScreen, toggleNavVisible],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [isSearchLoading, setIsSearchLoading] = useState(
|
const [isSearchLoading, setIsSearchLoading] = useState(
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import React, { useCallback, useContext } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { LayoutGrid } from 'lucide-react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { QueryKeys, Constants, PermissionTypes, Permissions } from 'librechat-data-provider';
|
import { QueryKeys, Constants } from 'librechat-data-provider';
|
||||||
import { TooltipAnchor, NewChatIcon, MobileSidebar, Sidebar, Button } from '@librechat/client';
|
import { TooltipAnchor, NewChatIcon, MobileSidebar, Sidebar, Button } from '@librechat/client';
|
||||||
import type { TMessage } from 'librechat-data-provider';
|
import type { TMessage } from 'librechat-data-provider';
|
||||||
import { useLocalize, useNewConvo, useHasAccess, AuthContext } from '~/hooks';
|
import { useLocalize, useNewConvo } from '~/hooks';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
export default function NewChat({
|
export default function NewChat({
|
||||||
|
|
@ -27,15 +26,6 @@ export default function NewChat({
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const { conversation } = store.useCreateConversationAtom(index);
|
const { conversation } = store.useCreateConversationAtom(index);
|
||||||
const authContext = useContext(AuthContext);
|
|
||||||
const hasAccessToAgents = useHasAccess({
|
|
||||||
permissionType: PermissionTypes.AGENTS,
|
|
||||||
permission: Permissions.USE,
|
|
||||||
});
|
|
||||||
const hasAccessToMarketplace = useHasAccess({
|
|
||||||
permissionType: PermissionTypes.MARKETPLACE,
|
|
||||||
permission: Permissions.USE,
|
|
||||||
});
|
|
||||||
|
|
||||||
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = useCallback(
|
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
|
|
@ -57,21 +47,6 @@ export default function NewChat({
|
||||||
[queryClient, conversation, newConvo, navigate, toggleNav, isSmallScreen],
|
[queryClient, conversation, newConvo, navigate, toggleNav, isSmallScreen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleAgentMarketplace = useCallback(() => {
|
|
||||||
navigate('/agents');
|
|
||||||
if (isSmallScreen) {
|
|
||||||
toggleNav();
|
|
||||||
}
|
|
||||||
}, [navigate, isSmallScreen, toggleNav]);
|
|
||||||
|
|
||||||
// Check if auth is ready (avoid race conditions)
|
|
||||||
const authReady =
|
|
||||||
authContext?.isAuthenticated !== undefined &&
|
|
||||||
(authContext?.isAuthenticated === false || authContext?.user !== undefined);
|
|
||||||
|
|
||||||
// Show agent marketplace when marketplace permission is enabled, auth is ready, and user has access to agents
|
|
||||||
const showAgentMarketplace = authReady && hasAccessToAgents && hasAccessToMarketplace;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between py-[2px] md:py-2">
|
<div className="flex items-center justify-between py-[2px] md:py-2">
|
||||||
|
|
@ -91,25 +66,7 @@ export default function NewChat({
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="flex">
|
<div className="flex gap-0.5">
|
||||||
{showAgentMarketplace && (
|
|
||||||
<div className="flex">
|
|
||||||
<TooltipAnchor
|
|
||||||
description={localize('com_agents_marketplace')}
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
data-testid="nav-agents-marketplace-button"
|
|
||||||
aria-label={localize('com_agents_marketplace')}
|
|
||||||
className="rounded-full border-none bg-transparent p-2 hover:bg-surface-hover md:rounded-xl"
|
|
||||||
onClick={handleAgentMarketplace}
|
|
||||||
>
|
|
||||||
<LayoutGrid className="icon-md md:h-6 md:w-6" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{headerButtons}
|
{headerButtons}
|
||||||
|
|
||||||
<TooltipAnchor
|
<TooltipAnchor
|
||||||
|
|
@ -123,7 +80,7 @@ export default function NewChat({
|
||||||
className="rounded-full border-none bg-transparent p-2 hover:bg-surface-hover md:rounded-xl"
|
className="rounded-full border-none bg-transparent p-2 hover:bg-surface-hover md:rounded-xl"
|
||||||
onClick={clickHandler}
|
onClick={clickHandler}
|
||||||
>
|
>
|
||||||
<NewChatIcon className="icon-md md:h-6 md:w-6" />
|
<NewChatIcon className="icon-lg text-text-primary" />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1179,21 +1179,12 @@
|
||||||
"com_ui_permissions_failed_load": "Failed to load permissions. Please try again.",
|
"com_ui_permissions_failed_load": "Failed to load permissions. Please try again.",
|
||||||
"com_ui_permissions_updated_success": "Permissions updated successfully",
|
"com_ui_permissions_updated_success": "Permissions updated successfully",
|
||||||
"com_ui_permissions_failed_update": "Failed to update permissions. Please try again.",
|
"com_ui_permissions_failed_update": "Failed to update permissions. Please try again.",
|
||||||
"com_ui_manage_permissions_for": "Manage Permissions for",
|
|
||||||
"com_ui_current_access": "Current Access",
|
|
||||||
"com_ui_no_users_groups_access": "No users or groups have access",
|
|
||||||
"com_ui_shared_with_count": "Shared with {{0}} {{1}}{{2}}",
|
|
||||||
"com_ui_person": "person",
|
|
||||||
"com_ui_people": "people",
|
"com_ui_people": "people",
|
||||||
"com_ui_and_public": " and public",
|
|
||||||
"com_ui_revoke_all": "Revoke All",
|
|
||||||
"com_ui_loading_permissions": "Loading permissions...",
|
|
||||||
"com_ui_user_group_permissions": "User & Group Permissions",
|
"com_ui_user_group_permissions": "User & Group Permissions",
|
||||||
"com_ui_no_individual_access": "No individual users or groups have access to this agent",
|
"com_ui_no_individual_access": "No individual users or groups have access to this agent",
|
||||||
"com_ui_share_everyone": "Share with everyone",
|
"com_ui_share_everyone": "Share with everyone",
|
||||||
"com_ui_share_everyone_description_var": "This {{resource}} will be available to everyone. Please make sure the {{resource}} is really meant to be shared with everyone. Be careful with your data.",
|
"com_ui_share_everyone_description_var": "This {{resource}} will be available to everyone. Please make sure the {{resource}} is really meant to be shared with everyone. Be careful with your data.",
|
||||||
"com_ui_save_changes": "Save Changes",
|
"com_ui_save_changes": "Save Changes",
|
||||||
"com_ui_unsaved_changes": "You have unsaved changes",
|
|
||||||
"com_ui_everyone_permission_level": "Everyone's permission level",
|
"com_ui_everyone_permission_level": "Everyone's permission level",
|
||||||
"com_ui_at_least_one_owner_required": "At least one owner is required",
|
"com_ui_at_least_one_owner_required": "At least one owner is required",
|
||||||
"com_agents_marketplace": "Agent Marketplace",
|
"com_agents_marketplace": "Agent Marketplace",
|
||||||
|
|
@ -1244,7 +1235,6 @@
|
||||||
"com_agents_agent_card_label": "{{name}} agent. {{description}}",
|
"com_agents_agent_card_label": "{{name}} agent. {{description}}",
|
||||||
"com_agents_empty_state_heading": "No agents found",
|
"com_agents_empty_state_heading": "No agents found",
|
||||||
"com_agents_search_empty_heading": "No search results",
|
"com_agents_search_empty_heading": "No search results",
|
||||||
"com_agents_no_description": "No description available",
|
|
||||||
"com_agents_results_for": "Results for '{{query}}'",
|
"com_agents_results_for": "Results for '{{query}}'",
|
||||||
"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_agents_no_more_results": "You've reached the end of the results",
|
"com_agents_no_more_results": "You've reached the end of the results",
|
||||||
|
|
|
||||||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -51470,7 +51470,7 @@
|
||||||
},
|
},
|
||||||
"packages/client": {
|
"packages/client": {
|
||||||
"name": "@librechat/client",
|
"name": "@librechat/client",
|
||||||
"version": "0.2.4",
|
"version": "0.2.5",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rollup/plugin-alias": "^5.1.0",
|
"@rollup/plugin-alias": "^5.1.0",
|
||||||
"@rollup/plugin-commonjs": "^25.0.2",
|
"@rollup/plugin-commonjs": "^25.0.2",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@librechat/client",
|
"name": "@librechat/client",
|
||||||
"version": "0.2.4",
|
"version": "0.2.5",
|
||||||
"description": "React components for LibreChat",
|
"description": "React components for LibreChat",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.es.js",
|
"module": "dist/index.es.js",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ const buttonVariants = cva(
|
||||||
destructive:
|
destructive:
|
||||||
'bg-surface-destructive text-destructive-foreground hover:bg-surface-destructive-hover',
|
'bg-surface-destructive text-destructive-foreground hover:bg-surface-destructive-hover',
|
||||||
outline:
|
outline:
|
||||||
'text-text-primary border border-border-light bg-background hover:bg-accent hover:text-accent-foreground',
|
'text-text-primary border border-border-light bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||||
ghost: 'hover:bg-surface-hover hover:text-accent-foreground',
|
ghost: 'hover:bg-surface-hover hover:text-accent-foreground',
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue