Spaces:
Sleeping
Sleeping
Update web/src/components/ChatArea.tsx
Browse files- web/src/components/ChatArea.tsx +128 -155
web/src/components/ChatArea.tsx
CHANGED
|
@@ -137,7 +137,7 @@ export function ChatArea({
|
|
| 137 |
workspaces,
|
| 138 |
currentWorkspaceId,
|
| 139 |
onSaveFile,
|
| 140 |
-
leftPanelVisible = false,
|
| 141 |
currentCourseId,
|
| 142 |
onCourseChange,
|
| 143 |
availableCourses = [],
|
|
@@ -176,8 +176,7 @@ export function ChatArea({
|
|
| 176 |
{ id: "course4", name: "Web Development" },
|
| 177 |
];
|
| 178 |
|
| 179 |
-
//
|
| 180 |
-
// messagesEndRef is used to scroll inside the scroll container
|
| 181 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 182 |
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
| 183 |
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
@@ -186,16 +185,13 @@ export function ChatArea({
|
|
| 186 |
const previousMessagesLength = useRef(messages.length);
|
| 187 |
|
| 188 |
const scrollToBottom = () => {
|
| 189 |
-
// scrollIntoView works as long as messagesEndRef is inside scrollContainerRef
|
| 190 |
messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
| 191 |
};
|
| 192 |
|
| 193 |
-
// Only auto-scroll when new messages are added (not on initial load)
|
| 194 |
useEffect(() => {
|
| 195 |
if (isInitialMount.current) {
|
| 196 |
isInitialMount.current = false;
|
| 197 |
previousMessagesLength.current = messages.length;
|
| 198 |
-
|
| 199 |
// Keep at top on initial load (your old behavior)
|
| 200 |
if (scrollContainerRef.current) {
|
| 201 |
scrollContainerRef.current.scrollTop = 0;
|
|
@@ -209,7 +205,6 @@ export function ChatArea({
|
|
| 209 |
previousMessagesLength.current = messages.length;
|
| 210 |
}, [messages]);
|
| 211 |
|
| 212 |
-
// Scroll button + top border
|
| 213 |
useEffect(() => {
|
| 214 |
const handleScroll = () => {
|
| 215 |
const el = scrollContainerRef.current;
|
|
@@ -253,7 +248,6 @@ export function ChatArea({
|
|
| 253 |
summary: "Quick Summary",
|
| 254 |
};
|
| 255 |
|
| 256 |
-
// Review topic click
|
| 257 |
const handleReviewTopic = (item: {
|
| 258 |
title: string;
|
| 259 |
previousQuestion: string;
|
|
@@ -365,7 +359,6 @@ export function ChatArea({
|
|
| 365 |
}
|
| 366 |
};
|
| 367 |
|
| 368 |
-
// Check if current chat is already saved
|
| 369 |
const isCurrentChatSaved = (): boolean => {
|
| 370 |
if (messages.length <= 1) return false;
|
| 371 |
|
|
@@ -525,7 +518,6 @@ export function ChatArea({
|
|
| 525 |
setPendingFiles((prev) => prev.map((pf, i) => (i === index ? { ...pf, type } : pf)));
|
| 526 |
};
|
| 527 |
|
| 528 |
-
// File helpers
|
| 529 |
const getFileIcon = (filename: string) => {
|
| 530 |
const ext = filename.toLowerCase();
|
| 531 |
if (ext.endsWith(".pdf")) return FileText;
|
|
@@ -737,147 +729,141 @@ export function ChatArea({
|
|
| 737 |
);
|
| 738 |
};
|
| 739 |
|
| 740 |
-
//
|
| 741 |
-
|
| 742 |
-
// ==============
|
| 743 |
-
// This reserves enough space so the last message won't be hidden behind the bottom composer.
|
| 744 |
-
// If your composer grows, bump this value.
|
| 745 |
-
const MESSAGES_BOTTOM_PADDING = "13rem";
|
| 746 |
|
| 747 |
return (
|
| 748 |
-
//
|
| 749 |
<div className="relative flex flex-col h-full min-h-0 overflow-hidden">
|
| 750 |
{/* =========================
|
| 751 |
-
1)
|
| 752 |
========================= */}
|
| 753 |
<div
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
|
|
|
| 757 |
>
|
| 758 |
-
{/*
|
| 759 |
-
<div
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 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 |
}
|
|
|
|
|
|
|
| 779 |
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
>
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
);
|
| 797 |
-
})()}
|
| 798 |
-
</div>
|
| 799 |
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
| 814 |
-
|
| 815 |
-
|
| 816 |
-
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
| 831 |
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
|
| 844 |
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
|
| 851 |
-
|
| 852 |
-
|
| 853 |
-
|
| 854 |
-
|
| 855 |
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
|
| 867 |
-
|
| 868 |
-
|
| 869 |
-
|
| 870 |
-
|
| 871 |
-
|
| 872 |
-
|
| 873 |
-
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
|
| 877 |
-
</div>
|
| 878 |
</div>
|
|
|
|
| 879 |
|
| 880 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 881 |
<div className="py-6" style={{ paddingBottom: MESSAGES_BOTTOM_PADDING }}>
|
| 882 |
<div className="w-full space-y-6 max-w-4xl mx-auto">
|
| 883 |
{messages.map((message) => (
|
|
@@ -925,31 +911,21 @@ export function ChatArea({
|
|
| 925 |
</div>
|
| 926 |
<div className="bg-muted rounded-2xl px-4 py-3">
|
| 927 |
<div className="flex gap-1">
|
| 928 |
-
<div
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
/>
|
| 932 |
-
<div
|
| 933 |
-
className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
|
| 934 |
-
style={{ animationDelay: "150ms" }}
|
| 935 |
-
/>
|
| 936 |
-
<div
|
| 937 |
-
className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
|
| 938 |
-
style={{ animationDelay: "300ms" }}
|
| 939 |
-
/>
|
| 940 |
</div>
|
| 941 |
</div>
|
| 942 |
</div>
|
| 943 |
)}
|
| 944 |
|
| 945 |
-
{/* MUST be inside scroll container */}
|
| 946 |
<div ref={messagesEndRef} />
|
| 947 |
</div>
|
| 948 |
</div>
|
| 949 |
</div>
|
| 950 |
|
| 951 |
{/* =========================
|
| 952 |
-
2) Scroll-to-bottom button (fixed
|
| 953 |
========================= */}
|
| 954 |
{showScrollButton && (
|
| 955 |
<div className="absolute z-30 left-0 right-0 flex justify-center pointer-events-none" style={{ bottom: "132px" }}>
|
|
@@ -966,10 +942,9 @@ export function ChatArea({
|
|
| 966 |
)}
|
| 967 |
|
| 968 |
{/* =========================
|
| 969 |
-
3)
|
| 970 |
-
NOTE: moved from absolute -> sticky footer container.
|
| 971 |
========================= */}
|
| 972 |
-
<div className="flex-shrink-0
|
| 973 |
<div className="max-w-4xl mx-auto px-4 py-4">
|
| 974 |
{uploadedFiles.length > 0 && (
|
| 975 |
<div className="mb-2 flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
|
@@ -1349,9 +1324,7 @@ export function ChatArea({
|
|
| 1349 |
</DialogTitle>
|
| 1350 |
<DialogDescription>File size: {selectedFile ? formatFileSize(selectedFile.file.size) : ""}</DialogDescription>
|
| 1351 |
</DialogHeader>
|
| 1352 |
-
<div className="flex-1 min-h-0 overflow-y-auto mt-4">
|
| 1353 |
-
{selectedFile && <FileViewerContent file={selectedFile.file} />}
|
| 1354 |
-
</div>
|
| 1355 |
</DialogContent>
|
| 1356 |
</Dialog>
|
| 1357 |
|
|
|
|
| 137 |
workspaces,
|
| 138 |
currentWorkspaceId,
|
| 139 |
onSaveFile,
|
| 140 |
+
leftPanelVisible = false,
|
| 141 |
currentCourseId,
|
| 142 |
onCourseChange,
|
| 143 |
availableCourses = [],
|
|
|
|
| 176 |
{ id: "course4", name: "Web Development" },
|
| 177 |
];
|
| 178 |
|
| 179 |
+
// Messages scroll container
|
|
|
|
| 180 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 181 |
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
| 182 |
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
| 185 |
const previousMessagesLength = useRef(messages.length);
|
| 186 |
|
| 187 |
const scrollToBottom = () => {
|
|
|
|
| 188 |
messagesEndRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
| 189 |
};
|
| 190 |
|
|
|
|
| 191 |
useEffect(() => {
|
| 192 |
if (isInitialMount.current) {
|
| 193 |
isInitialMount.current = false;
|
| 194 |
previousMessagesLength.current = messages.length;
|
|
|
|
| 195 |
// Keep at top on initial load (your old behavior)
|
| 196 |
if (scrollContainerRef.current) {
|
| 197 |
scrollContainerRef.current.scrollTop = 0;
|
|
|
|
| 205 |
previousMessagesLength.current = messages.length;
|
| 206 |
}, [messages]);
|
| 207 |
|
|
|
|
| 208 |
useEffect(() => {
|
| 209 |
const handleScroll = () => {
|
| 210 |
const el = scrollContainerRef.current;
|
|
|
|
| 248 |
summary: "Quick Summary",
|
| 249 |
};
|
| 250 |
|
|
|
|
| 251 |
const handleReviewTopic = (item: {
|
| 252 |
title: string;
|
| 253 |
previousQuestion: string;
|
|
|
|
| 359 |
}
|
| 360 |
};
|
| 361 |
|
|
|
|
| 362 |
const isCurrentChatSaved = (): boolean => {
|
| 363 |
if (messages.length <= 1) return false;
|
| 364 |
|
|
|
|
| 518 |
setPendingFiles((prev) => prev.map((pf, i) => (i === index ? { ...pf, type } : pf)));
|
| 519 |
};
|
| 520 |
|
|
|
|
| 521 |
const getFileIcon = (filename: string) => {
|
| 522 |
const ext = filename.toLowerCase();
|
| 523 |
if (ext.endsWith(".pdf")) return FileText;
|
|
|
|
| 729 |
);
|
| 730 |
};
|
| 731 |
|
| 732 |
+
// now composer is fixed as sibling => no huge bottom padding needed
|
| 733 |
+
const MESSAGES_BOTTOM_PADDING = "2rem";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 734 |
|
| 735 |
return (
|
| 736 |
+
// ✅ Three-part layout: TopBar (fixed) + Messages (scroll) + Composer (fixed)
|
| 737 |
<div className="relative flex flex-col h-full min-h-0 overflow-hidden">
|
| 738 |
{/* =========================
|
| 739 |
+
1) TOP BAR (fixed)
|
| 740 |
========================= */}
|
| 741 |
<div
|
| 742 |
+
className={`flex-shrink-0 flex items-center justify-between px-4 bg-card ${
|
| 743 |
+
showTopBorder ? "border-b border-border" : "border-b border-border"
|
| 744 |
+
}`}
|
| 745 |
+
style={{ height: "4.5rem", margin: 0, padding: "1rem 1rem", boxSizing: "border-box" }}
|
| 746 |
>
|
| 747 |
+
{/* Course Selector - Left */}
|
| 748 |
+
<div className="flex-shrink-0">
|
| 749 |
+
{(() => {
|
| 750 |
+
const current = workspaces.find((w) => w.id === currentWorkspaceId);
|
| 751 |
+
if (current?.type === "group") {
|
| 752 |
+
if (current.category === "course" && current.courseName) {
|
| 753 |
+
return (
|
| 754 |
+
<div className="h-9 px-3 inline-flex items-center rounded-md border font-semibold">
|
| 755 |
+
{current.courseName}
|
| 756 |
+
</div>
|
| 757 |
+
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 758 |
}
|
| 759 |
+
return null;
|
| 760 |
+
}
|
| 761 |
|
| 762 |
+
return (
|
| 763 |
+
<Select value={currentCourseId || "course1"} onValueChange={(val) => onCourseChange && onCourseChange(val)}>
|
| 764 |
+
<SelectTrigger className="w-[200px] h-9 font-semibold">
|
| 765 |
+
<SelectValue placeholder="Select course" />
|
| 766 |
+
</SelectTrigger>
|
| 767 |
+
<SelectContent>
|
| 768 |
+
{courses.map((course) => (
|
| 769 |
+
<SelectItem key={course.id} value={course.id}>
|
| 770 |
+
{course.name}
|
| 771 |
+
</SelectItem>
|
| 772 |
+
))}
|
| 773 |
+
</SelectContent>
|
| 774 |
+
</Select>
|
| 775 |
+
);
|
| 776 |
+
})()}
|
| 777 |
+
</div>
|
|
|
|
|
|
|
|
|
|
| 778 |
|
| 779 |
+
{/* Chat Mode Tabs - Center */}
|
| 780 |
+
<div className="absolute left-1/2 -translate-x-1/2 flex-shrink-0">
|
| 781 |
+
<Tabs
|
| 782 |
+
value={chatMode}
|
| 783 |
+
onValueChange={(value) => onChatModeChange(value as ChatMode)}
|
| 784 |
+
className="w-auto"
|
| 785 |
+
orientation="horizontal"
|
| 786 |
+
>
|
| 787 |
+
<TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground">
|
| 788 |
+
<TabsTrigger value="ask" className="w-[140px] px-3 text-sm">
|
| 789 |
+
Ask
|
| 790 |
+
</TabsTrigger>
|
| 791 |
+
<TabsTrigger value="review" className="w-[140px] px-3 text-sm relative">
|
| 792 |
+
Review
|
| 793 |
+
<span
|
| 794 |
+
className="absolute top-0 right-0 bg-red-500 rounded-full border-2"
|
| 795 |
+
style={{
|
| 796 |
+
width: "10px",
|
| 797 |
+
height: "10px",
|
| 798 |
+
transform: "translate(25%, -25%)",
|
| 799 |
+
zIndex: 10,
|
| 800 |
+
borderColor: "var(--muted)",
|
| 801 |
+
}}
|
| 802 |
+
/>
|
| 803 |
+
</TabsTrigger>
|
| 804 |
+
<TabsTrigger value="quiz" className="w-[140px] px-3 text-sm">
|
| 805 |
+
Quiz
|
| 806 |
+
</TabsTrigger>
|
| 807 |
+
</TabsList>
|
| 808 |
+
</Tabs>
|
| 809 |
+
</div>
|
| 810 |
|
| 811 |
+
{/* Action Buttons - Right */}
|
| 812 |
+
<div className="flex items-center gap-2 flex-shrink-0">
|
| 813 |
+
<Button
|
| 814 |
+
variant="ghost"
|
| 815 |
+
size="icon"
|
| 816 |
+
onClick={handleSaveClick}
|
| 817 |
+
disabled={!isLoggedIn}
|
| 818 |
+
className={`h-8 w-8 rounded-md hover:bg-muted/50 ${isCurrentChatSaved() ? "text-primary" : ""}`}
|
| 819 |
+
title={isCurrentChatSaved() ? "Unsave" : "Save"}
|
| 820 |
+
>
|
| 821 |
+
<Bookmark className={`h-4 w-4 ${isCurrentChatSaved() ? "fill-primary text-primary" : ""}`} />
|
| 822 |
+
</Button>
|
| 823 |
|
| 824 |
+
<Button
|
| 825 |
+
variant="ghost"
|
| 826 |
+
size="icon"
|
| 827 |
+
onClick={handleOpenDownloadDialog}
|
| 828 |
+
disabled={!isLoggedIn}
|
| 829 |
+
className="h-8 w-8 rounded-md hover:bg-muted/50"
|
| 830 |
+
title="Download"
|
| 831 |
+
>
|
| 832 |
+
<Download className="h-4 w-4" />
|
| 833 |
+
</Button>
|
| 834 |
|
| 835 |
+
<Button
|
| 836 |
+
variant="ghost"
|
| 837 |
+
size="icon"
|
| 838 |
+
onClick={handleShareClick}
|
| 839 |
+
disabled={!isLoggedIn}
|
| 840 |
+
className="h-8 w-8 rounded-md hover:bg-muted/50"
|
| 841 |
+
title="Share"
|
| 842 |
+
>
|
| 843 |
+
<Share2 className="h-4 w-4" />
|
| 844 |
+
</Button>
|
| 845 |
|
| 846 |
+
<Button
|
| 847 |
+
variant="outline"
|
| 848 |
+
onClick={handleClearClick}
|
| 849 |
+
disabled={!isLoggedIn}
|
| 850 |
+
className="h-8 px-3 gap-2 rounded-md border border-border disabled:opacity-60 !bg-[var(--card)] !text-[var(--card-foreground)] hover:!opacity-90 [&_svg]:!text-[var(--card-foreground)] [&_span]:!text-[var(--card-foreground)]"
|
| 851 |
+
title="New Chat"
|
| 852 |
+
>
|
| 853 |
+
<Plus className="h-4 w-4" />
|
| 854 |
+
<span className="text-sm font-medium">New chat</span>
|
| 855 |
+
</Button>
|
|
|
|
| 856 |
</div>
|
| 857 |
+
</div>
|
| 858 |
|
| 859 |
+
{/* =========================
|
| 860 |
+
2) MESSAGES (ONLY this area scrolls)
|
| 861 |
+
========================= */}
|
| 862 |
+
<div
|
| 863 |
+
ref={scrollContainerRef}
|
| 864 |
+
className="flex-1 min-h-0 overflow-y-auto"
|
| 865 |
+
style={{ overscrollBehavior: "contain" }}
|
| 866 |
+
>
|
| 867 |
<div className="py-6" style={{ paddingBottom: MESSAGES_BOTTOM_PADDING }}>
|
| 868 |
<div className="w-full space-y-6 max-w-4xl mx-auto">
|
| 869 |
{messages.map((message) => (
|
|
|
|
| 911 |
</div>
|
| 912 |
<div className="bg-muted rounded-2xl px-4 py-3">
|
| 913 |
<div className="flex gap-1">
|
| 914 |
+
<div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: "0ms" }} />
|
| 915 |
+
<div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: "150ms" }} />
|
| 916 |
+
<div className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: "300ms" }} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 917 |
</div>
|
| 918 |
</div>
|
| 919 |
</div>
|
| 920 |
)}
|
| 921 |
|
|
|
|
| 922 |
<div ref={messagesEndRef} />
|
| 923 |
</div>
|
| 924 |
</div>
|
| 925 |
</div>
|
| 926 |
|
| 927 |
{/* =========================
|
| 928 |
+
2.5) Scroll-to-bottom button (fixed)
|
| 929 |
========================= */}
|
| 930 |
{showScrollButton && (
|
| 931 |
<div className="absolute z-30 left-0 right-0 flex justify-center pointer-events-none" style={{ bottom: "132px" }}>
|
|
|
|
| 942 |
)}
|
| 943 |
|
| 944 |
{/* =========================
|
| 945 |
+
3) COMPOSER (fixed bottom, never scroll)
|
|
|
|
| 946 |
========================= */}
|
| 947 |
+
<div className="flex-shrink-0 bg-background/95 backdrop-blur-sm z-20 border-t border-border">
|
| 948 |
<div className="max-w-4xl mx-auto px-4 py-4">
|
| 949 |
{uploadedFiles.length > 0 && (
|
| 950 |
<div className="mb-2 flex flex-wrap gap-2 max-h-32 overflow-y-auto">
|
|
|
|
| 1324 |
</DialogTitle>
|
| 1325 |
<DialogDescription>File size: {selectedFile ? formatFileSize(selectedFile.file.size) : ""}</DialogDescription>
|
| 1326 |
</DialogHeader>
|
| 1327 |
+
<div className="flex-1 min-h-0 overflow-y-auto mt-4">{selectedFile && <FileViewerContent file={selectedFile.file} />}</div>
|
|
|
|
|
|
|
| 1328 |
</DialogContent>
|
| 1329 |
</Dialog>
|
| 1330 |
|