diff --git a/frontend/src/components/playground/HeroPlayground.tsx b/frontend/src/components/playground/HeroPlayground.tsx index 85e078f..aba209c 100644 --- a/frontend/src/components/playground/HeroPlayground.tsx +++ b/frontend/src/components/playground/HeroPlayground.tsx @@ -1,21 +1,32 @@ /** - * HeroPlayground + * HeroPlayground (v2 - WebSocket Enhanced) * * Combined demo + custom repo experience for the landing page hero. - * Handles mode switching, URL validation, indexing, and search. + * Now with real-time WebSocket progress updates and streaming file list. + * + * Features: + * - Mode switching (demo/custom) + * - URL validation + * - Real-time indexing progress via WebSocket + * - Streaming file list (the "holy shit" moment) + * - Celebration screen on completion */ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; +import { motion, AnimatePresence } from 'framer-motion'; import { Button } from '@/components/ui/button'; import { RepoModeSelector, RepoUrlInput, ValidationStatus, IndexingProgress, - type RepoMode + IndexingComplete, + type RepoMode, + type IndexingPhase, } from '@/components/playground'; import { useAnonymousSession } from '@/hooks/useAnonymousSession'; +import { useIndexingWebSocket } from '@/hooks/useIndexingWebSocket'; import { cn } from '@/lib/utils'; // Demo repos config @@ -62,18 +73,63 @@ export function HeroPlayground({ const [selectedDemo, setSelectedDemo] = useState(DEMO_REPOS[0].id); const [customUrl, setCustomUrl] = useState(''); const [query, setQuery] = useState(''); + const [showCelebration, setShowCelebration] = useState(false); - // Anonymous session hook for custom repos - const { state, validateUrl, startIndexing, reset, session } = useAnonymousSession(); + // Anonymous session hook for validation and job creation + const { state, validateUrl, startIndexing, reset: resetSession, session } = useAnonymousSession(); + + // Extract jobId for WebSocket connection + const jobId = state.status === 'indexing' ? state.jobId : null; + const repoName = customUrl.split('/').pop()?.replace('.git', '') || 'repository'; + + // Stable callbacks for WebSocket hook (MUST be memoized to prevent infinite loops!) + const handleWsCompleted = useCallback(() => { + setShowCelebration(true); + }, []); + + const handleWsError = useCallback((error: string, recoverable: boolean) => { + console.error('[HeroPlayground] Indexing error:', error, recoverable); + }, []); + + // Memoize options to prevent recreation every render + const wsOptions = useMemo(() => ({ + maxRecentFiles: 12, + onCompleted: handleWsCompleted, + onError: handleWsError, + }), [handleWsCompleted, handleWsError]); + + // WebSocket hook for real-time progress + const { + phase: wsPhase, + progress: wsProgress, + recentFiles, + completedStats, + repoId: wsRepoId, + error: wsError, + isCompleted: wsIsCompleted, + hasError: wsHasError, + reset: wsReset, + } = useIndexingWebSocket(jobId, wsOptions); + + // Map WebSocket phase to IndexingProgress phase + const getIndexingPhase = useCallback((): IndexingPhase => { + if (wsPhase === 'cloning') return 'cloning'; + if (wsPhase === 'indexing') return 'indexing'; + if (wsPhase === 'completed') return 'completed'; + if (wsPhase === 'error') return 'error'; + return 'connecting'; + }, [wsPhase]); // Handle mode change const handleModeChange = useCallback((newMode: RepoMode) => { setMode(newMode); if (newMode === 'demo') { - reset(); + resetSession(); + wsReset(); setCustomUrl(''); + setShowCelebration(false); } - }, [reset]); + }, [resetSession, wsReset]); // Handle search submit const handleSearch = useCallback((e?: React.FormEvent) => { @@ -84,55 +140,79 @@ export function HeroPlayground({ onSearch(query, selectedDemo, false); } else if (state.status === 'ready') { onSearch(query, state.repoId, true); + } else if (wsIsCompleted && wsRepoId) { + // Search using repo from completed WebSocket state + onSearch(query, wsRepoId, true); } - }, [query, mode, selectedDemo, state, loading, onSearch]); + }, [query, mode, selectedDemo, state, wsIsCompleted, wsRepoId, loading, onSearch]); + + // Start searching after celebration + const handleStartSearching = useCallback(() => { + setShowCelebration(false); + }, []); + + // Index another repo + const handleIndexAnother = useCallback(() => { + resetSession(); + wsReset(); + setCustomUrl(''); + setShowCelebration(false); + }, [resetSession, wsReset]); // Get validation state for ValidationStatus component - const getValidationState = () => { + const getValidationState = useCallback(() => { if (state.status === 'idle') return { type: 'idle' as const }; if (state.status === 'validating') return { type: 'validating' as const }; if (state.status === 'valid') return { type: 'valid' as const, validation: state.validation }; if (state.status === 'invalid') return { type: 'invalid' as const, error: state.error, reason: state.reason }; return { type: 'idle' as const }; - }; + }, [state]); + + // Determine visibility states + const showDemoSelector = mode === 'demo'; + const showUrlInput = mode === 'custom' && !['indexing', 'ready'].includes(state.status) && !showCelebration && !wsIsCompleted; + const showValidation = mode === 'custom' && ['validating', 'valid', 'invalid'].includes(state.status) && !showCelebration; + const showIndexing = mode === 'custom' && state.status === 'indexing' && !showCelebration && !wsIsCompleted; + const showReady = (mode === 'custom' && state.status === 'ready') || (wsIsCompleted && !showCelebration); + const isSearchDisabled = mode === 'custom' && state.status !== 'ready' && !wsIsCompleted; // Can search? const canSearch = mode === 'demo' ? remaining > 0 && query.trim().length > 0 - : state.status === 'ready' && remaining > 0 && query.trim().length > 0; - - // Determine what to show based on state - const showDemoSelector = mode === 'demo'; - const showUrlInput = mode === 'custom' && !['indexing', 'ready'].includes(state.status); - const showValidation = mode === 'custom' && ['validating', 'valid', 'invalid'].includes(state.status); - const showIndexing = mode === 'custom' && state.status === 'indexing'; - // Always show search - in custom mode it's disabled until repo is indexed - const showSearch = true; - const isSearchDisabled = mode === 'custom' && state.status !== 'ready'; + : (state.status === 'ready' || wsIsCompleted) && remaining > 0 && query.trim().length > 0; // Get contextual placeholder text - const getPlaceholder = () => { + const getPlaceholder = useCallback(() => { if (mode === 'demo') { return "Search for authentication, error handling..."; } - // Custom mode placeholders based on state - switch (state.status) { - case 'idle': - return "Enter a GitHub URL above to start..."; - case 'validating': - return "Validating repository..."; - case 'valid': - return "Click 'Index Repository' to continue..."; - case 'invalid': - return "Fix the URL above to continue..."; - case 'indexing': - return "Indexing in progress..."; - case 'ready': - return `Search in ${state.repoName}...`; - default: - return "Enter a GitHub URL to search..."; + if (state.status === 'idle') return "Enter a GitHub URL above to start..."; + if (state.status === 'validating') return "Validating repository..."; + if (state.status === 'valid') return "Click 'Index Repository' to continue..."; + if (state.status === 'invalid') return "Fix the URL above to continue..."; + if (state.status === 'indexing') return "Indexing in progress..."; + if (state.status === 'ready' || wsIsCompleted) return `Search in ${repoName}...`; + return "Enter a GitHub URL to search..."; + }, [mode, state.status, wsIsCompleted, repoName]); + + // Compute ready state info + const readyInfo = useMemo(() => { + if (wsIsCompleted && completedStats) { + return { + repoName, + fileCount: completedStats.files_processed, + functionsFound: completedStats.functions_indexed, + }; + } + if (state.status === 'ready') { + return { + repoName: state.repoName, + fileCount: state.fileCount, + functionsFound: state.functionsFound, + }; } - }; + return null; + }, [wsIsCompleted, completedStats, state, repoName]); return (
@@ -174,65 +254,111 @@ export function HeroPlayground({ )} {/* Custom URL Input */} - {showUrlInput && ( -
- -
- )} + + {showUrlInput && ( + + + + )} - {/* Validation Status */} - {showValidation && ( -
- -
- )} + {/* Validation Status */} + {showValidation && ( + + + + )} - {/* Indexing Progress */} - {showIndexing && ( -
- -
- )} + {/* Indexing Progress with WebSocket streaming */} + {showIndexing && ( + + + + )} - {/* Ready State Banner */} - {mode === 'custom' && state.status === 'ready' && ( -
-
- - - {state.repoName} indexed · {state.fileCount} files · {state.functionsFound} functions - -
- -
- )} + + + )} + + {/* Ready State Banner */} + {showReady && !showCelebration && readyInfo && ( + +
+ + + {readyInfo.repoName} indexed · {readyInfo.fileCount} files · {readyInfo.functionsFound.toLocaleString()} functions + +
+ +
+ )} +
{/* Search Box */} - {showSearch && ( + {!showCelebration && ( <>
@@ -282,19 +408,23 @@ export function HeroPlayground({ )} {/* Error State */} - {state.status === 'error' && ( -
-

{state.message}

- {state.canRetry && ( - - )} -
+ {(state.status === 'error' || wsHasError) && ( + +

+ {state.status === 'error' ? state.message : wsError} +

+ +
)} {/* Upgrade CTA (when limit reached) */} @@ -312,3 +442,5 @@ export function HeroPlayground({
); } + +export default HeroPlayground; diff --git a/frontend/src/components/playground/IndexingComplete.tsx b/frontend/src/components/playground/IndexingComplete.tsx new file mode 100644 index 0000000..82c6ab4 --- /dev/null +++ b/frontend/src/components/playground/IndexingComplete.tsx @@ -0,0 +1,260 @@ +/** + * IndexingComplete + * + * Celebration screen shown when repository indexing finishes. + * Premium animations with confetti effect and stats showcase. + */ + +import { useEffect, useState } from 'react'; +import { motion } from 'framer-motion'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +interface IndexingCompleteProps { + repoName: string; + stats: { + files_processed: number; + functions_indexed: number; + indexing_time_seconds: number; + }; + onStartSearching: () => void; + onIndexAnother?: () => void; +} + +// Confetti particle component +function Confetti() { + const [particles] = useState(() => + Array.from({ length: 50 }, (_, i) => ({ + id: i, + x: Math.random() * 100, + delay: Math.random() * 0.5, + duration: 2 + Math.random() * 2, + color: ['#818cf8', '#34d399', '#fbbf24', '#f472b6', '#60a5fa'][Math.floor(Math.random() * 5)], + })) + ); + + return ( +
+ {particles.map((p) => ( + + ))} +
+ ); +} + +// Animated stat display +function StatDisplay({ + label, + value, + suffix = '', + delay = 0, + highlight = false +}: { + label: string; + value: number; + suffix?: string; + delay?: number; + highlight?: boolean; +}) { + const [displayValue, setDisplayValue] = useState(0); + + useEffect(() => { + const timeout = setTimeout(() => { + const duration = 1500; + const steps = 30; + const increment = value / steps; + let current = 0; + + const interval = setInterval(() => { + current += increment; + if (current >= value) { + setDisplayValue(value); + clearInterval(interval); + } else { + setDisplayValue(Math.floor(current)); + } + }, duration / steps); + + return () => clearInterval(interval); + }, delay); + + return () => clearTimeout(timeout); + }, [value, delay]); + + return ( + +
+ {displayValue.toLocaleString()}{suffix} +
+
{label}
+
+ ); +} + +export function IndexingComplete({ + repoName, + stats, + onStartSearching, + onIndexAnother +}: IndexingCompleteProps) { + const [showConfetti, setShowConfetti] = useState(true); + + useEffect(() => { + const timeout = setTimeout(() => setShowConfetti(false), 4000); + return () => clearTimeout(timeout); + }, []); + + return ( + + {/* Confetti */} + {showConfetti && } + + {/* Success glow */} + + + {/* Content */} +
+ {/* Success icon */} + + + ✓ + + + + {/* Title */} + +

+ Ready to Search! +

+

+ {repoName} has been indexed +

+
+ + {/* Stats */} +
+ + + +
+ + {/* Actions */} +
+ + + {onIndexAnother && ( + + )} +
+ + {/* Pro tip */} + + 💡 Pro tip: Try searching for "authentication", "error handling", or "database" + +
+
+ ); +} + +export default IndexingComplete; diff --git a/frontend/src/components/playground/IndexingProgress.tsx b/frontend/src/components/playground/IndexingProgress.tsx index 05e7e3d..deb2eb7 100644 --- a/frontend/src/components/playground/IndexingProgress.tsx +++ b/frontend/src/components/playground/IndexingProgress.tsx @@ -1,13 +1,26 @@ /** - * IndexingProgress + * IndexingProgress (v2 - WebSocket Enhanced) * - * Displays real-time progress during repository indexing. - * Shows progress bar, file stats, and current file being processed. + * Displays real-time progress during repository indexing with + * streaming file list that shows each file as it's processed. + * + * Features: + * - Phase indicators (cloning → indexing → completed) + * - Streaming file list with staggered animations + * - Progress bar with gradient glow + * - Real-time stats + * - Premium animations */ +import { useState, useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; import { cn } from '@/lib/utils'; import { Progress } from '@/components/ui/progress'; +// ============================================================================= +// TYPES +// ============================================================================= + export interface ProgressData { percent: number; filesProcessed: number; @@ -16,121 +29,342 @@ export interface ProgressData { functionsFound: number; } +export type IndexingPhase = 'connecting' | 'cloning' | 'indexing' | 'completed' | 'error'; + interface IndexingProgressProps { progress: ProgressData; + phase?: IndexingPhase; repoName?: string; + recentFiles?: string[]; // Streaming file list from WebSocket onCancel?: () => void; } +// ============================================================================= +// SUB-COMPONENTS +// ============================================================================= + function AnimatedDots() { return ( -