import { useImperativeHandle, forwardRef, useRef, useCallback, useState, useMemo } from 'react'; import type { SessionType } from '@/lib/types/chat'; import type { LectureNoteEntry } from '@/lib/types/chat'; import type { DiscussionRequest } from '@/components/roundtable'; import type { Action, SpeechAction, DiscussionAction } from '@/lib/types/action'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useStageStore } from '@/lib/store'; import { PanelRightClose, BookOpen, MessageSquare } from 'lucide-react'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { useChatSessions } from './use-chat-sessions'; import { SessionList } from './session-list'; import { LectureNotesView } from './lecture-notes-view'; interface ChatAreaProps { className?: string; width?: number; onWidthChange?: (width: number) => void; collapsed?: boolean; onCollapseChange?: (collapsed: boolean) => void; activeBubbleId?: string | null; onActiveBubble?: (messageId: string | null) => void; onLiveSpeech?: (text: string | null, agentId?: string | null) => void; onSpeechProgress?: (ratio: number | null) => void; onThinking?: (state: { stage: string; agentId?: string } | null) => void; onCueUser?: (fromAgentId?: string, prompt?: string) => void; onLiveSessionError?: () => void; onStopSession?: () => void; onSegmentSealed?: ( messageId: string, partId: string, fullText: string, agentId: string | null, ) => void; /** When provided and returns true, StreamBuffer holds on the current text item after reveal. */ shouldHoldAfterReveal?: () => { holding: boolean; segmentDone: number } | boolean; currentSceneId?: string | null; } export interface ChatAreaRef { createSession: (type: SessionType, title: string) => Promise; endSession: (sessionId: string) => Promise; endActiveSession: () => Promise; softPauseActiveSession: () => Promise; resumeActiveSession: () => Promise; sendMessage: (content: string) => Promise; startDiscussion: (request: DiscussionRequest) => Promise; startLecture: (sceneId: string) => Promise; addLectureMessage: (sessionId: string, action: Action, actionIndex: number) => void; getIsStreaming: () => boolean; getActiveSessionType: () => string | null; getLectureMessageId: (sessionId: string) => string | null; pauseBuffer: (sessionId: string) => void; resumeBuffer: (sessionId: string) => void; pauseActiveLiveBuffer: () => boolean; resumeActiveLiveBuffer: () => void; switchToTab: (tab: 'lecture' | 'chat') => void; } const DEFAULT_WIDTH = 340; const MIN_WIDTH = 240; const MAX_WIDTH = 560; export const ChatArea = forwardRef( ( { className, width = DEFAULT_WIDTH, onWidthChange, collapsed = false, onCollapseChange, activeBubbleId, onActiveBubble, onLiveSpeech, onSpeechProgress, onThinking, onCueUser, onLiveSessionError, onStopSession, onSegmentSealed, shouldHoldAfterReveal, currentSceneId, }, ref, ) => { const { t } = useI18n(); const scenes = useStageStore((s) => s.scenes); const { sessions, activeSessionType, expandedSessionIds, isStreaming, createSession, endSession, endActiveSession, softPauseActiveSession, resumeActiveSession, sendMessage, startDiscussion, startLecture, addLectureMessage, toggleSessionExpand, getLectureMessageId, pauseBuffer, resumeBuffer, pauseActiveLiveBuffer, resumeActiveLiveBuffer, } = useChatSessions({ onLiveSpeech, onSpeechProgress, onThinking, onCueUser, onActiveBubble, onLiveSessionError, onStopSession, onSegmentSealed, shouldHoldAfterReveal, }); const [activeTab, setActiveTab] = useState<'lecture' | 'chat'>('lecture'); const isDraggingRef = useRef(false); const [isDragging, setIsDragging] = useState(false); const bottomRef = useRef(null); // Derive lecture notes directly from scenes — updates reactively as scenes stream in // Preserves action order so spotlight/laser badges appear inline between speech texts const lectureNotes: LectureNoteEntry[] = useMemo( () => scenes .filter((scene) => scene.actions && scene.actions.length > 0) .map((scene) => ({ sceneId: scene.id, sceneTitle: scene.title, sceneOrder: scene.order, items: scene .actions!.filter( (a) => a.type === 'speech' || a.type === 'spotlight' || a.type === 'laser' || a.type === 'play_video' || a.type === 'discussion', ) .map((a) => { if (a.type === 'speech') { return { kind: 'speech' as const, text: (a as SpeechAction).text, }; } return { kind: 'action' as const, type: a.type, label: a.type === 'discussion' ? (a as DiscussionAction).topic : undefined, }; }), completedAt: scene.updatedAt || scene.createdAt || 0, })) .sort((a, b) => a.sceneOrder - b.sceneOrder), [scenes], ); // Filter out lecture sessions for the Chat tab const chatSessions = useMemo(() => sessions.filter((s) => s.type !== 'lecture'), [sessions]); // Whether there's an active discussion/QA session (for amber dot on Chat tab) const hasActiveChatSession = useMemo( () => chatSessions.some((s) => s.status === 'active'), [chatSessions], ); // Wrap endSession for QA/Discussion: also notify parent for engine cleanup const handleEndSession = useCallback( async (sessionId: string) => { await endSession(sessionId); onStopSession?.(); }, [endSession, onStopSession], ); const switchToTab = useCallback((tab: 'lecture' | 'chat') => { setActiveTab(tab); }, []); useImperativeHandle(ref, () => ({ createSession, endSession, endActiveSession, softPauseActiveSession, resumeActiveSession, sendMessage, startDiscussion, startLecture, addLectureMessage, getIsStreaming: () => isStreaming, getActiveSessionType: () => activeSessionType, getLectureMessageId, pauseBuffer, resumeBuffer, pauseActiveLiveBuffer, resumeActiveLiveBuffer, switchToTab, })); // Drag-to-resize const handleDragStart = useCallback( (e: React.MouseEvent) => { e.preventDefault(); isDraggingRef.current = true; setIsDragging(true); const startX = e.clientX; const startWidth = width; const handleMouseMove = (me: MouseEvent) => { const delta = startX - me.clientX; const newWidth = Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, startWidth + delta)); onWidthChange?.(newWidth); }; const handleMouseUp = () => { isDraggingRef.current = false; setIsDragging(false); document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); document.body.style.cursor = ''; document.body.style.userSelect = ''; }; document.body.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }, [width, onWidthChange], ); const displayWidth = collapsed ? 0 : width; return (
{/* Drag handle */} {!collapsed && (
)}
setActiveTab(v as 'lecture' | 'chat')} className="flex flex-col h-full gap-0" > {/* Tab header row */}
{t('chat.tabs.lecture')} {t('chat.tabs.chat')} {/* Amber pulse dot when there's an active chat session and user is on Notes tab */} {hasActiveChatSession && activeTab === 'lecture' && ( )} {onCollapseChange && ( )}
{/* Notes Tab */} {/* Chat Tab */}
{chatSessions.length === 0 ? (

{t('chat.noConversations')}

{t('chat.startConversation')}

) : ( <>
)}
); }, ); ChatArea.displayName = 'ChatArea';