2144 lines
97 KiB
TypeScript
2144 lines
97 KiB
TypeScript
'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>
|
||
);
|
||
}
|