SarahXia0405 commited on
Commit
e09f320
·
verified ·
1 Parent(s): db0d63e

Update web/src/App.tsx

Browse files
Files changed (1) hide show
  1. web/src/App.tsx +1083 -55
web/src/App.tsx CHANGED
@@ -156,8 +156,7 @@ const DOC_TYPE_MAP: Record<FileType, string> = {
156
  other: "Other Course Document",
157
  };
158
 
159
- // ✅ UI course -> backend course_id mapping
160
- // 你现在后端只有 course_ist345,所以先把 My Space 的 course1 映射过去即可
161
  const BACKEND_COURSE_ID_MAP: Record<string, string> = {
162
  course1: "course_ist345",
163
  course2: "course_ist345",
@@ -171,6 +170,7 @@ function mapLanguagePref(lang: Language): string {
171
  return "Auto";
172
  }
173
 
 
174
  function savedChatsStorageKey(email: string) {
175
  return `saved_chats::${email}`;
176
  }
@@ -197,6 +197,7 @@ function hydrateSavedChats(raw: any): SavedChat[] {
197
  .filter(Boolean) as SavedChat[];
198
  }
199
 
 
200
  function profileStorageKey(email: string) {
201
  return `user_profile::${email}`;
202
  }
@@ -223,6 +224,7 @@ function App() {
223
 
224
  const [user, setUser] = useState<User | null>(null);
225
 
 
226
  const updateUser = (patch: Partial<User>) => {
227
  setUser((prev) => (prev ? { ...prev, ...patch } : prev));
228
  };
@@ -239,6 +241,7 @@ function App() {
239
  });
240
  };
241
 
 
242
  useEffect(() => {
243
  if (!user?.email) return;
244
  try {
@@ -248,12 +251,20 @@ function App() {
248
  }
249
  }, [user]);
250
 
 
 
 
251
  const MYSPACE_COURSE_KEY = "myspace_selected_course";
252
 
253
  const [currentCourseId, setCurrentCourseId] = useState<string>(() => {
254
  return localStorage.getItem(MYSPACE_COURSE_KEY) || "course1";
255
  });
256
 
 
 
 
 
 
257
  const availableCourses: CourseInfo[] = [
258
  {
259
  id: "course1",
@@ -276,7 +287,6 @@ function App() {
276
  email: "emily.zhang@university.edu",
277
  },
278
  },
279
- // ... keep your other courses unchanged
280
  {
281
  id: "course3",
282
  name: "Data Structures",
@@ -351,11 +361,7 @@ function App() {
351
 
352
  const hasUserMessages = currentMessages.some((msg) => msg.role === "user");
353
  const expectedWelcomeId =
354
- chatMode === "ask"
355
- ? "1"
356
- : chatMode === "review"
357
- ? "review-1"
358
- : "quiz-1";
359
  const hasWelcomeMessage = currentMessages.some(
360
  (msg) => msg.id === expectedWelcomeId && msg.role === "assistant"
361
  );
@@ -425,47 +431,289 @@ function App() {
425
 
426
  const [savedItems, setSavedItems] = useState<SavedItem[]>([]);
427
  const [recentlySavedId, setRecentlySavedId] = useState<string | null>(null);
428
- const [savedChats, setSavedChats] = useState<SavedChat[]>([]);
429
 
430
- // backend course_id for current UI course
431
- const backendCourseId = useMemo(() => {
432
- return BACKEND_COURSE_ID_MAP[currentCourseId] || "course_ist345";
433
- }, [currentCourseId]);
434
 
435
- // ✅ IMPORTANT: default doc_type should NOT be Syllabus
436
- // If user didn't upload a file, we want search across course materials.
437
- const getCurrentDocTypeForChat = (): string => {
438
- if (uploadedFiles.length > 0) {
439
- const last = uploadedFiles[uploadedFiles.length - 1];
440
- return DOC_TYPE_MAP[last.type] || "Other Course Document";
 
 
 
 
 
 
 
441
  }
442
- return "All"; // ✅ changed from "Syllabus"
443
- };
444
 
445
- // -------------------- keep the rest of your file as-is --------------------
446
- // The only other changes you need: when calling apiChat/apiQuizStart, pass course_id + the new doc_type.
447
-
448
- // (… keep your existing code above unchanged …)
 
 
 
 
 
 
 
 
449
 
450
- const spaceType: SpaceType = "individual"; // (keep your original derivation here)
451
- const groupMembers: GroupMember[] = [
452
  { id: "clare", name: "Clare AI", email: "clare@ai.assistant", isAI: true },
453
  { id: "1", name: "Sarah Johnson", email: "sarah.j@university.edu" },
454
  { id: "2", name: "Michael Chen", email: "michael.c@university.edu" },
455
  { id: "3", name: "Emma Williams", email: "emma.w@university.edu" },
456
- ];
 
 
 
 
457
 
458
  // ✅ used to prevent duplicate upload per file fingerprint
459
  const uploadedFingerprintsRef = useRef<Set<string>>(new Set());
460
 
461
- // -------------------------
462
- // ✅ send message (only showing the changed parts inside)
463
- // -------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
464
  const handleSendMessage = async (content: string) => {
465
  if (!user) return;
466
 
467
  const hasText = !!content.trim();
468
  const hasFiles = uploadedFiles.length > 0;
 
469
  if (!hasText && !hasFiles) return;
470
 
471
  const fileNames = hasFiles ? uploadedFiles.map((f) => f.file.name) : [];
@@ -486,6 +734,7 @@ function App() {
486
  ? content
487
  : `📎 Sent ${fileNames.length} file(s)\n${fileNames.map((n) => `- ${n}`).join("\n")}`;
488
 
 
489
  const attachmentsSnapshot: MessageAttachment[] = uploadedFiles.map((uf) => {
490
  const lower = uf.file.name.toLowerCase();
491
  const kind: MessageAttachmentKind =
@@ -520,43 +769,104 @@ function App() {
520
  else if (chatMode === "review") setReviewMessages((prev) => [...prev, userMessage]);
521
  else setQuizMessages((prev) => [...prev, userMessage]);
522
 
523
- setIsTyping(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
 
 
525
  try {
526
  const docType = getCurrentDocTypeForChat();
527
 
528
  const r: any = await apiChat({
529
  user_id: user.email,
530
  message: effectiveContent,
531
- learning_mode: chatMode === "quiz" ? "quiz" : learningMode,
532
  language_preference: mapLanguagePref(language),
533
  doc_type: docType,
534
  course_id: backendCourseId, // ✅ NEW
535
  } as any);
536
 
537
- const normalizeRefs = (raw: any): string[] => {
538
- const arr = Array.isArray(raw) ? raw : [];
539
- return arr
540
- .map((x) => {
541
- if (typeof x === "string") return x.trim() || null;
542
- const a = x?.source_file ? String(x.source_file) : "";
543
- const b = x?.section ? String(x.section) : "";
544
- const s = `${a}${a && b ? " — " : ""}${b}`.trim();
545
- return s || null;
546
- })
547
- .filter(Boolean) as string[];
548
- };
549
-
550
- const refs = normalizeRefs(r.refs ?? r.references);
551
 
552
  const assistantMessage: Message = {
553
  id: (Date.now() + 1).toString(),
554
  role: "assistant",
555
  content: r.reply || "",
556
  timestamp: new Date(),
557
- references: refs, // ✅ keep [] so UI shows References(0)
558
- sender: undefined,
559
- showNextButton: false,
560
  };
561
 
562
  setIsTyping(false);
@@ -564,19 +874,737 @@ function App() {
564
  setTimeout(() => {
565
  if (chatMode === "ask") setAskMessages((prev) => [...prev, assistantMessage]);
566
  else if (chatMode === "review") setReviewMessages((prev) => [...prev, assistantMessage]);
567
- else setQuizMessages((prev) => [...prev, assistantMessage]);
568
  }, 50);
569
 
 
 
 
 
 
 
570
  } catch (e: any) {
571
  setIsTyping(false);
572
  toast.error(e?.message || "Chat failed");
 
 
 
 
 
 
 
 
 
 
 
 
 
573
  }
574
  };
575
 
576
- // -------- keep your render/return unchanged (not repeating here) --------
577
- // Because your original file is extremely long, the above shows the essential modifications.
578
- // If you want, paste your current App.tsx into one message and I will return a fully reconstituted full file with no omissions.
579
- return <div />;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
580
  }
581
 
582
  export default App;
 
156
  other: "Other Course Document",
157
  };
158
 
159
+ // ✅ NEW: UI courseId -> backend course_id(你后端 logs 显示的是 course_ist345)
 
160
  const BACKEND_COURSE_ID_MAP: Record<string, string> = {
161
  course1: "course_ist345",
162
  course2: "course_ist345",
 
170
  return "Auto";
171
  }
172
 
173
+ // ✅ localStorage helpers for saved chats
174
  function savedChatsStorageKey(email: string) {
175
  return `saved_chats::${email}`;
176
  }
 
197
  .filter(Boolean) as SavedChat[];
198
  }
199
 
200
+ // ✅ localStorage helpers for user profile
201
  function profileStorageKey(email: string) {
202
  return `user_profile::${email}`;
203
  }
 
224
 
225
  const [user, setUser] = useState<User | null>(null);
226
 
227
+ // ✅ unified user update helpers
228
  const updateUser = (patch: Partial<User>) => {
229
  setUser((prev) => (prev ? { ...prev, ...patch } : prev));
230
  };
 
241
  });
242
  };
243
 
244
+ // ✅ persist user profile whenever it changes (per-email)
245
  useEffect(() => {
246
  if (!user?.email) return;
247
  try {
 
251
  }
252
  }, [user]);
253
 
254
+ // -------------------------
255
+ // ✅ Course selection (stable)
256
+ // -------------------------
257
  const MYSPACE_COURSE_KEY = "myspace_selected_course";
258
 
259
  const [currentCourseId, setCurrentCourseId] = useState<string>(() => {
260
  return localStorage.getItem(MYSPACE_COURSE_KEY) || "course1";
261
  });
262
 
263
+ // ✅ NEW: computed backend course id
264
+ const backendCourseId = useMemo(() => {
265
+ return BACKEND_COURSE_ID_MAP[currentCourseId] || "course_ist345";
266
+ }, [currentCourseId]);
267
+
268
  const availableCourses: CourseInfo[] = [
269
  {
270
  id: "course1",
 
287
  email: "emily.zhang@university.edu",
288
  },
289
  },
 
290
  {
291
  id: "course3",
292
  name: "Data Structures",
 
361
 
362
  const hasUserMessages = currentMessages.some((msg) => msg.role === "user");
363
  const expectedWelcomeId =
364
+ chatMode === "ask" ? "1" : chatMode === "review" ? "review-1" : "quiz-1";
 
 
 
 
365
  const hasWelcomeMessage = currentMessages.some(
366
  (msg) => msg.id === expectedWelcomeId && msg.role === "assistant"
367
  );
 
431
 
432
  const [savedItems, setSavedItems] = useState<SavedItem[]>([]);
433
  const [recentlySavedId, setRecentlySavedId] = useState<string | null>(null);
 
434
 
435
+ const [savedChats, setSavedChats] = useState<SavedChat[]>([]);
 
 
 
436
 
437
+ // ✅ load saved chats after login
438
+ useEffect(() => {
439
+ if (!user?.email) return;
440
+ try {
441
+ const raw = localStorage.getItem(savedChatsStorageKey(user.email));
442
+ if (!raw) {
443
+ setSavedChats([]);
444
+ return;
445
+ }
446
+ const parsed = JSON.parse(raw);
447
+ setSavedChats(hydrateSavedChats(parsed));
448
+ } catch {
449
+ setSavedChats([]);
450
  }
451
+ }, [user?.email]);
 
452
 
453
+ // persist saved chats whenever changed
454
+ useEffect(() => {
455
+ if (!user?.email) return;
456
+ try {
457
+ localStorage.setItem(
458
+ savedChatsStorageKey(user.email),
459
+ JSON.stringify(savedChats)
460
+ );
461
+ } catch {
462
+ // ignore
463
+ }
464
+ }, [savedChats, user?.email]);
465
 
466
+ const [groupMembers] = useState<GroupMember[]>([
 
467
  { id: "clare", name: "Clare AI", email: "clare@ai.assistant", isAI: true },
468
  { id: "1", name: "Sarah Johnson", email: "sarah.j@university.edu" },
469
  { id: "2", name: "Michael Chen", email: "michael.c@university.edu" },
470
  { id: "3", name: "Emma Williams", email: "emma.w@university.edu" },
471
+ ]);
472
+
473
+ const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
474
+ const [currentWorkspaceId, setCurrentWorkspaceId] =
475
+ useState<string>("individual");
476
 
477
  // ✅ used to prevent duplicate upload per file fingerprint
478
  const uploadedFingerprintsRef = useRef<Set<string>>(new Set());
479
 
480
+ useEffect(() => {
481
+ if (user) {
482
+ const userAvatar = `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(
483
+ user.email
484
+ )}`;
485
+ const course1Info = availableCourses.find((c) => c.id === "course1");
486
+ const course2Info = availableCourses.find((c) => c.name === "AI Ethics"); // may be undefined, that's OK
487
+
488
+ setWorkspaces([
489
+ { id: "individual", name: "My Space", type: "individual", avatar: userAvatar },
490
+ {
491
+ id: "group-1",
492
+ name: "CS 101 Study Group",
493
+ type: "group",
494
+ avatar: "https://api.dicebear.com/7.x/shapes/svg?seed=cs101group",
495
+ members: groupMembers,
496
+ category: "course",
497
+ courseName: course1Info?.name || "CS 101",
498
+ courseInfo: course1Info,
499
+ },
500
+ {
501
+ id: "group-2",
502
+ name: "AI Ethics Team",
503
+ type: "group",
504
+ avatar: "https://api.dicebear.com/7.x/shapes/svg?seed=aiethicsteam",
505
+ members: groupMembers,
506
+ category: "course",
507
+ courseName: course2Info?.name || "AI Ethics",
508
+ courseInfo: course2Info,
509
+ },
510
+ ]);
511
+ }
512
+ }, [user, groupMembers, availableCourses]);
513
+
514
+ const fallbackWorkspace: Workspace = {
515
+ id: "individual",
516
+ name: "My Space",
517
+ type: "individual",
518
+ avatar: user
519
+ ? `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(
520
+ user.email
521
+ )}`
522
+ : "",
523
+ };
524
+
525
+ const currentWorkspace: Workspace =
526
+ workspaces.find((w) => w.id === currentWorkspaceId) ||
527
+ workspaces[0] ||
528
+ fallbackWorkspace;
529
+
530
+ const spaceType: SpaceType = currentWorkspace?.type || "individual";
531
+
532
+ // =========================
533
+ // ✅ Scheme 1: "My Space" uses Group-like sidebar view model
534
+ // =========================
535
+ const mySpaceCourseInfo = useMemo(() => {
536
+ return availableCourses.find((c) => c.id === currentCourseId);
537
+ }, [availableCourses, currentCourseId]);
538
+
539
+ const mySpaceUserMember: GroupMember | null = useMemo(() => {
540
+ if (!user) return null;
541
+ return {
542
+ id: user.email,
543
+ name: user.name,
544
+ email: user.email,
545
+ avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(
546
+ user.email
547
+ )}`,
548
+ };
549
+ }, [user]);
550
+
551
+ const clareMember: GroupMember = useMemo(
552
+ () => ({ id: "clare", name: "Clare AI", email: "clare@ai.assistant", isAI: true }),
553
+ []
554
+ );
555
+
556
+ const sidebarWorkspaces: Workspace[] = useMemo(() => {
557
+ if (!workspaces?.length) return workspaces;
558
+ if (!mySpaceUserMember) return workspaces;
559
+
560
+ return workspaces.map((w) => {
561
+ if (w.id !== "individual") return w;
562
+
563
+ return {
564
+ ...w,
565
+ category: "course",
566
+ courseName: mySpaceCourseInfo?.name || w.courseName || "My Course",
567
+ courseInfo: mySpaceCourseInfo,
568
+ members: [clareMember, mySpaceUserMember],
569
+ };
570
+ });
571
+ }, [workspaces, mySpaceCourseInfo, mySpaceUserMember, clareMember]);
572
+
573
+ const sidebarSpaceType: SpaceType = useMemo(() => {
574
+ return currentWorkspaceId === "individual" ? "group" : spaceType;
575
+ }, [currentWorkspaceId, spaceType]);
576
+
577
+ const sidebarGroupMembers: GroupMember[] = useMemo(() => {
578
+ if (currentWorkspaceId === "individual" && mySpaceUserMember) {
579
+ return [clareMember, mySpaceUserMember];
580
+ }
581
+ return groupMembers;
582
+ }, [currentWorkspaceId, mySpaceUserMember, clareMember, groupMembers]);
583
+
584
+ // =========================
585
+ // ✅ Stable course switching logic
586
+ // =========================
587
+ const didHydrateMySpaceRef = useRef(false);
588
+
589
+ const handleCourseChange = (nextCourseId: string) => {
590
+ if (!nextCourseId) return;
591
+
592
+ if (
593
+ currentWorkspace.type === "group" &&
594
+ currentWorkspace.category === "course"
595
+ ) {
596
+ return;
597
+ }
598
+
599
+ setCurrentCourseId(nextCourseId);
600
+ try {
601
+ localStorage.setItem(MYSPACE_COURSE_KEY, nextCourseId);
602
+ } catch {
603
+ // ignore
604
+ }
605
+ };
606
+
607
+ useEffect(() => {
608
+ if (!currentWorkspace) return;
609
+
610
+ if (currentWorkspace.type === "group" && currentWorkspace.category === "course") {
611
+ const cid = currentWorkspace.courseInfo?.id;
612
+ if (cid && cid !== currentCourseId) setCurrentCourseId(cid);
613
+ didHydrateMySpaceRef.current = false;
614
+ return;
615
+ }
616
+
617
+ if (currentWorkspace.type === "individual") {
618
+ if (!didHydrateMySpaceRef.current) {
619
+ didHydrateMySpaceRef.current = true;
620
+
621
+ const saved = localStorage.getItem(MYSPACE_COURSE_KEY);
622
+ const valid = saved && availableCourses.some((c) => c.id === saved) ? saved : undefined;
623
+
624
+ const next = valid || currentCourseId || "course1";
625
+ if (next !== currentCourseId) setCurrentCourseId(next);
626
+ }
627
+ }
628
+ }, [
629
+ currentWorkspaceId,
630
+ currentWorkspace?.type,
631
+ currentWorkspace?.category,
632
+ currentWorkspace?.courseInfo?.id,
633
+ availableCourses,
634
+ currentCourseId,
635
+ currentWorkspace,
636
+ ]);
637
+
638
+ useEffect(() => {
639
+ if (currentWorkspace?.type !== "individual") return;
640
+ try {
641
+ const prev = localStorage.getItem(MYSPACE_COURSE_KEY);
642
+ if (prev !== currentCourseId) localStorage.setItem(MYSPACE_COURSE_KEY, currentCourseId);
643
+ } catch {
644
+ // ignore
645
+ }
646
+ }, [currentCourseId, currentWorkspace?.type]);
647
+
648
+ useEffect(() => {
649
+ document.documentElement.classList.toggle("dark", isDarkMode);
650
+ localStorage.setItem("theme", isDarkMode ? "dark" : "light");
651
+ }, [isDarkMode]);
652
+
653
+ useEffect(() => {
654
+ const prev = document.body.style.overflow;
655
+ document.body.style.overflow = "hidden";
656
+ return () => {
657
+ document.body.style.overflow = prev;
658
+ };
659
+ }, []);
660
+
661
+ useEffect(() => {
662
+ if (!user) return;
663
+
664
+ (async () => {
665
+ try {
666
+ const r = await apiMemoryline(user.email);
667
+ const pct = Math.round((r.progress_pct ?? 0) * 100);
668
+ setMemoryProgress(pct);
669
+ } catch {
670
+ // silent
671
+ }
672
+ })();
673
+ }, [user]);
674
+
675
+ // =========================
676
+ // ✅ Review Star (按天) state
677
+ // =========================
678
+ const reviewStarKey = useMemo(() => {
679
+ if (!user) return "";
680
+ return `review_star::${user.email}::${currentWorkspaceId}`;
681
+ }, [user, currentWorkspaceId]);
682
+
683
+ const [reviewStarState, setReviewStarState] = useState<ReviewStarState | null>(null);
684
+
685
+ useEffect(() => {
686
+ if (!user || !reviewStarKey) return;
687
+ if (chatMode !== "review") return;
688
+
689
+ const next = normalizeToday(reviewStarKey);
690
+ setReviewStarState(next);
691
+ }, [chatMode, reviewStarKey, user]);
692
+
693
+ const handleReviewActivity = (event: ReviewEventType) => {
694
+ if (!user || !reviewStarKey) return;
695
+ const next = markReviewActive(reviewStarKey, event);
696
+ setReviewStarState(next);
697
+ };
698
+
699
+ const reviewStarOpacity = starOpacity(reviewStarState);
700
+ const reviewEnergyPct = energyPct(reviewStarState);
701
+
702
+ // ✅ FIX: default doc_type should NOT be Syllabus
703
+ const getCurrentDocTypeForChat = (): string => {
704
+ if (uploadedFiles.length > 0) {
705
+ const last = uploadedFiles[uploadedFiles.length - 1];
706
+ return DOC_TYPE_MAP[last.type] || "Other Course Document";
707
+ }
708
+ return "All"; // ✅ IMPORTANT
709
+ };
710
+
711
  const handleSendMessage = async (content: string) => {
712
  if (!user) return;
713
 
714
  const hasText = !!content.trim();
715
  const hasFiles = uploadedFiles.length > 0;
716
+
717
  if (!hasText && !hasFiles) return;
718
 
719
  const fileNames = hasFiles ? uploadedFiles.map((f) => f.file.name) : [];
 
734
  ? content
735
  : `📎 Sent ${fileNames.length} file(s)\n${fileNames.map((n) => `- ${n}`).join("\n")}`;
736
 
737
+ // ✅ snapshot attachments at send-time
738
  const attachmentsSnapshot: MessageAttachment[] = uploadedFiles.map((uf) => {
739
  const lower = uf.file.name.toLowerCase();
740
  const kind: MessageAttachmentKind =
 
769
  else if (chatMode === "review") setReviewMessages((prev) => [...prev, userMessage]);
770
  else setQuizMessages((prev) => [...prev, userMessage]);
771
 
772
+ if (chatMode === "quiz") {
773
+ setIsTyping(true);
774
+
775
+ try {
776
+ const docType = getCurrentDocTypeForChat();
777
+
778
+ const r: any = await apiChat({
779
+ user_id: user.email,
780
+ message: effectiveContent,
781
+ learning_mode: "quiz",
782
+ language_preference: mapLanguagePref(language),
783
+ doc_type: docType,
784
+ course_id: backendCourseId, // ✅ NEW
785
+ } as any);
786
+
787
+ const normalizeRefs = (raw: any): string[] => {
788
+ const arr = Array.isArray(raw) ? raw : [];
789
+ return arr
790
+ .map((x) => {
791
+ if (typeof x === "string") {
792
+ const s = x.trim();
793
+ return s ? s : null;
794
+ }
795
+ const a = x?.source_file ? String(x.source_file) : "";
796
+ const b = x?.section ? String(x.section) : "";
797
+ const s = `${a}${a && b ? " — " : ""}${b}`.trim();
798
+ return s || null;
799
+ })
800
+ .filter(Boolean) as string[];
801
+ };
802
+
803
+ const refs = normalizeRefs((r as any).refs ?? (r as any).references);
804
+
805
+ const assistantMessage: Message = {
806
+ id: (Date.now() + 1).toString(),
807
+ role: "assistant",
808
+ content: r.reply || "",
809
+ timestamp: new Date(),
810
+ references: refs, // ✅ allow []
811
+ sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
812
+ showNextButton: false,
813
+ };
814
+
815
+ setIsTyping(false);
816
+
817
+ setTimeout(() => {
818
+ setQuizMessages((prev) => [...prev, assistantMessage]);
819
+ setQuizState((prev) => ({ ...prev, waitingForAnswer: true, showNextButton: false }));
820
+ }, 50);
821
+ } catch (e: any) {
822
+ setIsTyping(false);
823
+ toast.error(e?.message || "Quiz failed");
824
+
825
+ const assistantMessage: Message = {
826
+ id: (Date.now() + 1).toString(),
827
+ role: "assistant",
828
+ content: "Sorry — quiz request failed. Please try again.",
829
+ timestamp: new Date(),
830
+ sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
831
+ };
832
+
833
+ setTimeout(() => {
834
+ setQuizMessages((prev) => [...prev, assistantMessage]);
835
+ }, 50);
836
+ }
837
+
838
+ return;
839
+ }
840
 
841
+ setIsTyping(true);
842
  try {
843
  const docType = getCurrentDocTypeForChat();
844
 
845
  const r: any = await apiChat({
846
  user_id: user.email,
847
  message: effectiveContent,
848
+ learning_mode: learningMode,
849
  language_preference: mapLanguagePref(language),
850
  doc_type: docType,
851
  course_id: backendCourseId, // ✅ NEW
852
  } as any);
853
 
854
+ const refs = (r.refs || [])
855
+ .map((x: any) => {
856
+ const a = x?.source_file ? String(x.source_file) : "";
857
+ const b = x?.section ? String(x.section) : "";
858
+ const s = `${a}${a && b ? "" : ""}${b}`.trim();
859
+ return s || null;
860
+ })
861
+ .filter(Boolean) as string[];
 
 
 
 
 
 
862
 
863
  const assistantMessage: Message = {
864
  id: (Date.now() + 1).toString(),
865
  role: "assistant",
866
  content: r.reply || "",
867
  timestamp: new Date(),
868
+ references: refs, // ✅ allow []
869
+ sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
 
870
  };
871
 
872
  setIsTyping(false);
 
874
  setTimeout(() => {
875
  if (chatMode === "ask") setAskMessages((prev) => [...prev, assistantMessage]);
876
  else if (chatMode === "review") setReviewMessages((prev) => [...prev, assistantMessage]);
 
877
  }, 50);
878
 
879
+ try {
880
+ const ml = await apiMemoryline(user.email);
881
+ setMemoryProgress(Math.round((ml.progress_pct ?? 0) * 100));
882
+ } catch {
883
+ // ignore
884
+ }
885
  } catch (e: any) {
886
  setIsTyping(false);
887
  toast.error(e?.message || "Chat failed");
888
+
889
+ const assistantMessage: Message = {
890
+ id: (Date.now() + 1).toString(),
891
+ role: "assistant",
892
+ content: "Sorry — chat request failed. Please try again.",
893
+ timestamp: new Date(),
894
+ sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
895
+ };
896
+
897
+ setTimeout(() => {
898
+ if (chatMode === "ask") setAskMessages((prev) => [...prev, assistantMessage]);
899
+ if (chatMode === "review") setReviewMessages((prev) => [...prev, assistantMessage]);
900
+ }, 50);
901
  }
902
  };
903
 
904
+ const handleNextQuestion = async () => {
905
+ if (!user) return;
906
+
907
+ const prompt = "Please give me another question of the same quiz style.";
908
+ const sender: GroupMember = {
909
+ id: user.email,
910
+ name: user.name,
911
+ email: user.email,
912
+ avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`,
913
+ };
914
+
915
+ const userMessage: Message = {
916
+ id: Date.now().toString(),
917
+ role: "user",
918
+ content: prompt,
919
+ timestamp: new Date(),
920
+ sender,
921
+ };
922
+
923
+ setQuizMessages((prev) => [...prev, userMessage]);
924
+ setIsTyping(true);
925
+
926
+ try {
927
+ const docType = getCurrentDocTypeForChat();
928
+ const r: any = await apiChat({
929
+ user_id: user.email,
930
+ message: prompt,
931
+ learning_mode: "quiz",
932
+ language_preference: mapLanguagePref(language),
933
+ doc_type: docType,
934
+ course_id: backendCourseId, // ✅ NEW
935
+ } as any);
936
+
937
+ const refs = (r.refs || [])
938
+ .map((x: any) => {
939
+ const a = x?.source_file ? String(x.source_file) : "";
940
+ const b = x?.section ? String(x.section) : "";
941
+ const s = `${a}${a && b ? " — " : ""}${b}`.trim();
942
+ return s || null;
943
+ })
944
+ .filter(Boolean) as string[];
945
+
946
+ const assistantMessage: Message = {
947
+ id: (Date.now() + 1).toString(),
948
+ role: "assistant",
949
+ content: r.reply || "",
950
+ timestamp: new Date(),
951
+ references: refs,
952
+ sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
953
+ showNextButton: false,
954
+ };
955
+
956
+ setIsTyping(false);
957
+
958
+ setTimeout(() => {
959
+ setQuizMessages((prev) => [...prev, assistantMessage]);
960
+ setQuizState((prev) => ({
961
+ ...prev,
962
+ currentQuestion: prev.currentQuestion + 1,
963
+ waitingForAnswer: true,
964
+ showNextButton: false,
965
+ }));
966
+ }, 50);
967
+ } catch (e: any) {
968
+ setIsTyping(false);
969
+ toast.error(e?.message || "Quiz failed");
970
+
971
+ const assistantMessage: Message = {
972
+ id: (Date.now() + 1).toString(),
973
+ role: "assistant",
974
+ content: "Sorry — quiz request failed. Please try again.",
975
+ timestamp: new Date(),
976
+ sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
977
+ };
978
+
979
+ setTimeout(() => setQuizMessages((prev) => [...prev, assistantMessage]), 50);
980
+ }
981
+ };
982
+
983
+ const handleStartQuiz = async () => {
984
+ if (!user) return;
985
+
986
+ setIsTyping(true);
987
+ try {
988
+ const docType = getCurrentDocTypeForChat();
989
+
990
+ const r: any = await apiQuizStart({
991
+ user_id: user.email,
992
+ language_preference: mapLanguagePref(language),
993
+ doc_type: docType,
994
+ learning_mode: "quiz",
995
+ course_id: backendCourseId, // ✅ NEW
996
+ } as any);
997
+
998
+ const refs = (r.refs || [])
999
+ .map((x: any) => {
1000
+ const a = x?.source_file ? String(x.source_file) : "";
1001
+ const b = x?.section ? String(x.section) : "";
1002
+ const s = `${a}${a && b ? " — " : ""}${b}`.trim();
1003
+ return s || null;
1004
+ })
1005
+ .filter(Boolean) as string[];
1006
+
1007
+ const assistantMessage: Message = {
1008
+ id: Date.now().toString(),
1009
+ role: "assistant",
1010
+ content: r.reply || "",
1011
+ timestamp: new Date(),
1012
+ references: refs,
1013
+ sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
1014
+ showNextButton: false,
1015
+ };
1016
+
1017
+ setIsTyping(false);
1018
+
1019
+ setTimeout(() => {
1020
+ setQuizMessages((prev) => [...prev, assistantMessage]);
1021
+ setQuizState({ currentQuestion: 0, waitingForAnswer: true, showNextButton: false });
1022
+ }, 50);
1023
+ } catch (e: any) {
1024
+ setIsTyping(false);
1025
+ toast.error(e?.message || "Start quiz failed");
1026
+
1027
+ const assistantMessage: Message = {
1028
+ id: Date.now().toString(),
1029
+ role: "assistant",
1030
+ content: "Sorry — could not start the quiz. Please try again.",
1031
+ timestamp: new Date(),
1032
+ sender: spaceType === "group" ? groupMembers.find((m) => m.isAI) : undefined,
1033
+ };
1034
+
1035
+ setTimeout(() => setQuizMessages((prev) => [...prev, assistantMessage]), 50);
1036
+ }
1037
+ };
1038
+
1039
+ // =========================
1040
+ // File Upload (FIXED)
1041
+ // =========================
1042
+
1043
+ const handleFileUpload = async (input: File[] | FileList | null | undefined) => {
1044
+ const files = Array.isArray(input) ? input : input ? Array.from(input) : [];
1045
+ if (!files.length) return;
1046
+
1047
+ const newFiles: UploadedFile[] = files.map((file) => ({ file, type: "other" as FileType }));
1048
+
1049
+ setUploadedFiles((prev) => [...prev, ...newFiles]);
1050
+
1051
+ if (!user) return;
1052
+
1053
+ for (const f of files) {
1054
+ const fp = `${f.name}::${f.size}::${f.lastModified}`;
1055
+ if (uploadedFingerprintsRef.current.has(fp)) continue;
1056
+ uploadedFingerprintsRef.current.add(fp);
1057
+
1058
+ try {
1059
+ await apiUpload({
1060
+ user_id: user.email,
1061
+ doc_type: DOC_TYPE_MAP["other"] || "Other Course Document",
1062
+ file: f,
1063
+ });
1064
+ toast.success(`File uploaded: ${f.name}`);
1065
+ } catch (e: any) {
1066
+ toast.error(e?.message || `Upload failed: ${f.name}`);
1067
+ uploadedFingerprintsRef.current.delete(fp);
1068
+ }
1069
+ }
1070
+ };
1071
+
1072
+ const handleRemoveFile = (arg: any) => {
1073
+ setUploadedFiles((prev) => {
1074
+ if (!prev.length) return prev;
1075
+
1076
+ let idx = -1;
1077
+
1078
+ if (typeof arg === "number") {
1079
+ idx = arg;
1080
+ } else {
1081
+ const file =
1082
+ arg?.file instanceof File
1083
+ ? (arg as UploadedFile).file
1084
+ : arg instanceof File
1085
+ ? (arg as File)
1086
+ : null;
1087
+
1088
+ if (file) {
1089
+ idx = prev.findIndex(
1090
+ (x) =>
1091
+ x.file.name === file.name &&
1092
+ x.file.size === file.size &&
1093
+ x.file.lastModified === file.lastModified
1094
+ );
1095
+ }
1096
+ }
1097
+
1098
+ if (idx < 0 || idx >= prev.length) return prev;
1099
+
1100
+ const removed = prev[idx]?.file;
1101
+ const next = prev.filter((_, i) => i !== idx);
1102
+
1103
+ if (removed) {
1104
+ const fp = `${removed.name}::${removed.size}::${removed.lastModified}`;
1105
+ uploadedFingerprintsRef.current.delete(fp);
1106
+ }
1107
+
1108
+ return next;
1109
+ });
1110
+ };
1111
+
1112
+ const handleFileTypeChange = async (index: number, type: FileType) => {
1113
+ if (!user) return;
1114
+
1115
+ const target = uploadedFiles[index]?.file;
1116
+ if (!target) return;
1117
+
1118
+ setUploadedFiles((prev) => prev.map((f, i) => (i === index ? { ...f, type } : f)));
1119
+
1120
+ const fp = `${target.name}::${target.size}::${target.lastModified}`;
1121
+ if (uploadedFingerprintsRef.current.has(fp)) return;
1122
+ uploadedFingerprintsRef.current.add(fp);
1123
+
1124
+ try {
1125
+ await apiUpload({
1126
+ user_id: user.email,
1127
+ doc_type: DOC_TYPE_MAP[type] || "Other Course Document",
1128
+ file: target,
1129
+ });
1130
+ toast.success("File uploaded to backend");
1131
+ } catch (e: any) {
1132
+ toast.error(e?.message || "Upload failed");
1133
+ uploadedFingerprintsRef.current.delete(fp);
1134
+ }
1135
+ };
1136
+
1137
+ const isCurrentChatSaved = (): SavedChat | null => {
1138
+ if (messages.length <= 1) return null;
1139
+
1140
+ return (
1141
+ savedChats.find((chat) => {
1142
+ if (chat.chatMode !== chatMode) return false;
1143
+ if (chat.messages.length !== messages.length) return false;
1144
+
1145
+ return chat.messages.every((savedMsg, idx) => {
1146
+ const currentMsg = messages[idx];
1147
+ return (
1148
+ savedMsg.id === currentMsg.id &&
1149
+ savedMsg.role === currentMsg.role &&
1150
+ savedMsg.content === currentMsg.content
1151
+ );
1152
+ });
1153
+ }) || null
1154
+ );
1155
+ };
1156
+
1157
+ const handleDeleteSavedChat = (id: string) => {
1158
+ setSavedChats((prev) => prev.filter((chat) => chat.id !== id));
1159
+ toast.success("Chat deleted");
1160
+ };
1161
+
1162
+ const handleRenameSavedChat = (id: string, newTitle: string) => {
1163
+ setSavedChats((prev) => prev.map((chat) => (chat.id === id ? { ...chat, title: newTitle } : chat)));
1164
+ toast.success("Chat renamed");
1165
+ };
1166
+
1167
+ const handleSaveChat = () => {
1168
+ if (messages.length <= 1) {
1169
+ toast.info("No conversation to save");
1170
+ return;
1171
+ }
1172
+
1173
+ const existingChat = isCurrentChatSaved();
1174
+ if (existingChat) {
1175
+ handleDeleteSavedChat(existingChat.id);
1176
+ toast.success("Chat unsaved");
1177
+ return;
1178
+ }
1179
+
1180
+ const title = `Chat - ${
1181
+ chatMode === "ask" ? "Ask" : chatMode === "review" ? "Review" : "Quiz"
1182
+ } - ${new Date().toLocaleDateString()}`;
1183
+
1184
+ const newChat: SavedChat = {
1185
+ id: Date.now().toString(),
1186
+ title,
1187
+ messages: [...messages],
1188
+ chatMode,
1189
+ timestamp: new Date(),
1190
+ };
1191
+
1192
+ setSavedChats((prev) => [newChat, ...prev]);
1193
+ setLeftPanelVisible(true);
1194
+ toast.success("Chat saved!");
1195
+ };
1196
+
1197
+ const handleLoadChat = (savedChat: SavedChat) => {
1198
+ setChatMode(savedChat.chatMode);
1199
+
1200
+ if (savedChat.chatMode === "ask") setAskMessages(savedChat.messages);
1201
+ else if (savedChat.chatMode === "review") setReviewMessages(savedChat.messages);
1202
+ else {
1203
+ setQuizMessages(savedChat.messages);
1204
+ setQuizState({ currentQuestion: 0, waitingForAnswer: false, showNextButton: false });
1205
+ }
1206
+
1207
+ toast.success("Chat loaded!");
1208
+ };
1209
+
1210
+ const handleClearConversation = (shouldSave: boolean = false) => {
1211
+ if (shouldSave) handleSaveChat();
1212
+
1213
+ const initialMessages: Record<ChatMode, Message[]> = {
1214
+ ask: [
1215
+ {
1216
+ id: "1",
1217
+ role: "assistant",
1218
+ content:
1219
+ "👋 Hi! I'm Clare, your AI teaching assistant. I'm here to help you learn through personalized tutoring. Feel free to ask me anything about the course materials, or upload your documents to get started!",
1220
+ timestamp: new Date(),
1221
+ },
1222
+ ],
1223
+ review: [
1224
+ {
1225
+ id: "review-1",
1226
+ role: "assistant",
1227
+ content:
1228
+ "📚 Welcome to Review mode! I'll help you review and consolidate your learning. Let's go through what you've learned!",
1229
+ timestamp: new Date(),
1230
+ },
1231
+ ],
1232
+ quiz: [
1233
+ {
1234
+ id: "quiz-1",
1235
+ role: "assistant",
1236
+ content:
1237
+ "🎯 Welcome to Quiz mode! I'll test your understanding with personalized questions based on your learning history. Ready to start?",
1238
+ timestamp: new Date(),
1239
+ },
1240
+ ],
1241
+ };
1242
+
1243
+ if (chatMode === "ask") setAskMessages(initialMessages.ask);
1244
+ else if (chatMode === "review") setReviewMessages(initialMessages.review);
1245
+ else {
1246
+ setQuizMessages(initialMessages.quiz);
1247
+ setQuizState({ currentQuestion: 0, waitingForAnswer: false, showNextButton: false });
1248
+ }
1249
+ };
1250
+
1251
+ const handleSave = (
1252
+ content: string,
1253
+ type: "export" | "quiz" | "summary",
1254
+ saveAsChat: boolean = false,
1255
+ format: "pdf" | "text" = "text",
1256
+ workspaceId?: string
1257
+ ) => {
1258
+ if (!content.trim()) return;
1259
+
1260
+ if (saveAsChat && type !== "summary") {
1261
+ const chatMessages: Message[] = [
1262
+ {
1263
+ id: "1",
1264
+ role: "assistant",
1265
+ content:
1266
+ "👋 Hi! I'm Clare, your AI teaching assistant. I'm here to help you learn through personalized tutoring. Feel free to ask me anything about the course materials, or upload your documents to get started!",
1267
+ timestamp: new Date(),
1268
+ },
1269
+ { id: Date.now().toString(), role: "assistant", content, timestamp: new Date() },
1270
+ ];
1271
+
1272
+ const title = type === "export" ? "Exported Conversation" : "Micro-Quiz";
1273
+ const newChat: SavedChat = {
1274
+ id: Date.now().toString(),
1275
+ title: `${title} - ${new Date().toLocaleDateString()}`,
1276
+ messages: chatMessages,
1277
+ chatMode: "ask",
1278
+ timestamp: new Date(),
1279
+ };
1280
+
1281
+ setSavedChats((prev) => [newChat, ...prev]);
1282
+ setLeftPanelVisible(true);
1283
+ toast.success("Chat saved!");
1284
+ return;
1285
+ }
1286
+
1287
+ const existingItem = savedItems.find((item) => item.content === content && item.type === type);
1288
+ if (existingItem) {
1289
+ handleUnsave(existingItem.id);
1290
+ return;
1291
+ }
1292
+
1293
+ const title = type === "export" ? "Exported Conversation" : type === "quiz" ? "Micro-Quiz" : "Summarization";
1294
+ const newItem: SavedItem = {
1295
+ id: Date.now().toString(),
1296
+ title: `${title} - ${new Date().toLocaleDateString()}`,
1297
+ content,
1298
+ type,
1299
+ timestamp: new Date(),
1300
+ isSaved: true,
1301
+ format,
1302
+ workspaceId: workspaceId || currentWorkspaceId,
1303
+ };
1304
+
1305
+ setSavedItems((prev) => [newItem, ...prev]);
1306
+ setRecentlySavedId(newItem.id);
1307
+ setLeftPanelVisible(true);
1308
+
1309
+ setTimeout(() => setRecentlySavedId(null), 2000);
1310
+ toast.success("Saved for later!");
1311
+ };
1312
+
1313
+ const handleUnsave = (id: string) => {
1314
+ setSavedItems((prev) => prev.filter((item) => item.id !== id));
1315
+ toast.success("Removed from saved items");
1316
+ };
1317
+
1318
+ const handleCreateWorkspace = (payload: { name: string; category: "course" | "personal"; courseId?: string; invites: string[] }) => {
1319
+ const id = `group-${Date.now()}`;
1320
+ const avatar = `https://api.dicebear.com/7.x/shapes/svg?seed=${encodeURIComponent(payload.name)}`;
1321
+
1322
+ const creatorMember: GroupMember = user
1323
+ ? {
1324
+ id: user.email,
1325
+ name: user.name,
1326
+ email: user.email,
1327
+ avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(user.email)}`,
1328
+ }
1329
+ : { id: "unknown", name: "Unknown", email: "unknown@email.com" };
1330
+
1331
+ const members: GroupMember[] = [
1332
+ creatorMember,
1333
+ ...payload.invites.map((email) => ({
1334
+ id: email,
1335
+ name: email.split("@")[0] || email,
1336
+ email,
1337
+ })),
1338
+ ];
1339
+
1340
+ let newWorkspace: Workspace;
1341
+
1342
+ if (payload.category === "course") {
1343
+ const courseInfo = availableCourses.find((c) => c.id === payload.courseId);
1344
+ newWorkspace = {
1345
+ id,
1346
+ name: payload.name,
1347
+ type: "group",
1348
+ avatar,
1349
+ members,
1350
+ category: "course",
1351
+ courseName: courseInfo?.name || "Untitled Course",
1352
+ courseInfo,
1353
+ };
1354
+ } else {
1355
+ newWorkspace = {
1356
+ id,
1357
+ name: payload.name,
1358
+ type: "group",
1359
+ avatar,
1360
+ members,
1361
+ category: "personal",
1362
+ isEditable: true,
1363
+ };
1364
+ }
1365
+
1366
+ setWorkspaces((prev) => [...prev, newWorkspace]);
1367
+ setCurrentWorkspaceId(id);
1368
+
1369
+ if (payload.category === "course" && payload.courseId) {
1370
+ setCurrentCourseId(payload.courseId);
1371
+ }
1372
+
1373
+ toast.success("New group workspace created");
1374
+ };
1375
+
1376
+ const handleReviewClick = () => {
1377
+ setChatMode("review");
1378
+ setShowReviewBanner(false);
1379
+ localStorage.setItem("reviewBannerDismissed", "true");
1380
+ };
1381
+
1382
+ const handleDismissReviewBanner = () => {
1383
+ setShowReviewBanner(false);
1384
+ localStorage.setItem("reviewBannerDismissed", "true");
1385
+ };
1386
+
1387
+ // ✅ login: hydrate profile and only show onboarding if not completed
1388
+ const handleLogin = (newUser: User) => {
1389
+ const hydrated = hydrateUserFromStorage(newUser);
1390
+ setUser(hydrated);
1391
+ setShowOnboarding(!hydrated.onboardingCompleted);
1392
+ };
1393
+
1394
+ const handleOnboardingComplete = (updatedUser: User) => {
1395
+ handleUserSave({ ...updatedUser, onboardingCompleted: true });
1396
+ setShowOnboarding(false);
1397
+ };
1398
+
1399
+ const handleOnboardingSkip = () => {
1400
+ updateUser({ onboardingCompleted: true });
1401
+ setShowOnboarding(false);
1402
+ };
1403
+
1404
+ if (!user) return <LoginScreen onLogin={handleLogin} />;
1405
+
1406
+ if (showOnboarding && user)
1407
+ return <Onboarding user={user} onComplete={handleOnboardingComplete} onSkip={handleOnboardingSkip} />;
1408
+
1409
+ return (
1410
+ <div className="fixed inset-0 w-full bg-background overflow-hidden">
1411
+ <Toaster />
1412
+
1413
+ <div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
1414
+ <div className="flex-shrink-0">
1415
+ <Header
1416
+ user={user}
1417
+ onMenuClick={() => setLeftSidebarOpen(!leftSidebarOpen)}
1418
+ onUserClick={() => setShowProfileEditor(true)}
1419
+ isDarkMode={isDarkMode}
1420
+ onToggleDarkMode={() => setIsDarkMode(!isDarkMode)}
1421
+ language={language}
1422
+ onLanguageChange={setLanguage}
1423
+ workspaces={workspaces}
1424
+ currentWorkspace={currentWorkspace}
1425
+ onWorkspaceChange={setCurrentWorkspaceId}
1426
+ onCreateWorkspace={handleCreateWorkspace}
1427
+ onLogout={() => setUser(null)}
1428
+ availableCourses={availableCourses}
1429
+ onUserUpdate={handleUserSave}
1430
+ reviewStarOpacity={reviewStarOpacity}
1431
+ reviewEnergyPct={reviewEnergyPct}
1432
+ onStarClick={() => {
1433
+ setChatMode("review");
1434
+ setShowReviewBanner(false);
1435
+ localStorage.setItem("reviewBannerDismissed", "true");
1436
+ }}
1437
+ />
1438
+ </div>
1439
+
1440
+ {showProfileEditor && user && (
1441
+ <ProfileEditor user={user} onSave={handleUserSave} onClose={() => setShowProfileEditor(false)} />
1442
+ )}
1443
+
1444
+ {showReviewBanner && (
1445
+ <div className="flex-shrink-0 w-full bg-background border-b border-border relative z-50">
1446
+ <ReviewBanner onReview={handleReviewClick} onDismiss={handleDismissReviewBanner} />
1447
+ </div>
1448
+ )}
1449
+
1450
+ <div className="flex flex-1 min-h-0 min-w-0 overflow-hidden relative">
1451
+ {!leftPanelVisible && (
1452
+ <Button
1453
+ variant="secondary"
1454
+ size="icon"
1455
+ onClick={() => setLeftPanelVisible(true)}
1456
+ className="hidden lg:flex absolute z-[100] h-8 w-5 shadow-lg rounded-full bg-card border border-border transition-all duration-200 ease-in-out hover:translate-x-[10px]"
1457
+ style={{ left: "-5px", top: "1rem" }}
1458
+ title="Open panel"
1459
+ >
1460
+ <ChevronRight className="h-3 w-3" />
1461
+ </Button>
1462
+ )}
1463
+
1464
+ {leftSidebarOpen && (
1465
+ <div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setLeftSidebarOpen(false)} />
1466
+ )}
1467
+
1468
+ {leftPanelVisible ? (
1469
+ <aside className="hidden lg:flex w-80 h-full min-h-0 min-w-0 bg-card border-r border-border overflow-hidden relative flex-col">
1470
+ <Button
1471
+ variant="secondary"
1472
+ size="icon"
1473
+ onClick={() => setLeftPanelVisible(false)}
1474
+ className="absolute z-[70] h-8 w-5 shadow-lg rounded-full bg-card border border-border"
1475
+ style={{ right: "-10px", top: "1rem" }}
1476
+ title="Close panel"
1477
+ >
1478
+ <ChevronLeft className="h-3 w-3" />
1479
+ </Button>
1480
+
1481
+ <div className="flex-1 min-h-0 min-w-0 overflow-hidden">
1482
+ <LeftSidebar
1483
+ learningMode={learningMode}
1484
+ language={language}
1485
+ onLearningModeChange={setLearningMode}
1486
+ onLanguageChange={setLanguage}
1487
+ spaceType={sidebarSpaceType}
1488
+ groupMembers={sidebarGroupMembers}
1489
+ user={user}
1490
+ onLogin={setUser}
1491
+ onLogout={() => setUser(null)}
1492
+ isLoggedIn={!!user}
1493
+ onEditProfile={() => setShowProfileEditor(true)}
1494
+ savedItems={savedItems}
1495
+ recentlySavedId={recentlySavedId}
1496
+ onUnsave={handleUnsave}
1497
+ onSave={handleSave}
1498
+ savedChats={savedChats}
1499
+ onLoadChat={handleLoadChat}
1500
+ onDeleteSavedChat={handleDeleteSavedChat}
1501
+ onRenameSavedChat={handleRenameSavedChat}
1502
+ currentWorkspaceId={currentWorkspaceId}
1503
+ workspaces={sidebarWorkspaces}
1504
+ selectedCourse={currentCourseId}
1505
+ availableCourses={availableCourses}
1506
+ />
1507
+ </div>
1508
+ </aside>
1509
+ ) : null}
1510
+
1511
+ <aside
1512
+ className={[
1513
+ "fixed lg:hidden z-50",
1514
+ "left-0 top-0 bottom-0",
1515
+ "w-80 bg-card border-r border-border",
1516
+ "transform transition-transform duration-300 ease-in-out",
1517
+ leftSidebarOpen ? "translate-x-0" : "-translate-x-full",
1518
+ "overflow-hidden flex flex-col",
1519
+ ].join(" ")}
1520
+ >
1521
+ <div className="p-4 border-b border-border flex justify-between items-center flex-shrink-0">
1522
+ <h3>Settings & Guide</h3>
1523
+ <Button variant="ghost" size="icon" onClick={() => setLeftSidebarOpen(false)}>
1524
+ <X className="h-5 w-5" />
1525
+ </Button>
1526
+ </div>
1527
+
1528
+ <div className="flex-1 min-h-0 overflow-hidden">
1529
+ <LeftSidebar
1530
+ learningMode={learningMode}
1531
+ language={language}
1532
+ onLearningModeChange={setLearningMode}
1533
+ onLanguageChange={setLanguage}
1534
+ spaceType={sidebarSpaceType}
1535
+ groupMembers={sidebarGroupMembers}
1536
+ user={user}
1537
+ onLogin={setUser}
1538
+ onLogout={() => setUser(null)}
1539
+ isLoggedIn={!!user}
1540
+ onEditProfile={() => setShowProfileEditor(true)}
1541
+ savedItems={savedItems}
1542
+ recentlySavedId={recentlySavedId}
1543
+ onUnsave={handleUnsave}
1544
+ onSave={handleSave}
1545
+ savedChats={savedChats}
1546
+ onLoadChat={handleLoadChat}
1547
+ onDeleteSavedChat={handleDeleteSavedChat}
1548
+ onRenameSavedChat={handleRenameSavedChat}
1549
+ currentWorkspaceId={currentWorkspaceId}
1550
+ workspaces={sidebarWorkspaces}
1551
+ selectedCourse={currentCourseId}
1552
+ availableCourses={availableCourses}
1553
+ />
1554
+ </div>
1555
+ </aside>
1556
+
1557
+ <main className="flex flex-1 min-w-0 min-h-0 overflow-hidden flex-col">
1558
+ <div className="flex-1 min-h-0 min-w-0 overflow-hidden">
1559
+ <ChatArea
1560
+ messages={messages}
1561
+ onSendMessage={handleSendMessage}
1562
+ uploadedFiles={uploadedFiles}
1563
+ onFileUpload={handleFileUpload}
1564
+ onRemoveFile={handleRemoveFile}
1565
+ onFileTypeChange={handleFileTypeChange}
1566
+ memoryProgress={memoryProgress}
1567
+ isLoggedIn={!!user}
1568
+ learningMode={learningMode}
1569
+ onClearConversation={() => setShowClearDialog(true)}
1570
+ onSaveChat={handleSaveChat}
1571
+ onLearningModeChange={setLearningMode}
1572
+ spaceType={spaceType}
1573
+ chatMode={chatMode}
1574
+ onChatModeChange={setChatMode}
1575
+ onNextQuestion={handleNextQuestion}
1576
+ onStartQuiz={handleStartQuiz}
1577
+ quizState={quizState}
1578
+ isTyping={isTyping}
1579
+ showClearDialog={showClearDialog}
1580
+ onConfirmClear={(shouldSave) => {
1581
+ handleClearConversation(shouldSave);
1582
+ setShowClearDialog(false);
1583
+ }}
1584
+ onCancelClear={() => setShowClearDialog(false)}
1585
+ savedChats={savedChats}
1586
+ workspaces={workspaces}
1587
+ currentWorkspaceId={currentWorkspaceId}
1588
+ onSaveFile={(content, type, _format, targetWorkspaceId) =>
1589
+ handleSave(content, type, false, (_format ?? "text") as "pdf" | "text", targetWorkspaceId)
1590
+ }
1591
+ leftPanelVisible={leftPanelVisible}
1592
+ currentCourseId={currentCourseId}
1593
+ onCourseChange={handleCourseChange}
1594
+ availableCourses={availableCourses}
1595
+ showReviewBanner={showReviewBanner}
1596
+ onReviewActivity={handleReviewActivity}
1597
+ currentUserId={user?.email}
1598
+ docType={getCurrentDocTypeForChat()} // ✅ changed from "Syllabus"
1599
+ // ✅ bio is still allowed to be updated by chat/Clare
1600
+ onProfileBioUpdate={(bio) => updateUser({ bio })}
1601
+ />
1602
+ </div>
1603
+ </main>
1604
+ </div>
1605
+ </div>
1606
+ </div>
1607
+ );
1608
  }
1609
 
1610
  export default App;