| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236 |
- 'use client';
- 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 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<void>;
- }) {
- const { t } = useI18n();
- const { mode, getCurrentScene, scenes, currentSceneId, setCurrentSceneId, generatingOutlines } =
- 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<EngineMode>('idle');
- const [playbackCompleted, setPlaybackCompleted] = useState(false); // Distinguishes "never played" idle from "finished" idle
- const [lectureSpeech, setLectureSpeech] = useState<string | null>(null); // From PlaybackEngine (lecture)
- const [liveSpeech, setLiveSpeech] = useState<string | null>(null); // From buffer (discussion/QA)
- const [speechProgress, setSpeechProgress] = useState<number | null>(null); // StreamBuffer reveal progress (0–1)
- const [discussionTrigger, setDiscussionTrigger] = useState<TriggerEvent | null>(null);
- // Speaking agent tracking (Issue 2)
- const [speakingAgentId, setSpeakingAgentId] = useState<string | null>(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<string | null>(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<string | null>(null);
- // Scene switch confirmation dialog state
- const [pendingSceneId, setPendingSceneId] = useState<string | null>(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<AudioIndicatorState>('idle');
- const [audioAgentId, setAudioAgentId] = useState<string | null>(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<PlaybackEngine | null>(null);
- const audioPlayerRef = useRef(createAudioPlayer());
- const chatAreaRef = useRef<ChatAreaRef>(null);
- const lectureSessionIdRef = useRef<string | null>(null);
- const lectureActionCounterRef = useRef(0);
- const discussionAbortRef = useRef<AbortController | null>(null);
- const presentationIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
- const stageRef = useRef<HTMLDivElement>(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();
- }
- // Create ActionEngine for playback (with audioPlayer for TTS)
- const actionEngine = new ActionEngine(useStageStore, audioPlayerRef.current);
- // 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;
- // 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 (hasNextPending) {
- // On last real scene → advance to pending page
- setCurrentSceneId(PENDING_SCENE_ID);
- }
- }, [currentSceneId, gatedSceneSwitch, hasNextPending, isPendingScene, scenes, setCurrentSceneId]);
- const currentSceneIndex = isPendingScene
- ? scenes.length
- : scenes.findIndex((s) => s.id === currentSceneId);
- const totalScenesCount = scenes.length + (hasNextPending ? 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 (
- <div
- ref={stageRef}
- className={cn(
- 'flex-1 flex overflow-hidden bg-gray-50 dark:bg-gray-900',
- isPresenting && !controlsVisible && 'cursor-none',
- )}
- >
- {/* Scene Sidebar */}
- <SceneSidebar
- collapsed={sidebarCollapsed}
- onCollapseChange={setSidebarCollapsed}
- onSceneSelect={gatedSceneSwitch}
- onRetryOutline={onRetryOutline}
- />
- {/* Main Content Area */}
- <div className="flex-1 flex flex-col overflow-hidden min-w-0 relative">
- {/* Header */}
- {!isPresenting && <Header currentSceneTitle={currentScene?.title || ''} />}
- {/* Canvas Area */}
- <div
- className="overflow-hidden relative flex-1 min-h-0 isolate"
- style={{
- height: sceneViewerHeight,
- }}
- suppressHydrationWarning
- >
- <CanvasArea
- currentScene={currentScene}
- currentSceneIndex={currentSceneIndex}
- scenesCount={totalScenesCount}
- mode={mode}
- engineState={canvasEngineState}
- isLiveSession={
- chatIsStreaming || isTopicPending || engineMode === 'live' || !!chatSessionType
- }
- whiteboardOpen={whiteboardOpen}
- sidebarCollapsed={sidebarCollapsed}
- chatCollapsed={chatAreaCollapsed}
- onToggleSidebar={() => 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}
- isGenerationFailed={
- isPendingScene && failedOutlines.some((f) => f.id === generatingOutlines[0]?.id)
- }
- onRetryGeneration={
- onRetryOutline && generatingOutlines[0]
- ? () => onRetryOutline(generatingOutlines[0].id)
- : undefined
- }
- />
- </div>
- {/* Roundtable Area */}
- {mode === 'playback' && (
- <div
- className={cn(
- 'transition-opacity duration-300',
- !isPresenting && 'shrink-0',
- isPresenting && 'absolute inset-x-0 bottom-0 z-20',
- )}
- >
- <Roundtable
- mode={mode}
- initialParticipants={participants}
- playbackView={playbackView}
- currentSpeech={liveSpeech}
- lectureSpeech={lectureSpeech}
- idleText={firstSpeechText}
- playbackCompleted={playbackCompleted}
- discussionRequest={discussionRequest}
- engineMode={engineMode}
- isStreaming={chatIsStreaming}
- audioIndicatorState={audioIndicatorState}
- audioAgentId={audioAgentId}
- sessionType={
- chatSessionType === 'qa'
- ? 'qa'
- : chatSessionType === 'discussion'
- ? 'discussion'
- : undefined
- }
- speakingAgentId={speakingAgentId}
- speechProgress={speechProgress}
- showEndFlash={showEndFlash}
- endFlashSessionType={endFlashSessionType}
- thinkingState={thinkingState}
- isCueUser={isCueUser}
- isTopicPending={isTopicPending}
- onMessageSend={async (msg) => {
- // 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}
- />
- </div>
- )}
- </div>
- {/* Chat Area */}
- <ChatArea
- ref={chatAreaRef}
- width={chatAreaWidth}
- onWidthChange={setChatAreaWidth}
- collapsed={chatAreaCollapsed}
- onCollapseChange={setChatAreaCollapsed}
- activeBubbleId={activeBubbleId}
- onActiveBubble={(id) => 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 */}
- <AlertDialog
- open={!!pendingSceneId}
- onOpenChange={(open) => {
- if (!open) cancelSceneSwitch();
- }}
- >
- <AlertDialogContent
- container={isPresenting ? stageRef.current : undefined}
- className="max-w-sm rounded-2xl p-0 overflow-hidden border-0 shadow-[0_25px_60px_-12px_rgba(0,0,0,0.15)] dark:shadow-[0_25px_60px_-12px_rgba(0,0,0,0.5)]"
- >
- <VisuallyHidden.Root>
- <AlertDialogTitle>{t('stage.confirmSwitchTitle')}</AlertDialogTitle>
- </VisuallyHidden.Root>
- {/* Top accent bar */}
- <div className="h-1 bg-gradient-to-r from-amber-400 via-orange-400 to-red-400" />
- <div className="px-6 pt-5 pb-2 flex flex-col items-center text-center">
- {/* Icon */}
- <div className="w-12 h-12 rounded-full bg-amber-50 dark:bg-amber-900/20 flex items-center justify-center mb-4 ring-1 ring-amber-200/50 dark:ring-amber-700/30">
- <AlertTriangle className="w-6 h-6 text-amber-500 dark:text-amber-400" />
- </div>
- {/* Title */}
- <h3 className="text-base font-bold text-gray-900 dark:text-gray-100 mb-1.5">
- {t('stage.confirmSwitchTitle')}
- </h3>
- {/* Description */}
- <p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
- {t('stage.confirmSwitchMessage')}
- </p>
- </div>
- <AlertDialogFooter className="px-6 pb-5 pt-3 flex-row gap-3">
- <AlertDialogCancel onClick={cancelSceneSwitch} className="flex-1 rounded-xl">
- {t('common.cancel')}
- </AlertDialogCancel>
- <AlertDialogAction
- onClick={confirmSceneSwitch}
- className="flex-1 rounded-xl bg-gradient-to-r from-amber-500 to-orange-500 hover:from-amber-600 hover:to-orange-600 text-white border-0 shadow-md shadow-amber-200/50 dark:shadow-amber-900/30"
- >
- {t('common.confirm')}
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- </div>
- );
- }
|