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 && (
<>
);
}
+
+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 (
-
- .
- .
- .
+
+ {[0, 1, 2].map(i => (
+
+ .
+
+ ))}
);
}
+function PhaseIndicator({ phase }: { phase: IndexingPhase }) {
+ const phases: { key: IndexingPhase; label: string; icon: string }[] = [
+ { key: 'cloning', label: 'Clone', icon: '📥' },
+ { key: 'indexing', label: 'Index', icon: '⚡' },
+ { key: 'completed', label: 'Done', icon: '✓' },
+ ];
+
+ const currentIndex = phases.findIndex(p => p.key === phase);
+
+ return (
+
+ {phases.map((p, i) => {
+ const isActive = p.key === phase;
+ const isCompleted = currentIndex > i;
+ const isPending = currentIndex < i;
+
+ return (
+
+
+ {isCompleted ? '✓' : p.icon}
+
+
+ {p.label}
+
+ {i < phases.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+function FileIcon({ filename }: { filename: string }) {
+ // Determine icon based on file extension
+ const ext = filename.split('.').pop()?.toLowerCase() || '';
+
+ const iconMap: Record = {
+ py: '🐍',
+ js: '📜',
+ ts: '💠',
+ tsx: '⚛️',
+ jsx: '⚛️',
+ go: '🔵',
+ rs: '🦀',
+ java: '☕',
+ rb: '💎',
+ php: '🐘',
+ c: '⚙️',
+ cpp: '⚙️',
+ h: '📋',
+ md: '📝',
+ json: '📦',
+ yaml: '📄',
+ yml: '📄',
+ };
+
+ return {iconMap[ext] || '📄'};
+}
+
/**
- * Estimate remaining time based on current progress.
- * Returns null if not enough data to estimate.
+ * Streaming file list - The "holy shit" feature
+ * Shows files appearing in real-time as they're processed
*/
-function estimateRemainingSeconds(percent: number, filesProcessed: number): number | null {
- if (percent <= 0 || filesProcessed <= 0) return null;
-
- // Rough estimate: assume ~0.15s per file on average
- const remainingFiles = Math.ceil((filesProcessed / percent) * (100 - percent));
- return Math.max(1, Math.ceil(remainingFiles * 0.15));
+function StreamingFileList({ files, maxVisible = 8 }: { files: string[]; maxVisible?: number }) {
+ const listRef = useRef(null);
+ const visibleFiles = files.slice(0, maxVisible);
+ const hiddenCount = Math.max(0, files.length - maxVisible);
+
+ return (
+
+ {/* Gradient fade at bottom if more files hidden */}
+ {hiddenCount > 0 && (
+
+ )}
+
+
+ {visibleFiles.map((file, index) => (
+
+
+
+ {file}
+
+ {index === 0 && (
+
+ processing
+
+ )}
+
+ ))}
+
+
+ {/* Hidden files counter */}
+ {hiddenCount > 0 && (
+
+ +{hiddenCount} more files processed
+
+ )}
+
+ );
+}
+
+function GlowingProgress({ value }: { value: number }) {
+ return (
+
+ {/* Glow effect */}
+
+ {/* Actual progress bar */}
+
+
+ );
}
-export function IndexingProgress({ progress, repoName, onCancel }: IndexingProgressProps) {
+function StatCard({ label, value, highlight = false }: { label: string; value: string | number; highlight?: boolean }) {
+ return (
+
+
{label}
+
+ {typeof value === 'number' ? value.toLocaleString() : value}
+
+
+ );
+}
+
+// =============================================================================
+// MAIN COMPONENT
+// =============================================================================
+
+export function IndexingProgress({
+ progress,
+ phase = 'indexing',
+ repoName,
+ recentFiles = [],
+ onCancel
+}: IndexingProgressProps) {
const { percent, filesProcessed, filesTotal, currentFile, functionsFound } = progress;
- const estimatedRemaining = estimateRemainingSeconds(percent, filesProcessed);
+
+ // Estimate remaining time
+ const estimatedRemaining = (() => {
+ if (percent <= 0 || filesProcessed <= 0) return null;
+ const remainingFiles = Math.ceil((filesProcessed / percent) * (100 - percent));
+ return Math.max(1, Math.ceil(remainingFiles * 0.15));
+ })();
+
+ // Use currentFile if recentFiles is empty
+ const displayFiles = recentFiles.length > 0
+ ? recentFiles
+ : currentFile
+ ? [currentFile]
+ : [];
return (
-
{/* Header */}
-
-
+
+
-
⚡
-
- Indexing {repoName || 'repository'}
-
+
+ {phase === 'cloning' ? '📥' : phase === 'completed' ? '✅' : '⚡'}
+
+
+ {phase === 'cloning' ? 'Cloning' : phase === 'completed' ? 'Indexed' : 'Indexing'}
+ {' '}{repoName || 'repository'}
+ {phase !== 'completed' && }
-
+
{percent}%
-
+
+
+ {/* Phase indicator */}
+
{/* Progress bar */}
{/* Stats grid */}
-
-
-
-
Files
-
- {filesProcessed} / {filesTotal}
-
-
-
-
Functions
-
- {functionsFound.toLocaleString()}
-
-
-
-
Remaining
-
- {estimatedRemaining !== null ? `~${estimatedRemaining}s` : '—'}
-
-
+
+
+
+
+
+ 0 ? `${Math.round(functionsFound / filesProcessed)}/file` : '—'} />
- {/* Current file */}
- {currentFile && (
-
-
-
📄
-
- {currentFile}
-
+ {/* Streaming file list - THE FEATURE */}
+ {displayFiles.length > 0 && (
+
)}
{/* Cancel button */}
- {onCancel && (
-
+ {onCancel && phase !== 'completed' && (
+
)}
-
+
);
}
+
+export default IndexingProgress;
diff --git a/frontend/src/components/playground/index.ts b/frontend/src/components/playground/index.ts
index 2441356..180057e 100644
--- a/frontend/src/components/playground/index.ts
+++ b/frontend/src/components/playground/index.ts
@@ -3,10 +3,12 @@
*
* Anonymous repo indexing UI components.
* @see useAnonymousSession hook for state management
+ * @see useIndexingWebSocket hook for real-time progress
*/
export { RepoModeSelector, type RepoMode } from './RepoModeSelector';
export { RepoUrlInput } from './RepoUrlInput';
export { ValidationStatus } from './ValidationStatus';
-export { IndexingProgress, type ProgressData } from './IndexingProgress';
+export { IndexingProgress, type ProgressData, type IndexingPhase } from './IndexingProgress';
+export { IndexingComplete } from './IndexingComplete';
export { HeroPlayground } from './HeroPlayground';
diff --git a/frontend/src/hooks/useIndexingWebSocket.ts b/frontend/src/hooks/useIndexingWebSocket.ts
new file mode 100644
index 0000000..a8fdf80
--- /dev/null
+++ b/frontend/src/hooks/useIndexingWebSocket.ts
@@ -0,0 +1,272 @@
+/**
+ * useIndexingWebSocket
+ *
+ * WebSocket hook for real-time indexing progress updates.
+ * Uses refs for callbacks to prevent infinite render loops.
+ */
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { buildWsUrl, API_URL } from '@/config/api';
+
+// Types
+export type WSConnectionState = 'connecting' | 'connected' | 'disconnected' | 'error';
+
+export interface WSProgressEvent {
+ type: 'progress';
+ job_id: string;
+ files_processed: number;
+ files_total: number;
+ current_file: string;
+ functions_found: number;
+ percent: number;
+}
+
+export interface WSCompletedEvent {
+ type: 'completed';
+ job_id: string;
+ repo_id: string;
+ stats: {
+ files_processed: number;
+ functions_indexed: number;
+ indexing_time_seconds: number;
+ };
+}
+
+export interface WSErrorEvent {
+ type: 'error';
+ job_id: string;
+ error: string;
+ message: string;
+ recoverable: boolean;
+}
+
+export type WSEvent =
+ | WSProgressEvent
+ | WSCompletedEvent
+ | WSErrorEvent
+ | { type: 'cloning'; job_id: string; repo_name: string; message: string }
+ | { type: 'connected'; job_id: string; message: string }
+ | { type: 'ping'; job_id: string };
+
+export interface IndexingState {
+ connectionState: WSConnectionState;
+ phase: 'idle' | 'connecting' | 'cloning' | 'indexing' | 'completed' | 'error';
+ progress: {
+ percent: number;
+ filesProcessed: number;
+ filesTotal: number;
+ currentFile: string;
+ functionsFound: number;
+ };
+ recentFiles: string[];
+ completedStats: WSCompletedEvent['stats'] | null;
+ repoId: string | null;
+ error: string | null;
+ isRecoverable: boolean;
+}
+
+interface UseIndexingWebSocketOptions {
+ maxRecentFiles?: number;
+ onCompleted?: (repoId: string, stats: WSCompletedEvent['stats']) => void;
+ onError?: (error: string, recoverable: boolean) => void;
+}
+
+const INITIAL_STATE: IndexingState = {
+ connectionState: 'disconnected',
+ phase: 'idle',
+ progress: { percent: 0, filesProcessed: 0, filesTotal: 0, currentFile: '', functionsFound: 0 },
+ recentFiles: [],
+ completedStats: null,
+ repoId: null,
+ error: null,
+ isRecoverable: false,
+};
+
+export function useIndexingWebSocket(
+ jobId: string | null,
+ options: UseIndexingWebSocketOptions = {}
+) {
+ const { maxRecentFiles = 15 } = options;
+
+ // CRITICAL: Use refs for callbacks to avoid dependency loops
+ const onCompletedRef = useRef(options.onCompleted);
+ const onErrorRef = useRef(options.onError);
+ onCompletedRef.current = options.onCompleted;
+ onErrorRef.current = options.onError;
+
+ const [state, setState] = useState
(INITIAL_STATE);
+
+ const wsRef = useRef(null);
+ const reconnectAttempts = useRef(0);
+ const reconnectTimeout = useRef | null>(null);
+ const pollingInterval = useRef | null>(null);
+
+ const cleanup = useCallback(() => {
+ wsRef.current?.close();
+ wsRef.current = null;
+ if (reconnectTimeout.current) clearTimeout(reconnectTimeout.current);
+ if (pollingInterval.current) clearInterval(pollingInterval.current);
+ reconnectTimeout.current = null;
+ pollingInterval.current = null;
+ }, []);
+
+ const startPolling = useCallback((jid: string) => {
+ if (pollingInterval.current) return;
+ console.log('[WS] Falling back to polling');
+
+ pollingInterval.current = setInterval(async () => {
+ try {
+ const res = await fetch(`${API_URL}/playground/job/${jid}/status`);
+ if (!res.ok) return;
+ const data = await res.json();
+
+ if (data.status === 'completed') {
+ clearInterval(pollingInterval.current!);
+ pollingInterval.current = null;
+ const stats = {
+ files_processed: data.files_processed || 0,
+ functions_indexed: data.functions_indexed || 0,
+ indexing_time_seconds: data.indexing_time || 0,
+ };
+ setState(prev => ({ ...prev, phase: 'completed', repoId: data.repo_id, completedStats: stats }));
+ onCompletedRef.current?.(data.repo_id, stats);
+ } else if (data.status === 'failed') {
+ clearInterval(pollingInterval.current!);
+ pollingInterval.current = null;
+ setState(prev => ({ ...prev, phase: 'error', error: data.error || 'Failed', isRecoverable: false }));
+ onErrorRef.current?.(data.error || 'Failed', false);
+ } else {
+ setState(prev => ({
+ ...prev,
+ phase: 'indexing',
+ progress: {
+ percent: data.percent || 0,
+ filesProcessed: data.files_processed || 0,
+ filesTotal: data.files_total || 0,
+ currentFile: data.current_file || '',
+ functionsFound: data.functions_found || 0,
+ },
+ }));
+ }
+ } catch (err) {
+ console.error('[Polling] Error:', err);
+ }
+ }, 2000);
+ }, []);
+
+ const handleMessage = useCallback((event: MessageEvent) => {
+ try {
+ const data: WSEvent = JSON.parse(event.data);
+
+ if (data.type === 'connected') {
+ setState(prev => ({ ...prev, connectionState: 'connected', phase: 'connecting' }));
+ } else if (data.type === 'ping') {
+ // Keepalive
+ } else if (data.type === 'cloning') {
+ setState(prev => ({ ...prev, phase: 'cloning' }));
+ } else if (data.type === 'progress') {
+ setState(prev => {
+ const newFiles = data.current_file
+ ? [data.current_file, ...prev.recentFiles.filter(f => f !== data.current_file)].slice(0, maxRecentFiles)
+ : prev.recentFiles;
+ return {
+ ...prev,
+ phase: 'indexing',
+ progress: {
+ percent: data.percent,
+ filesProcessed: data.files_processed,
+ filesTotal: data.files_total,
+ currentFile: data.current_file,
+ functionsFound: data.functions_found,
+ },
+ recentFiles: newFiles,
+ };
+ });
+ } else if (data.type === 'completed') {
+ setState(prev => ({
+ ...prev,
+ phase: 'completed',
+ repoId: data.repo_id,
+ completedStats: data.stats,
+ progress: { ...prev.progress, percent: 100 },
+ }));
+ onCompletedRef.current?.(data.repo_id, data.stats);
+ } else if (data.type === 'error') {
+ setState(prev => ({ ...prev, phase: 'error', error: data.message, isRecoverable: data.recoverable }));
+ onErrorRef.current?.(data.message, data.recoverable);
+ }
+ } catch (err) {
+ console.error('[WS] Parse error:', err);
+ }
+ }, [maxRecentFiles]);
+
+ const connect = useCallback((jid: string) => {
+ cleanup();
+ setState(prev => ({ ...prev, connectionState: 'connecting', phase: 'connecting', error: null }));
+
+ const wsUrl = buildWsUrl(`/ws/playground/${jid}`);
+ console.log('[WS] Connecting to:', wsUrl);
+
+ try {
+ const ws = new WebSocket(wsUrl);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ console.log('[WS] Connected');
+ reconnectAttempts.current = 0;
+ setState(prev => ({ ...prev, connectionState: 'connected' }));
+ };
+
+ ws.onmessage = handleMessage;
+
+ ws.onerror = () => {
+ setState(prev => ({ ...prev, connectionState: 'error' }));
+ };
+
+ ws.onclose = (event) => {
+ console.log('[WS] Closed:', event.code);
+ if (event.code === 1000 || event.code === 4404) {
+ setState(prev => ({ ...prev, connectionState: 'disconnected' }));
+ return;
+ }
+ if (reconnectAttempts.current < 3) {
+ const delay = Math.min(1000 * Math.pow(2, reconnectAttempts.current), 5000);
+ reconnectTimeout.current = setTimeout(() => {
+ reconnectAttempts.current++;
+ connect(jid);
+ }, delay);
+ } else {
+ startPolling(jid);
+ }
+ };
+ } catch {
+ startPolling(jid);
+ }
+ }, [cleanup, handleMessage, startPolling]);
+
+ useEffect(() => {
+ if (jobId) {
+ connect(jobId);
+ } else {
+ cleanup();
+ setState(INITIAL_STATE);
+ }
+ return cleanup;
+ }, [jobId, connect, cleanup]);
+
+ const reset = useCallback(() => {
+ cleanup();
+ setState(INITIAL_STATE);
+ }, [cleanup]);
+
+ return {
+ ...state,
+ reset,
+ isConnected: state.connectionState === 'connected',
+ isIndexing: state.phase === 'indexing' || state.phase === 'cloning',
+ isCompleted: state.phase === 'completed',
+ hasError: state.phase === 'error',
+ };
+}
+
+export default useIndexingWebSocket;