Skip to content

Commit 2d587a1

Browse files
committed
feat(frontend): WebSocket real-time indexing progress (#115)
🚀 Features: - useIndexingWebSocket hook with auto-reconnect & polling fallback - Streaming file list - shows files appearing in real-time - Enhanced IndexingProgress with phase indicators & glow effects - IndexingComplete celebration screen with confetti & animated stats - Integrated WebSocket into HeroPlayground component 📁 Files: - NEW: hooks/useIndexingWebSocket.ts (WebSocket state machine) - NEW: components/playground/IndexingComplete.tsx (celebration UI) - UPDATED: components/playground/IndexingProgress.tsx (streaming file list) - UPDATED: components/playground/HeroPlayground.tsx (WS integration) - UPDATED: components/playground/index.ts (exports) 🎨 UX Highlights: - Files stream in real-time as they're processed - Phase indicator: Clone → Index → Done - Glowing progress bar - Confetti on completion - Animated stat counters Connects to backend WebSocket endpoint from PR #150
1 parent d0f2033 commit 2d587a1

5 files changed

Lines changed: 1192 additions & 166 deletions

File tree

frontend/src/components/playground/HeroPlayground.tsx

Lines changed: 208 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,32 @@
11
/**
2-
* HeroPlayground
2+
* HeroPlayground (v2 - WebSocket Enhanced)
33
*
44
* Combined demo + custom repo experience for the landing page hero.
5-
* Handles mode switching, URL validation, indexing, and search.
5+
* Now with real-time WebSocket progress updates and streaming file list.
6+
*
7+
* Features:
8+
* - Mode switching (demo/custom)
9+
* - URL validation
10+
* - Real-time indexing progress via WebSocket
11+
* - Streaming file list (the "holy shit" moment)
12+
* - Celebration screen on completion
613
*/
714

8-
import { useState, useCallback } from 'react';
15+
import { useState, useCallback, useEffect } from 'react';
916
import { useNavigate } from 'react-router-dom';
17+
import { motion, AnimatePresence } from 'framer-motion';
1018
import { Button } from '@/components/ui/button';
1119
import {
1220
RepoModeSelector,
1321
RepoUrlInput,
1422
ValidationStatus,
1523
IndexingProgress,
16-
type RepoMode
24+
IndexingComplete,
25+
type RepoMode,
26+
type IndexingPhase,
1727
} from '@/components/playground';
1828
import { useAnonymousSession } from '@/hooks/useAnonymousSession';
29+
import { useIndexingWebSocket } from '@/hooks/useIndexingWebSocket';
1930
import { cn } from '@/lib/utils';
2031

2132
// Demo repos config
@@ -62,18 +73,46 @@ export function HeroPlayground({
6273
const [selectedDemo, setSelectedDemo] = useState(DEMO_REPOS[0].id);
6374
const [customUrl, setCustomUrl] = useState('');
6475
const [query, setQuery] = useState('');
76+
const [showCelebration, setShowCelebration] = useState(false);
6577

66-
// Anonymous session hook for custom repos
67-
const { state, validateUrl, startIndexing, reset, session } = useAnonymousSession();
78+
// Anonymous session hook for validation and job creation
79+
const { state, validateUrl, startIndexing, reset: resetSession, session } = useAnonymousSession();
80+
81+
// Extract jobId for WebSocket connection
82+
const jobId = state.status === 'indexing' ? state.jobId : null;
83+
const repoName = customUrl.split('/').pop()?.replace('.git', '') || 'repository';
84+
85+
// WebSocket hook for real-time progress
86+
const wsState = useIndexingWebSocket(jobId, {
87+
maxRecentFiles: 12,
88+
onCompleted: (repoId, stats) => {
89+
console.log('[HeroPlayground] Indexing completed:', repoId, stats);
90+
setShowCelebration(true);
91+
},
92+
onError: (error, recoverable) => {
93+
console.error('[HeroPlayground] Indexing error:', error, recoverable);
94+
},
95+
});
96+
97+
// Map WebSocket phase to IndexingProgress phase
98+
const getIndexingPhase = (): IndexingPhase => {
99+
if (wsState.phase === 'cloning') return 'cloning';
100+
if (wsState.phase === 'indexing') return 'indexing';
101+
if (wsState.phase === 'completed') return 'completed';
102+
if (wsState.phase === 'error') return 'error';
103+
return 'connecting';
104+
};
68105

69106
// Handle mode change
70107
const handleModeChange = useCallback((newMode: RepoMode) => {
71108
setMode(newMode);
72109
if (newMode === 'demo') {
73-
reset();
110+
resetSession();
111+
wsState.reset();
74112
setCustomUrl('');
113+
setShowCelebration(false);
75114
}
76-
}, [reset]);
115+
}, [resetSession, wsState]);
77116

78117
// Handle search submit
79118
const handleSearch = useCallback((e?: React.FormEvent) => {
@@ -84,8 +123,24 @@ export function HeroPlayground({
84123
onSearch(query, selectedDemo, false);
85124
} else if (state.status === 'ready') {
86125
onSearch(query, state.repoId, true);
126+
} else if (wsState.isCompleted && wsState.repoId) {
127+
// Search using repo from completed WebSocket state
128+
onSearch(query, wsState.repoId, true);
87129
}
88-
}, [query, mode, selectedDemo, state, loading, onSearch]);
130+
}, [query, mode, selectedDemo, state, wsState, loading, onSearch]);
131+
132+
// Start searching after celebration
133+
const handleStartSearching = useCallback(() => {
134+
setShowCelebration(false);
135+
}, []);
136+
137+
// Index another repo
138+
const handleIndexAnother = useCallback(() => {
139+
resetSession();
140+
wsState.reset();
141+
setCustomUrl('');
142+
setShowCelebration(false);
143+
}, [resetSession, wsState]);
89144

90145
// Get validation state for ValidationStatus component
91146
const getValidationState = () => {
@@ -96,44 +151,44 @@ export function HeroPlayground({
96151
return { type: 'idle' as const };
97152
};
98153

154+
// Determine visibility states
155+
const showDemoSelector = mode === 'demo';
156+
const showUrlInput = mode === 'custom' && !['indexing', 'ready'].includes(state.status) && !showCelebration && !wsState.isCompleted;
157+
const showValidation = mode === 'custom' && ['validating', 'valid', 'invalid'].includes(state.status) && !showCelebration;
158+
const showIndexing = mode === 'custom' && state.status === 'indexing' && !showCelebration && !wsState.isCompleted;
159+
const showReady = (mode === 'custom' && state.status === 'ready') || (wsState.isCompleted && !showCelebration);
160+
const isSearchDisabled = mode === 'custom' && state.status !== 'ready' && !wsState.isCompleted;
161+
99162
// Can search?
100163
const canSearch = mode === 'demo'
101164
? remaining > 0 && query.trim().length > 0
102-
: state.status === 'ready' && remaining > 0 && query.trim().length > 0;
103-
104-
// Determine what to show based on state
105-
const showDemoSelector = mode === 'demo';
106-
const showUrlInput = mode === 'custom' && !['indexing', 'ready'].includes(state.status);
107-
const showValidation = mode === 'custom' && ['validating', 'valid', 'invalid'].includes(state.status);
108-
const showIndexing = mode === 'custom' && state.status === 'indexing';
109-
// Always show search - in custom mode it's disabled until repo is indexed
110-
const showSearch = true;
111-
const isSearchDisabled = mode === 'custom' && state.status !== 'ready';
165+
: (state.status === 'ready' || wsState.isCompleted) && remaining > 0 && query.trim().length > 0;
112166

113167
// Get contextual placeholder text
114168
const getPlaceholder = () => {
115169
if (mode === 'demo') {
116170
return "Search for authentication, error handling...";
117171
}
118-
// Custom mode placeholders based on state
119-
switch (state.status) {
120-
case 'idle':
121-
return "Enter a GitHub URL above to start...";
122-
case 'validating':
123-
return "Validating repository...";
124-
case 'valid':
125-
return "Click 'Index Repository' to continue...";
126-
case 'invalid':
127-
return "Fix the URL above to continue...";
128-
case 'indexing':
129-
return "Indexing in progress...";
130-
case 'ready':
131-
return `Search in ${state.repoName}...`;
132-
default:
133-
return "Enter a GitHub URL to search...";
134-
}
172+
if (state.status === 'idle') return "Enter a GitHub URL above to start...";
173+
if (state.status === 'validating') return "Validating repository...";
174+
if (state.status === 'valid') return "Click 'Index Repository' to continue...";
175+
if (state.status === 'invalid') return "Fix the URL above to continue...";
176+
if (state.status === 'indexing') return "Indexing in progress...";
177+
if (state.status === 'ready' || wsState.isCompleted) return `Search in ${repoName}...`;
178+
return "Enter a GitHub URL to search...";
135179
};
136180

181+
// Compute ready state info
182+
const readyInfo = wsState.isCompleted && wsState.completedStats ? {
183+
repoName,
184+
fileCount: wsState.completedStats.files_processed,
185+
functionsFound: wsState.completedStats.functions_indexed,
186+
} : state.status === 'ready' ? {
187+
repoName: state.repoName,
188+
fileCount: state.fileCount,
189+
functionsFound: state.functionsFound,
190+
} : null;
191+
137192
return (
138193
<div className="w-full max-w-2xl mx-auto">
139194
{/* Mode Selector */}
@@ -174,65 +229,114 @@ export function HeroPlayground({
174229
)}
175230

176231
{/* Custom URL Input */}
177-
{showUrlInput && (
178-
<div className="mb-4">
179-
<RepoUrlInput
180-
value={customUrl}
181-
onChange={setCustomUrl}
182-
onValidate={validateUrl}
183-
placeholder="https://github.com/owner/repo"
184-
disabled={state.status === 'validating'}
185-
/>
186-
</div>
187-
)}
232+
<AnimatePresence mode="wait">
233+
{showUrlInput && (
234+
<motion.div
235+
key="url-input"
236+
initial={{ opacity: 0, y: -10 }}
237+
animate={{ opacity: 1, y: 0 }}
238+
exit={{ opacity: 0, y: 10 }}
239+
className="mb-4"
240+
>
241+
<RepoUrlInput
242+
value={customUrl}
243+
onChange={setCustomUrl}
244+
onValidate={validateUrl}
245+
placeholder="https://github.com/owner/repo"
246+
disabled={state.status === 'validating'}
247+
/>
248+
</motion.div>
249+
)}
188250

189-
{/* Validation Status */}
190-
{showValidation && (
191-
<div className="mb-4">
192-
<ValidationStatus
193-
state={getValidationState()}
194-
onStartIndexing={state.status === 'valid' ? startIndexing : undefined}
195-
/>
196-
</div>
197-
)}
251+
{/* Validation Status */}
252+
{showValidation && (
253+
<motion.div
254+
key="validation"
255+
initial={{ opacity: 0, y: -10 }}
256+
animate={{ opacity: 1, y: 0 }}
257+
exit={{ opacity: 0, y: 10 }}
258+
className="mb-4"
259+
>
260+
<ValidationStatus
261+
state={getValidationState()}
262+
onStartIndexing={state.status === 'valid' ? startIndexing : undefined}
263+
/>
264+
</motion.div>
265+
)}
198266

199-
{/* Indexing Progress */}
200-
{showIndexing && (
201-
<div className="mb-4">
202-
<IndexingProgress
203-
progress={state.progress}
204-
repoName={customUrl.split('/').pop()?.replace('.git', '')}
205-
onCancel={reset}
206-
/>
207-
</div>
208-
)}
267+
{/* Indexing Progress with WebSocket streaming */}
268+
{showIndexing && (
269+
<motion.div
270+
key="indexing"
271+
initial={{ opacity: 0, scale: 0.95 }}
272+
animate={{ opacity: 1, scale: 1 }}
273+
exit={{ opacity: 0, scale: 0.95 }}
274+
className="mb-4"
275+
>
276+
<IndexingProgress
277+
progress={wsState.progress}
278+
phase={getIndexingPhase()}
279+
repoName={repoName}
280+
recentFiles={wsState.recentFiles}
281+
onCancel={() => {
282+
resetSession();
283+
wsState.reset();
284+
}}
285+
/>
286+
</motion.div>
287+
)}
209288

210-
{/* Ready State Banner */}
211-
{mode === 'custom' && state.status === 'ready' && (
212-
<div className="mb-4 px-4 py-3 rounded-lg bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-between">
213-
<div className="flex items-center gap-2">
214-
<span className="text-emerald-400"></span>
215-
<span className="text-emerald-300 text-sm">
216-
<strong>{state.repoName}</strong> indexed · {state.fileCount} files · {state.functionsFound} functions
217-
</span>
218-
</div>
219-
<button
220-
type="button"
221-
onClick={reset}
222-
className="text-xs text-zinc-500 hover:text-zinc-300"
289+
{/* Celebration Screen */}
290+
{showCelebration && wsState.completedStats && (
291+
<motion.div
292+
key="celebration"
293+
initial={{ opacity: 0, scale: 0.9 }}
294+
animate={{ opacity: 1, scale: 1 }}
295+
exit={{ opacity: 0, scale: 0.9 }}
296+
className="mb-4"
223297
>
224-
Index different repo
225-
</button>
226-
</div>
227-
)}
298+
<IndexingComplete
299+
repoName={repoName}
300+
stats={wsState.completedStats}
301+
onStartSearching={handleStartSearching}
302+
onIndexAnother={handleIndexAnother}
303+
/>
304+
</motion.div>
305+
)}
306+
307+
{/* Ready State Banner */}
308+
{showReady && !showCelebration && readyInfo && (
309+
<motion.div
310+
key="ready"
311+
initial={{ opacity: 0, y: -10 }}
312+
animate={{ opacity: 1, y: 0 }}
313+
exit={{ opacity: 0, y: 10 }}
314+
className="mb-4 px-4 py-3 rounded-xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-between"
315+
>
316+
<div className="flex items-center gap-2">
317+
<span className="text-emerald-400"></span>
318+
<span className="text-emerald-300 text-sm">
319+
<strong>{readyInfo.repoName}</strong> indexed · {readyInfo.fileCount} files · {readyInfo.functionsFound.toLocaleString()} functions
320+
</span>
321+
</div>
322+
<button
323+
type="button"
324+
onClick={handleIndexAnother}
325+
className="text-xs text-zinc-500 hover:text-zinc-300 transition-colors"
326+
>
327+
Index different repo
328+
</button>
329+
</motion.div>
330+
)}
331+
</AnimatePresence>
228332

229333
{/* Search Box */}
230-
{showSearch && (
334+
{!showCelebration && (
231335
<>
232336
<div className="relative mb-4">
233337
<div className="absolute inset-0 bg-gradient-to-r from-indigo-500/20 to-cyan-500/20 rounded-2xl blur-xl opacity-50" />
234338
<div className={cn(
235-
"relative bg-zinc-900/80 rounded-2xl border border-zinc-800 p-3",
339+
"relative bg-zinc-900/80 rounded-2xl border border-zinc-800 p-3 transition-opacity duration-300",
236340
isSearchDisabled && "opacity-60"
237341
)}>
238342
<form onSubmit={handleSearch} className="flex items-center gap-3">
@@ -282,19 +386,23 @@ export function HeroPlayground({
282386
)}
283387

284388
{/* Error State */}
285-
{state.status === 'error' && (
286-
<div className="mt-4 px-4 py-3 rounded-lg bg-red-500/10 border border-red-500/20">
287-
<p className="text-red-300 text-sm">{state.message}</p>
288-
{state.canRetry && (
289-
<button
290-
type="button"
291-
onClick={reset}
292-
className="mt-2 text-xs text-red-400 hover:text-red-300"
293-
>
294-
Try again
295-
</button>
296-
)}
297-
</div>
389+
{(state.status === 'error' || wsState.hasError) && (
390+
<motion.div
391+
initial={{ opacity: 0, y: 10 }}
392+
animate={{ opacity: 1, y: 0 }}
393+
className="mt-4 px-4 py-3 rounded-xl bg-red-500/10 border border-red-500/20"
394+
>
395+
<p className="text-red-300 text-sm">
396+
{state.status === 'error' ? state.message : wsState.error}
397+
</p>
398+
<button
399+
type="button"
400+
onClick={handleIndexAnother}
401+
className="mt-2 text-xs text-red-400 hover:text-red-300 transition-colors"
402+
>
403+
Try again
404+
</button>
405+
</motion.div>
298406
)}
299407

300408
{/* Upgrade CTA (when limit reached) */}
@@ -312,3 +420,5 @@ export function HeroPlayground({
312420
</div>
313421
);
314422
}
423+
424+
export default HeroPlayground;

0 commit comments

Comments
 (0)