Spaces:
Running
Running
| import { useState, useCallback, useMemo, useEffect, useRef, Suspense } from 'react'; | |
| import { useNavigate, useSearchParams } from 'react-router-dom'; | |
| import { motion, AnimatePresence } from 'framer-motion'; | |
| import { FileText, FlaskConical, Scale, TrendingUp, Play, Pencil } from 'lucide-react'; | |
| import type { BackendRuntimeStatus, EpisodeState, ResetParams, ScientistAction } from '@/types'; | |
| import { agentStepEpisode, getRuntimeStatus, resetEpisode, stepEpisode } from '@/lib/api'; | |
| import { sfx, startAmbient, stopAmbient } from '@/lib/audio'; | |
| import { fireSuccessConfetti, fireGavelConfetti } from '@/lib/confetti'; | |
| import { buildDemoScientistAction, DEMO_CASES, parseDemoCase } from '@/lib/demo'; | |
| import { useToast } from '@/components/Toast'; | |
| import { useKeyboardShortcuts, ShortcutsOverlay, ShortcutHint } from '@/components/KeyboardShortcuts'; | |
| import AutoPlayControls, { useAutoPlay } from '@/components/AutoPlayControls'; | |
| import PaperPanel from '@/components/PaperPanel'; | |
| import NegotiationLog from '@/components/NegotiationLog'; | |
| import ProtocolPanel from '@/components/ProtocolPanel'; | |
| import ScorePanel from '@/components/ScorePanel'; | |
| import Controls from '@/components/Controls'; | |
| import LabInventory from '@/components/LabInventory'; | |
| import JudgeAuditPanel from '@/components/JudgeAuditPanel'; | |
| import ReplayViewer from '@/components/ReplayViewer'; | |
| import CharacterStage from '@/components/CharacterStage'; | |
| import AnimatedCharacter from '@/components/AnimatedCharacter'; | |
| import LiveScoreGauges from '@/components/LiveScoreGauges'; | |
| import ProtocolTimeline from '@/components/ProtocolTimeline'; | |
| import AgentThoughts from '@/components/AgentThoughts'; | |
| import ProtocolEditor from '@/components/ProtocolEditor'; | |
| import LabScene3D from '@/components/LabScene3D'; | |
| import EpisodeResultsReport from '@/components/EpisodeResultsReport'; | |
| const TEMPLATE_OPTIONS = ['math_reasoning', 'ml_benchmark', 'finance_trading'] as const; | |
| const DIFFICULTY_OPTIONS = ['easy', 'medium', 'hard'] as const; | |
| export default function EpisodePage() { | |
| const navigate = useNavigate(); | |
| const [searchParams] = useSearchParams(); | |
| const { toast } = useToast(); | |
| const [episode, setEpisode] = useState<EpisodeState | null>(null); | |
| const [loading, setLoading] = useState(false); | |
| const [isJudging, setIsJudging] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const [autoStartTriggered, setAutoStartTriggered] = useState(false); | |
| const [runtimeStatus, setRuntimeStatus] = useState<BackendRuntimeStatus | null>(null); | |
| // Feature 5: Auto-play | |
| const [autoPlaying, setAutoPlaying] = useState(false); | |
| const [autoSpeed, setAutoSpeed] = useState(1); | |
| // Feature 11: Protocol editor toggle | |
| const [showEditor, setShowEditor] = useState(false); | |
| // Feature 13: 3D lab toggle | |
| const [show3DLab, setShow3DLab] = useState(false); | |
| const initialTemplate = useMemo(() => { | |
| const value = searchParams.get('template'); | |
| return TEMPLATE_OPTIONS.find((option) => option === value); | |
| }, [searchParams]); | |
| const initialDifficulty = useMemo(() => { | |
| const value = searchParams.get('difficulty'); | |
| return DIFFICULTY_OPTIONS.find((option) => option === value); | |
| }, [searchParams]); | |
| const initialSeed = useMemo(() => { | |
| const value = searchParams.get('seed'); | |
| if (!value) return undefined; | |
| const parsed = Number.parseInt(value, 10); | |
| return Number.isNaN(parsed) ? undefined : parsed; | |
| }, [searchParams]); | |
| const scriptedDemoRequested = useMemo( | |
| () => searchParams.get('demo') === '1', | |
| [searchParams], | |
| ); | |
| const autoStartRequested = useMemo( | |
| () => scriptedDemoRequested || searchParams.get('autostart') === '1', | |
| [scriptedDemoRequested, searchParams], | |
| ); | |
| const demoCase = useMemo(() => parseDemoCase(searchParams.get('demoCase')), [searchParams]); | |
| const demoMeta = useMemo( | |
| () => DEMO_CASES.find((item) => item.id === demoCase), | |
| [demoCase], | |
| ); | |
| const autoPlayRequested = useMemo( | |
| () => autoStartRequested || searchParams.get('autoplay') === '1', | |
| [autoStartRequested, searchParams], | |
| ); | |
| const backendModelStepAvailable = useMemo( | |
| () => !scriptedDemoRequested && Boolean(runtimeStatus?.agent_step_available), | |
| [scriptedDemoRequested, runtimeStatus?.agent_step_available], | |
| ); | |
| const phase = useMemo(() => { | |
| if (!episode) return 'waiting' as const; | |
| if (isJudging) return 'judging' as const; | |
| if (episode.done) return 'complete' as const; | |
| return 'negotiating' as const; | |
| }, [episode, isJudging]); | |
| const lastMessage = useMemo(() => { | |
| if (!episode?.conversation.length) return undefined; | |
| return episode.conversation[episode.conversation.length - 1]; | |
| }, [episode]); | |
| // Sound effects for phase transitions | |
| const prevPhaseRef = useRef(phase); | |
| const prevMsgCountRef = useRef(0); | |
| const resultsRef = useRef<HTMLDivElement | null>(null); | |
| useEffect(() => { | |
| const prev = prevPhaseRef.current; | |
| prevPhaseRef.current = phase; | |
| const hasAcceptCaveats = | |
| episode?.judge_audit?.verdict === 'accept' && | |
| (episode.judge_audit?.top_failure_reasons.length ?? 0) > 0; | |
| if (prev === 'waiting' && phase === 'negotiating') { | |
| sfx.episodeStart(); | |
| startAmbient(); | |
| } else if (phase === 'judging' && prev !== 'judging') { | |
| sfx.judgeAppear(); | |
| setTimeout(() => { | |
| sfx.gavel(); | |
| fireGavelConfetti(); | |
| }, 1500); | |
| } else if (phase === 'complete' && prev !== 'complete') { | |
| stopAmbient(); | |
| sfx.scoreReveal(); | |
| const verdict = episode?.judge_audit?.verdict; | |
| if ((verdict === 'accept' || verdict === 'success') && !hasAcceptCaveats) { | |
| setTimeout(() => { | |
| sfx.success(); | |
| fireSuccessConfetti(); | |
| }, 400); | |
| toast('Episode complete - Agreement reached!', 'success'); | |
| } else if (verdict === 'accept' && hasAcceptCaveats) { | |
| setTimeout(() => sfx.roundTick(), 400); | |
| toast('Episode complete - Accepted with caveats', 'warning'); | |
| } else if (verdict) { | |
| setTimeout(() => sfx.failure(), 400); | |
| toast(`Episode complete - Verdict: ${verdict}`, 'warning'); | |
| } | |
| } | |
| }, [phase, episode?.judge_audit, toast]); | |
| useEffect(() => { | |
| if (!episode?.conversation.length) return; | |
| const count = episode.conversation.length; | |
| if (count > prevMsgCountRef.current) { | |
| const latest = episode.conversation[count - 1]; | |
| if (latest.role === 'scientist') sfx.scientistSpeak(); | |
| else sfx.labManagerSpeak(); | |
| if (count > 1) sfx.roundTick(); | |
| } | |
| prevMsgCountRef.current = count; | |
| }, [episode?.conversation]); | |
| // Cleanup ambient on unmount | |
| useEffect(() => { | |
| return () => stopAmbient(); | |
| }, []); | |
| useEffect(() => { | |
| let cancelled = false; | |
| async function loadRuntimeStatus() { | |
| try { | |
| const status = await getRuntimeStatus(); | |
| if (!cancelled) { | |
| setRuntimeStatus(status); | |
| } | |
| } catch (runtimeError) { | |
| if (!cancelled) { | |
| console.warn('Failed to load runtime status', runtimeError); | |
| } | |
| } | |
| } | |
| void loadRuntimeStatus(); | |
| return () => { | |
| cancelled = true; | |
| }; | |
| }, []); | |
| useEffect(() => { | |
| if (episode?.done) { | |
| resultsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); | |
| } | |
| }, [episode?.done]); | |
| const handleStart = useCallback(async (params: ResetParams) => { | |
| setLoading(true); | |
| setIsJudging(false); | |
| setError(null); | |
| setAutoPlaying(false); | |
| sfx.click(); | |
| try { | |
| const state = await resetEpisode(params); | |
| const nextState = demoCase ? { ...state, demo_case: demoCase } : state; | |
| setEpisode(nextState); | |
| prevMsgCountRef.current = 0; | |
| toast('Episode started!', 'info'); | |
| // Feature 9: Update URL for shareable link | |
| const nextSearch = new URLSearchParams(); | |
| nextSearch.set('template', state.template); | |
| nextSearch.set('difficulty', state.difficulty); | |
| nextSearch.set('seed', String(state.seed)); | |
| if (scriptedDemoRequested) { | |
| nextSearch.set('demo', '1'); | |
| } | |
| if (autoStartRequested && !scriptedDemoRequested) { | |
| nextSearch.set('autostart', '1'); | |
| } | |
| if (autoPlayRequested) { | |
| nextSearch.set('autoplay', '1'); | |
| } | |
| if (demoCase) { | |
| nextSearch.set('demoCase', demoCase); | |
| } | |
| navigate(`/episode/${state.episode_id}?${nextSearch.toString()}`, { replace: true }); | |
| } catch (err) { | |
| console.error('Failed to start episode:', err); | |
| const msg = err instanceof Error ? err.message : 'Failed to start episode'; | |
| setError(msg); | |
| toast(msg, 'error'); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, [autoPlayRequested, autoStartRequested, demoCase, navigate, scriptedDemoRequested, toast]); | |
| const handleStepWithAction = useCallback(async (action?: ScientistAction) => { | |
| if (!episode || episode.done) return; | |
| setLoading(true); | |
| setError(null); | |
| if (!action) { | |
| sfx.negotiate(); | |
| } | |
| try { | |
| const isLastRound = episode.round >= episode.max_rounds - 1; | |
| let finalAction: ScientistAction; | |
| if (action) { | |
| finalAction = action; | |
| } else { | |
| finalAction = buildDemoScientistAction(episode, demoCase); | |
| } | |
| if (isLastRound && !action) { | |
| setIsJudging(true); | |
| await new Promise((r) => setTimeout(r, 2000)); | |
| } | |
| const state = !action && backendModelStepAvailable | |
| ? await agentStepEpisode(episode.session_id, episode) | |
| : await stepEpisode(episode.session_id, finalAction, episode); | |
| if (state.done && !isLastRound) { | |
| setIsJudging(true); | |
| await new Promise((r) => setTimeout(r, 2000)); | |
| } | |
| setIsJudging(false); | |
| setEpisode(state); | |
| sfx.protocolChange(); | |
| toast(`Round ${state.round}/${state.max_rounds}`, 'info'); | |
| } catch (err) { | |
| console.error('Failed to step episode:', err); | |
| const msg = err instanceof Error ? err.message : 'Failed to step episode'; | |
| setError(msg); | |
| toast(msg, 'error'); | |
| setIsJudging(false); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }, [backendModelStepAvailable, demoCase, episode, toast]); | |
| useEffect(() => { | |
| if (!autoStartRequested || autoStartTriggered || episode || loading) { | |
| return; | |
| } | |
| setAutoStartTriggered(true); | |
| void handleStart({ | |
| seed: initialSeed ?? 101, | |
| template: initialTemplate ?? 'ml_benchmark', | |
| difficulty: initialDifficulty ?? 'medium', | |
| }); | |
| }, [ | |
| autoStartTriggered, | |
| autoStartRequested, | |
| episode, | |
| handleStart, | |
| initialDifficulty, | |
| initialSeed, | |
| initialTemplate, | |
| loading, | |
| ]); | |
| useEffect(() => { | |
| if (!episode || episode.done || !autoPlayRequested) { | |
| return; | |
| } | |
| setAutoSpeed(2); | |
| setAutoPlaying(true); | |
| }, [autoPlayRequested, episode?.done, episode?.episode_id]); | |
| const handleStep = useCallback(() => handleStepWithAction(), [handleStepWithAction]); | |
| // Feature 5: Auto-play hook | |
| useAutoPlay( | |
| handleStep, | |
| autoSpeed, | |
| autoPlaying, | |
| episode?.done ?? true, | |
| loading, | |
| ); | |
| // Stop auto-play when episode ends | |
| useEffect(() => { | |
| if (episode?.done) setAutoPlaying(false); | |
| }, [episode?.done]); | |
| // Feature 3: Keyboard shortcuts | |
| const { showHelp, setShowHelp } = useKeyboardShortcuts({ | |
| onStep: episode && !episode.done ? handleStep : undefined, | |
| onRestart: () => { | |
| if (episode) handleStart({ seed: episode.seed, template: episode.template, difficulty: episode.difficulty }); | |
| }, | |
| onAutoPlay: () => { | |
| if (episode && !episode.done) setAutoPlaying((p) => !p); | |
| }, | |
| onToggleEditor: () => setShowEditor((p) => !p), | |
| disabled: loading, | |
| }); | |
| // Feature 11: Submit from protocol editor | |
| const handleEditorSubmit = useCallback((action: ScientistAction) => { | |
| setShowEditor(false); | |
| handleStepWithAction(action); | |
| }, [handleStepWithAction]); | |
| // ─── PRE-GAME SCREEN ─────────────────────────────────────────────── | |
| if (!episode) { | |
| return ( | |
| <div className="mx-auto max-w-screen-2xl p-4"> | |
| <ShortcutsOverlay show={showHelp} onClose={() => setShowHelp(false)} /> | |
| <div className="flex flex-col items-center justify-center py-16"> | |
| <motion.div | |
| className="mb-8 flex items-end justify-center gap-8" | |
| initial={{ opacity: 0, y: 20 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ duration: 0.6 }} | |
| > | |
| <AnimatedCharacter role="scientist" emotion="idle" isActive={false} size="xl" showAura={false} showEmoji={false} /> | |
| <motion.div className="mb-12 flex flex-col items-center" initial={{ scale: 0 }} animate={{ scale: 1 }} transition={{ delay: 0.3, type: 'spring' }}> | |
| <span className="text-2xl font-black text-muted-foreground/20">VS</span> | |
| </motion.div> | |
| <AnimatedCharacter role="lab_manager" emotion="idle" isActive={false} size="xl" showAura={false} showEmoji={false} /> | |
| <motion.div className="mb-12 flex flex-col items-center" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.5 }}> | |
| <span className="text-xs font-bold text-muted-foreground/30">then</span> | |
| </motion.div> | |
| <motion.div className="flex flex-col items-center" initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.6, type: 'spring' }}> | |
| <AnimatedCharacter role="judge" emotion="idle" isActive={false} size="xl" showAura={false} showEmoji={false} /> | |
| </motion.div> | |
| </motion.div> | |
| <motion.div className="mb-8 text-center" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.4 }}> | |
| <h1 className="mb-2 text-2xl font-bold"> | |
| {autoStartRequested ? 'Launching Live Replication Run' : 'Start a Paper Replication Episode'} | |
| </h1> | |
| <p className="text-muted-foreground"> | |
| {autoStartRequested | |
| ? 'Loading the benchmark, starting the agents, and running the negotiation automatically.' | |
| : 'Pick a seeded paper-derived benchmark, parse it into the environment, and watch the agents negotiate a reproducible plan.'} | |
| </p> | |
| <ShortcutHint className="mt-2" /> | |
| </motion.div> | |
| {error && ( | |
| <motion.div className="mb-4 w-full max-w-sm rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive" initial={{ opacity: 0 }} animate={{ opacity: 1 }}> | |
| {error} | |
| </motion.div> | |
| )} | |
| {!autoStartRequested && ( | |
| <motion.div className="w-full max-w-sm" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.5 }}> | |
| <Controls | |
| onStart={handleStart} | |
| disabled={loading} | |
| episodeActive={false} | |
| initialSeed={initialSeed} | |
| initialTemplate={initialTemplate} | |
| initialDifficulty={initialDifficulty} | |
| runtimeStatus={runtimeStatus} | |
| /> | |
| </motion.div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // ─── ACTIVE EPISODE SCREEN ───────────────────────────────────────── | |
| return ( | |
| <div className="mx-auto max-w-screen-2xl p-4"> | |
| <ShortcutsOverlay show={showHelp} onClose={() => setShowHelp(false)} /> | |
| {/* Character Stage */} | |
| <AnimatePresence> | |
| <motion.div initial={{ opacity: 0, y: -10 }} animate={{ opacity: 1, y: 0 }} className="mb-4"> | |
| <CharacterStage | |
| phase={phase} | |
| round={episode.round} | |
| maxRounds={episode.max_rounds} | |
| lastMessage={lastMessage} | |
| scores={episode.scores} | |
| judgeAudit={episode.judge_audit} | |
| /> | |
| </motion.div> | |
| </AnimatePresence> | |
| {error && ( | |
| <motion.div className="mb-4 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive" initial={{ opacity: 0 }} animate={{ opacity: 1 }}> | |
| {error} | |
| </motion.div> | |
| )} | |
| <motion.div | |
| className="mb-4 grid gap-3 md:grid-cols-4" | |
| initial={{ opacity: 0, y: 8 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.1 }} | |
| > | |
| {[ | |
| { | |
| title: '1. Source Paper', | |
| description: 'The left panel keeps the original claim, method, and protocol visible.', | |
| icon: FileText, | |
| }, | |
| { | |
| title: '2. Negotiation', | |
| description: 'The Scientist and Lab Manager revise the protocol under explicit lab constraints.', | |
| icon: FlaskConical, | |
| }, | |
| { | |
| title: '3. Deterministic Judge', | |
| description: 'The final plan is scored on rigor, feasibility, and fidelity with a fixed rubric.', | |
| icon: Scale, | |
| }, | |
| { | |
| title: '4. Training Loop', | |
| description: 'That terminal reward is exactly what powers the Scientist training path later.', | |
| icon: TrendingUp, | |
| }, | |
| ].map((item) => ( | |
| <div key={item.title} className="rounded-lg border border-border bg-card p-3"> | |
| <item.icon className="mb-2 h-4 w-4 text-primary" /> | |
| <h2 className="text-sm font-semibold">{item.title}</h2> | |
| <p className="mt-1 text-xs text-muted-foreground">{item.description}</p> | |
| </div> | |
| ))} | |
| </motion.div> | |
| {demoMeta && scriptedDemoRequested && ( | |
| <motion.div | |
| className="mb-4 rounded-xl border border-primary/20 bg-primary/5 p-4" | |
| initial={{ opacity: 0, y: 8 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.14 }} | |
| > | |
| <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> | |
| <div> | |
| <div className="text-xs font-semibold uppercase tracking-[0.18em] text-primary"> | |
| {demoMeta.title} | |
| </div> | |
| <h2 className="mt-1 text-base font-semibold">{demoMeta.subtitle}</h2> | |
| <p className="mt-1 text-sm text-muted-foreground">{demoMeta.summary}</p> | |
| </div> | |
| <div className="rounded-lg border border-border bg-background px-3 py-2 text-xs text-muted-foreground"> | |
| Seed {episode.seed} · {episode.template.replace(/_/g, ' ')} · {episode.difficulty} | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| {!scriptedDemoRequested && runtimeStatus && ( | |
| <motion.div | |
| className="mb-4 rounded-xl border border-emerald-500/20 bg-emerald-500/5 p-4" | |
| initial={{ opacity: 0, y: 8 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.16 }} | |
| > | |
| <div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> | |
| <div> | |
| <div className="text-xs font-semibold uppercase tracking-[0.18em] text-emerald-600"> | |
| Scientist Runtime | |
| </div> | |
| <h2 className="mt-1 text-base font-semibold"> | |
| {(runtimeStatus.scientist_runtime === 'anthropic' || runtimeStatus.scientist_runtime === 'ollama') && runtimeStatus.scientist_ready | |
| ? 'Localhost is using a model-backed Scientist policy.' | |
| : runtimeStatus.scientist_runtime === 'anthropic' || runtimeStatus.scientist_runtime === 'ollama' | |
| ? 'A model runtime is configured, but it is not ready.' | |
| : 'Localhost is using the deterministic baseline Scientist policy.'} | |
| </h2> | |
| <p className="mt-1 text-sm text-muted-foreground">{runtimeStatus.note}</p> | |
| </div> | |
| <div className="rounded-lg border border-border bg-background px-3 py-2 text-xs text-muted-foreground"> | |
| {runtimeStatus.scientist_runtime} · {runtimeStatus.scientist_model} | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| {!episode.done && episode.conversation.length === 0 && ( | |
| <motion.div | |
| className="mb-4 rounded-lg border border-primary/20 bg-primary/5 p-4" | |
| initial={{ opacity: 0, y: 8 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.15 }} | |
| > | |
| <div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> | |
| <div> | |
| <h2 className="text-sm font-semibold">Episode ready</h2> | |
| <p className="mt-1 text-sm text-muted-foreground"> | |
| The paper and constraints are loaded. Advance the first round to generate the Scientist proposal and the Lab Manager response. | |
| </p> | |
| </div> | |
| <div className="flex flex-wrap gap-2"> | |
| <button | |
| onClick={handleStep} | |
| disabled={loading} | |
| className="inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:opacity-50" | |
| > | |
| <Play className="h-4 w-4" /> | |
| Advance First Round | |
| </button> | |
| <button | |
| onClick={() => setShowEditor(true)} | |
| disabled={loading} | |
| className="inline-flex items-center gap-1.5 rounded-md border border-border px-3 py-2 text-sm font-medium text-foreground transition-colors hover:bg-muted disabled:opacity-50" | |
| > | |
| <Pencil className="h-4 w-4" /> | |
| Open Protocol Editor | |
| </button> | |
| </div> | |
| </div> | |
| </motion.div> | |
| )} | |
| {/* Feature 10: Agent Thoughts (full width above panels) */} | |
| {!episode.done && episode.conversation.length > 0 && ( | |
| <motion.div className="mb-4" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.2 }}> | |
| <AgentThoughts | |
| messages={episode.conversation} | |
| labConstraints={episode.lab_constraints} | |
| protocol={episode.protocol} | |
| /> | |
| </motion.div> | |
| )} | |
| {/* Three-panel layout */} | |
| <div className="grid grid-cols-1 gap-4 lg:grid-cols-12"> | |
| {/* Left panel */} | |
| <motion.div className="space-y-4 lg:col-span-3" initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.1 }}> | |
| <PaperPanel | |
| paper={episode.paper} | |
| seed={episode.seed} | |
| template={episode.template} | |
| difficulty={episode.difficulty} | |
| round={episode.round} | |
| maxRounds={episode.max_rounds} | |
| episodeId={episode.episode_id} | |
| /> | |
| <LabInventory constraints={episode.lab_constraints} /> | |
| {/* Feature 13: 3D Lab toggle */} | |
| <button | |
| onClick={() => setShow3DLab((p) => !p)} | |
| className="w-full rounded-lg border border-border bg-card px-3 py-2 text-xs font-medium text-muted-foreground hover:bg-muted transition-colors" | |
| > | |
| {show3DLab ? 'Hide' : 'Show'} 3D Lab View | |
| </button> | |
| <AnimatePresence> | |
| {show3DLab && ( | |
| <motion.div initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: 'auto' }} exit={{ opacity: 0, height: 0 }}> | |
| <Suspense fallback={<div className="h-[200px] rounded-lg border border-border bg-card flex items-center justify-center text-xs text-muted-foreground">Loading 3D...</div>}> | |
| <LabScene3D constraints={episode.lab_constraints} protocol={episode.protocol} /> | |
| </Suspense> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| <Controls | |
| onStart={handleStart} | |
| onStep={!episode.done ? handleStep : undefined} | |
| disabled={loading} | |
| episodeActive={true} | |
| runtimeStatus={runtimeStatus} | |
| /> | |
| {/* Feature 5: Auto-play controls */} | |
| <AnimatePresence> | |
| {!episode.done && ( | |
| <AutoPlayControls | |
| isPlaying={autoPlaying} | |
| speed={autoSpeed} | |
| round={episode.round} | |
| maxRounds={episode.max_rounds} | |
| done={episode.done} | |
| onTogglePlay={() => setAutoPlaying((p) => !p)} | |
| onSpeedChange={setAutoSpeed} | |
| /> | |
| )} | |
| </AnimatePresence> | |
| {/* Feature 6: Live score gauges */} | |
| {!episode.done && ( | |
| <LiveScoreGauges | |
| conversation={episode.conversation} | |
| protocol={episode.protocol} | |
| labConstraints={episode.lab_constraints} | |
| paper={episode.paper} | |
| /> | |
| )} | |
| <ShortcutHint /> | |
| </motion.div> | |
| {/* Center panel */} | |
| <motion.div className="flex flex-col lg:col-span-5" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}> | |
| <NegotiationLog | |
| messages={episode.conversation} | |
| episodeActive={true} | |
| disabled={loading} | |
| onKickoff={!episode.done ? handleStep : undefined} | |
| onOpenEditor={!episode.done ? () => setShowEditor(true) : undefined} | |
| className="min-h-[400px] rounded-lg border border-border bg-card" | |
| /> | |
| {/* Feature 7: Protocol evolution timeline */} | |
| {episode.conversation.length > 0 && ( | |
| <motion.div className="mt-4" initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ delay: 0.4 }}> | |
| <ProtocolTimeline messages={episode.conversation} /> | |
| </motion.div> | |
| )} | |
| {episode.done && episode.judge_audit && ( | |
| <motion.div className="mt-4" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.5 }}> | |
| <JudgeAuditPanel audit={episode.judge_audit} /> | |
| </motion.div> | |
| )} | |
| </motion.div> | |
| {/* Right panel */} | |
| <motion.div className="space-y-4 lg:col-span-4" initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: 0.3 }}> | |
| <ProtocolPanel protocol={episode.protocol} paper={episode.paper} /> | |
| <ScorePanel scores={episode.scores} done={episode.done} /> | |
| {episode.done && ( | |
| <div className="rounded-lg border border-primary/20 bg-primary/5 p-4"> | |
| <h2 className="text-sm font-semibold">Why This Matters for Training</h2> | |
| <p className="mt-2 text-sm text-muted-foreground"> | |
| This final judge result is not just a demo scorecard. It becomes the deterministic reward signal | |
| used by the minimal Colab notebook and the heavier training runtime to improve the Scientist policy. | |
| </p> | |
| <div className="mt-3 flex flex-wrap gap-2 text-[11px] font-medium"> | |
| <span className="rounded-full bg-background px-2 py-1 text-primary">same seed</span> | |
| <span className="rounded-full bg-background px-2 py-1 text-primary">same rubric</span> | |
| <span className="rounded-full bg-background px-2 py-1 text-primary">baseline vs trained</span> | |
| </div> | |
| </div> | |
| )} | |
| {/* Feature 11: Protocol editor toggle */} | |
| {!episode.done && ( | |
| <> | |
| <button | |
| onClick={() => setShowEditor((p) => !p)} | |
| className="w-full rounded-lg border border-primary/30 bg-primary/5 px-3 py-2 text-xs font-semibold text-primary hover:bg-primary/10 transition-colors" | |
| > | |
| {showEditor ? 'Hide' : 'Open'} Protocol Editor | |
| <kbd className="ml-2 rounded border border-primary/20 bg-primary/10 px-1 font-mono text-[9px]">E</kbd> | |
| </button> | |
| <AnimatePresence> | |
| {showEditor && ( | |
| <ProtocolEditor | |
| episode={episode} | |
| onSubmit={handleEditorSubmit} | |
| disabled={loading} | |
| /> | |
| )} | |
| </AnimatePresence> | |
| </> | |
| )} | |
| {episode.done && <ReplayViewer messages={episode.conversation} />} | |
| </motion.div> | |
| </div> | |
| {episode.done && ( | |
| <motion.div | |
| ref={resultsRef} | |
| className="mt-6" | |
| initial={{ opacity: 0, y: 12 }} | |
| animate={{ opacity: 1, y: 0 }} | |
| transition={{ delay: 0.35 }} | |
| > | |
| <EpisodeResultsReport episode={episode} /> | |
| </motion.div> | |
| )} | |
| </div> | |
| ); | |
| } | |