SarahXia0405 commited on
Commit
90a4d01
·
verified ·
1 Parent(s): 28dfe30

Update web/src/components/ChatArea.tsx

Browse files
Files changed (1) hide show
  1. web/src/components/ChatArea.tsx +31 -102
web/src/components/ChatArea.tsx CHANGED
@@ -33,20 +33,8 @@ import type {
33
  } from "../App";
34
  import { toast } from "sonner";
35
  import { jsPDF } from "jspdf";
36
- import {
37
- DropdownMenu,
38
- DropdownMenuContent,
39
- DropdownMenuItem,
40
- DropdownMenuTrigger,
41
- } from "./ui/dropdown-menu";
42
- import {
43
- Dialog,
44
- DialogContent,
45
- DialogDescription,
46
- DialogFooter,
47
- DialogHeader,
48
- DialogTitle,
49
- } from "./ui/dialog";
50
  import { Checkbox } from "./ui/checkbox";
51
  import {
52
  AlertDialog,
@@ -58,17 +46,11 @@ import {
58
  AlertDialogHeader,
59
  AlertDialogTitle,
60
  } from "./ui/alert-dialog";
61
- import {
62
- Select,
63
- SelectContent,
64
- SelectItem,
65
- SelectTrigger,
66
- SelectValue,
67
- } from "./ui/select";
68
  import { SmartReview } from "./SmartReview";
69
  import clareAvatar from "../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png";
70
 
71
- // ✅ NEW: objectURL cache hook (you added this file)
72
  import { useObjectUrlCache } from "../lib/useObjectUrlCache";
73
 
74
  type ReviewEventType = "send_message" | "review_topic" | "review_all";
@@ -103,12 +85,7 @@ interface ChatAreaProps {
103
  savedChats: SavedChat[];
104
  workspaces: Workspace[];
105
  currentWorkspaceId: string;
106
- onSaveFile?: (
107
- content: string,
108
- type: "export" | "summary",
109
- format?: "pdf" | "text",
110
- workspaceId?: string
111
- ) => void;
112
  leftPanelVisible?: boolean;
113
  currentCourseId?: string;
114
  onCourseChange?: (courseId: string) => void;
@@ -181,9 +158,6 @@ export function ChatArea({
181
  const [shareLink, setShareLink] = useState("");
182
  const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
183
 
184
- // ✅ NEW: objectURL cache for image thumbnail rendering
185
- const { getObjectUrl, revokeObjectUrl } = useObjectUrlCache();
186
-
187
  const courses =
188
  availableCourses.length > 0
189
  ? availableCourses
@@ -327,9 +301,7 @@ export function ChatArea({
327
 
328
  const buildPreviewContent = () => {
329
  if (!messages.length) return "";
330
- return messages
331
- .map((msg) => `${msg.role === "user" ? "You" : "Clare"}: ${msg.content}`)
332
- .join("\n\n");
333
  };
334
 
335
  const buildSummaryContent = () => {
@@ -518,8 +490,8 @@ export function ChatArea({
518
 
519
  const validFiles = files.filter((file) => {
520
  const ext = file.name.toLowerCase();
521
- return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some(
522
- (allowed) => ext.endsWith(allowed)
523
  );
524
  });
525
 
@@ -536,8 +508,8 @@ export function ChatArea({
536
  if (files.length > 0) {
537
  const validFiles = files.filter((file) => {
538
  const ext = file.name.toLowerCase();
539
- return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some(
540
- (allowed) => ext.endsWith(allowed)
541
  );
542
  });
543
 
@@ -591,7 +563,8 @@ export function ChatArea({
591
  if (ext.endsWith(".pdf")) return { bgColor: "bg-red-500", type: "PDF" };
592
  if (ext.endsWith(".docx") || ext.endsWith(".doc")) return { bgColor: "bg-blue-500", type: "Document" };
593
  if (ext.endsWith(".pptx") || ext.endsWith(".ppt")) return { bgColor: "bg-orange-500", type: "Presentation" };
594
- if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e))) return { bgColor: "bg-green-500", type: "Image" };
 
595
  return { bgColor: "bg-gray-500", type: "File" };
596
  };
597
 
@@ -601,11 +574,9 @@ export function ChatArea({
601
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
602
  };
603
 
604
- // ✅ (display-only helper)
605
- const isImageByName = (name: string) => {
606
- const ext = name.toLowerCase();
607
- return [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e));
608
- };
609
 
610
  const FileThumbnail = ({
611
  file,
@@ -622,10 +593,9 @@ export function ChatArea({
622
  onPreview: () => void;
623
  onRemove: (e: React.MouseEvent) => void;
624
  }) => {
625
- // ✅ Use cached objectURL for stable image preview
626
- const src = isImage ? getObjectUrl(file) : null;
627
-
628
  if (isImage) {
 
 
629
  return (
630
  <div
631
  className="relative cursor-pointer w-16 h-16 flex-shrink-0"
@@ -659,7 +629,6 @@ export function ChatArea({
659
  onRemove(e);
660
  }}
661
  style={{ zIndex: 100 }}
662
- aria-label="Remove file"
663
  >
664
  <X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
665
  </button>
@@ -690,7 +659,6 @@ export function ChatArea({
690
  onRemove(e);
691
  }}
692
  style={{ zIndex: 10 }}
693
- aria-label="Remove file"
694
  >
695
  <X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
696
  </button>
@@ -765,11 +733,7 @@ export function ChatArea({
765
  );
766
  }
767
 
768
- return (
769
- <div className="whitespace-pre-wrap text-sm font-mono p-4 bg-muted rounded-lg max-h-[60vh] overflow-y-auto">
770
- {content}
771
- </div>
772
- );
773
  };
774
 
775
  // ✅ Reserve space for composer so last message is never hidden
@@ -802,10 +766,7 @@ export function ChatArea({
802
  }
803
 
804
  return (
805
- <Select
806
- value={currentCourseId || "course1"}
807
- onValueChange={(val) => onCourseChange && onCourseChange(val)}
808
- >
809
  <SelectTrigger className="w-[200px] h-9 font-semibold">
810
  <SelectValue placeholder="Select course" />
811
  </SelectTrigger>
@@ -823,12 +784,7 @@ export function ChatArea({
823
 
824
  {/* Chat Mode Tabs - Center */}
825
  <div className="absolute left-1/2 -translate-x-1/2 flex-shrink-0">
826
- <Tabs
827
- value={chatMode}
828
- onValueChange={(value) => onChatModeChange(value as ChatMode)}
829
- className="w-auto"
830
- orientation="horizontal"
831
- >
832
  <TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground">
833
  <TabsTrigger value="ask" className="w-[140px] px-3 text-sm">
834
  Ask
@@ -904,11 +860,7 @@ export function ChatArea({
904
  {/* =========================
905
  1) Scroll Container (ONLY this scrolls)
906
  ========================= */}
907
- <div
908
- ref={scrollContainerRef}
909
- className="flex-1 min-h-0 overflow-y-auto overscroll-contain"
910
- style={{ overscrollBehavior: "contain" }}
911
- >
912
  {/* Messages */}
913
  <div className="py-6" style={{ paddingBottom: bottomPad }}>
914
  <div className="w-full space-y-6 max-w-4xl mx-auto">
@@ -918,8 +870,7 @@ export function ChatArea({
918
  message={message}
919
  showSenderInfo={spaceType === "group"}
920
  isFirstGreeting={
921
- (message.id === "1" || message.id === "review-1" || message.id === "quiz-1") &&
922
- message.role === "assistant"
923
  }
924
  showNextButton={message.showNextButton && !isAppTyping}
925
  onNextQuestion={onNextQuestion}
@@ -957,18 +908,9 @@ export function ChatArea({
957
  </div>
958
  <div className="bg-muted rounded-2xl px-4 py-3">
959
  <div className="flex gap-1">
960
- <div
961
- className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
962
- style={{ animationDelay: "0ms" }}
963
- />
964
- <div
965
- className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
966
- style={{ animationDelay: "150ms" }}
967
- />
968
- <div
969
- className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
970
- style={{ animationDelay: "300ms" }}
971
- />
972
  </div>
973
  </div>
974
  </div>
@@ -1004,7 +946,9 @@ export function ChatArea({
1004
  {uploadedFiles.map((uploadedFile, index) => {
1005
  const Icon = getFileIcon(uploadedFile.file.name);
1006
  const fileInfo = getFileTypeInfo(uploadedFile.file.name);
1007
- const isImage = isImageByName(uploadedFile.file.name);
 
 
1008
 
1009
  return (
1010
  <div key={index}>
@@ -1019,14 +963,6 @@ export function ChatArea({
1019
  }}
1020
  onRemove={(e) => {
1021
  e.stopPropagation();
1022
- // ✅ revoke objectURL for this image file (display-only change)
1023
- if (isImage) {
1024
- try {
1025
- revokeObjectUrl(uploadedFile.file);
1026
- } catch {
1027
- // ignore
1028
- }
1029
- }
1030
  setFileToDelete(index);
1031
  setShowDeleteDialog(true);
1032
  }}
@@ -1094,10 +1030,7 @@ export function ChatArea({
1094
  </div>
1095
  </DropdownMenuItem>
1096
 
1097
- <DropdownMenuItem
1098
- onClick={() => onLearningModeChange("exam")}
1099
- className={learningMode === "exam" ? "bg-accent" : ""}
1100
- >
1101
  <div className="flex flex-col">
1102
  <span className="font-medium">Exam Prep</span>
1103
  <span className="text-xs text-muted-foreground">Practice with quiz questions</span>
@@ -1190,9 +1123,7 @@ export function ChatArea({
1190
  <AlertDialogContent>
1191
  <AlertDialogHeader>
1192
  <AlertDialogTitle>Start New Conversation</AlertDialogTitle>
1193
- <AlertDialogDescription>
1194
- Would you like to save the current chat before starting a new conversation?
1195
- </AlertDialogDescription>
1196
 
1197
  <Button
1198
  variant="ghost"
@@ -1373,9 +1304,7 @@ export function ChatArea({
1373
  </DialogTitle>
1374
  <DialogDescription>File size: {selectedFile ? formatFileSize(selectedFile.file.size) : ""}</DialogDescription>
1375
  </DialogHeader>
1376
- <div className="flex-1 min-h-0 overflow-y-auto mt-4">
1377
- {selectedFile && <FileViewerContent file={selectedFile.file} />}
1378
- </div>
1379
  </DialogContent>
1380
  </Dialog>
1381
 
 
33
  } from "../App";
34
  import { toast } from "sonner";
35
  import { jsPDF } from "jspdf";
36
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu";
37
+ import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "./ui/dialog";
 
 
 
 
 
 
 
 
 
 
 
 
38
  import { Checkbox } from "./ui/checkbox";
39
  import {
40
  AlertDialog,
 
46
  AlertDialogHeader,
47
  AlertDialogTitle,
48
  } from "./ui/alert-dialog";
49
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
 
 
 
 
 
 
50
  import { SmartReview } from "./SmartReview";
51
  import clareAvatar from "../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png";
52
 
53
+ // ✅ NEW: object URL cache for image thumbnails
54
  import { useObjectUrlCache } from "../lib/useObjectUrlCache";
55
 
56
  type ReviewEventType = "send_message" | "review_topic" | "review_all";
 
85
  savedChats: SavedChat[];
86
  workspaces: Workspace[];
87
  currentWorkspaceId: string;
88
+ onSaveFile?: (content: string, type: "export" | "summary", format?: "pdf" | "text", workspaceId?: string) => void;
 
 
 
 
 
89
  leftPanelVisible?: boolean;
90
  currentCourseId?: string;
91
  onCourseChange?: (courseId: string) => void;
 
158
  const [shareLink, setShareLink] = useState("");
159
  const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
160
 
 
 
 
161
  const courses =
162
  availableCourses.length > 0
163
  ? availableCourses
 
301
 
302
  const buildPreviewContent = () => {
303
  if (!messages.length) return "";
304
+ return messages.map((msg) => `${msg.role === "user" ? "You" : "Clare"}: ${msg.content}`).join("\n\n");
 
 
305
  };
306
 
307
  const buildSummaryContent = () => {
 
490
 
491
  const validFiles = files.filter((file) => {
492
  const ext = file.name.toLowerCase();
493
+ return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some((allowed) =>
494
+ ext.endsWith(allowed)
495
  );
496
  });
497
 
 
508
  if (files.length > 0) {
509
  const validFiles = files.filter((file) => {
510
  const ext = file.name.toLowerCase();
511
+ return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some((allowed) =>
512
+ ext.endsWith(allowed)
513
  );
514
  });
515
 
 
563
  if (ext.endsWith(".pdf")) return { bgColor: "bg-red-500", type: "PDF" };
564
  if (ext.endsWith(".docx") || ext.endsWith(".doc")) return { bgColor: "bg-blue-500", type: "Document" };
565
  if (ext.endsWith(".pptx") || ext.endsWith(".ppt")) return { bgColor: "bg-orange-500", type: "Presentation" };
566
+ if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e)))
567
+ return { bgColor: "bg-green-500", type: "Image" };
568
  return { bgColor: "bg-gray-500", type: "File" };
569
  };
570
 
 
574
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
575
  };
576
 
577
+ // ✅ objectURL cache for image thumbnails (display only)
578
+ const cacheFiles = React.useMemo(() => uploadedFiles.map((u) => u.file), [uploadedFiles]);
579
+ const { getOrCreate } = useObjectUrlCache(cacheFiles);
 
 
580
 
581
  const FileThumbnail = ({
582
  file,
 
593
  onPreview: () => void;
594
  onRemove: (e: React.MouseEvent) => void;
595
  }) => {
 
 
 
596
  if (isImage) {
597
+ const src = getOrCreate(file);
598
+
599
  return (
600
  <div
601
  className="relative cursor-pointer w-16 h-16 flex-shrink-0"
 
629
  onRemove(e);
630
  }}
631
  style={{ zIndex: 100 }}
 
632
  >
633
  <X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
634
  </button>
 
659
  onRemove(e);
660
  }}
661
  style={{ zIndex: 10 }}
 
662
  >
663
  <X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
664
  </button>
 
733
  );
734
  }
735
 
736
+ return <div className="whitespace-pre-wrap text-sm font-mono p-4 bg-muted rounded-lg max-h-[60vh] overflow-y-auto">{content}</div>;
 
 
 
 
737
  };
738
 
739
  // ✅ Reserve space for composer so last message is never hidden
 
766
  }
767
 
768
  return (
769
+ <Select value={currentCourseId || "course1"} onValueChange={(val) => onCourseChange && onCourseChange(val)}>
 
 
 
770
  <SelectTrigger className="w-[200px] h-9 font-semibold">
771
  <SelectValue placeholder="Select course" />
772
  </SelectTrigger>
 
784
 
785
  {/* Chat Mode Tabs - Center */}
786
  <div className="absolute left-1/2 -translate-x-1/2 flex-shrink-0">
787
+ <Tabs value={chatMode} onValueChange={(value) => onChatModeChange(value as ChatMode)} className="w-auto" orientation="horizontal">
 
 
 
 
 
788
  <TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground">
789
  <TabsTrigger value="ask" className="w-[140px] px-3 text-sm">
790
  Ask
 
860
  {/* =========================
861
  1) Scroll Container (ONLY this scrolls)
862
  ========================= */}
863
+ <div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto overscroll-contain" style={{ overscrollBehavior: "contain" }}>
 
 
 
 
864
  {/* Messages */}
865
  <div className="py-6" style={{ paddingBottom: bottomPad }}>
866
  <div className="w-full space-y-6 max-w-4xl mx-auto">
 
870
  message={message}
871
  showSenderInfo={spaceType === "group"}
872
  isFirstGreeting={
873
+ (message.id === "1" || message.id === "review-1" || message.id === "quiz-1") && message.role === "assistant"
 
874
  }
875
  showNextButton={message.showNextButton && !isAppTyping}
876
  onNextQuestion={onNextQuestion}
 
908
  </div>
909
  <div className="bg-muted rounded-2xl px-4 py-3">
910
  <div className="flex gap-1">
911
+ <div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: "0ms" }} />
912
+ <div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: "150ms" }} />
913
+ <div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: "300ms" }} />
 
 
 
 
 
 
 
 
 
914
  </div>
915
  </div>
916
  </div>
 
946
  {uploadedFiles.map((uploadedFile, index) => {
947
  const Icon = getFileIcon(uploadedFile.file.name);
948
  const fileInfo = getFileTypeInfo(uploadedFile.file.name);
949
+ const isImage = ["jpg", "jpeg", "png", "gif", "webp"].some((ext) =>
950
+ uploadedFile.file.name.toLowerCase().endsWith(`.${ext}`)
951
+ );
952
 
953
  return (
954
  <div key={index}>
 
963
  }}
964
  onRemove={(e) => {
965
  e.stopPropagation();
 
 
 
 
 
 
 
 
966
  setFileToDelete(index);
967
  setShowDeleteDialog(true);
968
  }}
 
1030
  </div>
1031
  </DropdownMenuItem>
1032
 
1033
+ <DropdownMenuItem onClick={() => onLearningModeChange("exam")} className={learningMode === "exam" ? "bg-accent" : ""}>
 
 
 
1034
  <div className="flex flex-col">
1035
  <span className="font-medium">Exam Prep</span>
1036
  <span className="text-xs text-muted-foreground">Practice with quiz questions</span>
 
1123
  <AlertDialogContent>
1124
  <AlertDialogHeader>
1125
  <AlertDialogTitle>Start New Conversation</AlertDialogTitle>
1126
+ <AlertDialogDescription>Would you like to save the current chat before starting a new conversation?</AlertDialogDescription>
 
 
1127
 
1128
  <Button
1129
  variant="ghost"
 
1304
  </DialogTitle>
1305
  <DialogDescription>File size: {selectedFile ? formatFileSize(selectedFile.file.size) : ""}</DialogDescription>
1306
  </DialogHeader>
1307
+ <div className="flex-1 min-h-0 overflow-y-auto mt-4">{selectedFile && <FileViewerContent file={selectedFile.file} />}</div>
 
 
1308
  </DialogContent>
1309
  </Dialog>
1310