diff --git a/frontend/src/components/playground/IndexingProgress.tsx b/frontend/src/components/playground/IndexingProgress.tsx
new file mode 100644
index 0000000..05e7e3d
--- /dev/null
+++ b/frontend/src/components/playground/IndexingProgress.tsx
@@ -0,0 +1,136 @@
+/**
+ * IndexingProgress
+ *
+ * Displays real-time progress during repository indexing.
+ * Shows progress bar, file stats, and current file being processed.
+ */
+
+import { cn } from '@/lib/utils';
+import { Progress } from '@/components/ui/progress';
+
+export interface ProgressData {
+ percent: number;
+ filesProcessed: number;
+ filesTotal: number;
+ currentFile?: string;
+ functionsFound: number;
+}
+
+interface IndexingProgressProps {
+ progress: ProgressData;
+ repoName?: string;
+ onCancel?: () => void;
+}
+
+function AnimatedDots() {
+ return (
+
+ .
+ .
+ .
+
+ );
+}
+
+/**
+ * Estimate remaining time based on current progress.
+ * Returns null if not enough data to estimate.
+ */
+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));
+}
+
+export function IndexingProgress({ progress, repoName, onCancel }: IndexingProgressProps) {
+ const { percent, filesProcessed, filesTotal, currentFile, functionsFound } = progress;
+ const estimatedRemaining = estimateRemainingSeconds(percent, filesProcessed);
+
+ return (
+
+ {/* Header */}
+
+
+
+
⚡
+
+ Indexing {repoName || 'repository'}
+
+
+
+
+ {percent}%
+
+
+
+
+ {/* Progress bar */}
+
+
+ {/* Stats grid */}
+
+
+
+
Files
+
+ {filesProcessed} / {filesTotal}
+
+
+
+
Functions
+
+ {functionsFound.toLocaleString()}
+
+
+
+
Remaining
+
+ {estimatedRemaining !== null ? `~${estimatedRemaining}s` : '—'}
+
+
+
+
+
+ {/* Current file */}
+ {currentFile && (
+
+
+ 📄
+
+ {currentFile}
+
+
+
+ )}
+
+ {/* Cancel button */}
+ {onCancel && (
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/playground/RepoModeSelector.tsx b/frontend/src/components/playground/RepoModeSelector.tsx
new file mode 100644
index 0000000..d5aba68
--- /dev/null
+++ b/frontend/src/components/playground/RepoModeSelector.tsx
@@ -0,0 +1,65 @@
+/**
+ * RepoModeSelector
+ *
+ * Tab toggle between Demo repos and User's custom repo.
+ * Used at the top of the Playground to switch input modes.
+ */
+
+import { cn } from '@/lib/utils';
+
+export type RepoMode = 'demo' | 'custom';
+
+interface RepoModeSelectorProps {
+ mode: RepoMode;
+ onModeChange: (mode: RepoMode) => void;
+ disabled?: boolean;
+}
+
+export function RepoModeSelector({
+ mode,
+ onModeChange,
+ disabled = false
+}: RepoModeSelectorProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/playground/RepoUrlInput.tsx b/frontend/src/components/playground/RepoUrlInput.tsx
new file mode 100644
index 0000000..929d867
--- /dev/null
+++ b/frontend/src/components/playground/RepoUrlInput.tsx
@@ -0,0 +1,142 @@
+/**
+ * RepoUrlInput
+ *
+ * URL input field for GitHub repository URLs.
+ * Features debounced validation, GitHub icon, and clear button.
+ */
+
+import { useState, useEffect } from 'react';
+import { cn } from '@/lib/utils';
+
+interface RepoUrlInputProps {
+ value: string;
+ onChange: (url: string) => void;
+ onValidate: (url: string) => void;
+ disabled?: boolean;
+ placeholder?: string;
+}
+
+// Icons as named components for better readability
+function GitHubIcon() {
+ return (
+
+ );
+}
+
+function ClearIcon() {
+ return (
+
+ );
+}
+
+const DEBOUNCE_MS = 500;
+
+export function RepoUrlInput({
+ value,
+ onChange,
+ onValidate,
+ disabled = false,
+ placeholder = "https://github.com/owner/repo"
+}: RepoUrlInputProps) {
+ const [localValue, setLocalValue] = useState(value);
+
+ // Sync with external value changes
+ useEffect(() => {
+ setLocalValue(value);
+ }, [value]);
+
+ // Debounced validation trigger
+ useEffect(() => {
+ if (!localValue.trim() || localValue === value) {
+ return;
+ }
+
+ const timer = setTimeout(() => {
+ onChange(localValue);
+ onValidate(localValue);
+ }, DEBOUNCE_MS);
+
+ return () => clearTimeout(timer);
+ }, [localValue, value, onChange, onValidate]);
+
+ const handleChange = (e: React.ChangeEvent) => {
+ setLocalValue(e.target.value);
+ };
+
+ const handleClear = () => {
+ setLocalValue('');
+ onChange('');
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter' && localValue.trim()) {
+ e.preventDefault();
+ onChange(localValue);
+ onValidate(localValue);
+ }
+ };
+
+ return (
+
+ {/* GitHub icon */}
+
+
+
+
+ {/* Input */}
+
+
+ {/* Clear button */}
+ {localValue && !disabled && (
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/playground/ValidationStatus.tsx b/frontend/src/components/playground/ValidationStatus.tsx
new file mode 100644
index 0000000..a54c066
--- /dev/null
+++ b/frontend/src/components/playground/ValidationStatus.tsx
@@ -0,0 +1,170 @@
+/**
+ * ValidationStatus
+ *
+ * Displays repository validation result.
+ * Shows loading, valid (with stats), or invalid states.
+ */
+
+import { cn } from '@/lib/utils';
+import type { ValidationResult } from '@/services/playground-api';
+
+// ============ State Types ============
+
+type ValidationState =
+ | { type: 'idle' }
+ | { type: 'validating' }
+ | { type: 'valid'; validation: ValidationResult }
+ | { type: 'invalid'; error: string; reason?: string };
+
+interface ValidationStatusProps {
+ state: ValidationState;
+ onStartIndexing?: () => void;
+}
+
+// ============ Icons ============
+
+function CheckIcon() {
+ return (
+
+ );
+}
+
+function XIcon() {
+ return (
+
+ );
+}
+
+function LockIcon() {
+ return (
+
+ );
+}
+
+function SpinnerIcon() {
+ return (
+
+ );
+}
+
+function StarIcon() {
+ return (
+
+ );
+}
+
+// ============ Helpers ============
+
+function formatNumber(num: number): string {
+ if (num >= 1000) {
+ return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k';
+ }
+ return num.toString();
+}
+
+// ============ Component ============
+
+export function ValidationStatus({ state, onStartIndexing }: ValidationStatusProps) {
+ if (state.type === 'idle') {
+ return null;
+ }
+
+ if (state.type === 'validating') {
+ return (
+
+
+ Checking repository...
+
+ );
+ }
+
+ if (state.type === 'invalid') {
+ const icon = state.reason === 'private' ? : ;
+
+ return (
+
+ {icon}
+ {state.error}
+
+ );
+ }
+
+ // Valid state
+ const { validation } = state;
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ Ready to index
+
+
+ {/* Stats */}
+
+
+ 📁
+ {validation.file_count.toLocaleString()} files
+
+
+ {validation.stars > 0 && (
+
+
+ {formatNumber(validation.stars)}
+
+ )}
+
+ {validation.language && (
+
+ 💻
+ {validation.language}
+
+ )}
+
+
+ ⏱️
+ ~{validation.estimated_time_seconds}s
+
+
+
+ {/* Action */}
+ {onStartIndexing && (
+
+
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/playground/index.ts b/frontend/src/components/playground/index.ts
new file mode 100644
index 0000000..097a740
--- /dev/null
+++ b/frontend/src/components/playground/index.ts
@@ -0,0 +1,11 @@
+/**
+ * Playground Components
+ *
+ * Anonymous repo indexing UI components.
+ * @see useAnonymousSession hook for state management
+ */
+
+export { RepoModeSelector, type RepoMode } from './RepoModeSelector';
+export { RepoUrlInput } from './RepoUrlInput';
+export { ValidationStatus } from './ValidationStatus';
+export { IndexingProgress, type ProgressData } from './IndexingProgress';
diff --git a/frontend/src/hooks/useAnonymousSession.ts b/frontend/src/hooks/useAnonymousSession.ts
new file mode 100644
index 0000000..80ab1b2
--- /dev/null
+++ b/frontend/src/hooks/useAnonymousSession.ts
@@ -0,0 +1,345 @@
+/**
+ * useAnonymousSession Hook
+ *
+ * State machine for anonymous repo indexing flow.
+ * Manages validation, indexing progress, and session state.
+ *
+ * @example
+ * ```tsx
+ * const { state, validateUrl, startIndexing, reset } = useAnonymousSession();
+ *
+ * // State machine: idle → validating → valid/invalid → indexing → ready/error
+ * ```
+ */
+
+import { useState, useCallback, useRef, useEffect } from 'react';
+import {
+ playgroundAPI,
+ type ValidationResult,
+ type SessionData
+} from '../services/playground-api';
+
+// ============ State Types ============
+
+interface IdleState {
+ status: 'idle';
+}
+
+interface ValidatingState {
+ status: 'validating';
+ url: string;
+}
+
+interface ValidState {
+ status: 'valid';
+ url: string;
+ validation: ValidationResult;
+}
+
+interface InvalidState {
+ status: 'invalid';
+ url: string;
+ error: string;
+ reason?: 'private' | 'too_large' | 'invalid_url' | 'rate_limited';
+}
+
+interface IndexingState {
+ status: 'indexing';
+ url: string;
+ jobId: string;
+ progress: {
+ percent: number;
+ filesProcessed: number;
+ filesTotal: number;
+ currentFile?: string;
+ functionsFound: number;
+ };
+}
+
+interface ReadyState {
+ status: 'ready';
+ repoId: string;
+ repoName: string;
+ owner: string;
+ fileCount: number;
+ functionsFound: number;
+ expiresAt: string;
+}
+
+interface ErrorState {
+ status: 'error';
+ message: string;
+ canRetry: boolean;
+}
+
+export type PlaygroundState =
+ | IdleState
+ | ValidatingState
+ | ValidState
+ | InvalidState
+ | IndexingState
+ | ReadyState
+ | ErrorState;
+
+// ============ Hook Return Type ============
+
+export interface UseAnonymousSessionReturn {
+ state: PlaygroundState;
+ session: SessionData | null;
+ validateUrl: (url: string) => Promise;
+ startIndexing: () => Promise;
+ reset: () => void;
+ isLoading: boolean;
+}
+
+// ============ Constants ============
+
+const POLLING_INTERVAL_MS = 2000;
+
+// ============ Hook Implementation ============
+
+export function useAnonymousSession(): UseAnonymousSessionReturn {
+ const [state, setState] = useState({ status: 'idle' });
+ const [session, setSession] = useState(null);
+
+ // Refs for cleanup and race condition handling
+ const pollingRef = useRef | null>(null);
+ const currentUrlRef = useRef('');
+ const abortControllerRef = useRef(null);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ }
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+ };
+ }, []);
+
+ /**
+ * Check if user already has an indexed repo in session
+ */
+ const checkExistingSession = useCallback(async () => {
+ try {
+ const sessionData = await playgroundAPI.getSession();
+ setSession(sessionData);
+
+ if (sessionData.indexed_repo) {
+ setState({
+ status: 'ready',
+ repoId: sessionData.indexed_repo.repo_id,
+ repoName: sessionData.indexed_repo.name,
+ owner: sessionData.indexed_repo.owner || '',
+ fileCount: sessionData.indexed_repo.file_count,
+ functionsFound: 0,
+ expiresAt: sessionData.indexed_repo.expires_at,
+ });
+ }
+ } catch {
+ // No session yet - this is expected for new users
+ }
+ }, []);
+
+ // Check for existing session on mount
+ useEffect(() => {
+ checkExistingSession();
+ }, [checkExistingSession]);
+
+ /**
+ * Poll for indexing job status
+ */
+ const startPolling = useCallback((jobId: string, url: string) => {
+ // Clear any existing polling
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ }
+
+ const poll = async () => {
+ try {
+ const status = await playgroundAPI.getIndexingStatus(jobId);
+
+ if (status.status === 'completed') {
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ pollingRef.current = null;
+ }
+
+ const sessionData = await playgroundAPI.getSession();
+ setSession(sessionData);
+
+ setState({
+ status: 'ready',
+ repoId: status.repo_id!,
+ repoName: status.repository?.name || '',
+ owner: status.repository?.owner || '',
+ fileCount: status.stats?.files_indexed || 0,
+ functionsFound: status.stats?.functions_found || 0,
+ expiresAt: sessionData.indexed_repo?.expires_at || '',
+ });
+ } else if (status.status === 'failed') {
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ pollingRef.current = null;
+ }
+
+ setState({
+ status: 'error',
+ message: status.error || 'Indexing failed',
+ canRetry: true,
+ });
+ } else {
+ // Update progress
+ setState({
+ status: 'indexing',
+ url,
+ jobId,
+ progress: {
+ percent: status.progress?.percent_complete || 0,
+ filesProcessed: status.progress?.files_processed || 0,
+ filesTotal: status.progress?.files_total || 0,
+ currentFile: status.progress?.current_file,
+ functionsFound: status.progress?.functions_found || 0,
+ },
+ });
+ }
+ } catch (error) {
+ // Log but don't stop polling on transient errors
+ console.error('Polling error:', error);
+ }
+ };
+
+ // Poll immediately, then at interval
+ poll();
+ pollingRef.current = setInterval(poll, POLLING_INTERVAL_MS);
+ }, []);
+
+ /**
+ * Validate a GitHub URL
+ */
+ const validateUrl = useCallback(async (url: string) => {
+ if (!url.trim()) {
+ setState({ status: 'idle' });
+ return;
+ }
+
+ // Cancel any in-flight request
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ }
+ abortControllerRef.current = new AbortController();
+
+ currentUrlRef.current = url;
+ setState({ status: 'validating', url });
+
+ try {
+ const validation = await playgroundAPI.validateRepo(url);
+
+ // Ignore if URL changed during validation
+ if (currentUrlRef.current !== url) return;
+
+ if (validation.can_index) {
+ setState({ status: 'valid', url, validation });
+ } else {
+ setState({
+ status: 'invalid',
+ url,
+ error: getErrorMessage(validation.reason),
+ reason: validation.reason,
+ });
+ }
+ } catch (error) {
+ // Ignore abort errors
+ if (error instanceof Error && error.name === 'AbortError') return;
+ if (currentUrlRef.current !== url) return;
+
+ setState({
+ status: 'invalid',
+ url,
+ error: error instanceof Error ? error.message : 'Validation failed',
+ });
+ }
+ }, []);
+
+ /**
+ * Start indexing the validated repo
+ */
+ const startIndexing = useCallback(async () => {
+ if (state.status !== 'valid') {
+ console.error('Cannot start indexing: not in valid state');
+ return;
+ }
+
+ const { url, validation } = state;
+
+ try {
+ const job = await playgroundAPI.startIndexing(url);
+
+ setState({
+ status: 'indexing',
+ url,
+ jobId: job.job_id,
+ progress: {
+ percent: 0,
+ filesProcessed: 0,
+ filesTotal: validation.file_count,
+ functionsFound: 0,
+ },
+ });
+
+ startPolling(job.job_id, url);
+ } catch (error) {
+ setState({
+ status: 'error',
+ message: error instanceof Error ? error.message : 'Failed to start indexing',
+ canRetry: true,
+ });
+ }
+ }, [state, startPolling]);
+
+ /**
+ * Reset to idle state
+ */
+ const reset = useCallback(() => {
+ if (pollingRef.current) {
+ clearInterval(pollingRef.current);
+ pollingRef.current = null;
+ }
+ if (abortControllerRef.current) {
+ abortControllerRef.current.abort();
+ abortControllerRef.current = null;
+ }
+ currentUrlRef.current = '';
+ setState({ status: 'idle' });
+ }, []);
+
+ const isLoading = state.status === 'validating' || state.status === 'indexing';
+
+ return {
+ state,
+ session,
+ validateUrl,
+ startIndexing,
+ reset,
+ isLoading,
+ };
+}
+
+// ============ Helpers ============
+
+function getErrorMessage(reason?: string): string {
+ switch (reason) {
+ case 'private':
+ return 'This repository is private. Sign up to index private repos.';
+ case 'too_large':
+ return 'This repository is too large for anonymous indexing. Sign up for full access.';
+ case 'invalid_url':
+ return 'Please enter a valid GitHub repository URL.';
+ case 'rate_limited':
+ return 'Rate limit reached. Please try again later.';
+ default:
+ return 'Unable to validate this repository.';
+ }
+}
diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts
new file mode 100644
index 0000000..b95a468
--- /dev/null
+++ b/frontend/src/services/index.ts
@@ -0,0 +1,12 @@
+/**
+ * Services barrel export
+ */
+
+export { playgroundAPI, APIError } from './playground-api';
+export type {
+ ValidationResult,
+ IndexingJob,
+ SessionData,
+ SearchResult,
+ SearchResponse
+} from './playground-api';
diff --git a/frontend/src/services/playground-api.ts b/frontend/src/services/playground-api.ts
new file mode 100644
index 0000000..e3f3f51
--- /dev/null
+++ b/frontend/src/services/playground-api.ts
@@ -0,0 +1,320 @@
+/**
+ * Playground API Service
+ *
+ * Handles all anonymous indexing API calls.
+ *
+ * Endpoints:
+ * - POST /playground/validate-repo (BLOCKED by #134 - using mock)
+ * - POST /playground/index
+ * - GET /playground/index/{job_id}
+ * - GET /playground/session
+ * - POST /playground/search
+ */
+
+import { API_URL } from '../config/api';
+
+// ============ Types ============
+
+export interface ValidationResult {
+ can_index: boolean;
+ reason?: 'private' | 'too_large' | 'invalid_url' | 'rate_limited';
+ repo_name: string;
+ owner: string;
+ file_count: number;
+ stars: number;
+ language: string;
+ default_branch: string;
+ estimated_time_seconds: number;
+}
+
+export interface IndexingJob {
+ job_id: string;
+ status: 'queued' | 'cloning' | 'processing' | 'completed' | 'failed';
+ repo_id?: string;
+ repository?: {
+ owner: string;
+ name: string;
+ branch: string;
+ github_url: string;
+ };
+ progress?: {
+ files_processed: number;
+ files_total: number;
+ percent_complete: number;
+ current_file?: string;
+ functions_found: number;
+ };
+ stats?: {
+ files_indexed: number;
+ functions_found: number;
+ time_taken_seconds: number;
+ };
+ error?: string;
+ estimated_time_seconds?: number;
+ message?: string;
+}
+
+export interface SessionData {
+ session_id: string;
+ indexed_repo: {
+ repo_id: string;
+ github_url: string;
+ name: string;
+ owner?: string;
+ file_count: number;
+ indexed_at: string;
+ expires_at: string;
+ } | null;
+ searches: {
+ used: number;
+ limit: number;
+ remaining: number;
+ };
+}
+
+export interface SearchResult {
+ name: string;
+ type: string;
+ file_path: string;
+ code: string;
+ language: string;
+ line_start: number;
+ line_end: number;
+ score: number;
+}
+
+export interface SearchResponse {
+ results: SearchResult[];
+ search_time_ms: number;
+ remaining_searches: number;
+ limit: number;
+}
+
+// ============ Error Classes ============
+
+export class APIError extends Error {
+ constructor(
+ message: string,
+ public status: number,
+ public code?: string
+ ) {
+ super(message);
+ this.name = 'APIError';
+ }
+}
+
+// ============ Helpers ============
+
+async function parseErrorResponse(response: Response): Promise {
+ try {
+ const data = await response.json();
+ return data.detail?.message || data.detail || data.message || 'Request failed';
+ } catch {
+ return `Request failed with status ${response.status}`;
+ }
+}
+
+// ============ API Client ============
+
+class PlaygroundAPI {
+ private baseUrl: string;
+
+ constructor() {
+ this.baseUrl = `${API_URL}/playground`;
+ }
+
+ /**
+ * Validate a GitHub repo URL before indexing.
+ *
+ * @param githubUrl - Full GitHub URL (e.g., https://github.com/owner/repo)
+ * @returns Validation result with repo metadata
+ * @throws APIError on failure
+ *
+ * NOTE: Currently mocked due to Bug #134 (CacheService missing get/set)
+ */
+ async validateRepo(githubUrl: string): Promise {
+ // TODO: Remove mock when #134 is fixed
+ const USE_MOCK = true;
+
+ if (USE_MOCK) {
+ return this.mockValidateRepo(githubUrl);
+ }
+
+ const response = await fetch(`${this.baseUrl}/validate-repo`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ github_url: githubUrl }),
+ });
+
+ if (!response.ok) {
+ const message = await parseErrorResponse(response);
+ throw new APIError(message, response.status);
+ }
+
+ return response.json();
+ }
+
+ /**
+ * Mock validation for development until Bug #134 is fixed.
+ * Simulates realistic validation responses.
+ */
+ private async mockValidateRepo(githubUrl: string): Promise {
+ // Simulate network latency
+ await new Promise(resolve => setTimeout(resolve, 600 + Math.random() * 400));
+
+ // Parse URL
+ const match = githubUrl.match(/github\.com\/([^/]+)\/([^/\s?#]+)/);
+ if (!match) {
+ return {
+ can_index: false,
+ reason: 'invalid_url',
+ repo_name: '',
+ owner: '',
+ file_count: 0,
+ stars: 0,
+ language: '',
+ default_branch: '',
+ estimated_time_seconds: 0,
+ };
+ }
+
+ const [, owner, repo] = match;
+ const repoName = repo.replace(/\.git$/, '');
+
+ // Detect language from common repo names
+ const language = this.detectLanguage(repoName, owner);
+ const fileCount = 50 + Math.floor(Math.random() * 250);
+
+ return {
+ can_index: true,
+ repo_name: repoName,
+ owner,
+ file_count: fileCount,
+ stars: Math.floor(Math.random() * 50000),
+ language,
+ default_branch: 'main',
+ estimated_time_seconds: Math.ceil(fileCount / 10),
+ };
+ }
+
+ /**
+ * Simple language detection based on repo/owner name patterns.
+ */
+ private detectLanguage(repo: string, owner: string): string {
+ const name = `${owner}/${repo}`.toLowerCase();
+
+ if (name.includes('flask') || name.includes('django') || name.includes('fastapi')) {
+ return 'Python';
+ }
+ if (name.includes('express') || name.includes('next') || name.includes('react')) {
+ return 'TypeScript';
+ }
+ if (name.includes('rails') || name.includes('ruby')) {
+ return 'Ruby';
+ }
+ if (name.includes('spring') || name.includes('java')) {
+ return 'Java';
+ }
+ if (name.includes('gin') || name.includes('echo')) {
+ return 'Go';
+ }
+
+ // Random fallback
+ const languages = ['Python', 'TypeScript', 'JavaScript', 'Go', 'Rust'];
+ return languages[Math.floor(Math.random() * languages.length)];
+ }
+
+ /**
+ * Start indexing a repository.
+ *
+ * @param githubUrl - Full GitHub URL
+ * @returns Job info with job_id for polling
+ * @throws APIError on failure (409 if already indexed)
+ */
+ async startIndexing(githubUrl: string): Promise {
+ const response = await fetch(`${this.baseUrl}/index`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ github_url: githubUrl }),
+ });
+
+ if (!response.ok) {
+ const message = await parseErrorResponse(response);
+ throw new APIError(message, response.status, response.status === 409 ? 'ALREADY_INDEXED' : undefined);
+ }
+
+ return response.json();
+ }
+
+ /**
+ * Poll indexing job status.
+ *
+ * @param jobId - Job ID from startIndexing
+ * @returns Current job status with progress
+ */
+ async getIndexingStatus(jobId: string): Promise {
+ const response = await fetch(`${this.baseUrl}/index/${jobId}`, {
+ credentials: 'include',
+ });
+
+ if (!response.ok) {
+ const message = await parseErrorResponse(response);
+ throw new APIError(message, response.status);
+ }
+
+ return response.json();
+ }
+
+ /**
+ * Get current session data including indexed repo and search limits.
+ */
+ async getSession(): Promise {
+ const response = await fetch(`${this.baseUrl}/session`, {
+ credentials: 'include',
+ });
+
+ if (!response.ok) {
+ const message = await parseErrorResponse(response);
+ throw new APIError(message, response.status);
+ }
+
+ return response.json();
+ }
+
+ /**
+ * Search in indexed repository.
+ *
+ * @param query - Natural language search query
+ * @param repoId - Repository ID from session
+ * @param maxResults - Maximum results to return (default: 10)
+ * @throws APIError on failure (429 if rate limited)
+ */
+ async search(query: string, repoId: string, maxResults = 10): Promise {
+ const response = await fetch(`${this.baseUrl}/search`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({
+ query,
+ repo_id: repoId,
+ max_results: maxResults,
+ }),
+ });
+
+ if (!response.ok) {
+ const message = await parseErrorResponse(response);
+ throw new APIError(
+ response.status === 429 ? 'Daily search limit reached' : message,
+ response.status,
+ response.status === 429 ? 'RATE_LIMITED' : undefined
+ );
+ }
+
+ return response.json();
+ }
+}
+
+// Export singleton instance
+export const playgroundAPI = new PlaygroundAPI();