quantum-assistant / src /components /Practice /PracticeInterface.tsx
github-actions[bot]
Deploy demo from GitHub Actions - 2025-12-24 02:23:20
6cdce85
'use client';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { ChevronLeft, ChevronRight, FileText, Lightbulb, Image as ImageIcon } from 'lucide-react';
import { clsx } from 'clsx';
import { CodeEditor } from './CodeEditor';
import { ProblemList } from './ProblemList';
import { TestRunner } from './TestRunner';
import { AIHelper } from './AIHelper';
import { TASK_LABELS, CATEGORY_LABELS } from '@/config/constants';
import { extractCodeFromResponse, normalizeIndentation } from '@/lib/utils/response';
import type { CodingProblem, TestResult } from '@/types';
interface PracticeInterfaceProps {
className?: string;
}
// State saved per problem
interface ProblemState {
code: string;
testResult: TestResult | null;
}
export function PracticeInterface({ className }: PracticeInterfaceProps) {
const [selectedProblem, setSelectedProblem] = useState<CodingProblem | null>(null);
const [userCode, setUserCode] = useState('');
const [currentTestResult, setCurrentTestResult] = useState<TestResult | null>(null);
// Store state per problem (code and test results)
const [problemStates, setProblemStates] = useState<Map<string, ProblemState>>(new Map());
const [solvedProblems, setSolvedProblems] = useState<Set<string>>(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem('solvedProblems');
if (stored) {
try {
return new Set(JSON.parse(stored));
} catch {
return new Set();
}
}
}
return new Set();
});
const [isProblemListCollapsed, setIsProblemListCollapsed] = useState(false);
const [isAIHelperCollapsed, setIsAIHelperCollapsed] = useState(true);
const [problemListWidth, setProblemListWidth] = useState(320);
const [aiHelperWidth, setAIHelperWidth] = useState(320);
useEffect(() => {
if (typeof window !== 'undefined') {
localStorage.setItem('solvedProblems', JSON.stringify([...solvedProblems]));
}
}, [solvedProblems]);
// Save current problem state when code changes
useEffect(() => {
if (selectedProblem && userCode) {
setProblemStates(prev => {
const newStates = new Map(prev);
const existing = newStates.get(selectedProblem.id);
newStates.set(selectedProblem.id, {
code: userCode,
testResult: existing?.testResult ?? currentTestResult,
});
return newStates;
});
}
}, [selectedProblem, userCode]);
// Extract code from question for function_completion problems
const extractCodeFromQuestion = useCallback((question: string): { description: string; code: string | null } => {
const codeBlockMatch = question.match(/```python\n([\s\S]*?)```/);
if (codeBlockMatch) {
// Remove the code block from the description
const description = question.replace(/```python\n[\s\S]*?```/, '').trim();
return { description, code: codeBlockMatch[1].trim() };
}
return { description: question, code: null };
}, []);
// Get display description (without code block for function_completion)
const displayDescription = useMemo(() => {
if (!selectedProblem) return '';
if (selectedProblem.type === 'function_completion') {
const { description } = extractCodeFromQuestion(selectedProblem.question);
return description || 'Complete the function below:';
}
return selectedProblem.question;
}, [selectedProblem, extractCodeFromQuestion]);
// Get the function signature for function_completion problems
// Returns imports + def line + docstring (everything before 'pass')
const getFunctionSignature = useCallback((question: string): string | null => {
const { code } = extractCodeFromQuestion(question);
if (!code) return null;
const lines = code.split('\n');
const signatureLines: string[] = [];
let foundDef = false;
let inDocstring = false;
let docstringChar = '';
let docstringComplete = false;
for (const line of lines) {
const trimmed = line.trim();
// Check if this is the def line
if (!foundDef && trimmed.startsWith('def ')) {
foundDef = true;
signatureLines.push(line);
continue;
}
// If we haven't found def yet, this is an import or other preamble - include it
if (!foundDef) {
signatureLines.push(line);
continue;
}
// After def line, check for docstring
if (!inDocstring && !docstringComplete && (line.includes('"""') || line.includes("'''"))) {
signatureLines.push(line);
docstringChar = line.includes('"""') ? '"""' : "'''";
// Check if docstring starts and ends on same line
const count = (line.match(new RegExp(docstringChar.replace(/"/g, '\\"'), 'g')) || []).length;
if (count >= 2) {
// Docstring complete on one line
docstringComplete = true;
continue;
}
inDocstring = true;
continue;
}
// Check for docstring end (multi-line docstring)
if (inDocstring && line.includes(docstringChar)) {
signatureLines.push(line);
inDocstring = false;
docstringComplete = true;
continue;
}
// Still inside multi-line docstring
if (inDocstring) {
signatureLines.push(line);
continue;
}
// After docstring is complete, stop at 'pass' or any actual code
if (docstringComplete || foundDef) {
if (trimmed === 'pass' || trimmed === '' || trimmed.startsWith('#')) {
// Skip 'pass', empty lines, and comments after docstring
continue;
}
// Found actual implementation code - stop here
break;
}
}
return signatureLines.join('\n');
}, [extractCodeFromQuestion]);
const handleSelectProblem = useCallback((problem: CodingProblem) => {
// Check if we have saved state for this problem
const savedState = problemStates.get(problem.id);
if (savedState) {
// Restore saved code and test result
setUserCode(savedState.code);
setCurrentTestResult(savedState.testResult);
} else {
// Set initial code template based on problem type
if (problem.type === 'function_completion') {
const { code } = extractCodeFromQuestion(problem.question);
if (code) {
setUserCode(code + '\n # Your code here\n pass');
} else {
setUserCode('# Write your solution here\n');
}
} else {
setUserCode('# Write your solution here\n');
}
// Reset test result for new problem
setCurrentTestResult(null);
}
setSelectedProblem(problem);
}, [extractCodeFromQuestion, problemStates]);
const handleTestComplete = useCallback((result: TestResult) => {
setCurrentTestResult(result);
if (selectedProblem) {
// Save test result to problem state
setProblemStates(prev => {
const newStates = new Map(prev);
newStates.set(selectedProblem.id, {
code: userCode,
testResult: result,
});
return newStates;
});
if (result.passed) {
setSolvedProblems((prev) => new Set([...prev, selectedProblem.id]));
}
}
}, [selectedProblem, userCode]);
const toggleAIHelper = useCallback(() => {
setIsAIHelperCollapsed(prev => !prev);
}, []);
// Handler to apply code from AI Helper to the editor
const handleApplyCode = useCallback((code: string) => {
if (!selectedProblem) {
setUserCode(code);
return;
}
// Extract actual code from markdown code blocks if present
const extractedCode = extractCodeFromResponse(code, selectedProblem.entryPoint);
if (selectedProblem.type === 'function_completion') {
// For function completion, combine the function signature with the generated body
const signature = getFunctionSignature(selectedProblem.question);
if (signature) {
// Check if the AI response already includes the full function definition
const hasFullFunction = extractedCode.match(/^\s*def\s+\w+\s*\(/m);
if (hasFullFunction) {
// AI returned full function - use it directly
const normalized = normalizeIndentation(extractedCode, 0);
setUserCode(normalized);
} else {
// AI returned only the body - combine with signature
// Normalize the body code to have consistent 4-space indentation for function body
const normalizedBody = normalizeIndentation(extractedCode, 4);
setUserCode(signature + '\n' + normalizedBody);
}
} else {
setUserCode(extractedCode);
}
} else {
// For code generation, replace the entire code
const normalized = normalizeIndentation(extractedCode, 0);
setUserCode(normalized);
}
}, [selectedProblem, getFunctionSignature]);
return (
<div className={clsx('h-full flex overflow-hidden', className)}>
{/* Problem List Sidebar */}
<div
className={clsx(
'flex-shrink-0 transition-all duration-200 relative h-full',
isProblemListCollapsed ? 'w-12' : ''
)}
style={{ width: isProblemListCollapsed ? 48 : problemListWidth }}
>
{isProblemListCollapsed ? (
<div className="h-full flex flex-col items-center pt-4 bg-zinc-900/95 border-r border-zinc-800/80">
<button
onClick={() => setIsProblemListCollapsed(false)}
className="p-2 rounded-md hover:bg-zinc-800/50 transition-colors"
title="Expand problems"
>
<span className="text-xs text-zinc-500 [writing-mode:vertical-lr] font-medium">
Problems
</span>
</button>
</div>
) : (
<>
<ProblemList
onSelectProblem={handleSelectProblem}
selectedProblemId={selectedProblem?.id}
solvedProblems={solvedProblems}
/>
<button
onClick={() => setIsProblemListCollapsed(true)}
className="absolute -right-3 top-4 w-6 h-6 rounded-full bg-zinc-800 border border-zinc-700/50 flex items-center justify-center hover:bg-zinc-700 transition-colors z-50"
title="Collapse problems"
>
<ChevronLeft className="w-4 h-4 text-zinc-400" />
</button>
<div
className="absolute top-0 bottom-0 -right-0.5 w-1 cursor-col-resize hover:bg-teal-500/50 transition-colors z-40"
onMouseDown={(e) => {
e.preventDefault();
const startX = e.clientX;
const startWidth = problemListWidth;
const handleMouseMove = (moveEvent: MouseEvent) => {
const newWidth = Math.min(500, Math.max(240, startWidth + moveEvent.clientX - startX));
setProblemListWidth(newWidth);
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}}
/>
</>
)}
</div>
{/* Main Content */}
<div className="flex-1 flex flex-col min-w-0 bg-zinc-950 h-full overflow-hidden">
{selectedProblem ? (
<>
{/* Problem Description - compact header */}
<div className="flex-shrink-0 border-b border-zinc-800/80 bg-zinc-900/50">
<div className="px-4 py-3">
<div className="flex items-center gap-2 mb-2 flex-wrap">
<span
className={clsx(
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium border',
selectedProblem.type === 'function_completion'
? 'bg-emerald-900/30 text-emerald-400 border-emerald-700/30'
: 'bg-blue-900/30 text-blue-400 border-blue-700/30'
)}
>
{TASK_LABELS[selectedProblem.type].label}
</span>
<span className="text-xs text-zinc-500">
{CATEGORY_LABELS[selectedProblem.category]}
</span>
{selectedProblem.hasImage && (
<span className="flex items-center gap-1 text-xs text-zinc-500">
<ImageIcon className="w-3.5 h-3.5" />
Has image
</span>
)}
</div>
<div className="flex items-start gap-4">
<div className="flex-1 min-w-0">
<p className="text-sm text-zinc-300 leading-relaxed">
{displayDescription}
</p>
</div>
{selectedProblem.imageUrl && (
<div className="flex-shrink-0">
<img
src={selectedProblem.imageUrl}
alt="Problem illustration"
className="max-w-[160px] max-h-24 rounded-lg border border-zinc-700/50 bg-zinc-900 object-contain"
/>
</div>
)}
</div>
</div>
</div>
{/* Test Runner - at top, compact */}
<div className="flex-shrink-0 border-b border-zinc-800/80">
<TestRunner
key={selectedProblem.id}
userCode={userCode}
testCode={selectedProblem.testCode}
entryPoint={selectedProblem.entryPoint}
onTestComplete={handleTestComplete}
initialResult={currentTestResult}
/>
</div>
{/* Code Editor - takes remaining space */}
<div className="flex-1 min-h-0">
<CodeEditor
value={userCode}
onChange={setUserCode}
language="python"
height="calc(100vh - 220px)"
/>
</div>
</>
) : (
<div className="flex-1 flex flex-col items-center justify-center text-center px-4">
<div className="w-16 h-16 mb-5 rounded-xl bg-zinc-800/80 border border-teal-700/30 flex items-center justify-center">
<FileText className="w-8 h-8 text-teal-400" />
</div>
<h2 className="text-xl font-semibold text-zinc-200 mb-2">
Practice Mode
</h2>
<p className="text-zinc-500 max-w-md mb-6 text-sm leading-relaxed">
Select a coding problem from the sidebar to start practicing.
Solve problems and run unit tests to verify your solutions.
</p>
<div className="flex items-center gap-2 text-xs text-zinc-600">
<Lightbulb className="w-4 h-4" />
<span>Use the AI Helper for hints and guidance</span>
</div>
</div>
)}
</div>
{/* AI Helper Sidebar */}
<div
className={clsx(
'flex-shrink-0 transition-all duration-200 relative h-full'
)}
style={{ width: isAIHelperCollapsed ? 48 : aiHelperWidth }}
>
{!isAIHelperCollapsed && (
<>
<button
onClick={toggleAIHelper}
className="absolute -left-3 top-4 w-6 h-6 rounded-full bg-zinc-800 border border-zinc-700/50 flex items-center justify-center hover:bg-zinc-700 transition-colors z-50"
title="Collapse AI Helper"
>
<ChevronRight className="w-4 h-4 text-zinc-400" />
</button>
<div
className="absolute top-0 bottom-0 -left-0.5 w-1 cursor-col-resize hover:bg-teal-500/50 transition-colors z-40"
onMouseDown={(e) => {
e.preventDefault();
const startX = e.clientX;
const startWidth = aiHelperWidth;
const handleMouseMove = (moveEvent: MouseEvent) => {
const newWidth = Math.min(500, Math.max(240, startWidth - (moveEvent.clientX - startX)));
setAIHelperWidth(newWidth);
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
}}
/>
</>
)}
<AIHelper
problem={selectedProblem}
userCode={userCode}
isCollapsed={isAIHelperCollapsed}
onToggleCollapse={toggleAIHelper}
onApplyCode={handleApplyCode}
/>
</div>
</div>
);
}