SarahXia0405 commited on
Commit
4584696
·
verified ·
1 Parent(s): 14fec98

Update web/src/components/ChatArea.tsx

Browse files
Files changed (1) hide show
  1. web/src/components/ChatArea.tsx +181 -280
web/src/components/ChatArea.tsx CHANGED
@@ -1,5 +1,5 @@
1
  // web/src/components/ChatArea.tsx
2
- import React, { useState, useRef, useEffect } from "react";
3
  import { Button } from "./ui/button";
4
  import { Textarea } from "./ui/textarea";
5
  import { Input } from "./ui/input";
@@ -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,
@@ -92,12 +80,7 @@ interface ChatAreaProps {
92
  savedChats: SavedChat[];
93
  workspaces: Workspace[];
94
  currentWorkspaceId: string;
95
- onSaveFile?: (
96
- content: string,
97
- type: "export" | "summary",
98
- format?: "pdf" | "text",
99
- workspaceId?: string
100
- ) => void;
101
  leftPanelVisible?: boolean;
102
  currentCourseId?: string;
103
  onCourseChange?: (courseId: string) => void;
@@ -176,23 +159,44 @@ export function ChatArea({
176
  { id: "course4", name: "Web Development" },
177
  ];
178
 
179
- // Messages scroll container
180
  const messagesEndRef = useRef<HTMLDivElement>(null);
181
  const scrollContainerRef = useRef<HTMLDivElement>(null);
182
  const fileInputRef = useRef<HTMLInputElement>(null);
183
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  const isInitialMount = useRef(true);
185
  const previousMessagesLength = useRef(messages.length);
186
 
187
- const scrollToBottom = () => {
188
- messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
189
  };
190
 
 
191
  useEffect(() => {
192
  if (isInitialMount.current) {
193
  isInitialMount.current = false;
194
  previousMessagesLength.current = messages.length;
195
- // Keep at top on initial load (your old behavior)
 
196
  if (scrollContainerRef.current) {
197
  scrollContainerRef.current.scrollTop = 0;
198
  }
@@ -200,31 +204,36 @@ export function ChatArea({
200
  }
201
 
202
  if (messages.length > previousMessagesLength.current) {
203
- scrollToBottom();
 
 
 
 
 
 
 
204
  }
205
  previousMessagesLength.current = messages.length;
206
  }, [messages]);
207
 
 
208
  useEffect(() => {
209
- const handleScroll = () => {
210
- const el = scrollContainerRef.current;
211
- if (!el) return;
212
 
213
- const { scrollTop, scrollHeight, clientHeight } = el;
 
214
  const isAtBottom = scrollHeight - scrollTop - clientHeight < 120;
215
  setShowScrollButton(!isAtBottom);
216
  setShowTopBorder(scrollTop > 0);
217
  };
218
 
219
- const container = scrollContainerRef.current;
220
- if (!container) return;
221
-
222
  handleScroll();
223
- container.addEventListener("scroll", handleScroll);
224
- return () => container.removeEventListener("scroll", handleScroll);
225
  }, [messages]);
226
 
227
- const handleSubmit = (e: React.FormEvent) => {
228
  e.preventDefault();
229
  if (!input.trim() || !isLoggedIn) return;
230
 
@@ -248,6 +257,7 @@ export function ChatArea({
248
  summary: "Quick Summary",
249
  };
250
 
 
251
  const handleReviewTopic = (item: {
252
  title: string;
253
  previousQuestion: string;
@@ -270,9 +280,7 @@ export function ChatArea({
270
 
271
  const buildPreviewContent = () => {
272
  if (!messages.length) return "";
273
- return messages
274
- .map((msg) => `${msg.role === "user" ? "You" : "Clare"}: ${msg.content}`)
275
- .join("\n\n");
276
  };
277
 
278
  const buildSummaryContent = () => {
@@ -359,6 +367,7 @@ export function ChatArea({
359
  }
360
  };
361
 
 
362
  const isCurrentChatSaved = (): boolean => {
363
  if (messages.length <= 1) return false;
364
 
@@ -368,11 +377,7 @@ export function ChatArea({
368
 
369
  return chat.messages.every((savedMsg, idx) => {
370
  const currentMsg = messages[idx];
371
- return (
372
- savedMsg.id === currentMsg.id &&
373
- savedMsg.role === currentMsg.role &&
374
- savedMsg.content === currentMsg.content
375
- );
376
  });
377
  });
378
  };
@@ -460,8 +465,8 @@ export function ChatArea({
460
 
461
  const validFiles = files.filter((file) => {
462
  const ext = file.name.toLowerCase();
463
- return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some(
464
- (allowed) => ext.endsWith(allowed)
465
  );
466
  });
467
 
@@ -478,8 +483,8 @@ export function ChatArea({
478
  if (files.length > 0) {
479
  const validFiles = files.filter((file) => {
480
  const ext = file.name.toLowerCase();
481
- return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some(
482
- (allowed) => ext.endsWith(allowed)
483
  );
484
  });
485
 
@@ -518,6 +523,7 @@ export function ChatArea({
518
  setPendingFiles((prev) => prev.map((pf, i) => (i === index ? { ...pf, type } : pf)));
519
  };
520
 
 
521
  const getFileIcon = (filename: string) => {
522
  const ext = filename.toLowerCase();
523
  if (ext.endsWith(".pdf")) return FileText;
@@ -532,8 +538,7 @@ export function ChatArea({
532
  if (ext.endsWith(".pdf")) return { bgColor: "bg-red-500", type: "PDF" };
533
  if (ext.endsWith(".docx") || ext.endsWith(".doc")) return { bgColor: "bg-blue-500", type: "Document" };
534
  if (ext.endsWith(".pptx") || ext.endsWith(".ppt")) return { bgColor: "bg-orange-500", type: "Presentation" };
535
- if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e)))
536
- return { bgColor: "bg-green-500", type: "Image" };
537
  return { bgColor: "bg-gray-500", type: "File" };
538
  };
539
 
@@ -574,19 +579,13 @@ export function ChatArea({
574
  setImagePreview(e.target?.result as string);
575
  setImageLoading(false);
576
  };
577
- reader.onerror = () => {
578
- setImageLoading(false);
579
- };
580
  reader.readAsDataURL(file);
581
  }, [file, isImage]);
582
 
583
  if (isImage) {
584
  return (
585
- <div
586
- className="relative cursor-pointer w-16 h-16 flex-shrink-0"
587
- onClick={onPreview}
588
- style={{ width: "64px", height: "64px", flexShrink: 0 }}
589
- >
590
  <div className="w-full h-full relative bg-card border border-border rounded-lg hover:border-primary/50 transition-colors">
591
  <div className="w-full h-full overflow-hidden rounded-lg absolute inset-0">
592
  {imageLoading ? (
@@ -617,7 +616,7 @@ export function ChatArea({
617
  e.stopPropagation();
618
  onRemove(e);
619
  }}
620
- style={{ zIndex: 100, position: "absolute", top: "4px", right: "4px" }}
621
  >
622
  <X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
623
  </button>
@@ -627,7 +626,7 @@ export function ChatArea({
627
  }
628
 
629
  return (
630
- <div className="relative cursor-pointer" onClick={onPreview} style={{ width: "240px", flexShrink: 0 }}>
631
  <div className="h-16 w-full relative flex items-center px-3 bg-card border border-border rounded-lg hover:border-primary/50 transition-colors">
632
  <div className={`${fileInfo.bgColor} flex items-center justify-center w-10 h-10 rounded shrink-0`}>
633
  <Icon className="h-5 w-5 text-white" />
@@ -647,7 +646,7 @@ export function ChatArea({
647
  e.stopPropagation();
648
  onRemove(e);
649
  }}
650
- style={{ position: "absolute", top: "4px", right: "4px", zIndex: 10 }}
651
  >
652
  <X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
653
  </button>
@@ -729,152 +728,121 @@ export function ChatArea({
729
  );
730
  };
731
 
732
- // now composer is fixed as sibling => no huge bottom padding needed
733
- const MESSAGES_BOTTOM_PADDING = "2rem";
734
 
735
  return (
736
- // ✅ Three-part layout: TopBar (fixed) + Messages (scroll) + Composer (fixed)
737
- <div className="relative flex flex-col h-full min-h-0 overflow-hidden">
738
  {/* =========================
739
- 1) TOP BAR (fixed)
740
  ========================= */}
741
- <div
742
- className={`flex-shrink-0 flex items-center justify-between px-4 bg-card ${
743
- showTopBorder ? "border-b border-border" : "border-b border-border"
744
- }`}
745
- style={{ height: "4.5rem", margin: 0, padding: "1rem 1rem", boxSizing: "border-box" }}
746
- >
747
- {/* Course Selector - Left */}
748
- <div className="flex-shrink-0">
749
- {(() => {
750
- const current = workspaces.find((w) => w.id === currentWorkspaceId);
751
- if (current?.type === "group") {
752
- if (current.category === "course" && current.courseName) {
753
- return (
754
- <div className="h-9 px-3 inline-flex items-center rounded-md border font-semibold">
755
- {current.courseName}
756
- </div>
757
- );
758
  }
759
- return null;
760
- }
761
 
762
- return (
763
- <Select value={currentCourseId || "course1"} onValueChange={(val) => onCourseChange && onCourseChange(val)}>
764
- <SelectTrigger className="w-[200px] h-9 font-semibold">
765
- <SelectValue placeholder="Select course" />
766
- </SelectTrigger>
767
- <SelectContent>
768
- {courses.map((course) => (
769
- <SelectItem key={course.id} value={course.id}>
770
- {course.name}
771
- </SelectItem>
772
- ))}
773
- </SelectContent>
774
- </Select>
775
- );
776
- })()}
777
- </div>
778
 
779
- {/* Chat Mode Tabs - Center */}
780
- <div className="absolute left-1/2 -translate-x-1/2 flex-shrink-0">
781
- <Tabs
782
- value={chatMode}
783
- onValueChange={(value) => onChatModeChange(value as ChatMode)}
784
- className="w-auto"
785
- orientation="horizontal"
786
- >
787
- <TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground">
788
- <TabsTrigger value="ask" className="w-[140px] px-3 text-sm">
789
- Ask
790
- </TabsTrigger>
791
- <TabsTrigger value="review" className="w-[140px] px-3 text-sm relative">
792
- Review
793
- <span
794
- className="absolute top-0 right-0 bg-red-500 rounded-full border-2"
795
- style={{
796
- width: "10px",
797
- height: "10px",
798
- transform: "translate(25%, -25%)",
799
- zIndex: 10,
800
- borderColor: "var(--muted)",
801
- }}
802
- />
803
- </TabsTrigger>
804
- <TabsTrigger value="quiz" className="w-[140px] px-3 text-sm">
805
- Quiz
806
- </TabsTrigger>
807
- </TabsList>
808
- </Tabs>
809
- </div>
810
 
811
- {/* Action Buttons - Right */}
812
- <div className="flex items-center gap-2 flex-shrink-0">
813
- <Button
814
- variant="ghost"
815
- size="icon"
816
- onClick={handleSaveClick}
817
- disabled={!isLoggedIn}
818
- className={`h-8 w-8 rounded-md hover:bg-muted/50 ${isCurrentChatSaved() ? "text-primary" : ""}`}
819
- title={isCurrentChatSaved() ? "Unsave" : "Save"}
820
- >
821
- <Bookmark className={`h-4 w-4 ${isCurrentChatSaved() ? "fill-primary text-primary" : ""}`} />
822
- </Button>
823
 
824
- <Button
825
- variant="ghost"
826
- size="icon"
827
- onClick={handleOpenDownloadDialog}
828
- disabled={!isLoggedIn}
829
- className="h-8 w-8 rounded-md hover:bg-muted/50"
830
- title="Download"
831
- >
832
- <Download className="h-4 w-4" />
833
- </Button>
834
 
835
- <Button
836
- variant="ghost"
837
- size="icon"
838
- onClick={handleShareClick}
839
- disabled={!isLoggedIn}
840
- className="h-8 w-8 rounded-md hover:bg-muted/50"
841
- title="Share"
842
- >
843
- <Share2 className="h-4 w-4" />
844
- </Button>
845
 
846
- <Button
847
- variant="outline"
848
- onClick={handleClearClick}
849
- disabled={!isLoggedIn}
850
- 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)]"
851
- title="New Chat"
852
- >
853
- <Plus className="h-4 w-4" />
854
- <span className="text-sm font-medium">New chat</span>
855
- </Button>
 
856
  </div>
857
- </div>
858
 
859
- {/* =========================
860
- 2) MESSAGES (ONLY this area scrolls)
861
- ========================= */}
862
- <div
863
- ref={scrollContainerRef}
864
- className="flex-1 min-h-0 overflow-y-auto"
865
- style={{ overscrollBehavior: "contain" }}
866
- >
867
- <div className="py-6" style={{ paddingBottom: MESSAGES_BOTTOM_PADDING }}>
868
  <div className="w-full space-y-6 max-w-4xl mx-auto">
869
  {messages.map((message) => (
870
  <React.Fragment key={message.id}>
871
  <Message
872
  message={message}
873
  showSenderInfo={spaceType === "group"}
874
- isFirstGreeting={
875
- (message.id === "1" || message.id === "review-1" || message.id === "quiz-1") &&
876
- message.role === "assistant"
877
- }
878
  showNextButton={message.showNextButton && !isAppTyping}
879
  onNextQuestion={onNextQuestion}
880
  chatMode={chatMode}
@@ -925,15 +893,15 @@ export function ChatArea({
925
  </div>
926
 
927
  {/* =========================
928
- 2.5) Scroll-to-bottom button (fixed)
929
  ========================= */}
930
  {showScrollButton && (
931
- <div className="absolute z-30 left-0 right-0 flex justify-center pointer-events-none" style={{ bottom: "132px" }}>
932
  <Button
933
  variant="secondary"
934
  size="icon"
935
  className="rounded-full shadow-lg hover:shadow-xl transition-shadow bg-background border border-border pointer-events-auto w-10 h-10"
936
- onClick={scrollToBottom}
937
  title="Scroll to bottom"
938
  >
939
  <ArrowDown className="h-5 w-5" />
@@ -942,18 +910,16 @@ export function ChatArea({
942
  )}
943
 
944
  {/* =========================
945
- 3) COMPOSER (fixed bottom, never scroll)
946
  ========================= */}
947
- <div className="flex-shrink-0 bg-background/95 backdrop-blur-sm z-20 border-t border-border">
948
  <div className="max-w-4xl mx-auto px-4 py-4">
949
  {uploadedFiles.length > 0 && (
950
  <div className="mb-2 flex flex-wrap gap-2 max-h-32 overflow-y-auto">
951
  {uploadedFiles.map((uploadedFile, index) => {
952
  const Icon = getFileIcon(uploadedFile.file.name);
953
  const fileInfo = getFileTypeInfo(uploadedFile.file.name);
954
- const isImage = ["jpg", "jpeg", "png", "gif", "webp"].some((ext) =>
955
- uploadedFile.file.name.toLowerCase().endsWith(`.${ext}`)
956
- );
957
 
958
  return (
959
  <div key={index}>
@@ -978,26 +944,14 @@ export function ChatArea({
978
  </div>
979
  )}
980
 
981
- <form
982
- onSubmit={handleSubmit}
983
- onDragOver={handleDragOver}
984
- onDragLeave={handleDragLeave}
985
- onDrop={handleDrop}
986
- className={isDragging ? "opacity-75" : ""}
987
- >
988
  <div className="relative">
989
  {/* Mode Selector + Upload */}
990
  <div className="absolute bottom-3 left-2 flex items-center gap-1 z-10">
991
  {chatMode === "ask" && (
992
  <DropdownMenu>
993
  <DropdownMenuTrigger asChild>
994
- <Button
995
- variant="ghost"
996
- size="sm"
997
- className="gap-1.5 h-8 px-2 text-xs hover:bg-muted/50"
998
- disabled={!isLoggedIn}
999
- type="button"
1000
- >
1001
  <span>{modeLabels[learningMode]}</span>
1002
  <svg className="h-3 w-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
1003
  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
@@ -1005,62 +959,42 @@ export function ChatArea({
1005
  </Button>
1006
  </DropdownMenuTrigger>
1007
  <DropdownMenuContent align="start" className="w-56">
1008
- <DropdownMenuItem
1009
- onClick={() => onLearningModeChange("general")}
1010
- className={learningMode === "general" ? "bg-accent" : ""}
1011
- >
1012
  <div className="flex flex-col">
1013
  <span className="font-medium">General</span>
1014
- <span className="text-xs text-muted-foreground">
1015
- Answer various questions (context required)
1016
- </span>
1017
  </div>
1018
  </DropdownMenuItem>
1019
 
1020
- <DropdownMenuItem
1021
- onClick={() => onLearningModeChange("concept")}
1022
- className={learningMode === "concept" ? "bg-accent" : ""}
1023
- >
1024
  <div className="flex flex-col">
1025
  <span className="font-medium">Concept Explainer</span>
1026
  <span className="text-xs text-muted-foreground">Get detailed explanations of concepts</span>
1027
  </div>
1028
  </DropdownMenuItem>
1029
 
1030
- <DropdownMenuItem
1031
- onClick={() => onLearningModeChange("socratic")}
1032
- className={learningMode === "socratic" ? "bg-accent" : ""}
1033
- >
1034
  <div className="flex flex-col">
1035
  <span className="font-medium">Socratic Tutor</span>
1036
  <span className="text-xs text-muted-foreground">Learn through guided questions</span>
1037
  </div>
1038
  </DropdownMenuItem>
1039
 
1040
- <DropdownMenuItem
1041
- onClick={() => onLearningModeChange("exam")}
1042
- className={learningMode === "exam" ? "bg-accent" : ""}
1043
- >
1044
  <div className="flex flex-col">
1045
  <span className="font-medium">Exam Prep</span>
1046
  <span className="text-xs text-muted-foreground">Practice with quiz questions</span>
1047
  </div>
1048
  </DropdownMenuItem>
1049
 
1050
- <DropdownMenuItem
1051
- onClick={() => onLearningModeChange("assignment")}
1052
- className={learningMode === "assignment" ? "bg-accent" : ""}
1053
- >
1054
  <div className="flex flex-col">
1055
  <span className="font-medium">Assignment Helper</span>
1056
  <span className="text-xs text-muted-foreground">Get help with assignments</span>
1057
  </div>
1058
  </DropdownMenuItem>
1059
 
1060
- <DropdownMenuItem
1061
- onClick={() => onLearningModeChange("summary")}
1062
- className={learningMode === "summary" ? "bg-accent" : ""}
1063
- >
1064
  <div className="flex flex-col">
1065
  <span className="font-medium">Quick Summary</span>
1066
  <span className="text-xs text-muted-foreground">Get concise summaries</span>
@@ -1103,18 +1037,11 @@ export function ChatArea({
1103
  : "Ask Clare anything about the course or drag files here..."
1104
  }
1105
  disabled={!isLoggedIn || (chatMode === "quiz" && !quizState.waitingForAnswer)}
1106
- className={`min-h-[80px] pl-4 pr-20 resize-none bg-background border-2 ${
1107
- isDragging ? "border-primary border-dashed" : "border-border"
1108
- }`}
1109
  />
1110
 
1111
  <div className="absolute bottom-2 right-2 flex gap-1">
1112
- <Button
1113
- type="submit"
1114
- size="icon"
1115
- disabled={!input.trim() || !isLoggedIn}
1116
- className="h-8 w-8 rounded-full"
1117
- >
1118
  <Send className="h-4 w-4" />
1119
  </Button>
1120
  </div>
@@ -1138,9 +1065,7 @@ export function ChatArea({
1138
  <AlertDialogContent>
1139
  <AlertDialogHeader>
1140
  <AlertDialogTitle>Start New Conversation</AlertDialogTitle>
1141
- <AlertDialogDescription>
1142
- Would you like to save the current chat before starting a new conversation?
1143
- </AlertDialogDescription>
1144
 
1145
  <Button
1146
  variant="ghost"
@@ -1192,13 +1117,7 @@ export function ChatArea({
1192
  <div className="border rounded-lg bg-muted/40 flex flex-col max-h-64">
1193
  <div className="flex items-center justify-between p-4 sticky top-0 bg-muted/40 border-b z-10">
1194
  <span className="text-sm font-medium">Preview</span>
1195
- <Button
1196
- variant="outline"
1197
- size="sm"
1198
- className="h-7 px-2 text-xs gap-1.5"
1199
- onClick={handleCopyPreview}
1200
- title="Copy preview"
1201
- >
1202
  <Copy className="h-3 w-3" />
1203
  Copy
1204
  </Button>
@@ -1210,21 +1129,13 @@ export function ChatArea({
1210
 
1211
  <div className="space-y-3">
1212
  <div className="flex items-center space-x-2">
1213
- <Checkbox
1214
- id="download-chat"
1215
- checked={downloadOptions.chat}
1216
- onCheckedChange={(checked) => setDownloadOptions({ ...downloadOptions, chat: checked === true })}
1217
- />
1218
  <label htmlFor="download-chat" className="text-sm font-medium cursor-pointer">
1219
  Download chat
1220
  </label>
1221
  </div>
1222
  <div className="flex items-center space-x-2">
1223
- <Checkbox
1224
- id="download-summary"
1225
- checked={downloadOptions.summary}
1226
- onCheckedChange={(checked) => setDownloadOptions({ ...downloadOptions, summary: checked === true })}
1227
- />
1228
  <label htmlFor="download-summary" className="text-sm font-medium cursor-pointer">
1229
  Download summary
1230
  </label>
@@ -1273,9 +1184,7 @@ export function ChatArea({
1273
  ))}
1274
  </SelectContent>
1275
  </Select>
1276
- <p className="text-xs text-muted-foreground">
1277
- Sends this conversation to the selected workspace&apos;s Saved Files.
1278
- </p>
1279
  <Button onClick={handleShareSendToWorkspace} className="w-full">
1280
  Send
1281
  </Button>
@@ -1290,9 +1199,7 @@ export function ChatArea({
1290
  <AlertDialogHeader>
1291
  <AlertDialogTitle>Delete File</AlertDialogTitle>
1292
  <AlertDialogDescription>
1293
- Are you sure you want to delete &quot;
1294
- {fileToDelete !== null ? uploadedFiles[fileToDelete]?.file.name : ""}
1295
- &quot;? This action cannot be undone.
1296
  </AlertDialogDescription>
1297
  </AlertDialogHeader>
1298
  <AlertDialogFooter>
@@ -1316,10 +1223,7 @@ export function ChatArea({
1316
  <Dialog open={showFileViewer} onOpenChange={setShowFileViewer}>
1317
  <DialogContent className="max-w-4xl max-h-[85vh] flex flex-col overflow-hidden">
1318
  <DialogHeader className="min-w-0 flex-shrink-0">
1319
- <DialogTitle
1320
- className="pr-8 break-words break-all overflow-wrap-anywhere leading-relaxed"
1321
- style={{ wordBreak: "break-all", overflowWrap: "anywhere", maxWidth: "100%", lineHeight: "1.6" }}
1322
- >
1323
  {selectedFile?.file.name}
1324
  </DialogTitle>
1325
  <DialogDescription>File size: {selectedFile ? formatFileSize(selectedFile.file.size) : ""}</DialogDescription>
@@ -1352,10 +1256,7 @@ export function ChatArea({
1352
 
1353
  <div className="space-y-1">
1354
  <label className="text-xs text-muted-foreground">File Type</label>
1355
- <Select
1356
- value={pendingFile.type}
1357
- onValueChange={(value) => handlePendingFileTypeChange(index, value as FileType)}
1358
- >
1359
  <SelectTrigger className="h-8 text-xs">
1360
  <SelectValue />
1361
  </SelectTrigger>
 
1
  // web/src/components/ChatArea.tsx
2
+ import React, { useState, useRef, useEffect, useLayoutEffect } from "react";
3
  import { Button } from "./ui/button";
4
  import { Textarea } from "./ui/textarea";
5
  import { Input } from "./ui/input";
 
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,
 
80
  savedChats: SavedChat[];
81
  workspaces: Workspace[];
82
  currentWorkspaceId: string;
83
+ onSaveFile?: (content: string, type: "export" | "summary", format?: "pdf" | "text", workspaceId?: string) => void;
 
 
 
 
 
84
  leftPanelVisible?: boolean;
85
  currentCourseId?: string;
86
  onCourseChange?: (courseId: string) => void;
 
159
  { id: "course4", name: "Web Development" },
160
  ];
161
 
162
+ // Scroll refs
163
  const messagesEndRef = useRef<HTMLDivElement>(null);
164
  const scrollContainerRef = useRef<HTMLDivElement>(null);
165
  const fileInputRef = useRef<HTMLInputElement>(null);
166
 
167
+ // ✅ Composer measured height (dynamic) to reserve bottom padding for messages
168
+ const composerRef = useRef<HTMLDivElement>(null);
169
+ const [composerHeight, setComposerHeight] = useState<number>(160); // safe default
170
+
171
+ useLayoutEffect(() => {
172
+ const el = composerRef.current;
173
+ if (!el) return;
174
+
175
+ const update = () => setComposerHeight(el.getBoundingClientRect().height);
176
+
177
+ update();
178
+
179
+ // ResizeObserver is ideal for textarea growth / file chips height changes
180
+ const ro = new ResizeObserver(() => update());
181
+ ro.observe(el);
182
+
183
+ return () => ro.disconnect();
184
+ }, []);
185
+
186
  const isInitialMount = useRef(true);
187
  const previousMessagesLength = useRef(messages.length);
188
 
189
+ const scrollToBottom = (behavior: ScrollBehavior = "smooth") => {
190
+ messagesEndRef.current?.scrollIntoView({ behavior, block: "end" });
191
  };
192
 
193
+ // Auto-scroll only when new messages come in (not on initial mount)
194
  useEffect(() => {
195
  if (isInitialMount.current) {
196
  isInitialMount.current = false;
197
  previousMessagesLength.current = messages.length;
198
+
199
+ // You previously kept at top; keep that behavior
200
  if (scrollContainerRef.current) {
201
  scrollContainerRef.current.scrollTop = 0;
202
  }
 
204
  }
205
 
206
  if (messages.length > previousMessagesLength.current) {
207
+ // If user is near bottom, follow; otherwise don't steal scroll
208
+ const el = scrollContainerRef.current;
209
+ if (el) {
210
+ const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 240;
211
+ if (nearBottom) scrollToBottom("smooth");
212
+ } else {
213
+ scrollToBottom("smooth");
214
+ }
215
  }
216
  previousMessagesLength.current = messages.length;
217
  }, [messages]);
218
 
219
+ // Scroll button + top border
220
  useEffect(() => {
221
+ const container = scrollContainerRef.current;
222
+ if (!container) return;
 
223
 
224
+ const handleScroll = () => {
225
+ const { scrollTop, scrollHeight, clientHeight } = container;
226
  const isAtBottom = scrollHeight - scrollTop - clientHeight < 120;
227
  setShowScrollButton(!isAtBottom);
228
  setShowTopBorder(scrollTop > 0);
229
  };
230
 
 
 
 
231
  handleScroll();
232
+ container.addEventListener("scroll", handleScroll, { passive: true });
233
+ return () => container.removeEventListener("scroll", handleScroll as any);
234
  }, [messages]);
235
 
236
+ const handleSubmit = (e: React.FormEvent | React.KeyboardEvent) => {
237
  e.preventDefault();
238
  if (!input.trim() || !isLoggedIn) return;
239
 
 
257
  summary: "Quick Summary",
258
  };
259
 
260
+ // Review topic click
261
  const handleReviewTopic = (item: {
262
  title: string;
263
  previousQuestion: string;
 
280
 
281
  const buildPreviewContent = () => {
282
  if (!messages.length) return "";
283
+ return messages.map((msg) => `${msg.role === "user" ? "You" : "Clare"}: ${msg.content}`).join("\n\n");
 
 
284
  };
285
 
286
  const buildSummaryContent = () => {
 
367
  }
368
  };
369
 
370
+ // Check if current chat is already saved
371
  const isCurrentChatSaved = (): boolean => {
372
  if (messages.length <= 1) return false;
373
 
 
377
 
378
  return chat.messages.every((savedMsg, idx) => {
379
  const currentMsg = messages[idx];
380
+ return savedMsg.id === currentMsg.id && savedMsg.role === currentMsg.role && savedMsg.content === currentMsg.content;
 
 
 
 
381
  });
382
  });
383
  };
 
465
 
466
  const validFiles = files.filter((file) => {
467
  const ext = file.name.toLowerCase();
468
+ return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some((allowed) =>
469
+ ext.endsWith(allowed)
470
  );
471
  });
472
 
 
483
  if (files.length > 0) {
484
  const validFiles = files.filter((file) => {
485
  const ext = file.name.toLowerCase();
486
+ return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some((allowed) =>
487
+ ext.endsWith(allowed)
488
  );
489
  });
490
 
 
523
  setPendingFiles((prev) => prev.map((pf, i) => (i === index ? { ...pf, type } : pf)));
524
  };
525
 
526
+ // File helpers
527
  const getFileIcon = (filename: string) => {
528
  const ext = filename.toLowerCase();
529
  if (ext.endsWith(".pdf")) return FileText;
 
538
  if (ext.endsWith(".pdf")) return { bgColor: "bg-red-500", type: "PDF" };
539
  if (ext.endsWith(".docx") || ext.endsWith(".doc")) return { bgColor: "bg-blue-500", type: "Document" };
540
  if (ext.endsWith(".pptx") || ext.endsWith(".ppt")) return { bgColor: "bg-orange-500", type: "Presentation" };
541
+ if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e))) return { bgColor: "bg-green-500", type: "Image" };
 
542
  return { bgColor: "bg-gray-500", type: "File" };
543
  };
544
 
 
579
  setImagePreview(e.target?.result as string);
580
  setImageLoading(false);
581
  };
582
+ reader.onerror = () => setImageLoading(false);
 
 
583
  reader.readAsDataURL(file);
584
  }, [file, isImage]);
585
 
586
  if (isImage) {
587
  return (
588
+ <div className="relative cursor-pointer w-16 h-16 flex-shrink-0" onClick={onPreview} style={{ width: 64, height: 64 }}>
 
 
 
 
589
  <div className="w-full h-full relative bg-card border border-border rounded-lg hover:border-primary/50 transition-colors">
590
  <div className="w-full h-full overflow-hidden rounded-lg absolute inset-0">
591
  {imageLoading ? (
 
616
  e.stopPropagation();
617
  onRemove(e);
618
  }}
619
+ style={{ zIndex: 100 }}
620
  >
621
  <X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
622
  </button>
 
626
  }
627
 
628
  return (
629
+ <div className="relative cursor-pointer" onClick={onPreview} style={{ width: 240, flexShrink: 0 }}>
630
  <div className="h-16 w-full relative flex items-center px-3 bg-card border border-border rounded-lg hover:border-primary/50 transition-colors">
631
  <div className={`${fileInfo.bgColor} flex items-center justify-center w-10 h-10 rounded shrink-0`}>
632
  <Icon className="h-5 w-5 text-white" />
 
646
  e.stopPropagation();
647
  onRemove(e);
648
  }}
649
+ style={{ zIndex: 10 }}
650
  >
651
  <X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
652
  </button>
 
728
  );
729
  };
730
 
731
+ // Reserve space for composer so last message is never hidden
732
+ const bottomPad = Math.max(24, composerHeight + 24);
733
 
734
  return (
735
+ // ✅ This must fill parent; no outer scroll.
736
+ <div className="relative flex flex-col h-full min-h-0 w-full overflow-hidden">
737
  {/* =========================
738
+ 1) Scroll Container (ONLY this scrolls)
739
  ========================= */}
740
+ <div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto" style={{ overscrollBehavior: "contain" }}>
741
+ {/* Top Bar - Sticky inside scroll container */}
742
+ <div
743
+ className={`sticky top-0 flex items-center justify-between px-4 z-20 bg-card ${
744
+ showTopBorder ? "border-b border-border" : ""
745
+ }`}
746
+ style={{ height: "4.5rem", margin: 0, padding: "1rem 1rem", boxSizing: "border-box" }}
747
+ >
748
+ {/* Course Selector - Left */}
749
+ <div className="flex-shrink-0">
750
+ {(() => {
751
+ const current = workspaces.find((w) => w.id === currentWorkspaceId);
752
+ if (current?.type === "group") {
753
+ if (current.category === "course" && current.courseName) {
754
+ return <div className="h-9 px-3 inline-flex items-center rounded-md border font-semibold">{current.courseName}</div>;
755
+ }
756
+ return null;
757
  }
 
 
758
 
759
+ return (
760
+ <Select value={currentCourseId || "course1"} onValueChange={(val) => onCourseChange && onCourseChange(val)}>
761
+ <SelectTrigger className="w-[200px] h-9 font-semibold">
762
+ <SelectValue placeholder="Select course" />
763
+ </SelectTrigger>
764
+ <SelectContent>
765
+ {courses.map((course) => (
766
+ <SelectItem key={course.id} value={course.id}>
767
+ {course.name}
768
+ </SelectItem>
769
+ ))}
770
+ </SelectContent>
771
+ </Select>
772
+ );
773
+ })()}
774
+ </div>
775
 
776
+ {/* Chat Mode Tabs - Center */}
777
+ <div className="absolute left-1/2 -translate-x-1/2 flex-shrink-0">
778
+ <Tabs value={chatMode} onValueChange={(value) => onChatModeChange(value as ChatMode)} className="w-auto" orientation="horizontal">
779
+ <TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground">
780
+ <TabsTrigger value="ask" className="w-[140px] px-3 text-sm">
781
+ Ask
782
+ </TabsTrigger>
783
+ <TabsTrigger value="review" className="w-[140px] px-3 text-sm relative">
784
+ Review
785
+ <span
786
+ className="absolute top-0 right-0 bg-red-500 rounded-full border-2"
787
+ style={{
788
+ width: 10,
789
+ height: 10,
790
+ transform: "translate(25%, -25%)",
791
+ zIndex: 10,
792
+ borderColor: "var(--muted)",
793
+ }}
794
+ />
795
+ </TabsTrigger>
796
+ <TabsTrigger value="quiz" className="w-[140px] px-3 text-sm">
797
+ Quiz
798
+ </TabsTrigger>
799
+ </TabsList>
800
+ </Tabs>
801
+ </div>
 
 
 
 
 
802
 
803
+ {/* Action Buttons - Right */}
804
+ <div className="flex items-center gap-2 flex-shrink-0">
805
+ <Button
806
+ variant="ghost"
807
+ size="icon"
808
+ onClick={handleSaveClick}
809
+ disabled={!isLoggedIn}
810
+ className={`h-8 w-8 rounded-md hover:bg-muted/50 ${isCurrentChatSaved() ? "text-primary" : ""}`}
811
+ title={isCurrentChatSaved() ? "Unsave" : "Save"}
812
+ >
813
+ <Bookmark className={`h-4 w-4 ${isCurrentChatSaved() ? "fill-primary text-primary" : ""}`} />
814
+ </Button>
815
 
816
+ <Button variant="ghost" size="icon" onClick={handleOpenDownloadDialog} disabled={!isLoggedIn} className="h-8 w-8 rounded-md hover:bg-muted/50" title="Download">
817
+ <Download className="h-4 w-4" />
818
+ </Button>
 
 
 
 
 
 
 
819
 
820
+ <Button variant="ghost" size="icon" onClick={handleShareClick} disabled={!isLoggedIn} className="h-8 w-8 rounded-md hover:bg-muted/50" title="Share">
821
+ <Share2 className="h-4 w-4" />
822
+ </Button>
 
 
 
 
 
 
 
823
 
824
+ <Button
825
+ variant="outline"
826
+ onClick={handleClearClick}
827
+ disabled={!isLoggedIn}
828
+ 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)]"
829
+ title="New Chat"
830
+ >
831
+ <Plus className="h-4 w-4" />
832
+ <span className="text-sm font-medium">New chat</span>
833
+ </Button>
834
+ </div>
835
  </div>
 
836
 
837
+ {/* Messages */}
838
+ <div className="py-6" style={{ paddingBottom: bottomPad }}>
 
 
 
 
 
 
 
839
  <div className="w-full space-y-6 max-w-4xl mx-auto">
840
  {messages.map((message) => (
841
  <React.Fragment key={message.id}>
842
  <Message
843
  message={message}
844
  showSenderInfo={spaceType === "group"}
845
+ isFirstGreeting={(message.id === "1" || message.id === "review-1" || message.id === "quiz-1") && message.role === "assistant"}
 
 
 
846
  showNextButton={message.showNextButton && !isAppTyping}
847
  onNextQuestion={onNextQuestion}
848
  chatMode={chatMode}
 
893
  </div>
894
 
895
  {/* =========================
896
+ 2) Scroll-to-bottom button (fixed relative to ChatArea)
897
  ========================= */}
898
  {showScrollButton && (
899
+ <div className="absolute z-30 left-0 right-0 flex justify-center pointer-events-none" style={{ bottom: composerHeight + 16 }}>
900
  <Button
901
  variant="secondary"
902
  size="icon"
903
  className="rounded-full shadow-lg hover:shadow-xl transition-shadow bg-background border border-border pointer-events-auto w-10 h-10"
904
+ onClick={() => scrollToBottom("smooth")}
905
  title="Scroll to bottom"
906
  >
907
  <ArrowDown className="h-5 w-5" />
 
910
  )}
911
 
912
  {/* =========================
913
+ 3) Composer (NOT sticky; it is the bottom flex item, so it never moves)
914
  ========================= */}
915
+ <div ref={composerRef} className="flex-shrink-0 bg-background/95 backdrop-blur-sm z-20 border-t border-border">
916
  <div className="max-w-4xl mx-auto px-4 py-4">
917
  {uploadedFiles.length > 0 && (
918
  <div className="mb-2 flex flex-wrap gap-2 max-h-32 overflow-y-auto">
919
  {uploadedFiles.map((uploadedFile, index) => {
920
  const Icon = getFileIcon(uploadedFile.file.name);
921
  const fileInfo = getFileTypeInfo(uploadedFile.file.name);
922
+ const isImage = ["jpg", "jpeg", "png", "gif", "webp"].some((ext) => uploadedFile.file.name.toLowerCase().endsWith(`.${ext}`));
 
 
923
 
924
  return (
925
  <div key={index}>
 
944
  </div>
945
  )}
946
 
947
+ <form onSubmit={handleSubmit as any} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} className={isDragging ? "opacity-75" : ""}>
 
 
 
 
 
 
948
  <div className="relative">
949
  {/* Mode Selector + Upload */}
950
  <div className="absolute bottom-3 left-2 flex items-center gap-1 z-10">
951
  {chatMode === "ask" && (
952
  <DropdownMenu>
953
  <DropdownMenuTrigger asChild>
954
+ <Button variant="ghost" size="sm" className="gap-1.5 h-8 px-2 text-xs hover:bg-muted/50" disabled={!isLoggedIn} type="button">
 
 
 
 
 
 
955
  <span>{modeLabels[learningMode]}</span>
956
  <svg className="h-3 w-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
957
  <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
 
959
  </Button>
960
  </DropdownMenuTrigger>
961
  <DropdownMenuContent align="start" className="w-56">
962
+ <DropdownMenuItem onClick={() => onLearningModeChange("general")} className={learningMode === "general" ? "bg-accent" : ""}>
 
 
 
963
  <div className="flex flex-col">
964
  <span className="font-medium">General</span>
965
+ <span className="text-xs text-muted-foreground">Answer various questions (context required)</span>
 
 
966
  </div>
967
  </DropdownMenuItem>
968
 
969
+ <DropdownMenuItem onClick={() => onLearningModeChange("concept")} className={learningMode === "concept" ? "bg-accent" : ""}>
 
 
 
970
  <div className="flex flex-col">
971
  <span className="font-medium">Concept Explainer</span>
972
  <span className="text-xs text-muted-foreground">Get detailed explanations of concepts</span>
973
  </div>
974
  </DropdownMenuItem>
975
 
976
+ <DropdownMenuItem onClick={() => onLearningModeChange("socratic")} className={learningMode === "socratic" ? "bg-accent" : ""}>
 
 
 
977
  <div className="flex flex-col">
978
  <span className="font-medium">Socratic Tutor</span>
979
  <span className="text-xs text-muted-foreground">Learn through guided questions</span>
980
  </div>
981
  </DropdownMenuItem>
982
 
983
+ <DropdownMenuItem onClick={() => onLearningModeChange("exam")} className={learningMode === "exam" ? "bg-accent" : ""}>
 
 
 
984
  <div className="flex flex-col">
985
  <span className="font-medium">Exam Prep</span>
986
  <span className="text-xs text-muted-foreground">Practice with quiz questions</span>
987
  </div>
988
  </DropdownMenuItem>
989
 
990
+ <DropdownMenuItem onClick={() => onLearningModeChange("assignment")} className={learningMode === "assignment" ? "bg-accent" : ""}>
 
 
 
991
  <div className="flex flex-col">
992
  <span className="font-medium">Assignment Helper</span>
993
  <span className="text-xs text-muted-foreground">Get help with assignments</span>
994
  </div>
995
  </DropdownMenuItem>
996
 
997
+ <DropdownMenuItem onClick={() => onLearningModeChange("summary")} className={learningMode === "summary" ? "bg-accent" : ""}>
 
 
 
998
  <div className="flex flex-col">
999
  <span className="font-medium">Quick Summary</span>
1000
  <span className="text-xs text-muted-foreground">Get concise summaries</span>
 
1037
  : "Ask Clare anything about the course or drag files here..."
1038
  }
1039
  disabled={!isLoggedIn || (chatMode === "quiz" && !quizState.waitingForAnswer)}
1040
+ className={`min-h-[80px] pl-4 pr-20 resize-none bg-background border-2 ${isDragging ? "border-primary border-dashed" : "border-border"}`}
 
 
1041
  />
1042
 
1043
  <div className="absolute bottom-2 right-2 flex gap-1">
1044
+ <Button type="submit" size="icon" disabled={!input.trim() || !isLoggedIn} className="h-8 w-8 rounded-full">
 
 
 
 
 
1045
  <Send className="h-4 w-4" />
1046
  </Button>
1047
  </div>
 
1065
  <AlertDialogContent>
1066
  <AlertDialogHeader>
1067
  <AlertDialogTitle>Start New Conversation</AlertDialogTitle>
1068
+ <AlertDialogDescription>Would you like to save the current chat before starting a new conversation?</AlertDialogDescription>
 
 
1069
 
1070
  <Button
1071
  variant="ghost"
 
1117
  <div className="border rounded-lg bg-muted/40 flex flex-col max-h-64">
1118
  <div className="flex items-center justify-between p-4 sticky top-0 bg-muted/40 border-b z-10">
1119
  <span className="text-sm font-medium">Preview</span>
1120
+ <Button variant="outline" size="sm" className="h-7 px-2 text-xs gap-1.5" onClick={handleCopyPreview} title="Copy preview">
 
 
 
 
 
 
1121
  <Copy className="h-3 w-3" />
1122
  Copy
1123
  </Button>
 
1129
 
1130
  <div className="space-y-3">
1131
  <div className="flex items-center space-x-2">
1132
+ <Checkbox id="download-chat" checked={downloadOptions.chat} onCheckedChange={(checked) => setDownloadOptions({ ...downloadOptions, chat: checked === true })} />
 
 
 
 
1133
  <label htmlFor="download-chat" className="text-sm font-medium cursor-pointer">
1134
  Download chat
1135
  </label>
1136
  </div>
1137
  <div className="flex items-center space-x-2">
1138
+ <Checkbox id="download-summary" checked={downloadOptions.summary} onCheckedChange={(checked) => setDownloadOptions({ ...downloadOptions, summary: checked === true })} />
 
 
 
 
1139
  <label htmlFor="download-summary" className="text-sm font-medium cursor-pointer">
1140
  Download summary
1141
  </label>
 
1184
  ))}
1185
  </SelectContent>
1186
  </Select>
1187
+ <p className="text-xs text-muted-foreground">Sends this conversation to the selected workspace&apos;s Saved Files.</p>
 
 
1188
  <Button onClick={handleShareSendToWorkspace} className="w-full">
1189
  Send
1190
  </Button>
 
1199
  <AlertDialogHeader>
1200
  <AlertDialogTitle>Delete File</AlertDialogTitle>
1201
  <AlertDialogDescription>
1202
+ Are you sure you want to delete &quot;{fileToDelete !== null ? uploadedFiles[fileToDelete]?.file.name : ""}&quot;? This action cannot be undone.
 
 
1203
  </AlertDialogDescription>
1204
  </AlertDialogHeader>
1205
  <AlertDialogFooter>
 
1223
  <Dialog open={showFileViewer} onOpenChange={setShowFileViewer}>
1224
  <DialogContent className="max-w-4xl max-h-[85vh] flex flex-col overflow-hidden">
1225
  <DialogHeader className="min-w-0 flex-shrink-0">
1226
+ <DialogTitle className="pr-8 break-words break-all overflow-wrap-anywhere leading-relaxed" style={{ wordBreak: "break-all", overflowWrap: "anywhere", maxWidth: "100%", lineHeight: "1.6" }}>
 
 
 
1227
  {selectedFile?.file.name}
1228
  </DialogTitle>
1229
  <DialogDescription>File size: {selectedFile ? formatFileSize(selectedFile.file.size) : ""}</DialogDescription>
 
1256
 
1257
  <div className="space-y-1">
1258
  <label className="text-xs text-muted-foreground">File Type</label>
1259
+ <Select value={pendingFile.type} onValueChange={(value) => handlePendingFileTypeChange(index, value as FileType)}>
 
 
 
1260
  <SelectTrigger className="h-8 text-xs">
1261
  <SelectValue />
1262
  </SelectTrigger>