| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213 |
- /**
- * Derived Playback State - Pure function that computes a high-level PlaybackView
- * from the ~15 raw state variables scattered across Stage.
- *
- * This centralises all "what is happening now?" derivation logic so that
- * both Stage and Roundtable can consume a single, consistent view object
- * instead of re-deriving the same conditions inline.
- */
- import type { EngineMode, TriggerEvent } from './types';
- // ---------------------------------------------------------------------------
- // Input: raw state collected from Stage's useState variables
- // ---------------------------------------------------------------------------
- export interface PlaybackRawState {
- engineMode: EngineMode;
- lectureSpeech: string | null;
- liveSpeech: string | null;
- speakingAgentId: string | null;
- thinkingState: { stage: string; agentId?: string } | null;
- isCueUser: boolean;
- isTopicPending: boolean;
- chatIsStreaming: boolean;
- discussionTrigger: TriggerEvent | null;
- playbackCompleted: boolean;
- idleText: string | null;
- /** Whether the speaking agent is a student (not teacher). Provided by caller. */
- speakingStudent: boolean;
- /** Active session type — stays set between agent-loop turns (cleared only by doSessionCleanup). */
- sessionType: string | null;
- }
- // ---------------------------------------------------------------------------
- // Output: a single derived view consumed by Roundtable (and Stage for gating)
- // ---------------------------------------------------------------------------
- export type PlaybackPhase =
- | 'idle'
- | 'lecturePlaying'
- | 'lecturePaused'
- | 'waitingProactive'
- | 'discussionActive'
- | 'discussionPaused'
- | 'cueUser'
- | 'completed';
- export type BubbleButtonState = 'bars' | 'play' | 'restart' | 'none';
- export interface PlaybackView {
- /** High-level phase — "what is happening right now?" */
- phase: PlaybackPhase;
- /** Text to display in the speech bubble (without userMessage overlay) */
- sourceText: string;
- /** Who owns the speech bubble */
- bubbleRole: 'teacher' | 'agent' | 'user' | null;
- /** Who is actively speaking (avatar highlight) */
- activeRole: 'teacher' | 'agent' | 'user' | null;
- /** Bubble button state */
- buttonState: BubbleButtonState;
- /** Whether we're in a live SSE flow (suppresses lecture text) */
- isInLiveFlow: boolean;
- /** Whether any topic-related activity blocks scene switching */
- isTopicActive: boolean;
- }
- // ---------------------------------------------------------------------------
- // Pure computation
- // ---------------------------------------------------------------------------
- export function computePlaybackView(raw: PlaybackRawState): PlaybackView {
- const {
- engineMode,
- lectureSpeech,
- liveSpeech,
- speakingAgentId,
- thinkingState,
- isCueUser,
- isTopicPending,
- chatIsStreaming,
- discussionTrigger,
- playbackCompleted,
- idleText,
- speakingStudent,
- sessionType,
- } = raw;
- // ---- isInLiveFlow ----
- // True when there's any live SSE activity (agent speaking, thinking, or streaming).
- // Includes chatIsStreaming to cover the entire QA session (gaps between
- // agent response completion and user's next message).
- // Includes sessionType to bridge the gap between agent-loop turns: the `done`
- // event clears chatIsStreaming, but the session is still active until
- // doSessionCleanup runs. Without this, bubbleRole briefly falls through to
- // the 'teacher' idleText case, causing a visible flash.
- const isInLiveFlow = !!(speakingAgentId || thinkingState || chatIsStreaming || sessionType);
- // ---- phase ----
- // Live flow states MUST be checked before playbackCompleted so that
- // starting a QA from the completed state doesn't leak the restart icon
- // into agent bubbles.
- let phase: PlaybackPhase;
- if (isCueUser) {
- phase = 'cueUser';
- } else if (isTopicPending) {
- phase = 'discussionPaused';
- } else if (speakingAgentId || thinkingState || chatIsStreaming || sessionType) {
- phase = 'discussionActive';
- } else if (discussionTrigger) {
- phase = 'waitingProactive';
- } else if (playbackCompleted) {
- phase = 'completed';
- } else if (engineMode === 'playing') {
- phase = 'lecturePlaying';
- } else if (engineMode === 'paused') {
- phase = 'lecturePaused';
- } else {
- phase = 'idle';
- }
- // ---- sourceText (without userMessage — Roundtable overlays that locally) ----
- let sourceText: string;
- if (liveSpeech) {
- sourceText = liveSpeech;
- } else if (isInLiveFlow) {
- // In live flow but no text yet — show empty (loading dots handled by bubble)
- sourceText = '';
- } else if (lectureSpeech) {
- sourceText = lectureSpeech;
- } else if (phase === 'completed') {
- sourceText = '';
- } else {
- sourceText = idleText || '';
- }
- // ---- bubble loading states ----
- const isBubbleLoading = !!(speakingAgentId && !liveSpeech);
- const isAgentLoading = !!(speakingStudent && !liveSpeech);
- // ---- activeRole ----
- let activeRole: 'teacher' | 'agent' | 'user' | null;
- if (liveSpeech && speakingStudent) {
- activeRole = 'agent';
- } else if (liveSpeech) {
- activeRole = 'teacher';
- } else if (isAgentLoading) {
- activeRole = 'agent';
- } else if (isBubbleLoading) {
- activeRole = 'teacher';
- } else if (isCueUser) {
- activeRole = null;
- } else if (lectureSpeech) {
- activeRole = 'teacher';
- } else {
- activeRole = null;
- }
- // ---- bubbleRole ----
- let bubbleRole: 'teacher' | 'agent' | 'user' | null;
- if (liveSpeech && speakingStudent) {
- bubbleRole = 'agent';
- } else if (liveSpeech) {
- bubbleRole = 'teacher';
- } else if (isAgentLoading) {
- bubbleRole = 'agent';
- } else if (isBubbleLoading) {
- bubbleRole = 'teacher';
- } else if (isInLiveFlow) {
- bubbleRole = null;
- } else if (isCueUser) {
- bubbleRole = null;
- } else if (lectureSpeech || idleText) {
- bubbleRole = 'teacher';
- } else {
- bubbleRole = null;
- }
- // ---- buttonState ----
- let buttonState: BubbleButtonState;
- if (isTopicPending) {
- buttonState = 'play'; // resume topic
- } else if (phase === 'lecturePlaying') {
- buttonState = 'bars'; // breathing bars + hover pause
- } else if (phase === 'discussionActive') {
- buttonState = 'bars';
- } else if (phase === 'completed') {
- buttonState = 'restart';
- } else if (phase === 'idle' || phase === 'lecturePaused') {
- buttonState = 'play';
- } else {
- buttonState = 'none';
- }
- // ---- isTopicActive ----
- const isTopicActive =
- chatIsStreaming || isTopicPending || isCueUser || engineMode === 'live' || !!discussionTrigger;
- return {
- phase,
- sourceText,
- bubbleRole,
- activeRole,
- buttonState,
- isInLiveFlow,
- isTopicActive,
- };
- }
|