Spaces:
No application file
No application file
| '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 type { Action, DiscussionAction, SpeechAction } from '@/lib/types/action'; | |
| // 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); | |
| // 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); | |
| // 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); | |
| // Generate participants from selected agents | |
| const participants = useMemo( | |
| () => agentsToParticipants(selectedAgentIds, t), | |
| [selectedAgentIds, t], | |
| ); | |
| // 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); | |
| // 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); | |
| /** | |
| * Soft-pause: interrupt current agent stream but keep the session active. | |
| * Used when clicking the bubble pause button or opening input during QA/discussion. | |
| * Does NOT end the topic β user can continue speaking in the same session. | |
| * Preserves liveSpeech (with "..." appended) and speakingAgentId so the | |
| * roundtable bubble stays on the interrupted agent's text. | |
| */ | |
| const doSoftPause = useCallback(async () => { | |
| await chatAreaRef.current?.softPauseActiveSession(); | |
| // Append "..." to live speech to show interruption in roundtable bubble. | |
| // Only annotate when there's actual text being interrupted β during pure | |
| // director-thinking (prev is null, no agent assigned), leave liveSpeech | |
| // as-is so no spurious teacher bubble appears. | |
| setLiveSpeech((prev) => (prev !== null ? prev + '...' : null)); | |
| // Keep speakingAgentId β bubble identity is preserved | |
| setThinkingState(null); | |
| setChatIsStreaming(false); | |
| setIsTopicPending(true); | |
| // Don't clear chatSessionType, speakingAgentId, or liveSpeech | |
| // Don't show end flash | |
| // Don't call handleEndDiscussion β engine stays in current state | |
| }, []); | |
| /** | |
| * 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); | |
| // 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); | |
| }, []); | |
| /** 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]); | |
| /** | |
| * 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); | |
| } | |
| resetLiveState(); | |
| }, [chatSessionType, resetLiveState]); | |
| // Shared stop-discussion handler (used by both Roundtable and Canvas toolbar) | |
| const handleStopDiscussion = useCallback(async () => { | |
| await chatAreaRef.current?.endActiveSession(); | |
| doSessionCleanup(); | |
| }, [doSessionCleanup]); | |
| // 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; | |
| } | |
| // 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); | |
| // 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; | |
| return () => { | |
| if (engineRef.current) { | |
| engineRef.current.stop(); | |
| } | |
| audioPlayer.destroy(); | |
| if (discussionAbortRef.current) { | |
| discussionAbortRef.current.abort(); | |
| } | |
| }; | |
| }, []); | |
| // Sync mute state from settings store to audioPlayer | |
| const ttsMuted = useSettingsStore((s) => s.ttsMuted); | |
| 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 = 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(); | |
| } | |
| } | |
| }; | |
| // previous scene (gated) | |
| const handlePreviousScene = () => { | |
| 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); | |
| } | |
| }; | |
| // next scene (gated) | |
| const handleNextScene = () => { | |
| 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); | |
| } | |
| }; | |
| // get scene information | |
| const isPendingScene = currentSceneId === PENDING_SCENE_ID; | |
| const hasNextPending = generatingOutlines.length > 0; | |
| 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); | |
| }; | |
| // 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 = 80; // Header h-20 = 80px | |
| if (mode === 'playback') { | |
| return `calc(100% - ${headerHeight + 192}px)`; // Header + Roundtable | |
| } | |
| return `calc(100% - ${headerHeight}px)`; | |
| })(); | |
| return ( | |
| <div className="flex-1 flex overflow-hidden bg-gray-50 dark:bg-gray-900"> | |
| {/* 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 */} | |
| <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} | |
| showStopDiscussion={ | |
| engineMode === 'live' || | |
| (chatIsStreaming && (chatSessionType === 'qa' || chatSessionType === 'discussion')) | |
| } | |
| onStopDiscussion={handleStopDiscussion} | |
| hideToolbar={mode === 'playback'} | |
| 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' && ( | |
| <Roundtable | |
| mode={mode} | |
| initialParticipants={participants} | |
| playbackView={playbackView} | |
| currentSpeech={liveSpeech} | |
| lectureSpeech={lectureSpeech} | |
| idleText={firstSpeechText} | |
| playbackCompleted={playbackCompleted} | |
| discussionRequest={discussionRequest} | |
| engineMode={engineMode} | |
| isStreaming={chatIsStreaming} | |
| sessionType={ | |
| chatSessionType === 'qa' | |
| ? 'qa' | |
| : chatSessionType === 'discussion' | |
| ? 'discussion' | |
| : undefined | |
| } | |
| speakingAgentId={speakingAgentId} | |
| speechProgress={speechProgress} | |
| showEndFlash={showEndFlash} | |
| endFlashSessionType={endFlashSessionType} | |
| thinkingState={thinkingState} | |
| isCueUser={isCueUser} | |
| isTopicPending={isTopicPending} | |
| onMessageSend={(msg) => { | |
| // 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={async () => { | |
| // Soft-pause QA/Discussion if streaming (opening input = implicit pause) | |
| if (chatIsStreaming) { | |
| await doSoftPause(); | |
| } | |
| // Also pause playback engine | |
| if (engineRef.current && (engineMode === 'playing' || engineMode === 'live')) { | |
| engineRef.current.pause(); | |
| } | |
| }} | |
| onSoftPause={doSoftPause} | |
| onResumeTopic={doResumeTopic} | |
| onPlayPause={handlePlayPause} | |
| 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} | |
| /> | |
| )} | |
| </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); | |
| }} | |
| onStopSession={doSessionCleanup} | |
| /> | |
| {/* Scene switch confirmation dialog */} | |
| <AlertDialog | |
| open={!!pendingSceneId} | |
| onOpenChange={(open) => { | |
| if (!open) cancelSceneSwitch(); | |
| }} | |
| > | |
| <AlertDialogContent 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> | |
| ); | |
| } | |