derived-state.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. /**
  2. * Derived Playback State - Pure function that computes a high-level PlaybackView
  3. * from the ~15 raw state variables scattered across Stage.
  4. *
  5. * This centralises all "what is happening now?" derivation logic so that
  6. * both Stage and Roundtable can consume a single, consistent view object
  7. * instead of re-deriving the same conditions inline.
  8. */
  9. import type { EngineMode, TriggerEvent } from './types';
  10. // ---------------------------------------------------------------------------
  11. // Input: raw state collected from Stage's useState variables
  12. // ---------------------------------------------------------------------------
  13. export interface PlaybackRawState {
  14. engineMode: EngineMode;
  15. lectureSpeech: string | null;
  16. liveSpeech: string | null;
  17. speakingAgentId: string | null;
  18. thinkingState: { stage: string; agentId?: string } | null;
  19. isCueUser: boolean;
  20. isTopicPending: boolean;
  21. chatIsStreaming: boolean;
  22. discussionTrigger: TriggerEvent | null;
  23. playbackCompleted: boolean;
  24. idleText: string | null;
  25. /** Whether the speaking agent is a student (not teacher). Provided by caller. */
  26. speakingStudent: boolean;
  27. /** Active session type — stays set between agent-loop turns (cleared only by doSessionCleanup). */
  28. sessionType: string | null;
  29. }
  30. // ---------------------------------------------------------------------------
  31. // Output: a single derived view consumed by Roundtable (and Stage for gating)
  32. // ---------------------------------------------------------------------------
  33. export type PlaybackPhase =
  34. | 'idle'
  35. | 'lecturePlaying'
  36. | 'lecturePaused'
  37. | 'waitingProactive'
  38. | 'discussionActive'
  39. | 'discussionPaused'
  40. | 'cueUser'
  41. | 'completed';
  42. export type BubbleButtonState = 'bars' | 'play' | 'restart' | 'none';
  43. export interface PlaybackView {
  44. /** High-level phase — "what is happening right now?" */
  45. phase: PlaybackPhase;
  46. /** Text to display in the speech bubble (without userMessage overlay) */
  47. sourceText: string;
  48. /** Who owns the speech bubble */
  49. bubbleRole: 'teacher' | 'agent' | 'user' | null;
  50. /** Who is actively speaking (avatar highlight) */
  51. activeRole: 'teacher' | 'agent' | 'user' | null;
  52. /** Bubble button state */
  53. buttonState: BubbleButtonState;
  54. /** Whether we're in a live SSE flow (suppresses lecture text) */
  55. isInLiveFlow: boolean;
  56. /** Whether any topic-related activity blocks scene switching */
  57. isTopicActive: boolean;
  58. }
  59. // ---------------------------------------------------------------------------
  60. // Pure computation
  61. // ---------------------------------------------------------------------------
  62. export function computePlaybackView(raw: PlaybackRawState): PlaybackView {
  63. const {
  64. engineMode,
  65. lectureSpeech,
  66. liveSpeech,
  67. speakingAgentId,
  68. thinkingState,
  69. isCueUser,
  70. isTopicPending,
  71. chatIsStreaming,
  72. discussionTrigger,
  73. playbackCompleted,
  74. idleText,
  75. speakingStudent,
  76. sessionType,
  77. } = raw;
  78. // ---- isInLiveFlow ----
  79. // True when there's any live SSE activity (agent speaking, thinking, or streaming).
  80. // Includes chatIsStreaming to cover the entire QA session (gaps between
  81. // agent response completion and user's next message).
  82. // Includes sessionType to bridge the gap between agent-loop turns: the `done`
  83. // event clears chatIsStreaming, but the session is still active until
  84. // doSessionCleanup runs. Without this, bubbleRole briefly falls through to
  85. // the 'teacher' idleText case, causing a visible flash.
  86. const isInLiveFlow = !!(speakingAgentId || thinkingState || chatIsStreaming || sessionType);
  87. // ---- phase ----
  88. // Live flow states MUST be checked before playbackCompleted so that
  89. // starting a QA from the completed state doesn't leak the restart icon
  90. // into agent bubbles.
  91. let phase: PlaybackPhase;
  92. if (isCueUser) {
  93. phase = 'cueUser';
  94. } else if (isTopicPending) {
  95. phase = 'discussionPaused';
  96. } else if (speakingAgentId || thinkingState || chatIsStreaming || sessionType) {
  97. phase = 'discussionActive';
  98. } else if (discussionTrigger) {
  99. phase = 'waitingProactive';
  100. } else if (playbackCompleted) {
  101. phase = 'completed';
  102. } else if (engineMode === 'playing') {
  103. phase = 'lecturePlaying';
  104. } else if (engineMode === 'paused') {
  105. phase = 'lecturePaused';
  106. } else {
  107. phase = 'idle';
  108. }
  109. // ---- sourceText (without userMessage — Roundtable overlays that locally) ----
  110. let sourceText: string;
  111. if (liveSpeech) {
  112. sourceText = liveSpeech;
  113. } else if (isInLiveFlow) {
  114. // In live flow but no text yet — show empty (loading dots handled by bubble)
  115. sourceText = '';
  116. } else if (lectureSpeech) {
  117. sourceText = lectureSpeech;
  118. } else if (phase === 'completed') {
  119. sourceText = '';
  120. } else {
  121. sourceText = idleText || '';
  122. }
  123. // ---- bubble loading states ----
  124. const isBubbleLoading = !!(speakingAgentId && !liveSpeech);
  125. const isAgentLoading = !!(speakingStudent && !liveSpeech);
  126. // ---- activeRole ----
  127. let activeRole: 'teacher' | 'agent' | 'user' | null;
  128. if (liveSpeech && speakingStudent) {
  129. activeRole = 'agent';
  130. } else if (liveSpeech) {
  131. activeRole = 'teacher';
  132. } else if (isAgentLoading) {
  133. activeRole = 'agent';
  134. } else if (isBubbleLoading) {
  135. activeRole = 'teacher';
  136. } else if (isCueUser) {
  137. activeRole = null;
  138. } else if (lectureSpeech) {
  139. activeRole = 'teacher';
  140. } else {
  141. activeRole = null;
  142. }
  143. // ---- bubbleRole ----
  144. let bubbleRole: 'teacher' | 'agent' | 'user' | null;
  145. if (liveSpeech && speakingStudent) {
  146. bubbleRole = 'agent';
  147. } else if (liveSpeech) {
  148. bubbleRole = 'teacher';
  149. } else if (isAgentLoading) {
  150. bubbleRole = 'agent';
  151. } else if (isBubbleLoading) {
  152. bubbleRole = 'teacher';
  153. } else if (isInLiveFlow) {
  154. bubbleRole = null;
  155. } else if (isCueUser) {
  156. bubbleRole = null;
  157. } else if (lectureSpeech || idleText) {
  158. bubbleRole = 'teacher';
  159. } else {
  160. bubbleRole = null;
  161. }
  162. // ---- buttonState ----
  163. let buttonState: BubbleButtonState;
  164. if (isTopicPending) {
  165. buttonState = 'play'; // resume topic
  166. } else if (phase === 'lecturePlaying') {
  167. buttonState = 'bars'; // breathing bars + hover pause
  168. } else if (phase === 'discussionActive') {
  169. buttonState = 'bars';
  170. } else if (phase === 'completed') {
  171. buttonState = 'restart';
  172. } else if (phase === 'idle' || phase === 'lecturePaused') {
  173. buttonState = 'play';
  174. } else {
  175. buttonState = 'none';
  176. }
  177. // ---- isTopicActive ----
  178. const isTopicActive =
  179. chatIsStreaming || isTopicPending || isCueUser || engineMode === 'live' || !!discussionTrigger;
  180. return {
  181. phase,
  182. sourceText,
  183. bubbleRole,
  184. activeRole,
  185. buttonState,
  186. isInLiveFlow,
  187. isTopicActive,
  188. };
  189. }