// web/src/components/ChatArea.tsx import pdfIcon from "../assets/file-icons/pdf.png"; import pptIcon from "../assets/file-icons/ppt.png"; import otherIcon from "../assets/file-icons/other_format.png"; import React, { useEffect, useLayoutEffect, useMemo, useRef, 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; onProfileBioUpdate?: (bio: string) => void; // ✅ NEW 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; } // File viewer content (image full preview + pdf iframe; others download) function isImageFile(name: string) { const n = name.toLowerCase(); return [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => n.endsWith(e)); } function isPdfFile(name: string) { return name.toLowerCase().endsWith(".pdf"); } function isDocFile(name: string) { const n = name.toLowerCase(); return n.endsWith(".doc") || n.endsWith(".docx"); } function isPptFile(name: string) { const n = name.toLowerCase(); return n.endsWith(".ppt") || n.endsWith(".pptx"); } function FileViewerContent({ file }: { file: File }) { const [url, setUrl] = React.useState(""); React.useEffect(() => { const u = URL.createObjectURL(file); setUrl(u); return () => URL.revokeObjectURL(u); }, [file]); if (isImageFile(file.name)) { return (
{file.name}
); } if (isPdfFile(file.name)) { // Force PDF MIME, in case file.type is empty and the browser blocks preview. const pdfBlob = new Blob([file], { type: "application/pdf" }); const pdfUrl = URL.createObjectURL(pdfBlob); return (
PDF preview is blocked by your browser. Please open it in a new tab or download.
Open in new tab Download
); } const kind = isDocFile(file.name) ? "Word document" : isPptFile(file.name) ? "PowerPoint" : "File"; return (
Preview is not available for this {kind} format in the browser without conversion.
Download to view
); } 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, onProfileBioUpdate, 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(""); // -------------------------- // Profile Init Flow (Ask mode) // -------------------------- type InitStatus = "idle" | "offered" | "asking" | "generating" | "done"; type InitQ = { id: string; title: string; placeholder?: string; }; const INIT_QUESTIONS: InitQ[] = [ { id: "course_goal", title: "What’s the single most important outcome you want from this course?", placeholder: "e.g., understand LLM basics, build a project, prep for an exam, apply to work…", }, { id: "background", title: "What’s your current background (major, job, or anything relevant)?", placeholder: "One sentence is totally fine.", }, { id: "ai_experience", title: "Have you worked with AI/LLMs before? If yes, at what level?", placeholder: "e.g., none / used ChatGPT / built small projects / research…", }, { id: "python_level", title: "How comfortable are you with Python? (Beginner / Intermediate / Advanced)", placeholder: "Type one: Beginner / Intermediate / Advanced", }, { id: "preferred_format", title: "What helps you learn best? (You can list multiple, separated by commas)", placeholder: "Step-by-step, examples, visuals, concise answers, Socratic questions…", }, { id: "pace", title: "What pace do you prefer from me? (Fast / Steady / Very detailed)", placeholder: "Type one: Fast / Steady / Very detailed", }, { id: "biggest_pain", title: "Where do you typically get stuck when learning technical topics?", placeholder: "Concepts, tools, task breakdown, math, confidence, time management…", }, { id: "support_pref", title: "When you’re unsure, how should I support you?", placeholder: "Hints first / guided questions / direct answer / ask then answer…", }, ]; const [initStatus, setInitStatus] = useState("idle"); const [initNeedOffer, setInitNeedOffer] = useState(false); const [initStep, setInitStep] = useState(0); const [initAnswers, setInitAnswers] = useState>({}); const [generatedBio, setGeneratedBio] = useState(""); // IMPORTANT: allow typing during "asking"; lock typing only during "generating" const initInputLocked = chatMode === "ask" && initStatus === "generating"; // Use this to block other actions (uploads / drag-drop / etc.) during asking+generating const initBlockActions = chatMode === "ask" && (initStatus === "asking" || initStatus === "generating"); 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); }, []); // Check if we should run profile init flow (Ask mode only) useEffect(() => { if (!isLoggedIn) return; if (chatMode !== "ask") return; if (!currentUserId) return; // If already completed in this session, do nothing if (initStatus !== "idle") return; let cancelled = false; (async () => { try { const r = await fetch(`/api/profile/status?user_id=${encodeURIComponent(currentUserId)}`); if (!r.ok) return; const j = await r.json(); if (cancelled) return; if (j?.need_init) { setInitNeedOffer(true); setInitStatus("offered"); } } catch { // ignore } })(); return () => { cancelled = true; }; }, [isLoggedIn, chatMode, currentUserId, initStatus]); const handleSubmit = async (e: React.FormEvent | React.KeyboardEvent) => { e.preventDefault(); if (!isLoggedIn) return; // INIT FLOW: treat input as the answer to the current init question if (chatMode === "ask" && initStatus === "asking") { const text = input.trim(); if (!text) return; const q = INIT_QUESTIONS[initStep]; const nextAnswers = { ...initAnswers, [q.id]: text }; setInitAnswers(nextAnswers); setInput(""); const nextStep = initStep + 1; // finished -> generate bio + save to backend if (nextStep >= INIT_QUESTIONS.length) { setInitStatus("generating"); try { const r = await fetch("/api/profile/init_submit", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_id: currentUserId, answers: nextAnswers, language_preference: "English", }), }); if (!r.ok) throw new Error("init_submit failed"); const j = await r.json(); setGeneratedBio(j?.bio || ""); onProfileBioUpdate?.(j?.bio || ""); // ✅ NEW: sync into user profile setInitStatus("done"); setInitNeedOffer(false); // reset setInitStep(0); setInitAnswers({}); } catch { toast.error("Sorry — I couldn’t generate your Bio. Please try again."); setInitStatus("asking"); } return; } // go next question setInitStep(nextStep); return; } // ORIGINAL behavior (unchanged) const hasText = !!input.trim(); const hasFiles = uploadedFiles.length > 0; if (!hasText && !hasFiles) return; if (chatMode === "review") onReviewActivity?.("send_message"); onSendMessage(input); setInput(""); setPendingFiles([]); for (let i = uploadedFiles.length - 1; i >= 0; i--) { onRemoveFile(i); } }; 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) { // keep behavior 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; if (initBlockActions) return; // block drag UI during init setIsDragging(true); }; const handleDragLeave = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); }; const MAX_UPLOAD_FILES = 10; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); if (!isLoggedIn) return; if (initBlockActions) return; // block file drop during init 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) { toast.error("Please upload .pdf, .docx, .pptx, or image files"); return; } // limit total files per conversation const currentCount = uploadedFiles.length + pendingFiles.length; const remaining = MAX_UPLOAD_FILES - currentCount; if (remaining <= 0) { toast.error(`Each conversation can upload up to ${MAX_UPLOAD_FILES} files.`); return; } const accepted = validFiles.slice(0, remaining); const rejected = validFiles.length - accepted.length; if (rejected > 0) { toast.warning(`Only the first ${accepted.length} file(s) were added (max ${MAX_UPLOAD_FILES}).`); } // append, do NOT overwrite existing pending setPendingFiles((prev) => [ ...prev, ...accepted.map((file) => ({ file, type: "other" as FileType })), ]); setShowTypeDialog(true); }; const handleFileSelect = (e: React.ChangeEvent) => { if (initBlockActions) { e.target.value = ""; return; } const files = Array.from(e.target.files || []) as File[]; if (files.length === 0) { e.target.value = ""; return; } 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) { toast.error("Please upload .pdf, .docx, .pptx, or image files"); e.target.value = ""; return; } // limit total files per conversation const currentCount = uploadedFiles.length + pendingFiles.length; const remaining = MAX_UPLOAD_FILES - currentCount; if (remaining <= 0) { toast.error(`Each conversation can upload up to ${MAX_UPLOAD_FILES} files.`); e.target.value = ""; return; } const accepted = validFiles.slice(0, remaining); const rejected = validFiles.length - accepted.length; if (rejected > 0) { toast.warning(`Only the first ${accepted.length} file(s) were added (max ${MAX_UPLOAD_FILES}).`); } // append, do NOT overwrite existing pending setPendingFiles((prev) => [ ...prev, ...accepted.map((file) => ({ file, type: "other" as FileType })), ]); setShowTypeDialog(true); 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 = useMemo(() => { return [...uploadedFiles.map((u) => u.file), ...pendingFiles.map((p) => p.file)]; }, [uploadedFiles, pendingFiles]); const { getOrCreate } = useObjectUrlCache(allThumbFiles); // a compact "chip" UI (the one with left Trash) 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 isPdf = ext.endsWith(".pdf"); const isPpt = ext.endsWith(".ppt") || ext.endsWith(".pptx"); const fileIcon = isPdf ? pdfIcon : isPpt ? pptIcon : otherIcon; const label = isPdf ? "PDF" : isPpt ? "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}
{/* Thumbnail (image preview or file icon) */}
{isImage ? ( thumbUrl ? ( {file.name} ) : (
) ) : ( {file.name} )}
); }; const bottomPad = Math.max(24, composerHeight + 24); const initPlaceholder = initStatus === "asking" ? "Type your answer here and press Enter / Send..." : undefined; 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 && (
)} ))} {/* Profile Init Offer (Ask mode only) */} {chatMode === "ask" && initNeedOffer && initStatus === "offered" && (
Clare
Quick intro so I can personalize your experience
I’m Clare, your AI teaching assistant. If you’d like, we can answer a few short questions so I can tailor explanations, pacing, and practice to you. Your answers will be summarized into your Profile Bio and used only inside this platform.
)} {/* Current init question bubble */} {chatMode === "ask" && initStatus === "asking" && (
Clare
{INIT_QUESTIONS[initStep]?.title}
{INIT_QUESTIONS[initStep]?.placeholder || "Just type your answer and press Send."}
Question {initStep + 1} of {INIT_QUESTIONS.length}
)} {/* Generating bubble */} {chatMode === "ask" && initStatus === "generating" && (
Clare
Thanks — I’m generating your Profile Bio now…
)} {/* Done bubble with bio */} {chatMode === "ask" && initStatus === "done" && !!generatedBio && (
Clare
Thank you — I’ve saved this to your Profile Bio.
{generatedBio}
You can update it anytime. I’ll use this to adapt explanations, pacing, and practice.
)} {isAppTyping && (
Clare
)}
{/* Scroll-to-bottom button */} {showScrollButton && (
)} {/* Composer */}
{/* Uploaded Files Preview */} {(uploadedFiles.length > 0 || pendingFiles.length > 0) && (
{/* uploaded */} {uploadedFiles.map((uf, i) => { const key = `${uf.file.name}::${uf.file.size}::${uf.file.lastModified}`; const nameLower = uf.file.name.toLowerCase(); const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => nameLower.endsWith(e) ); const isPdf = nameLower.endsWith(".pdf"); const isPpt = nameLower.endsWith(".ppt") || nameLower.endsWith(".pptx"); const fileIcon = isPdf ? pdfIcon : isPpt ? pptIcon : otherIcon; const thumbUrl = isImage ? getOrCreate(uf.file) : null; return (
{ setSelectedFile({ file: uf.file, index: i }); setShowFileViewer(true); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setSelectedFile({ file: uf.file, index: i }); setShowFileViewer(true); } }} className="flex items-center justify-between gap-2 rounded-md border px-3 py-2 cursor-pointer hover:bg-muted/40" title="Click to preview" > {/* Thumbnail (image preview or file icon) */}
{isImage ? ( thumbUrl ? ( {uf.file.name} ) : (
) ) : ( {uf.file.name} )}
{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
)}