🔖 fix: Agent Marketplace Bookmark and New Chat buttons (#9549)

* don't require conversation for bookmark button

* wrap marketplace component so it can correctly use context hooks

* chore: re-order import statement for MarketplaceProvider

---------

Co-authored-by: Danny Avila <danacordially@gmail.com>
This commit is contained in:
Federico Ruggi 2025-09-11 01:01:34 +02:00 committed by GitHub
parent 04c3a5a861
commit 31445e391a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 203 additions and 214 deletions

View file

@ -11,7 +11,6 @@ import { useDocumentTitle, useHasAccess, useLocalize, TranslationKeys } from '~/
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider'; import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
import MarketplaceAdminSettings from './MarketplaceAdminSettings'; import MarketplaceAdminSettings from './MarketplaceAdminSettings';
import { SidePanelProvider, useChatContext } from '~/Providers'; import { SidePanelProvider, useChatContext } from '~/Providers';
import { MarketplaceProvider } from './MarketplaceContext';
import { SidePanelGroup } from '~/components/SidePanel'; import { SidePanelGroup } from '~/components/SidePanel';
import { OpenSidebar } from '~/components/Chat/Menus'; import { OpenSidebar } from '~/components/Chat/Menus';
import CategoryTabs from './CategoryTabs'; import CategoryTabs from './CategoryTabs';
@ -272,100 +271,176 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
} }
return ( return (
<div className={`relative flex w-full grow overflow-hidden bg-presentation ${className}`}> <div className={`relative flex w-full grow overflow-hidden bg-presentation ${className}`}>
<MarketplaceProvider> <SidePanelProvider>
<SidePanelProvider> <SidePanelGroup
<SidePanelGroup defaultLayout={defaultLayout}
defaultLayout={defaultLayout} fullPanelCollapse={fullCollapse}
fullPanelCollapse={fullCollapse} defaultCollapsed={defaultCollapsed}
defaultCollapsed={defaultCollapsed} >
> <main className="flex h-full flex-col overflow-hidden" role="main">
<main className="flex h-full flex-col overflow-hidden" role="main"> {/* Scrollable container */}
{/* Scrollable container */} <div
<div 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" >
> {/* 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"> <div className="mx-1 flex items-center gap-2">
<div className="mx-1 flex items-center gap-2"> {!navVisible ? (
{!navVisible ? ( <>
<> <OpenSidebar setNavVisible={setNavVisible} />
<OpenSidebar setNavVisible={setNavVisible} /> <TooltipAnchor
<TooltipAnchor description={localize('com_ui_new_chat')}
description={localize('com_ui_new_chat')} render={
render={ <Button
<Button size="icon"
size="icon" variant="outline"
variant="outline" data-testid="agents-new-chat-button"
data-testid="agents-new-chat-button" aria-label={localize('com_ui_new_chat')}
aria-label={localize('com_ui_new_chat')} className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden" onClick={handleNewChat}
onClick={handleNewChat} >
> <NewChatIcon />
<NewChatIcon /> </Button>
</Button> }
} />
/> </>
</> ) : (
) : ( // Invisible placeholder to maintain height
// Invisible placeholder to maintain height <div className="h-10 w-10" />
<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}
/>
</div> </div>
</div> </div>
{/* Scrollable content area */} )}
<div className="container mx-auto max-w-4xl px-4 pb-8"> {/* Hero Section - scrolls away */}
{/* Two-pane animated container wrapping category header + grid */} {!isSmallScreen && (
<div className="relative overflow-hidden"> <div className="container mx-auto max-w-4xl">
{/* Current content pane */} <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}
/>
</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}
scrollElement={scrollContainerRef.current}
/>
</div>
{/* Next content pane, only during transition */}
{isTransitioning && nextCategory && (
<div <div
className={cn( className={cn(
isTransitioning && 'absolute inset-0',
(animationDirection === 'right' animationDirection === 'right'
? 'motion-safe:animate-slide-out-left' ? 'motion-safe:animate-slide-in-right'
: 'motion-safe:animate-slide-out-right'), : 'motion-safe:animate-slide-in-left',
)} )}
key={`pane-current-${displayCategory}`} key={`pane-next-${nextCategory}-${animationDirection}`}
> >
{/* Category header - only show when not searching */} {/* Category header - only show when not searching */}
{!searchQuery && ( {!searchQuery && (
@ -373,13 +448,13 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
{(() => { {(() => {
// Get category data for display // Get category data for display
const getCategoryData = () => { const getCategoryData = () => {
if (displayCategory === 'promoted') { if (nextCategory === 'promoted') {
return { return {
name: localize('com_agents_top_picks'), name: localize('com_agents_top_picks'),
description: localize('com_agents_recommended'), description: localize('com_agents_recommended'),
}; };
} }
if (displayCategory === 'all') { if (nextCategory === 'all') {
return { return {
name: localize('com_agents_all'), name: localize('com_agents_all'),
description: localize('com_agents_all_description'), description: localize('com_agents_all_description'),
@ -388,7 +463,7 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
// Find the category in the API data // Find the category in the API data
const categoryData = categoriesQuery.data?.find( const categoryData = categoriesQuery.data?.find(
(cat) => cat.value === displayCategory, (cat) => cat.value === nextCategory,
); );
if (categoryData) { if (categoryData) {
return { return {
@ -396,7 +471,9 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
? localize(categoryData.label as TranslationKeys) ? localize(categoryData.label as TranslationKeys)
: categoryData.label, : categoryData.label,
description: categoryData.description?.startsWith('com_') description: categoryData.description?.startsWith('com_')
? localize(categoryData.description as TranslationKeys) ? localize(
categoryData.description as Parameters<typeof localize>[0],
)
: categoryData.description || '', : categoryData.description || '',
}; };
} }
@ -404,8 +481,8 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
// Fallback for unknown categories // Fallback for unknown categories
return { return {
name: name:
displayCategory.charAt(0).toUpperCase() + (nextCategory || '').charAt(0).toUpperCase() +
displayCategory.slice(1), (nextCategory || '').slice(1),
description: '', description: '',
}; };
}; };
@ -426,113 +503,30 @@ const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) =
{/* Agent grid */} {/* Agent grid */}
<AgentGrid <AgentGrid
key={`grid-${displayCategory}`} key={`grid-${nextCategory}`}
category={displayCategory} category={nextCategory}
searchQuery={searchQuery} searchQuery={searchQuery}
onSelectAgent={handleAgentSelect} onSelectAgent={handleAgentSelect}
scrollElement={scrollContainerRef.current} scrollElement={scrollContainerRef.current}
/> />
</div> </div>
)}
{/* Next content pane, only during transition */} {/* Note: Using Tailwind keyframes for slide in/out animations */}
{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}
scrollElement={scrollContainerRef.current}
/>
</div>
)}
{/* Note: Using Tailwind keyframes for slide in/out animations */}
</div>
</div> </div>
{/* Agent detail dialog */}
{isDetailOpen && selectedAgent && (
<AgentDetail
agent={selectedAgent}
isOpen={isDetailOpen}
onClose={handleDetailClose}
/>
)}
</div> </div>
</main> {/* Agent detail dialog */}
</SidePanelGroup> {isDetailOpen && selectedAgent && (
</SidePanelProvider> <AgentDetail
</MarketplaceProvider> agent={selectedAgent}
isOpen={isDetailOpen}
onClose={handleDetailClose}
/>
)}
</div>
</main>
</SidePanelGroup>
</SidePanelProvider>
</div> </div>
); );
}; };

View file

@ -1,6 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { FC } from 'react'; import type { FC } from 'react';
import { useRecoilValue } from 'recoil';
import { TooltipAnchor } from '@librechat/client'; import { TooltipAnchor } from '@librechat/client';
import { Menu, MenuButton, MenuItems } from '@headlessui/react'; import { Menu, MenuButton, MenuItems } from '@headlessui/react';
import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons'; import { BookmarkFilledIcon, BookmarkIcon } from '@radix-ui/react-icons';
@ -9,7 +8,6 @@ import { useGetConversationTags } from '~/data-provider';
import BookmarkNavItems from './BookmarkNavItems'; import BookmarkNavItems from './BookmarkNavItems';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
import { cn } from '~/utils'; import { cn } from '~/utils';
import store from '~/store';
type BookmarkNavProps = { type BookmarkNavProps = {
tags: string[]; tags: string[];
@ -20,7 +18,6 @@ type BookmarkNavProps = {
const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags, isSmallScreen }: BookmarkNavProps) => { const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags, isSmallScreen }: BookmarkNavProps) => {
const localize = useLocalize(); const localize = useLocalize();
const { data } = useGetConversationTags(); const { data } = useGetConversationTags();
const conversation = useRecoilValue(store.conversationByIndex(0));
const label = useMemo( const label = useMemo(
() => (tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')), () => (tags.length > 0 ? tags.join(', ') : localize('com_ui_bookmarks')),
[tags, localize], [tags, localize],
@ -56,11 +53,9 @@ const BookmarkNav: FC<BookmarkNavProps> = ({ tags, setTags, isSmallScreen }: Boo
anchor="bottom" anchor="bottom"
className="absolute left-0 top-full z-[100] mt-1 w-60 translate-y-0 overflow-hidden rounded-lg bg-surface-secondary p-1.5 shadow-lg outline-none" className="absolute left-0 top-full z-[100] mt-1 w-60 translate-y-0 overflow-hidden rounded-lg bg-surface-secondary p-1.5 shadow-lg outline-none"
> >
{data && conversation && ( {data && (
<BookmarkContext.Provider value={{ bookmarks: data.filter((tag) => tag.count > 0) }}> <BookmarkContext.Provider value={{ bookmarks: data.filter((tag) => tag.count > 0) }}>
<BookmarkNavItems <BookmarkNavItems
// Currently selected conversation
conversation={conversation}
// List of selected tags(string) // List of selected tags(string)
tags={tags} tags={tags}
// When a user selects a tag, this `setTags` function is called to refetch the list of conversations for the selected tag // When a user selects a tag, this `setTags` function is called to refetch the list of conversations for the selected tag

View file

@ -1,25 +1,16 @@
import { useEffect, useState, type FC } from 'react'; import { type FC } from 'react';
import { CrossCircledIcon } from '@radix-ui/react-icons'; import { CrossCircledIcon } from '@radix-ui/react-icons';
import type { TConversation } from 'librechat-data-provider';
import { useBookmarkContext } from '~/Providers/BookmarkContext'; import { useBookmarkContext } from '~/Providers/BookmarkContext';
import { BookmarkItems, BookmarkItem } from '~/components/Bookmarks'; import { BookmarkItems, BookmarkItem } from '~/components/Bookmarks';
import { useLocalize } from '~/hooks'; import { useLocalize } from '~/hooks';
const BookmarkNavItems: FC<{ const BookmarkNavItems: FC<{
conversation: TConversation;
tags: string[]; tags: string[];
setTags: (tags: string[]) => void; setTags: (tags: string[]) => void;
}> = ({ conversation, tags = [], setTags }) => { }> = ({ tags = [], setTags }) => {
const [currentConversation, setCurrentConversation] = useState<TConversation>();
const { bookmarks } = useBookmarkContext(); const { bookmarks } = useBookmarkContext();
const localize = useLocalize(); const localize = useLocalize();
useEffect(() => {
if (!currentConversation) {
setCurrentConversation(conversation);
}
}, [conversation, currentConversation]);
const getUpdatedSelected = (tag: string) => { const getUpdatedSelected = (tag: string) => {
if (tags.some((selectedTag) => selectedTag === tag)) { if (tags.some((selectedTag) => selectedTag === tag)) {
return tags.filter((selectedTag) => selectedTag !== tag); return tags.filter((selectedTag) => selectedTag !== tag);

View file

@ -8,6 +8,7 @@ import {
TwoFactorScreen, TwoFactorScreen,
RequestPasswordReset, RequestPasswordReset,
} from '~/components/Auth'; } from '~/components/Auth';
import { MarketplaceProvider } from '~/components/Agents/MarketplaceContext';
import AgentMarketplace from '~/components/Agents/Marketplace'; import AgentMarketplace from '~/components/Agents/Marketplace';
import { OAuthSuccess, OAuthError } from '~/components/OAuth'; import { OAuthSuccess, OAuthError } from '~/components/OAuth';
import { AuthContextProvider } from '~/hooks/AuthContext'; import { AuthContextProvider } from '~/hooks/AuthContext';
@ -112,11 +113,19 @@ export const router = createBrowserRouter(
}, },
{ {
path: 'agents', path: 'agents',
element: <AgentMarketplace />, element: (
<MarketplaceProvider>
<AgentMarketplace />
</MarketplaceProvider>
),
}, },
{ {
path: 'agents/:category', path: 'agents/:category',
element: <AgentMarketplace />, element: (
<MarketplaceProvider>
<AgentMarketplace />
</MarketplaceProvider>
),
}, },
], ],
}, },