From e45ca5f6d0abac0e8c672d121f9277ae7aa9562a Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 23 Sep 2025 11:10:53 -0700 Subject: [PATCH 1/2] initial impl --- web/src/app/api/agents/route.ts | 12 ++++++---- web/src/app/store/page.tsx | 16 +++++-------- web/src/app/store/store-client.tsx | 10 +++++--- web/src/components/ui/relative-time.tsx | 32 +++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 17 deletions(-) create mode 100644 web/src/components/ui/relative-time.tsx diff --git a/web/src/app/api/agents/route.ts b/web/src/app/api/agents/route.ts index 23431c1e20..ec86a22feb 100644 --- a/web/src/app/api/agents/route.ts +++ b/web/src/app/api/agents/route.ts @@ -6,8 +6,8 @@ import { unstable_cache } from 'next/cache' import { logger } from '@/util/logger' -// Cache for 60 seconds with stale-while-revalidate -export const revalidate = 60 +// Enable static generation for API route +export const revalidate = 600 // Cache for 10 minutes // Cached function for expensive agent aggregations const getCachedAgents = unstable_cache( @@ -251,7 +251,7 @@ const getCachedAgents = unstable_cache( }, ['agents-data'], { - revalidate: 60, + revalidate: 60 * 10, // Cache for 10 minutes tags: ['agents'], } ) @@ -265,9 +265,13 @@ export async function GET() { // Add cache headers for CDN and browser caching response.headers.set( 'Cache-Control', - 'public, max-age=60, s-maxage=60, stale-while-revalidate=300' + 'public, max-age=600, s-maxage=600, stale-while-revalidate=600' ) + // Add additional headers for better CDN caching + response.headers.set('Vary', 'Accept-Encoding') + response.headers.set('X-Content-Type-Options', 'nosniff') + return response } catch (error) { logger.error({ error }, 'Error fetching agents') diff --git a/web/src/app/store/page.tsx b/web/src/app/store/page.tsx index 0b34f043ab..9a33a86c39 100644 --- a/web/src/app/store/page.tsx +++ b/web/src/app/store/page.tsx @@ -1,7 +1,5 @@ import { Suspense } from 'react' import { Metadata } from 'next' -import { getServerSession } from 'next-auth/next' -import { authOptions } from '@/app/api/auth/[...nextauth]/auth-options' import { unstable_cache } from 'next/cache' import AgentStoreClient from './store-client' @@ -84,21 +82,19 @@ export const metadata: Metadata = { }, } -// Enable static generation with revalidation -export const revalidate = 60 +// Enable static site generation with ISR +export const revalidate = 60 * 10 // Revalidate every hour interface StorePageProps { searchParams: { [key: string]: string | string[] | undefined } } export default async function StorePage({ searchParams }: StorePageProps) { - // Get session for conditional rendering - const session = await getServerSession(authOptions) - - // Fetch agents data at build time / on revalidation + // Fetch agents data at build time const agentsData = await getCachedAgentsData() - // For now, pass empty array for publishers - client will handle this + // For static generation, we don't pass session data + // The client will handle authentication state const userPublishers: PublisherProfileResponse[] = [] return ( @@ -106,7 +102,7 @@ export default async function StorePage({ searchParams }: StorePageProps) { diff --git a/web/src/app/store/store-client.tsx b/web/src/app/store/store-client.tsx index d5e72aa034..978303d9a2 100644 --- a/web/src/app/store/store-client.tsx +++ b/web/src/app/store/store-client.tsx @@ -3,6 +3,7 @@ import { useState, useMemo, useCallback, memo, useEffect, useRef } from 'react' import { useQuery } from '@tanstack/react-query' import { useWindowVirtualizer } from '@tanstack/react-virtual' +import { useSession } from 'next-auth/react' import { Search, TrendingUp, @@ -29,7 +30,7 @@ import { SelectValue, } from '@/components/ui/select' import { toast } from '@/components/ui/use-toast' -import { formatRelativeTime } from '@/lib/date-utils' +import { RelativeTime } from '@/components/ui/relative-time' import { cn } from '@/lib/utils' import { useResponsiveColumns } from '@/hooks/use-responsive-columns' import type { Session } from 'next-auth' @@ -93,9 +94,12 @@ const EDITORS_CHOICE_AGENTS = [ export default function AgentStoreClient({ initialAgents, initialPublishers, - session, + session: initialSession, searchParams, }: AgentStoreClientProps) { + // Use client-side session for authentication state + const { data: clientSession } = useSession() + const session = clientSession || initialSession const [searchQuery, setSearchQuery] = useState( (searchParams.search as string) || '' ) @@ -403,7 +407,7 @@ export default function AgentStoreClient({ className="text-xs text-muted-foreground/60" title={new Date(agent.last_used).toLocaleString()} > - Used {formatRelativeTime(agent.last_used)} + Used )} diff --git a/web/src/components/ui/relative-time.tsx b/web/src/components/ui/relative-time.tsx new file mode 100644 index 0000000000..8712596b74 --- /dev/null +++ b/web/src/components/ui/relative-time.tsx @@ -0,0 +1,32 @@ +'use client' + +import { useState, useEffect } from 'react' +import { formatRelativeTime } from '@/lib/date-utils' + +interface RelativeTimeProps { + date: string +} + +export function RelativeTime({ date }: RelativeTimeProps) { + const [isClient, setIsClient] = useState(false) + const [relativeTime, setRelativeTime] = useState('') + + useEffect(() => { + setIsClient(true) + setRelativeTime(formatRelativeTime(date)) + + // Update every minute to keep relative time fresh + const interval = setInterval(() => { + setRelativeTime(formatRelativeTime(date)) + }, 60000) + + return () => clearInterval(interval) + }, [date]) + + // Show absolute date on server, relative time on client + if (!isClient) { + return <>{new Date(date).toLocaleDateString()} + } + + return <>{relativeTime} +} From 9d08cb8d9a3f3d0b0240acc2cf2ba0393a7a822b Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 23 Sep 2025 13:14:08 -0700 Subject: [PATCH 2/2] Redo store with infinite scroll instead of virtualized list --- bun.lock | 2 +- web/src/app/store/store-client.tsx | 401 +++++++++++++++-------------- 2 files changed, 213 insertions(+), 190 deletions(-) diff --git a/bun.lock b/bun.lock index 933bdf12b7..f5c7dba8af 100644 --- a/bun.lock +++ b/bun.lock @@ -236,7 +236,7 @@ }, "sdk": { "name": "@codebuff/sdk", - "version": "0.2.3", + "version": "0.2.4", "dependencies": { "@vscode/tree-sitter-wasm": "0.1.4", "ai": "^5.0.0", diff --git a/web/src/app/store/store-client.tsx b/web/src/app/store/store-client.tsx index 978303d9a2..8764b26d28 100644 --- a/web/src/app/store/store-client.tsx +++ b/web/src/app/store/store-client.tsx @@ -1,8 +1,7 @@ 'use client' -import { useState, useMemo, useCallback, memo, useEffect, useRef } from 'react' +import { useMemo, useCallback, memo, useEffect, useRef } from 'react' import { useQuery } from '@tanstack/react-query' -import { useWindowVirtualizer } from '@tanstack/react-virtual' import { useSession } from 'next-auth/react' import { Search, @@ -17,7 +16,7 @@ import { Copy, } from 'lucide-react' import Link from 'next/link' -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { Card, CardContent } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button' @@ -32,8 +31,8 @@ import { import { toast } from '@/components/ui/use-toast' import { RelativeTime } from '@/components/ui/relative-time' import { cn } from '@/lib/utils' -import { useResponsiveColumns } from '@/hooks/use-responsive-columns' import type { Session } from 'next-auth' +import { create } from 'zustand' interface AgentData { id: string @@ -48,25 +47,41 @@ interface AgentData { version: string created_at: string usage_count?: number - weekly_spent?: number // In dollars - total_spent?: number // In dollars - avg_cost_per_invocation?: number // In dollars + weekly_spent?: number + total_spent?: number + avg_cost_per_invocation?: number unique_users?: number last_used?: string - version_stats?: Record< - string, - { - weekly_dollars: number - total_dollars: number - total_invocations: number - avg_cost_per_run: number - unique_users: number - last_used?: Date - } - > + version_stats?: Record tags?: string[] } +interface AgentStoreState { + displayedCount: number + isLoadingMore: boolean + hasMore: boolean + searchQuery: string + sortBy: string + setDisplayedCount: (count: number) => void + setIsLoadingMore: (loading: boolean) => void + setHasMore: (hasMore: boolean) => void + setSearchQuery: (query: string) => void + setSortBy: (sortBy: string) => void +} + +const useAgentStoreState = create((set) => ({ + displayedCount: 0, + isLoadingMore: false, + hasMore: true, + searchQuery: '', + sortBy: 'cost', + setDisplayedCount: (count) => set({ displayedCount: count }), + setIsLoadingMore: (loading) => set({ isLoadingMore: loading }), + setHasMore: (hasMore) => set({ hasMore }), + setSearchQuery: (query) => set({ searchQuery: query }), + setSortBy: (sortBy) => set({ sortBy }), +})) + interface PublisherProfileResponse { id: string name: string @@ -91,6 +106,20 @@ const EDITORS_CHOICE_AGENTS = [ 'rampup-teacher-agent', ] +// Utility functions +const formatCurrency = (amount?: number) => { + if (!amount) return '$0.00' + if (amount >= 1000) return `${(amount / 1000).toFixed(1)}k` + return `${amount.toFixed(2)}` +} + +const formatUsageCount = (count?: number) => { + if (!count) return '0' + if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M` + if (count >= 1000) return `${(count / 1000).toFixed(1)}K` + return count.toString() +} + export default function AgentStoreClient({ initialAgents, initialPublishers, @@ -100,22 +129,48 @@ export default function AgentStoreClient({ // Use client-side session for authentication state const { data: clientSession } = useSession() const session = clientSession || initialSession - const [searchQuery, setSearchQuery] = useState( - (searchParams.search as string) || '' - ) - const [sortBy, setSortBy] = useState((searchParams.sort as string) || 'cost') - const columns = useResponsiveColumns() - // Normalize agent data for better performance + // Global state for persistence across navigation + const { + displayedCount, + isLoadingMore, + hasMore, + searchQuery, + sortBy, + setDisplayedCount, + setIsLoadingMore, + setHasMore, + setSearchQuery, + setSortBy, + } = useAgentStoreState() + + const observerRef = useRef(null) + const loadMoreRef = useRef(null) + const prevFilters = useRef({ searchQuery: '', sortBy: 'cost' }) + const isInitialized = useRef(false) + + // Initialize search/sort from URL params on first load only + useEffect(() => { + if (!isInitialized.current) { + const urlSearchQuery = (searchParams.search as string) || '' + const urlSortBy = (searchParams.sort as string) || 'cost' + + setSearchQuery(urlSearchQuery) + setSortBy(urlSortBy) + prevFilters.current = { searchQuery: urlSearchQuery, sortBy: urlSortBy } + isInitialized.current = true + } + }, [searchParams.search, searchParams.sort, setSearchQuery, setSortBy]) + + // Use ref to track loading state for IntersectionObserver + const loadingStateRef = useRef({ isLoadingMore, hasMore }) + useEffect(() => { + loadingStateRef.current = { isLoadingMore, hasMore } + }, [isLoadingMore, hasMore]) + + // Use the initial agents directly const agents = useMemo(() => { - return initialAgents.map((agent) => ({ - ...agent, - // Precompute expensive operations - createdAtMs: new Date(agent.created_at).getTime(), - nameLower: agent.name.toLowerCase(), - descriptionLower: agent.description?.toLowerCase() || '', - tagsLower: agent.tags?.map((tag) => tag.toLowerCase()) || [], - })) + return initialAgents }, [initialAgents]) const editorsChoice = useMemo(() => { @@ -127,7 +182,7 @@ export default function AgentStoreClient({ const matchesSearch = agent.name.toLowerCase().includes(searchQuery.toLowerCase()) || agent.description?.toLowerCase().includes(searchQuery.toLowerCase()) || - agent.tags?.some((tag) => + agent.tags?.some((tag: string) => tag.toLowerCase().includes(searchQuery.toLowerCase()) ) return matchesSearch @@ -158,99 +213,100 @@ export default function AgentStoreClient({ const matchesSearch = agent.name.toLowerCase().includes(searchQuery.toLowerCase()) || agent.description?.toLowerCase().includes(searchQuery.toLowerCase()) || - agent.tags?.some((tag) => + agent.tags?.some((tag: string) => tag.toLowerCase().includes(searchQuery.toLowerCase()) ) return matchesSearch }) }, [editorsChoice, searchQuery]) - // Get agents for a specific row without pre-building all rows - const getAgentsForRow = useCallback( - (rowIndex: number) => { - const startIndex = rowIndex * columns - return filteredAndSortedAgents.slice(startIndex, startIndex + columns) - }, - [filteredAndSortedAgents, columns] - ) - - // Calculate total rows needed - const totalRows = Math.ceil(filteredAndSortedAgents.length / columns) - - // Only create virtualizer when we have data and the component is mounted - const [isMounted, setIsMounted] = useState(false) - const measurementCache = useRef>(new Map()) - - useEffect(() => { - setIsMounted(true) - }, []) - - // Dynamic overscan based on device/viewport - const getOverscan = () => { - if (typeof window === 'undefined') return 6 - const isMobile = window.innerWidth < 768 - const isTouchDevice = 'ontouchstart' in window - return isMobile || isTouchDevice ? 15 : 8 - } + const ITEMS_PER_PAGE = 12 - // Dynamic height estimation based on columns - const getEstimatedSize = () => { - // Base card height + gap between rows - const baseCardHeight = 240 // More conservative estimate - const rowGap = 24 // gap-6 = 24px - return baseCardHeight + rowGap - } - - // Virtualizer for All Agents section only - const allAgentsVirtualizer = useWindowVirtualizer({ - count: isMounted ? totalRows : 0, - estimateSize: getEstimatedSize, - overscan: getOverscan(), - measureElement: (element) => { - // Cache measurements for better performance - const height = - element?.getBoundingClientRect().height ?? getEstimatedSize() - return height - }, - }) + // Derive displayed agents from count. + const displayedAgents = useMemo(() => { + return filteredAndSortedAgents.slice(0, displayedCount) + }, [filteredAndSortedAgents, displayedCount]) - // Remeasure when columns change or data changes significantly + // Initialize or reset displayed count when filters change or on initial load useEffect(() => { - if (allAgentsVirtualizer && isMounted) { - // Clear cache and remeasure when layout changes - measurementCache.current.clear() - allAgentsVirtualizer.measure() + const filtersHaveChanged = + prevFilters.current.searchQuery !== searchQuery || + prevFilters.current.sortBy !== sortBy + + // Only reset if filters have changed or if this is the very first load + if (filtersHaveChanged || displayedCount === 0) { + const initialCount = Math.min( + ITEMS_PER_PAGE, + filteredAndSortedAgents.length + ) + setDisplayedCount(initialCount) + prevFilters.current = { searchQuery, sortBy } // Update the ref } - }, [columns, filteredAndSortedAgents.length, allAgentsVirtualizer, isMounted]) - - // Handle viewport/orientation changes - useEffect(() => { - if (!isMounted || !allAgentsVirtualizer) return - const handleResize = () => { - // Debounce resize events - measurementCache.current.clear() - allAgentsVirtualizer.measure() - } + // Always update hasMore based on the current count vs total available + setHasMore(displayedCount < filteredAndSortedAgents.length) + }, [ + searchQuery, + sortBy, + filteredAndSortedAgents.length, + displayedCount, + setDisplayedCount, + setHasMore, + ]) // Load more items function - much simpler with count approach + const loadMoreItems = useCallback(() => { + if (isLoadingMore || !hasMore) return + + setIsLoadingMore(true) + + // Simulate a small delay to show loading state + setTimeout(() => { + const newCount = Math.min( + displayedCount + ITEMS_PER_PAGE, + filteredAndSortedAgents.length + ) - let resizeTimeout: NodeJS.Timeout - const debouncedResize = () => { - clearTimeout(resizeTimeout) - resizeTimeout = setTimeout(handleResize, 150) - } + setDisplayedCount(newCount) + setHasMore(newCount < filteredAndSortedAgents.length) + setIsLoadingMore(false) + }, 150) // Reduced delay for better UX + }, [ + displayedCount, + filteredAndSortedAgents.length, + isLoadingMore, + hasMore, + setDisplayedCount, + setHasMore, + setIsLoadingMore, + ]) + + // Intersection Observer for infinite scroll + useEffect(() => { + if (!loadMoreRef.current) return + + observerRef.current = new IntersectionObserver( + (entries) => { + const target = entries[0] + if ( + target.isIntersecting && + loadingStateRef.current.hasMore && + !loadingStateRef.current.isLoadingMore + ) { + loadMoreItems() + } + }, + { + rootMargin: '400px', // Start loading a full screen's worth before the element is visible + } + ) - window.addEventListener('resize', debouncedResize) - window.addEventListener('orientationchange', debouncedResize) + observerRef.current.observe(loadMoreRef.current) return () => { - window.removeEventListener('resize', debouncedResize) - window.removeEventListener('orientationchange', debouncedResize) - clearTimeout(resizeTimeout) + if (observerRef.current) { + observerRef.current.disconnect() + } } - }, [allAgentsVirtualizer, isMounted]) - - // Determine if we should use virtualization for All Agents section - const shouldVirtualizeAllAgents = isMounted && totalRows > 6 + }, [loadMoreItems]) // Fetch user's publishers if signed in const { data: publishers } = useQuery({ @@ -294,19 +350,6 @@ export default function AgentStoreClient({ } } - const formatCurrency = (amount?: number) => { - if (!amount) return '$0.00' - if (amount >= 1000) return `$${(amount / 1000).toFixed(1)}k` - return `$${amount.toFixed(2)}` - } - - const formatUsageCount = (count?: number) => { - if (!count) return '0' - if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M` - if (count >= 1000) return `${(count / 1000).toFixed(1)}K` - return count.toString() - } - const AgentCard = memo( ({ agent, @@ -353,13 +396,21 @@ export default function AgentStoreClient({
e.preventDefault()}>
- {shouldVirtualizeAllAgents ? ( - // Virtualized All Agents + {/* Infinite Scroll Grid */} +
+ {displayedAgents.map((agent) => ( +
+ +
+ ))} +
+ + {/* Loading More Indicator */} + {hasMore && (
- {allAgentsVirtualizer.getVirtualItems().map((virtualItem) => { - const agents = getAgentsForRow(virtualItem.index) - return ( -
allAgentsVirtualizer.measureElement(node)} - data-index={virtualItem.index} - style={{ - position: 'absolute', - top: 0, - left: 0, - width: '100%', - transform: `translateY(${virtualItem.start}px)`, - // Include padding/margin in measured element - paddingBottom: '24px', - }} - > -
- {agents?.map((agent) => ( - // No motion for virtualized items - use CSS transitions instead -
- -
- ))} -
-
- ) - })} -
- ) : ( - // Non-virtualized All Agents -
- {filteredAndSortedAgents.map((agent) => ( -
- + {isLoadingMore ? ( +
+
+ Loading more agents... +
+ ) : ( +
+ Scroll down to load more
- ))} + )}
)}
)} {/* No Results State */} - {filteredAndSortedAgents.length === 0 && - filteredEditorsChoice.length === 0 && ( + {displayedAgents.length === 0 && + filteredEditorsChoice.length === 0 && + filteredAndSortedAgents.length === 0 && (