import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { useStageStore } from '@/lib/store'; import { PENDING_SCENE_ID } from '@/lib/store/stage'; import { useCanvasStore } from '@/lib/store/canvas'; import { useSettingsStore } from '@/lib/store/settings'; import { useI18n } from '@/lib/hooks/use-i18n'; import { SceneSidebar } from './stage/scene-sidebar'; import { Header } from './header'; import { CanvasArea } from '@/components/canvas/canvas-area'; import { Roundtable } from '@/components/roundtable'; import { PlaybackEngine, computePlaybackView } from '@/lib/playback'; import type { EngineMode, TriggerEvent, Effect } from '@/lib/playback'; import { ActionEngine } from '@/lib/action/engine'; import { createAudioPlayer } from '@/lib/utils/audio-player'; import { useDiscussionTTS } from '@/lib/hooks/use-discussion-tts'; import { useWidgetIframeStore } from '@/lib/store/widget-iframe'; import type { AudioIndicatorState } from '@/components/roundtable/audio-indicator'; import type { Action, DiscussionAction, SpeechAction } from '@/lib/types/action'; import { cn } from '@/lib/utils'; // Playback state persistence removed — refresh always starts from the beginning import { ChatArea, type ChatAreaRef } from '@/components/chat/chat-area'; import { agentsToParticipants, useAgentRegistry } from '@/lib/orchestration/registry/store'; import type { AgentConfig } from '@/lib/orchestration/registry/types'; import { AlertDialog, AlertDialogContent, AlertDialogTitle, AlertDialogFooter, AlertDialogAction, AlertDialogCancel, } from '@/components/ui/alert-dialog'; import { AlertTriangle } from 'lucide-react'; import { VisuallyHidden } from 'radix-ui'; /** * Stage Component * * The main container for the classroom/course. * Combines sidebar (scene navigation) and content area (scene viewer). * Supports two modes: autonomous and playback. */ export function Stage({ onRetryOutline, }: { onRetryOutline?: (outlineId: string) => Promise; }) { const { t } = useI18n(); const { mode, getCurrentScene, scenes, currentSceneId, setCurrentSceneId, generatingOutlines, outlines, } = useStageStore(); const failedOutlines = useStageStore.use.failedOutlines(); const currentScene = getCurrentScene(); // Layout state from settings store (persisted via localStorage) const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed); const setSidebarCollapsed = useSettingsStore((s) => s.setSidebarCollapsed); const chatAreaWidth = useSettingsStore((s) => s.chatAreaWidth); const setChatAreaWidth = useSettingsStore((s) => s.setChatAreaWidth); const chatAreaCollapsed = useSettingsStore((s) => s.chatAreaCollapsed); const setChatAreaCollapsed = useSettingsStore((s) => s.setChatAreaCollapsed); const setTTSMuted = useSettingsStore((s) => s.setTTSMuted); const setTTSVolume = useSettingsStore((s) => s.setTTSVolume); // PlaybackEngine state const [engineMode, setEngineMode] = useState('idle'); const [playbackCompleted, setPlaybackCompleted] = useState(false); // Distinguishes "never played" idle from "finished" idle const [lectureSpeech, setLectureSpeech] = useState(null); // From PlaybackEngine (lecture) const [liveSpeech, setLiveSpeech] = useState(null); // From buffer (discussion/QA) const [speechProgress, setSpeechProgress] = useState(null); // StreamBuffer reveal progress (0–1) const [discussionTrigger, setDiscussionTrigger] = useState(null); // Speaking agent tracking (Issue 2) const [speakingAgentId, setSpeakingAgentId] = useState(null); // Thinking state (Issue 5) const [thinkingState, setThinkingState] = useState<{ stage: string; agentId?: string; } | null>(null); // Cue user state (Issue 7) const [isCueUser, setIsCueUser] = useState(false); // End flash state (Issue 3) const [showEndFlash, setShowEndFlash] = useState(false); const [endFlashSessionType, setEndFlashSessionType] = useState<'qa' | 'discussion'>('discussion'); // Streaming state for stop button (Issue 1) const [chatIsStreaming, setChatIsStreaming] = useState(false); const [chatSessionType, setChatSessionType] = useState(null); // Topic pending state: session is soft-paused, bubble stays visible, waiting for user input const [isTopicPending, setIsTopicPending] = useState(false); // Active bubble ID for playback highlight in chat area (Issue 8) const [activeBubbleId, setActiveBubbleId] = useState(null); // Scene switch confirmation dialog state const [pendingSceneId, setPendingSceneId] = useState(null); const [isPresenting, setIsPresenting] = useState(false); const [controlsVisible, setControlsVisible] = useState(true); const [isPresentationInteractionActive, setIsPresentationInteractionActive] = useState(false); // Whiteboard state (from canvas store so AI tools can open it) const whiteboardOpen = useCanvasStore.use.whiteboardOpen(); const setWhiteboardOpen = useCanvasStore.use.setWhiteboardOpen(); // Selected agents from settings store (Zustand) const selectedAgentIds = useSettingsStore((s) => s.selectedAgentIds); const ttsMuted = useSettingsStore((s) => s.ttsMuted); const ttsEnabled = useSettingsStore((s) => s.ttsEnabled); // Generate participants from selected agents const participants = useMemo( () => agentsToParticipants(selectedAgentIds, t), [selectedAgentIds, t], ); // Resolved AgentConfig array for hooks that need full agent objects // Subscribe to the agents record so voiceConfig changes trigger re-resolution const agentsRecord = useAgentRegistry((s) => s.agents); const selectedAgents = useMemo( () => selectedAgentIds.map((id) => agentsRecord[id]).filter((a): a is AgentConfig => a != null), [agentsRecord, selectedAgentIds], ); // Discussion TTS: audio indicator state const [audioIndicatorState, setAudioIndicatorState] = useState('idle'); const [audioAgentId, setAudioAgentId] = useState(null); const discussionTTS = useDiscussionTTS({ enabled: ttsEnabled && !ttsMuted, agents: selectedAgents, onAudioStateChange: (agentId, state) => { setAudioAgentId(agentId); setAudioIndicatorState(state); }, }); // Pick a student agent for discussion trigger (prioritize student > non-teacher > fallback) const pickStudentAgent = useCallback((): string => { const registry = useAgentRegistry.getState(); const agents = selectedAgentIds .map((id) => registry.getAgent(id)) .filter((a): a is AgentConfig => a != null); const students = agents.filter((a) => a.role === 'student'); if (students.length > 0) { return students[Math.floor(Math.random() * students.length)].id; } const nonTeachers = agents.filter((a) => a.role !== 'teacher'); if (nonTeachers.length > 0) { return nonTeachers[Math.floor(Math.random() * nonTeachers.length)].id; } return agents[0]?.id || 'default-1'; }, [selectedAgentIds]); const engineRef = useRef(null); const audioPlayerRef = useRef(createAudioPlayer()); const chatAreaRef = useRef(null); const lectureSessionIdRef = useRef(null); const lectureActionCounterRef = useRef(0); const discussionAbortRef = useRef(null); const presentationIdleTimerRef = useRef | null>(null); const stageRef = useRef(null); // Guard to prevent double flash when manual stop triggers onDiscussionEnd const manualStopRef = useRef(false); // Monotonic counter incremented on each scene switch — used to discard stale SSE callbacks const sceneEpochRef = useRef(0); // When true, the next engine init will auto-start playback (for auto-play scene advance) const autoStartRef = useRef(false); // Discussion buffer-level pause state (distinct from soft-pause which aborts SSE) const [isDiscussionPaused, setIsDiscussionPaused] = useState(false); /** * Resume a soft-paused topic: re-call /chat with existing session messages. * The director picks the next agent to continue. */ const doResumeTopic = useCallback(async () => { // Clear old bubble immediately — no lingering on interrupted text setIsTopicPending(false); setLiveSpeech(null); setSpeakingAgentId(null); setThinkingState({ stage: 'director' }); setChatIsStreaming(true); // Transition engine back to live — onInputActivate paused it when soft-pausing, // so we must explicitly resume to keep engine mode in sync with the chat loop. engineRef.current?.resume(); // Fire new chat round — SSE events will drive thinking → agent_start → speech await chatAreaRef.current?.resumeActiveSession(); }, []); /** Reset all live/discussion state (shared by doSessionCleanup & onDiscussionEnd) */ const resetLiveState = useCallback(() => { setLiveSpeech(null); setSpeakingAgentId(null); setSpeechProgress(null); setThinkingState(null); setIsCueUser(false); setIsTopicPending(false); setChatIsStreaming(false); setChatSessionType(null); setIsDiscussionPaused(false); }, []); /** Full scene reset (scene switch) — resetLiveState + lecture/visual state */ const resetSceneState = useCallback(() => { resetLiveState(); setPlaybackCompleted(false); setLectureSpeech(null); setSpeechProgress(null); setShowEndFlash(false); setActiveBubbleId(null); setDiscussionTrigger(null); }, [resetLiveState]); /** Request failure should exit live discussion UI without hard-closing the session. */ const handleLiveSessionError = useCallback(() => { engineRef.current?.handleDiscussionError(); resetLiveState(); setActiveBubbleId(null); }, [resetLiveState]); /** * Unified session cleanup — called by both roundtable stop button and chat area end button. * Handles: engine transition, flash, roundtable state clearing. */ const doSessionCleanup = useCallback(() => { const activeType = chatSessionType; // Engine cleanup — guard to avoid double flash from onDiscussionEnd manualStopRef.current = true; engineRef.current?.handleEndDiscussion(); manualStopRef.current = false; // Show end flash with correct session type if (activeType === 'qa' || activeType === 'discussion') { setEndFlashSessionType(activeType); setShowEndFlash(true); setTimeout(() => setShowEndFlash(false), 1800); } // Stop any in-flight discussion TTS audio discussionTTS.cleanup(); resetLiveState(); }, [chatSessionType, resetLiveState, discussionTTS]); // Shared stop-discussion handler (used by both Roundtable and Canvas toolbar) const handleStopDiscussion = useCallback(async () => { await chatAreaRef.current?.endActiveSession(); doSessionCleanup(); }, [doSessionCleanup]); const clearPresentationIdleTimer = useCallback(() => { if (presentationIdleTimerRef.current) { clearTimeout(presentationIdleTimerRef.current); presentationIdleTimerRef.current = null; } }, []); const resetPresentationIdleTimer = useCallback(() => { setControlsVisible(true); clearPresentationIdleTimer(); if (isPresenting && !isPresentationInteractionActive) { presentationIdleTimerRef.current = setTimeout(() => { setControlsVisible(false); }, 3000); } }, [clearPresentationIdleTimer, isPresenting, isPresentationInteractionActive]); const togglePresentation = useCallback(async () => { const stageElement = stageRef.current; if (!stageElement) return; try { if (document.fullscreenElement === stageElement) { // Unlock Escape key before exiting fullscreen // eslint-disable-next-line @typescript-eslint/no-explicit-any (navigator as any).keyboard?.unlock?.(); await document.exitFullscreen(); return; } setControlsVisible(true); await stageElement.requestFullscreen(); // Lock Escape key so it doesn't auto-exit fullscreen (#255) // Escape is handled manually in our keydown handler instead // eslint-disable-next-line @typescript-eslint/no-explicit-any await (navigator as any).keyboard?.lock?.(['Escape']).catch(() => {}); setSidebarCollapsed(true); setChatAreaCollapsed(true); } catch { // Firefox may deny fullscreen from certain keyboard events (e.g. F11) console.warn('[Presentation] Fullscreen request denied — browser policy'); } }, [setChatAreaCollapsed, setSidebarCollapsed]); useEffect(() => { const onFullscreenChange = () => { const active = document.fullscreenElement === stageRef.current; setIsPresenting(active); if (!active) { // Ensure keyboard unlock on any fullscreen exit // eslint-disable-next-line @typescript-eslint/no-explicit-any (navigator as any).keyboard?.unlock?.(); setControlsVisible(true); clearPresentationIdleTimer(); } }; document.addEventListener('fullscreenchange', onFullscreenChange); return () => document.removeEventListener('fullscreenchange', onFullscreenChange); }, [clearPresentationIdleTimer]); useEffect(() => { if (!isPresenting) { setControlsVisible(true); clearPresentationIdleTimer(); return; } const handleActivity = () => { resetPresentationIdleTimer(); }; window.addEventListener('mousemove', handleActivity); window.addEventListener('mousedown', handleActivity); window.addEventListener('touchstart', handleActivity); if (isPresentationInteractionActive) { setControlsVisible(true); clearPresentationIdleTimer(); } else { resetPresentationIdleTimer(); } return () => { window.removeEventListener('mousemove', handleActivity); window.removeEventListener('mousedown', handleActivity); window.removeEventListener('touchstart', handleActivity); clearPresentationIdleTimer(); }; }, [ clearPresentationIdleTimer, isPresenting, isPresentationInteractionActive, resetPresentationIdleTimer, ]); // Initialize playback engine when scene changes useEffect(() => { // Bump epoch so any stale SSE callbacks from the previous scene are discarded sceneEpochRef.current++; // End any active QA/discussion session — this synchronously aborts the SSE // stream inside use-chat-sessions (abortControllerRef.abort()), preventing // stale onLiveSpeech callbacks from leaking into the new scene. chatAreaRef.current?.endActiveSession(); // Also abort the engine-level discussion controller if (discussionAbortRef.current) { discussionAbortRef.current.abort(); discussionAbortRef.current = null; } // Stop any in-flight discussion TTS audio on scene switch discussionTTS.cleanup(); // Reset all roundtable/live state so scenes are fully isolated resetSceneState(); if (!currentScene || !currentScene.actions || currentScene.actions.length === 0) { engineRef.current = null; setEngineMode('idle'); return; } // Stop previous engine if (engineRef.current) { engineRef.current.stop(); } // Get widget iframe messaging callback for interactive scenes (keyed by sceneId) const widgetSendMessage = useWidgetIframeStore.getState().getSendMessage(currentScene.id); // Create ActionEngine for playback (with audioPlayer for TTS and widget messaging) const actionEngine = new ActionEngine(useStageStore, audioPlayerRef.current, widgetSendMessage); // Create new PlaybackEngine const engine = new PlaybackEngine([currentScene], actionEngine, audioPlayerRef.current, { onModeChange: (mode) => { setEngineMode(mode); }, onSceneChange: (_sceneId) => { // Scene change handled by engine }, onSpeechStart: (text) => { setLectureSpeech(text); // Add to lecture session with incrementing index for dedup // Chat area pacing is handled by the StreamBuffer (onTextReveal) if (lectureSessionIdRef.current) { const idx = lectureActionCounterRef.current++; const speechId = `speech-${Date.now()}`; chatAreaRef.current?.addLectureMessage( lectureSessionIdRef.current, { id: speechId, type: 'speech', text } as Action, idx, ); // Track active bubble for highlight (Issue 8) const msgId = chatAreaRef.current?.getLectureMessageId(lectureSessionIdRef.current!); if (msgId) setActiveBubbleId(msgId); } }, onSpeechEnd: () => { // Don't clear lectureSpeech — let it persist until the next // onSpeechStart replaces it or the scene transitions. // Clearing here causes fallback to idleText (first sentence). setActiveBubbleId(null); }, onEffectFire: (effect: Effect) => { // Add to lecture session with incrementing index if ( lectureSessionIdRef.current && (effect.kind === 'spotlight' || effect.kind === 'laser') ) { const idx = lectureActionCounterRef.current++; chatAreaRef.current?.addLectureMessage( lectureSessionIdRef.current, { id: `${effect.kind}-${Date.now()}`, type: effect.kind, elementId: effect.targetId, } as Action, idx, ); } }, onProactiveShow: (trigger) => { if (!trigger.agentId) { // Mutate in-place so engine.currentTrigger also gets the agentId // (confirmDiscussion reads agentId from the same object reference) trigger.agentId = pickStudentAgent(); } setDiscussionTrigger(trigger); }, onProactiveHide: () => { setDiscussionTrigger(null); }, onDiscussionConfirmed: (topic, prompt, agentId) => { // Start SSE discussion via ChatArea handleDiscussionSSE(topic, prompt, agentId); }, onDiscussionEnd: () => { // Abort any active SSE if (discussionAbortRef.current) { discussionAbortRef.current.abort(); discussionAbortRef.current = null; } setDiscussionTrigger(null); // Stop any in-flight discussion TTS audio discussionTTS.cleanup(); // Clear roundtable state (idempotent — may already be cleared by doSessionCleanup) resetLiveState(); // Only show flash for engine-initiated ends (not manual stop — that's handled by doSessionCleanup) if (!manualStopRef.current) { setEndFlashSessionType('discussion'); setShowEndFlash(true); setTimeout(() => setShowEndFlash(false), 1800); } // If all actions are exhausted (discussion was the last action), mark // playback as completed so the bubble shows reset instead of play. if (engineRef.current?.isExhausted()) { setPlaybackCompleted(true); } }, onUserInterrupt: (text) => { // User interrupted → start a discussion via chat chatAreaRef.current?.sendMessage(text); }, isAgentSelected: (agentId) => { const ids = useSettingsStore.getState().selectedAgentIds; return ids.includes(agentId); }, getPlaybackSpeed: () => useSettingsStore.getState().playbackSpeed || 1, onComplete: () => { // lectureSpeech intentionally NOT cleared — last sentence stays visible // until scene transition (auto-play) or user restarts. Scene change // effect handles the reset. setPlaybackCompleted(true); // End lecture session on playback complete if (lectureSessionIdRef.current) { chatAreaRef.current?.endSession(lectureSessionIdRef.current); lectureSessionIdRef.current = null; } // Auto-play: advance to next scene after a short pause const { autoPlayLecture } = useSettingsStore.getState(); if (autoPlayLecture) { setTimeout(() => { const stageState = useStageStore.getState(); if (!useSettingsStore.getState().autoPlayLecture) return; const allScenes = stageState.scenes; const curId = stageState.currentSceneId; const idx = allScenes.findIndex((s) => s.id === curId); if (idx >= 0 && idx < allScenes.length - 1) { const currentScene = allScenes[idx]; if ( currentScene.type === 'quiz' || currentScene.type === 'interactive' || currentScene.type === 'pbl' ) { return; } autoStartRef.current = true; stageState.setCurrentSceneId(allScenes[idx + 1].id); } else if (idx === allScenes.length - 1 && stageState.generatingOutlines.length > 0) { // Last scene exhausted but next is still generating — go to pending page const currentScene = allScenes[idx]; if ( currentScene.type === 'quiz' || currentScene.type === 'interactive' || currentScene.type === 'pbl' ) { return; } autoStartRef.current = true; stageState.setCurrentSceneId(PENDING_SCENE_ID); } }, 1500); } }, }); engineRef.current = engine; // Auto-start if triggered by auto-play scene advance if (autoStartRef.current) { autoStartRef.current = false; (async () => { if (currentScene && chatAreaRef.current) { const sessionId = await chatAreaRef.current.startLecture(currentScene.id); lectureSessionIdRef.current = sessionId; lectureActionCounterRef.current = 0; } engine.start(); })(); } else { // Load saved playback state and restore position (but never auto-play). } // eslint-disable-next-line react-hooks/exhaustive-deps -- Only re-run when scene changes, functions are stable refs }, [currentScene]); // Cleanup on unmount useEffect(() => { const audioPlayer = audioPlayerRef.current; const chatArea = chatAreaRef.current; return () => { if (engineRef.current) { engineRef.current.stop(); } audioPlayer.destroy(); if (discussionAbortRef.current) { discussionAbortRef.current.abort(); } discussionTTS.cleanup(); chatArea?.endActiveSession(); clearPresentationIdleTimer(); }; // eslint-disable-next-line react-hooks/exhaustive-deps -- unmount-only cleanup, clearPresentationIdleTimer is stable }, []); // Sync mute state from settings store to audioPlayer useEffect(() => { audioPlayerRef.current.setMuted(ttsMuted); }, [ttsMuted]); // Sync volume from settings store to audioPlayer const ttsVolume = useSettingsStore((s) => s.ttsVolume); useEffect(() => { if (!ttsMuted) { audioPlayerRef.current.setVolume(ttsVolume); } }, [ttsVolume, ttsMuted]); // Sync playback speed to audio player (for live-updating current audio) const playbackSpeed = useSettingsStore((s) => s.playbackSpeed); useEffect(() => { audioPlayerRef.current.setPlaybackRate(playbackSpeed); }, [playbackSpeed]); /** * Handle discussion SSE — POST /api/chat and push events to engine */ const handleDiscussionSSE = useCallback( async (topic: string, prompt?: string, agentId?: string) => { // Start discussion display in ChatArea (lecture speech is preserved independently) chatAreaRef.current?.startDiscussion({ topic, prompt, agentId: agentId || 'default-1', }); // Auto-switch to chat tab when discussion starts chatAreaRef.current?.switchToTab('chat'); // Immediately mark streaming for synchronized stop button setChatIsStreaming(true); setChatSessionType('discussion'); // Optimistic thinking: show thinking dots immediately (same as onMessageSend) setThinkingState({ stage: 'director' }); }, [], ); // First speech text for idle display (extracted here for playbackView) const firstSpeechText = useMemo( () => currentScene?.actions?.find((a): a is SpeechAction => a.type === 'speech')?.text ?? null, [currentScene], ); // Whether the speaking agent is a student (for bubble role derivation) const speakingStudentFlag = useMemo(() => { if (!speakingAgentId) return false; const agent = useAgentRegistry.getState().getAgent(speakingAgentId); return agent?.role !== 'teacher'; }, [speakingAgentId]); // Centralised derived playback view const playbackView = useMemo( () => computePlaybackView({ engineMode, lectureSpeech, liveSpeech, speakingAgentId, thinkingState, isCueUser, isTopicPending, chatIsStreaming, discussionTrigger, playbackCompleted, idleText: firstSpeechText, speakingStudent: speakingStudentFlag, sessionType: chatSessionType, }), [ engineMode, lectureSpeech, liveSpeech, speakingAgentId, thinkingState, isCueUser, isTopicPending, chatIsStreaming, discussionTrigger, playbackCompleted, firstSpeechText, speakingStudentFlag, chatSessionType, ], ); const isTopicActive = playbackView.isTopicActive; /** * Gated scene switch — if a topic is active, show AlertDialog before switching. * Returns true if the switch was immediate, false if gated (dialog shown). */ const gatedSceneSwitch = useCallback( (targetSceneId: string): boolean => { if (targetSceneId === currentSceneId) return false; if (isTopicActive) { setPendingSceneId(targetSceneId); return false; } setCurrentSceneId(targetSceneId); return true; }, [currentSceneId, isTopicActive, setCurrentSceneId], ); /** User confirmed scene switch via AlertDialog */ const confirmSceneSwitch = useCallback(() => { if (!pendingSceneId) return; chatAreaRef.current?.endActiveSession(); doSessionCleanup(); setCurrentSceneId(pendingSceneId); setPendingSceneId(null); }, [pendingSceneId, setCurrentSceneId, doSessionCleanup]); /** User cancelled scene switch via AlertDialog */ const cancelSceneSwitch = useCallback(() => { setPendingSceneId(null); }, []); // play/pause toggle const handlePlayPause = useCallback(async () => { const engine = engineRef.current; if (!engine) return; const mode = engine.getMode(); if (mode === 'playing' || mode === 'live') { engine.pause(); // Pause lecture buffer so text stops immediately if (lectureSessionIdRef.current) { chatAreaRef.current?.pauseBuffer(lectureSessionIdRef.current); } } else if (mode === 'paused') { engine.resume(); // Resume lecture buffer if (lectureSessionIdRef.current) { chatAreaRef.current?.resumeBuffer(lectureSessionIdRef.current); } } else { const wasCompleted = playbackCompleted; setPlaybackCompleted(false); // Starting playback - create/reuse lecture session if (currentScene && chatAreaRef.current) { const sessionId = await chatAreaRef.current.startLecture(currentScene.id); lectureSessionIdRef.current = sessionId; } if (wasCompleted) { // Restart from beginning (user clicked restart after completion) lectureActionCounterRef.current = 0; engine.start(); } else { // Continue from current position (e.g. after discussion end) engine.continuePlayback(); } } }, [playbackCompleted, currentScene]); // get scene information const isPendingScene = currentSceneId === PENDING_SCENE_ID; const hasNextPending = generatingOutlines.length > 0; // True when every outline has materialized into a scene and nothing is // currently generating — signals the classroom has finished and the user // can see a completion page. Comparing scenes.length === outlines.length // (rather than just `scenes.length > 0`) means a partial generation with // some failed outlines does not falsely trigger completion. const isCourseComplete = outlines.length > 0 && scenes.length === outlines.length && generatingOutlines.length === 0; const canAdvanceToPendingSlot = hasNextPending || isCourseComplete; // previous scene (gated) const handlePreviousScene = useCallback(() => { if (isPendingScene) { // From pending page → go to last real scene if (scenes.length > 0) { gatedSceneSwitch(scenes[scenes.length - 1].id); } return; } const currentIndex = scenes.findIndex((s) => s.id === currentSceneId); if (currentIndex > 0) { gatedSceneSwitch(scenes[currentIndex - 1].id); } }, [currentSceneId, gatedSceneSwitch, isPendingScene, scenes]); // next scene (gated) const handleNextScene = useCallback(() => { if (isPendingScene) return; // Already on pending, nowhere to go const currentIndex = scenes.findIndex((s) => s.id === currentSceneId); if (currentIndex < scenes.length - 1) { gatedSceneSwitch(scenes[currentIndex + 1].id); } else if (canAdvanceToPendingSlot) { // On last real scene → advance to pending slot (generating or completion page) setCurrentSceneId(PENDING_SCENE_ID); } }, [ currentSceneId, gatedSceneSwitch, canAdvanceToPendingSlot, isPendingScene, scenes, setCurrentSceneId, ]); const currentSceneIndex = isPendingScene ? scenes.length : scenes.findIndex((s) => s.id === currentSceneId); const totalScenesCount = scenes.length + (canAdvanceToPendingSlot ? 1 : 0); // get action information const totalActions = currentScene?.actions?.length || 0; // whiteboard toggle const handleWhiteboardToggle = () => { setWhiteboardOpen(!whiteboardOpen); }; const isPresentationShortcutTarget = useCallback((target: EventTarget | null) => { if (!(target instanceof HTMLElement)) return false; if (target.isContentEditable || target.closest('[contenteditable="true"]')) { return true; } return ( target.closest( ['input', 'textarea', 'select', '[role="slider"]', 'input[type="range"]'].join(', '), ) !== null ); }, []); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; // Let modifier-key combos (Ctrl+C, Ctrl+S, etc.) pass through to the browser if (event.ctrlKey || event.metaKey || event.altKey) return; if ( isPresentationShortcutTarget(event.target) || isPresentationShortcutTarget(document.activeElement) ) { return; } switch (event.key) { case 'ArrowLeft': if (!isPresenting) return; event.preventDefault(); handlePreviousScene(); resetPresentationIdleTimer(); break; case 'ArrowRight': if (!isPresenting) return; event.preventDefault(); handleNextScene(); resetPresentationIdleTimer(); break; case ' ': case 'Spacebar': // During active QA/discussion, Roundtable owns Space for // buffer-level pause/resume — don't also fire engine play/pause. if (chatSessionType === 'qa' || chatSessionType === 'discussion') break; event.preventDefault(); handlePlayPause(); break; case 'Escape': // With keyboard.lock(), Escape no longer auto-exits fullscreen. // If panels are open, roundtable handles Escape (close panels). // If no panels are open, manually exit fullscreen. if (isPresenting && !isPresentationInteractionActive) { event.preventDefault(); togglePresentation(); } break; case 'ArrowUp': event.preventDefault(); setTTSVolume(ttsVolume + 0.1); break; case 'ArrowDown': event.preventDefault(); setTTSVolume(ttsVolume - 0.1); break; case 'm': case 'M': event.preventDefault(); setTTSMuted(!ttsMuted); break; case 's': case 'S': event.preventDefault(); setSidebarCollapsed(!sidebarCollapsed); break; case 'c': case 'C': event.preventDefault(); setChatAreaCollapsed(!chatAreaCollapsed); break; default: break; } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [ chatSessionType, chatAreaCollapsed, handleNextScene, handlePlayPause, handlePreviousScene, isPresenting, isPresentationInteractionActive, isPresentationShortcutTarget, resetPresentationIdleTimer, setChatAreaCollapsed, setSidebarCollapsed, setTTSMuted, setTTSVolume, sidebarCollapsed, togglePresentation, ttsMuted, ttsVolume, ]); // Intercept F11 to use our presentation fullscreen instead of browser fullscreen // This way ESC can exit fullscreen (browser F11 fullscreen requires F11 to exit) useEffect(() => { const onF11 = (event: KeyboardEvent) => { if (event.key === 'F11') { event.preventDefault(); togglePresentation(); } }; window.addEventListener('keydown', onF11); return () => window.removeEventListener('keydown', onF11); }, [togglePresentation]); // Map engine mode to the CanvasArea's expected engine state const canvasEngineState = (() => { switch (engineMode) { case 'playing': case 'live': return 'playing'; case 'paused': return 'paused'; default: return 'idle'; } })(); // Build discussion request for Roundtable ProactiveCard from trigger const discussionRequest: DiscussionAction | null = discussionTrigger ? { type: 'discussion', id: discussionTrigger.id, topic: discussionTrigger.question, prompt: discussionTrigger.prompt, agentId: discussionTrigger.agentId || 'default-1', } : null; // Calculate scene viewer height (subtract Header's 80px height) const sceneViewerHeight = (() => { const headerHeight = isPresenting ? 0 : 80; // Header h-20 = 80px const roundtableHeight = mode === 'playback' && !isPresenting ? 192 : 0; return `calc(100% - ${headerHeight + roundtableHeight}px)`; })(); return (
{/* Scene Sidebar */} {/* Main Content Area */}
{/* Header */} {!isPresenting && (
)} {/* Canvas Area */}
setSidebarCollapsed(!sidebarCollapsed)} onToggleChat={() => setChatAreaCollapsed(!chatAreaCollapsed)} onPrevSlide={handlePreviousScene} onNextSlide={handleNextScene} onPlayPause={handlePlayPause} onWhiteboardClose={handleWhiteboardToggle} isPresenting={isPresenting} onTogglePresentation={togglePresentation} showStopDiscussion={ engineMode === 'live' || (chatIsStreaming && (chatSessionType === 'qa' || chatSessionType === 'discussion')) } onStopDiscussion={handleStopDiscussion} hideToolbar={mode === 'playback' || (isPresenting && !controlsVisible)} isPendingScene={isPendingScene} isCourseComplete={isCourseComplete} isGenerationFailed={ isPendingScene && failedOutlines.some((f) => f.id === generatingOutlines[0]?.id) } onRetryGeneration={ onRetryOutline && generatingOutlines[0] ? () => onRetryOutline(generatingOutlines[0].id) : undefined } />
{/* Roundtable Area */} {mode === 'playback' && (
{ // Always clear Level-1 pause state — the closure may hold a stale // isDiscussionPaused value (e.g. voice input's onTranscription callback // captures onMessageSend before React re-renders with the updated state). setIsDiscussionPaused(false); // Clear the sticky livePausedRef so the next agent-loop buffer // starts unpaused. (pauseActiveLiveBuffer sets a ref that new // buffers inherit — must be cleared before sendMessage creates one.) chatAreaRef.current?.resumeActiveLiveBuffer(); // Flush any buffered / in-flight TTS audio from the previous // agent turn so it doesn't leak into the next round. discussionTTS.cleanup(); // Clear soft-paused state — user is continuing the topic if (isTopicPending) { setIsTopicPending(false); setLiveSpeech(null); setSpeakingAgentId(null); } // User interrupts during playback — handleUserInterrupt triggers // onUserInterrupt callback which already calls sendMessage, so skip // the direct sendMessage below to avoid sending twice. // Include 'paused' because onInputActivate pauses the engine before // the user finishes typing — without this the interrupt position // would never be saved and resuming after QA skips to the next sentence. if ( engineRef.current && (engineMode === 'playing' || engineMode === 'live' || engineMode === 'paused') ) { engineRef.current.handleUserInterrupt(msg); } else { chatAreaRef.current?.sendMessage(msg); } // Auto-switch to chat tab when user sends a message chatAreaRef.current?.switchToTab('chat'); setIsCueUser(false); // Immediately mark streaming for synchronized stop button setChatIsStreaming(true); setChatSessionType(chatSessionType || 'qa'); // Optimistic thinking: show thinking dots immediately so there's // no blank gap between userMessage expiry and the SSE thinking event. // The real SSE event will overwrite this with the same or updated value. setThinkingState({ stage: 'director' }); }} onDiscussionStart={() => { // User clicks "Join" on ProactiveCard engineRef.current?.confirmDiscussion(); }} onDiscussionSkip={() => { // User clicks "Skip" on ProactiveCard engineRef.current?.skipDiscussion(); }} onStopDiscussion={handleStopDiscussion} onInputActivate={() => { // Level-1 pause: freeze buffer tick + TTS audio while SSE keeps buffering. // User resumes manually via Space / pause button after closing the input. // No isDiscussionPaused guard — always attempt to pause the buffer. // The return value ensures UI state stays in sync with buffer state. if (chatSessionType === 'qa' || chatSessionType === 'discussion') { const paused = chatAreaRef.current?.pauseActiveLiveBuffer(); if (paused) { discussionTTS.pause(); setIsDiscussionPaused(true); } } // Also pause playback engine if (engineRef.current && (engineMode === 'playing' || engineMode === 'live')) { engineRef.current.pause(); } }} onResumeTopic={doResumeTopic} onPlayPause={handlePlayPause} isDiscussionPaused={isDiscussionPaused} onDiscussionPause={() => { const paused = chatAreaRef.current?.pauseActiveLiveBuffer(); if (paused) { discussionTTS.pause(); setIsDiscussionPaused(true); } }} onDiscussionResume={() => { chatAreaRef.current?.resumeActiveLiveBuffer(); discussionTTS.resume(); setIsDiscussionPaused(false); }} totalActions={totalActions} currentActionIndex={0} currentSceneIndex={currentSceneIndex} scenesCount={totalScenesCount} whiteboardOpen={whiteboardOpen} sidebarCollapsed={sidebarCollapsed} chatCollapsed={chatAreaCollapsed} onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)} onToggleChat={() => setChatAreaCollapsed(!chatAreaCollapsed)} onPrevSlide={handlePreviousScene} onNextSlide={handleNextScene} onWhiteboardClose={handleWhiteboardToggle} isPresenting={isPresenting} controlsVisible={controlsVisible} onTogglePresentation={togglePresentation} onPresentationInteractionChange={setIsPresentationInteractionActive} fullscreenContainerRef={stageRef} />
)}
{/* Chat Area */} setActiveBubbleId(id)} currentSceneId={currentSceneId} onLiveSpeech={(text, agentId) => { // Capture epoch at call time — discard if scene has changed since const epoch = sceneEpochRef.current; // Use queueMicrotask to let any pending scene-switch reset settle first queueMicrotask(() => { if (sceneEpochRef.current !== epoch) return; // stale — scene changed setLiveSpeech(text); if (agentId !== undefined) { setSpeakingAgentId(agentId); } if (text !== null || agentId) { setChatIsStreaming(true); setChatSessionType(chatAreaRef.current?.getActiveSessionType?.() ?? null); setIsTopicPending(false); } else if (text === null && agentId === null) { setChatIsStreaming(false); // Don't clear chatSessionType here — it's needed by the stop // button when director cues user (cue_user → done → liveSpeech null). // It gets properly cleared in doSessionCleanup and scene change. } }); }} onSpeechProgress={(ratio) => { const epoch = sceneEpochRef.current; queueMicrotask(() => { if (sceneEpochRef.current !== epoch) return; setSpeechProgress(ratio); }); }} onThinking={(state) => { const epoch = sceneEpochRef.current; queueMicrotask(() => { if (sceneEpochRef.current !== epoch) return; setThinkingState(state); }); }} onCueUser={(_fromAgentId, _prompt) => { setIsCueUser(true); }} onLiveSessionError={handleLiveSessionError} onStopSession={doSessionCleanup} onSegmentSealed={discussionTTS.handleSegmentSealed} shouldHoldAfterReveal={discussionTTS.shouldHold} /> {/* Scene switch confirmation dialog */} { if (!open) cancelSceneSwitch(); }} > {t('stage.confirmSwitchTitle')} {/* Top accent bar */}
{/* Icon */}
{/* Title */}

{t('stage.confirmSwitchTitle')}

{/* Description */}

{t('stage.confirmSwitchMessage')}

{t('common.cancel')} {t('common.confirm')}
); }