Nitish kumar
Upload folder using huggingface_hub
c20f20c verified
'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>
);
}