| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094 |
- 'use client';
- import { useState, useRef, useCallback, useEffect } from 'react';
- import { motion, AnimatePresence } from 'motion/react';
- import {
- Mic,
- MicOff,
- Send,
- MessageSquare,
- Pause,
- Play,
- ChevronLeft,
- ChevronRight,
- Repeat,
- BookOpen,
- Loader2,
- Volume2,
- } from 'lucide-react';
- import { cn } from '@/lib/utils';
- import type { AudioIndicatorState } from './audio-indicator';
- import { CanvasToolbar } from '@/components/canvas/canvas-toolbar';
- import { useAudioRecorder } from '@/lib/hooks/use-audio-recorder';
- import { useI18n } from '@/lib/hooks/use-i18n';
- import { toast } from 'sonner';
- import { useSettingsStore, PLAYBACK_SPEEDS } from '@/lib/store/settings';
- import { ProactiveCard } from '@/components/chat/proactive-card';
- import { PresentationSpeechOverlay } from '@/components/roundtable/presentation-speech-overlay';
- import { AvatarDisplay } from '@/components/ui/avatar-display';
- import { HoverCard, HoverCardTrigger, HoverCardContent } from '@/components/ui/hover-card';
- import { useAgentRegistry } from '@/lib/orchestration/registry/store';
- import { DEFAULT_TEACHER_AVATAR, DEFAULT_USER_AVATAR } from '@/components/roundtable/constants';
- import type { DiscussionAction } from '@/lib/types/action';
- import type { EngineMode, PlaybackView } from '@/lib/playback';
- import type { Participant } from '@/lib/types/roundtable';
- export interface DiscussionRequest {
- topic: string;
- prompt?: string;
- agentId?: string; // Agent ID to initiate discussion (default: 'default-1')
- }
- interface RoundtableProps {
- readonly mode?: 'playback' | 'autonomous';
- readonly initialParticipants?: Participant[];
- readonly playbackView?: PlaybackView; // Centralised derived state from Stage
- readonly currentSpeech?: string | null; // Live SSE speech (from StreamBuffer — discussion/QA)
- readonly lectureSpeech?: string | null; // Active lecture speech (from PlaybackEngine, full text)
- readonly idleText?: string | null; // Static idle text (first speech action)
- readonly playbackCompleted?: boolean; // True when engine finished all actions (show restart icon)
- readonly discussionRequest?: DiscussionAction | null;
- readonly engineMode?: EngineMode;
- readonly isStreaming?: boolean;
- readonly sessionType?: 'qa' | 'discussion';
- readonly speakingAgentId?: string | null;
- readonly audioIndicatorState?: AudioIndicatorState;
- readonly audioAgentId?: string | null;
- readonly speechProgress?: number | null; // StreamBuffer reveal progress (0–1) for auto-scroll
- readonly showEndFlash?: boolean;
- readonly endFlashSessionType?: 'qa' | 'discussion';
- readonly thinkingState?: { stage: string; agentId?: string } | null;
- readonly isCueUser?: boolean;
- readonly isTopicPending?: boolean;
- readonly onMessageSend?: (message: string) => void;
- readonly onDiscussionStart?: (request: DiscussionAction) => void;
- readonly onDiscussionSkip?: () => void;
- readonly onStopDiscussion?: () => void;
- readonly onInputActivate?: () => void;
- readonly onResumeTopic?: () => void;
- readonly onPlayPause?: () => void;
- readonly isDiscussionPaused?: boolean;
- readonly onDiscussionPause?: () => void;
- readonly onDiscussionResume?: () => void;
- readonly totalActions?: number;
- readonly currentActionIndex?: number;
- // Toolbar props (merged from CanvasArea)
- readonly currentSceneIndex?: number;
- readonly scenesCount?: number;
- readonly whiteboardOpen?: boolean;
- readonly sidebarCollapsed?: boolean;
- readonly chatCollapsed?: boolean;
- readonly onToggleSidebar?: () => void;
- readonly onToggleChat?: () => void;
- readonly onPrevSlide?: () => void;
- readonly onNextSlide?: () => void;
- readonly onWhiteboardClose?: () => void;
- readonly isPresenting?: boolean;
- readonly controlsVisible?: boolean;
- readonly onTogglePresentation?: () => void;
- readonly onPresentationInteractionChange?: (active: boolean) => void;
- /** Ref to the fullscreen container — passed to ProactiveCard so its portal
- * renders inside the top-layer during presentation mode. */
- readonly fullscreenContainerRef?: React.RefObject<HTMLDivElement | null>;
- }
- const VOICE_WAVE_BARS = [
- { peak: 18, duration: 0.55 },
- { peak: 24, duration: 0.72 },
- { peak: 15, duration: 0.63 },
- { peak: 22, duration: 0.68 },
- { peak: 27, duration: 0.78 },
- { peak: 19, duration: 0.61 },
- { peak: 26, duration: 0.74 },
- { peak: 17, duration: 0.58 },
- { peak: 23, duration: 0.7 },
- { peak: 16, duration: 0.57 },
- { peak: 21, duration: 0.66 },
- { peak: 14, duration: 0.53 },
- ] as const;
- function VoiceWaveformBars({ barClassName }: { readonly barClassName: string }) {
- return VOICE_WAVE_BARS.map((bar, i) => (
- <motion.div
- key={i}
- animate={{
- height: [4, bar.peak, 4],
- opacity: [0.3, 1, 0.3],
- }}
- transition={{
- repeat: Infinity,
- duration: bar.duration,
- delay: i * 0.05,
- ease: 'easeInOut',
- }}
- className={cn('w-1 rounded-full', barClassName)}
- />
- ));
- }
- export function Roundtable({
- mode: _mode = 'autonomous',
- initialParticipants = [],
- playbackView,
- currentSpeech,
- lectureSpeech,
- idleText,
- playbackCompleted,
- discussionRequest,
- engineMode = 'idle',
- isStreaming,
- sessionType,
- speakingAgentId,
- audioIndicatorState,
- audioAgentId,
- speechProgress: _speechProgress,
- showEndFlash,
- endFlashSessionType = 'discussion',
- thinkingState,
- isCueUser,
- isTopicPending,
- onMessageSend,
- onDiscussionStart,
- onDiscussionSkip,
- onStopDiscussion,
- onInputActivate,
- onResumeTopic,
- onPlayPause,
- isDiscussionPaused,
- onDiscussionPause,
- onDiscussionResume,
- currentSceneIndex = 0,
- scenesCount = 1,
- whiteboardOpen = false,
- sidebarCollapsed,
- chatCollapsed,
- onToggleSidebar,
- onToggleChat,
- onPrevSlide,
- onNextSlide,
- onWhiteboardClose,
- isPresenting,
- controlsVisible,
- onTogglePresentation,
- onPresentationInteractionChange,
- fullscreenContainerRef,
- }: RoundtableProps) {
- const { t } = useI18n();
- const ttsMuted = useSettingsStore((s) => s.ttsMuted);
- const setTTSMuted = useSettingsStore((s) => s.setTTSMuted);
- const ttsEnabled = useSettingsStore((state) => state.ttsEnabled);
- const asrEnabled = useSettingsStore((state) => state.asrEnabled);
- const chatAreaWidth = useSettingsStore((s) => s.chatAreaWidth);
- const ttsVolume = useSettingsStore((s) => s.ttsVolume);
- const setTTSVolume = useSettingsStore((s) => s.setTTSVolume);
- const autoPlayLecture = useSettingsStore((s) => s.autoPlayLecture);
- const setAutoPlayLecture = useSettingsStore((s) => s.setAutoPlayLecture);
- const playbackSpeed = useSettingsStore((s) => s.playbackSpeed);
- const setPlaybackSpeed = useSettingsStore((s) => s.setPlaybackSpeed);
- const [isInputOpen, setIsInputOpen] = useState(false);
- const [isVoiceOpen, setIsVoiceOpen] = useState(false);
- const [inputValue, setInputValue] = useState('');
- const [userMessage, setUserMessage] = useState<string | null>(null);
- const agentScrollRef = useRef<HTMLDivElement>(null);
- const bubbleScrollRef = useRef<HTMLDivElement>(null);
- const teacherAvatarRef = useRef<HTMLDivElement>(null);
- const studentAvatarRefs = useRef<Map<string, HTMLDivElement>>(new Map());
- const userMessageClearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
- // End flash visible state (Issue 3)
- const [endFlashVisible, setEndFlashVisible] = useState(false);
- // Send cooldown: lock input from "message sent" until "agent bubble appears"
- const [isSendCooldown, setIsSendCooldown] = useState(false);
- const isSendCooldownRef = useRef(false);
- const teacherParticipant = initialParticipants.find((p) => p.role === 'teacher');
- const studentParticipants = initialParticipants.filter(
- (p) => p.role !== 'teacher' && p.role !== 'user',
- );
- // Stable ref object for the current discussion agent's avatar
- const discussionAnchorRef = useRef<HTMLDivElement>(null);
- const presentationActionAnchorRef = useRef<HTMLDivElement>(null);
- const presentationAgentAvatarRef = useRef<HTMLDivElement>(null);
- useEffect(() => {
- if (!discussionRequest) {
- discussionAnchorRef.current = null;
- return;
- }
- if (discussionRequest.agentId === teacherParticipant?.id) {
- discussionAnchorRef.current = teacherAvatarRef.current;
- } else {
- discussionAnchorRef.current =
- studentAvatarRefs.current.get(discussionRequest.agentId || '') || null;
- }
- }, [discussionRequest, teacherParticipant?.id]);
- // Derived state from Stage's computePlaybackView (centralised derivation)
- const isInLiveFlow =
- playbackView?.isInLiveFlow ??
- !!(speakingAgentId || thinkingState || isStreaming || sessionType);
- // Role-aware source text: userMessage overlay on top of playbackView
- const sourceText = userMessage
- ? userMessage
- : (playbackView?.sourceText ??
- (currentSpeech
- ? currentSpeech
- : isInLiveFlow
- ? ''
- : lectureSpeech || (playbackCompleted ? '' : idleText) || ''));
- const hasAgentFeedback = Boolean(playbackView?.sourceText || thinkingState);
- const prevHasAgentFeedbackRef = useRef(hasAgentFeedback);
- const clearUserMessageClearTimer = useCallback(() => {
- if (userMessageClearTimerRef.current) {
- clearTimeout(userMessageClearTimerRef.current);
- userMessageClearTimerRef.current = null;
- }
- }, []);
- const scheduleUserMessageClear = useCallback(() => {
- clearUserMessageClearTimer();
- userMessageClearTimerRef.current = setTimeout(() => {
- setUserMessage(null);
- userMessageClearTimerRef.current = null;
- }, 3000);
- }, [clearUserMessageClearTimer]);
- const showLocalUserMessage = useCallback(
- (text: string) => {
- setUserMessage(text);
- // Mark as "already seen feedback" so that the immediate thinkingState
- // transition (false→true) after user sends won't trigger the early-clear
- // effect and swallow the user bubble.
- prevHasAgentFeedbackRef.current = true;
- scheduleUserMessageClear();
- },
- [scheduleUserMessageClear],
- );
- // Auto-scroll bubble: keep latest streaming text visible during live/discussion flow
- useEffect(() => {
- if (!isInLiveFlow) return;
- const el = bubbleScrollRef.current;
- if (!el) return;
- const scrollableHeight = el.scrollHeight - el.clientHeight;
- if (scrollableHeight <= 0) return;
- el.scrollTo({ top: scrollableHeight, behavior: 'smooth' });
- }, [sourceText, isInLiveFlow]);
- // Clear user message early when agent starts responding
- useEffect(() => {
- const feedbackStarted = hasAgentFeedback && !prevHasAgentFeedbackRef.current;
- if (userMessage && feedbackStarted) {
- clearUserMessageClearTimer();
- setUserMessage(null);
- }
- prevHasAgentFeedbackRef.current = hasAgentFeedback;
- }, [clearUserMessageClearTimer, hasAgentFeedback, userMessage]);
- useEffect(() => () => clearUserMessageClearTimer(), [clearUserMessageClearTimer]);
- // End flash effect (Issue 3)
- useEffect(() => {
- if (showEndFlash) {
- setEndFlashVisible(true);
- const timer = setTimeout(() => setEndFlashVisible(false), 1800);
- return () => clearTimeout(timer);
- } else {
- setEndFlashVisible(false);
- }
- }, [showEndFlash]);
- // Clear send cooldown when agent bubble appears
- useEffect(() => {
- if (isSendCooldown && speakingAgentId) {
- setIsSendCooldown(false);
- isSendCooldownRef.current = false;
- }
- }, [isSendCooldown, speakingAgentId]);
- // Safety net: clear cooldown when streaming transitions from active → ended
- // (not when isStreaming was already false — that would clear cooldown immediately)
- const prevStreamingRef = useRef(false);
- useEffect(() => {
- if (prevStreamingRef.current && !isStreaming && isSendCooldown) {
- setIsSendCooldown(false);
- isSendCooldownRef.current = false;
- }
- prevStreamingRef.current = !!isStreaming;
- }, [isStreaming, isSendCooldown]);
- // Separate participants by role (teacherParticipant & studentParticipants declared earlier for effect)
- const userParticipant = initialParticipants.find((p) => p.role === 'user');
- const teacherAvatar = teacherParticipant?.avatar || DEFAULT_TEACHER_AVATAR;
- const teacherName = teacherParticipant?.name || t('roundtable.teacher');
- const userAvatar = userParticipant?.avatar || DEFAULT_USER_AVATAR;
- // Audio recording
- const { isRecording, isProcessing, startRecording, stopRecording, cancelRecording } =
- useAudioRecorder({
- onTranscription: (text) => {
- if (!text.trim()) {
- toast.info(t('roundtable.noSpeechDetected'));
- setIsVoiceOpen(false);
- return;
- }
- // Block if in send cooldown (e.g. text was sent while voice was processing)
- if (isSendCooldownRef.current) {
- setIsVoiceOpen(false);
- return;
- }
- showLocalUserMessage(text);
- onMessageSend?.(text);
- setIsSendCooldown(true);
- isSendCooldownRef.current = true;
- setIsVoiceOpen(false);
- },
- onError: (error) => {
- toast.error(error);
- setIsVoiceOpen(false);
- },
- });
- const handleSendMessage = () => {
- if (!inputValue.trim() || isSendCooldown) return;
- showLocalUserMessage(inputValue);
- onMessageSend?.(inputValue);
- setIsSendCooldown(true);
- isSendCooldownRef.current = true;
- setInputValue('');
- setIsInputOpen(false);
- };
- const handleToggleInput = () => {
- if (isSendCooldown) return;
- if (!isInputOpen) {
- onInputActivate?.();
- }
- setIsInputOpen(!isInputOpen);
- // Cancel any in-flight ASR to prevent ghost auto-sends
- if (isVoiceOpen || isProcessing) {
- cancelRecording();
- setIsVoiceOpen(false);
- }
- };
- const handleToggleVoice = () => {
- if (isVoiceOpen) {
- if (isRecording) {
- stopRecording();
- }
- setIsVoiceOpen(false);
- } else {
- if (isSendCooldown || isProcessing) return;
- onInputActivate?.();
- setIsVoiceOpen(true);
- setIsInputOpen(false);
- startRecording();
- }
- };
- // Keyboard shortcuts for roundtable interaction (#255)
- // T = toggle text input, V = toggle voice input, Escape = dismiss panels,
- // Space = discussion pause/resume (during live flow)
- useEffect(() => {
- const handleKeyDown = (e: KeyboardEvent) => {
- // Escape should always work, even when typing in an input
- if (e.key === 'Escape') {
- if (isInputOpen || isVoiceOpen) {
- e.preventDefault();
- e.stopPropagation(); // Prevent fullscreen exit when panels are open
- setIsInputOpen(false);
- setIsVoiceOpen(false);
- if (isRecording || isProcessing) cancelRecording();
- }
- return;
- }
- // Skip other shortcuts when user is typing in an input, textarea, or contentEditable
- const tag = (e.target as HTMLElement).tagName;
- if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target as HTMLElement).isContentEditable) {
- return;
- }
- switch (e.key) {
- case ' ':
- case 'Spacebar':
- // Only handle during live flow (QA/Discussion)
- if (!isInLiveFlow) return;
- e.preventDefault(); // Prevent page scroll
- if (isDiscussionPaused) {
- onDiscussionResume?.();
- } else if (!thinkingState && currentSpeech) {
- // Same guard as bubble click: don't pause during thinking or before text arrives
- onDiscussionPause?.();
- }
- break;
- case 't':
- case 'T':
- e.preventDefault();
- handleToggleInput();
- break;
- case 'v':
- case 'V':
- e.preventDefault();
- if (asrEnabled) handleToggleVoice();
- break;
- default:
- break;
- }
- };
- window.addEventListener('keydown', handleKeyDown);
- return () => window.removeEventListener('keydown', handleKeyDown);
- }, [
- isInLiveFlow,
- isDiscussionPaused,
- thinkingState,
- currentSpeech,
- onDiscussionPause,
- onDiscussionResume,
- asrEnabled,
- isInputOpen,
- isVoiceOpen,
- isRecording,
- isProcessing,
- ]);
- const isPresentationInteractionActive = isInputOpen || isVoiceOpen || isRecording || isProcessing;
- useEffect(() => {
- onPresentationInteractionChange?.(isPresentationInteractionActive);
- return () => {
- if (isPresentationInteractionActive) {
- onPresentationInteractionChange?.(false);
- }
- };
- }, [isPresentationInteractionActive, onPresentationInteractionChange]);
- // Determine active speaking state and bubble ownership
- // Check if current speaker is a student agent (not teacher)
- const speakingStudent = speakingAgentId
- ? studentParticipants.find((s) => s.id === speakingAgentId)
- : null;
- // Bubble loading: speakingAgentId is set (agent_start fired) but text hasn't arrived yet
- const isBubbleLoading = !!(speakingAgentId && !currentSpeech && !userMessage);
- // Student agent specifically loading (for agent-style bubble)
- const isAgentLoading = !!(speakingStudent && !currentSpeech && !userMessage);
- const activeRole: 'teacher' | 'user' | 'agent' | null = userMessage
- ? 'user'
- : (playbackView?.activeRole ??
- (currentSpeech && speakingStudent
- ? 'agent'
- : currentSpeech
- ? 'teacher'
- : isAgentLoading
- ? 'agent'
- : isBubbleLoading
- ? 'teacher'
- : isCueUser
- ? null
- : lectureSpeech
- ? 'teacher'
- : null));
- const bubbleRole: 'teacher' | 'user' | 'agent' | null = userMessage
- ? 'user'
- : (playbackView?.bubbleRole ??
- (currentSpeech && speakingStudent
- ? 'agent'
- : currentSpeech
- ? 'teacher'
- : isAgentLoading
- ? 'agent'
- : isBubbleLoading
- ? 'teacher'
- : isInLiveFlow
- ? null
- : isCueUser
- ? null
- : lectureSpeech || idleText
- ? 'teacher'
- : null));
- const bubbleName =
- bubbleRole === 'agent'
- ? speakingStudent?.name || t('settings.agentRoles.student')
- : bubbleRole === 'teacher'
- ? teacherName
- : bubbleRole === 'user'
- ? t('roundtable.you')
- : '';
- // Stable key based on speaker identity, NOT text content (prevents re-mount flicker)
- const bubbleKey =
- bubbleRole === 'user'
- ? 'user'
- : bubbleRole === 'agent'
- ? `agent-${speakingAgentId}`
- : bubbleRole === 'teacher'
- ? 'teacher'
- : 'idle';
- // Enriched playbackView that includes userMessage overlay for bubbleRole/sourceText
- const enrichedPlaybackView: PlaybackView = playbackView
- ? { ...playbackView, bubbleRole, sourceText, activeRole: activeRole ?? playbackView.activeRole }
- : {
- phase: 'idle' as const,
- sourceText,
- bubbleRole,
- activeRole,
- buttonState: 'none' as const,
- isInLiveFlow: false,
- isTopicActive: false,
- };
- // Show stop button whenever there's an active QA/discussion session or live mode.
- // sessionType is only cleared in doSessionCleanup, so this stays stable through
- // brief loading gaps (e.g. between user message and agent SSE response).
- const showStopButton =
- engineMode === 'live' || sessionType === 'qa' || sessionType === 'discussion';
- const handleCycleSpeed = useCallback(() => {
- const currentIndex = PLAYBACK_SPEEDS.indexOf(playbackSpeed as (typeof PLAYBACK_SPEEDS)[number]);
- const nextIndex = (currentIndex + 1) % PLAYBACK_SPEEDS.length;
- setPlaybackSpeed(PLAYBACK_SPEEDS[nextIndex]);
- }, [playbackSpeed, setPlaybackSpeed]);
- // Intentionally non-reactive: agent metadata is treated as immutable during a classroom session.
- const agentRegistry = useAgentRegistry.getState();
- const getAgentConfig = (id: string) => agentRegistry.getAgent(id);
- const presentationDiscussionParticipant = discussionRequest
- ? discussionRequest.agentId === teacherParticipant?.id
- ? teacherParticipant || null
- : studentParticipants.find((student) => student.id === discussionRequest.agentId) || null
- : null;
- const presentationDiscussionAgentConfig = discussionRequest
- ? getAgentConfig(discussionRequest.agentId || '')
- : null;
- const handlePresentationBubbleClick = useCallback(() => {
- if (isTopicPending) {
- onResumeTopic?.();
- return;
- }
- if (isInLiveFlow) {
- if (isDiscussionPaused) {
- onDiscussionResume?.();
- } else if (!thinkingState && currentSpeech) {
- onDiscussionPause?.();
- }
- return;
- }
- onPlayPause?.();
- }, [
- isTopicPending,
- isInLiveFlow,
- isDiscussionPaused,
- thinkingState,
- currentSpeech,
- onResumeTopic,
- onDiscussionResume,
- onDiscussionPause,
- onPlayPause,
- ]);
- const showPresentationDock =
- !!controlsVisible ||
- !!discussionRequest ||
- isCueUser ||
- isInputOpen ||
- isVoiceOpen ||
- isRecording ||
- isProcessing;
- const toolbar = (
- <CanvasToolbar
- className="shrink-0 h-8 px-3 border-b border-gray-100/40 dark:border-gray-700/30"
- currentSceneIndex={currentSceneIndex}
- scenesCount={scenesCount}
- engineState={
- engineMode === 'playing' || engineMode === 'live'
- ? 'playing'
- : engineMode === 'paused'
- ? 'paused'
- : 'idle'
- }
- isLiveSession={isStreaming || isTopicPending || engineMode === 'live'}
- whiteboardOpen={whiteboardOpen}
- sidebarCollapsed={sidebarCollapsed}
- chatCollapsed={chatCollapsed}
- onToggleSidebar={onToggleSidebar}
- onToggleChat={onToggleChat}
- onPrevSlide={onPrevSlide ?? (() => {})}
- onNextSlide={onNextSlide ?? (() => {})}
- onPlayPause={onPlayPause ?? (() => {})}
- onWhiteboardClose={onWhiteboardClose ?? (() => {})}
- isPresenting={isPresenting}
- onTogglePresentation={onTogglePresentation}
- showStopDiscussion={showStopButton}
- onStopDiscussion={onStopDiscussion}
- ttsEnabled={ttsEnabled}
- ttsMuted={ttsMuted}
- ttsVolume={ttsVolume}
- onToggleMute={() => ttsEnabled && setTTSMuted(!ttsMuted)}
- onVolumeChange={(v) => setTTSVolume(v)}
- autoPlayLecture={autoPlayLecture}
- onToggleAutoPlay={() => setAutoPlayLecture(!autoPlayLecture)}
- playbackSpeed={playbackSpeed}
- onCycleSpeed={handleCycleSpeed}
- />
- );
- if (isPresenting) {
- return (
- <div className="h-0 w-full relative z-10 overflow-visible">
- {/* Speech overlay — fills the full stage area via absolute positioning */}
- <PresentationSpeechOverlay
- playbackView={enrichedPlaybackView}
- participants={initialParticipants}
- speakingAgentId={speakingAgentId ?? null}
- isTopicPending={!!isTopicPending}
- side="left"
- onBubbleClick={handlePresentationBubbleClick}
- audioIndicatorState={audioIndicatorState ?? 'idle'}
- buttonState={enrichedPlaybackView?.buttonState}
- isPaused={isDiscussionPaused || engineMode === 'paused'}
- />
- {/* Click-outside backdrop to dismiss input/voice */}
- {(isInputOpen || isVoiceOpen) && (
- <div
- className="fixed top-0 left-0 right-0 bottom-14 z-[45] pointer-events-auto"
- onClick={() => {
- setIsInputOpen(false);
- setIsVoiceOpen(false);
- cancelRecording();
- }}
- />
- )}
- {/* ── Toolbar — pinned to bottom of screen ── */}
- <div
- className={cn(
- 'fixed bottom-0 left-0 z-[40] pointer-events-none flex items-center justify-center transition-all duration-300',
- controlsVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-2',
- )}
- style={{ right: chatCollapsed === false ? (chatAreaWidth ?? 320) : 0 }}
- >
- <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">
- {toolbar}
- </div>
- </div>
- {/* ── End flash notification ── */}
- <AnimatePresence>
- {endFlashVisible && (
- <motion.div
- initial={{ opacity: 0, y: 10, scale: 0.9 }}
- animate={{
- opacity: [0, 1, 1, 0],
- y: [10, 0, 0, 6],
- scale: [0.9, 1, 1, 0.95],
- }}
- transition={{
- duration: 1.8,
- times: [0, 0.15, 0.7, 1],
- ease: 'easeOut',
- }}
- 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"
- style={{
- left: `calc((100vw - ${chatCollapsed === false ? (chatAreaWidth ?? 320) : 0}px) / 2)`,
- }}
- >
- <span className="w-1.5 h-1.5 rounded-full bg-gray-400 inline-block mr-1.5" />
- {endFlashSessionType === 'discussion'
- ? t('roundtable.discussionEnded')
- : t('roundtable.qaEnded')}
- </motion.div>
- )}
- </AnimatePresence>
- {/* ── Center stack: input / voice / thinking — anchored above toolbar ── */}
- <div
- className="fixed bottom-14 left-0 z-[50] flex flex-col items-center justify-center gap-3 pointer-events-none transition-[right] duration-300"
- style={{ right: chatCollapsed === false ? (chatAreaWidth ?? 320) : 0 }}
- >
- {/* Input panel */}
- <AnimatePresence>
- {isInputOpen && (
- <motion.div
- key="presentation-input-stage"
- initial={{ opacity: 0, scale: 0.95, y: 15, filter: 'blur(4px)' }}
- animate={{ opacity: 1, scale: 1, y: 0, filter: 'blur(0px)' }}
- exit={{ opacity: 0, scale: 0.95, y: 15, filter: 'blur(4px)' }}
- className="w-[min(480px,calc(100vw-3rem))] pointer-events-auto"
- >
- <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">
- <div className="flex-1 min-w-0 flex items-center">
- <textarea
- value={inputValue}
- onChange={(e) => setInputValue(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
- e.preventDefault();
- handleSendMessage();
- }
- }}
- placeholder={t('roundtable.inputPlaceholder')}
- autoFocus
- rows={1}
- 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]"
- style={{ fieldSizing: 'content' } as Record<string, string>}
- />
- </div>
- <button
- onClick={handleSendMessage}
- disabled={isSendCooldown}
- className={cn(
- 'w-10 h-10 rounded-full flex items-center justify-center transition-all shrink-0',
- isSendCooldown
- ? 'bg-gray-500/50 cursor-not-allowed'
- : 'bg-purple-600 hover:bg-purple-700 shadow-[0_4px_16px_rgba(147,51,234,0.3)]',
- )}
- >
- {isSendCooldown ? (
- <Loader2 className="w-4 h-4 text-white animate-spin" />
- ) : (
- <Send className="w-4 h-4 text-white" />
- )}
- </button>
- </div>
- </motion.div>
- )}
- </AnimatePresence>
- {/* Voice panel */}
- <AnimatePresence>
- {isVoiceOpen && (
- <motion.div
- key="presentation-voice-stage"
- initial={{ opacity: 0, scale: 0.9, y: 20, filter: 'blur(4px)' }}
- animate={{ opacity: 1, scale: 1, y: 0, filter: 'blur(0px)' }}
- exit={{ opacity: 0, scale: 0.9, y: 20, filter: 'blur(4px)' }}
- className="pointer-events-auto"
- >
- <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">
- {/* Waveform bars */}
- <div className="flex items-center gap-0.5 h-8">
- <VoiceWaveformBars barClassName="bg-gradient-to-t from-purple-400 to-indigo-400" />
- </div>
- <span className="text-[11px] font-semibold tracking-wider text-purple-600 dark:text-purple-300 uppercase">
- {isProcessing ? t('roundtable.processing') : t('roundtable.listening')}
- </span>
- {/* Mic button */}
- <button
- type="button"
- aria-label={
- isRecording ? t('roundtable.stopRecording') : t('roundtable.startRecording')
- }
- className="relative group cursor-pointer bg-transparent border-none p-0"
- onClick={handleToggleVoice}
- >
- <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">
- <Mic className="w-5 h-5 text-white" />
- </div>
- <div className="absolute inset-0 rounded-full border-2 border-purple-500 opacity-40 animate-[ping_2s_ease-in-out_infinite]" />
- </button>
- </div>
- </motion.div>
- )}
- </AnimatePresence>
- {/* "Your turn" cue prompt — clickable, opens input panel */}
- <AnimatePresence>
- {isCueUser && !bubbleRole && !thinkingState && !isInputOpen && !isVoiceOpen && (
- <motion.div
- key="presentation-cue-user"
- initial={{ opacity: 0, scale: 0.92, y: 8 }}
- animate={{ opacity: 1, scale: 1, y: 0 }}
- exit={{ opacity: 0, scale: 0.92, y: 8 }}
- transition={{ duration: 0.22, ease: [0.21, 1, 0.36, 1] }}
- className="pointer-events-auto"
- >
- <button
- onClick={() => (asrEnabled ? handleToggleVoice() : handleToggleInput())}
- 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"
- >
- {asrEnabled ? <Mic className="w-4 h-4" /> : <MessageSquare className="w-4 h-4" />}
- {t('roundtable.yourTurn')}
- </button>
- </motion.div>
- )}
- </AnimatePresence>
- {/* Director thinking indicator */}
- <AnimatePresence>
- {thinkingState?.stage === 'director' && !currentSpeech && !userMessage && (
- <motion.div
- initial={{ opacity: 0, scale: 0.9 }}
- animate={{ opacity: 1, scale: 1 }}
- exit={{ opacity: 0, scale: 0.9 }}
- 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"
- >
- <div className="flex gap-1">
- {[0, 0.2, 0.4].map((delay) => (
- <motion.div
- key={delay}
- animate={{ opacity: [0.3, 1, 0.3] }}
- transition={{ repeat: Infinity, duration: 1.2, delay }}
- className="w-1.5 h-1.5 rounded-full bg-purple-400"
- />
- ))}
- </div>
- <span className="text-[10px] text-gray-500 dark:text-gray-400 font-medium">
- {t('roundtable.thinking')}
- </span>
- </motion.div>
- )}
- </AnimatePresence>
- </div>
- {/* ── Right-side stack: bubble + dock — flex column, no hardcoded px ── */}
- <div
- className="fixed bottom-5 z-[48] flex flex-col items-end gap-3 pointer-events-none transition-[right] duration-300"
- style={{ right: chatCollapsed ? 20 : 20 + (chatAreaWidth ?? 320) }}
- >
- {/* Right-side speech bubble (flows above dock via flex) */}
- <PresentationSpeechOverlay
- playbackView={enrichedPlaybackView}
- participants={initialParticipants}
- speakingAgentId={speakingAgentId ?? null}
- isTopicPending={!!isTopicPending}
- userAvatar={userAvatar}
- side="right"
- onBubbleClick={handlePresentationBubbleClick}
- audioIndicatorState={audioIndicatorState ?? 'idle'}
- buttonState={enrichedPlaybackView?.buttonState}
- isPaused={isDiscussionPaused || engineMode === 'paused'}
- />
- {/* Dock */}
- <AnimatePresence>
- {showPresentationDock && (
- <motion.div
- initial={{ opacity: 0, scale: 0.92 }}
- animate={{ opacity: 1, scale: 1 }}
- exit={{ opacity: 0, scale: 0.92 }}
- transition={{ duration: 0.2, ease: 'easeOut' }}
- className="pointer-events-auto"
- >
- <div
- ref={presentationActionAnchorRef}
- 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"
- >
- {/* Speaking / discussion-requesting agent avatar — shows when
- a student agent is actively speaking OR a discussion request
- is pending (so the user can see who's asking before joining) */}
- <AnimatePresence>
- {((activeRole === 'agent' && speakingStudent) ||
- presentationDiscussionParticipant) && (
- <motion.div
- ref={presentationAgentAvatarRef}
- key={`dock-agent-${(speakingStudent || presentationDiscussionParticipant)?.id}`}
- initial={{ opacity: 0, scale: 0.8, width: 0 }}
- animate={{ opacity: 1, scale: 1, width: 'auto' }}
- exit={{ opacity: 0, scale: 0.8, width: 0 }}
- transition={{ duration: 0.2, ease: 'easeOut' }}
- className="shrink-0 overflow-hidden"
- >
- <div className="relative w-10 h-10 rounded-full flex items-center justify-center">
- <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" />
- <div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-800 overflow-hidden relative z-10 text-lg">
- <AvatarDisplay
- src={
- (speakingStudent || presentationDiscussionParticipant)?.avatar ||
- '/avatars/user.png'
- }
- alt={
- (speakingStudent || presentationDiscussionParticipant)?.name || ''
- }
- />
- </div>
- </div>
- </motion.div>
- )}
- </AnimatePresence>
- {isSendCooldown ? (
- <div className="flex items-center justify-center w-8 h-8">
- <div className="flex items-center gap-[3px]">
- {[0, 1, 2].map((i) => (
- <motion.div
- key={i}
- animate={{ y: [0, -3, 0], opacity: [0.35, 0.9, 0.35] }}
- transition={{
- repeat: Infinity,
- duration: 0.9,
- delay: i * 0.12,
- ease: 'easeInOut',
- }}
- className="w-[3px] h-[3px] rounded-full bg-purple-400"
- />
- ))}
- </div>
- </div>
- ) : (
- <>
- <button
- aria-label={
- asrEnabled
- ? t('roundtable.voiceInput')
- : t('roundtable.voiceInputDisabled')
- }
- onClick={(e) => {
- e.stopPropagation();
- if (asrEnabled) handleToggleVoice();
- }}
- disabled={!asrEnabled}
- className={cn(
- 'w-8 h-8 rounded-full flex items-center justify-center transition-all active:scale-95',
- !asrEnabled
- ? 'text-gray-500 cursor-not-allowed'
- : isVoiceOpen
- ? 'bg-purple-600 text-white'
- : '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',
- )}
- >
- {asrEnabled ? <Mic className="w-4 h-4" /> : <MicOff className="w-4 h-4" />}
- </button>
- <button
- aria-label={t('roundtable.textInput')}
- onClick={(e) => {
- e.stopPropagation();
- handleToggleInput();
- }}
- className={cn(
- 'w-8 h-8 rounded-full flex items-center justify-center transition-all active:scale-95',
- isInputOpen
- ? 'bg-purple-600 text-white'
- : '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',
- )}
- >
- <MessageSquare className="w-4 h-4" />
- </button>
- </>
- )}
- <button
- type="button"
- aria-label={t('roundtable.you')}
- className="relative group cursor-pointer shrink-0 bg-transparent border-none p-0"
- onClick={(e) => {
- e.stopPropagation();
- handleToggleInput();
- }}
- >
- <div
- className={cn(
- 'relative w-10 h-10 rounded-full transition-all duration-300 flex items-center justify-center',
- activeRole === 'user' || isInputOpen || isCueUser
- ? 'scale-105'
- : 'opacity-70 group-hover:opacity-100 group-hover:scale-100',
- )}
- >
- <div
- className={cn(
- 'absolute inset-0 rounded-full border-2 transition-all duration-300',
- isCueUser
- ? 'border-amber-500 shadow-[0_0_10px_rgba(245,158,11,0.4)] animate-pulse'
- : activeRole === 'user' || isInputOpen
- ? 'border-purple-500 shadow-[0_0_6px_rgba(168,85,247,0.3)]'
- : 'border-gray-300/40 dark:border-white/20 group-hover:border-purple-400/50',
- )}
- />
- <div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-800 overflow-hidden relative z-10 text-lg">
- <AvatarDisplay src={userAvatar} alt={t('roundtable.you')} />
- </div>
- </div>
- </button>
- </div>
- <AnimatePresence>
- {discussionRequest && (
- <ProactiveCard
- action={discussionRequest}
- mode={engineMode === 'paused' ? 'paused' : 'playback'}
- anchorRef={presentationAgentAvatarRef}
- portalContainer={fullscreenContainerRef?.current}
- align="left"
- agentName={
- presentationDiscussionParticipant?.name ||
- presentationDiscussionAgentConfig?.name
- }
- agentAvatar={
- presentationDiscussionParticipant?.avatar ||
- presentationDiscussionAgentConfig?.avatar
- }
- agentColor={presentationDiscussionAgentConfig?.color}
- onSkip={() => onDiscussionSkip?.()}
- onListen={() => onDiscussionStart?.(discussionRequest)}
- onTogglePause={() => onPlayPause?.()}
- />
- )}
- </AnimatePresence>
- </motion.div>
- )}
- </AnimatePresence>
- </div>
- </div>
- );
- }
- return (
- <div
- className={cn(
- 'h-[192px] w-full flex flex-col relative z-10 transition-all duration-300',
- isPresenting && !controlsVisible
- ? 'border-t border-transparent bg-transparent backdrop-blur-none'
- : 'border-t border-gray-100 dark:border-gray-800 bg-white/60 dark:bg-gray-800/60 backdrop-blur-md',
- )}
- >
- {/* ── Toolbar strip — merged from CanvasArea ── */}
- <div
- className={cn(
- 'transition-opacity duration-300',
- isPresenting && !controlsVisible && 'opacity-0 pointer-events-none',
- )}
- >
- {toolbar}
- </div>
- {/* ── Interaction area — three-column layout ── */}
- <div className="flex-1 flex items-stretch min-h-0">
- {/* Left: Teacher identity */}
- <div
- className={cn(
- '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',
- isPresenting && !controlsVisible && 'opacity-0 pointer-events-none',
- )}
- >
- {/* Decorative Element (Top) */}
- <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" />
- <div className="absolute top-3 inset-x-0 flex flex-col items-center justify-center gap-1 opacity-10 pointer-events-none">
- <BookOpen size={20} className="text-purple-900 dark:text-purple-100" />
- <div className="w-8 h-0.5 bg-purple-900 dark:bg-purple-100 rounded-full" />
- </div>
- {/* Main Content */}
- <div className="flex-1 flex items-center justify-center gap-3 px-2 min-h-0 pb-1 pt-8">
- {/* Avatar Group (Left) */}
- <div
- ref={teacherAvatarRef}
- className="relative group cursor-pointer flex flex-col items-center justify-center gap-1"
- >
- <HoverCard openDelay={300} closeDelay={100}>
- <HoverCardTrigger asChild>
- <div className="flex flex-col items-center gap-1">
- <div
- className={cn(
- 'relative w-12 h-12 rounded-full transition-all duration-500 flex items-center justify-center',
- activeRole === 'teacher' ? 'scale-105' : 'opacity-90 scale-95',
- )}
- >
- <div
- className={cn(
- 'absolute inset-0 rounded-full border-2 transition-all duration-500',
- activeRole === 'teacher'
- ? 'border-purple-500 dark:border-purple-400 shadow-[0_0_12px_rgba(168,85,247,0.4)]'
- : 'border-gray-200 dark:border-gray-700 group-hover:border-purple-300 dark:group-hover:border-purple-600',
- )}
- />
- <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">
- <img
- src={teacherAvatar}
- alt={teacherName}
- className="w-full h-full object-cover"
- />
- </div>
- {activeRole === 'teacher' && (
- <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">
- <div className="w-1 h-1 bg-white rounded-full animate-pulse" />
- </div>
- )}
- </div>
- <span
- className={cn(
- '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',
- activeRole === 'teacher' && !speakingStudent
- ? 'text-purple-600 dark:text-purple-400 border-purple-200 dark:border-purple-700'
- : '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',
- )}
- >
- {teacherName}
- </span>
- </div>
- </HoverCardTrigger>
- <HoverCardContent
- side="bottom"
- align="center"
- className="w-64 p-3 max-h-[300px] overflow-y-auto"
- >
- {(() => {
- const teacherConfig = getAgentConfig(teacherParticipant?.id || '');
- return (
- <>
- <div className="flex items-center gap-2">
- <div className="w-8 h-8 rounded-full overflow-hidden shrink-0 bg-gray-100 dark:bg-gray-800">
- <img
- src={teacherAvatar}
- alt={teacherName}
- className="w-full h-full object-cover"
- />
- </div>
- <div className="min-w-0">
- <p className="text-sm font-medium truncate">{teacherName}</p>
- <span
- className="inline-block text-[10px] leading-tight px-1.5 py-0.5 rounded-full text-white mt-0.5"
- style={{
- backgroundColor: teacherConfig?.color || '#8b5cf6',
- }}
- >
- {t('settings.agentRoles.teacher')}
- </span>
- </div>
- </div>
- {teacherConfig?.persona && (
- <p className="text-xs text-muted-foreground mt-2 leading-relaxed whitespace-pre-line">
- {teacherConfig.persona}
- </p>
- )}
- </>
- );
- })()}
- </HoverCardContent>
- </HoverCard>
- {/* ProactiveCard from teacher avatar */}
- <AnimatePresence>
- {discussionRequest && discussionRequest.agentId === teacherParticipant?.id && (
- <ProactiveCard
- action={discussionRequest}
- mode={engineMode === 'paused' ? 'paused' : 'playback'}
- anchorRef={teacherAvatarRef}
- align="left"
- agentName={teacherName}
- agentAvatar={teacherAvatar}
- agentColor={getAgentConfig(teacherParticipant?.id || '')?.color}
- onSkip={() => onDiscussionSkip?.()}
- onListen={() => onDiscussionStart?.(discussionRequest)}
- onTogglePause={() => onPlayPause?.()}
- />
- )}
- </AnimatePresence>
- </div>
- </div>
- </div>
- {/* Center: Interaction stage */}
- <div className="flex-1 relative mx-3 mb-2">
- {/* End flash banner (Issue 3) */}
- <AnimatePresence>
- {endFlashVisible && (
- <motion.div
- initial={{ opacity: 0, y: -10, scale: 0.9 }}
- animate={{
- opacity: [0, 1, 1, 0],
- y: [-10, 0, 0, -6],
- scale: [0.9, 1, 1, 0.95],
- }}
- transition={{
- duration: 1.8,
- times: [0, 0.15, 0.7, 1],
- ease: 'easeOut',
- }}
- 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"
- >
- <span className="w-1.5 h-1.5 rounded-full bg-gray-400 inline-block mr-1.5" />
- {endFlashSessionType === 'discussion'
- ? t('roundtable.discussionEnded')
- : t('roundtable.qaEnded')}
- </motion.div>
- )}
- </AnimatePresence>
- <div
- onClick={() => {
- if (isInputOpen || isVoiceOpen) {
- setIsInputOpen(false);
- setIsVoiceOpen(false);
- if (isRecording || isProcessing) cancelRecording();
- }
- }}
- 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"
- >
- {/* Text input box */}
- <AnimatePresence>
- {isInputOpen && (
- <motion.div
- key="input-stage"
- initial={{
- opacity: 0,
- scale: 0.95,
- y: 15,
- filter: 'blur(4px)',
- }}
- animate={{ opacity: 1, scale: 1, y: 0, filter: 'blur(0px)' }}
- exit={{ opacity: 0, scale: 0.95, y: 15, filter: 'blur(4px)' }}
- onClick={(e) => e.stopPropagation()}
- className="absolute inset-x-6 bottom-4 z-20 flex items-center justify-end"
- >
- <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">
- <div className="pl-4 flex-1 py-1 min-w-0">
- <textarea
- value={inputValue}
- onChange={(e) => setInputValue(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
- e.preventDefault();
- handleSendMessage();
- }
- }}
- placeholder={t('roundtable.inputPlaceholder')}
- autoFocus
- rows={1}
- 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]"
- style={{ fieldSizing: 'content' } as Record<string, string>}
- />
- </div>
- <button
- onClick={handleSendMessage}
- disabled={isSendCooldown}
- className={cn(
- 'p-2.5 text-white rounded-xl transition shadow-md mb-0.5 shrink-0',
- isSendCooldown
- ? 'bg-gray-400 dark:bg-gray-600 cursor-not-allowed shadow-gray-200 dark:shadow-gray-900/50'
- : 'bg-purple-600 hover:bg-purple-700 dark:bg-purple-500 dark:hover:bg-purple-600 shadow-purple-200 dark:shadow-purple-900/50',
- )}
- >
- {isSendCooldown ? (
- <Loader2 className="w-4 h-4 animate-spin" />
- ) : (
- <Send className="w-4 h-4" />
- )}
- </button>
- </div>
- </motion.div>
- )}
- {/* Audio recording status */}
- {isVoiceOpen && (
- <motion.div
- key="voice-stage"
- initial={{
- opacity: 0,
- scale: 0.9,
- x: 20,
- filter: 'blur(4px)',
- }}
- animate={{ opacity: 1, scale: 1, x: 0, filter: 'blur(0px)' }}
- exit={{ opacity: 0, scale: 0.9, x: 20, filter: 'blur(4px)' }}
- onClick={(e) => e.stopPropagation()}
- className="absolute right-4 top-1/2 -translate-y-1/2 z-30 flex items-center gap-4 pr-2 pointer-events-none"
- >
- <div className="flex flex-col-reverse items-end gap-1 mr-[-10px] relative z-20">
- <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">
- <VoiceWaveformBars barClassName="bg-gradient-to-t from-purple-500 to-indigo-600 dark:from-purple-400 dark:to-indigo-500" />
- </div>
- <motion.div
- initial={{ opacity: 0, x: 10 }}
- animate={{ opacity: 1, x: 0 }}
- 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"
- >
- {isProcessing ? t('roundtable.processing') : t('roundtable.listening')}
- </motion.div>
- </div>
- <div
- className="pointer-events-auto relative group cursor-pointer"
- onClick={handleToggleVoice}
- >
- <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">
- <Mic className="w-6 h-6 text-white" />
- </div>
- <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" />
- <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" />
- <div className="absolute inset-0 bg-purple-600 dark:bg-purple-500 blur-2xl opacity-20 group-hover:opacity-40 transition-opacity z-0" />
- </div>
- </motion.div>
- )}
- </AnimatePresence>
- {/* Thinking dots (Issue 5) */}
- <AnimatePresence>
- {thinkingState?.stage === 'director' && !currentSpeech && !userMessage && (
- <motion.div
- initial={{ opacity: 0, scale: 0.9 }}
- animate={{ opacity: 1, scale: 1 }}
- exit={{ opacity: 0, scale: 0.9 }}
- 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"
- >
- <div className="flex gap-1">
- <motion.div
- animate={{ opacity: [0.3, 1, 0.3] }}
- transition={{
- repeat: Infinity,
- duration: 1.2,
- delay: 0,
- }}
- className="w-1.5 h-1.5 rounded-full bg-purple-500"
- />
- <motion.div
- animate={{ opacity: [0.3, 1, 0.3] }}
- transition={{
- repeat: Infinity,
- duration: 1.2,
- delay: 0.2,
- }}
- className="w-1.5 h-1.5 rounded-full bg-purple-500"
- />
- <motion.div
- animate={{ opacity: [0.3, 1, 0.3] }}
- transition={{
- repeat: Infinity,
- duration: 1.2,
- delay: 0.4,
- }}
- className="w-1.5 h-1.5 rounded-full bg-purple-500"
- />
- </div>
- <span className="text-[10px] text-gray-400 dark:text-gray-500 font-medium">
- {t('roundtable.thinking')}
- </span>
- </motion.div>
- )}
- </AnimatePresence>
- {/* Cue user: centered indicator when waiting for user input */}
- <AnimatePresence>
- {isCueUser && !bubbleRole && !thinkingState && !isInputOpen && !isVoiceOpen && (
- <motion.div
- initial={{ opacity: 0, scale: 0.85 }}
- animate={{ opacity: 1, scale: 1 }}
- exit={{ opacity: 0, scale: 0.85 }}
- transition={{ duration: 0.35, ease: [0.21, 1, 0.36, 1] }}
- 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"
- >
- {/* Button with ripple effect */}
- <div className="relative flex items-center justify-center">
- {/* Soft background glow */}
- <div
- className={cn(
- 'absolute w-24 h-24 rounded-full blur-2xl',
- asrEnabled
- ? 'bg-amber-400/[0.08] dark:bg-amber-500/[0.06]'
- : 'bg-purple-400/[0.08] dark:bg-purple-500/[0.06]',
- )}
- />
- {/* Expanding ripple 1 */}
- <motion.div
- animate={{ scale: [1, 2.2], opacity: [0.25, 0] }}
- transition={{
- repeat: Infinity,
- duration: 2.2,
- ease: 'easeOut',
- }}
- className={cn(
- 'absolute w-11 h-11 rounded-full border',
- asrEnabled
- ? 'border-amber-400/50 dark:border-amber-500/35'
- : 'border-purple-400/50 dark:border-purple-500/35',
- )}
- />
- {/* Expanding ripple 2 */}
- <motion.div
- animate={{ scale: [1, 2.2], opacity: [0.25, 0] }}
- transition={{
- repeat: Infinity,
- duration: 2.2,
- ease: 'easeOut',
- delay: 0.7,
- }}
- className={cn(
- 'absolute w-11 h-11 rounded-full border',
- asrEnabled
- ? 'border-amber-300/40 dark:border-amber-400/25'
- : 'border-purple-300/40 dark:border-purple-400/25',
- )}
- />
- {/* Action circle — voice (ASR on) or text input (ASR off) */}
- <motion.button
- onClick={(e) => {
- e.stopPropagation();
- if (asrEnabled) handleToggleVoice();
- else handleToggleInput();
- }}
- animate={{ scale: [1, 1.05, 1] }}
- transition={{
- repeat: Infinity,
- duration: 2,
- ease: 'easeInOut',
- }}
- className={cn(
- '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',
- asrEnabled
- ? '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'
- : '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',
- )}
- >
- {asrEnabled ? (
- <Mic className="w-[18px] h-[18px] text-white drop-shadow-sm" />
- ) : (
- <MessageSquare className="w-[18px] h-[18px] text-white drop-shadow-sm" />
- )}
- </motion.button>
- </div>
- {/* Visual indicator below button */}
- {asrEnabled ? (
- <div className="flex items-center justify-center gap-[3px] h-3">
- {[0, 1, 2, 3, 4, 3, 2, 1, 0].map((intensity, i) => (
- <motion.div
- key={i}
- animate={{
- scaleY: [0.3, 0.5 + intensity * 0.15, 0.3],
- opacity: [0.3, 0.7, 0.3],
- }}
- transition={{
- repeat: Infinity,
- duration: 0.8 + (i % 3) * 0.1,
- delay: i * 0.08,
- ease: 'easeInOut',
- }}
- className="w-[2.5px] h-full origin-center rounded-full bg-amber-400/70 dark:bg-amber-500/60"
- />
- ))}
- </div>
- ) : (
- <div className="flex items-center justify-center gap-[3px] h-3">
- {[0, 1, 2, 3, 2, 1, 0].map((intensity, i) => (
- <motion.div
- key={i}
- animate={{
- scaleY: [0.3, 0.45 + intensity * 0.15, 0.3],
- opacity: [0.25, 0.6, 0.25],
- }}
- transition={{
- repeat: Infinity,
- duration: 1.0 + (i % 3) * 0.15,
- delay: i * 0.12,
- ease: 'easeInOut',
- }}
- className="w-[2.5px] h-full origin-center rounded-full bg-purple-400/60 dark:bg-purple-500/50"
- />
- ))}
- </div>
- )}
- {/* Label */}
- <motion.span
- animate={{ opacity: [0.5, 0.9, 0.5] }}
- transition={{
- repeat: Infinity,
- duration: 2.5,
- ease: 'easeInOut',
- }}
- className={cn(
- 'text-[10px] font-medium tracking-wider',
- asrEnabled
- ? 'text-amber-600/70 dark:text-amber-400/60'
- : 'text-purple-600/70 dark:text-purple-400/60',
- )}
- >
- {t('roundtable.yourTurn')}
- </motion.span>
- </motion.div>
- )}
- </AnimatePresence>
- {/* Chat bubble */}
- <AnimatePresence mode="wait">
- {bubbleRole && (
- <motion.div
- key={bubbleKey}
- initial={{ opacity: 0, y: 8 }}
- animate={{
- opacity: isInputOpen || isVoiceOpen ? 0.4 : 1,
- y: 0,
- filter: isInputOpen || isVoiceOpen ? 'blur(1px) grayscale(0.2)' : 'none',
- }}
- exit={{ opacity: 0, y: -8, transition: { duration: 0.12 } }}
- transition={{ duration: 0.2, ease: [0.21, 1, 0.36, 1] }}
- className="w-full flex items-center relative z-10"
- >
- <div
- className={cn(
- 'flex w-full transition-all duration-500',
- bubbleRole === 'teacher' ? 'justify-start' : 'justify-end',
- )}
- >
- <div
- onClick={(e) => {
- e.stopPropagation();
- if (bubbleRole === 'user') return;
- // Topic pending: click Play to resume
- if (isTopicPending) {
- onResumeTopic?.();
- return;
- }
- // QA/Discussion: buffer-level pause/resume (freeze text reveal, SSE continues)
- if (isInLiveFlow) {
- if (isDiscussionPaused) {
- onDiscussionResume?.();
- } else if (!thinkingState && currentSpeech) {
- // Don't allow pause during thinking or before text arrives
- onDiscussionPause?.();
- }
- return;
- }
- // Lecture playback: toggle play/pause
- onPlayPause?.();
- }}
- className={cn(
- '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]',
- bubbleRole === 'teacher' ? 'pl-4 pr-10' : 'pl-4 pr-10',
- bubbleRole === 'user'
- ? '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'
- : bubbleRole === 'agent'
- ? cn(
- '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',
- (isInLiveFlow || isTopicPending) &&
- 'hover:shadow-md cursor-pointer',
- )
- : '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',
- )}
- >
- {bubbleRole &&
- (() => {
- const bubbleAvatar =
- bubbleRole === 'user'
- ? userAvatar
- : bubbleRole === 'agent'
- ? speakingStudent?.avatar || userAvatar
- : teacherAvatar;
- return (
- <div
- className={cn(
- 'absolute -top-2.5 z-20 pointer-events-none select-none',
- bubbleRole === 'teacher' ? '-left-2.5' : '-right-2.5',
- )}
- title={bubbleName}
- >
- <div
- className={cn(
- 'w-6 h-6 rounded-full overflow-hidden border-2 shadow-sm',
- bubbleRole === 'user'
- ? 'border-purple-400 dark:border-purple-500'
- : bubbleRole === 'agent'
- ? 'border-blue-300 dark:border-blue-600'
- : 'border-purple-200 dark:border-purple-700',
- )}
- >
- <AvatarDisplay src={bubbleAvatar} alt={bubbleName} />
- </div>
- </div>
- );
- })()}
- <div ref={bubbleScrollRef} className="overflow-y-auto scrollbar-hide">
- {/* Agent name + audio indicator header */}
- {bubbleRole !== 'user' && bubbleName && (
- <div className="flex items-center gap-1 mb-0.5">
- <span className="text-[10px] font-semibold text-gray-400 dark:text-gray-500 truncate">
- {bubbleName}
- </span>
- {(() => {
- const aiState =
- speakingAgentId === audioAgentId
- ? (audioIndicatorState ?? 'idle')
- : 'idle';
- if (aiState === 'generating')
- return (
- <Loader2 className="w-3 h-3 text-amber-500 dark:text-amber-400 animate-spin" />
- );
- if (aiState === 'playing')
- return (
- <Volume2 className="w-3 h-3 text-gray-400 dark:text-gray-500" />
- );
- return null;
- })()}
- </div>
- )}
- {isBubbleLoading ? (
- <div className="flex gap-1 items-center py-1">
- <motion.div
- animate={{ opacity: [0.3, 1, 0.3] }}
- transition={{
- repeat: Infinity,
- duration: 1,
- delay: 0,
- }}
- className={cn(
- 'w-1.5 h-1.5 rounded-full',
- isAgentLoading
- ? 'bg-blue-400 dark:bg-blue-500'
- : 'bg-purple-400 dark:bg-purple-500',
- )}
- />
- <motion.div
- animate={{ opacity: [0.3, 1, 0.3] }}
- transition={{
- repeat: Infinity,
- duration: 1,
- delay: 0.2,
- }}
- className={cn(
- 'w-1.5 h-1.5 rounded-full',
- isAgentLoading
- ? 'bg-blue-400 dark:bg-blue-500'
- : 'bg-purple-400 dark:bg-purple-500',
- )}
- />
- <motion.div
- animate={{ opacity: [0.3, 1, 0.3] }}
- transition={{
- repeat: Infinity,
- duration: 1,
- delay: 0.4,
- }}
- className={cn(
- 'w-1.5 h-1.5 rounded-full',
- isAgentLoading
- ? 'bg-blue-400 dark:bg-blue-500'
- : 'bg-purple-400 dark:bg-purple-500',
- )}
- />
- </div>
- ) : (
- <p className="whitespace-pre-wrap break-words" suppressHydrationWarning>
- {sourceText}
- {isTopicPending && (
- <span className="inline-block w-1.5 h-1.5 rounded-full bg-red-500 ml-1 align-middle" />
- )}
- </p>
- )}
- </div>
- {/* Playback state icon (hidden during loading — dots already indicate activity) */}
- {bubbleRole !== 'user' &&
- !isBubbleLoading &&
- (() => {
- const btnState = playbackView?.buttonState ?? 'none';
- const barsColor =
- bubbleRole === 'agent' ? 'bg-blue-500' : 'bg-purple-500';
- if (btnState === 'none') return null;
- if (btnState === 'play') {
- return (
- <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">
- <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" />
- </div>
- );
- }
- if (btnState === 'restart') {
- return (
- <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">
- <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" />
- </div>
- );
- }
- // btnState === 'bars'
- return (
- <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">
- {isDiscussionPaused ? (
- /* Paused: static Play icon */
- <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" />
- ) : (
- <>
- {/* Breathing bars — visible by default, hidden on hover */}
- <div className="flex gap-0.5 items-end justify-center h-3.5 w-3.5 group-hover/bubble:hidden">
- <motion.div
- animate={{ height: ['20%', '100%', '20%'] }}
- transition={{
- repeat: Infinity,
- duration: 0.6,
- }}
- className={cn('w-1 rounded-full', barsColor)}
- />
- <motion.div
- animate={{ height: ['40%', '100%', '40%'] }}
- transition={{
- repeat: Infinity,
- duration: 0.4,
- }}
- className={cn('w-1 rounded-full', barsColor)}
- />
- <motion.div
- animate={{ height: ['20%', '80%', '20%'] }}
- transition={{
- repeat: Infinity,
- duration: 0.5,
- }}
- className={cn('w-1 rounded-full', barsColor)}
- />
- </div>
- {/* Pause icon on hover */}
- <Pause className="w-3.5 h-3.5 text-purple-600 dark:text-purple-400 hidden group-hover/bubble:block" />
- </>
- )}
- </div>
- );
- })()}
- </div>
- </div>
- </motion.div>
- )}
- </AnimatePresence>
- </div>
- </div>
- {/* Right: Participants area */}
- <div
- className={cn(
- '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',
- isPresenting && !controlsVisible && 'opacity-0 pointer-events-none',
- )}
- >
- {/* Companion agent avatars — horizontal row, scrollable on overflow, arrows on hover */}
- <div className="flex-none relative group/scroll">
- {/* Left arrow */}
- <button
- onClick={() => {
- agentScrollRef.current?.scrollBy({
- left: -80,
- behavior: 'smooth',
- });
- }}
- 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"
- >
- <ChevronLeft className="w-3.5 h-3.5 text-gray-400" />
- </button>
- <div
- ref={agentScrollRef}
- className="overflow-x-auto overflow-y-hidden px-2 scrollbar-hide"
- onWheel={(e) => {
- if (Math.abs(e.deltaY) > Math.abs(e.deltaX)) {
- e.currentTarget.scrollLeft += e.deltaY;
- e.preventDefault();
- }
- }}
- >
- <div className="flex gap-1 w-max py-1">
- {studentParticipants.map((student) => {
- const isSpeaking = speakingAgentId === student.id;
- const isThinkingAgent =
- thinkingState?.stage === 'agent_loading' &&
- thinkingState.agentId === student.id;
- const agentConfig = getAgentConfig(student.id);
- const roleLabelKey = agentConfig?.role as
- | 'teacher'
- | 'assistant'
- | 'student'
- | undefined;
- const roleLabel = roleLabelKey ? t(`settings.agentRoles.${roleLabelKey}`) : '';
- const i18nDescription = t(`settings.agentDescriptions.${student.id}`);
- const description =
- i18nDescription !== `settings.agentDescriptions.${student.id}`
- ? i18nDescription
- : agentConfig?.persona || '';
- const hasDescription = !!description;
- const isDiscussionAgent =
- !!discussionRequest && discussionRequest.agentId === student.id;
- return (
- <div
- key={student.id}
- data-agent-id={student.id}
- ref={(el) => {
- if (el) studentAvatarRefs.current.set(student.id, el);
- else studentAvatarRefs.current.delete(student.id);
- }}
- className="relative group/student shrink-0"
- >
- {/* Breathing glow for discussion agent */}
- {isDiscussionAgent && (
- <motion.div
- animate={{
- scale: [1, 1.2, 1],
- opacity: [0.7, 0, 0.7],
- }}
- transition={{
- repeat: Infinity,
- duration: 2,
- ease: 'easeInOut',
- }}
- className="absolute inset-0 rounded-full pointer-events-none"
- style={{
- border: `2px solid ${agentConfig?.color || '#d97706'}`,
- }}
- />
- )}
- <HoverCard openDelay={300} closeDelay={100}>
- <HoverCardTrigger asChild>
- <div
- className={cn(
- 'relative w-9 h-9 rounded-full transition-all duration-300 cursor-pointer',
- isSpeaking
- ? 'opacity-100 grayscale-0 scale-110'
- : 'opacity-50 grayscale-[0.2] scale-95 hover:opacity-100 hover:grayscale-0 hover:scale-100',
- )}
- >
- <div
- className={cn(
- 'absolute inset-0 rounded-full border-2 transition-all duration-300',
- isSpeaking
- ? 'border-purple-500 dark:border-purple-400 shadow-[0_0_8px_rgba(168,85,247,0.4)]'
- : 'border-white dark:border-gray-700',
- )}
- />
- <div className="absolute inset-0.5 rounded-full bg-gray-100 dark:bg-gray-800 overflow-hidden">
- <img
- src={student.avatar}
- alt={student.name}
- className="w-full h-full"
- />
- </div>
- {/* Speaking indicator */}
- {isSpeaking && (
- <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">
- <div className="w-1 h-1 bg-white rounded-full animate-pulse" />
- </div>
- )}
- {/* Loading indicator (Issue 5) */}
- {isThinkingAgent && (
- <div className="absolute inset-0 rounded-full border-2 border-purple-400 border-t-transparent animate-spin z-20" />
- )}
- </div>
- </HoverCardTrigger>
- <HoverCardContent
- side="bottom"
- align="center"
- className="w-64 p-3 max-h-[300px] overflow-y-auto"
- >
- <div className="flex items-center gap-2">
- <div className="w-8 h-8 rounded-full overflow-hidden shrink-0 bg-gray-100 dark:bg-gray-800">
- <img
- src={student.avatar}
- alt={student.name}
- className="w-full h-full"
- />
- </div>
- <div className="min-w-0">
- <p className="text-sm font-medium truncate">{student.name}</p>
- {roleLabel && roleLabel !== `settings.agentRoles.${roleLabelKey}` && (
- <span
- className="inline-block text-[10px] leading-tight px-1.5 py-0.5 rounded-full text-white mt-0.5"
- style={{
- backgroundColor: agentConfig?.color || '#6b7280',
- }}
- >
- {roleLabel}
- </span>
- )}
- </div>
- </div>
- {hasDescription && (
- <p className="text-xs text-muted-foreground mt-2 leading-relaxed whitespace-pre-line">
- {description}
- </p>
- )}
- </HoverCardContent>
- </HoverCard>
- </div>
- );
- })}
- </div>
- </div>
- {/* Right arrow */}
- <button
- onClick={() => {
- agentScrollRef.current?.scrollBy({
- left: 80,
- behavior: 'smooth',
- });
- }}
- 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"
- >
- <ChevronRight className="w-3.5 h-3.5 text-gray-400" />
- </button>
- {/* ProactiveCard for student/non-teacher agents — rendered via portal */}
- <AnimatePresence>
- {discussionRequest &&
- discussionRequest.agentId !== teacherParticipant?.id &&
- (() => {
- const matchedStudent = studentParticipants.find(
- (s) => s.id === discussionRequest.agentId,
- );
- const agentConfig = getAgentConfig(discussionRequest.agentId || '');
- return (
- <ProactiveCard
- action={discussionRequest}
- mode={engineMode === 'paused' ? 'paused' : 'playback'}
- anchorRef={discussionAnchorRef}
- align="left"
- agentName={matchedStudent?.name || agentConfig?.name}
- agentAvatar={matchedStudent?.avatar || agentConfig?.avatar}
- agentColor={agentConfig?.color}
- onSkip={() => onDiscussionSkip?.()}
- onListen={() => onDiscussionStart?.(discussionRequest)}
- onTogglePause={() => onPlayPause?.()}
- />
- );
- })()}
- </AnimatePresence>
- </div>
- {/* Divider */}
- <div className="mx-auto my-1.5 w-8 h-px bg-gray-200 dark:bg-gray-700 opacity-50 shrink-0" />
- {/* User avatar + interaction buttons */}
- <div className="flex-1 flex items-center justify-center gap-3 px-2 min-h-0">
- <div className="flex flex-col gap-1.5 shrink-0">
- {isSendCooldown ? (
- /* Unified cooldown indicator — replaces both buttons with a single dot wave */
- <div className="flex items-center justify-center w-8 h-8">
- <div className="flex items-center gap-[3px]">
- {[0, 1, 2].map((i) => (
- <motion.div
- key={i}
- animate={{
- y: [0, -3, 0],
- opacity: [0.35, 0.9, 0.35],
- }}
- transition={{
- repeat: Infinity,
- duration: 0.9,
- delay: i * 0.12,
- ease: 'easeInOut',
- }}
- className="w-[4px] h-[4px] rounded-full bg-purple-400 dark:bg-purple-400"
- />
- ))}
- </div>
- </div>
- ) : (
- <>
- <button
- onClick={(e) => {
- e.stopPropagation();
- if (asrEnabled) handleToggleVoice();
- }}
- disabled={!asrEnabled}
- className={cn(
- 'w-8 h-8 rounded-full border flex items-center justify-center transition-all active:scale-95 shadow-sm',
- !asrEnabled
- ? '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'
- : isVoiceOpen
- ? 'bg-purple-600 dark:bg-purple-500 border-purple-600 dark:border-purple-500 text-white shadow-purple-200 dark:shadow-purple-800'
- : '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',
- )}
- >
- {asrEnabled ? (
- <Mic className="w-3.5 h-3.5" />
- ) : (
- <MicOff className="w-3.5 h-3.5" />
- )}
- </button>
- <button
- onClick={(e) => {
- e.stopPropagation();
- handleToggleInput();
- }}
- className={cn(
- 'w-8 h-8 rounded-full border flex items-center justify-center transition-all active:scale-95 shadow-sm',
- isInputOpen
- ? 'bg-purple-600 dark:bg-purple-500 border-purple-600 dark:border-purple-500 text-white shadow-purple-200 dark:shadow-purple-800'
- : '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',
- )}
- >
- <MessageSquare className="w-3.5 h-3.5" />
- </button>
- </>
- )}
- </div>
- {/* User avatar (big, clickable to open input) */}
- <div
- className="relative group cursor-pointer shrink-0"
- onClick={(e) => {
- e.stopPropagation();
- handleToggleInput();
- }}
- >
- <div
- className={cn(
- 'relative w-16 h-16 rounded-full transition-all duration-300 flex items-center justify-center',
- activeRole === 'user' || isInputOpen || isCueUser
- ? 'scale-105'
- : 'opacity-50 grayscale-[0.2] scale-95 group-hover:opacity-100 group-hover:grayscale-0 group-hover:scale-100',
- )}
- >
- <div
- className={cn(
- 'absolute inset-0 rounded-full border-2 transition-all duration-300',
- isCueUser
- ? 'border-amber-500 dark:border-amber-400 shadow-[0_0_12px_rgba(245,158,11,0.4)] animate-pulse'
- : activeRole === 'user' || isInputOpen
- ? 'border-purple-600 dark:border-purple-400 shadow-[0_0_8px_rgba(168,85,247,0.3)]'
- : 'border-white dark:border-gray-700 group-hover:border-purple-200 dark:group-hover:border-purple-600',
- )}
- />
- <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">
- <AvatarDisplay src={userAvatar} alt={t('roundtable.you')} />
- </div>
- <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">
- <div
- className={cn(
- 'w-1.5 h-1.5 rounded-full',
- isInputOpen || isCueUser
- ? 'bg-purple-500 animate-pulse'
- : 'bg-gray-300 dark:bg-gray-600',
- )}
- />
- </div>
- </div>
- {/* Cue user hint (Issue 7) */}
- <AnimatePresence>
- {isCueUser && (
- <motion.div
- initial={{ opacity: 0, y: 4, scale: 0.9 }}
- animate={{ opacity: 1, y: 0, scale: 1 }}
- exit={{ opacity: 0, y: 4, scale: 0.9 }}
- 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"
- >
- {t('roundtable.yourTurn')}
- </motion.div>
- )}
- </AnimatePresence>
- </div>
- </div>
- </div>
- </div>
- {/* close interaction row */}
- </div>
- );
- }
|