Spaces:
Sleeping
Sleeping
Update web/src/components/ChatArea.tsx
Browse files- web/src/components/ChatArea.tsx +400 -325
web/src/components/ChatArea.tsx
CHANGED
|
@@ -1,13 +1,12 @@
|
|
| 1 |
// web/src/components/ChatArea.tsx
|
| 2 |
-
import React, { useState, useRef, useEffect } from
|
| 3 |
-
import { Button } from
|
| 4 |
-
import { Textarea } from
|
| 5 |
-
import { Input } from
|
| 6 |
-
import { Label } from
|
| 7 |
import {
|
| 8 |
Send,
|
| 9 |
ArrowDown,
|
| 10 |
-
Trash2,
|
| 11 |
Share2,
|
| 12 |
Upload,
|
| 13 |
X,
|
|
@@ -18,22 +17,37 @@ import {
|
|
| 18 |
Bookmark,
|
| 19 |
Plus,
|
| 20 |
Download,
|
| 21 |
-
Copy
|
| 22 |
-
} from
|
| 23 |
-
import { Message } from
|
| 24 |
-
import {
|
| 25 |
-
import {
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
import {
|
| 30 |
DropdownMenu,
|
| 31 |
DropdownMenuContent,
|
| 32 |
DropdownMenuItem,
|
| 33 |
DropdownMenuTrigger,
|
| 34 |
-
} from
|
| 35 |
-
import {
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
import {
|
| 38 |
AlertDialog,
|
| 39 |
AlertDialogAction,
|
|
@@ -43,10 +57,10 @@ import {
|
|
| 43 |
AlertDialogFooter,
|
| 44 |
AlertDialogHeader,
|
| 45 |
AlertDialogTitle,
|
| 46 |
-
} from
|
| 47 |
-
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from
|
| 48 |
-
import { SmartReview } from
|
| 49 |
-
import clareAvatar from
|
| 50 |
|
| 51 |
interface ChatAreaProps {
|
| 52 |
messages: MessageType[];
|
|
@@ -78,7 +92,12 @@ interface ChatAreaProps {
|
|
| 78 |
savedChats: SavedChat[];
|
| 79 |
workspaces: Workspace[];
|
| 80 |
currentWorkspaceId: string;
|
| 81 |
-
onSaveFile?: (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
leftPanelVisible?: boolean;
|
| 83 |
currentCourseId?: string;
|
| 84 |
onCourseChange?: (courseId: string) => void;
|
|
@@ -124,80 +143,88 @@ export function ChatArea({
|
|
| 124 |
availableCourses = [],
|
| 125 |
showReviewBanner = false,
|
| 126 |
}: ChatAreaProps) {
|
| 127 |
-
const [input, setInput] = useState(
|
| 128 |
const [showScrollButton, setShowScrollButton] = useState(false);
|
| 129 |
const [showTopBorder, setShowTopBorder] = useState(false);
|
| 130 |
const [isDragging, setIsDragging] = useState(false);
|
|
|
|
| 131 |
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
| 132 |
const [showTypeDialog, setShowTypeDialog] = useState(false);
|
|
|
|
| 133 |
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
| 134 |
const [fileToDelete, setFileToDelete] = useState<number | null>(null);
|
|
|
|
| 135 |
const [selectedFile, setSelectedFile] = useState<{ file: File; index: number } | null>(null);
|
| 136 |
const [showFileViewer, setShowFileViewer] = useState(false);
|
|
|
|
| 137 |
const [showDownloadDialog, setShowDownloadDialog] = useState(false);
|
| 138 |
-
const [downloadPreview, setDownloadPreview] = useState(
|
| 139 |
-
const [downloadTab, setDownloadTab] = useState<
|
| 140 |
const [downloadOptions, setDownloadOptions] = useState({ chat: true, summary: false });
|
| 141 |
-
const [showShareDialog, setShowShareDialog] = useState(false);
|
| 142 |
-
const [shareLink, setShareLink] = useState('');
|
| 143 |
-
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>('');
|
| 144 |
|
| 145 |
-
|
| 146 |
-
const
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
| 153 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 154 |
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
| 155 |
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
| 156 |
const isInitialMount = useRef(true);
|
| 157 |
const previousMessagesLength = useRef(messages.length);
|
| 158 |
|
| 159 |
const scrollToBottom = () => {
|
| 160 |
-
messagesEndRef.current?.scrollIntoView({ behavior:
|
| 161 |
};
|
| 162 |
|
| 163 |
// Only auto-scroll when new messages are added (not on initial load)
|
| 164 |
useEffect(() => {
|
| 165 |
-
// Skip auto-scroll on initial mount
|
| 166 |
if (isInitialMount.current) {
|
| 167 |
isInitialMount.current = false;
|
| 168 |
previousMessagesLength.current = messages.length;
|
| 169 |
-
|
|
|
|
| 170 |
if (scrollContainerRef.current) {
|
| 171 |
scrollContainerRef.current.scrollTop = 0;
|
| 172 |
}
|
| 173 |
return;
|
| 174 |
}
|
| 175 |
|
| 176 |
-
// Only scroll if new messages were added (length increased)
|
| 177 |
if (messages.length > previousMessagesLength.current) {
|
| 178 |
scrollToBottom();
|
| 179 |
}
|
| 180 |
previousMessagesLength.current = messages.length;
|
| 181 |
}, [messages]);
|
| 182 |
|
|
|
|
| 183 |
useEffect(() => {
|
| 184 |
const handleScroll = () => {
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
};
|
| 193 |
|
| 194 |
const container = scrollContainerRef.current;
|
| 195 |
-
if (container)
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
}
|
| 201 |
}, [messages]);
|
| 202 |
|
| 203 |
// ✅ Prevent scroll chaining to left panel / outer containers
|
|
@@ -206,10 +233,8 @@ export function ChatArea({
|
|
| 206 |
if (!el) return;
|
| 207 |
|
| 208 |
const onWheel = (e: WheelEvent) => {
|
| 209 |
-
// Always stop bubbling so left panel won't react
|
| 210 |
e.stopPropagation();
|
| 211 |
|
| 212 |
-
// Only preventDefault at boundaries to stop scroll chaining
|
| 213 |
const atTop = el.scrollTop <= 0;
|
| 214 |
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
|
| 215 |
|
|
@@ -227,68 +252,56 @@ export function ChatArea({
|
|
| 227 |
if (!input.trim() || !isLoggedIn) return;
|
| 228 |
|
| 229 |
onSendMessage(input);
|
| 230 |
-
setInput(
|
| 231 |
};
|
| 232 |
|
| 233 |
const handleKeyDown = (e: React.KeyboardEvent) => {
|
| 234 |
-
if (e.key ===
|
| 235 |
e.preventDefault();
|
| 236 |
handleSubmit(e);
|
| 237 |
}
|
| 238 |
};
|
| 239 |
|
| 240 |
const modeLabels: Record<LearningMode, string> = {
|
| 241 |
-
general:
|
| 242 |
-
concept:
|
| 243 |
-
socratic:
|
| 244 |
-
exam:
|
| 245 |
-
assignment:
|
| 246 |
-
summary:
|
| 247 |
};
|
| 248 |
|
| 249 |
-
//
|
| 250 |
-
const handleReviewTopic = (item: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
const userMessage = `Please help me review: ${item.title}`;
|
| 252 |
const reviewData = `REVIEW_TOPIC:${item.title}|${item.previousQuestion}|${item.memoryRetention}|${item.schedule}|${item.status}|${item.weight}|${item.lastReviewed}`;
|
| 253 |
(window as any).__lastReviewData = reviewData;
|
| 254 |
onSendMessage(userMessage);
|
| 255 |
};
|
| 256 |
|
| 257 |
-
// Handle review all button click
|
| 258 |
const handleReviewAll = () => {
|
| 259 |
-
(window as any).__lastReviewData =
|
| 260 |
-
onSendMessage(
|
| 261 |
-
};
|
| 262 |
-
|
| 263 |
-
const handleClearClick = () => {
|
| 264 |
-
const isSavedChat = isCurrentChatSaved();
|
| 265 |
-
|
| 266 |
-
if (isSavedChat) {
|
| 267 |
-
onConfirmClear(false);
|
| 268 |
-
return;
|
| 269 |
-
}
|
| 270 |
-
|
| 271 |
-
const hasUserMessages = messages.some(msg => msg.role === 'user');
|
| 272 |
-
if (!hasUserMessages) {
|
| 273 |
-
onClearConversation();
|
| 274 |
-
return;
|
| 275 |
-
}
|
| 276 |
-
|
| 277 |
-
onClearConversation();
|
| 278 |
};
|
| 279 |
|
| 280 |
const buildPreviewContent = () => {
|
| 281 |
-
if (!messages.length) return
|
| 282 |
-
return messages
|
| 283 |
-
.map((msg) => `${msg.role === 'user' ? 'You' : 'Clare'}: ${msg.content}`)
|
| 284 |
-
.join('\n\n');
|
| 285 |
};
|
| 286 |
|
| 287 |
const buildSummaryContent = () => {
|
| 288 |
-
if (!messages.length) return
|
| 289 |
|
| 290 |
-
const userMessages = messages.filter(msg => msg.role ===
|
| 291 |
-
const assistantMessages = messages.filter(msg => msg.role ===
|
| 292 |
|
| 293 |
let summary = `Chat Summary\n================\n\n`;
|
| 294 |
summary += `Total Messages: ${messages.length}\n`;
|
|
@@ -298,14 +311,14 @@ export function ChatArea({
|
|
| 298 |
summary += `Key Points:\n`;
|
| 299 |
userMessages.slice(0, 3).forEach((msg, idx) => {
|
| 300 |
const preview = msg.content.substring(0, 80);
|
| 301 |
-
summary += `${idx + 1}. ${preview}${msg.content.length > 80 ?
|
| 302 |
});
|
| 303 |
|
| 304 |
return summary;
|
| 305 |
};
|
| 306 |
|
| 307 |
const handleOpenDownloadDialog = () => {
|
| 308 |
-
setDownloadTab(
|
| 309 |
setDownloadOptions({ chat: true, summary: false });
|
| 310 |
setDownloadPreview(buildPreviewContent());
|
| 311 |
setShowDownloadDialog(true);
|
|
@@ -314,66 +327,57 @@ export function ChatArea({
|
|
| 314 |
const handleCopyPreview = async () => {
|
| 315 |
try {
|
| 316 |
await navigator.clipboard.writeText(downloadPreview);
|
| 317 |
-
toast.success(
|
| 318 |
-
} catch
|
| 319 |
-
toast.error(
|
| 320 |
}
|
| 321 |
};
|
| 322 |
|
| 323 |
const handleDownloadFile = async () => {
|
| 324 |
try {
|
| 325 |
-
let contentToPdf =
|
| 326 |
|
| 327 |
-
if (downloadOptions.chat)
|
| 328 |
-
contentToPdf += buildPreviewContent();
|
| 329 |
-
}
|
| 330 |
|
| 331 |
if (downloadOptions.summary) {
|
| 332 |
-
if (downloadOptions.chat)
|
| 333 |
-
contentToPdf += '\n\n================\n\n';
|
| 334 |
-
}
|
| 335 |
contentToPdf += buildSummaryContent();
|
| 336 |
}
|
| 337 |
|
| 338 |
if (!contentToPdf.trim()) {
|
| 339 |
-
toast.error(
|
| 340 |
return;
|
| 341 |
}
|
| 342 |
|
| 343 |
-
const pdf = new jsPDF({
|
| 344 |
-
orientation: 'portrait',
|
| 345 |
-
unit: 'mm',
|
| 346 |
-
format: 'a4'
|
| 347 |
-
});
|
| 348 |
|
| 349 |
-
pdf.setFontSize(11);
|
| 350 |
pdf.setFontSize(14);
|
| 351 |
-
pdf.text(
|
| 352 |
pdf.setFontSize(11);
|
| 353 |
|
| 354 |
const pageHeight = pdf.internal.pageSize.getHeight();
|
| 355 |
const margin = 10;
|
| 356 |
const maxWidth = 190;
|
| 357 |
const lineHeight = 5;
|
| 358 |
-
let
|
| 359 |
|
| 360 |
const lines = pdf.splitTextToSize(contentToPdf, maxWidth);
|
| 361 |
-
|
| 362 |
lines.forEach((line: string) => {
|
| 363 |
-
if (
|
| 364 |
pdf.addPage();
|
| 365 |
-
|
| 366 |
}
|
| 367 |
-
pdf.text(line, margin,
|
| 368 |
-
|
| 369 |
});
|
| 370 |
|
| 371 |
-
pdf.save(
|
| 372 |
setShowDownloadDialog(false);
|
| 373 |
-
toast.success(
|
| 374 |
} catch (error) {
|
| 375 |
-
|
| 376 |
-
|
|
|
|
| 377 |
}
|
| 378 |
};
|
| 379 |
|
|
@@ -381,12 +385,12 @@ export function ChatArea({
|
|
| 381 |
const isCurrentChatSaved = (): boolean => {
|
| 382 |
if (messages.length <= 1) return false;
|
| 383 |
|
| 384 |
-
return savedChats.some(chat => {
|
| 385 |
if (chat.chatMode !== chatMode) return false;
|
| 386 |
if (chat.messages.length !== messages.length) return false;
|
| 387 |
|
| 388 |
-
return chat.messages.every((savedMsg,
|
| 389 |
-
const currentMsg = messages[
|
| 390 |
return (
|
| 391 |
savedMsg.id === currentMsg.id &&
|
| 392 |
savedMsg.role === currentMsg.role &&
|
|
@@ -398,20 +402,19 @@ export function ChatArea({
|
|
| 398 |
|
| 399 |
const handleSaveClick = () => {
|
| 400 |
if (messages.length <= 1) {
|
| 401 |
-
toast.info(
|
| 402 |
return;
|
| 403 |
}
|
| 404 |
-
|
| 405 |
onSaveChat();
|
| 406 |
};
|
| 407 |
|
| 408 |
const handleShareClick = () => {
|
| 409 |
if (messages.length <= 1) {
|
| 410 |
-
toast.info(
|
| 411 |
return;
|
| 412 |
}
|
| 413 |
const conversationText = buildPreviewContent();
|
| 414 |
-
const blob = new Blob([conversationText], { type:
|
| 415 |
const url = URL.createObjectURL(blob);
|
| 416 |
setShareLink(url);
|
| 417 |
setTargetWorkspaceId(currentWorkspaceId);
|
|
@@ -421,19 +424,38 @@ export function ChatArea({
|
|
| 421 |
const handleCopyShareLink = async () => {
|
| 422 |
try {
|
| 423 |
await navigator.clipboard.writeText(shareLink);
|
| 424 |
-
toast.success(
|
| 425 |
} catch {
|
| 426 |
-
toast.error(
|
| 427 |
}
|
| 428 |
};
|
| 429 |
|
| 430 |
const handleShareSendToWorkspace = () => {
|
| 431 |
const content = buildPreviewContent();
|
| 432 |
-
onSaveFile?.(content,
|
| 433 |
setShowShareDialog(false);
|
| 434 |
-
toast.success(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
};
|
| 436 |
|
|
|
|
| 437 |
const handleDragOver = (e: React.DragEvent) => {
|
| 438 |
e.preventDefault();
|
| 439 |
e.stopPropagation();
|
|
@@ -456,24 +478,22 @@ export function ChatArea({
|
|
| 456 |
const fileList = e.dataTransfer.files;
|
| 457 |
const files: File[] = [];
|
| 458 |
for (let i = 0; i < fileList.length; i++) {
|
| 459 |
-
const
|
| 460 |
-
if (
|
| 461 |
-
files.push(file);
|
| 462 |
-
}
|
| 463 |
}
|
| 464 |
|
| 465 |
const validFiles = files.filter((file) => {
|
| 466 |
const ext = file.name.toLowerCase();
|
| 467 |
-
return [
|
| 468 |
-
ext.endsWith(
|
| 469 |
);
|
| 470 |
});
|
| 471 |
|
| 472 |
if (validFiles.length > 0) {
|
| 473 |
-
setPendingFiles(validFiles.map(file => ({ file, type:
|
| 474 |
setShowTypeDialog(true);
|
| 475 |
} else {
|
| 476 |
-
toast.error(
|
| 477 |
}
|
| 478 |
};
|
| 479 |
|
|
@@ -482,31 +502,36 @@ export function ChatArea({
|
|
| 482 |
if (files.length > 0) {
|
| 483 |
const validFiles = files.filter((file) => {
|
| 484 |
const ext = file.name.toLowerCase();
|
| 485 |
-
return [
|
| 486 |
-
ext.endsWith(
|
| 487 |
);
|
| 488 |
});
|
|
|
|
| 489 |
if (validFiles.length > 0) {
|
| 490 |
-
setPendingFiles(validFiles.map(file => ({ file, type:
|
| 491 |
setShowTypeDialog(true);
|
| 492 |
} else {
|
| 493 |
-
toast.error(
|
| 494 |
}
|
| 495 |
}
|
| 496 |
-
e.target.value =
|
| 497 |
};
|
| 498 |
|
| 499 |
const handleConfirmUpload = () => {
|
| 500 |
-
onFileUpload(pendingFiles.map(pf => pf.file));
|
| 501 |
const startIndex = uploadedFiles.length;
|
|
|
|
|
|
|
| 502 |
pendingFiles.forEach((pf, idx) => {
|
| 503 |
setTimeout(() => {
|
| 504 |
onFileTypeChange(startIndex + idx, pf.type);
|
| 505 |
}, 0);
|
| 506 |
});
|
|
|
|
|
|
|
| 507 |
setPendingFiles([]);
|
| 508 |
setShowTypeDialog(false);
|
| 509 |
-
toast.success(`${
|
| 510 |
};
|
| 511 |
|
| 512 |
const handleCancelUpload = () => {
|
|
@@ -515,31 +540,33 @@ export function ChatArea({
|
|
| 515 |
};
|
| 516 |
|
| 517 |
const handlePendingFileTypeChange = (index: number, type: FileType) => {
|
| 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(
|
| 524 |
-
if (ext.endsWith(
|
| 525 |
-
if (ext.endsWith(
|
| 526 |
-
if ([
|
| 527 |
return File;
|
| 528 |
};
|
| 529 |
|
| 530 |
const getFileTypeInfo = (filename: string) => {
|
| 531 |
const ext = filename.toLowerCase();
|
| 532 |
-
if (ext.endsWith(
|
| 533 |
-
if (ext.endsWith(
|
| 534 |
-
if (ext.endsWith(
|
| 535 |
-
if ([
|
| 536 |
-
|
|
|
|
| 537 |
};
|
| 538 |
|
| 539 |
const formatFileSize = (bytes: number) => {
|
| 540 |
-
if (bytes < 1024) return bytes
|
| 541 |
-
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1)
|
| 542 |
-
return (bytes / (1024 * 1024)).toFixed(1)
|
| 543 |
};
|
| 544 |
|
| 545 |
const FileThumbnail = ({
|
|
@@ -548,7 +575,7 @@ export function ChatArea({
|
|
| 548 |
fileInfo,
|
| 549 |
isImage,
|
| 550 |
onPreview,
|
| 551 |
-
onRemove
|
| 552 |
}: {
|
| 553 |
file: File;
|
| 554 |
Icon: React.ComponentType<{ className?: string }>;
|
|
@@ -561,21 +588,22 @@ export function ChatArea({
|
|
| 561 |
const [imageLoading, setImageLoading] = useState(true);
|
| 562 |
|
| 563 |
useEffect(() => {
|
| 564 |
-
if (isImage) {
|
| 565 |
-
setImageLoading(true);
|
| 566 |
-
const reader = new FileReader();
|
| 567 |
-
reader.onload = (e) => {
|
| 568 |
-
setImagePreview(e.target?.result as string);
|
| 569 |
-
setImageLoading(false);
|
| 570 |
-
};
|
| 571 |
-
reader.onerror = () => {
|
| 572 |
-
setImageLoading(false);
|
| 573 |
-
};
|
| 574 |
-
reader.readAsDataURL(file);
|
| 575 |
-
} else {
|
| 576 |
setImagePreview(null);
|
| 577 |
setImageLoading(false);
|
|
|
|
| 578 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 579 |
}, [file, isImage]);
|
| 580 |
|
| 581 |
if (isImage) {
|
|
@@ -583,7 +611,7 @@ export function ChatArea({
|
|
| 583 |
<div
|
| 584 |
className="relative cursor-pointer w-16 h-16 flex-shrink-0"
|
| 585 |
onClick={onPreview}
|
| 586 |
-
style={{ width:
|
| 587 |
>
|
| 588 |
<div className="w-full h-full relative bg-card border border-border rounded-lg hover:border-primary/50 transition-colors">
|
| 589 |
<div className="w-full h-full overflow-hidden rounded-lg absolute inset-0">
|
|
@@ -597,7 +625,7 @@ export function ChatArea({
|
|
| 597 |
alt={file.name}
|
| 598 |
className="w-full h-full object-cover"
|
| 599 |
onError={(e) => {
|
| 600 |
-
e.currentTarget.style.display =
|
| 601 |
setImageLoading(false);
|
| 602 |
}}
|
| 603 |
/>
|
|
@@ -607,6 +635,7 @@ export function ChatArea({
|
|
| 607 |
</div>
|
| 608 |
)}
|
| 609 |
</div>
|
|
|
|
| 610 |
<button
|
| 611 |
type="button"
|
| 612 |
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"
|
|
@@ -614,15 +643,9 @@ export function ChatArea({
|
|
| 614 |
e.stopPropagation();
|
| 615 |
onRemove(e);
|
| 616 |
}}
|
| 617 |
-
style={{ zIndex: 100, position:
|
| 618 |
>
|
| 619 |
-
<X
|
| 620 |
-
className="h-2.5 w-2.5"
|
| 621 |
-
style={{
|
| 622 |
-
color: 'rgb(0, 0, 0)',
|
| 623 |
-
strokeWidth: 2
|
| 624 |
-
}}
|
| 625 |
-
/>
|
| 626 |
</button>
|
| 627 |
</div>
|
| 628 |
</div>
|
|
@@ -630,11 +653,7 @@ export function ChatArea({
|
|
| 630 |
}
|
| 631 |
|
| 632 |
return (
|
| 633 |
-
<div
|
| 634 |
-
className="relative cursor-pointer"
|
| 635 |
-
onClick={onPreview}
|
| 636 |
-
style={{ width: '240px', flexShrink: 0 }}
|
| 637 |
-
>
|
| 638 |
<div className="h-16 w-full relative flex items-center px-3 bg-card border border-border rounded-lg hover:border-primary/50 transition-colors">
|
| 639 |
<div className={`${fileInfo.bgColor} flex items-center justify-center w-10 h-10 rounded shrink-0`}>
|
| 640 |
<Icon className="h-5 w-5 text-white" />
|
|
@@ -644,10 +663,9 @@ export function ChatArea({
|
|
| 644 |
<p className="text-xs text-foreground truncate" title={file.name}>
|
| 645 |
{file.name}
|
| 646 |
</p>
|
| 647 |
-
<p className="text-[10px] text-muted-foreground mt-0.5 truncate">
|
| 648 |
-
{fileInfo.type}
|
| 649 |
-
</p>
|
| 650 |
</div>
|
|
|
|
| 651 |
<button
|
| 652 |
type="button"
|
| 653 |
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"
|
|
@@ -655,15 +673,9 @@ export function ChatArea({
|
|
| 655 |
e.stopPropagation();
|
| 656 |
onRemove(e);
|
| 657 |
}}
|
| 658 |
-
style={{ position:
|
| 659 |
>
|
| 660 |
-
<X
|
| 661 |
-
className="h-2.5 w-2.5"
|
| 662 |
-
style={{
|
| 663 |
-
color: 'rgb(0, 0, 0)',
|
| 664 |
-
strokeWidth: 2
|
| 665 |
-
}}
|
| 666 |
-
/>
|
| 667 |
</button>
|
| 668 |
</div>
|
| 669 |
</div>
|
|
@@ -671,7 +683,7 @@ export function ChatArea({
|
|
| 671 |
};
|
| 672 |
|
| 673 |
const FileViewerContent = ({ file }: { file: File }) => {
|
| 674 |
-
const [content, setContent] = useState<string>(
|
| 675 |
const [loading, setLoading] = useState(true);
|
| 676 |
const [error, setError] = useState<string | null>(null);
|
| 677 |
|
|
@@ -683,34 +695,38 @@ export function ChatArea({
|
|
| 683 |
|
| 684 |
const ext = file.name.toLowerCase();
|
| 685 |
|
| 686 |
-
if ([
|
| 687 |
const reader = new FileReader();
|
| 688 |
reader.onload = (e) => {
|
| 689 |
setContent(e.target?.result as string);
|
| 690 |
setLoading(false);
|
| 691 |
};
|
| 692 |
reader.onerror = () => {
|
| 693 |
-
setError(
|
| 694 |
setLoading(false);
|
| 695 |
};
|
| 696 |
reader.readAsDataURL(file);
|
| 697 |
-
|
| 698 |
-
|
|
|
|
|
|
|
|
|
|
| 699 |
setLoading(false);
|
| 700 |
-
|
| 701 |
-
const reader = new FileReader();
|
| 702 |
-
reader.onload = (e) => {
|
| 703 |
-
setContent(e.target?.result as string);
|
| 704 |
-
setLoading(false);
|
| 705 |
-
};
|
| 706 |
-
reader.onerror = () => {
|
| 707 |
-
setError('Failed to load file');
|
| 708 |
-
setLoading(false);
|
| 709 |
-
};
|
| 710 |
-
reader.readAsText(file);
|
| 711 |
}
|
| 712 |
-
|
| 713 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 714 |
setLoading(false);
|
| 715 |
}
|
| 716 |
};
|
|
@@ -722,7 +738,7 @@ export function ChatArea({
|
|
| 722 |
if (error) return <div className="text-center py-8 text-destructive">{error}</div>;
|
| 723 |
|
| 724 |
const ext = file.name.toLowerCase();
|
| 725 |
-
const isImage = [
|
| 726 |
|
| 727 |
if (isImage) {
|
| 728 |
return (
|
|
@@ -740,32 +756,27 @@ export function ChatArea({
|
|
| 740 |
};
|
| 741 |
|
| 742 |
return (
|
| 743 |
-
<div className="flex flex-col h-full min-h-0 overflow-hidden" style={{ overscrollBehavior:
|
| 744 |
-
<div className="flex-1 relative min-h-0 flex flex-col overflow-hidden" style={{ overscrollBehavior:
|
| 745 |
{/* ✅ Messages Area is the ONLY scroll container */}
|
| 746 |
<div
|
| 747 |
ref={scrollContainerRef}
|
| 748 |
className="flex-1 min-h-0 overflow-y-auto"
|
| 749 |
-
style={{
|
| 750 |
-
overscrollBehavior: 'contain',
|
| 751 |
-
}}
|
| 752 |
>
|
| 753 |
{/* Top Bar - Sticky */}
|
| 754 |
<div
|
| 755 |
-
className={`sticky top-0 flex items-center justify-between px-4 z-20 bg-card ${
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
padding: '1rem 1rem',
|
| 760 |
-
boxSizing: 'border-box',
|
| 761 |
-
}}
|
| 762 |
>
|
| 763 |
{/* Course Selector - Left */}
|
| 764 |
<div className="flex-shrink-0">
|
| 765 |
{(() => {
|
| 766 |
-
const current = workspaces.find(w => w.id === currentWorkspaceId);
|
| 767 |
-
if (current?.type ===
|
| 768 |
-
if (current.category ===
|
| 769 |
return (
|
| 770 |
<div className="h-9 px-3 inline-flex items-center rounded-md border font-semibold">
|
| 771 |
{current.courseName}
|
|
@@ -774,8 +785,12 @@ export function ChatArea({
|
|
| 774 |
}
|
| 775 |
return null;
|
| 776 |
}
|
|
|
|
| 777 |
return (
|
| 778 |
-
<Select
|
|
|
|
|
|
|
|
|
|
| 779 |
<SelectTrigger className="w-[200px] h-9 font-semibold">
|
| 780 |
<SelectValue placeholder="Select course" />
|
| 781 |
</SelectTrigger>
|
|
@@ -800,21 +815,25 @@ export function ChatArea({
|
|
| 800 |
orientation="horizontal"
|
| 801 |
>
|
| 802 |
<TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground">
|
| 803 |
-
<TabsTrigger value="ask" className="w-[140px] px-3 text-sm">
|
|
|
|
|
|
|
| 804 |
<TabsTrigger value="review" className="w-[140px] px-3 text-sm relative">
|
| 805 |
Review
|
| 806 |
<span
|
| 807 |
className="absolute top-0 right-0 bg-red-500 rounded-full border-2"
|
| 808 |
style={{
|
| 809 |
-
width:
|
| 810 |
-
height:
|
| 811 |
-
transform:
|
| 812 |
zIndex: 10,
|
| 813 |
-
borderColor:
|
| 814 |
}}
|
| 815 |
/>
|
| 816 |
</TabsTrigger>
|
| 817 |
-
<TabsTrigger value="quiz" className="w-[140px] px-3 text-sm">
|
|
|
|
|
|
|
| 818 |
</TabsList>
|
| 819 |
</Tabs>
|
| 820 |
</div>
|
|
@@ -826,11 +845,12 @@ export function ChatArea({
|
|
| 826 |
size="icon"
|
| 827 |
onClick={handleSaveClick}
|
| 828 |
disabled={!isLoggedIn}
|
| 829 |
-
className={`h-8 w-8 rounded-md hover:bg-muted/50 ${isCurrentChatSaved() ?
|
| 830 |
-
title={isCurrentChatSaved() ?
|
| 831 |
>
|
| 832 |
-
<Bookmark className={`h-4 w-4 ${isCurrentChatSaved() ?
|
| 833 |
</Button>
|
|
|
|
| 834 |
<Button
|
| 835 |
variant="ghost"
|
| 836 |
size="icon"
|
|
@@ -841,6 +861,7 @@ export function ChatArea({
|
|
| 841 |
>
|
| 842 |
<Download className="h-4 w-4" />
|
| 843 |
</Button>
|
|
|
|
| 844 |
<Button
|
| 845 |
variant="ghost"
|
| 846 |
size="icon"
|
|
@@ -851,6 +872,7 @@ export function ChatArea({
|
|
| 851 |
>
|
| 852 |
<Share2 className="h-4 w-4" />
|
| 853 |
</Button>
|
|
|
|
| 854 |
<Button
|
| 855 |
variant="outline"
|
| 856 |
onClick={handleClearClick}
|
|
@@ -865,41 +887,34 @@ export function ChatArea({
|
|
| 865 |
</div>
|
| 866 |
|
| 867 |
{/* Messages Content */}
|
| 868 |
-
<div
|
| 869 |
-
className="py-6"
|
| 870 |
-
style={{
|
| 871 |
-
paddingBottom: '12rem'
|
| 872 |
-
}}
|
| 873 |
-
>
|
| 874 |
<div className="w-full space-y-6 max-w-4xl mx-auto">
|
| 875 |
{messages.map((message) => (
|
| 876 |
<React.Fragment key={message.id}>
|
| 877 |
<Message
|
| 878 |
message={message}
|
| 879 |
-
showSenderInfo={spaceType ===
|
| 880 |
isFirstGreeting={
|
| 881 |
-
(message.id ===
|
| 882 |
-
message.role ===
|
| 883 |
}
|
| 884 |
showNextButton={message.showNextButton && !isAppTyping}
|
| 885 |
onNextQuestion={onNextQuestion}
|
| 886 |
chatMode={chatMode}
|
| 887 |
/>
|
| 888 |
|
| 889 |
-
{chatMode ===
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
<div className="
|
| 893 |
-
<
|
| 894 |
-
<div className="w-full" style={{ maxWidth: 'min(770px, calc(100% - 2rem))' }}>
|
| 895 |
-
<SmartReview onReviewTopic={handleReviewTopic} onReviewAll={handleReviewAll} />
|
| 896 |
-
</div>
|
| 897 |
</div>
|
| 898 |
-
|
|
|
|
| 899 |
|
| 900 |
-
{chatMode ===
|
| 901 |
-
message.id ===
|
| 902 |
-
message.role ===
|
| 903 |
quizState.currentQuestion === 0 &&
|
| 904 |
!quizState.waitingForAnswer &&
|
| 905 |
!isAppTyping && (
|
|
@@ -919,9 +934,18 @@ export function ChatArea({
|
|
| 919 |
</div>
|
| 920 |
<div className="bg-muted rounded-2xl px-4 py-3">
|
| 921 |
<div className="flex gap-1">
|
| 922 |
-
<div
|
| 923 |
-
|
| 924 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 925 |
</div>
|
| 926 |
</div>
|
| 927 |
</div>
|
|
@@ -937,9 +961,9 @@ export function ChatArea({
|
|
| 937 |
<div
|
| 938 |
className="fixed z-30 flex justify-center pointer-events-none"
|
| 939 |
style={{
|
| 940 |
-
bottom:
|
| 941 |
-
left: leftPanelVisible ?
|
| 942 |
-
right:
|
| 943 |
}}
|
| 944 |
>
|
| 945 |
<Button
|
|
@@ -958,8 +982,8 @@ export function ChatArea({
|
|
| 958 |
<div
|
| 959 |
className="fixed bottom-0 bg-background/95 backdrop-blur-sm z-10"
|
| 960 |
style={{
|
| 961 |
-
left: leftPanelVisible ?
|
| 962 |
-
right:
|
| 963 |
}}
|
| 964 |
>
|
| 965 |
<div className="max-w-4xl mx-auto px-4 py-4">
|
|
@@ -968,7 +992,7 @@ export function ChatArea({
|
|
| 968 |
{uploadedFiles.map((uploadedFile, index) => {
|
| 969 |
const Icon = getFileIcon(uploadedFile.file.name);
|
| 970 |
const fileInfo = getFileTypeInfo(uploadedFile.file.name);
|
| 971 |
-
const isImage = [
|
| 972 |
uploadedFile.file.name.toLowerCase().endsWith(`.${ext}`)
|
| 973 |
);
|
| 974 |
|
|
@@ -1000,12 +1024,12 @@ export function ChatArea({
|
|
| 1000 |
onDragOver={handleDragOver}
|
| 1001 |
onDragLeave={handleDragLeave}
|
| 1002 |
onDrop={handleDrop}
|
| 1003 |
-
className={isDragging ?
|
| 1004 |
>
|
| 1005 |
<div className="relative">
|
| 1006 |
-
{/* Mode Selector
|
| 1007 |
<div className="absolute bottom-3 left-2 flex items-center gap-1 z-10">
|
| 1008 |
-
{chatMode ===
|
| 1009 |
<DropdownMenu>
|
| 1010 |
<DropdownMenuTrigger asChild>
|
| 1011 |
<Button
|
|
@@ -1022,37 +1046,62 @@ export function ChatArea({
|
|
| 1022 |
</Button>
|
| 1023 |
</DropdownMenuTrigger>
|
| 1024 |
<DropdownMenuContent align="start" className="w-56">
|
| 1025 |
-
<DropdownMenuItem
|
|
|
|
|
|
|
|
|
|
| 1026 |
<div className="flex flex-col">
|
| 1027 |
<span className="font-medium">General</span>
|
| 1028 |
-
<span className="text-xs text-muted-foreground">
|
|
|
|
|
|
|
| 1029 |
</div>
|
| 1030 |
</DropdownMenuItem>
|
| 1031 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1032 |
<div className="flex flex-col">
|
| 1033 |
<span className="font-medium">Concept Explainer</span>
|
| 1034 |
<span className="text-xs text-muted-foreground">Get detailed explanations of concepts</span>
|
| 1035 |
</div>
|
| 1036 |
</DropdownMenuItem>
|
| 1037 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1038 |
<div className="flex flex-col">
|
| 1039 |
<span className="font-medium">Socratic Tutor</span>
|
| 1040 |
<span className="text-xs text-muted-foreground">Learn through guided questions</span>
|
| 1041 |
</div>
|
| 1042 |
</DropdownMenuItem>
|
| 1043 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1044 |
<div className="flex flex-col">
|
| 1045 |
<span className="font-medium">Exam Prep</span>
|
| 1046 |
<span className="text-xs text-muted-foreground">Practice with quiz questions</span>
|
| 1047 |
</div>
|
| 1048 |
</DropdownMenuItem>
|
| 1049 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1050 |
<div className="flex flex-col">
|
| 1051 |
<span className="font-medium">Assignment Helper</span>
|
| 1052 |
<span className="text-xs text-muted-foreground">Get help with assignments</span>
|
| 1053 |
</div>
|
| 1054 |
</DropdownMenuItem>
|
| 1055 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1056 |
<div className="flex flex-col">
|
| 1057 |
<span className="font-medium">Quick Summary</span>
|
| 1058 |
<span className="text-xs text-muted-foreground">Get concise summaries</span>
|
|
@@ -1066,7 +1115,7 @@ export function ChatArea({
|
|
| 1066 |
type="button"
|
| 1067 |
size="icon"
|
| 1068 |
variant="ghost"
|
| 1069 |
-
disabled={!isLoggedIn || (chatMode ===
|
| 1070 |
className="h-8 w-8 hover:bg-muted/50"
|
| 1071 |
onClick={() => fileInputRef.current?.click()}
|
| 1072 |
title="Upload files"
|
|
@@ -1082,29 +1131,26 @@ export function ChatArea({
|
|
| 1082 |
placeholder={
|
| 1083 |
!isLoggedIn
|
| 1084 |
? "Please log in on the right to start chatting..."
|
| 1085 |
-
: chatMode ===
|
| 1086 |
-
|
| 1087 |
-
|
| 1088 |
-
|
| 1089 |
-
|
| 1090 |
-
|
| 1091 |
-
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
}
|
| 1097 |
-
disabled={!isLoggedIn || (chatMode ===
|
| 1098 |
-
className={`min-h-[80px] pl-4 pr-20 resize-none bg-background border-2 ${
|
|
|
|
|
|
|
| 1099 |
/>
|
| 1100 |
|
| 1101 |
<div className="absolute bottom-2 right-2 flex gap-1">
|
| 1102 |
-
<Button
|
| 1103 |
-
type="submit"
|
| 1104 |
-
size="icon"
|
| 1105 |
-
disabled={!input.trim() || !isLoggedIn}
|
| 1106 |
-
className="h-8 w-8 rounded-full"
|
| 1107 |
-
>
|
| 1108 |
<Send className="h-4 w-4" />
|
| 1109 |
</Button>
|
| 1110 |
</div>
|
|
@@ -1132,6 +1178,7 @@ export function ChatArea({
|
|
| 1132 |
<AlertDialogDescription>
|
| 1133 |
Would you like to save the current chat before starting a new conversation?
|
| 1134 |
</AlertDialogDescription>
|
|
|
|
| 1135 |
<Button
|
| 1136 |
variant="ghost"
|
| 1137 |
size="icon"
|
|
@@ -1142,6 +1189,7 @@ export function ChatArea({
|
|
| 1142 |
<span className="sr-only">Close</span>
|
| 1143 |
</Button>
|
| 1144 |
</AlertDialogHeader>
|
|
|
|
| 1145 |
<AlertDialogFooter className="flex-col sm:flex-row gap-2 sm:justify-end">
|
| 1146 |
<Button variant="outline" onClick={() => onConfirmClear(false)} className="sm:flex-1 sm:max-w-[200px]">
|
| 1147 |
Start New (Don't Save)
|
|
@@ -1164,9 +1212,10 @@ export function ChatArea({
|
|
| 1164 |
<Tabs
|
| 1165 |
value={downloadTab}
|
| 1166 |
onValueChange={(value) => {
|
| 1167 |
-
|
| 1168 |
-
|
| 1169 |
-
|
|
|
|
| 1170 |
else setDownloadOptions({ chat: true, summary: false });
|
| 1171 |
}}
|
| 1172 |
className="w-full"
|
|
@@ -1180,7 +1229,13 @@ export function ChatArea({
|
|
| 1180 |
<div className="border rounded-lg bg-muted/40 flex flex-col max-h-64">
|
| 1181 |
<div className="flex items-center justify-between p-4 sticky top-0 bg-muted/40 border-b z-10">
|
| 1182 |
<span className="text-sm font-medium">Preview</span>
|
| 1183 |
-
<Button
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1184 |
<Copy className="h-3 w-3" />
|
| 1185 |
Copy
|
| 1186 |
</Button>
|
|
@@ -1197,7 +1252,9 @@ export function ChatArea({
|
|
| 1197 |
checked={downloadOptions.chat}
|
| 1198 |
onCheckedChange={(checked) => setDownloadOptions({ ...downloadOptions, chat: checked === true })}
|
| 1199 |
/>
|
| 1200 |
-
<label htmlFor="download-chat" className="text-sm font-medium cursor-pointer">
|
|
|
|
|
|
|
| 1201 |
</div>
|
| 1202 |
<div className="flex items-center space-x-2">
|
| 1203 |
<Checkbox
|
|
@@ -1205,12 +1262,16 @@ export function ChatArea({
|
|
| 1205 |
checked={downloadOptions.summary}
|
| 1206 |
onCheckedChange={(checked) => setDownloadOptions({ ...downloadOptions, summary: checked === true })}
|
| 1207 |
/>
|
| 1208 |
-
<label htmlFor="download-summary" className="text-sm font-medium cursor-pointer">
|
|
|
|
|
|
|
| 1209 |
</div>
|
| 1210 |
</div>
|
| 1211 |
|
| 1212 |
<DialogFooter>
|
| 1213 |
-
<Button variant="outline" onClick={() => setShowDownloadDialog(false)}>
|
|
|
|
|
|
|
| 1214 |
<Button onClick={handleDownloadFile}>Download</Button>
|
| 1215 |
</DialogFooter>
|
| 1216 |
</DialogContent>
|
|
@@ -1228,10 +1289,13 @@ export function ChatArea({
|
|
| 1228 |
<Label>Copy Link</Label>
|
| 1229 |
<div className="flex gap-2 items-center">
|
| 1230 |
<Input value={shareLink} readOnly className="flex-1" />
|
| 1231 |
-
<Button variant="secondary" onClick={handleCopyShareLink}>
|
|
|
|
|
|
|
| 1232 |
</div>
|
| 1233 |
<p className="text-xs text-muted-foreground">Temporary link valid for this session.</p>
|
| 1234 |
</div>
|
|
|
|
| 1235 |
<div className="space-y-2">
|
| 1236 |
<Label>Send to Workspace</Label>
|
| 1237 |
<Select value={targetWorkspaceId} onValueChange={setTargetWorkspaceId}>
|
|
@@ -1239,13 +1303,19 @@ export function ChatArea({
|
|
| 1239 |
<SelectValue placeholder="Choose a workspace" />
|
| 1240 |
</SelectTrigger>
|
| 1241 |
<SelectContent>
|
| 1242 |
-
{workspaces.map(w => (
|
| 1243 |
-
<SelectItem key={w.id} value={w.id}>
|
|
|
|
|
|
|
| 1244 |
))}
|
| 1245 |
</SelectContent>
|
| 1246 |
</Select>
|
| 1247 |
-
<p className="text-xs text-muted-foreground">
|
| 1248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1249 |
</div>
|
| 1250 |
</div>
|
| 1251 |
</DialogContent>
|
|
@@ -1257,7 +1327,9 @@ export function ChatArea({
|
|
| 1257 |
<AlertDialogHeader>
|
| 1258 |
<AlertDialogTitle>Delete File</AlertDialogTitle>
|
| 1259 |
<AlertDialogDescription>
|
| 1260 |
-
Are you sure you want to delete
|
|
|
|
|
|
|
| 1261 |
</AlertDialogDescription>
|
| 1262 |
</AlertDialogHeader>
|
| 1263 |
<AlertDialogFooter>
|
|
@@ -1284,21 +1356,19 @@ export function ChatArea({
|
|
| 1284 |
<DialogTitle
|
| 1285 |
className="pr-8 break-words break-all overflow-wrap-anywhere leading-relaxed"
|
| 1286 |
style={{
|
| 1287 |
-
wordBreak:
|
| 1288 |
-
overflowWrap:
|
| 1289 |
-
maxWidth:
|
| 1290 |
-
lineHeight:
|
| 1291 |
}}
|
| 1292 |
>
|
| 1293 |
{selectedFile?.file.name}
|
| 1294 |
</DialogTitle>
|
| 1295 |
<DialogDescription>
|
| 1296 |
-
File size: {selectedFile ? formatFileSize(selectedFile.file.size) :
|
| 1297 |
</DialogDescription>
|
| 1298 |
</DialogHeader>
|
| 1299 |
-
<div className="flex-1 min-h-0 overflow-y-auto mt-4">
|
| 1300 |
-
{selectedFile && <FileViewerContent file={selectedFile.file} />}
|
| 1301 |
-
</div>
|
| 1302 |
</DialogContent>
|
| 1303 |
</Dialog>
|
| 1304 |
|
|
@@ -1310,6 +1380,7 @@ export function ChatArea({
|
|
| 1310 |
<DialogTitle>Select File Types</DialogTitle>
|
| 1311 |
<DialogDescription>Please select the type for each file you are uploading.</DialogDescription>
|
| 1312 |
</DialogHeader>
|
|
|
|
| 1313 |
<div className="space-y-3 max-h-64 overflow-y-auto">
|
| 1314 |
{pendingFiles.map((pendingFile, index) => {
|
| 1315 |
const Icon = getFileIcon(pendingFile.file.name);
|
|
@@ -1322,6 +1393,7 @@ export function ChatArea({
|
|
| 1322 |
<p className="text-xs text-muted-foreground">{formatFileSize(pendingFile.file.size)}</p>
|
| 1323 |
</div>
|
| 1324 |
</div>
|
|
|
|
| 1325 |
<div className="space-y-1">
|
| 1326 |
<label className="text-xs text-muted-foreground">File Type</label>
|
| 1327 |
<Select
|
|
@@ -1343,8 +1415,11 @@ export function ChatArea({
|
|
| 1343 |
);
|
| 1344 |
})}
|
| 1345 |
</div>
|
|
|
|
| 1346 |
<DialogFooter>
|
| 1347 |
-
<Button variant="outline" onClick={handleCancelUpload}>
|
|
|
|
|
|
|
| 1348 |
<Button onClick={handleConfirmUpload}>Upload</Button>
|
| 1349 |
</DialogFooter>
|
| 1350 |
</DialogContent>
|
|
|
|
| 1 |
// web/src/components/ChatArea.tsx
|
| 2 |
+
import React, { useState, useRef, useEffect } from "react";
|
| 3 |
+
import { Button } from "./ui/button";
|
| 4 |
+
import { Textarea } from "./ui/textarea";
|
| 5 |
+
import { Input } from "./ui/input";
|
| 6 |
+
import { Label } from "./ui/label";
|
| 7 |
import {
|
| 8 |
Send,
|
| 9 |
ArrowDown,
|
|
|
|
| 10 |
Share2,
|
| 11 |
Upload,
|
| 12 |
X,
|
|
|
|
| 17 |
Bookmark,
|
| 18 |
Plus,
|
| 19 |
Download,
|
| 20 |
+
Copy,
|
| 21 |
+
} from "lucide-react";
|
| 22 |
+
import { Message } from "./Message";
|
| 23 |
+
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
| 24 |
+
import type {
|
| 25 |
+
Message as MessageType,
|
| 26 |
+
LearningMode,
|
| 27 |
+
UploadedFile,
|
| 28 |
+
FileType,
|
| 29 |
+
SpaceType,
|
| 30 |
+
ChatMode,
|
| 31 |
+
SavedChat,
|
| 32 |
+
Workspace,
|
| 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,
|
| 53 |
AlertDialogAction,
|
|
|
|
| 57 |
AlertDialogFooter,
|
| 58 |
AlertDialogHeader,
|
| 59 |
AlertDialogTitle,
|
| 60 |
+
} from "./ui/alert-dialog";
|
| 61 |
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
| 62 |
+
import { SmartReview } from "./SmartReview";
|
| 63 |
+
import clareAvatar from "../assets/dfe44dab3ad8cd93953eac4a3e68bd1a5f999653.png";
|
| 64 |
|
| 65 |
interface ChatAreaProps {
|
| 66 |
messages: MessageType[];
|
|
|
|
| 92 |
savedChats: SavedChat[];
|
| 93 |
workspaces: Workspace[];
|
| 94 |
currentWorkspaceId: string;
|
| 95 |
+
onSaveFile?: (
|
| 96 |
+
content: string,
|
| 97 |
+
type: "export" | "summary",
|
| 98 |
+
format?: "pdf" | "text",
|
| 99 |
+
workspaceId?: string
|
| 100 |
+
) => void;
|
| 101 |
leftPanelVisible?: boolean;
|
| 102 |
currentCourseId?: string;
|
| 103 |
onCourseChange?: (courseId: string) => void;
|
|
|
|
| 143 |
availableCourses = [],
|
| 144 |
showReviewBanner = false,
|
| 145 |
}: ChatAreaProps) {
|
| 146 |
+
const [input, setInput] = useState("");
|
| 147 |
const [showScrollButton, setShowScrollButton] = useState(false);
|
| 148 |
const [showTopBorder, setShowTopBorder] = useState(false);
|
| 149 |
const [isDragging, setIsDragging] = useState(false);
|
| 150 |
+
|
| 151 |
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
| 152 |
const [showTypeDialog, setShowTypeDialog] = useState(false);
|
| 153 |
+
|
| 154 |
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
| 155 |
const [fileToDelete, setFileToDelete] = useState<number | null>(null);
|
| 156 |
+
|
| 157 |
const [selectedFile, setSelectedFile] = useState<{ file: File; index: number } | null>(null);
|
| 158 |
const [showFileViewer, setShowFileViewer] = useState(false);
|
| 159 |
+
|
| 160 |
const [showDownloadDialog, setShowDownloadDialog] = useState(false);
|
| 161 |
+
const [downloadPreview, setDownloadPreview] = useState("");
|
| 162 |
+
const [downloadTab, setDownloadTab] = useState<"chat" | "summary">("chat");
|
| 163 |
const [downloadOptions, setDownloadOptions] = useState({ chat: true, summary: false });
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
+
const [showShareDialog, setShowShareDialog] = useState(false);
|
| 166 |
+
const [shareLink, setShareLink] = useState("");
|
| 167 |
+
const [targetWorkspaceId, setTargetWorkspaceId] = useState<string>("");
|
| 168 |
+
|
| 169 |
+
// Use availableCourses if provided, otherwise fallback
|
| 170 |
+
const courses =
|
| 171 |
+
availableCourses.length > 0
|
| 172 |
+
? availableCourses
|
| 173 |
+
: [
|
| 174 |
+
{ id: "course1", name: "Introduction to AI" },
|
| 175 |
+
{ id: "course2", name: "Machine Learning" },
|
| 176 |
+
{ id: "course3", name: "Data Structures" },
|
| 177 |
+
{ id: "course4", name: "Web Development" },
|
| 178 |
+
];
|
| 179 |
|
| 180 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 181 |
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
| 182 |
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 183 |
+
|
| 184 |
const isInitialMount = useRef(true);
|
| 185 |
const previousMessagesLength = useRef(messages.length);
|
| 186 |
|
| 187 |
const scrollToBottom = () => {
|
| 188 |
+
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
| 189 |
};
|
| 190 |
|
| 191 |
// Only auto-scroll when new messages are added (not on initial load)
|
| 192 |
useEffect(() => {
|
|
|
|
| 193 |
if (isInitialMount.current) {
|
| 194 |
isInitialMount.current = false;
|
| 195 |
previousMessagesLength.current = messages.length;
|
| 196 |
+
|
| 197 |
+
// stay at top on initial load
|
| 198 |
if (scrollContainerRef.current) {
|
| 199 |
scrollContainerRef.current.scrollTop = 0;
|
| 200 |
}
|
| 201 |
return;
|
| 202 |
}
|
| 203 |
|
|
|
|
| 204 |
if (messages.length > previousMessagesLength.current) {
|
| 205 |
scrollToBottom();
|
| 206 |
}
|
| 207 |
previousMessagesLength.current = messages.length;
|
| 208 |
}, [messages]);
|
| 209 |
|
| 210 |
+
// Scroll button + top border
|
| 211 |
useEffect(() => {
|
| 212 |
const handleScroll = () => {
|
| 213 |
+
const el = scrollContainerRef.current;
|
| 214 |
+
if (!el) return;
|
| 215 |
+
|
| 216 |
+
const { scrollTop, scrollHeight, clientHeight } = el;
|
| 217 |
+
const isAtBottom = scrollHeight - scrollTop - clientHeight < 100;
|
| 218 |
+
setShowScrollButton(!isAtBottom);
|
| 219 |
+
setShowTopBorder(scrollTop > 0);
|
| 220 |
};
|
| 221 |
|
| 222 |
const container = scrollContainerRef.current;
|
| 223 |
+
if (!container) return;
|
| 224 |
+
|
| 225 |
+
handleScroll();
|
| 226 |
+
container.addEventListener("scroll", handleScroll);
|
| 227 |
+
return () => container.removeEventListener("scroll", handleScroll);
|
|
|
|
| 228 |
}, [messages]);
|
| 229 |
|
| 230 |
// ✅ Prevent scroll chaining to left panel / outer containers
|
|
|
|
| 233 |
if (!el) return;
|
| 234 |
|
| 235 |
const onWheel = (e: WheelEvent) => {
|
|
|
|
| 236 |
e.stopPropagation();
|
| 237 |
|
|
|
|
| 238 |
const atTop = el.scrollTop <= 0;
|
| 239 |
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
|
| 240 |
|
|
|
|
| 252 |
if (!input.trim() || !isLoggedIn) return;
|
| 253 |
|
| 254 |
onSendMessage(input);
|
| 255 |
+
setInput("");
|
| 256 |
};
|
| 257 |
|
| 258 |
const handleKeyDown = (e: React.KeyboardEvent) => {
|
| 259 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
| 260 |
e.preventDefault();
|
| 261 |
handleSubmit(e);
|
| 262 |
}
|
| 263 |
};
|
| 264 |
|
| 265 |
const modeLabels: Record<LearningMode, string> = {
|
| 266 |
+
general: "General",
|
| 267 |
+
concept: "Concept Explainer",
|
| 268 |
+
socratic: "Socratic Tutor",
|
| 269 |
+
exam: "Exam Prep",
|
| 270 |
+
assignment: "Assignment Helper",
|
| 271 |
+
summary: "Quick Summary",
|
| 272 |
};
|
| 273 |
|
| 274 |
+
// Review topic click
|
| 275 |
+
const handleReviewTopic = (item: {
|
| 276 |
+
title: string;
|
| 277 |
+
previousQuestion: string;
|
| 278 |
+
memoryRetention: number;
|
| 279 |
+
schedule: string;
|
| 280 |
+
status: string;
|
| 281 |
+
weight: number;
|
| 282 |
+
lastReviewed: string;
|
| 283 |
+
}) => {
|
| 284 |
const userMessage = `Please help me review: ${item.title}`;
|
| 285 |
const reviewData = `REVIEW_TOPIC:${item.title}|${item.previousQuestion}|${item.memoryRetention}|${item.schedule}|${item.status}|${item.weight}|${item.lastReviewed}`;
|
| 286 |
(window as any).__lastReviewData = reviewData;
|
| 287 |
onSendMessage(userMessage);
|
| 288 |
};
|
| 289 |
|
|
|
|
| 290 |
const handleReviewAll = () => {
|
| 291 |
+
(window as any).__lastReviewData = "REVIEW_ALL";
|
| 292 |
+
onSendMessage("Please help me review all topics that need attention.");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
};
|
| 294 |
|
| 295 |
const buildPreviewContent = () => {
|
| 296 |
+
if (!messages.length) return "";
|
| 297 |
+
return messages.map((msg) => `${msg.role === "user" ? "You" : "Clare"}: ${msg.content}`).join("\n\n");
|
|
|
|
|
|
|
| 298 |
};
|
| 299 |
|
| 300 |
const buildSummaryContent = () => {
|
| 301 |
+
if (!messages.length) return "No messages to summarize.";
|
| 302 |
|
| 303 |
+
const userMessages = messages.filter((msg) => msg.role === "user");
|
| 304 |
+
const assistantMessages = messages.filter((msg) => msg.role === "assistant");
|
| 305 |
|
| 306 |
let summary = `Chat Summary\n================\n\n`;
|
| 307 |
summary += `Total Messages: ${messages.length}\n`;
|
|
|
|
| 311 |
summary += `Key Points:\n`;
|
| 312 |
userMessages.slice(0, 3).forEach((msg, idx) => {
|
| 313 |
const preview = msg.content.substring(0, 80);
|
| 314 |
+
summary += `${idx + 1}. ${preview}${msg.content.length > 80 ? "..." : ""}\n`;
|
| 315 |
});
|
| 316 |
|
| 317 |
return summary;
|
| 318 |
};
|
| 319 |
|
| 320 |
const handleOpenDownloadDialog = () => {
|
| 321 |
+
setDownloadTab("chat");
|
| 322 |
setDownloadOptions({ chat: true, summary: false });
|
| 323 |
setDownloadPreview(buildPreviewContent());
|
| 324 |
setShowDownloadDialog(true);
|
|
|
|
| 327 |
const handleCopyPreview = async () => {
|
| 328 |
try {
|
| 329 |
await navigator.clipboard.writeText(downloadPreview);
|
| 330 |
+
toast.success("Copied preview");
|
| 331 |
+
} catch {
|
| 332 |
+
toast.error("Copy failed");
|
| 333 |
}
|
| 334 |
};
|
| 335 |
|
| 336 |
const handleDownloadFile = async () => {
|
| 337 |
try {
|
| 338 |
+
let contentToPdf = "";
|
| 339 |
|
| 340 |
+
if (downloadOptions.chat) contentToPdf += buildPreviewContent();
|
|
|
|
|
|
|
| 341 |
|
| 342 |
if (downloadOptions.summary) {
|
| 343 |
+
if (downloadOptions.chat) contentToPdf += "\n\n================\n\n";
|
|
|
|
|
|
|
| 344 |
contentToPdf += buildSummaryContent();
|
| 345 |
}
|
| 346 |
|
| 347 |
if (!contentToPdf.trim()) {
|
| 348 |
+
toast.error("Please select at least one option");
|
| 349 |
return;
|
| 350 |
}
|
| 351 |
|
| 352 |
+
const pdf = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
|
|
|
|
| 354 |
pdf.setFontSize(14);
|
| 355 |
+
pdf.text("Chat Export", 10, 10);
|
| 356 |
pdf.setFontSize(11);
|
| 357 |
|
| 358 |
const pageHeight = pdf.internal.pageSize.getHeight();
|
| 359 |
const margin = 10;
|
| 360 |
const maxWidth = 190;
|
| 361 |
const lineHeight = 5;
|
| 362 |
+
let y = 20;
|
| 363 |
|
| 364 |
const lines = pdf.splitTextToSize(contentToPdf, maxWidth);
|
|
|
|
| 365 |
lines.forEach((line: string) => {
|
| 366 |
+
if (y > pageHeight - margin) {
|
| 367 |
pdf.addPage();
|
| 368 |
+
y = margin;
|
| 369 |
}
|
| 370 |
+
pdf.text(line, margin, y);
|
| 371 |
+
y += lineHeight;
|
| 372 |
});
|
| 373 |
|
| 374 |
+
pdf.save("chat-export.pdf");
|
| 375 |
setShowDownloadDialog(false);
|
| 376 |
+
toast.success("PDF downloaded successfully");
|
| 377 |
} catch (error) {
|
| 378 |
+
// eslint-disable-next-line no-console
|
| 379 |
+
console.error("PDF generation error:", error);
|
| 380 |
+
toast.error("Failed to generate PDF");
|
| 381 |
}
|
| 382 |
};
|
| 383 |
|
|
|
|
| 385 |
const isCurrentChatSaved = (): boolean => {
|
| 386 |
if (messages.length <= 1) return false;
|
| 387 |
|
| 388 |
+
return savedChats.some((chat) => {
|
| 389 |
if (chat.chatMode !== chatMode) return false;
|
| 390 |
if (chat.messages.length !== messages.length) return false;
|
| 391 |
|
| 392 |
+
return chat.messages.every((savedMsg, idx) => {
|
| 393 |
+
const currentMsg = messages[idx];
|
| 394 |
return (
|
| 395 |
savedMsg.id === currentMsg.id &&
|
| 396 |
savedMsg.role === currentMsg.role &&
|
|
|
|
| 402 |
|
| 403 |
const handleSaveClick = () => {
|
| 404 |
if (messages.length <= 1) {
|
| 405 |
+
toast.info("No conversation to save");
|
| 406 |
return;
|
| 407 |
}
|
|
|
|
| 408 |
onSaveChat();
|
| 409 |
};
|
| 410 |
|
| 411 |
const handleShareClick = () => {
|
| 412 |
if (messages.length <= 1) {
|
| 413 |
+
toast.info("No conversation to share");
|
| 414 |
return;
|
| 415 |
}
|
| 416 |
const conversationText = buildPreviewContent();
|
| 417 |
+
const blob = new Blob([conversationText], { type: "text/plain" });
|
| 418 |
const url = URL.createObjectURL(blob);
|
| 419 |
setShareLink(url);
|
| 420 |
setTargetWorkspaceId(currentWorkspaceId);
|
|
|
|
| 424 |
const handleCopyShareLink = async () => {
|
| 425 |
try {
|
| 426 |
await navigator.clipboard.writeText(shareLink);
|
| 427 |
+
toast.success("Link copied");
|
| 428 |
} catch {
|
| 429 |
+
toast.error("Failed to copy link");
|
| 430 |
}
|
| 431 |
};
|
| 432 |
|
| 433 |
const handleShareSendToWorkspace = () => {
|
| 434 |
const content = buildPreviewContent();
|
| 435 |
+
onSaveFile?.(content, "export", "text", targetWorkspaceId);
|
| 436 |
setShowShareDialog(false);
|
| 437 |
+
toast.success("Sent to workspace Saved Files");
|
| 438 |
+
};
|
| 439 |
+
|
| 440 |
+
const handleClearClick = () => {
|
| 441 |
+
const saved = isCurrentChatSaved();
|
| 442 |
+
|
| 443 |
+
if (saved) {
|
| 444 |
+
// current logic: if saved, don't prompt — just start new
|
| 445 |
+
onConfirmClear(false);
|
| 446 |
+
return;
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
const hasUserMessages = messages.some((m) => m.role === "user");
|
| 450 |
+
if (!hasUserMessages) {
|
| 451 |
+
onClearConversation();
|
| 452 |
+
return;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
onClearConversation();
|
| 456 |
};
|
| 457 |
|
| 458 |
+
// DnD
|
| 459 |
const handleDragOver = (e: React.DragEvent) => {
|
| 460 |
e.preventDefault();
|
| 461 |
e.stopPropagation();
|
|
|
|
| 478 |
const fileList = e.dataTransfer.files;
|
| 479 |
const files: File[] = [];
|
| 480 |
for (let i = 0; i < fileList.length; i++) {
|
| 481 |
+
const f = fileList.item(i);
|
| 482 |
+
if (f) files.push(f);
|
|
|
|
|
|
|
| 483 |
}
|
| 484 |
|
| 485 |
const validFiles = files.filter((file) => {
|
| 486 |
const ext = file.name.toLowerCase();
|
| 487 |
+
return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some((allowed) =>
|
| 488 |
+
ext.endsWith(allowed)
|
| 489 |
);
|
| 490 |
});
|
| 491 |
|
| 492 |
if (validFiles.length > 0) {
|
| 493 |
+
setPendingFiles(validFiles.map((file) => ({ file, type: "other" as FileType })));
|
| 494 |
setShowTypeDialog(true);
|
| 495 |
} else {
|
| 496 |
+
toast.error("Please upload .pdf, .docx, .pptx, or image files");
|
| 497 |
}
|
| 498 |
};
|
| 499 |
|
|
|
|
| 502 |
if (files.length > 0) {
|
| 503 |
const validFiles = files.filter((file) => {
|
| 504 |
const ext = file.name.toLowerCase();
|
| 505 |
+
return [".pdf", ".docx", ".pptx", ".jpg", ".jpeg", ".png", ".gif", ".webp", ".doc", ".ppt"].some((allowed) =>
|
| 506 |
+
ext.endsWith(allowed)
|
| 507 |
);
|
| 508 |
});
|
| 509 |
+
|
| 510 |
if (validFiles.length > 0) {
|
| 511 |
+
setPendingFiles(validFiles.map((file) => ({ file, type: "other" as FileType })));
|
| 512 |
setShowTypeDialog(true);
|
| 513 |
} else {
|
| 514 |
+
toast.error("Please upload .pdf, .docx, .pptx, or image files");
|
| 515 |
}
|
| 516 |
}
|
| 517 |
+
e.target.value = "";
|
| 518 |
};
|
| 519 |
|
| 520 |
const handleConfirmUpload = () => {
|
| 521 |
+
onFileUpload(pendingFiles.map((pf) => pf.file));
|
| 522 |
const startIndex = uploadedFiles.length;
|
| 523 |
+
|
| 524 |
+
// After uploadedFiles state grows (in parent), we call type change on next tick
|
| 525 |
pendingFiles.forEach((pf, idx) => {
|
| 526 |
setTimeout(() => {
|
| 527 |
onFileTypeChange(startIndex + idx, pf.type);
|
| 528 |
}, 0);
|
| 529 |
});
|
| 530 |
+
|
| 531 |
+
const count = pendingFiles.length;
|
| 532 |
setPendingFiles([]);
|
| 533 |
setShowTypeDialog(false);
|
| 534 |
+
toast.success(`${count} file(s) uploaded successfully`);
|
| 535 |
};
|
| 536 |
|
| 537 |
const handleCancelUpload = () => {
|
|
|
|
| 540 |
};
|
| 541 |
|
| 542 |
const handlePendingFileTypeChange = (index: number, type: FileType) => {
|
| 543 |
+
setPendingFiles((prev) => prev.map((pf, i) => (i === index ? { ...pf, type } : pf)));
|
| 544 |
};
|
| 545 |
|
| 546 |
+
// File helpers
|
| 547 |
const getFileIcon = (filename: string) => {
|
| 548 |
const ext = filename.toLowerCase();
|
| 549 |
+
if (ext.endsWith(".pdf")) return FileText;
|
| 550 |
+
if (ext.endsWith(".docx") || ext.endsWith(".doc")) return File;
|
| 551 |
+
if (ext.endsWith(".pptx") || ext.endsWith(".ppt")) return Presentation;
|
| 552 |
+
if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e))) return ImageIcon;
|
| 553 |
return File;
|
| 554 |
};
|
| 555 |
|
| 556 |
const getFileTypeInfo = (filename: string) => {
|
| 557 |
const ext = filename.toLowerCase();
|
| 558 |
+
if (ext.endsWith(".pdf")) return { bgColor: "bg-red-500", type: "PDF" };
|
| 559 |
+
if (ext.endsWith(".docx") || ext.endsWith(".doc")) return { bgColor: "bg-blue-500", type: "Document" };
|
| 560 |
+
if (ext.endsWith(".pptx") || ext.endsWith(".ppt")) return { bgColor: "bg-orange-500", type: "Presentation" };
|
| 561 |
+
if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e)))
|
| 562 |
+
return { bgColor: "bg-green-500", type: "Image" };
|
| 563 |
+
return { bgColor: "bg-gray-500", type: "File" };
|
| 564 |
};
|
| 565 |
|
| 566 |
const formatFileSize = (bytes: number) => {
|
| 567 |
+
if (bytes < 1024) return `${bytes} B`;
|
| 568 |
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
| 569 |
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
| 570 |
};
|
| 571 |
|
| 572 |
const FileThumbnail = ({
|
|
|
|
| 575 |
fileInfo,
|
| 576 |
isImage,
|
| 577 |
onPreview,
|
| 578 |
+
onRemove,
|
| 579 |
}: {
|
| 580 |
file: File;
|
| 581 |
Icon: React.ComponentType<{ className?: string }>;
|
|
|
|
| 588 |
const [imageLoading, setImageLoading] = useState(true);
|
| 589 |
|
| 590 |
useEffect(() => {
|
| 591 |
+
if (!isImage) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
setImagePreview(null);
|
| 593 |
setImageLoading(false);
|
| 594 |
+
return;
|
| 595 |
}
|
| 596 |
+
|
| 597 |
+
setImageLoading(true);
|
| 598 |
+
const reader = new FileReader();
|
| 599 |
+
reader.onload = (e) => {
|
| 600 |
+
setImagePreview(e.target?.result as string);
|
| 601 |
+
setImageLoading(false);
|
| 602 |
+
};
|
| 603 |
+
reader.onerror = () => {
|
| 604 |
+
setImageLoading(false);
|
| 605 |
+
};
|
| 606 |
+
reader.readAsDataURL(file);
|
| 607 |
}, [file, isImage]);
|
| 608 |
|
| 609 |
if (isImage) {
|
|
|
|
| 611 |
<div
|
| 612 |
className="relative cursor-pointer w-16 h-16 flex-shrink-0"
|
| 613 |
onClick={onPreview}
|
| 614 |
+
style={{ width: "64px", height: "64px", flexShrink: 0 }}
|
| 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">
|
|
|
|
| 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 |
/>
|
|
|
|
| 635 |
</div>
|
| 636 |
)}
|
| 637 |
</div>
|
| 638 |
+
|
| 639 |
<button
|
| 640 |
type="button"
|
| 641 |
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"
|
|
|
|
| 643 |
e.stopPropagation();
|
| 644 |
onRemove(e);
|
| 645 |
}}
|
| 646 |
+
style={{ zIndex: 100, position: "absolute", top: "4px", right: "4px" }}
|
| 647 |
>
|
| 648 |
+
<X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
</button>
|
| 650 |
</div>
|
| 651 |
</div>
|
|
|
|
| 653 |
}
|
| 654 |
|
| 655 |
return (
|
| 656 |
+
<div className="relative cursor-pointer" onClick={onPreview} style={{ width: "240px", flexShrink: 0 }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 657 |
<div className="h-16 w-full relative flex items-center px-3 bg-card border border-border rounded-lg hover:border-primary/50 transition-colors">
|
| 658 |
<div className={`${fileInfo.bgColor} flex items-center justify-center w-10 h-10 rounded shrink-0`}>
|
| 659 |
<Icon className="h-5 w-5 text-white" />
|
|
|
|
| 663 |
<p className="text-xs text-foreground truncate" title={file.name}>
|
| 664 |
{file.name}
|
| 665 |
</p>
|
| 666 |
+
<p className="text-[10px] text-muted-foreground mt-0.5 truncate">{fileInfo.type}</p>
|
|
|
|
|
|
|
| 667 |
</div>
|
| 668 |
+
|
| 669 |
<button
|
| 670 |
type="button"
|
| 671 |
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"
|
|
|
|
| 673 |
e.stopPropagation();
|
| 674 |
onRemove(e);
|
| 675 |
}}
|
| 676 |
+
style={{ position: "absolute", top: "4px", right: "4px", zIndex: 10 }}
|
| 677 |
>
|
| 678 |
+
<X className="h-2.5 w-2.5" style={{ color: "rgb(0, 0, 0)", strokeWidth: 2 }} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 679 |
</button>
|
| 680 |
</div>
|
| 681 |
</div>
|
|
|
|
| 683 |
};
|
| 684 |
|
| 685 |
const FileViewerContent = ({ file }: { file: File }) => {
|
| 686 |
+
const [content, setContent] = useState<string>("");
|
| 687 |
const [loading, setLoading] = useState(true);
|
| 688 |
const [error, setError] = useState<string | null>(null);
|
| 689 |
|
|
|
|
| 695 |
|
| 696 |
const ext = file.name.toLowerCase();
|
| 697 |
|
| 698 |
+
if ([".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e))) {
|
| 699 |
const reader = new FileReader();
|
| 700 |
reader.onload = (e) => {
|
| 701 |
setContent(e.target?.result as string);
|
| 702 |
setLoading(false);
|
| 703 |
};
|
| 704 |
reader.onerror = () => {
|
| 705 |
+
setError("Failed to load image");
|
| 706 |
setLoading(false);
|
| 707 |
};
|
| 708 |
reader.readAsDataURL(file);
|
| 709 |
+
return;
|
| 710 |
+
}
|
| 711 |
+
|
| 712 |
+
if (ext.endsWith(".pdf")) {
|
| 713 |
+
setContent("PDF files cannot be previewed directly. Please download the file to view it.");
|
| 714 |
setLoading(false);
|
| 715 |
+
return;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 716 |
}
|
| 717 |
+
|
| 718 |
+
const reader = new FileReader();
|
| 719 |
+
reader.onload = (e) => {
|
| 720 |
+
setContent(String(e.target?.result ?? ""));
|
| 721 |
+
setLoading(false);
|
| 722 |
+
};
|
| 723 |
+
reader.onerror = () => {
|
| 724 |
+
setError("Failed to load file");
|
| 725 |
+
setLoading(false);
|
| 726 |
+
};
|
| 727 |
+
reader.readAsText(file);
|
| 728 |
+
} catch {
|
| 729 |
+
setError("Failed to load file");
|
| 730 |
setLoading(false);
|
| 731 |
}
|
| 732 |
};
|
|
|
|
| 738 |
if (error) return <div className="text-center py-8 text-destructive">{error}</div>;
|
| 739 |
|
| 740 |
const ext = file.name.toLowerCase();
|
| 741 |
+
const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp"].some((e) => ext.endsWith(e));
|
| 742 |
|
| 743 |
if (isImage) {
|
| 744 |
return (
|
|
|
|
| 756 |
};
|
| 757 |
|
| 758 |
return (
|
| 759 |
+
<div className="flex flex-col h-full min-h-0 overflow-hidden" style={{ overscrollBehavior: "none" }}>
|
| 760 |
+
<div className="flex-1 relative min-h-0 flex flex-col overflow-hidden" style={{ overscrollBehavior: "none" }}>
|
| 761 |
{/* ✅ Messages Area is the ONLY scroll container */}
|
| 762 |
<div
|
| 763 |
ref={scrollContainerRef}
|
| 764 |
className="flex-1 min-h-0 overflow-y-auto"
|
| 765 |
+
style={{ overscrollBehavior: "contain" }}
|
|
|
|
|
|
|
| 766 |
>
|
| 767 |
{/* Top Bar - Sticky */}
|
| 768 |
<div
|
| 769 |
+
className={`sticky top-0 flex items-center justify-between px-4 z-20 bg-card ${
|
| 770 |
+
showTopBorder ? "border-b border-border" : ""
|
| 771 |
+
}`}
|
| 772 |
+
style={{ height: "4.5rem", margin: 0, padding: "1rem 1rem", boxSizing: "border-box" }}
|
|
|
|
|
|
|
|
|
|
| 773 |
>
|
| 774 |
{/* Course Selector - Left */}
|
| 775 |
<div className="flex-shrink-0">
|
| 776 |
{(() => {
|
| 777 |
+
const current = workspaces.find((w) => w.id === currentWorkspaceId);
|
| 778 |
+
if (current?.type === "group") {
|
| 779 |
+
if (current.category === "course" && current.courseName) {
|
| 780 |
return (
|
| 781 |
<div className="h-9 px-3 inline-flex items-center rounded-md border font-semibold">
|
| 782 |
{current.courseName}
|
|
|
|
| 785 |
}
|
| 786 |
return null;
|
| 787 |
}
|
| 788 |
+
|
| 789 |
return (
|
| 790 |
+
<Select
|
| 791 |
+
value={currentCourseId || "course1"}
|
| 792 |
+
onValueChange={(val) => onCourseChange && onCourseChange(val)}
|
| 793 |
+
>
|
| 794 |
<SelectTrigger className="w-[200px] h-9 font-semibold">
|
| 795 |
<SelectValue placeholder="Select course" />
|
| 796 |
</SelectTrigger>
|
|
|
|
| 815 |
orientation="horizontal"
|
| 816 |
>
|
| 817 |
<TabsList className="inline-flex h-8 items-center justify-center rounded-xl bg-muted p-1 text-muted-foreground">
|
| 818 |
+
<TabsTrigger value="ask" className="w-[140px] px-3 text-sm">
|
| 819 |
+
Ask
|
| 820 |
+
</TabsTrigger>
|
| 821 |
<TabsTrigger value="review" className="w-[140px] px-3 text-sm relative">
|
| 822 |
Review
|
| 823 |
<span
|
| 824 |
className="absolute top-0 right-0 bg-red-500 rounded-full border-2"
|
| 825 |
style={{
|
| 826 |
+
width: "10px",
|
| 827 |
+
height: "10px",
|
| 828 |
+
transform: "translate(25%, -25%)",
|
| 829 |
zIndex: 10,
|
| 830 |
+
borderColor: "var(--muted)",
|
| 831 |
}}
|
| 832 |
/>
|
| 833 |
</TabsTrigger>
|
| 834 |
+
<TabsTrigger value="quiz" className="w-[140px] px-3 text-sm">
|
| 835 |
+
Quiz
|
| 836 |
+
</TabsTrigger>
|
| 837 |
</TabsList>
|
| 838 |
</Tabs>
|
| 839 |
</div>
|
|
|
|
| 845 |
size="icon"
|
| 846 |
onClick={handleSaveClick}
|
| 847 |
disabled={!isLoggedIn}
|
| 848 |
+
className={`h-8 w-8 rounded-md hover:bg-muted/50 ${isCurrentChatSaved() ? "text-primary" : ""}`}
|
| 849 |
+
title={isCurrentChatSaved() ? "Unsave" : "Save"}
|
| 850 |
>
|
| 851 |
+
<Bookmark className={`h-4 w-4 ${isCurrentChatSaved() ? "fill-primary text-primary" : ""}`} />
|
| 852 |
</Button>
|
| 853 |
+
|
| 854 |
<Button
|
| 855 |
variant="ghost"
|
| 856 |
size="icon"
|
|
|
|
| 861 |
>
|
| 862 |
<Download className="h-4 w-4" />
|
| 863 |
</Button>
|
| 864 |
+
|
| 865 |
<Button
|
| 866 |
variant="ghost"
|
| 867 |
size="icon"
|
|
|
|
| 872 |
>
|
| 873 |
<Share2 className="h-4 w-4" />
|
| 874 |
</Button>
|
| 875 |
+
|
| 876 |
<Button
|
| 877 |
variant="outline"
|
| 878 |
onClick={handleClearClick}
|
|
|
|
| 887 |
</div>
|
| 888 |
|
| 889 |
{/* Messages Content */}
|
| 890 |
+
<div className="py-6" style={{ paddingBottom: "12rem" }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 891 |
<div className="w-full space-y-6 max-w-4xl mx-auto">
|
| 892 |
{messages.map((message) => (
|
| 893 |
<React.Fragment key={message.id}>
|
| 894 |
<Message
|
| 895 |
message={message}
|
| 896 |
+
showSenderInfo={spaceType === "group"}
|
| 897 |
isFirstGreeting={
|
| 898 |
+
(message.id === "1" || message.id === "review-1" || message.id === "quiz-1") &&
|
| 899 |
+
message.role === "assistant"
|
| 900 |
}
|
| 901 |
showNextButton={message.showNextButton && !isAppTyping}
|
| 902 |
onNextQuestion={onNextQuestion}
|
| 903 |
chatMode={chatMode}
|
| 904 |
/>
|
| 905 |
|
| 906 |
+
{chatMode === "review" && message.id === "review-1" && message.role === "assistant" && (
|
| 907 |
+
<div className="flex gap-2 justify-start px-4">
|
| 908 |
+
<div className="w-10 h-10 flex-shrink-0" />
|
| 909 |
+
<div className="w-full" style={{ maxWidth: "min(770px, calc(100% - 2rem))" }}>
|
| 910 |
+
<SmartReview onReviewTopic={handleReviewTopic} onReviewAll={handleReviewAll} />
|
|
|
|
|
|
|
|
|
|
| 911 |
</div>
|
| 912 |
+
</div>
|
| 913 |
+
)}
|
| 914 |
|
| 915 |
+
{chatMode === "quiz" &&
|
| 916 |
+
message.id === "quiz-1" &&
|
| 917 |
+
message.role === "assistant" &&
|
| 918 |
quizState.currentQuestion === 0 &&
|
| 919 |
!quizState.waitingForAnswer &&
|
| 920 |
!isAppTyping && (
|
|
|
|
| 934 |
</div>
|
| 935 |
<div className="bg-muted rounded-2xl px-4 py-3">
|
| 936 |
<div className="flex gap-1">
|
| 937 |
+
<div
|
| 938 |
+
className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
|
| 939 |
+
style={{ animationDelay: "0ms" }}
|
| 940 |
+
/>
|
| 941 |
+
<div
|
| 942 |
+
className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
|
| 943 |
+
style={{ animationDelay: "150ms" }}
|
| 944 |
+
/>
|
| 945 |
+
<div
|
| 946 |
+
className="w-2 h-2 rounded-full bg-muted-foreground/50 animate-bounce"
|
| 947 |
+
style={{ animationDelay: "300ms" }}
|
| 948 |
+
/>
|
| 949 |
</div>
|
| 950 |
</div>
|
| 951 |
</div>
|
|
|
|
| 961 |
<div
|
| 962 |
className="fixed z-30 flex justify-center pointer-events-none"
|
| 963 |
style={{
|
| 964 |
+
bottom: "120px",
|
| 965 |
+
left: leftPanelVisible ? "320px" : "0px",
|
| 966 |
+
right: "0px",
|
| 967 |
}}
|
| 968 |
>
|
| 969 |
<Button
|
|
|
|
| 982 |
<div
|
| 983 |
className="fixed bottom-0 bg-background/95 backdrop-blur-sm z-10"
|
| 984 |
style={{
|
| 985 |
+
left: leftPanelVisible ? "320px" : "0px",
|
| 986 |
+
right: "0px",
|
| 987 |
}}
|
| 988 |
>
|
| 989 |
<div className="max-w-4xl mx-auto px-4 py-4">
|
|
|
|
| 992 |
{uploadedFiles.map((uploadedFile, index) => {
|
| 993 |
const Icon = getFileIcon(uploadedFile.file.name);
|
| 994 |
const fileInfo = getFileTypeInfo(uploadedFile.file.name);
|
| 995 |
+
const isImage = ["jpg", "jpeg", "png", "gif", "webp"].some((ext) =>
|
| 996 |
uploadedFile.file.name.toLowerCase().endsWith(`.${ext}`)
|
| 997 |
);
|
| 998 |
|
|
|
|
| 1024 |
onDragOver={handleDragOver}
|
| 1025 |
onDragLeave={handleDragLeave}
|
| 1026 |
onDrop={handleDrop}
|
| 1027 |
+
className={isDragging ? "opacity-75" : ""}
|
| 1028 |
>
|
| 1029 |
<div className="relative">
|
| 1030 |
+
{/* Mode Selector + Upload */}
|
| 1031 |
<div className="absolute bottom-3 left-2 flex items-center gap-1 z-10">
|
| 1032 |
+
{chatMode === "ask" && (
|
| 1033 |
<DropdownMenu>
|
| 1034 |
<DropdownMenuTrigger asChild>
|
| 1035 |
<Button
|
|
|
|
| 1046 |
</Button>
|
| 1047 |
</DropdownMenuTrigger>
|
| 1048 |
<DropdownMenuContent align="start" className="w-56">
|
| 1049 |
+
<DropdownMenuItem
|
| 1050 |
+
onClick={() => onLearningModeChange("general")}
|
| 1051 |
+
className={learningMode === "general" ? "bg-accent" : ""}
|
| 1052 |
+
>
|
| 1053 |
<div className="flex flex-col">
|
| 1054 |
<span className="font-medium">General</span>
|
| 1055 |
+
<span className="text-xs text-muted-foreground">
|
| 1056 |
+
Answer various questions (context required)
|
| 1057 |
+
</span>
|
| 1058 |
</div>
|
| 1059 |
</DropdownMenuItem>
|
| 1060 |
+
|
| 1061 |
+
<DropdownMenuItem
|
| 1062 |
+
onClick={() => onLearningModeChange("concept")}
|
| 1063 |
+
className={learningMode === "concept" ? "bg-accent" : ""}
|
| 1064 |
+
>
|
| 1065 |
<div className="flex flex-col">
|
| 1066 |
<span className="font-medium">Concept Explainer</span>
|
| 1067 |
<span className="text-xs text-muted-foreground">Get detailed explanations of concepts</span>
|
| 1068 |
</div>
|
| 1069 |
</DropdownMenuItem>
|
| 1070 |
+
|
| 1071 |
+
<DropdownMenuItem
|
| 1072 |
+
onClick={() => onLearningModeChange("socratic")}
|
| 1073 |
+
className={learningMode === "socratic" ? "bg-accent" : ""}
|
| 1074 |
+
>
|
| 1075 |
<div className="flex flex-col">
|
| 1076 |
<span className="font-medium">Socratic Tutor</span>
|
| 1077 |
<span className="text-xs text-muted-foreground">Learn through guided questions</span>
|
| 1078 |
</div>
|
| 1079 |
</DropdownMenuItem>
|
| 1080 |
+
|
| 1081 |
+
<DropdownMenuItem
|
| 1082 |
+
onClick={() => onLearningModeChange("exam")}
|
| 1083 |
+
className={learningMode === "exam" ? "bg-accent" : ""}
|
| 1084 |
+
>
|
| 1085 |
<div className="flex flex-col">
|
| 1086 |
<span className="font-medium">Exam Prep</span>
|
| 1087 |
<span className="text-xs text-muted-foreground">Practice with quiz questions</span>
|
| 1088 |
</div>
|
| 1089 |
</DropdownMenuItem>
|
| 1090 |
+
|
| 1091 |
+
<DropdownMenuItem
|
| 1092 |
+
onClick={() => onLearningModeChange("assignment")}
|
| 1093 |
+
className={learningMode === "assignment" ? "bg-accent" : ""}
|
| 1094 |
+
>
|
| 1095 |
<div className="flex flex-col">
|
| 1096 |
<span className="font-medium">Assignment Helper</span>
|
| 1097 |
<span className="text-xs text-muted-foreground">Get help with assignments</span>
|
| 1098 |
</div>
|
| 1099 |
</DropdownMenuItem>
|
| 1100 |
+
|
| 1101 |
+
<DropdownMenuItem
|
| 1102 |
+
onClick={() => onLearningModeChange("summary")}
|
| 1103 |
+
className={learningMode === "summary" ? "bg-accent" : ""}
|
| 1104 |
+
>
|
| 1105 |
<div className="flex flex-col">
|
| 1106 |
<span className="font-medium">Quick Summary</span>
|
| 1107 |
<span className="text-xs text-muted-foreground">Get concise summaries</span>
|
|
|
|
| 1115 |
type="button"
|
| 1116 |
size="icon"
|
| 1117 |
variant="ghost"
|
| 1118 |
+
disabled={!isLoggedIn || (chatMode === "quiz" && !quizState.waitingForAnswer)}
|
| 1119 |
className="h-8 w-8 hover:bg-muted/50"
|
| 1120 |
onClick={() => fileInputRef.current?.click()}
|
| 1121 |
title="Upload files"
|
|
|
|
| 1131 |
placeholder={
|
| 1132 |
!isLoggedIn
|
| 1133 |
? "Please log in on the right to start chatting..."
|
| 1134 |
+
: chatMode === "quiz"
|
| 1135 |
+
? quizState.waitingForAnswer
|
| 1136 |
+
? "Type your answer here..."
|
| 1137 |
+
: quizState.currentQuestion > 0
|
| 1138 |
+
? "Click 'Next Question' to continue..."
|
| 1139 |
+
: "Click 'Start Quiz' to begin..."
|
| 1140 |
+
: spaceType === "group"
|
| 1141 |
+
? "Type a message or drag files here... (mention @Clare to get AI assistance)"
|
| 1142 |
+
: learningMode === "general"
|
| 1143 |
+
? "Ask me anything! Please provide context about your question..."
|
| 1144 |
+
: "Ask Clare anything about the course or drag files here..."
|
| 1145 |
}
|
| 1146 |
+
disabled={!isLoggedIn || (chatMode === "quiz" && !quizState.waitingForAnswer)}
|
| 1147 |
+
className={`min-h-[80px] pl-4 pr-20 resize-none bg-background border-2 ${
|
| 1148 |
+
isDragging ? "border-primary border-dashed" : "border-border"
|
| 1149 |
+
}`}
|
| 1150 |
/>
|
| 1151 |
|
| 1152 |
<div className="absolute bottom-2 right-2 flex gap-1">
|
| 1153 |
+
<Button type="submit" size="icon" disabled={!input.trim() || !isLoggedIn} className="h-8 w-8 rounded-full">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1154 |
<Send className="h-4 w-4" />
|
| 1155 |
</Button>
|
| 1156 |
</div>
|
|
|
|
| 1178 |
<AlertDialogDescription>
|
| 1179 |
Would you like to save the current chat before starting a new conversation?
|
| 1180 |
</AlertDialogDescription>
|
| 1181 |
+
|
| 1182 |
<Button
|
| 1183 |
variant="ghost"
|
| 1184 |
size="icon"
|
|
|
|
| 1189 |
<span className="sr-only">Close</span>
|
| 1190 |
</Button>
|
| 1191 |
</AlertDialogHeader>
|
| 1192 |
+
|
| 1193 |
<AlertDialogFooter className="flex-col sm:flex-row gap-2 sm:justify-end">
|
| 1194 |
<Button variant="outline" onClick={() => onConfirmClear(false)} className="sm:flex-1 sm:max-w-[200px]">
|
| 1195 |
Start New (Don't Save)
|
|
|
|
| 1212 |
<Tabs
|
| 1213 |
value={downloadTab}
|
| 1214 |
onValueChange={(value) => {
|
| 1215 |
+
const v = value as "chat" | "summary";
|
| 1216 |
+
setDownloadTab(v);
|
| 1217 |
+
setDownloadPreview(v === "chat" ? buildPreviewContent() : buildSummaryContent());
|
| 1218 |
+
if (v === "summary") setDownloadOptions({ chat: false, summary: true });
|
| 1219 |
else setDownloadOptions({ chat: true, summary: false });
|
| 1220 |
}}
|
| 1221 |
className="w-full"
|
|
|
|
| 1229 |
<div className="border rounded-lg bg-muted/40 flex flex-col max-h-64">
|
| 1230 |
<div className="flex items-center justify-between p-4 sticky top-0 bg-muted/40 border-b z-10">
|
| 1231 |
<span className="text-sm font-medium">Preview</span>
|
| 1232 |
+
<Button
|
| 1233 |
+
variant="outline"
|
| 1234 |
+
size="sm"
|
| 1235 |
+
className="h-7 px-2 text-xs gap-1.5"
|
| 1236 |
+
onClick={handleCopyPreview}
|
| 1237 |
+
title="Copy preview"
|
| 1238 |
+
>
|
| 1239 |
<Copy className="h-3 w-3" />
|
| 1240 |
Copy
|
| 1241 |
</Button>
|
|
|
|
| 1252 |
checked={downloadOptions.chat}
|
| 1253 |
onCheckedChange={(checked) => setDownloadOptions({ ...downloadOptions, chat: checked === true })}
|
| 1254 |
/>
|
| 1255 |
+
<label htmlFor="download-chat" className="text-sm font-medium cursor-pointer">
|
| 1256 |
+
Download chat
|
| 1257 |
+
</label>
|
| 1258 |
</div>
|
| 1259 |
<div className="flex items-center space-x-2">
|
| 1260 |
<Checkbox
|
|
|
|
| 1262 |
checked={downloadOptions.summary}
|
| 1263 |
onCheckedChange={(checked) => setDownloadOptions({ ...downloadOptions, summary: checked === true })}
|
| 1264 |
/>
|
| 1265 |
+
<label htmlFor="download-summary" className="text-sm font-medium cursor-pointer">
|
| 1266 |
+
Download summary
|
| 1267 |
+
</label>
|
| 1268 |
</div>
|
| 1269 |
</div>
|
| 1270 |
|
| 1271 |
<DialogFooter>
|
| 1272 |
+
<Button variant="outline" onClick={() => setShowDownloadDialog(false)}>
|
| 1273 |
+
Cancel
|
| 1274 |
+
</Button>
|
| 1275 |
<Button onClick={handleDownloadFile}>Download</Button>
|
| 1276 |
</DialogFooter>
|
| 1277 |
</DialogContent>
|
|
|
|
| 1289 |
<Label>Copy Link</Label>
|
| 1290 |
<div className="flex gap-2 items-center">
|
| 1291 |
<Input value={shareLink} readOnly className="flex-1" />
|
| 1292 |
+
<Button variant="secondary" onClick={handleCopyShareLink}>
|
| 1293 |
+
Copy
|
| 1294 |
+
</Button>
|
| 1295 |
</div>
|
| 1296 |
<p className="text-xs text-muted-foreground">Temporary link valid for this session.</p>
|
| 1297 |
</div>
|
| 1298 |
+
|
| 1299 |
<div className="space-y-2">
|
| 1300 |
<Label>Send to Workspace</Label>
|
| 1301 |
<Select value={targetWorkspaceId} onValueChange={setTargetWorkspaceId}>
|
|
|
|
| 1303 |
<SelectValue placeholder="Choose a workspace" />
|
| 1304 |
</SelectTrigger>
|
| 1305 |
<SelectContent>
|
| 1306 |
+
{workspaces.map((w) => (
|
| 1307 |
+
<SelectItem key={w.id} value={w.id}>
|
| 1308 |
+
{w.name}
|
| 1309 |
+
</SelectItem>
|
| 1310 |
))}
|
| 1311 |
</SelectContent>
|
| 1312 |
</Select>
|
| 1313 |
+
<p className="text-xs text-muted-foreground">
|
| 1314 |
+
Sends this conversation to the selected workspace's Saved Files.
|
| 1315 |
+
</p>
|
| 1316 |
+
<Button onClick={handleShareSendToWorkspace} className="w-full">
|
| 1317 |
+
Send
|
| 1318 |
+
</Button>
|
| 1319 |
</div>
|
| 1320 |
</div>
|
| 1321 |
</DialogContent>
|
|
|
|
| 1327 |
<AlertDialogHeader>
|
| 1328 |
<AlertDialogTitle>Delete File</AlertDialogTitle>
|
| 1329 |
<AlertDialogDescription>
|
| 1330 |
+
Are you sure you want to delete "
|
| 1331 |
+
{fileToDelete !== null ? uploadedFiles[fileToDelete]?.file.name : ""}
|
| 1332 |
+
"? This action cannot be undone.
|
| 1333 |
</AlertDialogDescription>
|
| 1334 |
</AlertDialogHeader>
|
| 1335 |
<AlertDialogFooter>
|
|
|
|
| 1356 |
<DialogTitle
|
| 1357 |
className="pr-8 break-words break-all overflow-wrap-anywhere leading-relaxed"
|
| 1358 |
style={{
|
| 1359 |
+
wordBreak: "break-all",
|
| 1360 |
+
overflowWrap: "anywhere",
|
| 1361 |
+
maxWidth: "100%",
|
| 1362 |
+
lineHeight: "1.6",
|
| 1363 |
}}
|
| 1364 |
>
|
| 1365 |
{selectedFile?.file.name}
|
| 1366 |
</DialogTitle>
|
| 1367 |
<DialogDescription>
|
| 1368 |
+
File size: {selectedFile ? formatFileSize(selectedFile.file.size) : ""}
|
| 1369 |
</DialogDescription>
|
| 1370 |
</DialogHeader>
|
| 1371 |
+
<div className="flex-1 min-h-0 overflow-y-auto mt-4">{selectedFile && <FileViewerContent file={selectedFile.file} />}</div>
|
|
|
|
|
|
|
| 1372 |
</DialogContent>
|
| 1373 |
</Dialog>
|
| 1374 |
|
|
|
|
| 1380 |
<DialogTitle>Select File Types</DialogTitle>
|
| 1381 |
<DialogDescription>Please select the type for each file you are uploading.</DialogDescription>
|
| 1382 |
</DialogHeader>
|
| 1383 |
+
|
| 1384 |
<div className="space-y-3 max-h-64 overflow-y-auto">
|
| 1385 |
{pendingFiles.map((pendingFile, index) => {
|
| 1386 |
const Icon = getFileIcon(pendingFile.file.name);
|
|
|
|
| 1393 |
<p className="text-xs text-muted-foreground">{formatFileSize(pendingFile.file.size)}</p>
|
| 1394 |
</div>
|
| 1395 |
</div>
|
| 1396 |
+
|
| 1397 |
<div className="space-y-1">
|
| 1398 |
<label className="text-xs text-muted-foreground">File Type</label>
|
| 1399 |
<Select
|
|
|
|
| 1415 |
);
|
| 1416 |
})}
|
| 1417 |
</div>
|
| 1418 |
+
|
| 1419 |
<DialogFooter>
|
| 1420 |
+
<Button variant="outline" onClick={handleCancelUpload}>
|
| 1421 |
+
Cancel
|
| 1422 |
+
</Button>
|
| 1423 |
<Button onClick={handleConfirmUpload}>Upload</Button>
|
| 1424 |
</DialogFooter>
|
| 1425 |
</DialogContent>
|