diff --git a/app/api/interview/[id]/evaluate/route.ts b/app/api/interview/[id]/evaluate/route.ts index 676c900..9dd7906 100644 --- a/app/api/interview/[id]/evaluate/route.ts +++ b/app/api/interview/[id]/evaluate/route.ts @@ -12,7 +12,6 @@ interface RouteParams { } // POST: Trigger evaluation of a submitted interview session -// Phase 4 will add the actual structural rule engine + AI reasoning evaluator export async function POST(request: NextRequest, { params }: RouteParams) { try { const { id } = await params; @@ -37,12 +36,31 @@ export async function POST(request: NextRequest, { params }: RouteParams) { // Atomic check-and-set: only claim the session if it's currently 'submitted' // This prevents race conditions where two concurrent requests both pass the status check - const session = await InterviewSession.findOneAndUpdate( + let session = await InterviewSession.findOneAndUpdate( { _id: id, userId: user._id, status: 'submitted' }, { $set: { status: 'evaluating' } }, { new: false } // return the pre-update doc so we can inspect canvas ); + // If not found as 'submitted', check if it's stuck in 'evaluating' (stale > 2 min) + if (!session) { + const twoMinutesAgo = new Date(Date.now() - 2 * 60 * 1000); + session = await InterviewSession.findOneAndUpdate( + { _id: id, userId: user._id, status: 'evaluating', updatedAt: { $lt: twoMinutesAgo } }, + { $set: { status: 'evaluating' } }, + { new: false } + ); + } + + // Also allow re-evaluation of already-evaluated sessions (user-triggered) + if (!session) { + session = await InterviewSession.findOneAndUpdate( + { _id: id, userId: user._id, status: 'evaluated' }, + { $set: { status: 'evaluating' } }, + { new: false } + ); + } + if (!session) { // Distinguish between "not found" and "wrong status" const exists = await InterviewSession.findOne({ _id: id, userId: user._id }).select('status').lean(); @@ -50,7 +68,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { return NextResponse.json({ error: 'Interview session not found' }, { status: 404 }); } return NextResponse.json( - { error: `Cannot evaluate session with status "${exists.status}". Must be "submitted".` }, + { error: `Cannot evaluate session with status "${exists.status}". Session must be "submitted", "evaluated", or stuck in "evaluating" for >2 minutes to be evaluated.` }, { status: 409 } ); } diff --git a/app/interview/[id]/page.tsx b/app/interview/[id]/page.tsx index 987adf9..fd53d2c 100644 --- a/app/interview/[id]/page.tsx +++ b/app/interview/[id]/page.tsx @@ -56,6 +56,7 @@ export default function InterviewCanvasPage({ params }: PageProps) { const [showHints, setShowHints] = useState(false); const [submitError, setSubmitError] = useState(null); const [isInterviewPanelOpen, setIsInterviewPanelOpen] = useState(false); + const [isQuestionPanelOpen, setIsQuestionPanelOpen] = useState(true); const [finalValidationTriggered, setFinalValidationTriggered] = useState(false); // Refs for save logic @@ -274,23 +275,13 @@ export default function InterviewCanvasPage({ params }: PageProps) { setSession(prev => prev ? { ...prev, status: 'submitted', submittedAt: new Date().toISOString() } : null); setSubmitError(null); - // Trigger evaluation - const evalResponse = await authFetch(`/api/interview/${id}/evaluate`, { - method: 'POST' - }); - - if (!evalResponse.ok) { - const evalData = await evalResponse.json().catch(() => ({})); - console.error('Evaluation failed:', evalData.error); - // We don't throw here to avoid showing an error after successful submission - // The status will remain 'submitted' and can be re-evaluated later - } else { - const evalData = await evalResponse.json(); - setSession(prev => prev ? { ...prev, status: 'evaluated', evaluation: evalData.evaluation } : null); + // Fire-and-forget: trigger evaluation in the background. + // The result page will poll until evaluation completes. + authFetch(`/api/interview/${id}/evaluate`, { method: 'POST' }) + .catch(err => console.warn('Background evaluation trigger:', err)); - // Redirect to results page now that evaluation is complete - router.push(`/interview/${id}/result`); - } + // Immediately redirect to results page — it will poll for completion + router.push(`/interview/${id}/result`); } catch (err) { console.error('Error submitting:', err); setSubmitError(err instanceof Error ? err.message : 'Failed to submit'); @@ -330,7 +321,7 @@ export default function InterviewCanvasPage({ params }: PageProps) { if (data.success && data.messages) { if (setMessages) setMessages(data.messages); setSession(prev => prev ? { ...prev, constraintChanges: data.constraintChanges } : null); - setIsInterviewPanelOpen(true); // Automatically slide open the interviewer panel + setIsInterviewPanelOpen(true); } } catch (err) { console.error('Chaos timeout failed:', err); @@ -411,13 +402,15 @@ export default function InterviewCanvasPage({ params }: PageProps) {
{/* Question Panel - left sidebar */} -
+
setShowHints(prev => !prev)} + isCollapsed={!isQuestionPanelOpen} + onToggle={() => setIsQuestionPanelOpen(prev => !prev)} />
diff --git a/app/interview/[id]/result/page.tsx b/app/interview/[id]/result/page.tsx index f3d98bb..18c2e81 100644 --- a/app/interview/[id]/result/page.tsx +++ b/app/interview/[id]/result/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect, use } from 'react'; +import { useState, useEffect, useRef, use } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; import { useRequireAuth } from '@/src/hooks/useRequireAuth'; @@ -39,40 +39,107 @@ export default function InterviewResultPage({ params }: PageProps) { const [session, setSession] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const [isEvaluating, setIsEvaluating] = useState(false); + const isEvaluatingRef = useRef(false); useEffect(() => { + let cancelled = false; + let pollTimer: NodeJS.Timeout; + let pollAttempts = 0; + const MAX_POLLS = 40; // 40 * 3000ms = 2 mins timeout limit + const fetchResult = async () => { if (!user?.uid || !id) return; try { - setIsLoading(true); + if (!isEvaluatingRef.current) setIsLoading(true); const response = await authFetch(`/api/interview/${id}`); if (!response.ok) { throw new Error('Failed to load results'); } const data = await response.json(); - if (data.session.status !== 'evaluated') { - // If not evaluated yet, it might be in progress, submitted, or evaluating - if (['in_progress', 'submitted', 'evaluating'].includes(data.session.status)) { - router.replace(`/interview/${id}`); + if (cancelled) return; + + if (data.session.status === 'evaluated') { + // Evaluation complete — show results + isEvaluatingRef.current = false; + setIsEvaluating(false); + setSession(data.session); + setIsLoading(false); + return; // stop polling + } + + if (['submitted', 'evaluating'].includes(data.session.status)) { + pollAttempts++; + if (pollAttempts >= MAX_POLLS) { + isEvaluatingRef.current = false; + setIsEvaluating(false); + setIsLoading(false); + setError('Evaluation is taking longer than expected. Please go back and try re-evaluating.'); return; } + + // Still evaluating — show spinner and poll again + isEvaluatingRef.current = true; + setIsEvaluating(true); + setIsLoading(false); + pollTimer = setTimeout(fetchResult, 3000); + return; } - setSession(data.session); + // in_progress — shouldn't be on this page + if (data.session.status === 'in_progress') { + router.replace(`/interview/${id}`); + return; + } } catch (err) { + if (cancelled) return; console.error('Error fetching results:', err); setError(err instanceof Error ? err.message : 'Failed to load results'); - } finally { setIsLoading(false); + isEvaluatingRef.current = false; + setIsEvaluating(false); } }; if (isAuthenticated && user) { fetchResult(); } + + return () => { + cancelled = true; + if (pollTimer) clearTimeout(pollTimer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAuthenticated, user, id, router]); + // Evaluating state — show a dedicated loading screen + if (isEvaluating) { + return ( +
+
+
+
+ + psychology + +
+
+

Evaluating Your Design

+

+ Our AI is analyzing your architecture for structural integrity, trade-off quality, and scalability patterns. + This typically takes 15-30 seconds. +

+
+
+ + Processing... +
+
+
+ ); + } + if (authLoading || isLoading) { return (
@@ -494,4 +561,4 @@ export default function InterviewResultPage({ params }: PageProps) {
); -} +} \ No newline at end of file diff --git a/app/interview/page.tsx b/app/interview/page.tsx index cc097c8..392812f 100644 --- a/app/interview/page.tsx +++ b/app/interview/page.tsx @@ -74,6 +74,7 @@ export default function InterviewPage() { const [isLoadingSessions, setIsLoadingSessions] = useState(true); const [isStarting, setIsStarting] = useState(null); // difficulty being started const [error, setError] = useState(null); + const [reEvaluatingId, setReEvaluatingId] = useState(null); const lastFetchedUid = useRef(null); const fetchSessions = useCallback(async () => { @@ -134,6 +135,44 @@ export default function InterviewPage() { } }; + const handleReEvaluate = async (e: React.MouseEvent, sessionId: string) => { + e.preventDefault(); + e.stopPropagation(); + if (reEvaluatingId) return; + + try { + setReEvaluatingId(sessionId); + // Update the card to show evaluating state immediately + setSessions(prev => prev.map(s => + s.id === sessionId ? { ...s, status: 'evaluating' as const } : s + )); + + const response = await authFetch(`/api/interview/${sessionId}/evaluate`, { + method: 'POST' + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || 'Re-evaluation failed'); + } + + const data = await response.json(); + // Update session with new evaluation result + setSessions(prev => prev.map(s => + s.id === sessionId + ? { ...s, status: 'evaluated' as const, finalScore: data.session?.finalScore ?? s.finalScore } + : s + )); + } catch (err) { + console.error('Re-evaluate failed:', err); + // Revert status to what it was before (refetch to be safe) + fetchSessions(); + setError(err instanceof Error ? err.message : 'Re-evaluation failed'); + } finally { + setReEvaluatingId(null); + } + }; + const formatRelativeTime = (dateString: string) => { const date = new Date(dateString); if (isNaN(date.getTime())) return 'Unknown'; @@ -334,6 +373,32 @@ export default function InterviewPage() {
)} + {/* Re-evaluate logic */} + {['submitted', 'evaluating', 'evaluated'].includes(session.status) && ( + + )} + arrow_forward diff --git a/components/interview/QuestionPanel.tsx b/components/interview/QuestionPanel.tsx index 50c9431..e14e536 100644 --- a/components/interview/QuestionPanel.tsx +++ b/components/interview/QuestionPanel.tsx @@ -9,6 +9,9 @@ interface QuestionPanelProps { /** Whether to reveal hints */ showHints?: boolean; onToggleHints?: () => void; + /** Whether the panel is collapsed to save space */ + isCollapsed?: boolean; + onToggle?: () => void; } const DIFFICULTY_COLORS = { @@ -22,12 +25,43 @@ export function QuestionPanel({ difficulty, constraintChanges = [], showHints = false, - onToggleHints + onToggleHints, + isCollapsed = false, + onToggle }: QuestionPanelProps) { const colors = DIFFICULTY_COLORS[difficulty]; + if (isCollapsed) { + return ( +
+ + +
+ quiz +
+ + {/* Vertical difficulty indicator */} +
+ +
+
+ ); + } + return ( -
+
{/* Header */}
@@ -35,9 +69,18 @@ export function QuestionPanel({ quiz Question - - {difficulty} - +
+ + {difficulty} + + +
diff --git a/next.config.ts b/next.config.ts index ad52782..dcd3523 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,7 +2,10 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "standalone", + experimental: { + turbopackUseSystemTlsCerts: true, + }, /* config options here */ }; -export default nextConfig; +export default nextConfig; \ No newline at end of file diff --git a/postcss.config.mjs b/postcss.config.mjs index 61e3684..fe1e17e 100644 --- a/postcss.config.mjs +++ b/postcss.config.mjs @@ -1,7 +1,5 @@ -const config = { +export default { plugins: { "@tailwindcss/postcss": {}, }, -}; - -export default config; +}; \ No newline at end of file diff --git a/src/lib/ai/geminiClient.ts b/src/lib/ai/geminiClient.ts index 76ec1e2..b05d4d1 100644 --- a/src/lib/ai/geminiClient.ts +++ b/src/lib/ai/geminiClient.ts @@ -31,22 +31,34 @@ function getClient(): OpenAI { * Uses Google Gemini 2.0 Flash via OpenRouter for high-quality generation. * Falls back gracefully with retry logic for transient errors. */ -export async function generateJSON(prompt: string, retries = 2): Promise { +export async function generateJSON(prompt: string, retries = 2, timeoutMs = 60000): Promise { const openrouter = getClient(); for (let attempt = 0; attempt <= retries; attempt++) { try { - const response = await openrouter.chat.completions.create({ - model: 'google/gemini-2.0-flash-001', - messages: [ + // AbortController with timeout to prevent hanging forever on slow AI responses + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + let response; + try { + response = await openrouter.chat.completions.create( { - role: 'user', - content: `${prompt}\n\nIMPORTANT: Respond with valid JSON only. No markdown formatting, no explanations.`, + model: 'google/gemini-2.0-flash-001', + messages: [ + { + role: 'user', + content: `${prompt}\n\nIMPORTANT: Respond with valid JSON only. No markdown formatting, no explanations.`, + }, + ], + temperature: 0.8, + max_tokens: 2048, }, - ], - temperature: 0.8, - max_tokens: 2048, - }); + { signal: controller.signal } + ); + } finally { + clearTimeout(timer); + } const text = response.choices[0]?.message?.content?.trim(); if (!text) { @@ -64,6 +76,12 @@ export async function generateJSON(prompt: string, retries = 2): Promise { const errMsg = error instanceof Error ? error.message : String(error); const status = (error as { status?: number })?.status; + // Immediately rethrow abort/timeout errors to avoid masking + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if ((error instanceof Error && error.name === 'AbortError') || (error as any).code === 'ETIMEDOUT' || errMsg.includes('timeout') || errMsg.includes('timed out')) { + throw error; + } + // Don't retry non-transient errors if (status === 401 || errMsg.includes('Invalid API key')) { console.error('OpenRouter auth error:', errMsg);