'use client'; import { useImperativeHandle, forwardRef, useRef, useCallback, useState, useMemo, useEffect, } from 'react'; import type { SessionType } from '@/lib/types/chat'; import type { DiscussionRequest } from '@/components/roundtable'; import type { Action } from '@/lib/types/action'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useStageStore } from '@/lib/store'; import { buildLectureNotes } from '@/lib/chat/lecture-notes'; import { PanelRightClose, BookOpen, MessageSquare } from 'lucide-react'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { useChatSessions, MANUAL_STOP_END_OPTIONS, type EndSessionOptions, type SessionCleanupPayload, } 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; onSoftCloseSession?: (payload: SessionCleanupPayload) => void; onSoftClosingChange?: (softClosing: boolean, deadline?: number) => void; onStopSession?: (payload: SessionCleanupPayload) => 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; currentActionIndex?: number | null; canJumpToAction?: (sceneId: string, actionIndex: number) => boolean; onJumpToAction?: (sceneId: string, actionIndex: number) => void; } export interface ChatAreaRef { createSession: (type: SessionType, title: string) => Promise; endSession: (sessionId: string, options?: EndSessionOptions) => Promise; endActiveSession: (options?: EndSessionOptions) => Promise; stopActiveSession: () => Promise; continueActiveSoftClosingSession: () => boolean; 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, onSoftCloseSession, onSoftClosingChange, onStopSession, onSegmentSealed, shouldHoldAfterReveal, currentSceneId, currentActionIndex, canJumpToAction, onJumpToAction, }, ref, ) => { const { t } = useI18n(); const scenes = useStageStore((s) => s.scenes); const { sessions, activeSessionType, expandedSessionIds, isStreaming, createSession, endSession, endActiveSession, continueSoftClosingSession, confirmSoftClosingSession, softPauseActiveSession, resumeActiveSession, sendMessage, startDiscussion, startLecture, addLectureMessage, toggleSessionExpand, getLectureMessageId, pauseBuffer, resumeBuffer, pauseActiveLiveBuffer, resumeActiveLiveBuffer, } = useChatSessions({ onLiveSpeech, onSpeechProgress, onThinking, onCueUser, onActiveBubble, onLiveSessionError, onSoftCloseSession, 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. const lectureNotes = useMemo(() => buildLectureNotes(scenes), [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], ); const softClosingChatSession = useMemo( () => chatSessions.find((s) => s.status === 'soft-closing'), [chatSessions], ); useEffect(() => { onSoftClosingChange?.( Boolean(softClosingChatSession), softClosingChatSession?.softCloseDeadline, ); }, [softClosingChatSession, onSoftClosingChange]); // Wrap endSession for QA/Discussion: also notify parent for engine cleanup const handleEndSession = useCallback( async (sessionId: string) => { const session = chatSessions.find((candidate) => candidate.id === sessionId); if (session?.status === 'soft-closing') { const payload = await confirmSoftClosingSession(sessionId); if (payload) onStopSession?.(payload); return; } await endSession(sessionId, MANUAL_STOP_END_OPTIONS); onStopSession?.({ sessionId, source: 'manual_stop' }); }, [chatSessions, confirmSoftClosingSession, endSession, onStopSession], ); const handleStopActiveSession = useCallback(async () => { const active = chatSessions.find( (session) => session.status === 'active' || session.status === 'soft-closing', ); if (active) await handleEndSession(active.id); }, [chatSessions, handleEndSession]); const handleContinueActiveSoftClosingSession = useCallback((): boolean => { const softClosing = chatSessions.find((session) => session.status === 'soft-closing'); return softClosing ? continueSoftClosingSession(softClosing.id) : false; }, [chatSessions, continueSoftClosingSession]); const switchToTab = useCallback((tab: 'lecture' | 'chat') => { setActiveTab(tab); }, []); useImperativeHandle(ref, () => ({ createSession, endSession, endActiveSession, stopActiveSession: handleStopActiveSession, continueActiveSoftClosingSession: handleContinueActiveSoftClosingSession, 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';