From ee330848484fa0085909f97c81bd66d364445c45 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Tue, 5 Aug 2025 01:53:27 +0200 Subject: [PATCH] feat: Implement animated category transitions in AgentMarketplace and update NewChat component layout --- client/src/components/Agents/Marketplace.tsx | 284 +++++++++++++++---- client/src/components/Nav/NewChat.tsx | 42 ++- client/tailwind.config.cjs | 20 ++ 3 files changed, 266 insertions(+), 80 deletions(-) diff --git a/client/src/components/Agents/Marketplace.tsx b/client/src/components/Agents/Marketplace.tsx index b489cee272..742cf57cd0 100644 --- a/client/src/components/Agents/Marketplace.tsx +++ b/client/src/components/Agents/Marketplace.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useRef } from 'react'; import { useRecoilState } from 'recoil'; import { useOutletContext } from 'react-router-dom'; import { useQueryClient } from '@tanstack/react-query'; @@ -48,6 +48,16 @@ const AgentMarketplace: React.FC = ({ className = '' }) = const searchQuery = searchParams.get('q') || ''; const selectedAgentId = searchParams.get('agent_id') || ''; + // Animation state + type Direction = 'left' | 'right'; + const [displayCategory, setDisplayCategory] = useState(activeTab); + const [nextCategory, setNextCategory] = useState(null); + const [isTransitioning, setIsTransitioning] = useState(false); + const [animationDirection, setAnimationDirection] = useState('right'); + + // Keep a ref of initial mount to avoid animating first sync + const didInitRef = useRef(false); + // Local state const [isDetailOpen, setIsDetailOpen] = useState(false); const [selectedAgent, setSelectedAgent] = useState(null); @@ -110,22 +120,89 @@ const AgentMarketplace: React.FC = ({ className = '' }) = }; /** - * Handle category tab selection changes - * - * @param tabValue - The selected category value + * Determine ordered tabs to compute indices for direction + */ + const orderedTabs = useMemo(() => { + const dynamic = (categoriesQuery.data || []).map((c) => c.value); + // Ensure unique and stable order + const set = new Set(['promoted', 'all', ...dynamic]); + return Array.from(set); + }, [categoriesQuery.data]); + + const getTabIndex = (tab: string): number => { + const idx = orderedTabs.indexOf(tab); + return idx >= 0 ? idx : 0; + }; + + /** + * Handle category tab selection changes with directional animation */ const handleTabChange = (tabValue: string) => { + if (tabValue === activeTab || isTransitioning) { + // Ignore redundant or rapid clicks during transition + return; + } + + const currentIndex = getTabIndex(displayCategory); + const newIndex = getTabIndex(tabValue); + const direction: Direction = newIndex > currentIndex ? 'right' : 'left'; + + setAnimationDirection(direction); + setNextCategory(tabValue); + setIsTransitioning(true); + + // Update URL immediately, preserving current search params const currentSearchParams = searchParams.toString(); const searchParamsStr = currentSearchParams ? `?${currentSearchParams}` : ''; - - // Navigate to the selected category if (tabValue === 'promoted') { navigate(`/agents${searchParamsStr}`); } else { navigate(`/agents/${tabValue}${searchParamsStr}`); } + + // Complete transition after 300ms + window.setTimeout(() => { + setDisplayCategory(tabValue); + setNextCategory(null); + setIsTransitioning(false); + }, 300); }; + /** + * Sync animation when URL changes externally (back/forward or deep links) + */ + useEffect(() => { + if (!didInitRef.current) { + // First render: do not animate; just set display to current active tab + didInitRef.current = true; + setDisplayCategory(activeTab); + return; + } + if (isTransitioning || activeTab === displayCategory) { + return; + } + // Compute direction vs current displayCategory and animate + const currentIndex = getTabIndex(displayCategory); + const newIndex = getTabIndex(activeTab); + const direction: Direction = newIndex > currentIndex ? 'right' : 'left'; + + setAnimationDirection(direction); + setNextCategory(activeTab); + setIsTransitioning(true); + + const timeoutId = window.setTimeout(() => { + setDisplayCategory(activeTab); + setNextCategory(null); + setIsTransitioning(false); + }, 300); + + return () => { + window.clearTimeout(timeoutId); + }; + }, [activeTab, displayCategory, isTransitioning, orderedTabs]); + + // No longer needed with keyframes + /** * Handle search query changes * @@ -285,63 +362,156 @@ const AgentMarketplace: React.FC = ({ className = '' }) = {/* Scrollable content area */}
- {/* Category header - only show when not searching */} - {!searchQuery && ( -
- {(() => { - // Get category data for display - const getCategoryData = () => { - if (activeTab === 'promoted') { - return { - name: localize('com_agents_top_picks'), - description: localize('com_agents_recommended'), + {/* 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: '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 === displayCategory, + ); + if (categoryData) { + return { + name: categoryData.label, + description: categoryData.description || '', + }; + } + + // Fallback for unknown categories + return { + name: + displayCategory.charAt(0).toUpperCase() + + displayCategory.slice(1), + description: '', + }; }; - } - 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 || '', - }; - } + const { name, description } = getCategoryData(); - // Fallback for unknown categories - return { - name: activeTab.charAt(0).toUpperCase() + activeTab.slice(1), - description: '', - }; - }; + return ( +
+

{name}

+ {description && ( +

{description}

+ )} +
+ ); + })()} +
+ )} - const { name, description } = getCategoryData(); - - return ( -
-

{name}

- {description && ( -

{description}

- )} -
- ); - })()} + {/* Agent grid */} +
- )} - {/* 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: '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 === nextCategory, + ); + if (categoryData) { + return { + name: categoryData.label, + description: 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 */} +
{/* Agent detail dialog */} diff --git a/client/src/components/Nav/NewChat.tsx b/client/src/components/Nav/NewChat.tsx index 82b7d78bed..c7e82336d9 100644 --- a/client/src/components/Nav/NewChat.tsx +++ b/client/src/components/Nav/NewChat.tsx @@ -92,7 +92,26 @@ export default function NewChat({ } />
+ {showAgentMarketplace && ( +
+ + + + } + /> +
+ )} {headerButtons} +
- - {/* Agent Marketplace button - separate row like ChatGPT */} - {showAgentMarketplace && ( -
- - - - {localize('com_agents_marketplace')} - - - } - /> -
- )} {subHeaders != null ? subHeaders : null} ); diff --git a/client/tailwind.config.cjs b/client/tailwind.config.cjs index e2f493ad60..c30d2ca703 100644 --- a/client/tailwind.config.cjs +++ b/client/tailwind.config.cjs @@ -31,11 +31,31 @@ module.exports = { from: { height: 'var(--radix-accordion-content-height)' }, to: { height: 0 }, }, + 'slide-in-right': { + '0%': { transform: 'translateX(100%)' }, + '100%': { transform: 'translateX(0)' }, + }, + 'slide-in-left': { + '0%': { transform: 'translateX(-100%)' }, + '100%': { transform: 'translateX(0)' }, + }, + 'slide-out-left': { + '0%': { transform: 'translateX(0)' }, + '100%': { transform: 'translateX(-100%)' }, + }, + 'slide-out-right': { + '0%': { transform: 'translateX(0)' }, + '100%': { transform: 'translateX(100%)' }, + }, }, animation: { 'fade-in': 'fadeIn 0.5s ease-out forwards', 'accordion-down': 'accordion-down 0.2s ease-out', 'accordion-up': 'accordion-up 0.2s ease-out', + 'slide-in-right': 'slide-in-right 300ms cubic-bezier(0.25, 0.1, 0.25, 1)', + 'slide-in-left': 'slide-in-left 300ms cubic-bezier(0.25, 0.1, 0.25, 1)', + 'slide-out-left': 'slide-out-left 300ms cubic-bezier(0.25, 0.1, 0.25, 1)', + 'slide-out-right': 'slide-out-right 300ms cubic-bezier(0.25, 0.1, 0.25, 1)', }, colors: { gray: {