stage.tsx 47 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236
  1. 'use client';
  2. import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
  3. import { useStageStore } from '@/lib/store';
  4. import { PENDING_SCENE_ID } from '@/lib/store/stage';
  5. import { useCanvasStore } from '@/lib/store/canvas';
  6. import { useSettingsStore } from '@/lib/store/settings';
  7. import { useI18n } from '@/lib/hooks/use-i18n';
  8. import { SceneSidebar } from './stage/scene-sidebar';
  9. import { Header } from './header';
  10. import { CanvasArea } from '@/components/canvas/canvas-area';
  11. import { Roundtable } from '@/components/roundtable';
  12. import { PlaybackEngine, computePlaybackView } from '@/lib/playback';
  13. import type { EngineMode, TriggerEvent, Effect } from '@/lib/playback';
  14. import { ActionEngine } from '@/lib/action/engine';
  15. import { createAudioPlayer } from '@/lib/utils/audio-player';
  16. import { useDiscussionTTS } from '@/lib/hooks/use-discussion-tts';
  17. import type { AudioIndicatorState } from '@/components/roundtable/audio-indicator';
  18. import type { Action, DiscussionAction, SpeechAction } from '@/lib/types/action';
  19. import { cn } from '@/lib/utils';
  20. // Playback state persistence removed — refresh always starts from the beginning
  21. import { ChatArea, type ChatAreaRef } from '@/components/chat/chat-area';
  22. import { agentsToParticipants, useAgentRegistry } from '@/lib/orchestration/registry/store';
  23. import type { AgentConfig } from '@/lib/orchestration/registry/types';
  24. import {
  25. AlertDialog,
  26. AlertDialogContent,
  27. AlertDialogTitle,
  28. AlertDialogFooter,
  29. AlertDialogAction,
  30. AlertDialogCancel,
  31. } from '@/components/ui/alert-dialog';
  32. import { AlertTriangle } from 'lucide-react';
  33. import { VisuallyHidden } from 'radix-ui';
  34. /**
  35. * Stage Component
  36. *
  37. * The main container for the classroom/course.
  38. * Combines sidebar (scene navigation) and content area (scene viewer).
  39. * Supports two modes: autonomous and playback.
  40. */
  41. export function Stage({
  42. onRetryOutline,
  43. }: {
  44. onRetryOutline?: (outlineId: string) => Promise<void>;
  45. }) {
  46. const { t } = useI18n();
  47. const { mode, getCurrentScene, scenes, currentSceneId, setCurrentSceneId, generatingOutlines } =
  48. useStageStore();
  49. const failedOutlines = useStageStore.use.failedOutlines();
  50. const currentScene = getCurrentScene();
  51. // Layout state from settings store (persisted via localStorage)
  52. const sidebarCollapsed = useSettingsStore((s) => s.sidebarCollapsed);
  53. const setSidebarCollapsed = useSettingsStore((s) => s.setSidebarCollapsed);
  54. const chatAreaWidth = useSettingsStore((s) => s.chatAreaWidth);
  55. const setChatAreaWidth = useSettingsStore((s) => s.setChatAreaWidth);
  56. const chatAreaCollapsed = useSettingsStore((s) => s.chatAreaCollapsed);
  57. const setChatAreaCollapsed = useSettingsStore((s) => s.setChatAreaCollapsed);
  58. const setTTSMuted = useSettingsStore((s) => s.setTTSMuted);
  59. const setTTSVolume = useSettingsStore((s) => s.setTTSVolume);
  60. // PlaybackEngine state
  61. const [engineMode, setEngineMode] = useState<EngineMode>('idle');
  62. const [playbackCompleted, setPlaybackCompleted] = useState(false); // Distinguishes "never played" idle from "finished" idle
  63. const [lectureSpeech, setLectureSpeech] = useState<string | null>(null); // From PlaybackEngine (lecture)
  64. const [liveSpeech, setLiveSpeech] = useState<string | null>(null); // From buffer (discussion/QA)
  65. const [speechProgress, setSpeechProgress] = useState<number | null>(null); // StreamBuffer reveal progress (0–1)
  66. const [discussionTrigger, setDiscussionTrigger] = useState<TriggerEvent | null>(null);
  67. // Speaking agent tracking (Issue 2)
  68. const [speakingAgentId, setSpeakingAgentId] = useState<string | null>(null);
  69. // Thinking state (Issue 5)
  70. const [thinkingState, setThinkingState] = useState<{
  71. stage: string;
  72. agentId?: string;
  73. } | null>(null);
  74. // Cue user state (Issue 7)
  75. const [isCueUser, setIsCueUser] = useState(false);
  76. // End flash state (Issue 3)
  77. const [showEndFlash, setShowEndFlash] = useState(false);
  78. const [endFlashSessionType, setEndFlashSessionType] = useState<'qa' | 'discussion'>('discussion');
  79. // Streaming state for stop button (Issue 1)
  80. const [chatIsStreaming, setChatIsStreaming] = useState(false);
  81. const [chatSessionType, setChatSessionType] = useState<string | null>(null);
  82. // Topic pending state: session is soft-paused, bubble stays visible, waiting for user input
  83. const [isTopicPending, setIsTopicPending] = useState(false);
  84. // Active bubble ID for playback highlight in chat area (Issue 8)
  85. const [activeBubbleId, setActiveBubbleId] = useState<string | null>(null);
  86. // Scene switch confirmation dialog state
  87. const [pendingSceneId, setPendingSceneId] = useState<string | null>(null);
  88. const [isPresenting, setIsPresenting] = useState(false);
  89. const [controlsVisible, setControlsVisible] = useState(true);
  90. const [isPresentationInteractionActive, setIsPresentationInteractionActive] = useState(false);
  91. // Whiteboard state (from canvas store so AI tools can open it)
  92. const whiteboardOpen = useCanvasStore.use.whiteboardOpen();
  93. const setWhiteboardOpen = useCanvasStore.use.setWhiteboardOpen();
  94. // Selected agents from settings store (Zustand)
  95. const selectedAgentIds = useSettingsStore((s) => s.selectedAgentIds);
  96. const ttsMuted = useSettingsStore((s) => s.ttsMuted);
  97. const ttsEnabled = useSettingsStore((s) => s.ttsEnabled);
  98. // Generate participants from selected agents
  99. const participants = useMemo(
  100. () => agentsToParticipants(selectedAgentIds, t),
  101. [selectedAgentIds, t],
  102. );
  103. // Resolved AgentConfig array for hooks that need full agent objects
  104. // Subscribe to the agents record so voiceConfig changes trigger re-resolution
  105. const agentsRecord = useAgentRegistry((s) => s.agents);
  106. const selectedAgents = useMemo(
  107. () => selectedAgentIds.map((id) => agentsRecord[id]).filter((a): a is AgentConfig => a != null),
  108. [agentsRecord, selectedAgentIds],
  109. );
  110. // Discussion TTS: audio indicator state
  111. const [audioIndicatorState, setAudioIndicatorState] = useState<AudioIndicatorState>('idle');
  112. const [audioAgentId, setAudioAgentId] = useState<string | null>(null);
  113. const discussionTTS = useDiscussionTTS({
  114. enabled: ttsEnabled && !ttsMuted,
  115. agents: selectedAgents,
  116. onAudioStateChange: (agentId, state) => {
  117. setAudioAgentId(agentId);
  118. setAudioIndicatorState(state);
  119. },
  120. });
  121. // Pick a student agent for discussion trigger (prioritize student > non-teacher > fallback)
  122. const pickStudentAgent = useCallback((): string => {
  123. const registry = useAgentRegistry.getState();
  124. const agents = selectedAgentIds
  125. .map((id) => registry.getAgent(id))
  126. .filter((a): a is AgentConfig => a != null);
  127. const students = agents.filter((a) => a.role === 'student');
  128. if (students.length > 0) {
  129. return students[Math.floor(Math.random() * students.length)].id;
  130. }
  131. const nonTeachers = agents.filter((a) => a.role !== 'teacher');
  132. if (nonTeachers.length > 0) {
  133. return nonTeachers[Math.floor(Math.random() * nonTeachers.length)].id;
  134. }
  135. return agents[0]?.id || 'default-1';
  136. }, [selectedAgentIds]);
  137. const engineRef = useRef<PlaybackEngine | null>(null);
  138. const audioPlayerRef = useRef(createAudioPlayer());
  139. const chatAreaRef = useRef<ChatAreaRef>(null);
  140. const lectureSessionIdRef = useRef<string | null>(null);
  141. const lectureActionCounterRef = useRef(0);
  142. const discussionAbortRef = useRef<AbortController | null>(null);
  143. const presentationIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  144. const stageRef = useRef<HTMLDivElement>(null);
  145. // Guard to prevent double flash when manual stop triggers onDiscussionEnd
  146. const manualStopRef = useRef(false);
  147. // Monotonic counter incremented on each scene switch — used to discard stale SSE callbacks
  148. const sceneEpochRef = useRef(0);
  149. // When true, the next engine init will auto-start playback (for auto-play scene advance)
  150. const autoStartRef = useRef(false);
  151. // Discussion buffer-level pause state (distinct from soft-pause which aborts SSE)
  152. const [isDiscussionPaused, setIsDiscussionPaused] = useState(false);
  153. /**
  154. * Resume a soft-paused topic: re-call /chat with existing session messages.
  155. * The director picks the next agent to continue.
  156. */
  157. const doResumeTopic = useCallback(async () => {
  158. // Clear old bubble immediately — no lingering on interrupted text
  159. setIsTopicPending(false);
  160. setLiveSpeech(null);
  161. setSpeakingAgentId(null);
  162. setThinkingState({ stage: 'director' });
  163. setChatIsStreaming(true);
  164. // Transition engine back to live — onInputActivate paused it when soft-pausing,
  165. // so we must explicitly resume to keep engine mode in sync with the chat loop.
  166. engineRef.current?.resume();
  167. // Fire new chat round — SSE events will drive thinking → agent_start → speech
  168. await chatAreaRef.current?.resumeActiveSession();
  169. }, []);
  170. /** Reset all live/discussion state (shared by doSessionCleanup & onDiscussionEnd) */
  171. const resetLiveState = useCallback(() => {
  172. setLiveSpeech(null);
  173. setSpeakingAgentId(null);
  174. setSpeechProgress(null);
  175. setThinkingState(null);
  176. setIsCueUser(false);
  177. setIsTopicPending(false);
  178. setChatIsStreaming(false);
  179. setChatSessionType(null);
  180. setIsDiscussionPaused(false);
  181. }, []);
  182. /** Full scene reset (scene switch) — resetLiveState + lecture/visual state */
  183. const resetSceneState = useCallback(() => {
  184. resetLiveState();
  185. setPlaybackCompleted(false);
  186. setLectureSpeech(null);
  187. setSpeechProgress(null);
  188. setShowEndFlash(false);
  189. setActiveBubbleId(null);
  190. setDiscussionTrigger(null);
  191. }, [resetLiveState]);
  192. /** Request failure should exit live discussion UI without hard-closing the session. */
  193. const handleLiveSessionError = useCallback(() => {
  194. engineRef.current?.handleDiscussionError();
  195. resetLiveState();
  196. setActiveBubbleId(null);
  197. }, [resetLiveState]);
  198. /**
  199. * Unified session cleanup — called by both roundtable stop button and chat area end button.
  200. * Handles: engine transition, flash, roundtable state clearing.
  201. */
  202. const doSessionCleanup = useCallback(() => {
  203. const activeType = chatSessionType;
  204. // Engine cleanup — guard to avoid double flash from onDiscussionEnd
  205. manualStopRef.current = true;
  206. engineRef.current?.handleEndDiscussion();
  207. manualStopRef.current = false;
  208. // Show end flash with correct session type
  209. if (activeType === 'qa' || activeType === 'discussion') {
  210. setEndFlashSessionType(activeType);
  211. setShowEndFlash(true);
  212. setTimeout(() => setShowEndFlash(false), 1800);
  213. }
  214. // Stop any in-flight discussion TTS audio
  215. discussionTTS.cleanup();
  216. resetLiveState();
  217. }, [chatSessionType, resetLiveState, discussionTTS]);
  218. // Shared stop-discussion handler (used by both Roundtable and Canvas toolbar)
  219. const handleStopDiscussion = useCallback(async () => {
  220. await chatAreaRef.current?.endActiveSession();
  221. doSessionCleanup();
  222. }, [doSessionCleanup]);
  223. const clearPresentationIdleTimer = useCallback(() => {
  224. if (presentationIdleTimerRef.current) {
  225. clearTimeout(presentationIdleTimerRef.current);
  226. presentationIdleTimerRef.current = null;
  227. }
  228. }, []);
  229. const resetPresentationIdleTimer = useCallback(() => {
  230. setControlsVisible(true);
  231. clearPresentationIdleTimer();
  232. if (isPresenting && !isPresentationInteractionActive) {
  233. presentationIdleTimerRef.current = setTimeout(() => {
  234. setControlsVisible(false);
  235. }, 3000);
  236. }
  237. }, [clearPresentationIdleTimer, isPresenting, isPresentationInteractionActive]);
  238. const togglePresentation = useCallback(async () => {
  239. const stageElement = stageRef.current;
  240. if (!stageElement) return;
  241. try {
  242. if (document.fullscreenElement === stageElement) {
  243. // Unlock Escape key before exiting fullscreen
  244. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  245. (navigator as any).keyboard?.unlock?.();
  246. await document.exitFullscreen();
  247. return;
  248. }
  249. setControlsVisible(true);
  250. await stageElement.requestFullscreen();
  251. // Lock Escape key so it doesn't auto-exit fullscreen (#255)
  252. // Escape is handled manually in our keydown handler instead
  253. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  254. await (navigator as any).keyboard?.lock?.(['Escape']).catch(() => {});
  255. setSidebarCollapsed(true);
  256. setChatAreaCollapsed(true);
  257. } catch {
  258. // Firefox may deny fullscreen from certain keyboard events (e.g. F11)
  259. console.warn('[Presentation] Fullscreen request denied — browser policy');
  260. }
  261. }, [setChatAreaCollapsed, setSidebarCollapsed]);
  262. useEffect(() => {
  263. const onFullscreenChange = () => {
  264. const active = document.fullscreenElement === stageRef.current;
  265. setIsPresenting(active);
  266. if (!active) {
  267. // Ensure keyboard unlock on any fullscreen exit
  268. // eslint-disable-next-line @typescript-eslint/no-explicit-any
  269. (navigator as any).keyboard?.unlock?.();
  270. setControlsVisible(true);
  271. clearPresentationIdleTimer();
  272. }
  273. };
  274. document.addEventListener('fullscreenchange', onFullscreenChange);
  275. return () => document.removeEventListener('fullscreenchange', onFullscreenChange);
  276. }, [clearPresentationIdleTimer]);
  277. useEffect(() => {
  278. if (!isPresenting) {
  279. setControlsVisible(true);
  280. clearPresentationIdleTimer();
  281. return;
  282. }
  283. const handleActivity = () => {
  284. resetPresentationIdleTimer();
  285. };
  286. window.addEventListener('mousemove', handleActivity);
  287. window.addEventListener('mousedown', handleActivity);
  288. window.addEventListener('touchstart', handleActivity);
  289. if (isPresentationInteractionActive) {
  290. setControlsVisible(true);
  291. clearPresentationIdleTimer();
  292. } else {
  293. resetPresentationIdleTimer();
  294. }
  295. return () => {
  296. window.removeEventListener('mousemove', handleActivity);
  297. window.removeEventListener('mousedown', handleActivity);
  298. window.removeEventListener('touchstart', handleActivity);
  299. clearPresentationIdleTimer();
  300. };
  301. }, [
  302. clearPresentationIdleTimer,
  303. isPresenting,
  304. isPresentationInteractionActive,
  305. resetPresentationIdleTimer,
  306. ]);
  307. // Initialize playback engine when scene changes
  308. useEffect(() => {
  309. // Bump epoch so any stale SSE callbacks from the previous scene are discarded
  310. sceneEpochRef.current++;
  311. // End any active QA/discussion session — this synchronously aborts the SSE
  312. // stream inside use-chat-sessions (abortControllerRef.abort()), preventing
  313. // stale onLiveSpeech callbacks from leaking into the new scene.
  314. chatAreaRef.current?.endActiveSession();
  315. // Also abort the engine-level discussion controller
  316. if (discussionAbortRef.current) {
  317. discussionAbortRef.current.abort();
  318. discussionAbortRef.current = null;
  319. }
  320. // Stop any in-flight discussion TTS audio on scene switch
  321. discussionTTS.cleanup();
  322. // Reset all roundtable/live state so scenes are fully isolated
  323. resetSceneState();
  324. if (!currentScene || !currentScene.actions || currentScene.actions.length === 0) {
  325. engineRef.current = null;
  326. setEngineMode('idle');
  327. return;
  328. }
  329. // Stop previous engine
  330. if (engineRef.current) {
  331. engineRef.current.stop();
  332. }
  333. // Create ActionEngine for playback (with audioPlayer for TTS)
  334. const actionEngine = new ActionEngine(useStageStore, audioPlayerRef.current);
  335. // Create new PlaybackEngine
  336. const engine = new PlaybackEngine([currentScene], actionEngine, audioPlayerRef.current, {
  337. onModeChange: (mode) => {
  338. setEngineMode(mode);
  339. },
  340. onSceneChange: (_sceneId) => {
  341. // Scene change handled by engine
  342. },
  343. onSpeechStart: (text) => {
  344. setLectureSpeech(text);
  345. // Add to lecture session with incrementing index for dedup
  346. // Chat area pacing is handled by the StreamBuffer (onTextReveal)
  347. if (lectureSessionIdRef.current) {
  348. const idx = lectureActionCounterRef.current++;
  349. const speechId = `speech-${Date.now()}`;
  350. chatAreaRef.current?.addLectureMessage(
  351. lectureSessionIdRef.current,
  352. { id: speechId, type: 'speech', text } as Action,
  353. idx,
  354. );
  355. // Track active bubble for highlight (Issue 8)
  356. const msgId = chatAreaRef.current?.getLectureMessageId(lectureSessionIdRef.current!);
  357. if (msgId) setActiveBubbleId(msgId);
  358. }
  359. },
  360. onSpeechEnd: () => {
  361. // Don't clear lectureSpeech — let it persist until the next
  362. // onSpeechStart replaces it or the scene transitions.
  363. // Clearing here causes fallback to idleText (first sentence).
  364. setActiveBubbleId(null);
  365. },
  366. onEffectFire: (effect: Effect) => {
  367. // Add to lecture session with incrementing index
  368. if (
  369. lectureSessionIdRef.current &&
  370. (effect.kind === 'spotlight' || effect.kind === 'laser')
  371. ) {
  372. const idx = lectureActionCounterRef.current++;
  373. chatAreaRef.current?.addLectureMessage(
  374. lectureSessionIdRef.current,
  375. {
  376. id: `${effect.kind}-${Date.now()}`,
  377. type: effect.kind,
  378. elementId: effect.targetId,
  379. } as Action,
  380. idx,
  381. );
  382. }
  383. },
  384. onProactiveShow: (trigger) => {
  385. if (!trigger.agentId) {
  386. // Mutate in-place so engine.currentTrigger also gets the agentId
  387. // (confirmDiscussion reads agentId from the same object reference)
  388. trigger.agentId = pickStudentAgent();
  389. }
  390. setDiscussionTrigger(trigger);
  391. },
  392. onProactiveHide: () => {
  393. setDiscussionTrigger(null);
  394. },
  395. onDiscussionConfirmed: (topic, prompt, agentId) => {
  396. // Start SSE discussion via ChatArea
  397. handleDiscussionSSE(topic, prompt, agentId);
  398. },
  399. onDiscussionEnd: () => {
  400. // Abort any active SSE
  401. if (discussionAbortRef.current) {
  402. discussionAbortRef.current.abort();
  403. discussionAbortRef.current = null;
  404. }
  405. setDiscussionTrigger(null);
  406. // Stop any in-flight discussion TTS audio
  407. discussionTTS.cleanup();
  408. // Clear roundtable state (idempotent — may already be cleared by doSessionCleanup)
  409. resetLiveState();
  410. // Only show flash for engine-initiated ends (not manual stop — that's handled by doSessionCleanup)
  411. if (!manualStopRef.current) {
  412. setEndFlashSessionType('discussion');
  413. setShowEndFlash(true);
  414. setTimeout(() => setShowEndFlash(false), 1800);
  415. }
  416. // If all actions are exhausted (discussion was the last action), mark
  417. // playback as completed so the bubble shows reset instead of play.
  418. if (engineRef.current?.isExhausted()) {
  419. setPlaybackCompleted(true);
  420. }
  421. },
  422. onUserInterrupt: (text) => {
  423. // User interrupted → start a discussion via chat
  424. chatAreaRef.current?.sendMessage(text);
  425. },
  426. isAgentSelected: (agentId) => {
  427. const ids = useSettingsStore.getState().selectedAgentIds;
  428. return ids.includes(agentId);
  429. },
  430. getPlaybackSpeed: () => useSettingsStore.getState().playbackSpeed || 1,
  431. onComplete: () => {
  432. // lectureSpeech intentionally NOT cleared — last sentence stays visible
  433. // until scene transition (auto-play) or user restarts. Scene change
  434. // effect handles the reset.
  435. setPlaybackCompleted(true);
  436. // End lecture session on playback complete
  437. if (lectureSessionIdRef.current) {
  438. chatAreaRef.current?.endSession(lectureSessionIdRef.current);
  439. lectureSessionIdRef.current = null;
  440. }
  441. // Auto-play: advance to next scene after a short pause
  442. const { autoPlayLecture } = useSettingsStore.getState();
  443. if (autoPlayLecture) {
  444. setTimeout(() => {
  445. const stageState = useStageStore.getState();
  446. if (!useSettingsStore.getState().autoPlayLecture) return;
  447. const allScenes = stageState.scenes;
  448. const curId = stageState.currentSceneId;
  449. const idx = allScenes.findIndex((s) => s.id === curId);
  450. if (idx >= 0 && idx < allScenes.length - 1) {
  451. const currentScene = allScenes[idx];
  452. if (
  453. currentScene.type === 'quiz' ||
  454. currentScene.type === 'interactive' ||
  455. currentScene.type === 'pbl'
  456. ) {
  457. return;
  458. }
  459. autoStartRef.current = true;
  460. stageState.setCurrentSceneId(allScenes[idx + 1].id);
  461. } else if (idx === allScenes.length - 1 && stageState.generatingOutlines.length > 0) {
  462. // Last scene exhausted but next is still generating — go to pending page
  463. const currentScene = allScenes[idx];
  464. if (
  465. currentScene.type === 'quiz' ||
  466. currentScene.type === 'interactive' ||
  467. currentScene.type === 'pbl'
  468. ) {
  469. return;
  470. }
  471. autoStartRef.current = true;
  472. stageState.setCurrentSceneId(PENDING_SCENE_ID);
  473. }
  474. }, 1500);
  475. }
  476. },
  477. });
  478. engineRef.current = engine;
  479. // Auto-start if triggered by auto-play scene advance
  480. if (autoStartRef.current) {
  481. autoStartRef.current = false;
  482. (async () => {
  483. if (currentScene && chatAreaRef.current) {
  484. const sessionId = await chatAreaRef.current.startLecture(currentScene.id);
  485. lectureSessionIdRef.current = sessionId;
  486. lectureActionCounterRef.current = 0;
  487. }
  488. engine.start();
  489. })();
  490. } else {
  491. // Load saved playback state and restore position (but never auto-play).
  492. }
  493. // eslint-disable-next-line react-hooks/exhaustive-deps -- Only re-run when scene changes, functions are stable refs
  494. }, [currentScene]);
  495. // Cleanup on unmount
  496. useEffect(() => {
  497. const audioPlayer = audioPlayerRef.current;
  498. const chatArea = chatAreaRef.current;
  499. return () => {
  500. if (engineRef.current) {
  501. engineRef.current.stop();
  502. }
  503. audioPlayer.destroy();
  504. if (discussionAbortRef.current) {
  505. discussionAbortRef.current.abort();
  506. }
  507. discussionTTS.cleanup();
  508. chatArea?.endActiveSession();
  509. clearPresentationIdleTimer();
  510. };
  511. // eslint-disable-next-line react-hooks/exhaustive-deps -- unmount-only cleanup, clearPresentationIdleTimer is stable
  512. }, []);
  513. // Sync mute state from settings store to audioPlayer
  514. useEffect(() => {
  515. audioPlayerRef.current.setMuted(ttsMuted);
  516. }, [ttsMuted]);
  517. // Sync volume from settings store to audioPlayer
  518. const ttsVolume = useSettingsStore((s) => s.ttsVolume);
  519. useEffect(() => {
  520. if (!ttsMuted) {
  521. audioPlayerRef.current.setVolume(ttsVolume);
  522. }
  523. }, [ttsVolume, ttsMuted]);
  524. // Sync playback speed to audio player (for live-updating current audio)
  525. const playbackSpeed = useSettingsStore((s) => s.playbackSpeed);
  526. useEffect(() => {
  527. audioPlayerRef.current.setPlaybackRate(playbackSpeed);
  528. }, [playbackSpeed]);
  529. /**
  530. * Handle discussion SSE — POST /api/chat and push events to engine
  531. */
  532. const handleDiscussionSSE = useCallback(
  533. async (topic: string, prompt?: string, agentId?: string) => {
  534. // Start discussion display in ChatArea (lecture speech is preserved independently)
  535. chatAreaRef.current?.startDiscussion({
  536. topic,
  537. prompt,
  538. agentId: agentId || 'default-1',
  539. });
  540. // Auto-switch to chat tab when discussion starts
  541. chatAreaRef.current?.switchToTab('chat');
  542. // Immediately mark streaming for synchronized stop button
  543. setChatIsStreaming(true);
  544. setChatSessionType('discussion');
  545. // Optimistic thinking: show thinking dots immediately (same as onMessageSend)
  546. setThinkingState({ stage: 'director' });
  547. },
  548. [],
  549. );
  550. // First speech text for idle display (extracted here for playbackView)
  551. const firstSpeechText = useMemo(
  552. () => currentScene?.actions?.find((a): a is SpeechAction => a.type === 'speech')?.text ?? null,
  553. [currentScene],
  554. );
  555. // Whether the speaking agent is a student (for bubble role derivation)
  556. const speakingStudentFlag = useMemo(() => {
  557. if (!speakingAgentId) return false;
  558. const agent = useAgentRegistry.getState().getAgent(speakingAgentId);
  559. return agent?.role !== 'teacher';
  560. }, [speakingAgentId]);
  561. // Centralised derived playback view
  562. const playbackView = useMemo(
  563. () =>
  564. computePlaybackView({
  565. engineMode,
  566. lectureSpeech,
  567. liveSpeech,
  568. speakingAgentId,
  569. thinkingState,
  570. isCueUser,
  571. isTopicPending,
  572. chatIsStreaming,
  573. discussionTrigger,
  574. playbackCompleted,
  575. idleText: firstSpeechText,
  576. speakingStudent: speakingStudentFlag,
  577. sessionType: chatSessionType,
  578. }),
  579. [
  580. engineMode,
  581. lectureSpeech,
  582. liveSpeech,
  583. speakingAgentId,
  584. thinkingState,
  585. isCueUser,
  586. isTopicPending,
  587. chatIsStreaming,
  588. discussionTrigger,
  589. playbackCompleted,
  590. firstSpeechText,
  591. speakingStudentFlag,
  592. chatSessionType,
  593. ],
  594. );
  595. const isTopicActive = playbackView.isTopicActive;
  596. /**
  597. * Gated scene switch — if a topic is active, show AlertDialog before switching.
  598. * Returns true if the switch was immediate, false if gated (dialog shown).
  599. */
  600. const gatedSceneSwitch = useCallback(
  601. (targetSceneId: string): boolean => {
  602. if (targetSceneId === currentSceneId) return false;
  603. if (isTopicActive) {
  604. setPendingSceneId(targetSceneId);
  605. return false;
  606. }
  607. setCurrentSceneId(targetSceneId);
  608. return true;
  609. },
  610. [currentSceneId, isTopicActive, setCurrentSceneId],
  611. );
  612. /** User confirmed scene switch via AlertDialog */
  613. const confirmSceneSwitch = useCallback(() => {
  614. if (!pendingSceneId) return;
  615. chatAreaRef.current?.endActiveSession();
  616. doSessionCleanup();
  617. setCurrentSceneId(pendingSceneId);
  618. setPendingSceneId(null);
  619. }, [pendingSceneId, setCurrentSceneId, doSessionCleanup]);
  620. /** User cancelled scene switch via AlertDialog */
  621. const cancelSceneSwitch = useCallback(() => {
  622. setPendingSceneId(null);
  623. }, []);
  624. // play/pause toggle
  625. const handlePlayPause = useCallback(async () => {
  626. const engine = engineRef.current;
  627. if (!engine) return;
  628. const mode = engine.getMode();
  629. if (mode === 'playing' || mode === 'live') {
  630. engine.pause();
  631. // Pause lecture buffer so text stops immediately
  632. if (lectureSessionIdRef.current) {
  633. chatAreaRef.current?.pauseBuffer(lectureSessionIdRef.current);
  634. }
  635. } else if (mode === 'paused') {
  636. engine.resume();
  637. // Resume lecture buffer
  638. if (lectureSessionIdRef.current) {
  639. chatAreaRef.current?.resumeBuffer(lectureSessionIdRef.current);
  640. }
  641. } else {
  642. const wasCompleted = playbackCompleted;
  643. setPlaybackCompleted(false);
  644. // Starting playback - create/reuse lecture session
  645. if (currentScene && chatAreaRef.current) {
  646. const sessionId = await chatAreaRef.current.startLecture(currentScene.id);
  647. lectureSessionIdRef.current = sessionId;
  648. }
  649. if (wasCompleted) {
  650. // Restart from beginning (user clicked restart after completion)
  651. lectureActionCounterRef.current = 0;
  652. engine.start();
  653. } else {
  654. // Continue from current position (e.g. after discussion end)
  655. engine.continuePlayback();
  656. }
  657. }
  658. }, [playbackCompleted, currentScene]);
  659. // get scene information
  660. const isPendingScene = currentSceneId === PENDING_SCENE_ID;
  661. const hasNextPending = generatingOutlines.length > 0;
  662. // previous scene (gated)
  663. const handlePreviousScene = useCallback(() => {
  664. if (isPendingScene) {
  665. // From pending page → go to last real scene
  666. if (scenes.length > 0) {
  667. gatedSceneSwitch(scenes[scenes.length - 1].id);
  668. }
  669. return;
  670. }
  671. const currentIndex = scenes.findIndex((s) => s.id === currentSceneId);
  672. if (currentIndex > 0) {
  673. gatedSceneSwitch(scenes[currentIndex - 1].id);
  674. }
  675. }, [currentSceneId, gatedSceneSwitch, isPendingScene, scenes]);
  676. // next scene (gated)
  677. const handleNextScene = useCallback(() => {
  678. if (isPendingScene) return; // Already on pending, nowhere to go
  679. const currentIndex = scenes.findIndex((s) => s.id === currentSceneId);
  680. if (currentIndex < scenes.length - 1) {
  681. gatedSceneSwitch(scenes[currentIndex + 1].id);
  682. } else if (hasNextPending) {
  683. // On last real scene → advance to pending page
  684. setCurrentSceneId(PENDING_SCENE_ID);
  685. }
  686. }, [currentSceneId, gatedSceneSwitch, hasNextPending, isPendingScene, scenes, setCurrentSceneId]);
  687. const currentSceneIndex = isPendingScene
  688. ? scenes.length
  689. : scenes.findIndex((s) => s.id === currentSceneId);
  690. const totalScenesCount = scenes.length + (hasNextPending ? 1 : 0);
  691. // get action information
  692. const totalActions = currentScene?.actions?.length || 0;
  693. // whiteboard toggle
  694. const handleWhiteboardToggle = () => {
  695. setWhiteboardOpen(!whiteboardOpen);
  696. };
  697. const isPresentationShortcutTarget = useCallback((target: EventTarget | null) => {
  698. if (!(target instanceof HTMLElement)) return false;
  699. if (target.isContentEditable || target.closest('[contenteditable="true"]')) {
  700. return true;
  701. }
  702. return (
  703. target.closest(
  704. ['input', 'textarea', 'select', '[role="slider"]', 'input[type="range"]'].join(', '),
  705. ) !== null
  706. );
  707. }, []);
  708. useEffect(() => {
  709. const onKeyDown = (event: KeyboardEvent) => {
  710. if (event.defaultPrevented) return;
  711. // Let modifier-key combos (Ctrl+C, Ctrl+S, etc.) pass through to the browser
  712. if (event.ctrlKey || event.metaKey || event.altKey) return;
  713. if (
  714. isPresentationShortcutTarget(event.target) ||
  715. isPresentationShortcutTarget(document.activeElement)
  716. ) {
  717. return;
  718. }
  719. switch (event.key) {
  720. case 'ArrowLeft':
  721. if (!isPresenting) return;
  722. event.preventDefault();
  723. handlePreviousScene();
  724. resetPresentationIdleTimer();
  725. break;
  726. case 'ArrowRight':
  727. if (!isPresenting) return;
  728. event.preventDefault();
  729. handleNextScene();
  730. resetPresentationIdleTimer();
  731. break;
  732. case ' ':
  733. case 'Spacebar':
  734. // During active QA/discussion, Roundtable owns Space for
  735. // buffer-level pause/resume — don't also fire engine play/pause.
  736. if (chatSessionType === 'qa' || chatSessionType === 'discussion') break;
  737. event.preventDefault();
  738. handlePlayPause();
  739. break;
  740. case 'Escape':
  741. // With keyboard.lock(), Escape no longer auto-exits fullscreen.
  742. // If panels are open, roundtable handles Escape (close panels).
  743. // If no panels are open, manually exit fullscreen.
  744. if (isPresenting && !isPresentationInteractionActive) {
  745. event.preventDefault();
  746. togglePresentation();
  747. }
  748. break;
  749. case 'ArrowUp':
  750. event.preventDefault();
  751. setTTSVolume(ttsVolume + 0.1);
  752. break;
  753. case 'ArrowDown':
  754. event.preventDefault();
  755. setTTSVolume(ttsVolume - 0.1);
  756. break;
  757. case 'm':
  758. case 'M':
  759. event.preventDefault();
  760. setTTSMuted(!ttsMuted);
  761. break;
  762. case 's':
  763. case 'S':
  764. event.preventDefault();
  765. setSidebarCollapsed(!sidebarCollapsed);
  766. break;
  767. case 'c':
  768. case 'C':
  769. event.preventDefault();
  770. setChatAreaCollapsed(!chatAreaCollapsed);
  771. break;
  772. default:
  773. break;
  774. }
  775. };
  776. window.addEventListener('keydown', onKeyDown);
  777. return () => window.removeEventListener('keydown', onKeyDown);
  778. }, [
  779. chatSessionType,
  780. chatAreaCollapsed,
  781. handleNextScene,
  782. handlePlayPause,
  783. handlePreviousScene,
  784. isPresenting,
  785. isPresentationInteractionActive,
  786. isPresentationShortcutTarget,
  787. resetPresentationIdleTimer,
  788. setChatAreaCollapsed,
  789. setSidebarCollapsed,
  790. setTTSMuted,
  791. setTTSVolume,
  792. sidebarCollapsed,
  793. togglePresentation,
  794. ttsMuted,
  795. ttsVolume,
  796. ]);
  797. // Intercept F11 to use our presentation fullscreen instead of browser fullscreen
  798. // This way ESC can exit fullscreen (browser F11 fullscreen requires F11 to exit)
  799. useEffect(() => {
  800. const onF11 = (event: KeyboardEvent) => {
  801. if (event.key === 'F11') {
  802. event.preventDefault();
  803. togglePresentation();
  804. }
  805. };
  806. window.addEventListener('keydown', onF11);
  807. return () => window.removeEventListener('keydown', onF11);
  808. }, [togglePresentation]);
  809. // Map engine mode to the CanvasArea's expected engine state
  810. const canvasEngineState = (() => {
  811. switch (engineMode) {
  812. case 'playing':
  813. case 'live':
  814. return 'playing';
  815. case 'paused':
  816. return 'paused';
  817. default:
  818. return 'idle';
  819. }
  820. })();
  821. // Build discussion request for Roundtable ProactiveCard from trigger
  822. const discussionRequest: DiscussionAction | null = discussionTrigger
  823. ? {
  824. type: 'discussion',
  825. id: discussionTrigger.id,
  826. topic: discussionTrigger.question,
  827. prompt: discussionTrigger.prompt,
  828. agentId: discussionTrigger.agentId || 'default-1',
  829. }
  830. : null;
  831. // Calculate scene viewer height (subtract Header's 80px height)
  832. const sceneViewerHeight = (() => {
  833. const headerHeight = isPresenting ? 0 : 80; // Header h-20 = 80px
  834. const roundtableHeight = mode === 'playback' && !isPresenting ? 192 : 0;
  835. return `calc(100% - ${headerHeight + roundtableHeight}px)`;
  836. })();
  837. return (
  838. <div
  839. ref={stageRef}
  840. className={cn(
  841. 'flex-1 flex overflow-hidden bg-gray-50 dark:bg-gray-900',
  842. isPresenting && !controlsVisible && 'cursor-none',
  843. )}
  844. >
  845. {/* Scene Sidebar */}
  846. <SceneSidebar
  847. collapsed={sidebarCollapsed}
  848. onCollapseChange={setSidebarCollapsed}
  849. onSceneSelect={gatedSceneSwitch}
  850. onRetryOutline={onRetryOutline}
  851. />
  852. {/* Main Content Area */}
  853. <div className="flex-1 flex flex-col overflow-hidden min-w-0 relative">
  854. {/* Header */}
  855. {!isPresenting && <Header currentSceneTitle={currentScene?.title || ''} />}
  856. {/* Canvas Area */}
  857. <div
  858. className="overflow-hidden relative flex-1 min-h-0 isolate"
  859. style={{
  860. height: sceneViewerHeight,
  861. }}
  862. suppressHydrationWarning
  863. >
  864. <CanvasArea
  865. currentScene={currentScene}
  866. currentSceneIndex={currentSceneIndex}
  867. scenesCount={totalScenesCount}
  868. mode={mode}
  869. engineState={canvasEngineState}
  870. isLiveSession={
  871. chatIsStreaming || isTopicPending || engineMode === 'live' || !!chatSessionType
  872. }
  873. whiteboardOpen={whiteboardOpen}
  874. sidebarCollapsed={sidebarCollapsed}
  875. chatCollapsed={chatAreaCollapsed}
  876. onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
  877. onToggleChat={() => setChatAreaCollapsed(!chatAreaCollapsed)}
  878. onPrevSlide={handlePreviousScene}
  879. onNextSlide={handleNextScene}
  880. onPlayPause={handlePlayPause}
  881. onWhiteboardClose={handleWhiteboardToggle}
  882. isPresenting={isPresenting}
  883. onTogglePresentation={togglePresentation}
  884. showStopDiscussion={
  885. engineMode === 'live' ||
  886. (chatIsStreaming && (chatSessionType === 'qa' || chatSessionType === 'discussion'))
  887. }
  888. onStopDiscussion={handleStopDiscussion}
  889. hideToolbar={mode === 'playback' || (isPresenting && !controlsVisible)}
  890. isPendingScene={isPendingScene}
  891. isGenerationFailed={
  892. isPendingScene && failedOutlines.some((f) => f.id === generatingOutlines[0]?.id)
  893. }
  894. onRetryGeneration={
  895. onRetryOutline && generatingOutlines[0]
  896. ? () => onRetryOutline(generatingOutlines[0].id)
  897. : undefined
  898. }
  899. />
  900. </div>
  901. {/* Roundtable Area */}
  902. {mode === 'playback' && (
  903. <div
  904. className={cn(
  905. 'transition-opacity duration-300',
  906. !isPresenting && 'shrink-0',
  907. isPresenting && 'absolute inset-x-0 bottom-0 z-20',
  908. )}
  909. >
  910. <Roundtable
  911. mode={mode}
  912. initialParticipants={participants}
  913. playbackView={playbackView}
  914. currentSpeech={liveSpeech}
  915. lectureSpeech={lectureSpeech}
  916. idleText={firstSpeechText}
  917. playbackCompleted={playbackCompleted}
  918. discussionRequest={discussionRequest}
  919. engineMode={engineMode}
  920. isStreaming={chatIsStreaming}
  921. audioIndicatorState={audioIndicatorState}
  922. audioAgentId={audioAgentId}
  923. sessionType={
  924. chatSessionType === 'qa'
  925. ? 'qa'
  926. : chatSessionType === 'discussion'
  927. ? 'discussion'
  928. : undefined
  929. }
  930. speakingAgentId={speakingAgentId}
  931. speechProgress={speechProgress}
  932. showEndFlash={showEndFlash}
  933. endFlashSessionType={endFlashSessionType}
  934. thinkingState={thinkingState}
  935. isCueUser={isCueUser}
  936. isTopicPending={isTopicPending}
  937. onMessageSend={async (msg) => {
  938. // Always clear Level-1 pause state — the closure may hold a stale
  939. // isDiscussionPaused value (e.g. voice input's onTranscription callback
  940. // captures onMessageSend before React re-renders with the updated state).
  941. setIsDiscussionPaused(false);
  942. // Clear the sticky livePausedRef so the next agent-loop buffer
  943. // starts unpaused. (pauseActiveLiveBuffer sets a ref that new
  944. // buffers inherit — must be cleared before sendMessage creates one.)
  945. chatAreaRef.current?.resumeActiveLiveBuffer();
  946. // Flush any buffered / in-flight TTS audio from the previous
  947. // agent turn so it doesn't leak into the next round.
  948. discussionTTS.cleanup();
  949. // Clear soft-paused state — user is continuing the topic
  950. if (isTopicPending) {
  951. setIsTopicPending(false);
  952. setLiveSpeech(null);
  953. setSpeakingAgentId(null);
  954. }
  955. // User interrupts during playback — handleUserInterrupt triggers
  956. // onUserInterrupt callback which already calls sendMessage, so skip
  957. // the direct sendMessage below to avoid sending twice.
  958. // Include 'paused' because onInputActivate pauses the engine before
  959. // the user finishes typing — without this the interrupt position
  960. // would never be saved and resuming after QA skips to the next sentence.
  961. if (
  962. engineRef.current &&
  963. (engineMode === 'playing' || engineMode === 'live' || engineMode === 'paused')
  964. ) {
  965. engineRef.current.handleUserInterrupt(msg);
  966. } else {
  967. chatAreaRef.current?.sendMessage(msg);
  968. }
  969. // Auto-switch to chat tab when user sends a message
  970. chatAreaRef.current?.switchToTab('chat');
  971. setIsCueUser(false);
  972. // Immediately mark streaming for synchronized stop button
  973. setChatIsStreaming(true);
  974. setChatSessionType(chatSessionType || 'qa');
  975. // Optimistic thinking: show thinking dots immediately so there's
  976. // no blank gap between userMessage expiry and the SSE thinking event.
  977. // The real SSE event will overwrite this with the same or updated value.
  978. setThinkingState({ stage: 'director' });
  979. }}
  980. onDiscussionStart={() => {
  981. // User clicks "Join" on ProactiveCard
  982. engineRef.current?.confirmDiscussion();
  983. }}
  984. onDiscussionSkip={() => {
  985. // User clicks "Skip" on ProactiveCard
  986. engineRef.current?.skipDiscussion();
  987. }}
  988. onStopDiscussion={handleStopDiscussion}
  989. onInputActivate={() => {
  990. // Level-1 pause: freeze buffer tick + TTS audio while SSE keeps buffering.
  991. // User resumes manually via Space / pause button after closing the input.
  992. // No isDiscussionPaused guard — always attempt to pause the buffer.
  993. // The return value ensures UI state stays in sync with buffer state.
  994. if (chatSessionType === 'qa' || chatSessionType === 'discussion') {
  995. const paused = chatAreaRef.current?.pauseActiveLiveBuffer();
  996. if (paused) {
  997. discussionTTS.pause();
  998. setIsDiscussionPaused(true);
  999. }
  1000. }
  1001. // Also pause playback engine
  1002. if (engineRef.current && (engineMode === 'playing' || engineMode === 'live')) {
  1003. engineRef.current.pause();
  1004. }
  1005. }}
  1006. onResumeTopic={doResumeTopic}
  1007. onPlayPause={handlePlayPause}
  1008. isDiscussionPaused={isDiscussionPaused}
  1009. onDiscussionPause={() => {
  1010. const paused = chatAreaRef.current?.pauseActiveLiveBuffer();
  1011. if (paused) {
  1012. discussionTTS.pause();
  1013. setIsDiscussionPaused(true);
  1014. }
  1015. }}
  1016. onDiscussionResume={() => {
  1017. chatAreaRef.current?.resumeActiveLiveBuffer();
  1018. discussionTTS.resume();
  1019. setIsDiscussionPaused(false);
  1020. }}
  1021. totalActions={totalActions}
  1022. currentActionIndex={0}
  1023. currentSceneIndex={currentSceneIndex}
  1024. scenesCount={totalScenesCount}
  1025. whiteboardOpen={whiteboardOpen}
  1026. sidebarCollapsed={sidebarCollapsed}
  1027. chatCollapsed={chatAreaCollapsed}
  1028. onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
  1029. onToggleChat={() => setChatAreaCollapsed(!chatAreaCollapsed)}
  1030. onPrevSlide={handlePreviousScene}
  1031. onNextSlide={handleNextScene}
  1032. onWhiteboardClose={handleWhiteboardToggle}
  1033. isPresenting={isPresenting}
  1034. controlsVisible={controlsVisible}
  1035. onTogglePresentation={togglePresentation}
  1036. onPresentationInteractionChange={setIsPresentationInteractionActive}
  1037. fullscreenContainerRef={stageRef}
  1038. />
  1039. </div>
  1040. )}
  1041. </div>
  1042. {/* Chat Area */}
  1043. <ChatArea
  1044. ref={chatAreaRef}
  1045. width={chatAreaWidth}
  1046. onWidthChange={setChatAreaWidth}
  1047. collapsed={chatAreaCollapsed}
  1048. onCollapseChange={setChatAreaCollapsed}
  1049. activeBubbleId={activeBubbleId}
  1050. onActiveBubble={(id) => setActiveBubbleId(id)}
  1051. currentSceneId={currentSceneId}
  1052. onLiveSpeech={(text, agentId) => {
  1053. // Capture epoch at call time — discard if scene has changed since
  1054. const epoch = sceneEpochRef.current;
  1055. // Use queueMicrotask to let any pending scene-switch reset settle first
  1056. queueMicrotask(() => {
  1057. if (sceneEpochRef.current !== epoch) return; // stale — scene changed
  1058. setLiveSpeech(text);
  1059. if (agentId !== undefined) {
  1060. setSpeakingAgentId(agentId);
  1061. }
  1062. if (text !== null || agentId) {
  1063. setChatIsStreaming(true);
  1064. setChatSessionType(chatAreaRef.current?.getActiveSessionType?.() ?? null);
  1065. setIsTopicPending(false);
  1066. } else if (text === null && agentId === null) {
  1067. setChatIsStreaming(false);
  1068. // Don't clear chatSessionType here — it's needed by the stop
  1069. // button when director cues user (cue_user → done → liveSpeech null).
  1070. // It gets properly cleared in doSessionCleanup and scene change.
  1071. }
  1072. });
  1073. }}
  1074. onSpeechProgress={(ratio) => {
  1075. const epoch = sceneEpochRef.current;
  1076. queueMicrotask(() => {
  1077. if (sceneEpochRef.current !== epoch) return;
  1078. setSpeechProgress(ratio);
  1079. });
  1080. }}
  1081. onThinking={(state) => {
  1082. const epoch = sceneEpochRef.current;
  1083. queueMicrotask(() => {
  1084. if (sceneEpochRef.current !== epoch) return;
  1085. setThinkingState(state);
  1086. });
  1087. }}
  1088. onCueUser={(_fromAgentId, _prompt) => {
  1089. setIsCueUser(true);
  1090. }}
  1091. onLiveSessionError={handleLiveSessionError}
  1092. onStopSession={doSessionCleanup}
  1093. onSegmentSealed={discussionTTS.handleSegmentSealed}
  1094. shouldHoldAfterReveal={discussionTTS.shouldHold}
  1095. />
  1096. {/* Scene switch confirmation dialog */}
  1097. <AlertDialog
  1098. open={!!pendingSceneId}
  1099. onOpenChange={(open) => {
  1100. if (!open) cancelSceneSwitch();
  1101. }}
  1102. >
  1103. <AlertDialogContent
  1104. container={isPresenting ? stageRef.current : undefined}
  1105. 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)]"
  1106. >
  1107. <VisuallyHidden.Root>
  1108. <AlertDialogTitle>{t('stage.confirmSwitchTitle')}</AlertDialogTitle>
  1109. </VisuallyHidden.Root>
  1110. {/* Top accent bar */}
  1111. <div className="h-1 bg-gradient-to-r from-amber-400 via-orange-400 to-red-400" />
  1112. <div className="px-6 pt-5 pb-2 flex flex-col items-center text-center">
  1113. {/* Icon */}
  1114. <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">
  1115. <AlertTriangle className="w-6 h-6 text-amber-500 dark:text-amber-400" />
  1116. </div>
  1117. {/* Title */}
  1118. <h3 className="text-base font-bold text-gray-900 dark:text-gray-100 mb-1.5">
  1119. {t('stage.confirmSwitchTitle')}
  1120. </h3>
  1121. {/* Description */}
  1122. <p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
  1123. {t('stage.confirmSwitchMessage')}
  1124. </p>
  1125. </div>
  1126. <AlertDialogFooter className="px-6 pb-5 pt-3 flex-row gap-3">
  1127. <AlertDialogCancel onClick={cancelSceneSwitch} className="flex-1 rounded-xl">
  1128. {t('common.cancel')}
  1129. </AlertDialogCancel>
  1130. <AlertDialogAction
  1131. onClick={confirmSceneSwitch}
  1132. 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"
  1133. >
  1134. {t('common.confirm')}
  1135. </AlertDialogAction>
  1136. </AlertDialogFooter>
  1137. </AlertDialogContent>
  1138. </AlertDialog>
  1139. </div>
  1140. );
  1141. }