SarahXia0405 commited on
Commit
9306b1f
·
verified ·
1 Parent(s): bba3a81

Update web/src/App.tsx

Browse files
Files changed (1) hide show
  1. web/src/App.tsx +125 -131
web/src/App.tsx CHANGED
@@ -1,7 +1,6 @@
1
  // web/src/App.tsx
2
  import React, { useState, useEffect, useRef, useMemo } from "react";
3
  import { Header } from "./components/Header";
4
- // import { LeftSidebar } from "./components/LeftSidebar";
5
  import { ChatArea } from "./components/ChatArea";
6
  import { LoginScreen } from "./components/LoginScreen";
7
  import { ProfileEditor } from "./components/ProfileEditor";
@@ -45,9 +44,26 @@ export interface Message {
45
  }
46
 
47
  export interface User {
 
48
  name: string;
49
  email: string;
50
- bio?: string;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  }
52
 
53
  export interface GroupMember {
@@ -149,6 +165,22 @@ function hydrateSavedChats(raw: any): SavedChat[] {
149
  .filter(Boolean) as SavedChat[];
150
  }
151
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  function App() {
153
  const [isDarkMode, setIsDarkMode] = useState(() => {
154
  const saved = localStorage.getItem("theme");
@@ -157,6 +189,32 @@ function App() {
157
 
158
  const [user, setUser] = useState<User | null>(null);
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  // -------------------------
161
  // ✅ Course selection (stable)
162
  // -------------------------
@@ -563,47 +621,6 @@ function App() {
563
  const reviewStarOpacity = starOpacity(reviewStarState);
564
  const reviewEnergyPct = energyPct(reviewStarState);
565
 
566
- const generateQuizQuestion = () => {
567
- const questions: Array<{
568
- type: "multiple-choice" | "fill-in-blank" | "open-ended";
569
- question: string;
570
- options?: string[];
571
- correctAnswer: string;
572
- explanation: string;
573
- sampleAnswer?: string;
574
- }> = [
575
- {
576
- type: "multiple-choice",
577
- question: "Which of the following is NOT a principle of Responsible AI?",
578
- options: ["A) Fairness", "B) Transparency", "C) Profit Maximization", "D) Accountability"],
579
- correctAnswer: "C",
580
- explanation:
581
- "Profit Maximization is not a principle of Responsible AI. The key principles are Fairness, Transparency, and Accountability.",
582
- },
583
- {
584
- type: "fill-in-blank",
585
- question:
586
- "Algorithmic fairness ensures that AI systems do not discriminate against individuals based on their _____.",
587
- correctAnswer: "protected characteristics",
588
- explanation:
589
- "Protected characteristics include attributes like race, gender, age, religion, etc. AI systems should not discriminate based on these.",
590
- },
591
- {
592
- type: "open-ended",
593
- question: "Explain why transparency is important in AI systems.",
594
- correctAnswer:
595
- "Transparency allows users to understand how AI systems make decisions, which builds trust and enables accountability.",
596
- sampleAnswer:
597
- "Transparency allows users to understand how AI systems make decisions, which builds trust and enables accountability.",
598
- explanation:
599
- "Transparency is crucial because it helps users understand AI decision-making processes, enables debugging, and ensures accountability.",
600
- },
601
- ];
602
-
603
- const randomIndex = Math.floor(Math.random() * questions.length);
604
- return questions[randomIndex];
605
- };
606
-
607
  const getCurrentDocTypeForChat = (): string => {
608
  if (uploadedFiles.length > 0) {
609
  const last = uploadedFiles[uploadedFiles.length - 1];
@@ -612,36 +629,32 @@ function App() {
612
  return "Syllabus";
613
  };
614
 
615
-
616
  const handleSendMessage = async (content: string) => {
617
  if (!user) return;
618
-
619
  const hasText = !!content.trim();
620
  const hasFiles = uploadedFiles.length > 0;
621
-
622
- // ✅ 允许“只发文件不发文字”
623
  if (!hasText && !hasFiles) return;
624
-
625
- // ✅ 如果用户没写字,但上传了文件:构造一条后端可用的 message
626
  const fileNames = hasFiles ? uploadedFiles.map((f) => f.file.name) : [];
627
  const fileLine = fileNames.length ? `Uploaded files: ${fileNames.join(", ")}` : "";
628
-
629
  const effectiveContent = hasText
630
  ? content
631
  : `I've uploaded file(s). Please read them and help me based on their content.\n${fileLine}`.trim();
632
-
633
  const sender: GroupMember = {
634
  id: user.email,
635
  name: user.name,
636
  email: user.email,
637
  avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`,
638
  };
639
-
640
- // ✅ 用户消息在 UI 里也要可见(否则会出现空白气泡)
641
  const userVisibleContent = hasText
642
  ? content
643
  : `📎 Sent ${fileNames.length} file(s)\n${fileNames.map((n) => `- ${n}`).join("\n")}`;
644
-
645
  const userMessage: Message = {
646
  id: Date.now().toString(),
647
  role: "user",
@@ -649,17 +662,17 @@ function App() {
649
  timestamp: new Date(),
650
  sender,
651
  };
652
-
653
  if (chatMode === "ask") setAskMessages((prev) => [...prev, userMessage]);
654
  else if (chatMode === "review") setReviewMessages((prev) => [...prev, userMessage]);
655
  else setQuizMessages((prev) => [...prev, userMessage]);
656
-
657
  if (chatMode === "quiz") {
658
  setIsTyping(true);
659
-
660
  try {
661
  const docType = getCurrentDocTypeForChat();
662
-
663
  const r = await apiChat({
664
  user_id: user.email,
665
  message: effectiveContent,
@@ -672,13 +685,10 @@ function App() {
672
  const arr = Array.isArray(raw) ? raw : [];
673
  return arr
674
  .map((x) => {
675
- // Case A: backend already returns "file — pX#Y" strings
676
  if (typeof x === "string") {
677
  const s = x.trim();
678
  return s ? s : null;
679
  }
680
-
681
- // Case B: backend returns { source_file, section }
682
  const a = x?.source_file ? String(x.source_file) : "";
683
  const b = x?.section ? String(x.section) : "";
684
  const s = `${a}${a && b ? " — " : ""}${b}`.trim();
@@ -686,12 +696,9 @@ function App() {
686
  })
687
  .filter(Boolean) as string[];
688
  };
689
-
690
- // support r.refs OR r.references (either name)
691
- const refs = normalizeRefs((r as any).refs ?? (r as any).references);
692
 
 
693
 
694
-
695
  const assistantMessage: Message = {
696
  id: (Date.now() + 1).toString(),
697
  role: "assistant",
@@ -701,9 +708,9 @@ function App() {
701
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
702
  showNextButton: false,
703
  };
704
-
705
  setIsTyping(false);
706
-
707
  setTimeout(() => {
708
  setQuizMessages((prev) => [...prev, assistantMessage]);
709
  setQuizState((prev) => ({ ...prev, waitingForAnswer: true, showNextButton: false }));
@@ -711,7 +718,7 @@ function App() {
711
  } catch (e: any) {
712
  setIsTyping(false);
713
  toast.error(e?.message || "Quiz failed");
714
-
715
  const assistantMessage: Message = {
716
  id: (Date.now() + 1).toString(),
717
  role: "assistant",
@@ -719,19 +726,19 @@ function App() {
719
  timestamp: new Date(),
720
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
721
  };
722
-
723
  setTimeout(() => {
724
  setQuizMessages((prev) => [...prev, assistantMessage]);
725
  }, 50);
726
  }
727
-
728
  return;
729
  }
730
-
731
  setIsTyping(true);
732
  try {
733
  const docType = getCurrentDocTypeForChat();
734
-
735
  const r = await apiChat({
736
  user_id: user.email,
737
  message: effectiveContent,
@@ -739,16 +746,16 @@ function App() {
739
  language_preference: mapLanguagePref(language),
740
  doc_type: docType,
741
  });
742
-
743
  const refs = (r.refs || [])
744
- .map((x) => {
745
  const a = x?.source_file ? String(x.source_file) : "";
746
  const b = x?.section ? String(x.section) : "";
747
  const s = `${a}${a && b ? " — " : ""}${b}`.trim();
748
  return s || null;
749
  })
750
  .filter(Boolean) as string[];
751
-
752
  const assistantMessage: Message = {
753
  id: (Date.now() + 1).toString(),
754
  role: "assistant",
@@ -757,14 +764,14 @@ function App() {
757
  references: refs.length ? refs : undefined,
758
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
759
  };
760
-
761
  setIsTyping(false);
762
-
763
  setTimeout(() => {
764
  if (chatMode === "ask") setAskMessages((prev) => [...prev, assistantMessage]);
765
  else if (chatMode === "review") setReviewMessages((prev) => [...prev, assistantMessage]);
766
  }, 50);
767
-
768
  try {
769
  const ml = await apiMemoryline(user.email);
770
  setMemoryProgress(Math.round((ml.progress_pct ?? 0) * 100));
@@ -774,7 +781,7 @@ function App() {
774
  } catch (e: any) {
775
  setIsTyping(false);
776
  toast.error(e?.message || "Chat failed");
777
-
778
  const assistantMessage: Message = {
779
  id: (Date.now() + 1).toString(),
780
  role: "assistant",
@@ -782,7 +789,7 @@ function App() {
782
  timestamp: new Date(),
783
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
784
  };
785
-
786
  setTimeout(() => {
787
  if (chatMode === "ask") setAskMessages((prev) => [...prev, assistantMessage]);
788
  if (chatMode === "review") setReviewMessages((prev) => [...prev, assistantMessage]);
@@ -823,7 +830,7 @@ function App() {
823
  });
824
 
825
  const refs = (r.refs || [])
826
- .map((x) => {
827
  const a = x?.source_file ? String(x.source_file) : "";
828
  const b = x?.section ? String(x.section) : "";
829
  const s = `${a}${a && b ? " — " : ""}${b}`.trim();
@@ -883,7 +890,7 @@ function App() {
883
  });
884
 
885
  const refs = (r.refs || [])
886
- .map((x) => {
887
  const a = x?.source_file ? String(x.source_file) : "";
888
  const b = x?.section ? String(x.section) : "";
889
  const s = `${a}${a && b ? " — " : ""}${b}`.trim();
@@ -926,26 +933,22 @@ function App() {
926
  // =========================
927
  // File Upload (FIXED)
928
  // =========================
929
- // 1) Upload: accept File[] OR FileList OR null
930
 
931
  const handleFileUpload = async (input: File[] | FileList | null | undefined) => {
932
  const files = Array.isArray(input) ? input : input ? Array.from(input) : [];
933
  if (!files.length) return;
934
-
935
  const newFiles: UploadedFile[] = files.map((file) => ({ file, type: "other" as FileType }));
936
-
937
- // update UI immediately
938
  setUploadedFiles((prev) => [...prev, ...newFiles]);
939
-
940
- // if not logged in (shouldn't happen), skip backend upload
941
  if (!user) return;
942
-
943
- // upload to backend right away (default as "Other Course Document")
944
  for (const f of files) {
945
  const fp = `${f.name}::${f.size}::${f.lastModified}`;
946
  if (uploadedFingerprintsRef.current.has(fp)) continue;
947
  uploadedFingerprintsRef.current.add(fp);
948
-
949
  try {
950
  await apiUpload({
951
  user_id: user.email,
@@ -955,19 +958,17 @@ function App() {
955
  toast.success(`File uploaded: ${f.name}`);
956
  } catch (e: any) {
957
  toast.error(e?.message || `Upload failed: ${f.name}`);
958
- // allow retry if failed
959
  uploadedFingerprintsRef.current.delete(fp);
960
  }
961
  }
962
  };
963
-
964
- // 2) Remove: accept index OR File OR UploadedFile (use any to avoid prop signature mismatch crash)
965
  const handleRemoveFile = (arg: any) => {
966
  setUploadedFiles((prev) => {
967
  if (!prev.length) return prev;
968
-
969
  let idx = -1;
970
-
971
  if (typeof arg === "number") {
972
  idx = arg;
973
  } else {
@@ -977,46 +978,41 @@ function App() {
977
  : arg instanceof File
978
  ? (arg as File)
979
  : null;
980
-
981
  if (file) {
982
  idx = prev.findIndex(
983
  (x) =>
984
- x.file.name === file.name &&
985
- x.file.size === file.size &&
986
- x.file.lastModified === file.lastModified
987
  );
988
  }
989
  }
990
-
991
  if (idx < 0 || idx >= prev.length) return prev;
992
-
993
  const removed = prev[idx]?.file;
994
  const next = prev.filter((_, i) => i !== idx);
995
-
996
  if (removed) {
997
  const fp = `${removed.name}::${removed.size}::${removed.lastModified}`;
998
  uploadedFingerprintsRef.current.delete(fp);
999
  }
1000
-
1001
  return next;
1002
  });
1003
  };
1004
-
1005
- // 3) Type Change: must be async, and must NOT leave "await" outside
1006
  const handleFileTypeChange = async (index: number, type: FileType) => {
1007
  if (!user) return;
1008
-
1009
  const target = uploadedFiles[index]?.file;
1010
  if (!target) return;
1011
-
1012
- // update UI state immediately
1013
  setUploadedFiles((prev) => prev.map((f, i) => (i === index ? { ...f, type } : f)));
1014
-
1015
- // dedupe upload per file fingerprint
1016
  const fp = `${target.name}::${target.size}::${target.lastModified}`;
1017
  if (uploadedFingerprintsRef.current.has(fp)) return;
1018
  uploadedFingerprintsRef.current.add(fp);
1019
-
1020
  try {
1021
  await apiUpload({
1022
  user_id: user.email,
@@ -1026,7 +1022,6 @@ function App() {
1026
  toast.success("File uploaded to backend");
1027
  } catch (e: any) {
1028
  toast.error(e?.message || "Upload failed");
1029
- // allow retry if failed
1030
  uploadedFingerprintsRef.current.delete(fp);
1031
  }
1032
  };
@@ -1041,11 +1036,7 @@ function App() {
1041
 
1042
  return chat.messages.every((savedMsg, idx) => {
1043
  const currentMsg = messages[idx];
1044
- return (
1045
- savedMsg.id === currentMsg.id &&
1046
- savedMsg.role === currentMsg.role &&
1047
- savedMsg.content === currentMsg.content
1048
- );
1049
  });
1050
  }) || null
1051
  );
@@ -1074,9 +1065,7 @@ function App() {
1074
  return;
1075
  }
1076
 
1077
- const title = `Chat - ${
1078
- chatMode === "ask" ? "Ask" : chatMode === "review" ? "Review" : "Quiz"
1079
- } - ${new Date().toLocaleDateString()}`;
1080
 
1081
  const newChat: SavedChat = {
1082
  id: Date.now().toString(),
@@ -1281,19 +1270,25 @@ function App() {
1281
  localStorage.setItem("reviewBannerDismissed", "true");
1282
  };
1283
 
 
1284
  const handleLogin = (newUser: User) => {
1285
- setUser(newUser);
1286
- setShowOnboarding(true);
 
1287
  };
1288
 
1289
  const handleOnboardingComplete = (updatedUser: User) => {
1290
- setUser(updatedUser);
1291
  setShowOnboarding(false);
1292
  };
1293
 
1294
- const handleOnboardingSkip = () => setShowOnboarding(false);
 
 
 
1295
 
1296
  if (!user) return <LoginScreen onLogin={handleLogin} />;
 
1297
  if (showOnboarding && user)
1298
  return <Onboarding user={user} onComplete={handleOnboardingComplete} onSkip={handleOnboardingSkip} />;
1299
 
@@ -1317,7 +1312,7 @@ function App() {
1317
  onCreateWorkspace={handleCreateWorkspace}
1318
  onLogout={() => setUser(null)}
1319
  availableCourses={availableCourses}
1320
- onUserUpdate={setUser}
1321
  reviewStarOpacity={reviewStarOpacity}
1322
  reviewEnergyPct={reviewEnergyPct}
1323
  onStarClick={() => {
@@ -1329,7 +1324,7 @@ function App() {
1329
  </div>
1330
 
1331
  {showProfileEditor && user && (
1332
- <ProfileEditor user={user} onSave={setUser} onClose={() => setShowProfileEditor(false)} />
1333
  )}
1334
 
1335
  {showReviewBanner && (
@@ -1486,10 +1481,9 @@ function App() {
1486
  showReviewBanner={showReviewBanner}
1487
  onReviewActivity={handleReviewActivity}
1488
  currentUserId={user?.email}
1489
- docType={"Syllabus"}
1490
- onProfileBioUpdate={(bio) => {
1491
- setUser((prev) => (prev ? { ...prev, bio } : prev));
1492
- }}
1493
  />
1494
  </div>
1495
  </main>
 
1
  // web/src/App.tsx
2
  import React, { useState, useEffect, useRef, useMemo } from "react";
3
  import { Header } from "./components/Header";
 
4
  import { ChatArea } from "./components/ChatArea";
5
  import { LoginScreen } from "./components/LoginScreen";
6
  import { ProfileEditor } from "./components/ProfileEditor";
 
44
  }
45
 
46
  export interface User {
47
+ // required identity
48
  name: string;
49
  email: string;
50
+
51
+ // profile fields
52
+ studentId?: string;
53
+ department?: string;
54
+ yearLevel?: string;
55
+ major?: string;
56
+ bio?: string; // may be generated by Clare, then user can edit in ProfileEditor
57
+
58
+ // learning preferences
59
+ learningStyle?: string; // "visual" | "auditory" | ...
60
+ learningPace?: string; // "slow" | "moderate" | "fast"
61
+
62
+ // avatar
63
+ avatarUrl?: string;
64
+
65
+ // control flags
66
+ onboardingCompleted?: boolean;
67
  }
68
 
69
  export interface GroupMember {
 
165
  .filter(Boolean) as SavedChat[];
166
  }
167
 
168
+ // ✅ localStorage helpers for user profile
169
+ function profileStorageKey(email: string) {
170
+ return `user_profile::${email}`;
171
+ }
172
+
173
+ function hydrateUserFromStorage(base: User): User {
174
+ try {
175
+ const raw = localStorage.getItem(profileStorageKey(base.email));
176
+ if (!raw) return base;
177
+ const saved = JSON.parse(raw) as Partial<User>;
178
+ return { ...base, ...saved };
179
+ } catch {
180
+ return base;
181
+ }
182
+ }
183
+
184
  function App() {
185
  const [isDarkMode, setIsDarkMode] = useState(() => {
186
  const saved = localStorage.getItem("theme");
 
189
 
190
  const [user, setUser] = useState<User | null>(null);
191
 
192
+ // ✅ unified user update helpers
193
+ const updateUser = (patch: Partial<User>) => {
194
+ setUser((prev) => (prev ? { ...prev, ...patch } : prev));
195
+ };
196
+
197
+ const handleUserSave = (next: User) => {
198
+ setUser((prev) => {
199
+ if (!prev) return next;
200
+ return {
201
+ ...prev,
202
+ ...next,
203
+ onboardingCompleted: next.onboardingCompleted ?? prev.onboardingCompleted,
204
+ };
205
+ });
206
+ };
207
+
208
+ // ✅ persist user profile whenever it changes (per-email)
209
+ useEffect(() => {
210
+ if (!user?.email) return;
211
+ try {
212
+ localStorage.setItem(profileStorageKey(user.email), JSON.stringify(user));
213
+ } catch {
214
+ // ignore
215
+ }
216
+ }, [user]);
217
+
218
  // -------------------------
219
  // ✅ Course selection (stable)
220
  // -------------------------
 
621
  const reviewStarOpacity = starOpacity(reviewStarState);
622
  const reviewEnergyPct = energyPct(reviewStarState);
623
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
624
  const getCurrentDocTypeForChat = (): string => {
625
  if (uploadedFiles.length > 0) {
626
  const last = uploadedFiles[uploadedFiles.length - 1];
 
629
  return "Syllabus";
630
  };
631
 
 
632
  const handleSendMessage = async (content: string) => {
633
  if (!user) return;
634
+
635
  const hasText = !!content.trim();
636
  const hasFiles = uploadedFiles.length > 0;
637
+
 
638
  if (!hasText && !hasFiles) return;
639
+
 
640
  const fileNames = hasFiles ? uploadedFiles.map((f) => f.file.name) : [];
641
  const fileLine = fileNames.length ? `Uploaded files: ${fileNames.join(", ")}` : "";
642
+
643
  const effectiveContent = hasText
644
  ? content
645
  : `I've uploaded file(s). Please read them and help me based on their content.\n${fileLine}`.trim();
646
+
647
  const sender: GroupMember = {
648
  id: user.email,
649
  name: user.name,
650
  email: user.email,
651
  avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`,
652
  };
653
+
 
654
  const userVisibleContent = hasText
655
  ? content
656
  : `📎 Sent ${fileNames.length} file(s)\n${fileNames.map((n) => `- ${n}`).join("\n")}`;
657
+
658
  const userMessage: Message = {
659
  id: Date.now().toString(),
660
  role: "user",
 
662
  timestamp: new Date(),
663
  sender,
664
  };
665
+
666
  if (chatMode === "ask") setAskMessages((prev) => [...prev, userMessage]);
667
  else if (chatMode === "review") setReviewMessages((prev) => [...prev, userMessage]);
668
  else setQuizMessages((prev) => [...prev, userMessage]);
669
+
670
  if (chatMode === "quiz") {
671
  setIsTyping(true);
672
+
673
  try {
674
  const docType = getCurrentDocTypeForChat();
675
+
676
  const r = await apiChat({
677
  user_id: user.email,
678
  message: effectiveContent,
 
685
  const arr = Array.isArray(raw) ? raw : [];
686
  return arr
687
  .map((x) => {
 
688
  if (typeof x === "string") {
689
  const s = x.trim();
690
  return s ? s : null;
691
  }
 
 
692
  const a = x?.source_file ? String(x.source_file) : "";
693
  const b = x?.section ? String(x.section) : "";
694
  const s = `${a}${a && b ? " — " : ""}${b}`.trim();
 
696
  })
697
  .filter(Boolean) as string[];
698
  };
 
 
 
699
 
700
+ const refs = normalizeRefs((r as any).refs ?? (r as any).references);
701
 
 
702
  const assistantMessage: Message = {
703
  id: (Date.now() + 1).toString(),
704
  role: "assistant",
 
708
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
709
  showNextButton: false,
710
  };
711
+
712
  setIsTyping(false);
713
+
714
  setTimeout(() => {
715
  setQuizMessages((prev) => [...prev, assistantMessage]);
716
  setQuizState((prev) => ({ ...prev, waitingForAnswer: true, showNextButton: false }));
 
718
  } catch (e: any) {
719
  setIsTyping(false);
720
  toast.error(e?.message || "Quiz failed");
721
+
722
  const assistantMessage: Message = {
723
  id: (Date.now() + 1).toString(),
724
  role: "assistant",
 
726
  timestamp: new Date(),
727
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
728
  };
729
+
730
  setTimeout(() => {
731
  setQuizMessages((prev) => [...prev, assistantMessage]);
732
  }, 50);
733
  }
734
+
735
  return;
736
  }
737
+
738
  setIsTyping(true);
739
  try {
740
  const docType = getCurrentDocTypeForChat();
741
+
742
  const r = await apiChat({
743
  user_id: user.email,
744
  message: effectiveContent,
 
746
  language_preference: mapLanguagePref(language),
747
  doc_type: docType,
748
  });
749
+
750
  const refs = (r.refs || [])
751
+ .map((x: any) => {
752
  const a = x?.source_file ? String(x.source_file) : "";
753
  const b = x?.section ? String(x.section) : "";
754
  const s = `${a}${a && b ? " — " : ""}${b}`.trim();
755
  return s || null;
756
  })
757
  .filter(Boolean) as string[];
758
+
759
  const assistantMessage: Message = {
760
  id: (Date.now() + 1).toString(),
761
  role: "assistant",
 
764
  references: refs.length ? refs : undefined,
765
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
766
  };
767
+
768
  setIsTyping(false);
769
+
770
  setTimeout(() => {
771
  if (chatMode === "ask") setAskMessages((prev) => [...prev, assistantMessage]);
772
  else if (chatMode === "review") setReviewMessages((prev) => [...prev, assistantMessage]);
773
  }, 50);
774
+
775
  try {
776
  const ml = await apiMemoryline(user.email);
777
  setMemoryProgress(Math.round((ml.progress_pct ?? 0) * 100));
 
781
  } catch (e: any) {
782
  setIsTyping(false);
783
  toast.error(e?.message || "Chat failed");
784
+
785
  const assistantMessage: Message = {
786
  id: (Date.now() + 1).toString(),
787
  role: "assistant",
 
789
  timestamp: new Date(),
790
  sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
791
  };
792
+
793
  setTimeout(() => {
794
  if (chatMode === "ask") setAskMessages((prev) => [...prev, assistantMessage]);
795
  if (chatMode === "review") setReviewMessages((prev) => [...prev, assistantMessage]);
 
830
  });
831
 
832
  const refs = (r.refs || [])
833
+ .map((x: any) => {
834
  const a = x?.source_file ? String(x.source_file) : "";
835
  const b = x?.section ? String(x.section) : "";
836
  const s = `${a}${a && b ? " — " : ""}${b}`.trim();
 
890
  });
891
 
892
  const refs = (r.refs || [])
893
+ .map((x: any) => {
894
  const a = x?.source_file ? String(x.source_file) : "";
895
  const b = x?.section ? String(x.section) : "";
896
  const s = `${a}${a && b ? " — " : ""}${b}`.trim();
 
933
  // =========================
934
  // File Upload (FIXED)
935
  // =========================
 
936
 
937
  const handleFileUpload = async (input: File[] | FileList | null | undefined) => {
938
  const files = Array.isArray(input) ? input : input ? Array.from(input) : [];
939
  if (!files.length) return;
940
+
941
  const newFiles: UploadedFile[] = files.map((file) => ({ file, type: "other" as FileType }));
942
+
 
943
  setUploadedFiles((prev) => [...prev, ...newFiles]);
944
+
 
945
  if (!user) return;
946
+
 
947
  for (const f of files) {
948
  const fp = `${f.name}::${f.size}::${f.lastModified}`;
949
  if (uploadedFingerprintsRef.current.has(fp)) continue;
950
  uploadedFingerprintsRef.current.add(fp);
951
+
952
  try {
953
  await apiUpload({
954
  user_id: user.email,
 
958
  toast.success(`File uploaded: ${f.name}`);
959
  } catch (e: any) {
960
  toast.error(e?.message || `Upload failed: ${f.name}`);
 
961
  uploadedFingerprintsRef.current.delete(fp);
962
  }
963
  }
964
  };
965
+
 
966
  const handleRemoveFile = (arg: any) => {
967
  setUploadedFiles((prev) => {
968
  if (!prev.length) return prev;
969
+
970
  let idx = -1;
971
+
972
  if (typeof arg === "number") {
973
  idx = arg;
974
  } else {
 
978
  : arg instanceof File
979
  ? (arg as File)
980
  : null;
981
+
982
  if (file) {
983
  idx = prev.findIndex(
984
  (x) =>
985
+ x.file.name === file.name && x.file.size === file.size && x.file.lastModified === file.lastModified
 
 
986
  );
987
  }
988
  }
989
+
990
  if (idx < 0 || idx >= prev.length) return prev;
991
+
992
  const removed = prev[idx]?.file;
993
  const next = prev.filter((_, i) => i !== idx);
994
+
995
  if (removed) {
996
  const fp = `${removed.name}::${removed.size}::${removed.lastModified}`;
997
  uploadedFingerprintsRef.current.delete(fp);
998
  }
999
+
1000
  return next;
1001
  });
1002
  };
1003
+
 
1004
  const handleFileTypeChange = async (index: number, type: FileType) => {
1005
  if (!user) return;
1006
+
1007
  const target = uploadedFiles[index]?.file;
1008
  if (!target) return;
1009
+
 
1010
  setUploadedFiles((prev) => prev.map((f, i) => (i === index ? { ...f, type } : f)));
1011
+
 
1012
  const fp = `${target.name}::${target.size}::${target.lastModified}`;
1013
  if (uploadedFingerprintsRef.current.has(fp)) return;
1014
  uploadedFingerprintsRef.current.add(fp);
1015
+
1016
  try {
1017
  await apiUpload({
1018
  user_id: user.email,
 
1022
  toast.success("File uploaded to backend");
1023
  } catch (e: any) {
1024
  toast.error(e?.message || "Upload failed");
 
1025
  uploadedFingerprintsRef.current.delete(fp);
1026
  }
1027
  };
 
1036
 
1037
  return chat.messages.every((savedMsg, idx) => {
1038
  const currentMsg = messages[idx];
1039
+ return savedMsg.id === currentMsg.id && savedMsg.role === currentMsg.role && savedMsg.content === currentMsg.content;
 
 
 
 
1040
  });
1041
  }) || null
1042
  );
 
1065
  return;
1066
  }
1067
 
1068
+ const title = `Chat - ${chatMode === "ask" ? "Ask" : chatMode === "review" ? "Review" : "Quiz"} - ${new Date().toLocaleDateString()}`;
 
 
1069
 
1070
  const newChat: SavedChat = {
1071
  id: Date.now().toString(),
 
1270
  localStorage.setItem("reviewBannerDismissed", "true");
1271
  };
1272
 
1273
+ // ✅ login: hydrate profile and only show onboarding if not completed
1274
  const handleLogin = (newUser: User) => {
1275
+ const hydrated = hydrateUserFromStorage(newUser);
1276
+ setUser(hydrated);
1277
+ setShowOnboarding(!hydrated.onboardingCompleted);
1278
  };
1279
 
1280
  const handleOnboardingComplete = (updatedUser: User) => {
1281
+ handleUserSave({ ...updatedUser, onboardingCompleted: true });
1282
  setShowOnboarding(false);
1283
  };
1284
 
1285
+ const handleOnboardingSkip = () => {
1286
+ updateUser({ onboardingCompleted: true });
1287
+ setShowOnboarding(false);
1288
+ };
1289
 
1290
  if (!user) return <LoginScreen onLogin={handleLogin} />;
1291
+
1292
  if (showOnboarding && user)
1293
  return <Onboarding user={user} onComplete={handleOnboardingComplete} onSkip={handleOnboardingSkip} />;
1294
 
 
1312
  onCreateWorkspace={handleCreateWorkspace}
1313
  onLogout={() => setUser(null)}
1314
  availableCourses={availableCourses}
1315
+ onUserUpdate={handleUserSave}
1316
  reviewStarOpacity={reviewStarOpacity}
1317
  reviewEnergyPct={reviewEnergyPct}
1318
  onStarClick={() => {
 
1324
  </div>
1325
 
1326
  {showProfileEditor && user && (
1327
+ <ProfileEditor user={user} onSave={handleUserSave} onClose={() => setShowProfileEditor(false)} />
1328
  )}
1329
 
1330
  {showReviewBanner && (
 
1481
  showReviewBanner={showReviewBanner}
1482
  onReviewActivity={handleReviewActivity}
1483
  currentUserId={user?.email}
1484
+ docType={"Syllabus"}
1485
+ // ✅ bio is still allowed to be updated by chat/Clare
1486
+ onProfileBioUpdate={(bio) => updateUser({ bio })}
 
1487
  />
1488
  </div>
1489
  </main>