diff --git a/backend/routes/ws_playground.py b/backend/routes/ws_playground.py index cc642d4..337a28c 100644 --- a/backend/routes/ws_playground.py +++ b/backend/routes/ws_playground.py @@ -152,6 +152,9 @@ async def websocket_playground_index(websocket: WebSocket, job_id: str): job_id=job_id[:12], event_type=event_type ) + # Small delay to ensure client processes message before close + # This prevents race condition where onclose fires before onmessage + await asyncio.sleep(0.2) break except json.JSONDecodeError: diff --git a/backend/services/anonymous_indexer.py b/backend/services/anonymous_indexer.py index 9ffacb8..e5a47f1 100644 --- a/backend/services/anonymous_indexer.py +++ b/backend/services/anonymous_indexer.py @@ -43,9 +43,9 @@ def to_dict(self) -> dict: @dataclass class JobStats: """Final stats for completed job.""" - files_indexed: int = 0 - functions_found: int = 0 - time_taken_seconds: float = 0 + files_processed: int = 0 + functions_indexed: int = 0 + indexing_time_seconds: float = 0 def to_dict(self) -> dict: return asdict(self) @@ -345,12 +345,18 @@ async def run_indexing_job( job_manager.update_status(job_id, JobStatus.PROCESSING) # Progress callback for real-time updates - async def progress_callback(files_processed: int, functions_found: int, total: int): + async def progress_callback( + files_processed: int, + functions_found: int, + total: int, + current_file: Optional[str] = None + ): job_manager.update_progress( job_id, files_processed=files_processed, functions_found=functions_found, - files_total=total + files_total=total, + current_file=current_file ) # Run indexing with timeout @@ -370,9 +376,9 @@ async def progress_callback(files_processed: int, functions_found: int, total: i # --- Step 3: Mark complete --- elapsed = time.time() - start_time stats = JobStats( - files_indexed=file_count, - functions_found=total_functions, - time_taken_seconds=round(elapsed, 2) + files_processed=file_count, + functions_indexed=total_functions, + indexing_time_seconds=round(elapsed, 2) ) job_manager.update_status( diff --git a/backend/services/indexer_optimized.py b/backend/services/indexer_optimized.py index 4eb9db7..521935e 100644 --- a/backend/services/indexer_optimized.py +++ b/backend/services/indexer_optimized.py @@ -672,8 +672,11 @@ async def index_repository_with_progress( files_processed = min(i + self.FILE_BATCH_SIZE, total_files) - # Send progress update - await progress_callback(files_processed, len(all_functions_data), total_files) + # Get the last file in this batch for display + current_file = batch[-1].name if batch else None + + # Send progress update with current file + await progress_callback(files_processed, len(all_functions_data), total_files, current_file) logger.debug("Processing files", processed=files_processed, diff --git a/backend/tests/test_anonymous_indexing.py b/backend/tests/test_anonymous_indexing.py index 3fe7db6..2bf97de 100644 --- a/backend/tests/test_anonymous_indexing.py +++ b/backend/tests/test_anonymous_indexing.py @@ -199,9 +199,9 @@ def test_update_status_completed_with_stats(self, job_manager, mock_redis): }) stats = JobStats( - files_indexed=100, - functions_found=500, - time_taken_seconds=45.5 + files_processed=100, + functions_indexed=500, + indexing_time_seconds=45.5 ) result = job_manager.update_status( @@ -259,13 +259,13 @@ def test_job_progress_none_excluded(self): def test_job_stats_to_dict(self): """JobStats converts to dict correctly.""" stats = JobStats( - files_indexed=100, - functions_found=500, - time_taken_seconds=45.5 + files_processed=100, + functions_indexed=500, + indexing_time_seconds=45.5 ) d = stats.to_dict() - assert d["files_indexed"] == 100 - assert d["time_taken_seconds"] == 45.5 + assert d["files_processed"] == 100 + assert d["indexing_time_seconds"] == 45.5 # ============================================================================= @@ -608,9 +608,9 @@ def test_completed_job_returns_repo_id(self, mock_job_class, client): "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:01:00Z", "stats": { - "files_indexed": 100, - "functions_found": 500, - "time_taken_seconds": 45.2 + "files_processed": 100, + "functions_indexed": 500, + "indexing_time_seconds": 45.2 } } mock_job_class.return_value = mock_job_manager @@ -621,7 +621,7 @@ def test_completed_job_returns_repo_id(self, mock_job_class, client): data = response.json() assert data["status"] == "completed" assert data["repo_id"] == "anon_idx_test123456" - assert data["stats"]["files_indexed"] == 100 + assert data["stats"]["files_processed"] == 100 @patch('routes.playground.AnonymousIndexingJob') def test_failed_job_returns_error(self, mock_job_class, client): diff --git a/backend/tests/test_ws_playground.py b/backend/tests/test_ws_playground.py index c558981..3148fb7 100644 --- a/backend/tests/test_ws_playground.py +++ b/backend/tests/test_ws_playground.py @@ -66,7 +66,7 @@ def test_websocket_handles_already_completed_job(self): "job_id": "idx_test123", "status": "completed", "repo_id": "anon_test123", - "stats": {"files_indexed": 100, "functions_found": 500} + "stats": {"files_processed": 100, "functions_indexed": 500} }) with patch('routes.ws_playground.redis_client', mock_redis): @@ -172,9 +172,9 @@ def test_completed_event_includes_stats(self): job_manager = AnonymousIndexingJob(mock_redis) stats = JobStats( - files_indexed=100, - functions_found=500, - time_taken_seconds=45.2 + files_processed=100, + functions_indexed=500, + indexing_time_seconds=45.2 ) job_manager.update_status( @@ -189,7 +189,7 @@ def test_completed_event_includes_stats(self): assert event_data["type"] == "completed" assert event_data["repo_id"] == "anon_test123" - assert event_data["stats"]["functions_found"] == 500 + assert event_data["stats"]["functions_indexed"] == 500 def test_processing_status_skips_duplicate_publish(self): """PROCESSING status should not publish (handled by update_progress).""" diff --git a/frontend/src/components/playground/HeroPlayground.tsx b/frontend/src/components/playground/HeroPlayground.tsx index aba209c..67f99c9 100644 --- a/frontend/src/components/playground/HeroPlayground.tsx +++ b/frontend/src/components/playground/HeroPlayground.tsx @@ -169,11 +169,13 @@ export function HeroPlayground({ }, [state]); // Determine visibility states + const hasError = state.status === 'error' || wsHasError; 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 showUrlInput = mode === 'custom' && !['indexing', 'ready', 'error'].includes(state.status) && !showCelebration && !wsIsCompleted && !wsHasError; + const showValidation = mode === 'custom' && ['validating', 'valid', 'invalid'].includes(state.status) && !showCelebration && !hasError; + const showIndexing = mode === 'custom' && state.status === 'indexing' && !showCelebration && !wsIsCompleted && !wsHasError; const showReady = (mode === 'custom' && state.status === 'ready') || (wsIsCompleted && !showCelebration); + const showError = mode === 'custom' && hasError && !showCelebration; const isSearchDisabled = mode === 'custom' && state.status !== 'ready' && !wsIsCompleted; // Can search? @@ -350,6 +352,34 @@ export function HeroPlayground({ )} + + {/* Error State - Inside AnimatePresence to prevent simultaneous renders */} + {showError && ( + +
+ ⚠️ +
+

Something went wrong

+

+ {state.status === 'error' ? state.message : wsError || 'An unexpected error occurred'} +

+ +
+
+
+ )} {/* Search Box */} @@ -407,26 +437,6 @@ export function HeroPlayground({ )} - {/* Error State */} - {(state.status === 'error' || wsHasError) && ( - -

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

- -
- )} - {/* Upgrade CTA (when limit reached) */} {remaining <= 0 && (
diff --git a/frontend/src/components/playground/IndexingProgress.tsx b/frontend/src/components/playground/IndexingProgress.tsx index deb2eb7..4f2da7f 100644 --- a/frontend/src/components/playground/IndexingProgress.tsx +++ b/frontend/src/components/playground/IndexingProgress.tsx @@ -267,10 +267,19 @@ export function IndexingProgress({ }: IndexingProgressProps) { const { percent, filesProcessed, filesTotal, currentFile, functionsFound } = progress; - // Estimate remaining time + // Safe values to prevent NaN/division by zero + const safePercent = Number.isFinite(percent) ? Math.max(0, Math.min(100, percent)) : 0; + const safeFilesProcessed = Number.isFinite(filesProcessed) ? Math.max(0, filesProcessed) : 0; + const safeFilesTotal = Number.isFinite(filesTotal) ? Math.max(0, filesTotal) : 0; + const safeFunctionsFound = Number.isFinite(functionsFound) ? Math.max(0, functionsFound) : 0; + + // Is this the initial "starting" state? + const isStarting = safeFilesTotal === 0 || (safePercent === 0 && safeFilesProcessed === 0); + + // Estimate remaining time (only when we have real data) const estimatedRemaining = (() => { - if (percent <= 0 || filesProcessed <= 0) return null; - const remainingFiles = Math.ceil((filesProcessed / percent) * (100 - percent)); + if (isStarting || safePercent <= 0 || safeFilesProcessed <= 0) return null; + const remainingFiles = Math.ceil((safeFilesProcessed / safePercent) * (100 - safePercent)); return Math.max(1, Math.ceil(remainingFiles * 0.15)); })(); @@ -287,7 +296,7 @@ export function IndexingProgress({ initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} role="status" - aria-label={`Indexing ${repoName || 'repository'}: ${percent}% complete`} + aria-label={`Indexing ${repoName || 'repository'}: ${safePercent}% complete`} > {/* Header */}
@@ -308,12 +317,12 @@ export function IndexingProgress({
- {percent}% + {safePercent}%
@@ -323,16 +332,29 @@ export function IndexingProgress({ {/* Progress bar */}
- +
{/* Stats grid */}
- - - - 0 ? `${Math.round(functionsFound / filesProcessed)}/file` : '—'} /> + + 0} + /> + + 0 ? `${Math.round(safeFunctionsFound / safeFilesProcessed)}/file` : '—'} + />
diff --git a/frontend/src/hooks/useIndexingWebSocket.ts b/frontend/src/hooks/useIndexingWebSocket.ts index a8fdf80..ab4c6e1 100644 --- a/frontend/src/hooks/useIndexingWebSocket.ts +++ b/frontend/src/hooks/useIndexingWebSocket.ts @@ -248,8 +248,18 @@ export function useIndexingWebSocket( if (jobId) { connect(jobId); } else { + // Only cleanup connection, DON'T reset state! + // This preserves completedStats when jobId becomes null after completion cleanup(); - setState(INITIAL_STATE); + // Only reset if we were never completed (e.g., user navigated away during indexing) + setState(prev => { + if (prev.phase === 'completed') { + // Keep completed state - just disconnect + return { ...prev, connectionState: 'disconnected' }; + } + // Reset if we were mid-indexing (user cancelled, navigated away, etc.) + return INITIAL_STATE; + }); } return cleanup; }, [jobId, connect, cleanup]);