SarahXia0405's picture
Update web/src/components/ChatArea.tsx
c709a04 verified
// 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<PendingFile[]>([]);
const [showTypeDialog, setShowTypeDialog] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [fileToDelete, setFileToDelete] = useState<number | null>(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<string>("");
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<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// ✅ Composer measured height (dynamic) to reserve bottom padding for messages
const composerRef = useRef<HTMLDivElement>(null);
const [composerHeight, setComposerHeight] = useState<number>(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<LearningMode, string> = {
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<HTMLInputElement>) => {
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 (
<div className="flex items-center gap-2 rounded-xl border border-border bg-card px-3 py-2 shadow-sm w-[320px] max-w-full">
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleRemove();
}}
className="ml-2 inline-flex h-7 w-7 items-center justify-center rounded-md border border-border bg-card hover:bg-muted"
title="Remove"
>
<Trash2 className="h-4 w-4" />
</button>
<div className="min-w-0 flex-1">
<div className="text-sm font-medium truncate" title={file.name}>
{file.name}
</div>
<div className="text-xs text-muted-foreground">{label}</div>
</div>
{isImage ? (
<div className="relative h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted">
{thumbUrl ? (
<img
src={thumbUrl}
alt={file.name}
className="h-full w-full object-cover"
draggable={false}
/>
) : (
<div className="h-full w-full flex items-center justify-center">
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</div>
)}
</div>
) : null}
</div>
);
};
const bottomPad = Math.max(24, composerHeight + 24);
return (
<div className="relative flex flex-col h-full min-h-0 w-full overflow-hidden">
{/* Top Bar */}
<div
className={`flex-shrink-0 flex items-center justify-between px-4 bg-card z-20 ${
showTopBorder ? "border-b border-border" : ""
}`}
style={{
height: "4.5rem",
margin: 0,
padding: "1rem 1rem",
boxSizing: "border-box",
}}
>
{/* Course Selector - Left */}
<div className="flex-shrink-0">
{(() => {
const current = workspaces.find((w) => w.id === currentWorkspaceId);
if (current?.type === "group") {
if (current.category === "course" && current.courseName) {
return (
<div className="h-9 px-3 inline-flex items-center rounded-md border font-semibold">
{current.courseName}
</div>
);
}
return null;
}
return (
<Select
value={currentCourseId || "course1"}
onValueChange={(val) => onCourseChange && onCourseChange(val)}
>
<SelectTrigger className="w-[200px] h-9 font-semibold">
<SelectValue placeholder="Select course" />
</SelectTrigger>
<SelectContent>
{courses.map((course) => (
<SelectItem key={course.id} value={course.id}>
{course.name}
</SelectItem>
))}
</SelectContent>
</Select>
);
})()}
</div>
{/* Tabs - Center */}
<div className="absolute left-1/2 -translate-x-1/2 flex-shrink-0">
<Tabs
value={chatMode}
onValueChange={(value) => onChatModeChange(value as ChatMode)}
className="w-auto"
orientation="horizontal"
>
<TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground">
<TabsTrigger value="ask" className="w-[140px] px-3 text-sm">
Ask
</TabsTrigger>
<TabsTrigger value="review" className="w-[140px] px-3 text-sm relative">
Review
<span
className="absolute top-0 right-0 bg-red-500 rounded-full border-2"
style={{
width: 10,
height: 10,
transform: "translate(25%, -25%)",
zIndex: 10,
borderColor: "var(--muted)",
}}
/>
</TabsTrigger>
<TabsTrigger value="quiz" className="w-[140px] px-3 text-sm">
Quiz
</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Action Buttons - Right */}
<div className="flex items-center gap-2 flex-shrink-0">
<Button
variant="ghost"
size="icon"
onClick={handleSaveClick}
disabled={!isLoggedIn}
className={`h-8 w-8 rounded-md hover:bg-muted/50 ${
isCurrentChatSaved() ? "text-primary" : ""
}`}
title={isCurrentChatSaved() ? "Unsave" : "Save"}
>
<Bookmark
className={`h-4 w-4 ${
isCurrentChatSaved() ? "fill-primary text-primary" : ""
}`}
/>
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleOpenDownloadDialog}
disabled={!isLoggedIn}
className="h-8 w-8 rounded-md hover:bg-muted/50"
title="Download"
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={handleShareClick}
disabled={!isLoggedIn}
className="h-8 w-8 rounded-md hover:bg-muted/50"
title="Share"
>
<Share2 className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={handleClearClick}
disabled={!isLoggedIn}
className="h-8 px-3 gap-2 rounded-md border border-border disabled:opacity-60 !bg-[var(--card)] !text-[var(--card-foreground)] hover:!opacity-90 [&_svg]:!text-[var(--card-foreground)] [&_span]:!text-[var(--card-foreground)]"
title="New Chat"
>
<Plus className="h-4 w-4" />
<span className="text-sm font-medium">New chat</span>
</Button>
</div>
</div>
{/* Scroll Container */}
<div
ref={scrollContainerRef}
className="flex-1 min-h-0 overflow-y-auto overscroll-contain"
style={{ overscrollBehavior: "contain" }}
>
<div className="py-6" style={{ paddingBottom: bottomPad }}>
<div className="w-full space-y-6 max-w-4xl mx-auto">
{messages.map((message) => (
<React.Fragment key={message.id}>
<Message
message={message}
showSenderInfo={spaceType === "group"}
isFirstGreeting={
(message.id === "1" ||
message.id === "review-1" ||
message.id === "quiz-1") &&
message.role === "assistant"
}
showNextButton={message.showNextButton && !isAppTyping}
onNextQuestion={onNextQuestion}
chatMode={chatMode}
currentUserId={currentUserId}
learningMode={learningMode}
docType={docType}
/>
{chatMode === "review" &&
message.id === "review-1" &&
message.role === "assistant" && (
<div className="flex gap-2 justify-start px-4">
<div className="w-10 h-10 flex-shrink-0" />
<div
className="w-full"
style={{
maxWidth: "min(770px, calc(100% - 2rem))",
}}
>
<SmartReview
onReviewTopic={handleReviewTopic}
onReviewAll={handleReviewAll}
/>
</div>
</div>
)}
{chatMode === "quiz" &&
message.id === "quiz-1" &&
message.role === "assistant" &&
quizState.currentQuestion === 0 &&
!quizState.waitingForAnswer &&
!isAppTyping && (
<div className="flex justify-center py-4">
<Button
onClick={onStartQuiz}
className="bg-red-500 hover:bg-red-600 text-white"
>
Start Quiz
</Button>
</div>
)}
</React.Fragment>
))}
{isAppTyping && (
<div className="flex gap-2 justify-start px-4">
<div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
<img
src={clareAvatar}
alt="Clare"
className="w-full h-full object-cover"
/>
</div>
<div className="bg-muted rounded-2xl px-4 py-3">
<div className="flex gap-1">
<div
className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
style={{ animationDelay: "0ms" }}
/>
<div
className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
style={{ animationDelay: "150ms" }}
/>
<div
className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
style={{ animationDelay: "300ms" }}
/>
</div>
</div>
</div>
)}
</div>
</div>
</div>
{/* Scroll-to-bottom button */}
{showScrollButton && (
<div
className="absolute z-30 left-0 right-0 flex justify-center pointer-events-none"
style={{ bottom: composerHeight + 16 }}
>
<Button
variant="secondary"
size="icon"
className="rounded-full shadow-lg hover:shadow-xl transition-shadow bg-background border border-border pointer-events-auto w-10 h-10"
onClick={() => scrollToBottom("smooth")}
title="Scroll to bottom"
>
<ArrowDown className="h-5 w-5" />
</Button>
</div>
)}
{/* Composer */}
<div
ref={composerRef}
className="flex-shrink-0 bg-background/95 backdrop-blur-sm z-20 border-t border-border"
>
<div className="max-w-4xl mx-auto px-4 py-4">
{/* Uploaded Files Preview (chip UI) */}
{(uploadedFiles.length > 0 || pendingFiles.length > 0) && (
<div className="mb-2 flex flex-wrap gap-2 max-h-32 overflow-y-auto">
{/* 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 (
<div
key={key}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2"
>
{/* ✅ Thumbnail (only for images) */}
{isImage ? (
<div className="h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted">
{thumbUrl ? (
<img
src={thumbUrl}
alt={uf.file.name}
className="h-full w-full object-cover"
draggable={false}
/>
) : (
<div className="h-full w-full flex items-center justify-center">
<ImageIcon className="h-4 w-4 text-muted-foreground" />
</div>
)}
</div>
) : null}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">
{uf.file.name}
</div>
<div className="text-xs text-muted-foreground">
{uf.type}
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => onRemoveFile(i)}
title="Remove"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
})}
{/* pending */}
{pendingFiles.map((p, idx) => (
<FileChip
key={`p-${p.file.name}-${p.file.size}-${p.file.lastModified}`}
file={p.file}
index={idx}
source="pending"
/>
))}
</div>
)}
<form
onSubmit={handleSubmit as any}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={isDragging ? "opacity-75" : ""}
>
<div className="relative">
{/* Mode Selector + Upload */}
<div className="absolute bottom-3 left-2 flex items-center gap-1 z-10">
{chatMode === "ask" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="gap-1.5 h-8 px-2 text-xs hover:bg-muted/50"
disabled={!isLoggedIn}
type="button"
>
<span>{modeLabels[learningMode]}</span>
<svg
className="h-3 w-3 opacity-50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-56">
<DropdownMenuItem
onClick={() => onLearningModeChange("general")}
className={learningMode === "general" ? "bg-accent" : ""}
>
<div className="flex flex-col">
<span className="font-medium">General</span>
<span className="text-xs text-muted-foreground">
Answer various questions (context required)
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onLearningModeChange("concept")}
className={learningMode === "concept" ? "bg-accent" : ""}
>
<div className="flex flex-col">
<span className="font-medium">Concept Explainer</span>
<span className="text-xs text-muted-foreground">
Get detailed explanations of concepts
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onLearningModeChange("socratic")}
className={learningMode === "socratic" ? "bg-accent" : ""}
>
<div className="flex flex-col">
<span className="font-medium">Socratic Tutor</span>
<span className="text-xs text-muted-foreground">
Learn through guided questions
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onLearningModeChange("exam")}
className={learningMode === "exam" ? "bg-accent" : ""}
>
<div className="flex flex-col">
<span className="font-medium">Exam Prep</span>
<span className="text-xs text-muted-foreground">
Practice with quiz questions
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onLearningModeChange("assignment")}
className={learningMode === "assignment" ? "bg-accent" : ""}
>
<div className="flex flex-col">
<span className="font-medium">Assignment Helper</span>
<span className="text-xs text-muted-foreground">
Get help with assignments
</span>
</div>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onLearningModeChange("summary")}
className={learningMode === "summary" ? "bg-accent" : ""}
>
<div className="flex flex-col">
<span className="font-medium">Quick Summary</span>
<span className="text-xs text-muted-foreground">
Get concise summaries
</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<Button
type="button"
size="icon"
variant="ghost"
disabled={
!isLoggedIn ||
(chatMode === "quiz" && !quizState.waitingForAnswer)
}
className="h-8 w-8 hover:bg-muted/50"
onClick={() => fileInputRef.current?.click()}
title="Upload files"
>
<Upload className="h-4 w-4" />
</Button>
</div>
<Textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
!isLoggedIn
? "Please log in on the right to start chatting..."
: chatMode === "quiz"
? quizState.waitingForAnswer
? "Type your answer here..."
: quizState.currentQuestion > 0
? "Click 'Next Question' to continue..."
: "Click 'Start Quiz' to begin..."
: spaceType === "group"
? "Type a message or drag files here... (mention @Clare to get AI assistance)"
: learningMode === "general"
? "Ask me anything! Please provide context about your question..."
: "Ask Clare anything about the course or drag files here..."
}
disabled={
!isLoggedIn ||
(chatMode === "quiz" && !quizState.waitingForAnswer)
}
className={`min-h-[80px] pl-4 pr-20 resize-none bg-background border-2 ${
isDragging ? "border-primary border-dashed" : "border-border"
}`}
/>
<div className="absolute bottom-2 right-2 flex gap-1">
<Button
type="submit"
size="icon"
disabled={!input.trim() || !isLoggedIn}
className="h-8 w-8 rounded-full"
>
<Send className="h-4 w-4" />
</Button>
</div>
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.docx,.pptx,.doc,.ppt,.jpg,.jpeg,.png,.gif,.webp"
onChange={handleFileSelect}
className="hidden"
disabled={!isLoggedIn}
/>
</div>
</form>
</div>
</div>
{/* Start New Conversation Confirmation Dialog */}
<AlertDialog open={showClearDialog} onOpenChange={onCancelClear}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Start New Conversation</AlertDialogTitle>
<AlertDialogDescription>Would you like to save the current chat before starting a new conversation?</AlertDialogDescription>
<Button
variant="ghost"
size="icon"
className="absolute right-4 top-4 h-6 w-6 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"
onClick={onCancelClear}
>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</AlertDialogHeader>
<AlertDialogFooter className="flex-col sm:flex-row gap-2 sm:justify-end">
<Button variant="outline" onClick={() => onConfirmClear(false)} className="sm:flex-1 sm:max-w-[200px]">
Start New (Don't Save)
</Button>
<AlertDialogAction onClick={() => onConfirmClear(true)} className="sm:flex-1 sm:max-w-[200px]">
Save & Start New
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* Download Preview Dialog */}
<Dialog open={showDownloadDialog} onOpenChange={setShowDownloadDialog}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>Download this chat</DialogTitle>
<DialogDescription>Preview and copy before downloading.</DialogDescription>
</DialogHeader>
<Tabs
value={downloadTab}
onValueChange={(value) => {
const v = value as "chat" | "summary";
setDownloadTab(v);
setDownloadPreview(v === "chat" ? buildPreviewContent() : buildSummaryContent());
if (v === "summary") setDownloadOptions({ chat: false, summary: true });
else setDownloadOptions({ chat: true, summary: false });
}}
className="w-full"
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="chat">Download chat</TabsTrigger>
<TabsTrigger value="summary">Summary of the chat</TabsTrigger>
</TabsList>
</Tabs>
<div className="border rounded-lg bg-muted/40 flex flex-col max-h-64">
<div className="flex items-center justify-between p-4 sticky top-0 bg-muted/40 border-b z-10">
<span className="text-sm font-medium">Preview</span>
<Button variant="outline" size="sm" className="h-7 px-2 text-xs gap-1.5" onClick={handleCopyPreview} title="Copy preview">
<Copy className="h-3 w-3" />
Copy
</Button>
</div>
<div className="text-sm text-foreground overflow-y-auto flex-1 p-4">
<div className="whitespace-pre-wrap">{downloadPreview}</div>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="download-chat"
checked={downloadOptions.chat}
onCheckedChange={(checked) => setDownloadOptions({ ...downloadOptions, chat: checked === true })}
/>
<label htmlFor="download-chat" className="text-sm font-medium cursor-pointer">
Download chat
</label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="download-summary"
checked={downloadOptions.summary}
onCheckedChange={(checked) => setDownloadOptions({ ...downloadOptions, summary: checked === true })}
/>
<label htmlFor="download-summary" className="text-sm font-medium cursor-pointer">
Download summary
</label>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowDownloadDialog(false)}>
Cancel
</Button>
<Button onClick={handleDownloadFile}>Download</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Share Dialog */}
<Dialog open={showShareDialog} onOpenChange={setShowShareDialog}>
<DialogContent className="w-[600px] max-w-[600px] sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Share Conversation</DialogTitle>
<DialogDescription>Select how you want to share.</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Copy Link</Label>
<div className="flex gap-2 items-center">
<Input value={shareLink} readOnly className="flex-1" />
<Button variant="secondary" onClick={handleCopyShareLink}>
Copy
</Button>
</div>
<p className="text-xs text-muted-foreground">Temporary link valid for this session.</p>
</div>
<div className="space-y-2">
<Label>Send to Workspace</Label>
<Select value={targetWorkspaceId} onValueChange={setTargetWorkspaceId}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Choose a workspace" />
</SelectTrigger>
<SelectContent>
{workspaces.map((w) => (
<SelectItem key={w.id} value={w.id}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">Sends this conversation to the selected workspace&apos;s Saved Files.</p>
<Button onClick={handleShareSendToWorkspace} className="w-full">
Send
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Delete File Confirmation Dialog (kept, but if triggered, delete is correct now) */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete File</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete &quot;{fileToDelete !== null ? uploadedFiles[fileToDelete]?.file.name : ""}&quot;? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (fileToDelete !== null) {
onRemoveFile(fileToDelete); // ✅ pass index
setFileToDelete(null);
}
setShowDeleteDialog(false);
}}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* File Viewer Dialog */}
<Dialog open={showFileViewer} onOpenChange={setShowFileViewer}>
<DialogContent className="max-w-4xl max-h-[85vh] flex flex-col overflow-hidden">
<DialogHeader className="min-w-0 flex-shrink-0">
<DialogTitle
className="pr-8 break-words break-all overflow-wrap-anywhere leading-relaxed"
style={{ wordBreak: "break-all", overflowWrap: "anywhere", maxWidth: "100%", lineHeight: "1.6" }}
>
{selectedFile?.file.name}
</DialogTitle>
<DialogDescription>File size: {selectedFile ? formatFileSize(selectedFile.file.size) : ""}</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0 overflow-y-auto mt-4">{selectedFile && <FileViewerContent file={selectedFile.file} />}</div>
</DialogContent>
</Dialog>
{/* File Type Selection Dialog */}
{showTypeDialog && (
<Dialog open={showTypeDialog} onOpenChange={setShowTypeDialog}>
<DialogContent className="sm:max-w-[425px]" style={{ zIndex: 99999 }}>
<DialogHeader>
<DialogTitle>Select File Types</DialogTitle>
<DialogDescription>Please select the type for each file you are uploading.</DialogDescription>
</DialogHeader>
<div className="space-y-3 max-h-64 overflow-y-auto">
{pendingFiles.map((pendingFile, index) => {
const Icon = getFileIcon(pendingFile.file.name);
return (
<div key={index} className="p-3 bg-muted rounded-md space-y-2">
<div className="flex items-center gap-2 group">
<Icon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm truncate">{pendingFile.file.name}</p>
<p className="text-xs text-muted-foreground">{formatFileSize(pendingFile.file.size)}</p>
</div>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">File Type</label>
<Select value={pendingFile.type} onValueChange={(value) => handlePendingFileTypeChange(index, value as FileType)}>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="!z-[100000] !bg-background !text-foreground" style={{ zIndex: 100000 }}>
<SelectItem value="syllabus">Syllabus</SelectItem>
<SelectItem value="lecture-slides">Lecture Slides / PPT</SelectItem>
<SelectItem value="literature-review">Literature Review / Paper</SelectItem>
<SelectItem value="other">Other Course Document</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
})}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancelUpload}>
Cancel
</Button>
<Button onClick={handleConfirmUpload}>Upload</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
}