- {/* Scrollable container */} -
- {/* Hero Section - scrolls away */} - {!isSmallScreen && ( -
-
-

- {localize('com_agents_marketplace')} -

-

- {localize('com_agents_marketplace_subtitle')} -

-
-
- )} - {/* Sticky wrapper for search bar and categories */} -
-
- {isSmallScreen ? ( -
- - -
- ) : null} - {/* Search bar */} -
- - {/* TODO: Remove this once we have a better way to handle admin settings */} - {!isSmallScreen && } -
- - {/* Category tabs */} +
+ {/* The compact header has no room for a visible title, but a landmark label is + not reachable by heading navigation: without this the document's outline + would start at an agent card. */} +

+ {localize('com_agents_marketplace')} +

+
+
+ {isSmallScreen && } + + +
+
+
-
- {/* Scrollable content area */} -
- {/* Two-pane animated container wrapping category header + grid */} -
- {/* Current content pane */} -
- {/* Category header - only show when not searching */} - {!searchQuery && ( -
- {(() => { - // 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 ( -
-

{name}

- {description && ( -

{description}

- )} -
- ); - })()} -
- )} - - {/* Agent grid */} - -
- - {/* Next content pane, only during transition */} - {isTransitioning && nextCategory && ( -
- {/* Category header - only show when not searching */} - {!searchQuery && ( -
- {(() => { - // 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[0], - ) - : categoryData.description || '', - }; - } - - // Fallback for unknown categories - return { - name: - (nextCategory || '').charAt(0).toUpperCase() + - (nextCategory || '').slice(1), - description: '', - }; - }; - - const { name, description } = getCategoryData(); - - return ( -
-

{name}

- {description && ( -

{description}

- )} -
- ); - })()} -
- )} - - {/* Agent grid */} - -
- )} - - {/* Note: Using Tailwind keyframes for slide in/out animations */} +
+ +
+
+ {/* Deliberately unkeyed: the grid's own `scopeKey` already carries the category, + so it replaces the results itself and hands focus back when the scope + changes. Remounting it on the category instead would destroy an open detail + dialog and its return target, and the replacement grid starts with an empty + `previousScopeKeyRef`, so it reads that render as a first mount and leaves + focus on the document. */} + +
diff --git a/client/src/components/Agents/MarketplaceAdminSettings.tsx b/client/src/components/Agents/MarketplaceAdminSettings.tsx index dbe37feda62..53869ce728d 100644 --- a/client/src/components/Agents/MarketplaceAdminSettings.tsx +++ b/client/src/components/Agents/MarketplaceAdminSettings.tsx @@ -10,7 +10,7 @@ const permissions: PermissionConfig[] = [ { permission: Permissions.USE, labelKey: 'com_ui_marketplace_allow_use' }, ]; -const MarketplaceAdminSettings = ({ compact = false }: { compact?: boolean }) => { +const MarketplaceAdminSettings = () => { const localize = useLocalize(); const { showToast } = useToastContext(); @@ -23,22 +23,16 @@ const MarketplaceAdminSettings = ({ compact = false }: { compact?: boolean }) => }, }); - const trigger = compact ? ( + /* `outline` matches the search field it sits beside — same border token, same + radius — and `size-9` keeps the two controls the same height. */ + const trigger = ( - ) : ( - ); diff --git a/client/src/components/Agents/MarketplaceContext.tsx b/client/src/components/Agents/MarketplaceContext.tsx index 9193cbb82b2..d6338df5e0e 100644 --- a/client/src/components/Agents/MarketplaceContext.tsx +++ b/client/src/components/Agents/MarketplaceContext.tsx @@ -8,10 +8,42 @@ import { useChatHelpers } from '~/hooks'; */ interface MarketplaceProviderProps { children: React.ReactNode; + host: MarketplaceHost; } -export const MarketplaceProvider: React.FC = ({ children }) => { +/** + * App-global chat operations the marketplace invokes but does not own. + * + * Starting an agent from a card is a new conversation with that agent, not another + * column beside whatever was already open and not the transcript the last new chat + * left behind. Both of those live in the app's conversation state, so the host + * performs the reset and the marketplace only asks for it. The host arrives as a prop + * rather than being built here: a context this feature fills from `~/store` itself + * would still be the feature reaching for shell state, and would not survive the move + * to its own workspace. + */ +export interface MarketplaceHost { + resetNewConversation: () => void; +} + +export const MarketplaceHostContext = React.createContext(null); + +/** Throws rather than defaulting to a no-op: a missing host would silently leave the + * previous conversations open, which is the whole reason the reset exists. */ +export function useMarketplaceHost(): MarketplaceHost { + const host = React.useContext(MarketplaceHostContext); + if (host == null) { + throw new Error('useMarketplaceHost must be used inside a MarketplaceProvider'); + } + return host; +} + +export const MarketplaceProvider: React.FC = ({ children, host }) => { const chatHelpers = useChatHelpers(0, 'new'); - return {children}; + return ( + + {children} + + ); }; diff --git a/client/src/components/Agents/MineFilterToggle.tsx b/client/src/components/Agents/MineFilterToggle.tsx new file mode 100644 index 00000000000..c3e808843f1 --- /dev/null +++ b/client/src/components/Agents/MineFilterToggle.tsx @@ -0,0 +1,37 @@ +import { UserRound } from 'lucide-react'; +import { Button, TooltipAnchor } from '@librechat/client'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +interface MineFilterToggleProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; +} + +const MineFilterToggle: React.FC = ({ checked, onCheckedChange }) => { + const localize = useLocalize(); + + return ( + onCheckedChange(!checked)} + className={cn( + 'h-8 gap-1.5 px-2.5 text-xs transition-none', + checked && 'border-border-heavy bg-surface-active-alt hover:bg-surface-active-alt', + )} + > +