index.tsx 95 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094
  1. 'use client';
  2. import { useState, useRef, useCallback, useEffect } from 'react';
  3. import { motion, AnimatePresence } from 'motion/react';
  4. import {
  5. Mic,
  6. MicOff,
  7. Send,
  8. MessageSquare,
  9. Pause,
  10. Play,
  11. ChevronLeft,
  12. ChevronRight,
  13. Repeat,
  14. BookOpen,
  15. Loader2,
  16. Volume2,
  17. } from 'lucide-react';
  18. import { cn } from '@/lib/utils';
  19. import type { AudioIndicatorState } from './audio-indicator';
  20. import { CanvasToolbar } from '@/components/canvas/canvas-toolbar';
  21. import { useAudioRecorder } from '@/lib/hooks/use-audio-recorder';
  22. import { useI18n } from '@/lib/hooks/use-i18n';
  23. import { toast } from 'sonner';
  24. import { useSettingsStore, PLAYBACK_SPEEDS } from '@/lib/store/settings';
  25. import { ProactiveCard } from '@/components/chat/proactive-card';
  26. import { PresentationSpeechOverlay } from '@/components/roundtable/presentation-speech-overlay';
  27. import { AvatarDisplay } from '@/components/ui/avatar-display';
  28. import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
  29. import { useAgentRegistry } from '@/lib/orchestration/registry/store';
  30. import { DEFAULT_TEACHER_AVATAR, DEFAULT_USER_AVATAR } from '@/components/roundtable/constants';
  31. import type { DiscussionAction } from '@/lib/types/action';
  32. import type { EngineMode, PlaybackView } from '@/lib/playback';
  33. import type { Participant } from '@/lib/types/roundtable';
  34. export interface DiscussionRequest {
  35. topic: string;
  36. prompt?: string;
  37. agentId?: string; // Agent ID to initiate discussion (default: 'default-1')
  38. }
  39. interface RoundtableProps {
  40. readonly mode?: 'playback' | 'autonomous';
  41. readonly initialParticipants?: Participant[];
  42. readonly playbackView?: PlaybackView; // Centralised derived state from Stage
  43. readonly currentSpeech?: string | null; // Live SSE speech (from StreamBuffer — discussion/QA)
  44. readonly lectureSpeech?: string | null; // Active lecture speech (from PlaybackEngine, full text)
  45. readonly idleText?: string | null; // Static idle text (first speech action)
  46. readonly playbackCompleted?: boolean; // True when engine finished all actions (show restart icon)
  47. readonly discussionRequest?: DiscussionAction | null;
  48. readonly engineMode?: EngineMode;
  49. readonly isStreaming?: boolean;
  50. readonly sessionType?: 'qa' | 'discussion';
  51. readonly speakingAgentId?: string | null;
  52. readonly audioIndicatorState?: AudioIndicatorState;
  53. readonly audioAgentId?: string | null;
  54. readonly speechProgress?: number | null; // StreamBuffer reveal progress (0–1) for auto-scroll
  55. readonly showEndFlash?: boolean;
  56. readonly endFlashSessionType?: 'qa' | 'discussion';
  57. readonly thinkingState?: { stage: string; agentId?: string } | null;
  58. readonly isCueUser?: boolean;
  59. readonly isTopicPending?: boolean;
  60. readonly onMessageSend?: (message: string) => void;
  61. readonly onDiscussionStart?: (request: DiscussionAction) => void;
  62. readonly onDiscussionSkip?: () => void;
  63. readonly onStopDiscussion?: () => void;
  64. readonly onInputActivate?: () => void;
  65. readonly onResumeTopic?: () => void;
  66. readonly onPlayPause?: () => void;
  67. readonly isDiscussionPaused?: boolean;
  68. readonly onDiscussionPause?: () => void;
  69. readonly onDiscussionResume?: () => void;
  70. readonly totalActions?: number;
  71. readonly currentActionIndex?: number;
  72. // Toolbar props (merged from CanvasArea)
  73. readonly currentSceneIndex?: number;
  74. readonly scenesCount?: number;
  75. readonly whiteboardOpen?: boolean;
  76. readonly sidebarCollapsed?: boolean;
  77. readonly chatCollapsed?: boolean;
  78. readonly onToggleSidebar?: () => void;
  79. readonly onToggleChat?: () => void;
  80. readonly onPrevSlide?: () => void;
  81. readonly onNextSlide?: () => void;
  82. readonly onWhiteboardClose?: () => void;
  83. readonly isPresenting?: boolean;
  84. readonly controlsVisible?: boolean;
  85. readonly onTogglePresentation?: () => void;
  86. readonly onPresentationInteractionChange?: (active: boolean) => void;
  87. /** Ref to the fullscreen container — passed to ProactiveCard so its portal
  88. * renders inside the top-layer during presentation mode. */
  89. readonly fullscreenContainerRef?: React.RefObject<HTMLDivElement | null>;
  90. }
  91. const VOICE_WAVE_BARS = [
  92. { peak: 18, duration: 0.55 },
  93. { peak: 24, duration: 0.72 },
  94. { peak: 15, duration: 0.63 },
  95. { peak: 22, duration: 0.68 },
  96. { peak: 27, duration: 0.78 },
  97. { peak: 19, duration: 0.61 },
  98. { peak: 26, duration: 0.74 },
  99. { peak: 17, duration: 0.58 },
  100. { peak: 23, duration: 0.7 },
  101. { peak: 16, duration: 0.57 },
  102. { peak: 21, duration: 0.66 },
  103. { peak: 14, duration: 0.53 },
  104. ] as const;
  105. function VoiceWaveformBars({ barClassName }: { readonly barClassName: string }) {
  106. return VOICE_WAVE_BARS.map((bar, i) => (
  107. <motion.div
  108. key={i}
  109. animate={{
  110. height: [4, bar.peak, 4],
  111. opacity: [0.3, 1, 0.3],
  112. }}
  113. transition={{
  114. repeat: Infinity,
  115. duration: bar.duration,
  116. delay: i * 0.05,
  117. ease: 'easeInOut',
  118. }}
  119. className={cn('w-1 rounded-full', barClassName)}
  120. />
  121. ));
  122. }
  123. export function Roundtable({
  124. mode: _mode = 'autonomous',
  125. initialParticipants = [],
  126. playbackView,
  127. currentSpeech,
  128. lectureSpeech,
  129. idleText,
  130. playbackCompleted,
  131. discussionRequest,
  132. engineMode = 'idle',
  133. isStreaming,
  134. sessionType,
  135. speakingAgentId,
  136. audioIndicatorState,
  137. audioAgentId,
  138. speechProgress: _speechProgress,
  139. showEndFlash,
  140. endFlashSessionType = 'discussion',
  141. thinkingState,
  142. isCueUser,
  143. isTopicPending,
  144. onMessageSend,
  145. onDiscussionStart,
  146. onDiscussionSkip,
  147. onStopDiscussion,
  148. onInputActivate,
  149. onResumeTopic,
  150. onPlayPause,
  151. isDiscussionPaused,
  152. onDiscussionPause,
  153. onDiscussionResume,
  154. currentSceneIndex = 0,
  155. scenesCount = 1,
  156. whiteboardOpen = false,
  157. sidebarCollapsed,
  158. chatCollapsed,
  159. onToggleSidebar,
  160. onToggleChat,
  161. onPrevSlide,
  162. onNextSlide,
  163. onWhiteboardClose,
  164. isPresenting,
  165. controlsVisible,
  166. onTogglePresentation,
  167. onPresentationInteractionChange,
  168. fullscreenContainerRef,
  169. }: RoundtableProps) {
  170. const { t } = useI18n();
  171. const ttsMuted = useSettingsStore((s) => s.ttsMuted);
  172. const setTTSMuted = useSettingsStore((s) => s.setTTSMuted);
  173. const ttsEnabled = useSettingsStore((state) => state.ttsEnabled);
  174. const asrEnabled = useSettingsStore((state) => state.asrEnabled);
  175. const chatAreaWidth = useSettingsStore((s) => s.chatAreaWidth);
  176. const ttsVolume = useSettingsStore((s) => s.ttsVolume);
  177. const setTTSVolume = useSettingsStore((s) => s.setTTSVolume);
  178. const autoPlayLecture = useSettingsStore((s) => s.autoPlayLecture);
  179. const setAutoPlayLecture = useSettingsStore((s) => s.setAutoPlayLecture);
  180. const playbackSpeed = useSettingsStore((s) => s.playbackSpeed);
  181. const setPlaybackSpeed = useSettingsStore((s) => s.setPlaybackSpeed);
  182. const [isInputOpen, setIsInputOpen] = useState(false);
  183. const [isVoiceOpen, setIsVoiceOpen] = useState(false);
  184. const [inputValue, setInputValue] = useState('');
  185. const [userMessage, setUserMessage] = useState<string | null>(null);
  186. const agentScrollRef = useRef<HTMLDivElement>(null);
  187. const bubbleScrollRef = useRef<HTMLDivElement>(null);
  188. const teacherAvatarRef = useRef<HTMLDivElement>(null);
  189. const studentAvatarRefs = useRef<Map<string, HTMLDivElement>>(new Map());
  190. const userMessageClearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  191. // End flash visible state (Issue 3)
  192. const [endFlashVisible, setEndFlashVisible] = useState(false);
  193. // Send cooldown: lock input from "message sent" until "agent bubble appears"
  194. const [isSendCooldown, setIsSendCooldown] = useState(false);
  195. const isSendCooldownRef = useRef(false);
  196. const teacherParticipant = initialParticipants.find((p) => p.role === 'teacher');
  197. const studentParticipants = initialParticipants.filter(
  198. (p) => p.role !== 'teacher' && p.role !== 'user',
  199. );
  200. // Stable ref object for the current discussion agent's avatar
  201. const discussionAnchorRef = useRef<HTMLDivElement>(null);
  202. const presentationActionAnchorRef = useRef<HTMLDivElement>(null);
  203. const presentationAgentAvatarRef = useRef<HTMLDivElement>(null);
  204. useEffect(() => {
  205. if (!discussionRequest) {
  206. discussionAnchorRef.current = null;
  207. return;
  208. }
  209. if (discussionRequest.agentId === teacherParticipant?.id) {
  210. discussionAnchorRef.current = teacherAvatarRef.current;
  211. } else {
  212. discussionAnchorRef.current =
  213. studentAvatarRefs.current.get(discussionRequest.agentId || '') || null;
  214. }
  215. }, [discussionRequest, teacherParticipant?.id]);
  216. // Derived state from Stage's computePlaybackView (centralised derivation)
  217. const isInLiveFlow =
  218. playbackView?.isInLiveFlow ??
  219. !!(speakingAgentId || thinkingState || isStreaming || sessionType);
  220. // Role-aware source text: userMessage overlay on top of playbackView
  221. const sourceText = userMessage
  222. ? userMessage
  223. : (playbackView?.sourceText ??
  224. (currentSpeech
  225. ? currentSpeech
  226. : isInLiveFlow
  227. ? ''
  228. : lectureSpeech || (playbackCompleted ? '' : idleText) || ''));
  229. const hasAgentFeedback = Boolean(playbackView?.sourceText || thinkingState);
  230. const prevHasAgentFeedbackRef = useRef(hasAgentFeedback);
  231. const clearUserMessageClearTimer = useCallback(() => {
  232. if (userMessageClearTimerRef.current) {
  233. clearTimeout(userMessageClearTimerRef.current);
  234. userMessageClearTimerRef.current = null;
  235. }
  236. }, []);
  237. const scheduleUserMessageClear = useCallback(() => {
  238. clearUserMessageClearTimer();
  239. userMessageClearTimerRef.current = setTimeout(() => {
  240. setUserMessage(null);
  241. userMessageClearTimerRef.current = null;
  242. }, 3000);
  243. }, [clearUserMessageClearTimer]);
  244. const showLocalUserMessage = useCallback(
  245. (text: string) => {
  246. setUserMessage(text);
  247. // Mark as "already seen feedback" so that the immediate thinkingState
  248. // transition (false→true) after user sends won't trigger the early-clear
  249. // effect and swallow the user bubble.
  250. prevHasAgentFeedbackRef.current = true;
  251. scheduleUserMessageClear();
  252. },
  253. [scheduleUserMessageClear],
  254. );
  255. // Auto-scroll bubble: keep latest streaming text visible during live/discussion flow
  256. useEffect(() => {
  257. if (!isInLiveFlow) return;
  258. const el = bubbleScrollRef.current;
  259. if (!el) return;
  260. const scrollableHeight = el.scrollHeight - el.clientHeight;
  261. if (scrollableHeight <= 0) return;
  262. el.scrollTo({ top: scrollableHeight, behavior: 'smooth' });
  263. }, [sourceText, isInLiveFlow]);
  264. // Clear user message early when agent starts responding
  265. useEffect(() => {
  266. const feedbackStarted = hasAgentFeedback && !prevHasAgentFeedbackRef.current;
  267. if (userMessage && feedbackStarted) {
  268. clearUserMessageClearTimer();
  269. setUserMessage(null);
  270. }
  271. prevHasAgentFeedbackRef.current = hasAgentFeedback;
  272. }, [clearUserMessageClearTimer, hasAgentFeedback, userMessage]);
  273. useEffect(() => () => clearUserMessageClearTimer(), [clearUserMessageClearTimer]);
  274. // End flash effect (Issue 3)
  275. useEffect(() => {
  276. if (showEndFlash) {
  277. setEndFlashVisible(true);
  278. const timer = setTimeout(() => setEndFlashVisible(false), 1800);
  279. return () => clearTimeout(timer);
  280. } else {
  281. setEndFlashVisible(false);
  282. }
  283. }, [showEndFlash]);
  284. // Clear send cooldown when agent bubble appears
  285. useEffect(() => {
  286. if (isSendCooldown && speakingAgentId) {
  287. setIsSendCooldown(false);
  288. isSendCooldownRef.current = false;
  289. }
  290. }, [isSendCooldown, speakingAgentId]);
  291. // Safety net: clear cooldown when streaming transitions from active → ended
  292. // (not when isStreaming was already false — that would clear cooldown immediately)
  293. const prevStreamingRef = useRef(false);
  294. useEffect(() => {
  295. if (prevStreamingRef.current && !isStreaming && isSendCooldown) {
  296. setIsSendCooldown(false);
  297. isSendCooldownRef.current = false;
  298. }
  299. prevStreamingRef.current = !!isStreaming;
  300. }, [isStreaming, isSendCooldown]);
  301. // Separate participants by role (teacherParticipant & studentParticipants declared earlier for effect)
  302. const userParticipant = initialParticipants.find((p) => p.role === 'user');
  303. const teacherAvatar = teacherParticipant?.avatar || DEFAULT_TEACHER_AVATAR;
  304. const teacherName = teacherParticipant?.name || t('roundtable.teacher');
  305. const userAvatar = userParticipant?.avatar || DEFAULT_USER_AVATAR;
  306. // Audio recording
  307. const { isRecording, isProcessing, startRecording, stopRecording, cancelRecording } =
  308. useAudioRecorder({
  309. onTranscription: (text) => {
  310. if (!text.trim()) {
  311. toast.info(t('roundtable.noSpeechDetected'));
  312. setIsVoiceOpen(false);
  313. return;
  314. }
  315. // Block if in send cooldown (e.g. text was sent while voice was processing)
  316. if (isSendCooldownRef.current) {
  317. setIsVoiceOpen(false);
  318. return;
  319. }
  320. showLocalUserMessage(text);
  321. onMessageSend?.(text);
  322. setIsSendCooldown(true);
  323. isSendCooldownRef.current = true;
  324. setIsVoiceOpen(false);
  325. },
  326. onError: (error) => {
  327. toast.error(error);
  328. setIsVoiceOpen(false);
  329. },
  330. });
  331. const handleSendMessage = () => {
  332. if (!inputValue.trim() || isSendCooldown) return;
  333. showLocalUserMessage(inputValue);
  334. onMessageSend?.(inputValue);
  335. setIsSendCooldown(true);
  336. isSendCooldownRef.current = true;
  337. setInputValue('');
  338. setIsInputOpen(false);
  339. };
  340. const handleToggleInput = () => {
  341. if (isSendCooldown) return;
  342. if (!isInputOpen) {
  343. onInputActivate?.();
  344. }
  345. setIsInputOpen(!isInputOpen);
  346. // Cancel any in-flight ASR to prevent ghost auto-sends
  347. if (isVoiceOpen || isProcessing) {
  348. cancelRecording();
  349. setIsVoiceOpen(false);
  350. }
  351. };
  352. const handleToggleVoice = () => {
  353. if (isVoiceOpen) {
  354. if (isRecording) {
  355. stopRecording();
  356. }
  357. setIsVoiceOpen(false);
  358. } else {
  359. if (isSendCooldown || isProcessing) return;
  360. onInputActivate?.();
  361. setIsVoiceOpen(true);
  362. setIsInputOpen(false);
  363. startRecording();
  364. }
  365. };
  366. // Keyboard shortcuts for roundtable interaction (#255)
  367. // T = toggle text input, V = toggle voice input, Escape = dismiss panels,
  368. // Space = discussion pause/resume (during live flow)
  369. useEffect(() => {
  370. const handleKeyDown = (e: KeyboardEvent) => {
  371. // Escape should always work, even when typing in an input
  372. if (e.key === 'Escape') {
  373. if (isInputOpen || isVoiceOpen) {
  374. e.preventDefault();
  375. e.stopPropagation(); // Prevent fullscreen exit when panels are open
  376. setIsInputOpen(false);
  377. setIsVoiceOpen(false);
  378. if (isRecording || isProcessing) cancelRecording();
  379. }
  380. return;
  381. }
  382. // Skip other shortcuts when user is typing in an input, textarea, or contentEditable
  383. const tag = (e.target as HTMLElement).tagName;
  384. if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable) {
  385. return;
  386. }
  387. switch (e.key) {
  388. case ' ':
  389. case 'Spacebar':
  390. // Only handle during live flow (QA/Discussion)
  391. if (!isInLiveFlow) return;
  392. e.preventDefault(); // Prevent page scroll
  393. if (isDiscussionPaused) {
  394. onDiscussionResume?.();
  395. } else if (!thinkingState && currentSpeech) {
  396. // Same guard as bubble click: don't pause during thinking or before text arrives
  397. onDiscussionPause?.();
  398. }
  399. break;
  400. case 't':
  401. case 'T':
  402. e.preventDefault();
  403. handleToggleInput();
  404. break;
  405. case 'v':
  406. case 'V':
  407. e.preventDefault();
  408. if (asrEnabled) handleToggleVoice();
  409. break;
  410. default:
  411. break;
  412. }
  413. };
  414. window.addEventListener('keydown', handleKeyDown);
  415. return () => window.removeEventListener('keydown', handleKeyDown);
  416. }, [
  417. isInLiveFlow,
  418. isDiscussionPaused,
  419. thinkingState,
  420. currentSpeech,
  421. onDiscussionPause,
  422. onDiscussionResume,
  423. asrEnabled,
  424. isInputOpen,
  425. isVoiceOpen,
  426. isRecording,
  427. isProcessing,
  428. ]);
  429. const isPresentationInteractionActive = isInputOpen || isVoiceOpen || isRecording || isProcessing;
  430. useEffect(() => {
  431. onPresentationInteractionChange?.(isPresentationInteractionActive);
  432. return () => {
  433. if (isPresentationInteractionActive) {
  434. onPresentationInteractionChange?.(false);
  435. }
  436. };
  437. }, [isPresentationInteractionActive, onPresentationInteractionChange]);
  438. // Determine active speaking state and bubble ownership
  439. // Check if current speaker is a student agent (not teacher)
  440. const speakingStudent = speakingAgentId
  441. ? studentParticipants.find((s) => s.id === speakingAgentId)
  442. : null;
  443. // Bubble loading: speakingAgentId is set (agent_start fired) but text hasn't arrived yet
  444. const isBubbleLoading = !!(speakingAgentId && !currentSpeech && !userMessage);
  445. // Student agent specifically loading (for agent-style bubble)
  446. const isAgentLoading = !!(speakingStudent && !currentSpeech && !userMessage);
  447. const activeRole: 'teacher' | 'user' | 'agent' | null = userMessage
  448. ? 'user'
  449. : (playbackView?.activeRole ??
  450. (currentSpeech && speakingStudent
  451. ? 'agent'
  452. : currentSpeech
  453. ? 'teacher'
  454. : isAgentLoading
  455. ? 'agent'
  456. : isBubbleLoading
  457. ? 'teacher'
  458. : isCueUser
  459. ? null
  460. : lectureSpeech
  461. ? 'teacher'
  462. : null));
  463. const bubbleRole: 'teacher' | 'user' | 'agent' | null = userMessage
  464. ? 'user'
  465. : (playbackView?.bubbleRole ??
  466. (currentSpeech && speakingStudent
  467. ? 'agent'
  468. : currentSpeech
  469. ? 'teacher'
  470. : isAgentLoading
  471. ? 'agent'
  472. : isBubbleLoading
  473. ? 'teacher'
  474. : isInLiveFlow
  475. ? null
  476. : isCueUser
  477. ? null
  478. : lectureSpeech || idleText
  479. ? 'teacher'
  480. : null));
  481. const bubbleName =
  482. bubbleRole === 'agent'
  483. ? speakingStudent?.name || t('settings.agentRoles.student')
  484. : bubbleRole === 'teacher'
  485. ? teacherName
  486. : bubbleRole === 'user'
  487. ? t('roundtable.you')
  488. : '';
  489. // Stable key based on speaker identity, NOT text content (prevents re-mount flicker)
  490. const bubbleKey =
  491. bubbleRole === 'user'
  492. ? 'user'
  493. : bubbleRole === 'agent'
  494. ? `agent-${speakingAgentId}`
  495. : bubbleRole === 'teacher'
  496. ? 'teacher'
  497. : 'idle';
  498. // Enriched playbackView that includes userMessage overlay for bubbleRole/sourceText
  499. const enrichedPlaybackView: PlaybackView = playbackView
  500. ? { ...playbackView, bubbleRole, sourceText, activeRole: activeRole ?? playbackView.activeRole }
  501. : {
  502. phase: 'idle' as const,
  503. sourceText,
  504. bubbleRole,
  505. activeRole,
  506. buttonState: 'none' as const,
  507. isInLiveFlow: false,
  508. isTopicActive: false,
  509. };
  510. // Show stop button whenever there's an active QA/discussion session or live mode.
  511. // sessionType is only cleared in doSessionCleanup, so this stays stable through
  512. // brief loading gaps (e.g. between user message and agent SSE response).
  513. const showStopButton =
  514. engineMode === 'live' || sessionType === 'qa' || sessionType === 'discussion';
  515. const handleCycleSpeed = useCallback(() => {
  516. const currentIndex = PLAYBACK_SPEEDS.indexOf(playbackSpeed as (typeof PLAYBACK_SPEEDS)[number]);
  517. const nextIndex = (currentIndex + 1) % PLAYBACK_SPEEDS.length;
  518. setPlaybackSpeed(PLAYBACK_SPEEDS[nextIndex]);
  519. }, [playbackSpeed, setPlaybackSpeed]);
  520. // Intentionally non-reactive: agent metadata is treated as immutable during a classroom session.
  521. const agentRegistry = useAgentRegistry.getState();
  522. const getAgentConfig = (id: string) => agentRegistry.getAgent(id);
  523. const presentationDiscussionParticipant = discussionRequest
  524. ? discussionRequest.agentId === teacherParticipant?.id
  525. ? teacherParticipant || null
  526. : studentParticipants.find((student) => student.id === discussionRequest.agentId) || null
  527. : null;
  528. const presentationDiscussionAgentConfig = discussionRequest
  529. ? getAgentConfig(discussionRequest.agentId || '')
  530. : null;
  531. const handlePresentationBubbleClick = useCallback(() => {
  532. if (isTopicPending) {
  533. onResumeTopic?.();
  534. return;
  535. }
  536. if (isInLiveFlow) {
  537. if (isDiscussionPaused) {
  538. onDiscussionResume?.();
  539. } else if (!thinkingState && currentSpeech) {
  540. onDiscussionPause?.();
  541. }
  542. return;
  543. }
  544. onPlayPause?.();
  545. }, [
  546. isTopicPending,
  547. isInLiveFlow,
  548. isDiscussionPaused,
  549. thinkingState,
  550. currentSpeech,
  551. onResumeTopic,
  552. onDiscussionResume,
  553. onDiscussionPause,
  554. onPlayPause,
  555. ]);
  556. const showPresentationDock =
  557. !!controlsVisible ||
  558. !!discussionRequest ||
  559. isCueUser ||
  560. isInputOpen ||
  561. isVoiceOpen ||
  562. isRecording ||
  563. isProcessing;
  564. const toolbar = (
  565. <CanvasToolbar
  566. className="shrink-0 h-8 px-3 border-b border-gray-100/40 dark:border-gray-700/30"
  567. currentSceneIndex={currentSceneIndex}
  568. scenesCount={scenesCount}
  569. engineState={
  570. engineMode === 'playing' || engineMode === 'live'
  571. ? 'playing'
  572. : engineMode === 'paused'
  573. ? 'paused'
  574. : 'idle'
  575. }
  576. isLiveSession={isStreaming || isTopicPending || engineMode === 'live'}
  577. whiteboardOpen={whiteboardOpen}
  578. sidebarCollapsed={sidebarCollapsed}
  579. chatCollapsed={chatCollapsed}
  580. onToggleSidebar={onToggleSidebar}
  581. onToggleChat={onToggleChat}
  582. onPrevSlide={onPrevSlide ?? (() => {})}
  583. onNextSlide={onNextSlide ?? (() => {})}
  584. onPlayPause={onPlayPause ?? (() => {})}
  585. onWhiteboardClose={onWhiteboardClose ?? (() => {})}
  586. isPresenting={isPresenting}
  587. onTogglePresentation={onTogglePresentation}
  588. showStopDiscussion={showStopButton}
  589. onStopDiscussion={onStopDiscussion}
  590. ttsEnabled={ttsEnabled}
  591. ttsMuted={ttsMuted}
  592. ttsVolume={ttsVolume}
  593. onToggleMute={() => ttsEnabled && setTTSMuted(!ttsMuted)}
  594. onVolumeChange={(v) => setTTSVolume(v)}
  595. autoPlayLecture={autoPlayLecture}
  596. onToggleAutoPlay={() => setAutoPlayLecture(!autoPlayLecture)}
  597. playbackSpeed={playbackSpeed}
  598. onCycleSpeed={handleCycleSpeed}
  599. />
  600. );
  601. if (isPresenting) {
  602. return (
  603. <div className="h-0 w-full relative z-10 overflow-visible">
  604. {/* Speech overlay — fills the full stage area via absolute positioning */}
  605. <PresentationSpeechOverlay
  606. playbackView={enrichedPlaybackView}
  607. participants={initialParticipants}
  608. speakingAgentId={speakingAgentId ?? null}
  609. isTopicPending={!!isTopicPending}
  610. side="left"
  611. onBubbleClick={handlePresentationBubbleClick}
  612. audioIndicatorState={audioIndicatorState ?? 'idle'}
  613. buttonState={enrichedPlaybackView?.buttonState}
  614. isPaused={isDiscussionPaused || engineMode === 'paused'}
  615. />
  616. {/* Click-outside backdrop to dismiss input/voice */}
  617. {(isInputOpen || isVoiceOpen) && (
  618. <div
  619. className="fixed top-0 left-0 right-0 bottom-14 z-[45] pointer-events-auto"
  620. onClick={() => {
  621. setIsInputOpen(false);
  622. setIsVoiceOpen(false);
  623. cancelRecording();
  624. }}
  625. />
  626. )}
  627. {/* ── Toolbar — pinned to bottom of screen ── */}
  628. <div
  629. className={cn(
  630. 'fixed bottom-0 left-0 z-[40] pointer-events-none flex items-center justify-center transition-all duration-300',
  631. controlsVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2',
  632. )}
  633. style={{ right: chatCollapsed === false ? (chatAreaWidth ?? 320) : 0 }}
  634. >
  635. <div className="mb-3 px-2 py-1 rounded-full bg-white/70 dark:bg-black/60 backdrop-blur-xl border border-gray-200/60 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.08)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.4)] pointer-events-auto">
  636. {toolbar}
  637. </div>
  638. </div>
  639. {/* ── End flash notification ── */}
  640. <AnimatePresence>
  641. {endFlashVisible && (
  642. <motion.div
  643. initial={{ opacity: 0, y: 10, scale: 0.9 }}
  644. animate={{
  645. opacity: [0, 1, 1, 0],
  646. y: [10, 0, 0, 6],
  647. scale: [0.9, 1, 1, 0.95],
  648. }}
  649. transition={{
  650. duration: 1.8,
  651. times: [0, 0.15, 0.7, 1],
  652. ease: 'easeOut',
  653. }}
  654. className="fixed bottom-20 -translate-x-1/2 z-[50] bg-gray-100/80 dark:bg-gray-800/80 backdrop-blur-md text-gray-700 dark:text-white px-3.5 py-1.5 rounded-full text-xs font-medium pointer-events-none"
  655. style={{
  656. left: `calc((100vw - ${chatCollapsed === false ? (chatAreaWidth ?? 320) : 0}px) / 2)`,
  657. }}
  658. >
  659. <span className="w-1.5 h-1.5 rounded-full bg-gray-400 inline-block mr-1.5" />
  660. {endFlashSessionType === 'discussion'
  661. ? t('roundtable.discussionEnded')
  662. : t('roundtable.qaEnded')}
  663. </motion.div>
  664. )}
  665. </AnimatePresence>
  666. {/* ── Center stack: input / voice / thinking — anchored above toolbar ── */}
  667. <div
  668. className="fixed bottom-14 left-0 z-[50] flex flex-col items-center justify-center gap-3 pointer-events-none transition-[right] duration-300"
  669. style={{ right: chatCollapsed === false ? (chatAreaWidth ?? 320) : 0 }}
  670. >
  671. {/* Input panel */}
  672. <AnimatePresence>
  673. {isInputOpen && (
  674. <motion.div
  675. key="presentation-input-stage"
  676. initial={{ opacity: 0, scale: 0.95, y: 15, filter: 'blur(4px)' }}
  677. animate={{ opacity: 1, scale: 1, y: 0, filter: 'blur(0px)' }}
  678. exit={{ opacity: 0, scale: 0.95, y: 15, filter: 'blur(4px)' }}
  679. className="w-[min(480px,calc(100vw-3rem))] pointer-events-auto"
  680. >
  681. <div className="flex items-center gap-3 bg-white/70 dark:bg-black/60 backdrop-blur-xl rounded-full px-4 py-2 shadow-[0_8px_32px_rgba(0,0,0,0.08)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.4)] border border-gray-200/60 dark:border-white/10">
  682. <div className="flex-1 min-w-0 flex items-center">
  683. <textarea
  684. value={inputValue}
  685. onChange={(e) => setInputValue(e.target.value)}
  686. onKeyDown={(e) => {
  687. if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
  688. e.preventDefault();
  689. handleSendMessage();
  690. }
  691. }}
  692. placeholder={t('roundtable.inputPlaceholder')}
  693. autoFocus
  694. rows={1}
  695. className="w-full resize-none bg-transparent border-none focus:ring-0 focus:outline-none outline-none shadow-none ring-0 text-gray-900 dark:text-white text-sm placeholder:text-gray-400 dark:placeholder:text-gray-400 py-0 leading-[40px] max-h-[80px]"
  696. style={{ fieldSizing: 'content' } as Record<string, string>}
  697. />
  698. </div>
  699. <button
  700. onClick={handleSendMessage}
  701. disabled={isSendCooldown}
  702. className={cn(
  703. 'w-10 h-10 rounded-full flex items-center justify-center transition-all shrink-0',
  704. isSendCooldown
  705. ? 'bg-gray-500/50 cursor-not-allowed'
  706. : 'bg-purple-600 hover:bg-purple-700 shadow-[0_4px_16px_rgba(147,51,234,0.3)]',
  707. )}
  708. >
  709. {isSendCooldown ? (
  710. <Loader2 className="w-4 h-4 text-white animate-spin" />
  711. ) : (
  712. <Send className="w-4 h-4 text-white" />
  713. )}
  714. </button>
  715. </div>
  716. </motion.div>
  717. )}
  718. </AnimatePresence>
  719. {/* Voice panel */}
  720. <AnimatePresence>
  721. {isVoiceOpen && (
  722. <motion.div
  723. key="presentation-voice-stage"
  724. initial={{ opacity: 0, scale: 0.9, y: 20, filter: 'blur(4px)' }}
  725. animate={{ opacity: 1, scale: 1, y: 0, filter: 'blur(0px)' }}
  726. exit={{ opacity: 0, scale: 0.9, y: 20, filter: 'blur(4px)' }}
  727. className="pointer-events-auto"
  728. >
  729. <div className="flex items-center gap-4 bg-white/70 dark:bg-black/60 backdrop-blur-xl rounded-full px-5 py-3 shadow-[0_8px_32px_rgba(0,0,0,0.08)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.4)] border border-gray-200/60 dark:border-white/10">
  730. {/* Waveform bars */}
  731. <div className="flex items-center gap-0.5 h-8">
  732. <VoiceWaveformBars barClassName="bg-gradient-to-t from-purple-400 to-indigo-400" />
  733. </div>
  734. <span className="text-[11px] font-semibold tracking-wider text-purple-600 dark:text-purple-300 uppercase">
  735. {isProcessing ? t('roundtable.processing') : t('roundtable.listening')}
  736. </span>
  737. {/* Mic button */}
  738. <button
  739. type="button"
  740. aria-label={
  741. isRecording ? t('roundtable.stopRecording') : t('roundtable.startRecording')
  742. }
  743. className="relative group cursor-pointer bg-transparent border-none p-0"
  744. onClick={handleToggleVoice}
  745. >
  746. <div className="relative w-12 h-12 rounded-full bg-gradient-to-br from-purple-600 to-indigo-700 shadow-[0_4px_20px_rgba(147,51,234,0.3)] flex items-center justify-center group-hover:scale-105 transition-transform duration-300 border border-white/20">
  747. <Mic className="w-5 h-5 text-white" />
  748. </div>
  749. <div className="absolute inset-0 rounded-full border-2 border-purple-500 opacity-40 animate-[ping_2s_ease-in-out_infinite]" />
  750. </button>
  751. </div>
  752. </motion.div>
  753. )}
  754. </AnimatePresence>
  755. {/* "Your turn" cue prompt — clickable, opens input panel */}
  756. <AnimatePresence>
  757. {isCueUser && !bubbleRole && !thinkingState && !isInputOpen && !isVoiceOpen && (
  758. <motion.div
  759. key="presentation-cue-user"
  760. initial={{ opacity: 0, scale: 0.92, y: 8 }}
  761. animate={{ opacity: 1, scale: 1, y: 0 }}
  762. exit={{ opacity: 0, scale: 0.92, y: 8 }}
  763. transition={{ duration: 0.22, ease: [0.21, 1, 0.36, 1] }}
  764. className="pointer-events-auto"
  765. >
  766. <button
  767. onClick={() => (asrEnabled ? handleToggleVoice() : handleToggleInput())}
  768. className="flex items-center gap-2 px-5 py-2.5 rounded-full bg-white/70 dark:bg-black/50 backdrop-blur-xl border border-amber-400/50 dark:border-amber-500/50 shadow-[0_0_16px_rgba(245,158,11,0.2),0_8px_32px_rgba(0,0,0,0.06)] dark:shadow-[0_0_16px_rgba(245,158,11,0.25),0_8px_32px_rgba(0,0,0,0.4)] text-amber-600 dark:text-amber-400 text-sm font-semibold tracking-wide hover:bg-gray-100/80 dark:hover:bg-black/60 hover:border-amber-500/70 dark:hover:border-amber-400/70 hover:shadow-[0_0_24px_rgba(245,158,11,0.25)] dark:hover:shadow-[0_0_24px_rgba(245,158,11,0.35)] transition-all active:scale-95 animate-pulse"
  769. >
  770. {asrEnabled ? <Mic className="w-4 h-4" /> : <MessageSquare className="w-4 h-4" />}
  771. {t('roundtable.yourTurn')}
  772. </button>
  773. </motion.div>
  774. )}
  775. </AnimatePresence>
  776. {/* Director thinking indicator */}
  777. <AnimatePresence>
  778. {thinkingState?.stage === 'director' && !currentSpeech && !userMessage && (
  779. <motion.div
  780. initial={{ opacity: 0, scale: 0.9 }}
  781. animate={{ opacity: 1, scale: 1 }}
  782. exit={{ opacity: 0, scale: 0.9 }}
  783. className="flex items-center gap-2 px-4 py-2 bg-white/70 dark:bg-black/50 backdrop-blur-xl rounded-full border border-gray-200/60 dark:border-white/10"
  784. >
  785. <div className="flex gap-1">
  786. {[0, 0.2, 0.4].map((delay) => (
  787. <motion.div
  788. key={delay}
  789. animate={{ opacity: [0.3, 1, 0.3] }}
  790. transition={{ repeat: Infinity, duration: 1.2, delay }}
  791. className="w-1.5 h-1.5 rounded-full bg-purple-400"
  792. />
  793. ))}
  794. </div>
  795. <span className="text-[10px] text-gray-500 dark:text-gray-400 font-medium">
  796. {t('roundtable.thinking')}
  797. </span>
  798. </motion.div>
  799. )}
  800. </AnimatePresence>
  801. </div>
  802. {/* ── Right-side stack: bubble + dock — flex column, no hardcoded px ── */}
  803. <div
  804. className="fixed bottom-5 z-[48] flex flex-col items-end gap-3 pointer-events-none transition-[right] duration-300"
  805. style={{ right: chatCollapsed ? 20 : 20 + (chatAreaWidth ?? 320) }}
  806. >
  807. {/* Right-side speech bubble (flows above dock via flex) */}
  808. <PresentationSpeechOverlay
  809. playbackView={enrichedPlaybackView}
  810. participants={initialParticipants}
  811. speakingAgentId={speakingAgentId ?? null}
  812. isTopicPending={!!isTopicPending}
  813. userAvatar={userAvatar}
  814. side="right"
  815. onBubbleClick={handlePresentationBubbleClick}
  816. audioIndicatorState={audioIndicatorState ?? 'idle'}
  817. buttonState={enrichedPlaybackView?.buttonState}
  818. isPaused={isDiscussionPaused || engineMode === 'paused'}
  819. />
  820. {/* Dock */}
  821. <AnimatePresence>
  822. {showPresentationDock && (
  823. <motion.div
  824. initial={{ opacity: 0, scale: 0.92 }}
  825. animate={{ opacity: 1, scale: 1 }}
  826. exit={{ opacity: 0, scale: 0.92 }}
  827. transition={{ duration: 0.2, ease: 'easeOut' }}
  828. className="pointer-events-auto"
  829. >
  830. <div
  831. ref={presentationActionAnchorRef}
  832. className="flex items-center gap-2.5 rounded-full bg-white/70 dark:bg-black/60 backdrop-blur-xl border border-gray-200/60 dark:border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.08)] dark:shadow-[0_8px_32px_rgba(0,0,0,0.4)] px-2.5 py-2"
  833. >
  834. {/* Speaking / discussion-requesting agent avatar — shows when
  835. a student agent is actively speaking OR a discussion request
  836. is pending (so the user can see who's asking before joining) */}
  837. <AnimatePresence>
  838. {((activeRole === 'agent' && speakingStudent) ||
  839. presentationDiscussionParticipant) && (
  840. <motion.div
  841. ref={presentationAgentAvatarRef}
  842. key={`dock-agent-${(speakingStudent || presentationDiscussionParticipant)?.id}`}
  843. initial={{ opacity: 0, scale: 0.8, width: 0 }}
  844. animate={{ opacity: 1, scale: 1, width: 'auto' }}
  845. exit={{ opacity: 0, scale: 0.8, width: 0 }}
  846. transition={{ duration: 0.2, ease: 'easeOut' }}
  847. className="shrink-0 overflow-hidden"
  848. >
  849. <div className="relative w-10 h-10 rounded-full flex items-center justify-center">
  850. <div className="absolute inset-0 rounded-full border-2 border-blue-500 shadow-[0_0_6px_rgba(59,130,246,0.3)] transition-all duration-300" />
  851. <div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-800 overflow-hidden relative z-10 text-lg">
  852. <AvatarDisplay
  853. src={
  854. (speakingStudent || presentationDiscussionParticipant)?.avatar ||
  855. '/avatars/user.png'
  856. }
  857. alt={
  858. (speakingStudent || presentationDiscussionParticipant)?.name || ''
  859. }
  860. />
  861. </div>
  862. </div>
  863. </motion.div>
  864. )}
  865. </AnimatePresence>
  866. {isSendCooldown ? (
  867. <div className="flex items-center justify-center w-8 h-8">
  868. <div className="flex items-center gap-[3px]">
  869. {[0, 1, 2].map((i) => (
  870. <motion.div
  871. key={i}
  872. animate={{ y: [0, -3, 0], opacity: [0.35, 0.9, 0.35] }}
  873. transition={{
  874. repeat: Infinity,
  875. duration: 0.9,
  876. delay: i * 0.12,
  877. ease: 'easeInOut',
  878. }}
  879. className="w-[3px] h-[3px] rounded-full bg-purple-400"
  880. />
  881. ))}
  882. </div>
  883. </div>
  884. ) : (
  885. <>
  886. <button
  887. aria-label={
  888. asrEnabled
  889. ? t('roundtable.voiceInput')
  890. : t('roundtable.voiceInputDisabled')
  891. }
  892. onClick={(e) => {
  893. e.stopPropagation();
  894. if (asrEnabled) handleToggleVoice();
  895. }}
  896. disabled={!asrEnabled}
  897. className={cn(
  898. 'w-8 h-8 rounded-full flex items-center justify-center transition-all active:scale-95',
  899. !asrEnabled
  900. ? 'text-gray-500 cursor-not-allowed'
  901. : isVoiceOpen
  902. ? 'bg-purple-600 text-white'
  903. : 'text-gray-500 dark:text-gray-300 hover:text-gray-700 dark:hover:text-white hover:bg-gray-200/50 dark:hover:bg-white/10',
  904. )}
  905. >
  906. {asrEnabled ? <Mic className="w-4 h-4" /> : <MicOff className="w-4 h-4" />}
  907. </button>
  908. <button
  909. aria-label={t('roundtable.textInput')}
  910. onClick={(e) => {
  911. e.stopPropagation();
  912. handleToggleInput();
  913. }}
  914. className={cn(
  915. 'w-8 h-8 rounded-full flex items-center justify-center transition-all active:scale-95',
  916. isInputOpen
  917. ? 'bg-purple-600 text-white'
  918. : 'text-gray-500 dark:text-gray-300 hover:text-gray-700 dark:hover:text-white hover:bg-gray-200/50 dark:hover:bg-white/10',
  919. )}
  920. >
  921. <MessageSquare className="w-4 h-4" />
  922. </button>
  923. </>
  924. )}
  925. <button
  926. type="button"
  927. aria-label={t('roundtable.you')}
  928. className="relative group cursor-pointer shrink-0 bg-transparent border-none p-0"
  929. onClick={(e) => {
  930. e.stopPropagation();
  931. handleToggleInput();
  932. }}
  933. >
  934. <div
  935. className={cn(
  936. 'relative w-10 h-10 rounded-full transition-all duration-300 flex items-center justify-center',
  937. activeRole === 'user' || isInputOpen || isCueUser
  938. ? 'scale-105'
  939. : 'opacity-70 group-hover:opacity-100 group-hover:scale-100',
  940. )}
  941. >
  942. <div
  943. className={cn(
  944. 'absolute inset-0 rounded-full border-2 transition-all duration-300',
  945. isCueUser
  946. ? 'border-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.4)] animate-pulse'
  947. : activeRole === 'user' || isInputOpen
  948. ? 'border-purple-500 shadow-[0_0_6px_rgba(168,85,247,0.3)]'
  949. : 'border-gray-300/40 dark:border-white/20 group-hover:border-purple-400/50',
  950. )}
  951. />
  952. <div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-800 overflow-hidden relative z-10 text-lg">
  953. <AvatarDisplay src={userAvatar} alt={t('roundtable.you')} />
  954. </div>
  955. </div>
  956. </button>
  957. </div>
  958. <AnimatePresence>
  959. {discussionRequest && (
  960. <ProactiveCard
  961. action={discussionRequest}
  962. mode={engineMode === 'paused' ? 'paused' : 'playback'}
  963. anchorRef={presentationAgentAvatarRef}
  964. portalContainer={fullscreenContainerRef?.current}
  965. align="left"
  966. agentName={
  967. presentationDiscussionParticipant?.name ||
  968. presentationDiscussionAgentConfig?.name
  969. }
  970. agentAvatar={
  971. presentationDiscussionParticipant?.avatar ||
  972. presentationDiscussionAgentConfig?.avatar
  973. }
  974. agentColor={presentationDiscussionAgentConfig?.color}
  975. onSkip={() => onDiscussionSkip?.()}
  976. onListen={() => onDiscussionStart?.(discussionRequest)}
  977. onTogglePause={() => onPlayPause?.()}
  978. />
  979. )}
  980. </AnimatePresence>
  981. </motion.div>
  982. )}
  983. </AnimatePresence>
  984. </div>
  985. </div>
  986. );
  987. }
  988. return (
  989. <div
  990. className={cn(
  991. 'h-[192px] w-full flex flex-col relative z-10 transition-all duration-300',
  992. isPresenting && !controlsVisible
  993. ? 'border-t border-transparent bg-transparent backdrop-blur-none'
  994. : 'border-t border-gray-100 dark:border-gray-800 bg-white/60 dark:bg-gray-800/60 backdrop-blur-md',
  995. )}
  996. >
  997. {/* ── Toolbar strip — merged from CanvasArea ── */}
  998. <div
  999. className={cn(
  1000. 'transition-opacity duration-300',
  1001. isPresenting && !controlsVisible && 'opacity-0 pointer-events-none',
  1002. )}
  1003. >
  1004. {toolbar}
  1005. </div>
  1006. {/* ── Interaction area — three-column layout ── */}
  1007. <div className="flex-1 flex items-stretch min-h-0">
  1008. {/* Left: Teacher identity */}
  1009. <div
  1010. className={cn(
  1011. 'w-[90px] shrink-0 flex flex-col border-r border-gray-100/50 dark:border-gray-700/50 bg-white/40 dark:bg-gray-900/40 overflow-visible relative transition-opacity duration-300',
  1012. isPresenting && !controlsVisible && 'opacity-0 pointer-events-none',
  1013. )}
  1014. >
  1015. {/* Decorative Element (Top) */}
  1016. <div className="absolute top-0 inset-x-0 h-16 bg-gradient-to-b from-purple-50/50 dark:from-purple-900/10 to-transparent pointer-events-none" />
  1017. <div className="absolute top-3 inset-x-0 flex flex-col items-center justify-center gap-1 opacity-10 pointer-events-none">
  1018. <BookOpen size={20} className="text-purple-900 dark:text-purple-100" />
  1019. <div className="w-8 h-0.5 bg-purple-900 dark:bg-purple-100 rounded-full" />
  1020. </div>
  1021. {/* Main Content */}
  1022. <div className="flex-1 flex items-center justify-center gap-3 px-2 min-h-0 pb-1 pt-8">
  1023. {/* Avatar Group (Left) */}
  1024. <div
  1025. ref={teacherAvatarRef}
  1026. className="relative group cursor-pointer flex flex-col items-center justify-center gap-1"
  1027. >
  1028. <HoverCard openDelay={300} closeDelay={100}>
  1029. <HoverCardTrigger asChild>
  1030. <div className="flex flex-col items-center gap-1">
  1031. <div
  1032. className={cn(
  1033. 'relative w-12 h-12 rounded-full transition-all duration-500 flex items-center justify-center',
  1034. activeRole === 'teacher' ? 'scale-105' : 'opacity-90 scale-95',
  1035. )}
  1036. >
  1037. <div
  1038. className={cn(
  1039. 'absolute inset-0 rounded-full border-2 transition-all duration-500',
  1040. activeRole === 'teacher'
  1041. ? 'border-purple-500 dark:border-purple-400 shadow-[0_0_12px_rgba(168,85,247,0.4)]'
  1042. : 'border-gray-200 dark:border-gray-700 group-hover:border-purple-300 dark:group-hover:border-purple-600',
  1043. )}
  1044. />
  1045. <div className="w-10 h-10 rounded-full bg-white dark:bg-gray-800 overflow-hidden relative z-10 shadow-sm border border-gray-50 dark:border-gray-700">
  1046. <img
  1047. src={teacherAvatar}
  1048. alt={teacherName}
  1049. className="w-full h-full object-cover"
  1050. />
  1051. </div>
  1052. {activeRole === 'teacher' && (
  1053. <div className="absolute -right-0.5 top-0.5 w-4 h-4 bg-green-500 dark:bg-green-400 rounded-full border-2 border-white dark:border-gray-800 flex items-center justify-center z-20">
  1054. <div className="w-1 h-1 bg-white rounded-full animate-pulse" />
  1055. </div>
  1056. )}
  1057. </div>
  1058. <span
  1059. className={cn(
  1060. 'max-w-[80px] truncate px-2 py-0.5 rounded-full text-[10px] font-bold tracking-wider uppercase border shadow-sm transition-all duration-300 bg-white/90 dark:bg-gray-800/90',
  1061. activeRole === 'teacher' && !speakingStudent
  1062. ? 'text-purple-600 dark:text-purple-400 border-purple-200 dark:border-purple-700'
  1063. : 'text-gray-400 dark:text-gray-500 border-gray-100 dark:border-gray-700 group-hover:text-purple-500 dark:group-hover:text-purple-400 group-hover:border-purple-200 dark:group-hover:border-purple-600',
  1064. )}
  1065. >
  1066. {teacherName}
  1067. </span>
  1068. </div>
  1069. </HoverCardTrigger>
  1070. <HoverCardContent
  1071. side="bottom"
  1072. align="center"
  1073. className="w-64 p-3 max-h-[300px] overflow-y-auto"
  1074. >
  1075. {(() => {
  1076. const teacherConfig = getAgentConfig(teacherParticipant?.id || '');
  1077. return (
  1078. <>
  1079. <div className="flex items-center gap-2">
  1080. <div className="w-8 h-8 rounded-full overflow-hidden shrink-0 bg-gray-100 dark:bg-gray-800">
  1081. <img
  1082. src={teacherAvatar}
  1083. alt={teacherName}
  1084. className="w-full h-full object-cover"
  1085. />
  1086. </div>
  1087. <div className="min-w-0">
  1088. <p className="text-sm font-medium truncate">{teacherName}</p>
  1089. <span
  1090. className="inline-block text-[10px] leading-tight px-1.5 py-0.5 rounded-full text-white mt-0.5"
  1091. style={{
  1092. backgroundColor: teacherConfig?.color || '#8b5cf6',
  1093. }}
  1094. >
  1095. {t('settings.agentRoles.teacher')}
  1096. </span>
  1097. </div>
  1098. </div>
  1099. {teacherConfig?.persona && (
  1100. <p className="text-xs text-muted-foreground mt-2 leading-relaxed whitespace-pre-line">
  1101. {teacherConfig.persona}
  1102. </p>
  1103. )}
  1104. </>
  1105. );
  1106. })()}
  1107. </HoverCardContent>
  1108. </HoverCard>
  1109. {/* ProactiveCard from teacher avatar */}
  1110. <AnimatePresence>
  1111. {discussionRequest && discussionRequest.agentId === teacherParticipant?.id && (
  1112. <ProactiveCard
  1113. action={discussionRequest}
  1114. mode={engineMode === 'paused' ? 'paused' : 'playback'}
  1115. anchorRef={teacherAvatarRef}
  1116. align="left"
  1117. agentName={teacherName}
  1118. agentAvatar={teacherAvatar}
  1119. agentColor={getAgentConfig(teacherParticipant?.id || '')?.color}
  1120. onSkip={() => onDiscussionSkip?.()}
  1121. onListen={() => onDiscussionStart?.(discussionRequest)}
  1122. onTogglePause={() => onPlayPause?.()}
  1123. />
  1124. )}
  1125. </AnimatePresence>
  1126. </div>
  1127. </div>
  1128. </div>
  1129. {/* Center: Interaction stage */}
  1130. <div className="flex-1 relative mx-3 mb-2">
  1131. {/* End flash banner (Issue 3) */}
  1132. <AnimatePresence>
  1133. {endFlashVisible && (
  1134. <motion.div
  1135. initial={{ opacity: 0, y: -10, scale: 0.9 }}
  1136. animate={{
  1137. opacity: [0, 1, 1, 0],
  1138. y: [-10, 0, 0, -6],
  1139. scale: [0.9, 1, 1, 0.95],
  1140. }}
  1141. transition={{
  1142. duration: 1.8,
  1143. times: [0, 0.15, 0.7, 1],
  1144. ease: 'easeOut',
  1145. }}
  1146. className="absolute top-1 left-1/2 -translate-x-1/2 z-50 bg-gray-800/80 backdrop-blur-md text-white px-3.5 py-1.5 rounded-full text-xs font-medium pointer-events-none"
  1147. >
  1148. <span className="w-1.5 h-1.5 rounded-full bg-gray-400 inline-block mr-1.5" />
  1149. {endFlashSessionType === 'discussion'
  1150. ? t('roundtable.discussionEnded')
  1151. : t('roundtable.qaEnded')}
  1152. </motion.div>
  1153. )}
  1154. </AnimatePresence>
  1155. <div
  1156. onClick={() => {
  1157. if (isInputOpen || isVoiceOpen) {
  1158. setIsInputOpen(false);
  1159. setIsVoiceOpen(false);
  1160. if (isRecording || isProcessing) cancelRecording();
  1161. }
  1162. }}
  1163. className="relative w-full h-full rounded-[2.5rem] bg-gradient-to-b from-white/40 to-white/80 dark:from-gray-800/40 dark:to-gray-800/80 backdrop-blur-xl border border-white/50 dark:border-gray-700/50 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.05),inset_0_1px_0_0_rgba(255,255,255,0.9)] dark:shadow-[0_20px_60px_-15px_rgba(0,0,0,0.3)] flex flex-col justify-center px-6 overflow-hidden group transition-all duration-700 cursor-default"
  1164. >
  1165. {/* Text input box */}
  1166. <AnimatePresence>
  1167. {isInputOpen && (
  1168. <motion.div
  1169. key="input-stage"
  1170. initial={{
  1171. opacity: 0,
  1172. scale: 0.95,
  1173. y: 15,
  1174. filter: 'blur(4px)',
  1175. }}
  1176. animate={{ opacity: 1, scale: 1, y: 0, filter: 'blur(0px)' }}
  1177. exit={{ opacity: 0, scale: 0.95, y: 15, filter: 'blur(4px)' }}
  1178. onClick={(e) => e.stopPropagation()}
  1179. className="absolute inset-x-6 bottom-4 z-20 flex items-center justify-end"
  1180. >
  1181. <div className="relative w-fit max-w-[85%] sm:max-w-[65%] min-w-[200px] sm:min-w-[300px] bg-white/90 dark:bg-gray-800/90 backdrop-blur-md p-2 pr-2 rounded-2xl rounded-br-none shadow-2xl border border-purple-200 dark:border-purple-700 flex items-end gap-2 ring-1 ring-purple-100/50 dark:ring-purple-800/50">
  1182. <div className="pl-4 flex-1 py-1 min-w-0">
  1183. <textarea
  1184. value={inputValue}
  1185. onChange={(e) => setInputValue(e.target.value)}
  1186. onKeyDown={(e) => {
  1187. if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
  1188. e.preventDefault();
  1189. handleSendMessage();
  1190. }
  1191. }}
  1192. placeholder={t('roundtable.inputPlaceholder')}
  1193. autoFocus
  1194. rows={1}
  1195. className="w-full resize-none bg-transparent border-none focus:ring-0 focus:outline-none outline-none shadow-none ring-0 text-gray-700 dark:text-gray-200 text-sm placeholder:text-gray-400 dark:placeholder:text-gray-500 min-h-[40px] max-h-[120px]"
  1196. style={{ fieldSizing: 'content' } as Record<string, string>}
  1197. />
  1198. </div>
  1199. <button
  1200. onClick={handleSendMessage}
  1201. disabled={isSendCooldown}
  1202. className={cn(
  1203. 'p-2.5 text-white rounded-xl transition shadow-md mb-0.5 shrink-0',
  1204. isSendCooldown
  1205. ? 'bg-gray-400 dark:bg-gray-600 cursor-not-allowed shadow-gray-200 dark:shadow-gray-900/50'
  1206. : 'bg-purple-600 hover:bg-purple-700 dark:bg-purple-500 dark:hover:bg-purple-600 shadow-purple-200 dark:shadow-purple-900/50',
  1207. )}
  1208. >
  1209. {isSendCooldown ? (
  1210. <Loader2 className="w-4 h-4 animate-spin" />
  1211. ) : (
  1212. <Send className="w-4 h-4" />
  1213. )}
  1214. </button>
  1215. </div>
  1216. </motion.div>
  1217. )}
  1218. {/* Audio recording status */}
  1219. {isVoiceOpen && (
  1220. <motion.div
  1221. key="voice-stage"
  1222. initial={{
  1223. opacity: 0,
  1224. scale: 0.9,
  1225. x: 20,
  1226. filter: 'blur(4px)',
  1227. }}
  1228. animate={{ opacity: 1, scale: 1, x: 0, filter: 'blur(0px)' }}
  1229. exit={{ opacity: 0, scale: 0.9, x: 20, filter: 'blur(4px)' }}
  1230. onClick={(e) => e.stopPropagation()}
  1231. className="absolute right-4 top-1/2 -translate-y-1/2 z-30 flex items-center gap-4 pr-2 pointer-events-none"
  1232. >
  1233. <div className="flex flex-col-reverse items-end gap-1 mr-[-10px] relative z-20">
  1234. <div className="flex items-center gap-0.5 h-8 px-2 py-1.5 bg-white/80 dark:bg-gray-800/80 backdrop-blur-md rounded-xl border border-purple-100 dark:border-purple-800 shadow-sm">
  1235. <VoiceWaveformBars barClassName="bg-gradient-to-t from-purple-500 to-indigo-600 dark:from-purple-400 dark:to-indigo-500" />
  1236. </div>
  1237. <motion.div
  1238. initial={{ opacity: 0, x: 10 }}
  1239. animate={{ opacity: 1, x: 0 }}
  1240. className="text-[10px] font-bold tracking-widest text-purple-600 dark:text-purple-400 uppercase bg-white/90 dark:bg-gray-800/90 backdrop-blur-sm px-2 py-0.5 rounded-full shadow-sm border border-purple-100/50 dark:border-purple-800/50 mr-1"
  1241. >
  1242. {isProcessing ? t('roundtable.processing') : t('roundtable.listening')}
  1243. </motion.div>
  1244. </div>
  1245. <div
  1246. className="pointer-events-auto relative group cursor-pointer"
  1247. onClick={handleToggleVoice}
  1248. >
  1249. <div className="relative w-16 h-16 rounded-full bg-gradient-to-br from-purple-600 to-indigo-700 dark:from-purple-500 dark:to-indigo-600 shadow-[0_4px_20px_rgba(147,51,234,0.3)] flex items-center justify-center z-20 group-hover:scale-105 transition-transform duration-300 border border-white/20 dark:border-white/10">
  1250. <Mic className="w-6 h-6 text-white" />
  1251. </div>
  1252. <div className="absolute inset-0 rounded-full border-2 border-purple-500 dark:border-purple-400 opacity-40 animate-[ping_2s_ease-in-out_infinite] z-10" />
  1253. <div className="absolute inset-0 rounded-full border border-indigo-400 dark:border-indigo-300 opacity-20 animate-[ping_3s_ease-in-out_infinite_0.5s] z-10" />
  1254. <div className="absolute inset-0 bg-purple-600 dark:bg-purple-500 blur-2xl opacity-20 group-hover:opacity-40 transition-opacity z-0" />
  1255. </div>
  1256. </motion.div>
  1257. )}
  1258. </AnimatePresence>
  1259. {/* Thinking dots (Issue 5) */}
  1260. <AnimatePresence>
  1261. {thinkingState?.stage === 'director' && !currentSpeech && !userMessage && (
  1262. <motion.div
  1263. initial={{ opacity: 0, scale: 0.9 }}
  1264. animate={{ opacity: 1, scale: 1 }}
  1265. exit={{ opacity: 0, scale: 0.9 }}
  1266. className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20 flex items-center gap-2 px-4 py-2 bg-white/90 dark:bg-gray-800/90 backdrop-blur-md rounded-full shadow-sm border border-gray-100 dark:border-gray-700"
  1267. >
  1268. <div className="flex gap-1">
  1269. <motion.div
  1270. animate={{ opacity: [0.3, 1, 0.3] }}
  1271. transition={{
  1272. repeat: Infinity,
  1273. duration: 1.2,
  1274. delay: 0,
  1275. }}
  1276. className="w-1.5 h-1.5 rounded-full bg-purple-500"
  1277. />
  1278. <motion.div
  1279. animate={{ opacity: [0.3, 1, 0.3] }}
  1280. transition={{
  1281. repeat: Infinity,
  1282. duration: 1.2,
  1283. delay: 0.2,
  1284. }}
  1285. className="w-1.5 h-1.5 rounded-full bg-purple-500"
  1286. />
  1287. <motion.div
  1288. animate={{ opacity: [0.3, 1, 0.3] }}
  1289. transition={{
  1290. repeat: Infinity,
  1291. duration: 1.2,
  1292. delay: 0.4,
  1293. }}
  1294. className="w-1.5 h-1.5 rounded-full bg-purple-500"
  1295. />
  1296. </div>
  1297. <span className="text-[10px] text-gray-400 dark:text-gray-500 font-medium">
  1298. {t('roundtable.thinking')}
  1299. </span>
  1300. </motion.div>
  1301. )}
  1302. </AnimatePresence>
  1303. {/* Cue user: centered indicator when waiting for user input */}
  1304. <AnimatePresence>
  1305. {isCueUser && !bubbleRole && !thinkingState && !isInputOpen && !isVoiceOpen && (
  1306. <motion.div
  1307. initial={{ opacity: 0, scale: 0.85 }}
  1308. animate={{ opacity: 1, scale: 1 }}
  1309. exit={{ opacity: 0, scale: 0.85 }}
  1310. transition={{ duration: 0.35, ease: [0.21, 1, 0.36, 1] }}
  1311. className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20 flex flex-col items-center gap-2.5"
  1312. >
  1313. {/* Button with ripple effect */}
  1314. <div className="relative flex items-center justify-center">
  1315. {/* Soft background glow */}
  1316. <div
  1317. className={cn(
  1318. 'absolute w-24 h-24 rounded-full blur-2xl',
  1319. asrEnabled
  1320. ? 'bg-amber-400/[0.08] dark:bg-amber-500/[0.06]'
  1321. : 'bg-purple-400/[0.08] dark:bg-purple-500/[0.06]',
  1322. )}
  1323. />
  1324. {/* Expanding ripple 1 */}
  1325. <motion.div
  1326. animate={{ scale: [1, 2.2], opacity: [0.25, 0] }}
  1327. transition={{
  1328. repeat: Infinity,
  1329. duration: 2.2,
  1330. ease: 'easeOut',
  1331. }}
  1332. className={cn(
  1333. 'absolute w-11 h-11 rounded-full border',
  1334. asrEnabled
  1335. ? 'border-amber-400/50 dark:border-amber-500/35'
  1336. : 'border-purple-400/50 dark:border-purple-500/35',
  1337. )}
  1338. />
  1339. {/* Expanding ripple 2 */}
  1340. <motion.div
  1341. animate={{ scale: [1, 2.2], opacity: [0.25, 0] }}
  1342. transition={{
  1343. repeat: Infinity,
  1344. duration: 2.2,
  1345. ease: 'easeOut',
  1346. delay: 0.7,
  1347. }}
  1348. className={cn(
  1349. 'absolute w-11 h-11 rounded-full border',
  1350. asrEnabled
  1351. ? 'border-amber-300/40 dark:border-amber-400/25'
  1352. : 'border-purple-300/40 dark:border-purple-400/25',
  1353. )}
  1354. />
  1355. {/* Action circle — voice (ASR on) or text input (ASR off) */}
  1356. <motion.button
  1357. onClick={(e) => {
  1358. e.stopPropagation();
  1359. if (asrEnabled) handleToggleVoice();
  1360. else handleToggleInput();
  1361. }}
  1362. animate={{ scale: [1, 1.05, 1] }}
  1363. transition={{
  1364. repeat: Infinity,
  1365. duration: 2,
  1366. ease: 'easeInOut',
  1367. }}
  1368. className={cn(
  1369. 'relative w-11 h-11 rounded-full flex items-center justify-center shadow-lg cursor-pointer hover:shadow-xl active:scale-95 z-10 bg-gradient-to-br',
  1370. asrEnabled
  1371. ? 'from-amber-400 to-orange-500 dark:from-amber-500 dark:to-orange-600 shadow-amber-400/30 dark:shadow-amber-600/20 hover:shadow-amber-400/40 dark:hover:shadow-amber-600/30'
  1372. : 'from-purple-400 to-indigo-500 dark:from-purple-500 dark:to-indigo-600 shadow-purple-400/30 dark:shadow-purple-600/20 hover:shadow-purple-400/40 dark:hover:shadow-purple-600/30',
  1373. )}
  1374. >
  1375. {asrEnabled ? (
  1376. <Mic className="w-[18px] h-[18px] text-white drop-shadow-sm" />
  1377. ) : (
  1378. <MessageSquare className="w-[18px] h-[18px] text-white drop-shadow-sm" />
  1379. )}
  1380. </motion.button>
  1381. </div>
  1382. {/* Visual indicator below button */}
  1383. {asrEnabled ? (
  1384. <div className="flex items-center justify-center gap-[3px] h-3">
  1385. {[0, 1, 2, 3, 4, 3, 2, 1, 0].map((intensity, i) => (
  1386. <motion.div
  1387. key={i}
  1388. animate={{
  1389. scaleY: [0.3, 0.5 + intensity * 0.15, 0.3],
  1390. opacity: [0.3, 0.7, 0.3],
  1391. }}
  1392. transition={{
  1393. repeat: Infinity,
  1394. duration: 0.8 + (i % 3) * 0.1,
  1395. delay: i * 0.08,
  1396. ease: 'easeInOut',
  1397. }}
  1398. className="w-[2.5px] h-full origin-center rounded-full bg-amber-400/70 dark:bg-amber-500/60"
  1399. />
  1400. ))}
  1401. </div>
  1402. ) : (
  1403. <div className="flex items-center justify-center gap-[3px] h-3">
  1404. {[0, 1, 2, 3, 2, 1, 0].map((intensity, i) => (
  1405. <motion.div
  1406. key={i}
  1407. animate={{
  1408. scaleY: [0.3, 0.45 + intensity * 0.15, 0.3],
  1409. opacity: [0.25, 0.6, 0.25],
  1410. }}
  1411. transition={{
  1412. repeat: Infinity,
  1413. duration: 1.0 + (i % 3) * 0.15,
  1414. delay: i * 0.12,
  1415. ease: 'easeInOut',
  1416. }}
  1417. className="w-[2.5px] h-full origin-center rounded-full bg-purple-400/60 dark:bg-purple-500/50"
  1418. />
  1419. ))}
  1420. </div>
  1421. )}
  1422. {/* Label */}
  1423. <motion.span
  1424. animate={{ opacity: [0.5, 0.9, 0.5] }}
  1425. transition={{
  1426. repeat: Infinity,
  1427. duration: 2.5,
  1428. ease: 'easeInOut',
  1429. }}
  1430. className={cn(
  1431. 'text-[10px] font-medium tracking-wider',
  1432. asrEnabled
  1433. ? 'text-amber-600/70 dark:text-amber-400/60'
  1434. : 'text-purple-600/70 dark:text-purple-400/60',
  1435. )}
  1436. >
  1437. {t('roundtable.yourTurn')}
  1438. </motion.span>
  1439. </motion.div>
  1440. )}
  1441. </AnimatePresence>
  1442. {/* Chat bubble */}
  1443. <AnimatePresence mode="wait">
  1444. {bubbleRole && (
  1445. <motion.div
  1446. key={bubbleKey}
  1447. initial={{ opacity: 0, y: 8 }}
  1448. animate={{
  1449. opacity: isInputOpen || isVoiceOpen ? 0.4 : 1,
  1450. y: 0,
  1451. filter: isInputOpen || isVoiceOpen ? 'blur(1px) grayscale(0.2)' : 'none',
  1452. }}
  1453. exit={{ opacity: 0, y: -8, transition: { duration: 0.12 } }}
  1454. transition={{ duration: 0.2, ease: [0.21, 1, 0.36, 1] }}
  1455. className="w-full flex items-center relative z-10"
  1456. >
  1457. <div
  1458. className={cn(
  1459. 'flex w-full transition-all duration-500',
  1460. bubbleRole === 'teacher' ? 'justify-start' : 'justify-end',
  1461. )}
  1462. >
  1463. <div
  1464. onClick={(e) => {
  1465. e.stopPropagation();
  1466. if (bubbleRole === 'user') return;
  1467. // Topic pending: click Play to resume
  1468. if (isTopicPending) {
  1469. onResumeTopic?.();
  1470. return;
  1471. }
  1472. // QA/Discussion: buffer-level pause/resume (freeze text reveal, SSE continues)
  1473. if (isInLiveFlow) {
  1474. if (isDiscussionPaused) {
  1475. onDiscussionResume?.();
  1476. } else if (!thinkingState && currentSpeech) {
  1477. // Don't allow pause during thinking or before text arrives
  1478. onDiscussionPause?.();
  1479. }
  1480. return;
  1481. }
  1482. // Lecture playback: toggle play/pause
  1483. onPlayPause?.();
  1484. }}
  1485. className={cn(
  1486. 'relative px-4 pt-2 pb-3 rounded-2xl text-[15px] leading-relaxed transition-all border w-[min(420px,calc(100%-3rem))] group/bubble flex flex-col max-h-[110px]',
  1487. bubbleRole === 'teacher' ? 'pl-4 pr-10' : 'pl-4 pr-10',
  1488. bubbleRole === 'user'
  1489. ? 'bg-purple-600/95 dark:bg-purple-500/95 backdrop-blur-sm border-purple-400/40 dark:border-purple-300/40 text-white rounded-br-sm shadow-md shadow-purple-300/30 dark:shadow-purple-800/30'
  1490. : bubbleRole === 'agent'
  1491. ? cn(
  1492. 'bg-blue-50/95 dark:bg-blue-950/60 backdrop-blur-sm border-blue-200/60 dark:border-blue-800/60 text-gray-700 dark:text-gray-200 rounded-br-sm shadow-sm',
  1493. (isInLiveFlow || isTopicPending) &&
  1494. 'hover:shadow-md cursor-pointer',
  1495. )
  1496. : 'bg-white dark:bg-gray-800 border-gray-100 dark:border-gray-700 text-gray-700 dark:text-gray-200 rounded-bl-sm shadow-sm hover:shadow-md cursor-pointer',
  1497. )}
  1498. >
  1499. {bubbleRole &&
  1500. (() => {
  1501. const bubbleAvatar =
  1502. bubbleRole === 'user'
  1503. ? userAvatar
  1504. : bubbleRole === 'agent'
  1505. ? speakingStudent?.avatar || userAvatar
  1506. : teacherAvatar;
  1507. return (
  1508. <div
  1509. className={cn(
  1510. 'absolute -top-2.5 z-20 pointer-events-none select-none',
  1511. bubbleRole === 'teacher' ? '-left-2.5' : '-right-2.5',
  1512. )}
  1513. title={bubbleName}
  1514. >
  1515. <div
  1516. className={cn(
  1517. 'w-6 h-6 rounded-full overflow-hidden border-2 shadow-sm',
  1518. bubbleRole === 'user'
  1519. ? 'border-purple-400 dark:border-purple-500'
  1520. : bubbleRole === 'agent'
  1521. ? 'border-blue-300 dark:border-blue-600'
  1522. : 'border-purple-200 dark:border-purple-700',
  1523. )}
  1524. >
  1525. <AvatarDisplay src={bubbleAvatar} alt={bubbleName} />
  1526. </div>
  1527. </div>
  1528. );
  1529. })()}
  1530. <div ref={bubbleScrollRef} className="overflow-y-auto scrollbar-hide">
  1531. {/* Agent name + audio indicator header */}
  1532. {bubbleRole !== 'user' && bubbleName && (
  1533. <div className="flex items-center gap-1 mb-0.5">
  1534. <span className="text-[10px] font-semibold text-gray-400 dark:text-gray-500 truncate">
  1535. {bubbleName}
  1536. </span>
  1537. {(() => {
  1538. const aiState =
  1539. speakingAgentId === audioAgentId
  1540. ? (audioIndicatorState ?? 'idle')
  1541. : 'idle';
  1542. if (aiState === 'generating')
  1543. return (
  1544. <Loader2 className="w-3 h-3 text-amber-500 dark:text-amber-400 animate-spin" />
  1545. );
  1546. if (aiState === 'playing')
  1547. return (
  1548. <Volume2 className="w-3 h-3 text-gray-400 dark:text-gray-500" />
  1549. );
  1550. return null;
  1551. })()}
  1552. </div>
  1553. )}
  1554. {isBubbleLoading ? (
  1555. <div className="flex gap-1 items-center py-1">
  1556. <motion.div
  1557. animate={{ opacity: [0.3, 1, 0.3] }}
  1558. transition={{
  1559. repeat: Infinity,
  1560. duration: 1,
  1561. delay: 0,
  1562. }}
  1563. className={cn(
  1564. 'w-1.5 h-1.5 rounded-full',
  1565. isAgentLoading
  1566. ? 'bg-blue-400 dark:bg-blue-500'
  1567. : 'bg-purple-400 dark:bg-purple-500',
  1568. )}
  1569. />
  1570. <motion.div
  1571. animate={{ opacity: [0.3, 1, 0.3] }}
  1572. transition={{
  1573. repeat: Infinity,
  1574. duration: 1,
  1575. delay: 0.2,
  1576. }}
  1577. className={cn(
  1578. 'w-1.5 h-1.5 rounded-full',
  1579. isAgentLoading
  1580. ? 'bg-blue-400 dark:bg-blue-500'
  1581. : 'bg-purple-400 dark:bg-purple-500',
  1582. )}
  1583. />
  1584. <motion.div
  1585. animate={{ opacity: [0.3, 1, 0.3] }}
  1586. transition={{
  1587. repeat: Infinity,
  1588. duration: 1,
  1589. delay: 0.4,
  1590. }}
  1591. className={cn(
  1592. 'w-1.5 h-1.5 rounded-full',
  1593. isAgentLoading
  1594. ? 'bg-blue-400 dark:bg-blue-500'
  1595. : 'bg-purple-400 dark:bg-purple-500',
  1596. )}
  1597. />
  1598. </div>
  1599. ) : (
  1600. <p className="whitespace-pre-wrap break-words" suppressHydrationWarning>
  1601. {sourceText}
  1602. {isTopicPending && (
  1603. <span className="inline-block w-1.5 h-1.5 rounded-full bg-red-500 ml-1 align-middle" />
  1604. )}
  1605. </p>
  1606. )}
  1607. </div>
  1608. {/* Playback state icon (hidden during loading — dots already indicate activity) */}
  1609. {bubbleRole !== 'user' &&
  1610. !isBubbleLoading &&
  1611. (() => {
  1612. const btnState = playbackView?.buttonState ?? 'none';
  1613. const barsColor =
  1614. bubbleRole === 'agent' ? 'bg-blue-500' : 'bg-purple-500';
  1615. if (btnState === 'none') return null;
  1616. if (btnState === 'play') {
  1617. return (
  1618. <div className="absolute right-2.5 bottom-2.5 p-1.5 rounded-full bg-gray-50/80 dark:bg-gray-700/80 hover:bg-purple-100 dark:hover:bg-purple-900/50 group-hover/bubble:bg-purple-100 dark:group-hover/bubble:bg-purple-900/50 transition-all duration-300 cursor-pointer">
  1619. <Play className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 hover:text-purple-600 dark:hover:text-purple-400 group-hover/bubble:text-purple-600 dark:group-hover/bubble:text-purple-400 ml-0.5" />
  1620. </div>
  1621. );
  1622. }
  1623. if (btnState === 'restart') {
  1624. return (
  1625. <div className="absolute right-2.5 bottom-2.5 p-1.5 rounded-full bg-gray-50/80 dark:bg-gray-700/80 hover:bg-purple-100 dark:hover:bg-purple-900/50 group-hover/bubble:bg-purple-100 dark:group-hover/bubble:bg-purple-900/50 transition-all duration-300 cursor-pointer">
  1626. <Repeat className="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 hover:text-purple-600 dark:hover:text-purple-400 group-hover/bubble:text-purple-600 dark:group-hover/bubble:text-purple-400" />
  1627. </div>
  1628. );
  1629. }
  1630. // btnState === 'bars'
  1631. return (
  1632. <div className="absolute right-2.5 bottom-2.5 p-1.5 rounded-full bg-gray-50/80 dark:bg-gray-700/80 group-hover/bubble:bg-purple-100 dark:group-hover/bubble:bg-purple-900/50 transition-all duration-300">
  1633. {isDiscussionPaused ? (
  1634. /* Paused: static Play icon */
  1635. <Play className="w-3.5 h-3.5 text-amber-500 dark:text-amber-400 group-hover/bubble:text-purple-600 dark:group-hover/bubble:text-purple-400 ml-0.5" />
  1636. ) : (
  1637. <>
  1638. {/* Breathing bars — visible by default, hidden on hover */}
  1639. <div className="flex gap-0.5 items-end justify-center h-3.5 w-3.5 group-hover/bubble:hidden">
  1640. <motion.div
  1641. animate={{ height: ['20%', '100%', '20%'] }}
  1642. transition={{
  1643. repeat: Infinity,
  1644. duration: 0.6,
  1645. }}
  1646. className={cn('w-1 rounded-full', barsColor)}
  1647. />
  1648. <motion.div
  1649. animate={{ height: ['40%', '100%', '40%'] }}
  1650. transition={{
  1651. repeat: Infinity,
  1652. duration: 0.4,
  1653. }}
  1654. className={cn('w-1 rounded-full', barsColor)}
  1655. />
  1656. <motion.div
  1657. animate={{ height: ['20%', '80%', '20%'] }}
  1658. transition={{
  1659. repeat: Infinity,
  1660. duration: 0.5,
  1661. }}
  1662. className={cn('w-1 rounded-full', barsColor)}
  1663. />
  1664. </div>
  1665. {/* Pause icon on hover */}
  1666. <Pause className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400 hidden group-hover/bubble:block" />
  1667. </>
  1668. )}
  1669. </div>
  1670. );
  1671. })()}
  1672. </div>
  1673. </div>
  1674. </motion.div>
  1675. )}
  1676. </AnimatePresence>
  1677. </div>
  1678. </div>
  1679. {/* Right: Participants area */}
  1680. <div
  1681. className={cn(
  1682. 'w-[140px] shrink-0 flex flex-col py-3 border-l border-gray-100/50 dark:border-gray-700/50 bg-gray-50/30 dark:bg-gray-900/30 overflow-visible transition-opacity duration-300',
  1683. isPresenting && !controlsVisible && 'opacity-0 pointer-events-none',
  1684. )}
  1685. >
  1686. {/* Companion agent avatars — horizontal row, scrollable on overflow, arrows on hover */}
  1687. <div className="flex-none relative group/scroll">
  1688. {/* Left arrow */}
  1689. <button
  1690. onClick={() => {
  1691. agentScrollRef.current?.scrollBy({
  1692. left: -80,
  1693. behavior: 'smooth',
  1694. });
  1695. }}
  1696. className="absolute left-0 top-0 bottom-0 w-5 z-10 flex items-center justify-center bg-gradient-to-r from-gray-50/90 dark:from-gray-900/90 to-transparent opacity-0 group-hover/scroll:opacity-100 transition-opacity cursor-pointer"
  1697. >
  1698. <ChevronLeft className="w-3.5 h-3.5 text-gray-400" />
  1699. </button>
  1700. <div
  1701. ref={agentScrollRef}
  1702. className="overflow-x-auto overflow-y-hidden px-2 scrollbar-hide"
  1703. onWheel={(e) => {
  1704. if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
  1705. e.currentTarget.scrollLeft += e.deltaY;
  1706. e.preventDefault();
  1707. }
  1708. }}
  1709. >
  1710. <div className="flex gap-1 w-max py-1">
  1711. {studentParticipants.map((student) => {
  1712. const isSpeaking = speakingAgentId === student.id;
  1713. const isThinkingAgent =
  1714. thinkingState?.stage === 'agent_loading' &&
  1715. thinkingState.agentId === student.id;
  1716. const agentConfig = getAgentConfig(student.id);
  1717. const roleLabelKey = agentConfig?.role as
  1718. | 'teacher'
  1719. | 'assistant'
  1720. | 'student'
  1721. | undefined;
  1722. const roleLabel = roleLabelKey ? t(`settings.agentRoles.${roleLabelKey}`) : '';
  1723. const i18nDescription = t(`settings.agentDescriptions.${student.id}`);
  1724. const description =
  1725. i18nDescription !== `settings.agentDescriptions.${student.id}`
  1726. ? i18nDescription
  1727. : agentConfig?.persona || '';
  1728. const hasDescription = !!description;
  1729. const isDiscussionAgent =
  1730. !!discussionRequest && discussionRequest.agentId === student.id;
  1731. return (
  1732. <div
  1733. key={student.id}
  1734. data-agent-id={student.id}
  1735. ref={(el) => {
  1736. if (el) studentAvatarRefs.current.set(student.id, el);
  1737. else studentAvatarRefs.current.delete(student.id);
  1738. }}
  1739. className="relative group/student shrink-0"
  1740. >
  1741. {/* Breathing glow for discussion agent */}
  1742. {isDiscussionAgent && (
  1743. <motion.div
  1744. animate={{
  1745. scale: [1, 1.2, 1],
  1746. opacity: [0.7, 0, 0.7],
  1747. }}
  1748. transition={{
  1749. repeat: Infinity,
  1750. duration: 2,
  1751. ease: 'easeInOut',
  1752. }}
  1753. className="absolute inset-0 rounded-full pointer-events-none"
  1754. style={{
  1755. border: `2px solid ${agentConfig?.color || '#d97706'}`,
  1756. }}
  1757. />
  1758. )}
  1759. <HoverCard openDelay={300} closeDelay={100}>
  1760. <HoverCardTrigger asChild>
  1761. <div
  1762. className={cn(
  1763. 'relative w-9 h-9 rounded-full transition-all duration-300 cursor-pointer',
  1764. isSpeaking
  1765. ? 'opacity-100 grayscale-0 scale-110'
  1766. : 'opacity-50 grayscale-[0.2] scale-95 hover:opacity-100 hover:grayscale-0 hover:scale-100',
  1767. )}
  1768. >
  1769. <div
  1770. className={cn(
  1771. 'absolute inset-0 rounded-full border-2 transition-all duration-300',
  1772. isSpeaking
  1773. ? 'border-purple-500 dark:border-purple-400 shadow-[0_0_8px_rgba(168,85,247,0.4)]'
  1774. : 'border-white dark:border-gray-700',
  1775. )}
  1776. />
  1777. <div className="absolute inset-0.5 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
  1778. <img
  1779. src={student.avatar}
  1780. alt={student.name}
  1781. className="w-full h-full"
  1782. />
  1783. </div>
  1784. {/* Speaking indicator */}
  1785. {isSpeaking && (
  1786. <div className="absolute -right-0.5 -top-0.5 w-3 h-3 bg-green-500 rounded-full border border-white dark:border-gray-800 z-20 flex items-center justify-center">
  1787. <div className="w-1 h-1 bg-white rounded-full animate-pulse" />
  1788. </div>
  1789. )}
  1790. {/* Loading indicator (Issue 5) */}
  1791. {isThinkingAgent && (
  1792. <div className="absolute inset-0 rounded-full border-2 border-purple-400 border-t-transparent animate-spin z-20" />
  1793. )}
  1794. </div>
  1795. </HoverCardTrigger>
  1796. <HoverCardContent
  1797. side="bottom"
  1798. align="center"
  1799. className="w-64 p-3 max-h-[300px] overflow-y-auto"
  1800. >
  1801. <div className="flex items-center gap-2">
  1802. <div className="w-8 h-8 rounded-full overflow-hidden shrink-0 bg-gray-100 dark:bg-gray-800">
  1803. <img
  1804. src={student.avatar}
  1805. alt={student.name}
  1806. className="w-full h-full"
  1807. />
  1808. </div>
  1809. <div className="min-w-0">
  1810. <p className="text-sm font-medium truncate">{student.name}</p>
  1811. {roleLabel && roleLabel !== `settings.agentRoles.${roleLabelKey}` && (
  1812. <span
  1813. className="inline-block text-[10px] leading-tight px-1.5 py-0.5 rounded-full text-white mt-0.5"
  1814. style={{
  1815. backgroundColor: agentConfig?.color || '#6b7280',
  1816. }}
  1817. >
  1818. {roleLabel}
  1819. </span>
  1820. )}
  1821. </div>
  1822. </div>
  1823. {hasDescription && (
  1824. <p className="text-xs text-muted-foreground mt-2 leading-relaxed whitespace-pre-line">
  1825. {description}
  1826. </p>
  1827. )}
  1828. </HoverCardContent>
  1829. </HoverCard>
  1830. </div>
  1831. );
  1832. })}
  1833. </div>
  1834. </div>
  1835. {/* Right arrow */}
  1836. <button
  1837. onClick={() => {
  1838. agentScrollRef.current?.scrollBy({
  1839. left: 80,
  1840. behavior: 'smooth',
  1841. });
  1842. }}
  1843. className="absolute right-0 top-0 bottom-0 w-5 z-10 flex items-center justify-center bg-gradient-to-l from-gray-50/90 dark:from-gray-900/90 to-transparent opacity-0 group-hover/scroll:opacity-100 transition-opacity cursor-pointer"
  1844. >
  1845. <ChevronRight className="w-3.5 h-3.5 text-gray-400" />
  1846. </button>
  1847. {/* ProactiveCard for student/non-teacher agents — rendered via portal */}
  1848. <AnimatePresence>
  1849. {discussionRequest &&
  1850. discussionRequest.agentId !== teacherParticipant?.id &&
  1851. (() => {
  1852. const matchedStudent = studentParticipants.find(
  1853. (s) => s.id === discussionRequest.agentId,
  1854. );
  1855. const agentConfig = getAgentConfig(discussionRequest.agentId || '');
  1856. return (
  1857. <ProactiveCard
  1858. action={discussionRequest}
  1859. mode={engineMode === 'paused' ? 'paused' : 'playback'}
  1860. anchorRef={discussionAnchorRef}
  1861. align="left"
  1862. agentName={matchedStudent?.name || agentConfig?.name}
  1863. agentAvatar={matchedStudent?.avatar || agentConfig?.avatar}
  1864. agentColor={agentConfig?.color}
  1865. onSkip={() => onDiscussionSkip?.()}
  1866. onListen={() => onDiscussionStart?.(discussionRequest)}
  1867. onTogglePause={() => onPlayPause?.()}
  1868. />
  1869. );
  1870. })()}
  1871. </AnimatePresence>
  1872. </div>
  1873. {/* Divider */}
  1874. <div className="mx-auto my-1.5 w-8 h-px bg-gray-200 dark:bg-gray-700 opacity-50 shrink-0" />
  1875. {/* User avatar + interaction buttons */}
  1876. <div className="flex-1 flex items-center justify-center gap-3 px-2 min-h-0">
  1877. <div className="flex flex-col gap-1.5 shrink-0">
  1878. {isSendCooldown ? (
  1879. /* Unified cooldown indicator — replaces both buttons with a single dot wave */
  1880. <div className="flex items-center justify-center w-8 h-8">
  1881. <div className="flex items-center gap-[3px]">
  1882. {[0, 1, 2].map((i) => (
  1883. <motion.div
  1884. key={i}
  1885. animate={{
  1886. y: [0, -3, 0],
  1887. opacity: [0.35, 0.9, 0.35],
  1888. }}
  1889. transition={{
  1890. repeat: Infinity,
  1891. duration: 0.9,
  1892. delay: i * 0.12,
  1893. ease: 'easeInOut',
  1894. }}
  1895. className="w-[4px] h-[4px] rounded-full bg-purple-400 dark:bg-purple-400"
  1896. />
  1897. ))}
  1898. </div>
  1899. </div>
  1900. ) : (
  1901. <>
  1902. <button
  1903. onClick={(e) => {
  1904. e.stopPropagation();
  1905. if (asrEnabled) handleToggleVoice();
  1906. }}
  1907. disabled={!asrEnabled}
  1908. className={cn(
  1909. 'w-8 h-8 rounded-full border flex items-center justify-center transition-all active:scale-95 shadow-sm',
  1910. !asrEnabled
  1911. ? 'bg-gray-100 dark:bg-gray-800/50 text-gray-300 dark:text-gray-600 border-gray-200 dark:border-gray-700 cursor-not-allowed'
  1912. : isVoiceOpen
  1913. ? 'bg-purple-600 dark:bg-purple-500 border-purple-600 dark:border-purple-500 text-white shadow-purple-200 dark:shadow-purple-800'
  1914. : 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 hover:bg-purple-50 dark:hover:bg-purple-900/20 hover:text-purple-600 dark:hover:text-purple-400 hover:border-purple-200 dark:hover:border-purple-700',
  1915. )}
  1916. >
  1917. {asrEnabled ? (
  1918. <Mic className="w-3.5 h-3.5" />
  1919. ) : (
  1920. <MicOff className="w-3.5 h-3.5" />
  1921. )}
  1922. </button>
  1923. <button
  1924. onClick={(e) => {
  1925. e.stopPropagation();
  1926. handleToggleInput();
  1927. }}
  1928. className={cn(
  1929. 'w-8 h-8 rounded-full border flex items-center justify-center transition-all active:scale-95 shadow-sm',
  1930. isInputOpen
  1931. ? 'bg-purple-600 dark:bg-purple-500 border-purple-600 dark:border-purple-500 text-white shadow-purple-200 dark:shadow-purple-800'
  1932. : 'bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-400 dark:text-gray-500 hover:bg-purple-50 dark:hover:bg-purple-900/20 hover:text-purple-600 dark:hover:text-purple-400 hover:border-purple-200 dark:hover:border-purple-700',
  1933. )}
  1934. >
  1935. <MessageSquare className="w-3.5 h-3.5" />
  1936. </button>
  1937. </>
  1938. )}
  1939. </div>
  1940. {/* User avatar (big, clickable to open input) */}
  1941. <div
  1942. className="relative group cursor-pointer shrink-0"
  1943. onClick={(e) => {
  1944. e.stopPropagation();
  1945. handleToggleInput();
  1946. }}
  1947. >
  1948. <div
  1949. className={cn(
  1950. 'relative w-16 h-16 rounded-full transition-all duration-300 flex items-center justify-center',
  1951. activeRole === 'user' || isInputOpen || isCueUser
  1952. ? 'scale-105'
  1953. : 'opacity-50 grayscale-[0.2] scale-95 group-hover:opacity-100 group-hover:grayscale-0 group-hover:scale-100',
  1954. )}
  1955. >
  1956. <div
  1957. className={cn(
  1958. 'absolute inset-0 rounded-full border-2 transition-all duration-300',
  1959. isCueUser
  1960. ? 'border-amber-500 dark:border-amber-400 shadow-[0_0_12px_rgba(245,158,11,0.4)] animate-pulse'
  1961. : activeRole === 'user' || isInputOpen
  1962. ? 'border-purple-600 dark:border-purple-400 shadow-[0_0_8px_rgba(168,85,247,0.3)]'
  1963. : 'border-white dark:border-gray-700 group-hover:border-purple-200 dark:group-hover:border-purple-600',
  1964. )}
  1965. />
  1966. <div className="w-14 h-14 rounded-full bg-gray-50 dark:bg-gray-800 overflow-hidden relative z-10 shadow-sm border border-gray-50 dark:border-gray-700 text-2xl">
  1967. <AvatarDisplay src={userAvatar} alt={t('roundtable.you')} />
  1968. </div>
  1969. <div className="absolute top-0 right-0 w-5 h-5 bg-white dark:bg-gray-800 rounded-full flex items-center justify-center shadow-md border border-gray-100 dark:border-gray-700 z-20">
  1970. <div
  1971. className={cn(
  1972. 'w-1.5 h-1.5 rounded-full',
  1973. isInputOpen || isCueUser
  1974. ? 'bg-purple-500 animate-pulse'
  1975. : 'bg-gray-300 dark:bg-gray-600',
  1976. )}
  1977. />
  1978. </div>
  1979. </div>
  1980. {/* Cue user hint (Issue 7) */}
  1981. <AnimatePresence>
  1982. {isCueUser && (
  1983. <motion.div
  1984. initial={{ opacity: 0, y: 4, scale: 0.9 }}
  1985. animate={{ opacity: 1, y: 0, scale: 1 }}
  1986. exit={{ opacity: 0, y: 4, scale: 0.9 }}
  1987. className="absolute -bottom-2 left-1/2 -translate-x-1/2 whitespace-nowrap px-2 py-0.5 bg-amber-500 text-white text-[9px] font-bold rounded-full shadow-sm z-30"
  1988. >
  1989. {t('roundtable.yourTurn')}
  1990. </motion.div>
  1991. )}
  1992. </AnimatePresence>
  1993. </div>
  1994. </div>
  1995. </div>
  1996. </div>
  1997. {/* close interaction row */}
  1998. </div>
  1999. );
  2000. }