SarahXia0405 commited on
Commit
018ef9e
·
verified ·
1 Parent(s): 6f7cf01

Update web/src/components/ChatArea.tsx

Browse files
Files changed (1) hide show
  1. web/src/components/ChatArea.tsx +282 -173
web/src/components/ChatArea.tsx CHANGED
@@ -12,7 +12,7 @@ import {
12
  Share2,
13
  Upload,
14
  X,
15
- Trash2,
16
  File,
17
  FileText,
18
  Presentation,
@@ -37,8 +37,20 @@ import type {
37
  } from "../App";
38
  import { toast } from "sonner";
39
  import { jsPDF } from "jspdf";
40
- import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
41
- import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
 
 
 
 
 
 
 
 
 
 
 
 
42
  import { Checkbox } from "./ui/checkbox";
43
  import {
44
  AlertDialog,
@@ -50,7 +62,13 @@ import {
50
  AlertDialogHeader,
51
  AlertDialogTitle,
52
  } from "./ui/alert-dialog";
53
- import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
 
 
 
 
 
 
54
  import { SmartReview } from "./SmartReview";
55
  import clareAvatar from "../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png";
56
 
@@ -66,7 +84,6 @@ interface ChatAreaProps {
66
  onFileUpload: (files: File[]) => void;
67
  onRemoveFile: (index: number) => void;
68
 
69
-
70
  onFileTypeChange: (index: number, type: FileType) => void;
71
  memoryProgress: number;
72
  isLoggedIn: boolean;
@@ -91,7 +108,12 @@ interface ChatAreaProps {
91
  savedChats: SavedChat[];
92
  workspaces: Workspace[];
93
  currentWorkspaceId: string;
94
- onSaveFile?: (content: string, type: "export" | "summary", format?: "pdf" | "text", workspaceId?: string) => void;
 
 
 
 
 
95
  leftPanelVisible?: boolean;
96
  currentCourseId?: string;
97
  onCourseChange?: (courseId: string) => void;
@@ -99,6 +121,10 @@ interface ChatAreaProps {
99
  showReviewBanner?: boolean;
100
 
101
  onReviewActivity?: (event: ReviewEventType) => void;
 
 
 
 
102
  }
103
 
104
  interface PendingFile {
@@ -139,6 +165,10 @@ export function ChatArea({
139
  availableCourses = [],
140
  showReviewBanner = false,
141
  onReviewActivity,
 
 
 
 
142
  }: ChatAreaProps) {
143
  const [input, setInput] = useState("");
144
  const [showScrollButton, setShowScrollButton] = useState(false);
@@ -151,13 +181,19 @@ export function ChatArea({
151
  const [showDeleteDialog, setShowDeleteDialog] = useState(false);
152
  const [fileToDelete, setFileToDelete] = useState<number | null>(null);
153
 
154
- const [selectedFile, setSelectedFile] = useState<{ file: File; index: number } | null>(null);
 
 
 
155
  const [showFileViewer, setShowFileViewer] = useState(false);
156
 
157
  const [showDownloadDialog, setShowDownloadDialog] = useState(false);
158
  const [downloadPreview, setDownloadPreview] = useState("");
159
  const [downloadTab, setDownloadTab] = useState<"chat" | "summary">("chat");
160
- const [downloadOptions, setDownloadOptions] = useState({ chat: true, summary: false });
 
 
 
161
 
162
  const [showShareDialog, setShowShareDialog] = useState(false);
163
  const [shareLink, setShareLink] = useState("");
@@ -223,7 +259,8 @@ export function ChatArea({
223
  if (messages.length > previousMessagesLength.current) {
224
  const el = scrollContainerRef.current;
225
  if (el) {
226
- const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 240;
 
227
  if (nearBottom) scrollToBottom("smooth");
228
  }
229
  }
@@ -297,7 +334,12 @@ export function ChatArea({
297
 
298
  const buildPreviewContent = () => {
299
  if (!messages.length) return "";
300
- return messages.map((msg) => `${msg.role === "user" ? "You" : "Clare"}: ${msg.content}`).join("\n\n");
 
 
 
 
 
301
  };
302
 
303
  const buildSummaryContent = () => {
@@ -314,7 +356,9 @@ export function ChatArea({
314
  summary += `Key Points:\n`;
315
  userMessages.slice(0, 3).forEach((msg, idx) => {
316
  const preview = msg.content.substring(0, 80);
317
- summary += `${idx + 1}. ${preview}${msg.content.length > 80 ? "..." : ""}\n`;
 
 
318
  });
319
 
320
  return summary;
@@ -352,7 +396,11 @@ export function ChatArea({
352
  return;
353
  }
354
 
355
- const pdf = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
 
 
 
 
356
 
357
  pdf.setFontSize(14);
358
  pdf.text("Chat Export", 10, 10);
@@ -393,7 +441,11 @@ export function ChatArea({
393
 
394
  return chat.messages.every((savedMsg, idx) => {
395
  const currentMsg = messages[idx];
396
- return savedMsg.id === currentMsg.id && savedMsg.role === currentMsg.role && savedMsg.content === currentMsg.content;
 
 
 
 
397
  });
398
  });
399
  };
@@ -481,13 +533,24 @@ export function ChatArea({
481
 
482
  const validFiles = files.filter((file) => {
483
  const ext = file.name.toLowerCase();
484
- return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some((allowed) =>
485
- ext.endsWith(allowed)
486
- );
 
 
 
 
 
 
 
 
 
487
  });
488
 
489
  if (validFiles.length > 0) {
490
- setPendingFiles(validFiles.map((file) => ({ file, type: "other" as FileType })));
 
 
491
  setShowTypeDialog(true);
492
  } else {
493
  toast.error("Please upload .pdf, .docx, .pptx, or image files");
@@ -499,13 +562,24 @@ export function ChatArea({
499
  if (files.length > 0) {
500
  const validFiles = files.filter((file) => {
501
  const ext = file.name.toLowerCase();
502
- return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some((allowed) =>
503
- ext.endsWith(allowed)
504
- );
 
 
 
 
 
 
 
 
 
505
  });
506
 
507
  if (validFiles.length > 0) {
508
- setPendingFiles(validFiles.map((file) => ({ file, type: "other" as FileType })));
 
 
509
  setShowTypeDialog(true);
510
  } else {
511
  toast.error("Please upload .pdf, .docx, .pptx, or image files");
@@ -536,7 +610,9 @@ export function ChatArea({
536
  };
537
 
538
  const handlePendingFileTypeChange = (index: number, type: FileType) => {
539
- setPendingFiles((prev) => prev.map((pf, i) => (i === index ? { ...pf, type } : pf)));
 
 
540
  };
541
 
542
  // File helpers
@@ -545,18 +621,9 @@ export function ChatArea({
545
  if (ext.endsWith(".pdf")) return FileText;
546
  if (ext.endsWith(".docx") || ext.endsWith(".doc")) return File;
547
  if (ext.endsWith(".pptx") || ext.endsWith(".ppt")) return Presentation;
548
- if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e))) return ImageIcon;
549
- return File;
550
- };
551
-
552
- const getFileTypeInfo = (filename: string) => {
553
- const ext = filename.toLowerCase();
554
- if (ext.endsWith(".pdf")) return { bgColor: "bg-red-500", type: "PDF" };
555
- if (ext.endsWith(".docx") || ext.endsWith(".doc")) return { bgColor: "bg-blue-500", type: "Document" };
556
- if (ext.endsWith(".pptx") || ext.endsWith(".ppt")) return { bgColor: "bg-orange-500", type: "Presentation" };
557
  if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e)))
558
- return { bgColor: "bg-green-500", type: "Image" };
559
- return { bgColor: "bg-gray-500", type: "File" };
560
  };
561
 
562
  const formatFileSize = (bytes: number) => {
@@ -564,14 +631,8 @@ export function ChatArea({
564
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
565
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
566
  };
567
-
568
- const fileKey = (f: File) => `${f.name}::${f.size}::${f.lastModified}`;
569
-
570
- const removeUploadedByFile = (file: File) => {
571
- const idx = uploadedFiles.findIndex((u) => fileKey(u.file) === fileKey(file));
572
- if (idx >= 0) onRemoveFile(idx);
573
- };
574
 
 
575
 
576
  // ✅ useObjectUrlCache: for image thumbnails (uploaded + pending)
577
  const allThumbFiles = React.useMemo(() => {
@@ -591,7 +652,7 @@ export function ChatArea({
591
  }) => {
592
  const ext = file.name.toLowerCase();
593
  const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e));
594
-
595
  const label = ext.endsWith(".pdf")
596
  ? "PDF"
597
  : ext.endsWith(".pptx") || ext.endsWith(".ppt")
@@ -601,29 +662,22 @@ export function ChatArea({
601
  : isImage
602
  ? "Image"
603
  : "File";
604
-
605
  const thumbUrl = isImage ? getOrCreate(file) : null;
606
-
607
  const handleRemove = () => {
608
- if (source === "uploaded") {
609
- // 关键:仍然走 index 删除(或按 file index)
610
- onRemoveFile(index);
611
- // 如果你更想稳一点(不依赖 index),用下面这一行替代上面那行:
612
- // removeUploadedByFile(file);
613
- } else {
614
- setPendingFiles((prev) => prev.filter((p) => fileKey(p.file) !== fileKey(file)));
615
- }
616
  };
617
-
618
  return (
619
  <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">
620
- {/* 叉叉:只 stopPropagation,不要一堆 stopImmediatePropagation,避免某些浏览器/事件链出问题 */}
621
  <button
622
  type="button"
623
  onClick={(e) => {
624
  e.preventDefault();
625
  e.stopPropagation();
626
- onRemoveFile?.(index); // ✅ 用 index 删(前后端状态都会删掉)
627
  }}
628
  className="ml-2 inline-flex h-7 w-7 items-center justify-center rounded-md border border-border bg-card hover:bg-muted"
629
  title="Remove"
@@ -631,16 +685,13 @@ export function ChatArea({
631
  <Trash2 className="h-4 w-4" />
632
  </button>
633
 
634
-
635
- {/* 文本区 */}
636
  <div className="min-w-0 flex-1">
637
  <div className="text-sm font-medium truncate" title={file.name}>
638
  {file.name}
639
  </div>
640
  <div className="text-xs text-muted-foreground">{label}</div>
641
  </div>
642
-
643
- {/* ✅ 缩略图:更小更干净 */}
644
  {isImage ? (
645
  <div className="relative h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted">
646
  {thumbUrl ? (
@@ -661,73 +712,6 @@ export function ChatArea({
661
  );
662
  };
663
 
664
- const FileViewerContent = ({ file }: { file: File }) => {
665
- const [content, setContent] = useState<string>("");
666
- const [loading, setLoading] = useState(true);
667
- const [error, setError] = useState<string | null>(null);
668
-
669
- useEffect(() => {
670
- const loadFile = async () => {
671
- try {
672
- setLoading(true);
673
- setError(null);
674
-
675
- const ext = file.name.toLowerCase();
676
-
677
- if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e))) {
678
- // Use objectURL for viewer as well
679
- setContent(getOrCreate(file));
680
- setLoading(false);
681
- return;
682
- }
683
-
684
- if (ext.endsWith(".pdf")) {
685
- setContent("PDF files cannot be previewed directly. Please download the file to view it.");
686
- setLoading(false);
687
- return;
688
- }
689
-
690
- const reader = new FileReader();
691
- reader.onload = (e) => {
692
- setContent(String(e.target?.result ?? ""));
693
- setLoading(false);
694
- };
695
- reader.onerror = () => {
696
- setError("Failed to load file");
697
- setLoading(false);
698
- };
699
- reader.readAsText(file);
700
- } catch {
701
- setError("Failed to load file");
702
- setLoading(false);
703
- }
704
- };
705
-
706
- loadFile();
707
- // eslint-disable-next-line react-hooks/exhaustive-deps
708
- }, [file]);
709
-
710
- if (loading) return <div className="text-center py-8">Loading...</div>;
711
- if (error) return <div className="text-center py-8 text-destructive">{error}</div>;
712
-
713
- const ext = file.name.toLowerCase();
714
- const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e));
715
-
716
- if (isImage) {
717
- return (
718
- <div className="flex justify-center">
719
- <img src={content} alt={file.name} className="max-w-full h-auto rounded-lg" />
720
- </div>
721
- );
722
- }
723
-
724
- return (
725
- <div className="whitespace-pre-wrap text-sm font-mono p-4 bg-muted rounded-lg max-h-[60vh] overflow-y-auto">
726
- {content}
727
- </div>
728
- );
729
- };
730
-
731
  const bottomPad = Math.max(24, composerHeight + 24);
732
 
733
  return (
@@ -737,7 +721,12 @@ export function ChatArea({
737
  className={`flex-shrink-0 flex items-center justify-between px-4 bg-card z-20 ${
738
  showTopBorder ? "border-b border-border" : ""
739
  }`}
740
- style={{ height: "4.5rem", margin: 0, padding: "1rem 1rem", boxSizing: "border-box" }}
 
 
 
 
 
741
  >
742
  {/* Course Selector - Left */}
743
  <div className="flex-shrink-0">
@@ -755,7 +744,10 @@ export function ChatArea({
755
  }
756
 
757
  return (
758
- <Select value={currentCourseId || "course1"} onValueChange={(val) => onCourseChange && onCourseChange(val)}>
 
 
 
759
  <SelectTrigger className="w-[200px] h-9 font-semibold">
760
  <SelectValue placeholder="Select course" />
761
  </SelectTrigger>
@@ -773,7 +765,12 @@ export function ChatArea({
773
 
774
  {/* Tabs - Center */}
775
  <div className="absolute left-1/2 -translate-x-1/2 flex-shrink-0">
776
- <Tabs value={chatMode} onValueChange={(value) => onChatModeChange(value as ChatMode)} className="w-auto" orientation="horizontal">
 
 
 
 
 
777
  <TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground">
778
  <TabsTrigger value="ask" className="w-[140px] px-3 text-sm">
779
  Ask
@@ -805,10 +802,16 @@ export function ChatArea({
805
  size="icon"
806
  onClick={handleSaveClick}
807
  disabled={!isLoggedIn}
808
- className={`h-8 w-8 rounded-md hover:bg-muted/50 ${isCurrentChatSaved() ? "text-primary" : ""}`}
 
 
809
  title={isCurrentChatSaved() ? "Unsave" : "Save"}
810
  >
811
- <Bookmark className={`h-4 w-4 ${isCurrentChatSaved() ? "fill-primary text-primary" : ""}`} />
 
 
 
 
812
  </Button>
813
 
814
  <Button
@@ -859,20 +862,40 @@ export function ChatArea({
859
  <Message
860
  message={message}
861
  showSenderInfo={spaceType === "group"}
862
- isFirstGreeting={(message.id === "1" || message.id === "review-1" || message.id === "quiz-1") && message.role === "assistant"}
 
 
 
 
 
863
  showNextButton={message.showNextButton && !isAppTyping}
864
  onNextQuestion={onNextQuestion}
865
  chatMode={chatMode}
 
 
 
 
 
866
  />
867
 
868
- {chatMode === "review" && message.id === "review-1" && message.role === "assistant" && (
869
- <div className="flex gap-2 justify-start px-4">
870
- <div className="w-10 h-10 flex-shrink-0" />
871
- <div className="w-full" style={{ maxWidth: "min(770px, calc(100% - 2rem))" }}>
872
- <SmartReview onReviewTopic={handleReviewTopic} onReviewAll={handleReviewAll} />
 
 
 
 
 
 
 
 
 
 
 
873
  </div>
874
- </div>
875
- )}
876
 
877
  {chatMode === "quiz" &&
878
  message.id === "quiz-1" &&
@@ -881,7 +904,10 @@ export function ChatArea({
881
  !quizState.waitingForAnswer &&
882
  !isAppTyping && (
883
  <div className="flex justify-center py-4">
884
- <Button onClick={onStartQuiz} className="bg-red-500 hover:bg-red-600 text-white">
 
 
 
885
  Start Quiz
886
  </Button>
887
  </div>
@@ -892,13 +918,26 @@ export function ChatArea({
892
  {isAppTyping && (
893
  <div className="flex gap-2 justify-start px-4">
894
  <div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
895
- <img src={clareAvatar} alt="Clare" className="w-full h-full object-cover" />
 
 
 
 
896
  </div>
897
  <div className="bg-muted rounded-2xl px-4 py-3">
898
  <div className="flex gap-1">
899
- <div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: "0ms" }} />
900
- <div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: "150ms" }} />
901
- <div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: "300ms" }} />
 
 
 
 
 
 
 
 
 
902
  </div>
903
  </div>
904
  </div>
@@ -909,7 +948,10 @@ export function ChatArea({
909
 
910
  {/* Scroll-to-bottom button */}
911
  {showScrollButton && (
912
- <div className="absolute z-30 left-0 right-0 flex justify-center pointer-events-none" style={{ bottom: composerHeight + 16 }}>
 
 
 
913
  <Button
914
  variant="secondary"
915
  size="icon"
@@ -923,7 +965,10 @@ export function ChatArea({
923
  )}
924
 
925
  {/* Composer */}
926
- <div ref={composerRef} className="flex-shrink-0 bg-background/95 backdrop-blur-sm z-20 border-t border-border">
 
 
 
927
  <div className="max-w-4xl mx-auto px-4 py-4">
928
  {/* Uploaded Files Preview (chip UI) */}
929
  {(uploadedFiles.length > 0 || pendingFiles.length > 0) && (
@@ -931,13 +976,18 @@ export function ChatArea({
931
  {/* uploaded */}
932
  {uploadedFiles.map((uf, i) => {
933
  const key = `${uf.file.name}::${uf.file.size}::${uf.file.lastModified}`;
934
-
935
  const ext = uf.file.name.toLowerCase();
936
- const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e));
 
 
937
  const thumbUrl = isImage ? getOrCreate(uf.file) : null;
938
-
939
  return (
940
- <div key={key} className="flex items-center justify-between gap-2 rounded-md border px-3 py-2">
 
 
 
941
  {/* ✅ Thumbnail (only for images) */}
942
  {isImage ? (
943
  <div className="h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted">
@@ -955,13 +1005,16 @@ export function ChatArea({
955
  )}
956
  </div>
957
  ) : null}
958
-
959
  <div className="min-w-0 flex-1">
960
- <div className="truncate text-sm font-medium">{uf.file.name}</div>
961
- <div className="text-xs text-muted-foreground">{uf.type}</div>
 
 
 
 
962
  </div>
963
-
964
- {/* ✅ 原样保留删除逻辑 */}
965
  <Button
966
  variant="ghost"
967
  size="icon"
@@ -974,9 +1027,14 @@ export function ChatArea({
974
  );
975
  })}
976
 
977
- {/* pending (type dialog 之前也能显示的话) */}
978
  {pendingFiles.map((p, idx) => (
979
- <FileChip key={`p-${p.file.name}-${p.file.size}-${p.file.lastModified}`} file={p.file} index={idx} source="pending" />
 
 
 
 
 
980
  ))}
981
  </div>
982
  )}
@@ -1002,51 +1060,91 @@ export function ChatArea({
1002
  type="button"
1003
  >
1004
  <span>{modeLabels[learningMode]}</span>
1005
- <svg className="h-3 w-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1006
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
 
 
 
 
 
 
 
 
 
 
1007
  </svg>
1008
  </Button>
1009
  </DropdownMenuTrigger>
1010
  <DropdownMenuContent align="start" className="w-56">
1011
- <DropdownMenuItem onClick={() => onLearningModeChange("general")} className={learningMode === "general" ? "bg-accent" : ""}>
 
 
 
1012
  <div className="flex flex-col">
1013
  <span className="font-medium">General</span>
1014
- <span className="text-xs text-muted-foreground">Answer various questions (context required)</span>
 
 
1015
  </div>
1016
  </DropdownMenuItem>
1017
 
1018
- <DropdownMenuItem onClick={() => onLearningModeChange("concept")} className={learningMode === "concept" ? "bg-accent" : ""}>
 
 
 
1019
  <div className="flex flex-col">
1020
  <span className="font-medium">Concept Explainer</span>
1021
- <span className="text-xs text-muted-foreground">Get detailed explanations of concepts</span>
 
 
1022
  </div>
1023
  </DropdownMenuItem>
1024
 
1025
- <DropdownMenuItem onClick={() => onLearningModeChange("socratic")} className={learningMode === "socratic" ? "bg-accent" : ""}>
 
 
 
1026
  <div className="flex flex-col">
1027
  <span className="font-medium">Socratic Tutor</span>
1028
- <span className="text-xs text-muted-foreground">Learn through guided questions</span>
 
 
1029
  </div>
1030
  </DropdownMenuItem>
1031
 
1032
- <DropdownMenuItem onClick={() => onLearningModeChange("exam")} className={learningMode === "exam" ? "bg-accent" : ""}>
 
 
 
1033
  <div className="flex flex-col">
1034
  <span className="font-medium">Exam Prep</span>
1035
- <span className="text-xs text-muted-foreground">Practice with quiz questions</span>
 
 
1036
  </div>
1037
  </DropdownMenuItem>
1038
 
1039
- <DropdownMenuItem onClick={() => onLearningModeChange("assignment")} className={learningMode === "assignment" ? "bg-accent" : ""}>
 
 
 
1040
  <div className="flex flex-col">
1041
  <span className="font-medium">Assignment Helper</span>
1042
- <span className="text-xs text-muted-foreground">Get help with assignments</span>
 
 
1043
  </div>
1044
  </DropdownMenuItem>
1045
 
1046
- <DropdownMenuItem onClick={() => onLearningModeChange("summary")} className={learningMode === "summary" ? "bg-accent" : ""}>
 
 
 
1047
  <div className="flex flex-col">
1048
  <span className="font-medium">Quick Summary</span>
1049
- <span className="text-xs text-muted-foreground">Get concise summaries</span>
 
 
1050
  </div>
1051
  </DropdownMenuItem>
1052
  </DropdownMenuContent>
@@ -1057,7 +1155,10 @@ export function ChatArea({
1057
  type="button"
1058
  size="icon"
1059
  variant="ghost"
1060
- disabled={!isLoggedIn || (chatMode === "quiz" && !quizState.waitingForAnswer)}
 
 
 
1061
  className="h-8 w-8 hover:bg-muted/50"
1062
  onClick={() => fileInputRef.current?.click()}
1063
  title="Upload files"
@@ -1085,14 +1186,22 @@ export function ChatArea({
1085
  ? "Ask me anything! Please provide context about your question..."
1086
  : "Ask Clare anything about the course or drag files here..."
1087
  }
1088
- disabled={!isLoggedIn || (chatMode === "quiz" && !quizState.waitingForAnswer)}
 
 
 
1089
  className={`min-h-[80px] pl-4 pr-20 resize-none bg-background border-2 ${
1090
  isDragging ? "border-primary border-dashed" : "border-border"
1091
  }`}
1092
  />
1093
 
1094
  <div className="absolute bottom-2 right-2 flex gap-1">
1095
- <Button type="submit" size="icon" disabled={!input.trim() || !isLoggedIn} className="h-8 w-8 rounded-full">
 
 
 
 
 
1096
  <Send className="h-4 w-4" />
1097
  </Button>
1098
  </div>
 
12
  Share2,
13
  Upload,
14
  X,
15
+ Trash2,
16
  File,
17
  FileText,
18
  Presentation,
 
37
  } from "../App";
38
  import { toast } from "sonner";
39
  import { jsPDF } from "jspdf";
40
+ import {
41
+ DropdownMenu,
42
+ DropdownMenuContent,
43
+ DropdownMenuItem,
44
+ DropdownMenuTrigger,
45
+ } from "./ui/dropdown-menu";
46
+ import {
47
+ Dialog,
48
+ DialogContent,
49
+ DialogDescription,
50
+ DialogFooter,
51
+ DialogHeader,
52
+ DialogTitle,
53
+ } from "./ui/dialog";
54
  import { Checkbox } from "./ui/checkbox";
55
  import {
56
  AlertDialog,
 
62
  AlertDialogHeader,
63
  AlertDialogTitle,
64
  } from "./ui/alert-dialog";
65
+ import {
66
+ Select,
67
+ SelectContent,
68
+ SelectItem,
69
+ SelectTrigger,
70
+ SelectValue,
71
+ } from "./ui/select";
72
  import { SmartReview } from "./SmartReview";
73
  import clareAvatar from "../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png";
74
 
 
84
  onFileUpload: (files: File[]) => void;
85
  onRemoveFile: (index: number) => void;
86
 
 
87
  onFileTypeChange: (index: number, type: FileType) => void;
88
  memoryProgress: number;
89
  isLoggedIn: boolean;
 
108
  savedChats: SavedChat[];
109
  workspaces: Workspace[];
110
  currentWorkspaceId: string;
111
+ onSaveFile?: (
112
+ content: string,
113
+ type: "export" | "summary",
114
+ format?: "pdf" | "text",
115
+ workspaceId?: string
116
+ ) => void;
117
  leftPanelVisible?: boolean;
118
  currentCourseId?: string;
119
  onCourseChange?: (courseId: string) => void;
 
121
  showReviewBanner?: boolean;
122
 
123
  onReviewActivity?: (event: ReviewEventType) => void;
124
+
125
+ // ✅ NEW: pass through for feedback submission
126
+ currentUserId?: string; // backend user_id
127
+ docType?: string; // backend doc_type (optional)
128
  }
129
 
130
  interface PendingFile {
 
165
  availableCourses = [],
166
  showReviewBanner = false,
167
  onReviewActivity,
168
+
169
+ // ✅ NEW
170
+ currentUserId,
171
+ docType,
172
  }: ChatAreaProps) {
173
  const [input, setInput] = useState("");
174
  const [showScrollButton, setShowScrollButton] = useState(false);
 
181
  const [showDeleteDialog, setShowDeleteDialog] = useState(false);
182
  const [fileToDelete, setFileToDelete] = useState<number | null>(null);
183
 
184
+ const [selectedFile, setSelectedFile] = useState<{
185
+ file: File;
186
+ index: number;
187
+ } | null>(null);
188
  const [showFileViewer, setShowFileViewer] = useState(false);
189
 
190
  const [showDownloadDialog, setShowDownloadDialog] = useState(false);
191
  const [downloadPreview, setDownloadPreview] = useState("");
192
  const [downloadTab, setDownloadTab] = useState<"chat" | "summary">("chat");
193
+ const [downloadOptions, setDownloadOptions] = useState({
194
+ chat: true,
195
+ summary: false,
196
+ });
197
 
198
  const [showShareDialog, setShowShareDialog] = useState(false);
199
  const [shareLink, setShareLink] = useState("");
 
259
  if (messages.length > previousMessagesLength.current) {
260
  const el = scrollContainerRef.current;
261
  if (el) {
262
+ const nearBottom =
263
+ el.scrollHeight - el.scrollTop - el.clientHeight < 240;
264
  if (nearBottom) scrollToBottom("smooth");
265
  }
266
  }
 
334
 
335
  const buildPreviewContent = () => {
336
  if (!messages.length) return "";
337
+ return messages
338
+ .map(
339
+ (msg) =>
340
+ `${msg.role === "user" ? "You" : "Clare"}: ${msg.content}`
341
+ )
342
+ .join("\n\n");
343
  };
344
 
345
  const buildSummaryContent = () => {
 
356
  summary += `Key Points:\n`;
357
  userMessages.slice(0, 3).forEach((msg, idx) => {
358
  const preview = msg.content.substring(0, 80);
359
+ summary += `${idx + 1}. ${preview}${
360
+ msg.content.length > 80 ? "..." : ""
361
+ }\n`;
362
  });
363
 
364
  return summary;
 
396
  return;
397
  }
398
 
399
+ const pdf = new jsPDF({
400
+ orientation: "portrait",
401
+ unit: "mm",
402
+ format: "a4",
403
+ });
404
 
405
  pdf.setFontSize(14);
406
  pdf.text("Chat Export", 10, 10);
 
441
 
442
  return chat.messages.every((savedMsg, idx) => {
443
  const currentMsg = messages[idx];
444
+ return (
445
+ savedMsg.id === currentMsg.id &&
446
+ savedMsg.role === currentMsg.role &&
447
+ savedMsg.content === currentMsg.content
448
+ );
449
  });
450
  });
451
  };
 
533
 
534
  const validFiles = files.filter((file) => {
535
  const ext = file.name.toLowerCase();
536
+ return [
537
+ ".pdf",
538
+ ".docx",
539
+ ".pptx",
540
+ ".jpg",
541
+ ".jpeg",
542
+ ".png",
543
+ ".gif",
544
+ ".webp",
545
+ ".doc",
546
+ ".ppt",
547
+ ].some((allowed) => ext.endsWith(allowed));
548
  });
549
 
550
  if (validFiles.length > 0) {
551
+ setPendingFiles(
552
+ validFiles.map((file) => ({ file, type: "other" as FileType }))
553
+ );
554
  setShowTypeDialog(true);
555
  } else {
556
  toast.error("Please upload .pdf, .docx, .pptx, or image files");
 
562
  if (files.length > 0) {
563
  const validFiles = files.filter((file) => {
564
  const ext = file.name.toLowerCase();
565
+ return [
566
+ ".pdf",
567
+ ".docx",
568
+ ".pptx",
569
+ ".jpg",
570
+ ".jpeg",
571
+ ".png",
572
+ ".gif",
573
+ ".webp",
574
+ ".doc",
575
+ ".ppt",
576
+ ].some((allowed) => ext.endsWith(allowed));
577
  });
578
 
579
  if (validFiles.length > 0) {
580
+ setPendingFiles(
581
+ validFiles.map((file) => ({ file, type: "other" as FileType }))
582
+ );
583
  setShowTypeDialog(true);
584
  } else {
585
  toast.error("Please upload .pdf, .docx, .pptx, or image files");
 
610
  };
611
 
612
  const handlePendingFileTypeChange = (index: number, type: FileType) => {
613
+ setPendingFiles((prev) =>
614
+ prev.map((pf, i) => (i === index ? { ...pf, type } : pf))
615
+ );
616
  };
617
 
618
  // File helpers
 
621
  if (ext.endsWith(".pdf")) return FileText;
622
  if (ext.endsWith(".docx") || ext.endsWith(".doc")) return File;
623
  if (ext.endsWith(".pptx") || ext.endsWith(".ppt")) return Presentation;
 
 
 
 
 
 
 
 
 
624
  if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e)))
625
+ return ImageIcon;
626
+ return File;
627
  };
628
 
629
  const formatFileSize = (bytes: number) => {
 
631
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
632
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
633
  };
 
 
 
 
 
 
 
634
 
635
+ const fileKey = (f: File) => `${f.name}::${f.size}::${f.lastModified}`;
636
 
637
  // ✅ useObjectUrlCache: for image thumbnails (uploaded + pending)
638
  const allThumbFiles = React.useMemo(() => {
 
652
  }) => {
653
  const ext = file.name.toLowerCase();
654
  const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e));
655
+
656
  const label = ext.endsWith(".pdf")
657
  ? "PDF"
658
  : ext.endsWith(".pptx") || ext.endsWith(".ppt")
 
662
  : isImage
663
  ? "Image"
664
  : "File";
665
+
666
  const thumbUrl = isImage ? getOrCreate(file) : null;
667
+
668
  const handleRemove = () => {
669
+ if (source === "uploaded") onRemoveFile(index);
670
+ else setPendingFiles((prev) => prev.filter((p) => fileKey(p.file) !== fileKey(file)));
 
 
 
 
 
 
671
  };
672
+
673
  return (
674
  <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">
 
675
  <button
676
  type="button"
677
  onClick={(e) => {
678
  e.preventDefault();
679
  e.stopPropagation();
680
+ handleRemove();
681
  }}
682
  className="ml-2 inline-flex h-7 w-7 items-center justify-center rounded-md border border-border bg-card hover:bg-muted"
683
  title="Remove"
 
685
  <Trash2 className="h-4 w-4" />
686
  </button>
687
 
 
 
688
  <div className="min-w-0 flex-1">
689
  <div className="text-sm font-medium truncate" title={file.name}>
690
  {file.name}
691
  </div>
692
  <div className="text-xs text-muted-foreground">{label}</div>
693
  </div>
694
+
 
695
  {isImage ? (
696
  <div className="relative h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted">
697
  {thumbUrl ? (
 
712
  );
713
  };
714
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
715
  const bottomPad = Math.max(24, composerHeight + 24);
716
 
717
  return (
 
721
  className={`flex-shrink-0 flex items-center justify-between px-4 bg-card z-20 ${
722
  showTopBorder ? "border-b border-border" : ""
723
  }`}
724
+ style={{
725
+ height: "4.5rem",
726
+ margin: 0,
727
+ padding: "1rem 1rem",
728
+ boxSizing: "border-box",
729
+ }}
730
  >
731
  {/* Course Selector - Left */}
732
  <div className="flex-shrink-0">
 
744
  }
745
 
746
  return (
747
+ <Select
748
+ value={currentCourseId || "course1"}
749
+ onValueChange={(val) => onCourseChange && onCourseChange(val)}
750
+ >
751
  <SelectTrigger className="w-[200px] h-9 font-semibold">
752
  <SelectValue placeholder="Select course" />
753
  </SelectTrigger>
 
765
 
766
  {/* Tabs - Center */}
767
  <div className="absolute left-1/2 -translate-x-1/2 flex-shrink-0">
768
+ <Tabs
769
+ value={chatMode}
770
+ onValueChange={(value) => onChatModeChange(value as ChatMode)}
771
+ className="w-auto"
772
+ orientation="horizontal"
773
+ >
774
  <TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground">
775
  <TabsTrigger value="ask" className="w-[140px] px-3 text-sm">
776
  Ask
 
802
  size="icon"
803
  onClick={handleSaveClick}
804
  disabled={!isLoggedIn}
805
+ className={`h-8 w-8 rounded-md hover:bg-muted/50 ${
806
+ isCurrentChatSaved() ? "text-primary" : ""
807
+ }`}
808
  title={isCurrentChatSaved() ? "Unsave" : "Save"}
809
  >
810
+ <Bookmark
811
+ className={`h-4 w-4 ${
812
+ isCurrentChatSaved() ? "fill-primary text-primary" : ""
813
+ }`}
814
+ />
815
  </Button>
816
 
817
  <Button
 
862
  <Message
863
  message={message}
864
  showSenderInfo={spaceType === "group"}
865
+ isFirstGreeting={
866
+ (message.id === "1" ||
867
+ message.id === "review-1" ||
868
+ message.id === "quiz-1") &&
869
+ message.role === "assistant"
870
+ }
871
  showNextButton={message.showNextButton && !isAppTyping}
872
  onNextQuestion={onNextQuestion}
873
  chatMode={chatMode}
874
+
875
+ // ✅ NEW: required for feedback submission
876
+ currentUserId={currentUserId}
877
+ learningMode={learningMode}
878
+ docType={docType}
879
  />
880
 
881
+ {chatMode === "review" &&
882
+ message.id === "review-1" &&
883
+ message.role === "assistant" && (
884
+ <div className="flex gap-2 justify-start px-4">
885
+ <div className="w-10 h-10 flex-shrink-0" />
886
+ <div
887
+ className="w-full"
888
+ style={{
889
+ maxWidth: "min(770px, calc(100% - 2rem))",
890
+ }}
891
+ >
892
+ <SmartReview
893
+ onReviewTopic={handleReviewTopic}
894
+ onReviewAll={handleReviewAll}
895
+ />
896
+ </div>
897
  </div>
898
+ )}
 
899
 
900
  {chatMode === "quiz" &&
901
  message.id === "quiz-1" &&
 
904
  !quizState.waitingForAnswer &&
905
  !isAppTyping && (
906
  <div className="flex justify-center py-4">
907
+ <Button
908
+ onClick={onStartQuiz}
909
+ className="bg-red-500 hover:bg-red-600 text-white"
910
+ >
911
  Start Quiz
912
  </Button>
913
  </div>
 
918
  {isAppTyping && (
919
  <div className="flex gap-2 justify-start px-4">
920
  <div className="w-10 h-10 rounded-full overflow-hidden bg-white flex items-center justify-center flex-shrink-0">
921
+ <img
922
+ src={clareAvatar}
923
+ alt="Clare"
924
+ className="w-full h-full object-cover"
925
+ />
926
  </div>
927
  <div className="bg-muted rounded-2xl px-4 py-3">
928
  <div className="flex gap-1">
929
+ <div
930
+ className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
931
+ style={{ animationDelay: "0ms" }}
932
+ />
933
+ <div
934
+ className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
935
+ style={{ animationDelay: "150ms" }}
936
+ />
937
+ <div
938
+ className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
939
+ style={{ animationDelay: "300ms" }}
940
+ />
941
  </div>
942
  </div>
943
  </div>
 
948
 
949
  {/* Scroll-to-bottom button */}
950
  {showScrollButton && (
951
+ <div
952
+ className="absolute z-30 left-0 right-0 flex justify-center pointer-events-none"
953
+ style={{ bottom: composerHeight + 16 }}
954
+ >
955
  <Button
956
  variant="secondary"
957
  size="icon"
 
965
  )}
966
 
967
  {/* Composer */}
968
+ <div
969
+ ref={composerRef}
970
+ className="flex-shrink-0 bg-background/95 backdrop-blur-sm z-20 border-t border-border"
971
+ >
972
  <div className="max-w-4xl mx-auto px-4 py-4">
973
  {/* Uploaded Files Preview (chip UI) */}
974
  {(uploadedFiles.length > 0 || pendingFiles.length > 0) && (
 
976
  {/* uploaded */}
977
  {uploadedFiles.map((uf, i) => {
978
  const key = `${uf.file.name}::${uf.file.size}::${uf.file.lastModified}`;
979
+
980
  const ext = uf.file.name.toLowerCase();
981
+ const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) =>
982
+ ext.endsWith(e)
983
+ );
984
  const thumbUrl = isImage ? getOrCreate(uf.file) : null;
985
+
986
  return (
987
+ <div
988
+ key={key}
989
+ className="flex items-center justify-between gap-2 rounded-md border px-3 py-2"
990
+ >
991
  {/* ✅ Thumbnail (only for images) */}
992
  {isImage ? (
993
  <div className="h-10 w-10 flex-shrink-0 rounded-lg overflow-hidden border border-border bg-muted">
 
1005
  )}
1006
  </div>
1007
  ) : null}
1008
+
1009
  <div className="min-w-0 flex-1">
1010
+ <div className="truncate text-sm font-medium">
1011
+ {uf.file.name}
1012
+ </div>
1013
+ <div className="text-xs text-muted-foreground">
1014
+ {uf.type}
1015
+ </div>
1016
  </div>
1017
+
 
1018
  <Button
1019
  variant="ghost"
1020
  size="icon"
 
1027
  );
1028
  })}
1029
 
1030
+ {/* pending */}
1031
  {pendingFiles.map((p, idx) => (
1032
+ <FileChip
1033
+ key={`p-${p.file.name}-${p.file.size}-${p.file.lastModified}`}
1034
+ file={p.file}
1035
+ index={idx}
1036
+ source="pending"
1037
+ />
1038
  ))}
1039
  </div>
1040
  )}
 
1060
  type="button"
1061
  >
1062
  <span>{modeLabels[learningMode]}</span>
1063
+ <svg
1064
+ className="h-3 w-3 opacity-50"
1065
+ fill="none"
1066
+ stroke="currentColor"
1067
+ viewBox="0 0 24 24"
1068
+ >
1069
+ <path
1070
+ strokeLinecap="round"
1071
+ strokeLinejoin="round"
1072
+ strokeWidth={2}
1073
+ d="M19 9l-7 7-7-7"
1074
+ />
1075
  </svg>
1076
  </Button>
1077
  </DropdownMenuTrigger>
1078
  <DropdownMenuContent align="start" className="w-56">
1079
+ <DropdownMenuItem
1080
+ onClick={() => onLearningModeChange("general")}
1081
+ className={learningMode === "general" ? "bg-accent" : ""}
1082
+ >
1083
  <div className="flex flex-col">
1084
  <span className="font-medium">General</span>
1085
+ <span className="text-xs text-muted-foreground">
1086
+ Answer various questions (context required)
1087
+ </span>
1088
  </div>
1089
  </DropdownMenuItem>
1090
 
1091
+ <DropdownMenuItem
1092
+ onClick={() => onLearningModeChange("concept")}
1093
+ className={learningMode === "concept" ? "bg-accent" : ""}
1094
+ >
1095
  <div className="flex flex-col">
1096
  <span className="font-medium">Concept Explainer</span>
1097
+ <span className="text-xs text-muted-foreground">
1098
+ Get detailed explanations of concepts
1099
+ </span>
1100
  </div>
1101
  </DropdownMenuItem>
1102
 
1103
+ <DropdownMenuItem
1104
+ onClick={() => onLearningModeChange("socratic")}
1105
+ className={learningMode === "socratic" ? "bg-accent" : ""}
1106
+ >
1107
  <div className="flex flex-col">
1108
  <span className="font-medium">Socratic Tutor</span>
1109
+ <span className="text-xs text-muted-foreground">
1110
+ Learn through guided questions
1111
+ </span>
1112
  </div>
1113
  </DropdownMenuItem>
1114
 
1115
+ <DropdownMenuItem
1116
+ onClick={() => onLearningModeChange("exam")}
1117
+ className={learningMode === "exam" ? "bg-accent" : ""}
1118
+ >
1119
  <div className="flex flex-col">
1120
  <span className="font-medium">Exam Prep</span>
1121
+ <span className="text-xs text-muted-foreground">
1122
+ Practice with quiz questions
1123
+ </span>
1124
  </div>
1125
  </DropdownMenuItem>
1126
 
1127
+ <DropdownMenuItem
1128
+ onClick={() => onLearningModeChange("assignment")}
1129
+ className={learningMode === "assignment" ? "bg-accent" : ""}
1130
+ >
1131
  <div className="flex flex-col">
1132
  <span className="font-medium">Assignment Helper</span>
1133
+ <span className="text-xs text-muted-foreground">
1134
+ Get help with assignments
1135
+ </span>
1136
  </div>
1137
  </DropdownMenuItem>
1138
 
1139
+ <DropdownMenuItem
1140
+ onClick={() => onLearningModeChange("summary")}
1141
+ className={learningMode === "summary" ? "bg-accent" : ""}
1142
+ >
1143
  <div className="flex flex-col">
1144
  <span className="font-medium">Quick Summary</span>
1145
+ <span className="text-xs text-muted-foreground">
1146
+ Get concise summaries
1147
+ </span>
1148
  </div>
1149
  </DropdownMenuItem>
1150
  </DropdownMenuContent>
 
1155
  type="button"
1156
  size="icon"
1157
  variant="ghost"
1158
+ disabled={
1159
+ !isLoggedIn ||
1160
+ (chatMode === "quiz" && !quizState.waitingForAnswer)
1161
+ }
1162
  className="h-8 w-8 hover:bg-muted/50"
1163
  onClick={() => fileInputRef.current?.click()}
1164
  title="Upload files"
 
1186
  ? "Ask me anything! Please provide context about your question..."
1187
  : "Ask Clare anything about the course or drag files here..."
1188
  }
1189
+ disabled={
1190
+ !isLoggedIn ||
1191
+ (chatMode === "quiz" && !quizState.waitingForAnswer)
1192
+ }
1193
  className={`min-h-[80px] pl-4 pr-20 resize-none bg-background border-2 ${
1194
  isDragging ? "border-primary border-dashed" : "border-border"
1195
  }`}
1196
  />
1197
 
1198
  <div className="absolute bottom-2 right-2 flex gap-1">
1199
+ <Button
1200
+ type="submit"
1201
+ size="icon"
1202
+ disabled={!input.trim() || !isLoggedIn}
1203
+ className="h-8 w-8 rounded-full"
1204
+ >
1205
  <Send className="h-4 w-4" />
1206
  </Button>
1207
  </div>