Spaces:
Sleeping
Sleeping
Update web/src/components/ChatArea.tsx
Browse files- web/src/components/ChatArea.tsx +100 -193
web/src/components/ChatArea.tsx
CHANGED
|
@@ -50,6 +50,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
|
|
| 50 |
import { SmartReview } from "./SmartReview";
|
| 51 |
import clareAvatar from "../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png";
|
| 52 |
|
|
|
|
|
|
|
|
|
|
| 53 |
type ReviewEventType = "send_message" | "review_topic" | "review_all";
|
| 54 |
|
| 55 |
interface ChatAreaProps {
|
|
@@ -89,7 +92,6 @@ interface ChatAreaProps {
|
|
| 89 |
availableCourses?: Array<{ id: string; name: string }>;
|
| 90 |
showReviewBanner?: boolean;
|
| 91 |
|
| 92 |
-
// ✅ NEW: for Review star brightness (daily)
|
| 93 |
onReviewActivity?: (event: ReviewEventType) => void;
|
| 94 |
}
|
| 95 |
|
|
@@ -199,17 +201,14 @@ export function ChatArea({
|
|
| 199 |
return;
|
| 200 |
}
|
| 201 |
|
| 202 |
-
// smooth scroll without scrollIntoView (prevents scrolling the wrong ancestor)
|
| 203 |
el.scrollTo({ top, behavior });
|
| 204 |
};
|
| 205 |
|
| 206 |
-
// Auto-scroll only when new messages come in (not on initial mount)
|
| 207 |
useEffect(() => {
|
| 208 |
if (isInitialMount.current) {
|
| 209 |
isInitialMount.current = false;
|
| 210 |
previousMessagesLength.current = messages.length;
|
| 211 |
|
| 212 |
-
// Keep your previous behavior: start at top
|
| 213 |
const el = scrollContainerRef.current;
|
| 214 |
if (el) el.scrollTop = 0;
|
| 215 |
return;
|
|
@@ -225,7 +224,6 @@ export function ChatArea({
|
|
| 225 |
previousMessagesLength.current = messages.length;
|
| 226 |
}, [messages]);
|
| 227 |
|
| 228 |
-
// Scroll button + top border
|
| 229 |
useEffect(() => {
|
| 230 |
const container = scrollContainerRef.current;
|
| 231 |
if (!container) return;
|
|
@@ -246,7 +244,6 @@ export function ChatArea({
|
|
| 246 |
e.preventDefault();
|
| 247 |
if (!input.trim() || !isLoggedIn) return;
|
| 248 |
|
| 249 |
-
// ✅ Review activity: user sent a message in Review tab
|
| 250 |
if (chatMode === "review") onReviewActivity?.("send_message");
|
| 251 |
|
| 252 |
onSendMessage(input);
|
|
@@ -269,7 +266,6 @@ export function ChatArea({
|
|
| 269 |
summary: "Quick Summary",
|
| 270 |
};
|
| 271 |
|
| 272 |
-
// Review topic click
|
| 273 |
const handleReviewTopic = (item: {
|
| 274 |
title: string;
|
| 275 |
previousQuestion: string;
|
|
@@ -279,7 +275,6 @@ export function ChatArea({
|
|
| 279 |
weight: number;
|
| 280 |
lastReviewed: string;
|
| 281 |
}) => {
|
| 282 |
-
// ✅ Review activity: clicked Review this topic
|
| 283 |
onReviewActivity?.("review_topic");
|
| 284 |
|
| 285 |
const userMessage = `Please help me review: ${item.title}`;
|
|
@@ -289,9 +284,7 @@ export function ChatArea({
|
|
| 289 |
};
|
| 290 |
|
| 291 |
const handleReviewAll = () => {
|
| 292 |
-
// ✅ Review activity: clicked Review all
|
| 293 |
onReviewActivity?.("review_all");
|
| 294 |
-
|
| 295 |
(window as any).__lastReviewData = "REVIEW_ALL";
|
| 296 |
onSendMessage("Please help me review all topics that need attention.");
|
| 297 |
};
|
|
@@ -385,7 +378,6 @@ export function ChatArea({
|
|
| 385 |
}
|
| 386 |
};
|
| 387 |
|
| 388 |
-
// Check if current chat is already saved
|
| 389 |
const isCurrentChatSaved = (): boolean => {
|
| 390 |
if (messages.length <= 1) return false;
|
| 391 |
|
|
@@ -441,7 +433,7 @@ export function ChatArea({
|
|
| 441 |
const saved = isCurrentChatSaved();
|
| 442 |
|
| 443 |
if (saved) {
|
| 444 |
-
onConfirmClear(false);
|
| 445 |
return;
|
| 446 |
}
|
| 447 |
|
|
@@ -567,114 +559,87 @@ export function ChatArea({
|
|
| 567 |
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
| 568 |
};
|
| 569 |
|
| 570 |
-
// ✅
|
| 571 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 572 |
file,
|
| 573 |
-
|
| 574 |
-
fileInfo,
|
| 575 |
-
isImage,
|
| 576 |
-
onPreview,
|
| 577 |
-
onRemove,
|
| 578 |
}: {
|
| 579 |
file: File;
|
| 580 |
-
|
| 581 |
-
fileInfo: { bgColor: string; type: string };
|
| 582 |
-
isImage: boolean;
|
| 583 |
-
onPreview: () => void;
|
| 584 |
-
onRemove: () => void; // ✅ CHANGED
|
| 585 |
}) => {
|
| 586 |
-
const
|
| 587 |
-
const [
|
| 588 |
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
|
|
|
|
|
|
|
|
|
| 595 |
|
| 596 |
-
|
| 597 |
-
const reader = new FileReader();
|
| 598 |
-
reader.onload = (e) => {
|
| 599 |
-
setImagePreview(e.target?.result as string);
|
| 600 |
-
setImageLoading(false);
|
| 601 |
-
};
|
| 602 |
-
reader.onerror = () => setImageLoading(false);
|
| 603 |
-
reader.readAsDataURL(file);
|
| 604 |
-
}, [file, isImage]);
|
| 605 |
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 612 |
>
|
| 613 |
-
<
|
| 614 |
-
|
| 615 |
-
{imageLoading ? (
|
| 616 |
-
<div className="w-full h-full flex items-center justify-center bg-muted">
|
| 617 |
-
<Icon className="h-5 w-5 text-muted-foreground animate-pulse" />
|
| 618 |
-
</div>
|
| 619 |
-
) : imagePreview ? (
|
| 620 |
-
<img
|
| 621 |
-
src={imagePreview}
|
| 622 |
-
alt={file.name}
|
| 623 |
-
className="w-full h-full object-cover"
|
| 624 |
-
onError={(e) => {
|
| 625 |
-
e.currentTarget.style.display = "none";
|
| 626 |
-
setImageLoading(false);
|
| 627 |
-
}}
|
| 628 |
-
/>
|
| 629 |
-
) : (
|
| 630 |
-
<div className="w-full h-full flex items-center justify-center bg-muted">
|
| 631 |
-
<Icon className="h-5 w-5 text-muted-foreground" />
|
| 632 |
-
</div>
|
| 633 |
-
)}
|
| 634 |
-
</div>
|
| 635 |
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
onClick={(e) => {
|
| 640 |
-
e.stopPropagation();
|
| 641 |
-
onRemove(); // ✅ CHANGED
|
| 642 |
-
}}
|
| 643 |
-
style={{ zIndex: 100 }}
|
| 644 |
-
>
|
| 645 |
-
<X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
|
| 646 |
-
</button>
|
| 647 |
</div>
|
|
|
|
| 648 |
</div>
|
| 649 |
-
);
|
| 650 |
-
}
|
| 651 |
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
|
|
|
|
|
|
| 664 |
</div>
|
| 665 |
-
|
| 666 |
-
<button
|
| 667 |
-
type="button"
|
| 668 |
-
className="absolute top-1 right-1 h-4 w-4 rounded-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-sm flex items-center justify-center cursor-pointer z-10"
|
| 669 |
-
onClick={(e) => {
|
| 670 |
-
e.stopPropagation();
|
| 671 |
-
onRemove(); // ✅ CHANGED
|
| 672 |
-
}}
|
| 673 |
-
style={{ zIndex: 10 }}
|
| 674 |
-
>
|
| 675 |
-
<X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
|
| 676 |
-
</button>
|
| 677 |
-
</div>
|
| 678 |
</div>
|
| 679 |
);
|
| 680 |
};
|
|
@@ -693,16 +658,9 @@ export function ChatArea({
|
|
| 693 |
const ext = file.name.toLowerCase();
|
| 694 |
|
| 695 |
if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e))) {
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
setLoading(false);
|
| 700 |
-
};
|
| 701 |
-
reader.onerror = () => {
|
| 702 |
-
setError("Failed to load image");
|
| 703 |
-
setLoading(false);
|
| 704 |
-
};
|
| 705 |
-
reader.readAsDataURL(file);
|
| 706 |
return;
|
| 707 |
}
|
| 708 |
|
|
@@ -729,6 +687,7 @@ export function ChatArea({
|
|
| 729 |
};
|
| 730 |
|
| 731 |
loadFile();
|
|
|
|
| 732 |
}, [file]);
|
| 733 |
|
| 734 |
if (loading) return <div className="text-center py-8">Loading...</div>;
|
|
@@ -745,17 +704,18 @@ export function ChatArea({
|
|
| 745 |
);
|
| 746 |
}
|
| 747 |
|
| 748 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 749 |
};
|
| 750 |
|
| 751 |
-
// ✅ Reserve space for composer so last message is never hidden
|
| 752 |
const bottomPad = Math.max(24, composerHeight + 24);
|
| 753 |
|
| 754 |
return (
|
| 755 |
<div className="relative flex flex-col h-full min-h-0 w-full overflow-hidden">
|
| 756 |
-
{/*
|
| 757 |
-
0) Top Bar (fixed in ChatArea layout; NOT inside scroll container)
|
| 758 |
-
========================= */}
|
| 759 |
<div
|
| 760 |
className={`flex-shrink-0 flex items-center justify-between px-4 bg-card z-20 ${
|
| 761 |
showTopBorder ? "border-b border-border" : ""
|
|
@@ -768,11 +728,7 @@ export function ChatArea({
|
|
| 768 |
const current = workspaces.find((w) => w.id === currentWorkspaceId);
|
| 769 |
if (current?.type === "group") {
|
| 770 |
if (current.category === "course" && current.courseName) {
|
| 771 |
-
return
|
| 772 |
-
<div className="h-9 px-3 inline-flex items-center rounded-md border font-semibold">
|
| 773 |
-
{current.courseName}
|
| 774 |
-
</div>
|
| 775 |
-
);
|
| 776 |
}
|
| 777 |
return null;
|
| 778 |
}
|
|
@@ -794,7 +750,7 @@ export function ChatArea({
|
|
| 794 |
})()}
|
| 795 |
</div>
|
| 796 |
|
| 797 |
-
{/*
|
| 798 |
<div className="absolute left-1/2 -translate-x-1/2 flex-shrink-0">
|
| 799 |
<Tabs value={chatMode} onValueChange={(value) => onChatModeChange(value as ChatMode)} className="w-auto" orientation="horizontal">
|
| 800 |
<TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground">
|
|
@@ -869,11 +825,8 @@ export function ChatArea({
|
|
| 869 |
</div>
|
| 870 |
</div>
|
| 871 |
|
| 872 |
-
{/*
|
| 873 |
-
1) Scroll Container (ONLY this scrolls)
|
| 874 |
-
========================= */}
|
| 875 |
<div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto overscroll-contain" style={{ overscrollBehavior: "contain" }}>
|
| 876 |
-
{/* Messages */}
|
| 877 |
<div className="py-6" style={{ paddingBottom: bottomPad }}>
|
| 878 |
<div className="w-full space-y-6 max-w-4xl mx-auto">
|
| 879 |
{messages.map((message) => (
|
|
@@ -881,9 +834,7 @@ export function ChatArea({
|
|
| 881 |
<Message
|
| 882 |
message={message}
|
| 883 |
showSenderInfo={spaceType === "group"}
|
| 884 |
-
isFirstGreeting={
|
| 885 |
-
(message.id === "1" || message.id === "review-1" || message.id === "quiz-1") && message.role === "assistant"
|
| 886 |
-
}
|
| 887 |
showNextButton={message.showNextButton && !isAppTyping}
|
| 888 |
onNextQuestion={onNextQuestion}
|
| 889 |
chatMode={chatMode}
|
|
@@ -931,9 +882,7 @@ export function ChatArea({
|
|
| 931 |
</div>
|
| 932 |
</div>
|
| 933 |
|
| 934 |
-
{/*
|
| 935 |
-
2) Scroll-to-bottom button (positioned above composer)
|
| 936 |
-
========================= */}
|
| 937 |
{showScrollButton && (
|
| 938 |
<div className="absolute z-30 left-0 right-0 flex justify-center pointer-events-none" style={{ bottom: composerHeight + 16 }}>
|
| 939 |
<Button
|
|
@@ -948,40 +897,15 @@ export function ChatArea({
|
|
| 948 |
</div>
|
| 949 |
)}
|
| 950 |
|
| 951 |
-
{/*
|
| 952 |
-
3) Composer (bottom flex item; never scrolls)
|
| 953 |
-
========================= */}
|
| 954 |
<div ref={composerRef} className="flex-shrink-0 bg-background/95 backdrop-blur-sm z-20 border-t border-border">
|
| 955 |
<div className="max-w-4xl mx-auto px-4 py-4">
|
|
|
|
| 956 |
{uploadedFiles.length > 0 && (
|
| 957 |
<div className="mb-2 flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
| 958 |
-
{uploadedFiles.map((
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
const isImage = ["jpg", "jpeg", "png", "gif", "webp"].some((ext) =>
|
| 962 |
-
uploadedFile.file.name.toLowerCase().endsWith(`.${ext}`)
|
| 963 |
-
);
|
| 964 |
-
|
| 965 |
-
return (
|
| 966 |
-
<div key={index}>
|
| 967 |
-
<FileThumbnail
|
| 968 |
-
file={uploadedFile.file}
|
| 969 |
-
Icon={Icon}
|
| 970 |
-
fileInfo={fileInfo}
|
| 971 |
-
isImage={isImage}
|
| 972 |
-
onPreview={() => {
|
| 973 |
-
setSelectedFile({ file: uploadedFile.file, index });
|
| 974 |
-
setShowFileViewer(true);
|
| 975 |
-
}}
|
| 976 |
-
// ✅ CHANGE 2: 直接 set state,不传事件
|
| 977 |
-
onRemove={() => {
|
| 978 |
-
setFileToDelete(index);
|
| 979 |
-
setShowDeleteDialog(true);
|
| 980 |
-
}}
|
| 981 |
-
/>
|
| 982 |
-
</div>
|
| 983 |
-
);
|
| 984 |
-
})}
|
| 985 |
</div>
|
| 986 |
)}
|
| 987 |
|
|
@@ -1012,30 +936,21 @@ export function ChatArea({
|
|
| 1012 |
</Button>
|
| 1013 |
</DropdownMenuTrigger>
|
| 1014 |
<DropdownMenuContent align="start" className="w-56">
|
| 1015 |
-
<DropdownMenuItem
|
| 1016 |
-
onClick={() => onLearningModeChange("general")}
|
| 1017 |
-
className={learningMode === "general" ? "bg-accent" : ""}
|
| 1018 |
-
>
|
| 1019 |
<div className="flex flex-col">
|
| 1020 |
<span className="font-medium">General</span>
|
| 1021 |
<span className="text-xs text-muted-foreground">Answer various questions (context required)</span>
|
| 1022 |
</div>
|
| 1023 |
</DropdownMenuItem>
|
| 1024 |
|
| 1025 |
-
<DropdownMenuItem
|
| 1026 |
-
onClick={() => onLearningModeChange("concept")}
|
| 1027 |
-
className={learningMode === "concept" ? "bg-accent" : ""}
|
| 1028 |
-
>
|
| 1029 |
<div className="flex flex-col">
|
| 1030 |
<span className="font-medium">Concept Explainer</span>
|
| 1031 |
<span className="text-xs text-muted-foreground">Get detailed explanations of concepts</span>
|
| 1032 |
</div>
|
| 1033 |
</DropdownMenuItem>
|
| 1034 |
|
| 1035 |
-
<DropdownMenuItem
|
| 1036 |
-
onClick={() => onLearningModeChange("socratic")}
|
| 1037 |
-
className={learningMode === "socratic" ? "bg-accent" : ""}
|
| 1038 |
-
>
|
| 1039 |
<div className="flex flex-col">
|
| 1040 |
<span className="font-medium">Socratic Tutor</span>
|
| 1041 |
<span className="text-xs text-muted-foreground">Learn through guided questions</span>
|
|
@@ -1049,20 +964,14 @@ export function ChatArea({
|
|
| 1049 |
</div>
|
| 1050 |
</DropdownMenuItem>
|
| 1051 |
|
| 1052 |
-
<DropdownMenuItem
|
| 1053 |
-
onClick={() => onLearningModeChange("assignment")}
|
| 1054 |
-
className={learningMode === "assignment" ? "bg-accent" : ""}
|
| 1055 |
-
>
|
| 1056 |
<div className="flex flex-col">
|
| 1057 |
<span className="font-medium">Assignment Helper</span>
|
| 1058 |
<span className="text-xs text-muted-foreground">Get help with assignments</span>
|
| 1059 |
</div>
|
| 1060 |
</DropdownMenuItem>
|
| 1061 |
|
| 1062 |
-
<DropdownMenuItem
|
| 1063 |
-
onClick={() => onLearningModeChange("summary")}
|
| 1064 |
-
className={learningMode === "summary" ? "bg-accent" : ""}
|
| 1065 |
-
>
|
| 1066 |
<div className="flex flex-col">
|
| 1067 |
<span className="font-medium">Quick Summary</span>
|
| 1068 |
<span className="text-xs text-muted-foreground">Get concise summaries</span>
|
|
@@ -1271,15 +1180,13 @@ export function ChatArea({
|
|
| 1271 |
</DialogContent>
|
| 1272 |
</Dialog>
|
| 1273 |
|
| 1274 |
-
{/* Delete File Confirmation Dialog */}
|
| 1275 |
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
| 1276 |
-
|
| 1277 |
-
<AlertDialogContent className="z-[99999]" style={{ zIndex: 99999 }}>
|
| 1278 |
<AlertDialogHeader>
|
| 1279 |
<AlertDialogTitle>Delete File</AlertDialogTitle>
|
| 1280 |
<AlertDialogDescription>
|
| 1281 |
-
Are you sure you want to delete "
|
| 1282 |
-
{fileToDelete !== null ? uploadedFiles[fileToDelete]?.file.name : ""}"? This action cannot be undone.
|
| 1283 |
</AlertDialogDescription>
|
| 1284 |
</AlertDialogHeader>
|
| 1285 |
<AlertDialogFooter>
|
|
|
|
| 50 |
import { SmartReview } from "./SmartReview";
|
| 51 |
import clareAvatar from "../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png";
|
| 52 |
|
| 53 |
+
// ✅ NEW
|
| 54 |
+
import { useObjectUrlCache } from "../lib/useObjectUrlCache";
|
| 55 |
+
|
| 56 |
type ReviewEventType = "send_message" | "review_topic" | "review_all";
|
| 57 |
|
| 58 |
interface ChatAreaProps {
|
|
|
|
| 92 |
availableCourses?: Array<{ id: string; name: string }>;
|
| 93 |
showReviewBanner?: boolean;
|
| 94 |
|
|
|
|
| 95 |
onReviewActivity?: (event: ReviewEventType) => void;
|
| 96 |
}
|
| 97 |
|
|
|
|
| 201 |
return;
|
| 202 |
}
|
| 203 |
|
|
|
|
| 204 |
el.scrollTo({ top, behavior });
|
| 205 |
};
|
| 206 |
|
|
|
|
| 207 |
useEffect(() => {
|
| 208 |
if (isInitialMount.current) {
|
| 209 |
isInitialMount.current = false;
|
| 210 |
previousMessagesLength.current = messages.length;
|
| 211 |
|
|
|
|
| 212 |
const el = scrollContainerRef.current;
|
| 213 |
if (el) el.scrollTop = 0;
|
| 214 |
return;
|
|
|
|
| 224 |
previousMessagesLength.current = messages.length;
|
| 225 |
}, [messages]);
|
| 226 |
|
|
|
|
| 227 |
useEffect(() => {
|
| 228 |
const container = scrollContainerRef.current;
|
| 229 |
if (!container) return;
|
|
|
|
| 244 |
e.preventDefault();
|
| 245 |
if (!input.trim() || !isLoggedIn) return;
|
| 246 |
|
|
|
|
| 247 |
if (chatMode === "review") onReviewActivity?.("send_message");
|
| 248 |
|
| 249 |
onSendMessage(input);
|
|
|
|
| 266 |
summary: "Quick Summary",
|
| 267 |
};
|
| 268 |
|
|
|
|
| 269 |
const handleReviewTopic = (item: {
|
| 270 |
title: string;
|
| 271 |
previousQuestion: string;
|
|
|
|
| 275 |
weight: number;
|
| 276 |
lastReviewed: string;
|
| 277 |
}) => {
|
|
|
|
| 278 |
onReviewActivity?.("review_topic");
|
| 279 |
|
| 280 |
const userMessage = `Please help me review: ${item.title}`;
|
|
|
|
| 284 |
};
|
| 285 |
|
| 286 |
const handleReviewAll = () => {
|
|
|
|
| 287 |
onReviewActivity?.("review_all");
|
|
|
|
| 288 |
(window as any).__lastReviewData = "REVIEW_ALL";
|
| 289 |
onSendMessage("Please help me review all topics that need attention.");
|
| 290 |
};
|
|
|
|
| 378 |
}
|
| 379 |
};
|
| 380 |
|
|
|
|
| 381 |
const isCurrentChatSaved = (): boolean => {
|
| 382 |
if (messages.length <= 1) return false;
|
| 383 |
|
|
|
|
| 433 |
const saved = isCurrentChatSaved();
|
| 434 |
|
| 435 |
if (saved) {
|
| 436 |
+
onConfirmClear(false as any);
|
| 437 |
return;
|
| 438 |
}
|
| 439 |
|
|
|
|
| 559 |
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
| 560 |
};
|
| 561 |
|
| 562 |
+
// ✅ useObjectUrlCache: for image thumbnails (uploaded + pending)
|
| 563 |
+
const allThumbFiles = React.useMemo(() => {
|
| 564 |
+
return [
|
| 565 |
+
...uploadedFiles.map((u) => u.file),
|
| 566 |
+
...pendingFiles.map((p) => p.file),
|
| 567 |
+
];
|
| 568 |
+
}, [uploadedFiles, pendingFiles]);
|
| 569 |
+
const { getOrCreate } = useObjectUrlCache(allThumbFiles);
|
| 570 |
+
|
| 571 |
+
// ✅ NEW: a compact "chip" UI (the one with left X)
|
| 572 |
+
const FileChip = ({
|
| 573 |
file,
|
| 574 |
+
index,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 575 |
}: {
|
| 576 |
file: File;
|
| 577 |
+
index: number;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 578 |
}) => {
|
| 579 |
+
const ext = file.name.toLowerCase();
|
| 580 |
+
const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e));
|
| 581 |
|
| 582 |
+
const label = ext.endsWith(".pdf")
|
| 583 |
+
? "PDF"
|
| 584 |
+
: ext.endsWith(".pptx") || ext.endsWith(".ppt")
|
| 585 |
+
? "Presentation"
|
| 586 |
+
: ext.endsWith(".docx") || ext.endsWith(".doc")
|
| 587 |
+
? "Document"
|
| 588 |
+
: isImage
|
| 589 |
+
? "Image"
|
| 590 |
+
: "File";
|
| 591 |
|
| 592 |
+
const thumbUrl = isImage ? getOrCreate(file) : null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 593 |
|
| 594 |
+
return (
|
| 595 |
+
<div className="flex items-center gap-3 rounded-xl border border-border bg-card px-3 py-2 shadow-sm w-[340px] max-w-full">
|
| 596 |
+
{/* ✅ FIX: type="button" + preventDefault/stopPropagation + direct remove */}
|
| 597 |
+
<button
|
| 598 |
+
type="button"
|
| 599 |
+
className="h-6 w-6 rounded-full border border-border bg-background flex items-center justify-center hover:bg-muted"
|
| 600 |
+
onClick={(e) => {
|
| 601 |
+
e.preventDefault();
|
| 602 |
+
e.stopPropagation();
|
| 603 |
+
|
| 604 |
+
// If user is previewing this file, close viewer safely
|
| 605 |
+
setSelectedFile((prev) => {
|
| 606 |
+
if (!prev) return prev;
|
| 607 |
+
if (prev.index === index) return null;
|
| 608 |
+
return prev;
|
| 609 |
+
});
|
| 610 |
+
setShowFileViewer(false);
|
| 611 |
+
|
| 612 |
+
onRemoveFile(index);
|
| 613 |
+
}}
|
| 614 |
+
aria-label="Remove file"
|
| 615 |
+
title="Remove"
|
| 616 |
>
|
| 617 |
+
<X className="h-4 w-4" />
|
| 618 |
+
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 619 |
|
| 620 |
+
<div className="min-w-0 flex-1">
|
| 621 |
+
<div className="text-sm font-medium truncate" title={file.name}>
|
| 622 |
+
{file.name}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 623 |
</div>
|
| 624 |
+
<div className="text-xs text-muted-foreground">{label}</div>
|
| 625 |
</div>
|
|
|
|
|
|
|
| 626 |
|
| 627 |
+
{isImage ? (
|
| 628 |
+
<div className="relative h-14 w-14 flex-shrink-0 rounded-lg overflow-hidden border border-border">
|
| 629 |
+
{thumbUrl ? (
|
| 630 |
+
<img
|
| 631 |
+
src={thumbUrl}
|
| 632 |
+
alt={file.name}
|
| 633 |
+
className="h-full w-full object-cover"
|
| 634 |
+
draggable={false}
|
| 635 |
+
/>
|
| 636 |
+
) : (
|
| 637 |
+
<div className="h-full w-full flex items-center justify-center bg-muted">
|
| 638 |
+
<ImageIcon className="h-5 w-5 text-muted-foreground" />
|
| 639 |
+
</div>
|
| 640 |
+
)}
|
| 641 |
</div>
|
| 642 |
+
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 643 |
</div>
|
| 644 |
);
|
| 645 |
};
|
|
|
|
| 658 |
const ext = file.name.toLowerCase();
|
| 659 |
|
| 660 |
if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e))) {
|
| 661 |
+
// Use objectURL for viewer as well
|
| 662 |
+
setContent(getOrCreate(file));
|
| 663 |
+
setLoading(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 664 |
return;
|
| 665 |
}
|
| 666 |
|
|
|
|
| 687 |
};
|
| 688 |
|
| 689 |
loadFile();
|
| 690 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 691 |
}, [file]);
|
| 692 |
|
| 693 |
if (loading) return <div className="text-center py-8">Loading...</div>;
|
|
|
|
| 704 |
);
|
| 705 |
}
|
| 706 |
|
| 707 |
+
return (
|
| 708 |
+
<div className="whitespace-pre-wrap text-sm font-mono p-4 bg-muted rounded-lg max-h-[60vh] overflow-y-auto">
|
| 709 |
+
{content}
|
| 710 |
+
</div>
|
| 711 |
+
);
|
| 712 |
};
|
| 713 |
|
|
|
|
| 714 |
const bottomPad = Math.max(24, composerHeight + 24);
|
| 715 |
|
| 716 |
return (
|
| 717 |
<div className="relative flex flex-col h-full min-h-0 w-full overflow-hidden">
|
| 718 |
+
{/* Top Bar */}
|
|
|
|
|
|
|
| 719 |
<div
|
| 720 |
className={`flex-shrink-0 flex items-center justify-between px-4 bg-card z-20 ${
|
| 721 |
showTopBorder ? "border-b border-border" : ""
|
|
|
|
| 728 |
const current = workspaces.find((w) => w.id === currentWorkspaceId);
|
| 729 |
if (current?.type === "group") {
|
| 730 |
if (current.category === "course" && current.courseName) {
|
| 731 |
+
return <div className="h-9 px-3 inline-flex items-center rounded-md border font-semibold">{current.courseName}</div>;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 732 |
}
|
| 733 |
return null;
|
| 734 |
}
|
|
|
|
| 750 |
})()}
|
| 751 |
</div>
|
| 752 |
|
| 753 |
+
{/* Tabs - Center */}
|
| 754 |
<div className="absolute left-1/2 -translate-x-1/2 flex-shrink-0">
|
| 755 |
<Tabs value={chatMode} onValueChange={(value) => onChatModeChange(value as ChatMode)} className="w-auto" orientation="horizontal">
|
| 756 |
<TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground">
|
|
|
|
| 825 |
</div>
|
| 826 |
</div>
|
| 827 |
|
| 828 |
+
{/* Scroll Container */}
|
|
|
|
|
|
|
| 829 |
<div ref={scrollContainerRef} className="flex-1 min-h-0 overflow-y-auto overscroll-contain" style={{ overscrollBehavior: "contain" }}>
|
|
|
|
| 830 |
<div className="py-6" style={{ paddingBottom: bottomPad }}>
|
| 831 |
<div className="w-full space-y-6 max-w-4xl mx-auto">
|
| 832 |
{messages.map((message) => (
|
|
|
|
| 834 |
<Message
|
| 835 |
message={message}
|
| 836 |
showSenderInfo={spaceType === "group"}
|
| 837 |
+
isFirstGreeting={(message.id === "1" || message.id === "review-1" || message.id === "quiz-1") && message.role === "assistant"}
|
|
|
|
|
|
|
| 838 |
showNextButton={message.showNextButton && !isAppTyping}
|
| 839 |
onNextQuestion={onNextQuestion}
|
| 840 |
chatMode={chatMode}
|
|
|
|
| 882 |
</div>
|
| 883 |
</div>
|
| 884 |
|
| 885 |
+
{/* Scroll-to-bottom button */}
|
|
|
|
|
|
|
| 886 |
{showScrollButton && (
|
| 887 |
<div className="absolute z-30 left-0 right-0 flex justify-center pointer-events-none" style={{ bottom: composerHeight + 16 }}>
|
| 888 |
<Button
|
|
|
|
| 897 |
</div>
|
| 898 |
)}
|
| 899 |
|
| 900 |
+
{/* Composer */}
|
|
|
|
|
|
|
| 901 |
<div ref={composerRef} className="flex-shrink-0 bg-background/95 backdrop-blur-sm z-20 border-t border-border">
|
| 902 |
<div className="max-w-4xl mx-auto px-4 py-4">
|
| 903 |
+
{/* ✅ Uploaded Files Preview (chip UI) */}
|
| 904 |
{uploadedFiles.length > 0 && (
|
| 905 |
<div className="mb-2 flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
| 906 |
+
{uploadedFiles.map((u, index) => (
|
| 907 |
+
<FileChip key={`${u.file.name}-${u.file.size}-${u.file.lastModified}-${index}`} file={u.file} index={index} />
|
| 908 |
+
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 909 |
</div>
|
| 910 |
)}
|
| 911 |
|
|
|
|
| 936 |
</Button>
|
| 937 |
</DropdownMenuTrigger>
|
| 938 |
<DropdownMenuContent align="start" className="w-56">
|
| 939 |
+
<DropdownMenuItem onClick={() => onLearningModeChange("general")} className={learningMode === "general" ? "bg-accent" : ""}>
|
|
|
|
|
|
|
|
|
|
| 940 |
<div className="flex flex-col">
|
| 941 |
<span className="font-medium">General</span>
|
| 942 |
<span className="text-xs text-muted-foreground">Answer various questions (context required)</span>
|
| 943 |
</div>
|
| 944 |
</DropdownMenuItem>
|
| 945 |
|
| 946 |
+
<DropdownMenuItem onClick={() => onLearningModeChange("concept")} className={learningMode === "concept" ? "bg-accent" : ""}>
|
|
|
|
|
|
|
|
|
|
| 947 |
<div className="flex flex-col">
|
| 948 |
<span className="font-medium">Concept Explainer</span>
|
| 949 |
<span className="text-xs text-muted-foreground">Get detailed explanations of concepts</span>
|
| 950 |
</div>
|
| 951 |
</DropdownMenuItem>
|
| 952 |
|
| 953 |
+
<DropdownMenuItem onClick={() => onLearningModeChange("socratic")} className={learningMode === "socratic" ? "bg-accent" : ""}>
|
|
|
|
|
|
|
|
|
|
| 954 |
<div className="flex flex-col">
|
| 955 |
<span className="font-medium">Socratic Tutor</span>
|
| 956 |
<span className="text-xs text-muted-foreground">Learn through guided questions</span>
|
|
|
|
| 964 |
</div>
|
| 965 |
</DropdownMenuItem>
|
| 966 |
|
| 967 |
+
<DropdownMenuItem onClick={() => onLearningModeChange("assignment")} className={learningMode === "assignment" ? "bg-accent" : ""}>
|
|
|
|
|
|
|
|
|
|
| 968 |
<div className="flex flex-col">
|
| 969 |
<span className="font-medium">Assignment Helper</span>
|
| 970 |
<span className="text-xs text-muted-foreground">Get help with assignments</span>
|
| 971 |
</div>
|
| 972 |
</DropdownMenuItem>
|
| 973 |
|
| 974 |
+
<DropdownMenuItem onClick={() => onLearningModeChange("summary")} className={learningMode === "summary" ? "bg-accent" : ""}>
|
|
|
|
|
|
|
|
|
|
| 975 |
<div className="flex flex-col">
|
| 976 |
<span className="font-medium">Quick Summary</span>
|
| 977 |
<span className="text-xs text-muted-foreground">Get concise summaries</span>
|
|
|
|
| 1180 |
</DialogContent>
|
| 1181 |
</Dialog>
|
| 1182 |
|
| 1183 |
+
{/* Delete File Confirmation Dialog (kept, but chip delete is instant now) */}
|
| 1184 |
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
| 1185 |
+
<AlertDialogContent>
|
|
|
|
| 1186 |
<AlertDialogHeader>
|
| 1187 |
<AlertDialogTitle>Delete File</AlertDialogTitle>
|
| 1188 |
<AlertDialogDescription>
|
| 1189 |
+
Are you sure you want to delete "{fileToDelete !== null ? uploadedFiles[fileToDelete]?.file.name : ""}"? This action cannot be undone.
|
|
|
|
| 1190 |
</AlertDialogDescription>
|
| 1191 |
</AlertDialogHeader>
|
| 1192 |
<AlertDialogFooter>
|