// web/src/components/ChatArea.tsx import React, { useRef, useLayoutEffect } from "react"; import React, { useEffect, useMemo, useState } from "react"; import { Button } from "./ui/button"; import { Textarea } from "./ui/textarea"; import { Input } from "./ui/input"; import { Label } from "./ui/label"; import { Send, ArrowDown, Share2, Upload, X, Trash2, File, FileText, Presentation, Image as ImageIcon, Bookmark, Plus, Download, Copy, } from "lucide-react"; import { Message } from "./Message"; import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; import type { Message as MessageType, LearningMode, UploadedFile, FileType, SpaceType, ChatMode, SavedChat, Workspace, } from "../App"; import { toast } from "sonner"; import { jsPDF } from "jspdf"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "./ui/dropdown-menu"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "./ui/dialog"; import { Checkbox } from "./ui/checkbox"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "./ui/alert-dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "./ui/select"; import { SmartReview } from "./SmartReview"; import clareAvatar from "../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png"; // NEW import { useObjectUrlCache } from "../lib/useObjectUrlCache"; type ReviewEventType = "send_message" | "review_topic" | "review_all"; interface ChatAreaProps { messages: MessageType[]; onSendMessage: (content: string) => void; uploadedFiles: UploadedFile[]; onFileUpload: (files: File[]) => void; onRemoveFile: (index: number) => void; onFileTypeChange: (index: number, type: FileType) => void; memoryProgress: number; isLoggedIn: boolean; learningMode: LearningMode; onClearConversation: () => void; onSaveChat: () => void; onLearningModeChange: (mode: LearningMode) => void; spaceType: SpaceType; chatMode: ChatMode; onChatModeChange: (mode: ChatMode) => void; onNextQuestion: () => void; onStartQuiz: () => void; quizState: { currentQuestion: number; waitingForAnswer: boolean; showNextButton: boolean; }; isTyping: boolean; showClearDialog: boolean; onConfirmClear: (shouldSave: boolean) => void; onCancelClear: () => void; savedChats: SavedChat[]; workspaces: Workspace[]; currentWorkspaceId: string; onSaveFile?: ( content: string, type: "export" | "summary", format?: "pdf" | "text", workspaceId?: string ) => void; leftPanelVisible?: boolean; currentCourseId?: string; onCourseChange?: (courseId: string) => void; availableCourses?: Array<{ id: string; name: string }>; showReviewBanner?: boolean; onReviewActivity?: (event: ReviewEventType) => void; currentUserId?: string; // backend user_id docType?: string; // backend doc_type (optional) } interface PendingFile { file: File; type: FileType; } export function ChatArea({ messages, onSendMessage, uploadedFiles, onFileUpload, onRemoveFile, onFileTypeChange, memoryProgress, isLoggedIn, learningMode, onClearConversation, onSaveChat, onLearningModeChange, spaceType, chatMode, onChatModeChange, onNextQuestion, onStartQuiz, quizState, isTyping: isAppTyping, showClearDialog, onConfirmClear, onCancelClear, savedChats, workspaces, currentWorkspaceId, onSaveFile, leftPanelVisible = false, currentCourseId, onCourseChange, availableCourses = [], showReviewBanner = false, onReviewActivity, currentUserId, docType, }: ChatAreaProps) { const [input, setInput] = useState(""); const [showScrollButton, setShowScrollButton] = useState(false); const [showTopBorder, setShowTopBorder] = useState(false); const [isDragging, setIsDragging] = useState(false); const [pendingFiles, setPendingFiles] = useState([]); const [showTypeDialog, setShowTypeDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [fileToDelete, setFileToDelete] = useState(null); const [selectedFile, setSelectedFile] = useState<{ file: File; index: number; } | null>(null); const [showFileViewer, setShowFileViewer] = useState(false); const [showDownloadDialog, setShowDownloadDialog] = useState(false); const [downloadPreview, setDownloadPreview] = useState(""); const [downloadTab, setDownloadTab] = useState<"chat" | "summary">("chat"); const [downloadOptions, setDownloadOptions] = useState({ chat: true, summary: false, }); const [showShareDialog, setShowShareDialog] = useState(false); const [shareLink, setShareLink] = useState(""); const [targetWorkspaceId, setTargetWorkspaceId] = useState(""); const courses = availableCourses.length > 0 ? availableCourses : [ { id: "course1", name: "Introduction to AI" }, { id: "course2", name: "Machine Learning" }, { id: "course3", name: "Data Structures" }, { id: "course4", name: "Web Development" }, ]; // Scroll refs const scrollContainerRef = useRef(null); const fileInputRef = useRef(null); // ✅ Composer measured height (dynamic) to reserve bottom padding for messages const composerRef = useRef(null); const [composerHeight, setComposerHeight] = useState(160); useLayoutEffect(() => { const el = composerRef.current; if (!el) return; const update = () => setComposerHeight(el.getBoundingClientRect().height); update(); const ro = new ResizeObserver(() => update()); ro.observe(el); return () => ro.disconnect(); }, []); const isInitialMount = useRef(true); const previousMessagesLength = useRef(messages.length); const scrollToBottom = (behavior: ScrollBehavior = "smooth") => { const el = scrollContainerRef.current; if (!el) return; const top = el.scrollHeight - el.clientHeight; if (behavior === "auto") { el.scrollTop = top; return; } el.scrollTo({ top, behavior }); }; useEffect(() => { if (isInitialMount.current) { isInitialMount.current = false; previousMessagesLength.current = messages.length; const el = scrollContainerRef.current; if (el) el.scrollTop = 0; return; } if (messages.length > previousMessagesLength.current) { const el = scrollContainerRef.current; if (el) { const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 240; if (nearBottom) scrollToBottom("smooth"); } } previousMessagesLength.current = messages.length; }, [messages]); useEffect(() => { const container = scrollContainerRef.current; if (!container) return; const handleScroll = () => { const { scrollTop, scrollHeight, clientHeight } = container; const isAtBottom = scrollHeight - scrollTop - clientHeight < 120; setShowScrollButton(!isAtBottom); setShowTopBorder(scrollTop > 0); }; handleScroll(); container.addEventListener("scroll", handleScroll, { passive: true }); return () => container.removeEventListener("scroll", handleScroll); }, []); const handleSubmit = (e: React.FormEvent | React.KeyboardEvent) => { e.preventDefault(); if (!input.trim() || !isLoggedIn) return; if (chatMode === "review") onReviewActivity?.("send_message"); onSendMessage(input); setInput(""); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSubmit(e); } }; const modeLabels: Record = { general: "General", concept: "Concept Explainer", socratic: "Socratic Tutor", exam: "Exam Prep", assignment: "Assignment Helper", summary: "Quick Summary", }; const handleReviewTopic = (item: { title: string; previousQuestion: string; memoryRetention: number; schedule: string; status: string; weight: number; lastReviewed: string; }) => { onReviewActivity?.("review_topic"); const userMessage = `Please help me review: ${item.title}`; const reviewData = `REVIEW_TOPIC:${item.title}|${item.previousQuestion}|${item.memoryRetention}|${item.schedule}|${item.status}|${item.weight}|${item.lastReviewed}`; (window as any).__lastReviewData = reviewData; onSendMessage(userMessage); }; const handleReviewAll = () => { onReviewActivity?.("review_all"); (window as any).__lastReviewData = "REVIEW_ALL"; onSendMessage("Please help me review all topics that need attention."); }; const buildPreviewContent = () => { if (!messages.length) return ""; return messages .map( (msg) => `${msg.role === "user" ? "You" : "Clare"}: ${msg.content}` ) .join("\n\n"); }; const buildSummaryContent = () => { if (!messages.length) return "No messages to summarize."; const userMessages = messages.filter((msg) => msg.role === "user"); const assistantMessages = messages.filter((msg) => msg.role === "assistant"); let summary = `Chat Summary\n================\n\n`; summary += `Total Messages: ${messages.length}\n`; summary += `- User Messages: ${userMessages.length}\n`; summary += `- Assistant Responses: ${assistantMessages.length}\n\n`; summary += `Key Points:\n`; userMessages.slice(0, 3).forEach((msg, idx) => { const preview = msg.content.substring(0, 80); summary += `${idx + 1}. ${preview}${ msg.content.length > 80 ? "..." : "" }\n`; }); return summary; }; const handleOpenDownloadDialog = () => { setDownloadTab("chat"); setDownloadOptions({ chat: true, summary: false }); setDownloadPreview(buildPreviewContent()); setShowDownloadDialog(true); }; const handleCopyPreview = async () => { try { await navigator.clipboard.writeText(downloadPreview); toast.success("Copied preview"); } catch { toast.error("Copy failed"); } }; const handleDownloadFile = async () => { try { let contentToPdf = ""; if (downloadOptions.chat) contentToPdf += buildPreviewContent(); if (downloadOptions.summary) { if (downloadOptions.chat) contentToPdf += "\n\n================\n\n"; contentToPdf += buildSummaryContent(); } if (!contentToPdf.trim()) { toast.error("Please select at least one option"); return; } const pdf = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4", }); pdf.setFontSize(14); pdf.text("Chat Export", 10, 10); pdf.setFontSize(11); const pageHeight = pdf.internal.pageSize.getHeight(); const margin = 10; const maxWidth = 190; const lineHeight = 5; let y = 20; const lines = pdf.splitTextToSize(contentToPdf, maxWidth); lines.forEach((line: string) => { if (y > pageHeight - margin) { pdf.addPage(); y = margin; } pdf.text(line, margin, y); y += lineHeight; }); pdf.save("chat-export.pdf"); setShowDownloadDialog(false); toast.success("PDF downloaded successfully"); } catch (error) { // eslint-disable-next-line no-console console.error("PDF generation error:", error); toast.error("Failed to generate PDF"); } }; const isCurrentChatSaved = (): boolean => { if (messages.length <= 1) return false; return savedChats.some((chat) => { if (chat.chatMode !== chatMode) return false; if (chat.messages.length !== messages.length) return false; return chat.messages.every((savedMsg, idx) => { const currentMsg = messages[idx]; return ( savedMsg.id === currentMsg.id && savedMsg.role === currentMsg.role && savedMsg.content === currentMsg.content ); }); }); }; const handleSaveClick = () => { if (messages.length <= 1) { toast.info("No conversation to save"); return; } onSaveChat(); }; const handleShareClick = () => { if (messages.length <= 1) { toast.info("No conversation to share"); return; } const conversationText = buildPreviewContent(); const blob = new Blob([conversationText], { type: "text/plain" }); const url = URL.createObjectURL(blob); setShareLink(url); setTargetWorkspaceId(currentWorkspaceId); setShowShareDialog(true); }; const handleCopyShareLink = async () => { try { await navigator.clipboard.writeText(shareLink); toast.success("Link copied"); } catch { toast.error("Failed to copy link"); } }; const handleShareSendToWorkspace = () => { const content = buildPreviewContent(); onSaveFile?.(content, "export", "text", targetWorkspaceId); setShowShareDialog(false); toast.success("Sent to workspace Saved Files"); }; const handleClearClick = () => { const saved = isCurrentChatSaved(); if (saved) { onConfirmClear(false as any); return; } const hasUserMessages = messages.some((m) => m.role === "user"); if (!hasUserMessages) { onClearConversation(); return; } onClearConversation(); }; // DnD const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (!isLoggedIn) return; setIsDragging(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); if (!isLoggedIn) return; const fileList = e.dataTransfer.files; const files: File[] = []; for (let i = 0; i < fileList.length; i++) { const f = fileList.item(i); if (f) files.push(f); } const validFiles = files.filter((file) => { const ext = file.name.toLowerCase(); return [ ".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt", ].some((allowed) => ext.endsWith(allowed)); }); if (validFiles.length > 0) { setPendingFiles( validFiles.map((file) => ({ file, type: "other" as FileType })) ); setShowTypeDialog(true); } else { toast.error("Please upload .pdf, .docx, .pptx, or image files"); } }; const handleFileSelect = (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []) as File[]; if (files.length > 0) { const validFiles = files.filter((file) => { const ext = file.name.toLowerCase(); return [ ".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt", ].some((allowed) => ext.endsWith(allowed)); }); if (validFiles.length > 0) { setPendingFiles( validFiles.map((file) => ({ file, type: "other" as FileType })) ); setShowTypeDialog(true); } else { toast.error("Please upload .pdf, .docx, .pptx, or image files"); } } e.target.value = ""; }; const handleConfirmUpload = () => { onFileUpload(pendingFiles.map((pf) => pf.file)); const startIndex = uploadedFiles.length; pendingFiles.forEach((pf, idx) => { setTimeout(() => { onFileTypeChange(startIndex + idx, pf.type); }, 0); }); const count = pendingFiles.length; setPendingFiles([]); setShowTypeDialog(false); toast.success(`${count} file(s) uploaded successfully`); }; const handleCancelUpload = () => { setPendingFiles([]); setShowTypeDialog(false); }; const handlePendingFileTypeChange = (index: number, type: FileType) => { setPendingFiles((prev) => prev.map((pf, i) => (i === index ? { ...pf, type } : pf)) ); }; // File helpers const getFileIcon = (filename: string) => { const ext = filename.toLowerCase(); if (ext.endsWith(".pdf")) return FileText; if (ext.endsWith(".docx") || ext.endsWith(".doc")) return File; if (ext.endsWith(".pptx") || ext.endsWith(".ppt")) return Presentation; if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e))) return ImageIcon; return File; }; const formatFileSize = (bytes: number) => { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; }; const fileKey = (f: File) => `${f.name}::${f.size}::${f.lastModified}`; // ✅ useObjectUrlCache: for image thumbnails (uploaded + pending) const allThumbFiles = React.useMemo(() => { return [...uploadedFiles.map((u) => u.file), ...pendingFiles.map((p) => p.file)]; }, [uploadedFiles, pendingFiles]); const { getOrCreate } = useObjectUrlCache(allThumbFiles); // ✅ NEW: a compact "chip" UI (the one with left X) const FileChip = ({ file, index, source, }: { file: File; index: number; source: "uploaded" | "pending"; }) => { const ext = file.name.toLowerCase(); const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e)); const label = ext.endsWith(".pdf") ? "PDF" : ext.endsWith(".pptx") || ext.endsWith(".ppt") ? "Presentation" : ext.endsWith(".docx") || ext.endsWith(".doc") ? "Document" : isImage ? "Image" : "File"; const thumbUrl = isImage ? getOrCreate(file) : null; const handleRemove = () => { if (source === "uploaded") onRemoveFile(index); else setPendingFiles((prev) => prev.filter((p) => fileKey(p.file) !== fileKey(file))); }; return (
{file.name}
{label}
{isImage ? (
{thumbUrl ? ( {file.name} ) : (
)}
) : null}
); }; const bottomPad = Math.max(24, composerHeight + 24); return (
{/* Top Bar */}
{/* Course Selector - Left */}
{(() => { const current = workspaces.find((w) => w.id === currentWorkspaceId); if (current?.type === "group") { if (current.category === "course" && current.courseName) { return (
{current.courseName}
); } return null; } return ( ); })()}
{/* Tabs - Center */}
onChatModeChange(value as ChatMode)} className="w-auto" orientation="horizontal" > Ask Review Quiz
{/* Action Buttons - Right */}
{/* Scroll Container */}
{messages.map((message) => ( {chatMode === "review" && message.id === "review-1" && message.role === "assistant" && (
)} {chatMode === "quiz" && message.id === "quiz-1" && message.role === "assistant" && quizState.currentQuestion === 0 && !quizState.waitingForAnswer && !isAppTyping && (
)} ))} {isAppTyping && (
Clare
)}
{/* Scroll-to-bottom button */} {showScrollButton && (
)} {/* Composer */}
{/* Uploaded Files Preview (chip UI) */} {(uploadedFiles.length > 0 || pendingFiles.length > 0) && (
{/* uploaded */} {uploadedFiles.map((uf, i) => { const key = `${uf.file.name}::${uf.file.size}::${uf.file.lastModified}`; const ext = uf.file.name.toLowerCase(); const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e) ); const thumbUrl = isImage ? getOrCreate(uf.file) : null; return (
{/* ✅ Thumbnail (only for images) */} {isImage ? (
{thumbUrl ? ( {uf.file.name} ) : (
)}
) : null}
{uf.file.name}
{uf.type}
); })} {/* pending */} {pendingFiles.map((p, idx) => ( ))}
)}
{/* Mode Selector + Upload */}
{chatMode === "ask" && ( onLearningModeChange("general")} className={learningMode === "general" ? "bg-accent" : ""} >
General Answer various questions (context required)
onLearningModeChange("concept")} className={learningMode === "concept" ? "bg-accent" : ""} >
Concept Explainer Get detailed explanations of concepts
onLearningModeChange("socratic")} className={learningMode === "socratic" ? "bg-accent" : ""} >
Socratic Tutor Learn through guided questions
onLearningModeChange("exam")} className={learningMode === "exam" ? "bg-accent" : ""} >
Exam Prep Practice with quiz questions
onLearningModeChange("assignment")} className={learningMode === "assignment" ? "bg-accent" : ""} >
Assignment Helper Get help with assignments
onLearningModeChange("summary")} className={learningMode === "summary" ? "bg-accent" : ""} >
Quick Summary Get concise summaries
)}