Files
openmaic/OpenMAIC/components/roundtable/index.tsx
2026-08-16 14:58:47 +08:00

2144 lines
97 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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;
/** Session entered the soft-closing grace window (client-side, ~15s). */
readonly isSoftClosing?: boolean;
readonly softCloseDeadline?: number;
readonly isTopicPending?: boolean;
readonly onMessageSend?: (message: string) => void;
readonly onDiscussionStart?: (request: DiscussionAction) => void;
readonly onDiscussionSkip?: () => void;
readonly onStopDiscussion?: () => void;
readonly onContinueDiscussion?: () => void;
readonly onInputActivate?: () => void;
readonly onUserInputActivity?: (
kind: 'text_input' | 'composition_start' | 'recording_start',
) => 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>;
}
// This must stay in sync with the non-presentation textarea's max-h-[100px] class.
const NON_PRESENTATION_INPUT_MAX_HEIGHT_PX = 100;
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,
isSoftClosing,
softCloseDeadline,
isTopicPending,
onMessageSend,
onDiscussionStart,
onDiscussionSkip,
onStopDiscussion,
onContinueDiscussion,
onInputActivate,
onUserInputActivity,
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 nonPresentationInputRef = useRef<HTMLTextAreaElement>(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);
useEffect(() => {
if (isPresenting) return;
const textarea = nonPresentationInputRef.current;
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = `${Math.min(
textarea.scrollHeight,
NON_PRESENTATION_INPUT_MAX_HEIGHT_PX,
)}px`;
}, [inputValue, isInputOpen, isPresenting]);
// 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?.();
onUserInputActivity?.('recording_start');
setIsVoiceOpen(true);
setIsInputOpen(false);
startRecording();
}
};
const handleContinueSoftClosing = () => {
onContinueDiscussion?.();
setIsVoiceOpen(false);
setIsInputOpen(true);
};
// 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'}
isSoftClosing={isSoftClosing}
softCloseDeadline={softCloseDeadline}
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}
onContinueDiscussion={handleContinueSoftClosing}
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)}
onBeforeInput={() => onUserInputActivity?.('text_input')}
onCompositionStart={() => onUserInputActivity?.('composition_start')}
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
data-testid="roundtable-non-presentation-card"
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"
data-testid="roundtable-non-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)' }}
onClick={(e) => e.stopPropagation()}
className="absolute inset-x-6 bottom-4 z-20 flex items-center justify-end"
>
<div
data-testid="roundtable-non-presentation-input-panel"
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
ref={nonPresentationInputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onBeforeInput={() => onUserInputActivity?.('text_input')}
onCompositionStart={() => onUserInputActivity?.('composition_start')}
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 overflow-y-auto 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-[100px]"
/>
</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.
Classrooms now only have the teacher + user, so this strip is empty. */}
{studentParticipants.length > 0 && (
<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>
);
}