Spaces:
Sleeping
Sleeping
Update web/src/components/ChatArea.tsx
Browse files- web/src/components/ChatArea.tsx +108 -52
web/src/components/ChatArea.tsx
CHANGED
|
@@ -33,8 +33,20 @@ import type {
|
|
| 33 |
} from "../App";
|
| 34 |
import { toast } from "sonner";
|
| 35 |
import { jsPDF } from "jspdf";
|
| 36 |
-
import {
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
import { Checkbox } from "./ui/checkbox";
|
| 39 |
import {
|
| 40 |
AlertDialog,
|
|
@@ -46,10 +58,19 @@ import {
|
|
| 46 |
AlertDialogHeader,
|
| 47 |
AlertDialogTitle,
|
| 48 |
} from "./ui/alert-dialog";
|
| 49 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
|
@@ -82,7 +103,12 @@ interface ChatAreaProps {
|
|
| 82 |
savedChats: SavedChat[];
|
| 83 |
workspaces: Workspace[];
|
| 84 |
currentWorkspaceId: string;
|
| 85 |
-
onSaveFile?: (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
leftPanelVisible?: boolean;
|
| 87 |
currentCourseId?: string;
|
| 88 |
onCourseChange?: (courseId: string) => void;
|
|
@@ -155,6 +181,9 @@ export function ChatArea({
|
|
| 155 |
const [shareLink, setShareLink] = useState("");
|
| 156 |
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
|
| 157 |
|
|
|
|
|
|
|
|
|
|
| 158 |
const courses =
|
| 159 |
availableCourses.length > 0
|
| 160 |
? availableCourses
|
|
@@ -298,7 +327,9 @@ export function ChatArea({
|
|
| 298 |
|
| 299 |
const buildPreviewContent = () => {
|
| 300 |
if (!messages.length) return "";
|
| 301 |
-
return messages
|
|
|
|
|
|
|
| 302 |
};
|
| 303 |
|
| 304 |
const buildSummaryContent = () => {
|
|
@@ -487,8 +518,8 @@ export function ChatArea({
|
|
| 487 |
|
| 488 |
const validFiles = files.filter((file) => {
|
| 489 |
const ext = file.name.toLowerCase();
|
| 490 |
-
return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some(
|
| 491 |
-
ext.endsWith(allowed)
|
| 492 |
);
|
| 493 |
});
|
| 494 |
|
|
@@ -505,8 +536,8 @@ export function ChatArea({
|
|
| 505 |
if (files.length > 0) {
|
| 506 |
const validFiles = files.filter((file) => {
|
| 507 |
const ext = file.name.toLowerCase();
|
| 508 |
-
return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some(
|
| 509 |
-
ext.endsWith(allowed)
|
| 510 |
);
|
| 511 |
});
|
| 512 |
|
|
@@ -560,8 +591,7 @@ export function ChatArea({
|
|
| 560 |
if (ext.endsWith(".pdf")) return { bgColor: "bg-red-500", type: "PDF" };
|
| 561 |
if (ext.endsWith(".docx") || ext.endsWith(".doc")) return { bgColor: "bg-blue-500", type: "Document" };
|
| 562 |
if (ext.endsWith(".pptx") || ext.endsWith(".ppt")) return { bgColor: "bg-orange-500", type: "Presentation" };
|
| 563 |
-
if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e)))
|
| 564 |
-
return { bgColor: "bg-green-500", type: "Image" };
|
| 565 |
return { bgColor: "bg-gray-500", type: "File" };
|
| 566 |
};
|
| 567 |
|
|
@@ -571,6 +601,12 @@ export function ChatArea({
|
|
| 571 |
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
| 572 |
};
|
| 573 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 574 |
const FileThumbnail = ({
|
| 575 |
file,
|
| 576 |
Icon,
|
|
@@ -586,25 +622,8 @@ export function ChatArea({
|
|
| 586 |
onPreview: () => void;
|
| 587 |
onRemove: (e: React.MouseEvent) => void;
|
| 588 |
}) => {
|
| 589 |
-
|
| 590 |
-
const
|
| 591 |
-
|
| 592 |
-
useEffect(() => {
|
| 593 |
-
if (!isImage) {
|
| 594 |
-
setImagePreview(null);
|
| 595 |
-
setImageLoading(false);
|
| 596 |
-
return;
|
| 597 |
-
}
|
| 598 |
-
|
| 599 |
-
setImageLoading(true);
|
| 600 |
-
const reader = new FileReader();
|
| 601 |
-
reader.onload = (e) => {
|
| 602 |
-
setImagePreview(e.target?.result as string);
|
| 603 |
-
setImageLoading(false);
|
| 604 |
-
};
|
| 605 |
-
reader.onerror = () => setImageLoading(false);
|
| 606 |
-
reader.readAsDataURL(file);
|
| 607 |
-
}, [file, isImage]);
|
| 608 |
|
| 609 |
if (isImage) {
|
| 610 |
return (
|
|
@@ -615,23 +634,19 @@ export function ChatArea({
|
|
| 615 |
>
|
| 616 |
<div className="w-full h-full relative bg-card border border-border rounded-lg hover:border-primary/50 transition-colors">
|
| 617 |
<div className="w-full h-full overflow-hidden rounded-lg absolute inset-0">
|
| 618 |
-
{
|
| 619 |
-
<div className="w-full h-full flex items-center justify-center bg-muted">
|
| 620 |
-
<Icon className="h-5 w-5 text-muted-foreground animate-pulse" />
|
| 621 |
-
</div>
|
| 622 |
-
) : imagePreview ? (
|
| 623 |
<img
|
| 624 |
-
src={
|
| 625 |
alt={file.name}
|
| 626 |
className="w-full h-full object-cover"
|
|
|
|
| 627 |
onError={(e) => {
|
| 628 |
e.currentTarget.style.display = "none";
|
| 629 |
-
setImageLoading(false);
|
| 630 |
}}
|
| 631 |
/>
|
| 632 |
) : (
|
| 633 |
<div className="w-full h-full flex items-center justify-center bg-muted">
|
| 634 |
-
<Icon className="h-5 w-5 text-muted-foreground" />
|
| 635 |
</div>
|
| 636 |
)}
|
| 637 |
</div>
|
|
@@ -644,6 +659,7 @@ export function ChatArea({
|
|
| 644 |
onRemove(e);
|
| 645 |
}}
|
| 646 |
style={{ zIndex: 100 }}
|
|
|
|
| 647 |
>
|
| 648 |
<X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
|
| 649 |
</button>
|
|
@@ -674,6 +690,7 @@ export function ChatArea({
|
|
| 674 |
onRemove(e);
|
| 675 |
}}
|
| 676 |
style={{ zIndex: 10 }}
|
|
|
|
| 677 |
>
|
| 678 |
<X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
|
| 679 |
</button>
|
|
@@ -748,7 +765,11 @@ export function ChatArea({
|
|
| 748 |
);
|
| 749 |
}
|
| 750 |
|
| 751 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 752 |
};
|
| 753 |
|
| 754 |
// ✅ Reserve space for composer so last message is never hidden
|
|
@@ -781,7 +802,10 @@ export function ChatArea({
|
|
| 781 |
}
|
| 782 |
|
| 783 |
return (
|
| 784 |
-
<Select
|
|
|
|
|
|
|
|
|
|
| 785 |
<SelectTrigger className="w-[200px] h-9 font-semibold">
|
| 786 |
<SelectValue placeholder="Select course" />
|
| 787 |
</SelectTrigger>
|
|
@@ -799,7 +823,12 @@ export function ChatArea({
|
|
| 799 |
|
| 800 |
{/* Chat Mode Tabs - Center */}
|
| 801 |
<div className="absolute left-1/2 -translate-x-1/2 flex-shrink-0">
|
| 802 |
-
<Tabs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 803 |
<TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground">
|
| 804 |
<TabsTrigger value="ask" className="w-[140px] px-3 text-sm">
|
| 805 |
Ask
|
|
@@ -875,7 +904,11 @@ export function ChatArea({
|
|
| 875 |
{/* =========================
|
| 876 |
1) Scroll Container (ONLY this scrolls)
|
| 877 |
========================= */}
|
| 878 |
-
<div
|
|
|
|
|
|
|
|
|
|
|
|
|
| 879 |
{/* Messages */}
|
| 880 |
<div className="py-6" style={{ paddingBottom: bottomPad }}>
|
| 881 |
<div className="w-full space-y-6 max-w-4xl mx-auto">
|
|
@@ -885,7 +918,8 @@ export function ChatArea({
|
|
| 885 |
message={message}
|
| 886 |
showSenderInfo={spaceType === "group"}
|
| 887 |
isFirstGreeting={
|
| 888 |
-
(message.id === "1" || message.id === "review-1" || message.id === "quiz-1") &&
|
|
|
|
| 889 |
}
|
| 890 |
showNextButton={message.showNextButton && !isAppTyping}
|
| 891 |
onNextQuestion={onNextQuestion}
|
|
@@ -923,9 +957,18 @@ export function ChatArea({
|
|
| 923 |
</div>
|
| 924 |
<div className="bg-muted rounded-2xl px-4 py-3">
|
| 925 |
<div className="flex gap-1">
|
| 926 |
-
<div
|
| 927 |
-
|
| 928 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 929 |
</div>
|
| 930 |
</div>
|
| 931 |
</div>
|
|
@@ -961,9 +1004,7 @@ export function ChatArea({
|
|
| 961 |
{uploadedFiles.map((uploadedFile, index) => {
|
| 962 |
const Icon = getFileIcon(uploadedFile.file.name);
|
| 963 |
const fileInfo = getFileTypeInfo(uploadedFile.file.name);
|
| 964 |
-
const isImage =
|
| 965 |
-
uploadedFile.file.name.toLowerCase().endsWith(`.${ext}`)
|
| 966 |
-
);
|
| 967 |
|
| 968 |
return (
|
| 969 |
<div key={index}>
|
|
@@ -978,6 +1019,14 @@ export function ChatArea({
|
|
| 978 |
}}
|
| 979 |
onRemove={(e) => {
|
| 980 |
e.stopPropagation();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 981 |
setFileToDelete(index);
|
| 982 |
setShowDeleteDialog(true);
|
| 983 |
}}
|
|
@@ -1045,7 +1094,10 @@ export function ChatArea({
|
|
| 1045 |
</div>
|
| 1046 |
</DropdownMenuItem>
|
| 1047 |
|
| 1048 |
-
<DropdownMenuItem
|
|
|
|
|
|
|
|
|
|
| 1049 |
<div className="flex flex-col">
|
| 1050 |
<span className="font-medium">Exam Prep</span>
|
| 1051 |
<span className="text-xs text-muted-foreground">Practice with quiz questions</span>
|
|
@@ -1138,7 +1190,9 @@ export function ChatArea({
|
|
| 1138 |
<AlertDialogContent>
|
| 1139 |
<AlertDialogHeader>
|
| 1140 |
<AlertDialogTitle>Start New Conversation</AlertDialogTitle>
|
| 1141 |
-
<AlertDialogDescription>
|
|
|
|
|
|
|
| 1142 |
|
| 1143 |
<Button
|
| 1144 |
variant="ghost"
|
|
@@ -1319,7 +1373,9 @@ export function ChatArea({
|
|
| 1319 |
</DialogTitle>
|
| 1320 |
<DialogDescription>File size: {selectedFile ? formatFileSize(selectedFile.file.size) : ""}</DialogDescription>
|
| 1321 |
</DialogHeader>
|
| 1322 |
-
<div className="flex-1 min-h-0 overflow-y-auto mt-4">
|
|
|
|
|
|
|
| 1323 |
</DialogContent>
|
| 1324 |
</Dialog>
|
| 1325 |
|
|
|
|
| 33 |
} from "../App";
|
| 34 |
import { toast } from "sonner";
|
| 35 |
import { jsPDF } from "jspdf";
|
| 36 |
+
import {
|
| 37 |
+
DropdownMenu,
|
| 38 |
+
DropdownMenuContent,
|
| 39 |
+
DropdownMenuItem,
|
| 40 |
+
DropdownMenuTrigger,
|
| 41 |
+
} from "./ui/dropdown-menu";
|
| 42 |
+
import {
|
| 43 |
+
Dialog,
|
| 44 |
+
DialogContent,
|
| 45 |
+
DialogDescription,
|
| 46 |
+
DialogFooter,
|
| 47 |
+
DialogHeader,
|
| 48 |
+
DialogTitle,
|
| 49 |
+
} from "./ui/dialog";
|
| 50 |
import { Checkbox } from "./ui/checkbox";
|
| 51 |
import {
|
| 52 |
AlertDialog,
|
|
|
|
| 58 |
AlertDialogHeader,
|
| 59 |
AlertDialogTitle,
|
| 60 |
} from "./ui/alert-dialog";
|
| 61 |
+
import {
|
| 62 |
+
Select,
|
| 63 |
+
SelectContent,
|
| 64 |
+
SelectItem,
|
| 65 |
+
SelectTrigger,
|
| 66 |
+
SelectValue,
|
| 67 |
+
} from "./ui/select";
|
| 68 |
import { SmartReview } from "./SmartReview";
|
| 69 |
import clareAvatar from "../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png";
|
| 70 |
|
| 71 |
+
// ✅ NEW: objectURL cache hook (you added this file)
|
| 72 |
+
import { useObjectUrlCache } from "../lib/useObjectUrlCache";
|
| 73 |
+
|
| 74 |
type ReviewEventType = "send_message" | "review_topic" | "review_all";
|
| 75 |
|
| 76 |
interface ChatAreaProps {
|
|
|
|
| 103 |
savedChats: SavedChat[];
|
| 104 |
workspaces: Workspace[];
|
| 105 |
currentWorkspaceId: string;
|
| 106 |
+
onSaveFile?: (
|
| 107 |
+
content: string,
|
| 108 |
+
type: "export" | "summary",
|
| 109 |
+
format?: "pdf" | "text",
|
| 110 |
+
workspaceId?: string
|
| 111 |
+
) => void;
|
| 112 |
leftPanelVisible?: boolean;
|
| 113 |
currentCourseId?: string;
|
| 114 |
onCourseChange?: (courseId: string) => void;
|
|
|
|
| 181 |
const [shareLink, setShareLink] = useState("");
|
| 182 |
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
|
| 183 |
|
| 184 |
+
// ✅ NEW: objectURL cache for image thumbnail rendering
|
| 185 |
+
const { getObjectUrl, revokeObjectUrl } = useObjectUrlCache();
|
| 186 |
+
|
| 187 |
const courses =
|
| 188 |
availableCourses.length > 0
|
| 189 |
? availableCourses
|
|
|
|
| 327 |
|
| 328 |
const buildPreviewContent = () => {
|
| 329 |
if (!messages.length) return "";
|
| 330 |
+
return messages
|
| 331 |
+
.map((msg) => `${msg.role === "user" ? "You" : "Clare"}: ${msg.content}`)
|
| 332 |
+
.join("\n\n");
|
| 333 |
};
|
| 334 |
|
| 335 |
const buildSummaryContent = () => {
|
|
|
|
| 518 |
|
| 519 |
const validFiles = files.filter((file) => {
|
| 520 |
const ext = file.name.toLowerCase();
|
| 521 |
+
return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some(
|
| 522 |
+
(allowed) => ext.endsWith(allowed)
|
| 523 |
);
|
| 524 |
});
|
| 525 |
|
|
|
|
| 536 |
if (files.length > 0) {
|
| 537 |
const validFiles = files.filter((file) => {
|
| 538 |
const ext = file.name.toLowerCase();
|
| 539 |
+
return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some(
|
| 540 |
+
(allowed) => ext.endsWith(allowed)
|
| 541 |
);
|
| 542 |
});
|
| 543 |
|
|
|
|
| 591 |
if (ext.endsWith(".pdf")) return { bgColor: "bg-red-500", type: "PDF" };
|
| 592 |
if (ext.endsWith(".docx") || ext.endsWith(".doc")) return { bgColor: "bg-blue-500", type: "Document" };
|
| 593 |
if (ext.endsWith(".pptx") || ext.endsWith(".ppt")) return { bgColor: "bg-orange-500", type: "Presentation" };
|
| 594 |
+
if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e))) return { bgColor: "bg-green-500", type: "Image" };
|
|
|
|
| 595 |
return { bgColor: "bg-gray-500", type: "File" };
|
| 596 |
};
|
| 597 |
|
|
|
|
| 601 |
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
| 602 |
};
|
| 603 |
|
| 604 |
+
// ✅ (display-only helper)
|
| 605 |
+
const isImageByName = (name: string) => {
|
| 606 |
+
const ext = name.toLowerCase();
|
| 607 |
+
return [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e));
|
| 608 |
+
};
|
| 609 |
+
|
| 610 |
const FileThumbnail = ({
|
| 611 |
file,
|
| 612 |
Icon,
|
|
|
|
| 622 |
onPreview: () => void;
|
| 623 |
onRemove: (e: React.MouseEvent) => void;
|
| 624 |
}) => {
|
| 625 |
+
// ✅ Use cached objectURL for stable image preview
|
| 626 |
+
const src = isImage ? getObjectUrl(file) : null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 627 |
|
| 628 |
if (isImage) {
|
| 629 |
return (
|
|
|
|
| 634 |
>
|
| 635 |
<div className="w-full h-full relative bg-card border border-border rounded-lg hover:border-primary/50 transition-colors">
|
| 636 |
<div className="w-full h-full overflow-hidden rounded-lg absolute inset-0">
|
| 637 |
+
{src ? (
|
|
|
|
|
|
|
|
|
|
|
|
|
| 638 |
<img
|
| 639 |
+
src={src}
|
| 640 |
alt={file.name}
|
| 641 |
className="w-full h-full object-cover"
|
| 642 |
+
draggable={false}
|
| 643 |
onError={(e) => {
|
| 644 |
e.currentTarget.style.display = "none";
|
|
|
|
| 645 |
}}
|
| 646 |
/>
|
| 647 |
) : (
|
| 648 |
<div className="w-full h-full flex items-center justify-center bg-muted">
|
| 649 |
+
<Icon className="h-5 w-5 text-muted-foreground animate-pulse" />
|
| 650 |
</div>
|
| 651 |
)}
|
| 652 |
</div>
|
|
|
|
| 659 |
onRemove(e);
|
| 660 |
}}
|
| 661 |
style={{ zIndex: 100 }}
|
| 662 |
+
aria-label="Remove file"
|
| 663 |
>
|
| 664 |
<X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
|
| 665 |
</button>
|
|
|
|
| 690 |
onRemove(e);
|
| 691 |
}}
|
| 692 |
style={{ zIndex: 10 }}
|
| 693 |
+
aria-label="Remove file"
|
| 694 |
>
|
| 695 |
<X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
|
| 696 |
</button>
|
|
|
|
| 765 |
);
|
| 766 |
}
|
| 767 |
|
| 768 |
+
return (
|
| 769 |
+
<div className="whitespace-pre-wrap text-sm font-mono p-4 bg-muted rounded-lg max-h-[60vh] overflow-y-auto">
|
| 770 |
+
{content}
|
| 771 |
+
</div>
|
| 772 |
+
);
|
| 773 |
};
|
| 774 |
|
| 775 |
// ✅ Reserve space for composer so last message is never hidden
|
|
|
|
| 802 |
}
|
| 803 |
|
| 804 |
return (
|
| 805 |
+
<Select
|
| 806 |
+
value={currentCourseId || "course1"}
|
| 807 |
+
onValueChange={(val) => onCourseChange && onCourseChange(val)}
|
| 808 |
+
>
|
| 809 |
<SelectTrigger className="w-[200px] h-9 font-semibold">
|
| 810 |
<SelectValue placeholder="Select course" />
|
| 811 |
</SelectTrigger>
|
|
|
|
| 823 |
|
| 824 |
{/* Chat Mode Tabs - Center */}
|
| 825 |
<div className="absolute left-1/2 -translate-x-1/2 flex-shrink-0">
|
| 826 |
+
<Tabs
|
| 827 |
+
value={chatMode}
|
| 828 |
+
onValueChange={(value) => onChatModeChange(value as ChatMode)}
|
| 829 |
+
className="w-auto"
|
| 830 |
+
orientation="horizontal"
|
| 831 |
+
>
|
| 832 |
<TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground">
|
| 833 |
<TabsTrigger value="ask" className="w-[140px] px-3 text-sm">
|
| 834 |
Ask
|
|
|
|
| 904 |
{/* =========================
|
| 905 |
1) Scroll Container (ONLY this scrolls)
|
| 906 |
========================= */}
|
| 907 |
+
<div
|
| 908 |
+
ref={scrollContainerRef}
|
| 909 |
+
className="flex-1 min-h-0 overflow-y-auto overscroll-contain"
|
| 910 |
+
style={{ overscrollBehavior: "contain" }}
|
| 911 |
+
>
|
| 912 |
{/* Messages */}
|
| 913 |
<div className="py-6" style={{ paddingBottom: bottomPad }}>
|
| 914 |
<div className="w-full space-y-6 max-w-4xl mx-auto">
|
|
|
|
| 918 |
message={message}
|
| 919 |
showSenderInfo={spaceType === "group"}
|
| 920 |
isFirstGreeting={
|
| 921 |
+
(message.id === "1" || message.id === "review-1" || message.id === "quiz-1") &&
|
| 922 |
+
message.role === "assistant"
|
| 923 |
}
|
| 924 |
showNextButton={message.showNextButton && !isAppTyping}
|
| 925 |
onNextQuestion={onNextQuestion}
|
|
|
|
| 957 |
</div>
|
| 958 |
<div className="bg-muted rounded-2xl px-4 py-3">
|
| 959 |
<div className="flex gap-1">
|
| 960 |
+
<div
|
| 961 |
+
className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
|
| 962 |
+
style={{ animationDelay: "0ms" }}
|
| 963 |
+
/>
|
| 964 |
+
<div
|
| 965 |
+
className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
|
| 966 |
+
style={{ animationDelay: "150ms" }}
|
| 967 |
+
/>
|
| 968 |
+
<div
|
| 969 |
+
className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
|
| 970 |
+
style={{ animationDelay: "300ms" }}
|
| 971 |
+
/>
|
| 972 |
</div>
|
| 973 |
</div>
|
| 974 |
</div>
|
|
|
|
| 1004 |
{uploadedFiles.map((uploadedFile, index) => {
|
| 1005 |
const Icon = getFileIcon(uploadedFile.file.name);
|
| 1006 |
const fileInfo = getFileTypeInfo(uploadedFile.file.name);
|
| 1007 |
+
const isImage = isImageByName(uploadedFile.file.name);
|
|
|
|
|
|
|
| 1008 |
|
| 1009 |
return (
|
| 1010 |
<div key={index}>
|
|
|
|
| 1019 |
}}
|
| 1020 |
onRemove={(e) => {
|
| 1021 |
e.stopPropagation();
|
| 1022 |
+
// ✅ revoke objectURL for this image file (display-only change)
|
| 1023 |
+
if (isImage) {
|
| 1024 |
+
try {
|
| 1025 |
+
revokeObjectUrl(uploadedFile.file);
|
| 1026 |
+
} catch {
|
| 1027 |
+
// ignore
|
| 1028 |
+
}
|
| 1029 |
+
}
|
| 1030 |
setFileToDelete(index);
|
| 1031 |
setShowDeleteDialog(true);
|
| 1032 |
}}
|
|
|
|
| 1094 |
</div>
|
| 1095 |
</DropdownMenuItem>
|
| 1096 |
|
| 1097 |
+
<DropdownMenuItem
|
| 1098 |
+
onClick={() => onLearningModeChange("exam")}
|
| 1099 |
+
className={learningMode === "exam" ? "bg-accent" : ""}
|
| 1100 |
+
>
|
| 1101 |
<div className="flex flex-col">
|
| 1102 |
<span className="font-medium">Exam Prep</span>
|
| 1103 |
<span className="text-xs text-muted-foreground">Practice with quiz questions</span>
|
|
|
|
| 1190 |
<AlertDialogContent>
|
| 1191 |
<AlertDialogHeader>
|
| 1192 |
<AlertDialogTitle>Start New Conversation</AlertDialogTitle>
|
| 1193 |
+
<AlertDialogDescription>
|
| 1194 |
+
Would you like to save the current chat before starting a new conversation?
|
| 1195 |
+
</AlertDialogDescription>
|
| 1196 |
|
| 1197 |
<Button
|
| 1198 |
variant="ghost"
|
|
|
|
| 1373 |
</DialogTitle>
|
| 1374 |
<DialogDescription>File size: {selectedFile ? formatFileSize(selectedFile.file.size) : ""}</DialogDescription>
|
| 1375 |
</DialogHeader>
|
| 1376 |
+
<div className="flex-1 min-h-0 overflow-y-auto mt-4">
|
| 1377 |
+
{selectedFile && <FileViewerContent file={selectedFile.file} />}
|
| 1378 |
+
</div>
|
| 1379 |
</DialogContent>
|
| 1380 |
</Dialog>
|
| 1381 |
|