From 7549f4eb9d2d4ae38ca8b1fe56d2e409381307e5 Mon Sep 17 00:00:00 2001 From: dadukhankevin Date: Tue, 3 Feb 2026 18:43:01 -0600 Subject: [PATCH 01/14] cleaner navigation --- .../src/NavigationView/index.tsx | 385 ++++++++---------- .../src/components/ui/circular-progress.tsx | 97 +++++ 2 files changed, 263 insertions(+), 219 deletions(-) create mode 100644 webviews/codex-webviews/src/components/ui/circular-progress.tsx diff --git a/webviews/codex-webviews/src/NavigationView/index.tsx b/webviews/codex-webviews/src/NavigationView/index.tsx index 05e082d5a..d7608f8c7 100644 --- a/webviews/codex-webviews/src/NavigationView/index.tsx +++ b/webviews/codex-webviews/src/NavigationView/index.tsx @@ -3,10 +3,9 @@ import { createRoot } from "react-dom/client"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import bibleData from "../assets/bible-books-lookup.json"; -import { Progress } from "../components/ui/progress"; +import { DualRingProgress } from "../components/ui/circular-progress"; import "../tailwind.css"; import { CodexItem } from "types"; -import { Popover, PopoverContent, PopoverTrigger } from "../components/ui/popover"; import { Languages } from "lucide-react"; import { RenameModal } from "../components/RenameModal"; @@ -190,10 +189,6 @@ function NavigationView() { }, }); - const [expandedValidationTicks, setExpandedValidationTicks] = useState>( - {} - ); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); // Initialize Bible book map on component mount @@ -583,126 +578,41 @@ function NavigationView() { .filter((item): item is CodexItem => item !== null); }; - const toggleTextValidationLevelTicks = - (itemKey: string) => (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - const key = `${itemKey}-text`; - setExpandedValidationTicks((prev) => ({ ...prev, [key]: !prev[key] })); - }; - - const toggleAudioValidationLevelTicks = - (itemKey: string) => (e: React.MouseEvent) => { - e.stopPropagation(); - e.preventDefault(); - const key = `${itemKey}-audio`; - setExpandedValidationTicks((prev) => ({ ...prev, [key]: !prev[key] })); - }; - - const renderProgressSection = ( - itemKey: string, - progress?: { - percentTranslationsCompleted?: number; - percentFullyValidatedTranslations?: number; - percentTextValidatedTranslations?: number; - percentAudioTranslationsCompleted?: number; - percentAudioValidatedTranslations?: number; - textValidationLevels?: number[]; - audioValidationLevels?: number[]; - requiredTextValidations?: number; - requiredAudioValidations?: number; + const getProgressValues = (progress?: { + percentTranslationsCompleted?: number; + percentTextValidatedTranslations?: number; + percentFullyValidatedTranslations?: number; + percentAudioTranslationsCompleted?: number; + percentAudioValidatedTranslations?: number; + }) => { + if (typeof progress !== "object") { + return { + textCompletion: 0, + textValidation: 0, + audioCompletion: 0, + audioValidation: 0, + }; } - ) => { - if (typeof progress !== "object") return null; - const textCompleted = Math.max( - 0, - Math.min(100, progress.percentTranslationsCompleted ?? 0) - ); - const textValidated = Math.max( - 0, - Math.min( - 100, - progress.percentTextValidatedTranslations ?? - progress.percentFullyValidatedTranslations ?? - 0 - ) - ); - const audioCompleted = Math.max( - 0, - Math.min(100, progress.percentAudioTranslationsCompleted ?? 0) - ); - const audioValidated = Math.max( - 0, - Math.min(100, progress.percentAudioValidatedTranslations ?? 0) - ); - - const textLevels = Array.isArray(progress.textValidationLevels) - ? progress.textValidationLevels - : [textValidated]; - const audioLevels = Array.isArray(progress.audioValidationLevels) - ? progress.audioValidationLevels - : [audioValidated]; - const requiredText = progress.requiredTextValidations; - const requiredAudio = progress.requiredAudioValidations; - - return ( -
-
- - - -
- -
- -
-
- - - -
- -
- -
-
- ); + return { + textCompletion: Math.max(0, Math.min(100, progress.percentTranslationsCompleted ?? 0)), + textValidation: Math.max( + 0, + Math.min( + 100, + progress.percentTextValidatedTranslations ?? + progress.percentFullyValidatedTranslations ?? + 0 + ) + ), + audioCompletion: Math.max( + 0, + Math.min(100, progress.percentAudioTranslationsCompleted ?? 0) + ), + audioValidation: Math.max( + 0, + Math.min(100, progress.percentAudioValidatedTranslations ?? 0) + ), + }; }; const renderItem = (item: CodexItem) => { @@ -781,6 +691,9 @@ function NavigationView() { ); } + const progressValues = getProgressValues(item.progress); + const hasProgress = item.progress && typeof item.progress === "object"; + return (
-
-
- {isGroup && ( - - )} +
+ {isGroup && ( - - {displayLabel} - -
- {renderProgressSection(itemId, item.progress)} -
+ )} + + + {displayLabel} + - {/* Menu button positioned absolutely */} - {(!isGroup || item.type === "corpus") && ( - <> - - - - - - {item.type === "codexDocument" && ( -
{ - e.stopPropagation(); - handleEditBookName(item); - }} - > - - Edit Book Name -
- )} - {item.type === "corpus" && ( -
{ - e.stopPropagation(); - handleEditCorpusMarker(item); - }} - > - - Rename Group -
- )} - {!isGroup && ( -
{ - e.stopPropagation(); - handleDelete(item); - }} - > - - Delete -
- )} -
-
- - )} + + +
+ )} +
+ )} + + {/* Direct action buttons - visible on hover */} +
+ {item.type === "codexDocument" && ( + + )} + {item.type === "corpus" && ( + + )} + {!isGroup && ( + + )} +
+
{isGroup && isExpanded && item.children && (
@@ -972,6 +905,21 @@ function NavigationView() {
+ {/* Progress legend */} +
+
+
+ Complete +
+
+
+ Validated +
+
+
{(() => { if (filteredCodexItems.length > 0 || otherDictionaries.length > 0) { @@ -1000,28 +948,27 @@ function NavigationView() {
- {/* Add Files Button */} - - - {/* Export Files Button */} - + {/* Action Buttons - Side by Side */} +
+ + +
{/* Project Dictionary */} {projectDictionary && renderItem(projectDictionary)} diff --git a/webviews/codex-webviews/src/components/ui/circular-progress.tsx b/webviews/codex-webviews/src/components/ui/circular-progress.tsx new file mode 100644 index 000000000..dfe03c66e --- /dev/null +++ b/webviews/codex-webviews/src/components/ui/circular-progress.tsx @@ -0,0 +1,97 @@ +import * as React from "react"; + +interface DualRingProgressProps { + completionValue: number; + validationValue: number; + size?: number; + className?: string; +} + +function DualRingProgress({ + completionValue, + validationValue, + size = 32, + className = "", +}: DualRingProgressProps) { + const outerStrokeWidth = 3; + const innerStrokeWidth = 2.5; + const gap = 1; + + const outerRadius = (size - outerStrokeWidth) / 2; + const innerRadius = outerRadius - outerStrokeWidth / 2 - gap - innerStrokeWidth / 2; + + const outerCircumference = outerRadius * 2 * Math.PI; + const innerCircumference = innerRadius * 2 * Math.PI; + + const clampedCompletion = Math.max(0, Math.min(100, completionValue)); + const clampedValidation = Math.max(0, Math.min(100, validationValue)); + + const outerOffset = outerCircumference - (clampedCompletion / 100) * outerCircumference; + const innerOffset = innerCircumference - (clampedValidation / 100) * innerCircumference; + + const displayValue = Math.floor(clampedCompletion); + + return ( +
+ + {/* Outer background circle (completion track) */} + + {/* Outer progress circle (completion - blue) */} + + {/* Inner background circle (validation track) */} + + {/* Inner progress circle (validation - gold) */} + + + {/* Percentage in center */} +
+ + {displayValue} + +
+
+ ); +} + +export { DualRingProgress }; From 193755db13fdedae64224d764b25af54feb6b8ba Mon Sep 17 00:00:00 2001 From: dadukhankevin Date: Tue, 3 Feb 2026 22:37:43 -0600 Subject: [PATCH 02/14] much cleaner, and normal, comments system (like discord) --- .../src/CommentsView/CommentsView.tsx | 1581 ++++++----------- .../src/NavigationView/index.tsx | 44 +- .../src/components/ui/expandable-progress.tsx | 107 ++ 3 files changed, 674 insertions(+), 1058 deletions(-) create mode 100644 webviews/codex-webviews/src/components/ui/expandable-progress.tsx diff --git a/webviews/codex-webviews/src/CommentsView/CommentsView.tsx b/webviews/codex-webviews/src/CommentsView/CommentsView.tsx index 3f635a80b..cec8cb9a2 100644 --- a/webviews/codex-webviews/src/CommentsView/CommentsView.tsx +++ b/webviews/codex-webviews/src/CommentsView/CommentsView.tsx @@ -1,28 +1,19 @@ -import { useState, useEffect, useCallback, useMemo } from "react"; +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { MessageSquare, - Search, Plus, - ChevronRight, - ChevronDown, - Edit, + ChevronLeft, Check, - Circle, X, Trash2, Undo2, Send, - Reply, - Eye, - EyeOff, - ChevronUp, + Hash, Clock, - MoreHorizontal, + Reply, } from "lucide-react"; import { Button } from "../components/ui/button"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card"; import { Badge } from "../components/ui/badge"; -import { Input } from "../components/ui/input"; import { NotebookCommentThread, CommentPostMessages, CellIdGlobalState } from "../../../../types"; import { v4 as uuidv4 } from "uuid"; import { WebviewHeader } from "../components/WebviewHeader"; @@ -31,138 +22,65 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../com const vscode = acquireVsCodeApi(); type Comment = NotebookCommentThread["comments"][0]; -interface UserAvatar { - username: string; - email?: string; - size?: "small" | "medium" | "large"; -} - // Helper function to generate deterministic colors for usernames const getUserColor = (username: string): string => { - // Distinct, readable colors for avatars (using actual color values) const colors = [ - "#3b82f6", // blue-500 - "#10b981", // emerald-500 - "#8b5cf6", // violet-500 - "#f59e0b", // amber-500 - "#ec4899", // pink-500 - "#06b6d4", // cyan-500 - "#ef4444", // red-500 - "#6366f1", // indigo-500 - "#14b8a6", // teal-500 - "#84cc16", // lime-500 - "#f97316", // orange-500 - "#a855f7", // purple-500 - "#f43f5e", // rose-500 - "#0ea5e9", // sky-500 - "#22c55e", // green-500 - "#eab308", // yellow-500 + "#3b82f6", "#10b981", "#8b5cf6", "#f59e0b", "#ec4899", "#06b6d4", + "#ef4444", "#6366f1", "#14b8a6", "#84cc16", "#f97316", "#a855f7", ]; - - // Create deterministic hash from username using a better hash function let hash = 0; - if (username.length === 0) return colors[0]; - for (let i = 0; i < username.length; i++) { - const char = username.charCodeAt(i); - hash = (hash << 5) - hash + char; - hash = hash & hash; // Convert to 32bit integer + hash = (hash << 5) - hash + username.charCodeAt(i); + hash = hash & hash; } - - // Add some additional mixing to reduce collisions - hash = hash ^ (hash >>> 16); - hash = hash * 0x85ebca6b; - hash = hash ^ (hash >>> 13); - hash = hash * 0xc2b2ae35; - hash = hash ^ (hash >>> 16); - - // Use absolute value and modulo to get color index - const colorIndex = Math.abs(hash) % colors.length; - return colors[colorIndex]; + return colors[Math.abs(hash) % colors.length]; }; -// Helper function to format timestamps in a user-friendly way +// Helper function to format timestamps const formatTimestamp = (timestamp: string | number): { display: string; full: string } => { const now = new Date(); const date = new Date(typeof timestamp === "string" ? parseInt(timestamp) : timestamp); - - // If invalid date, return fallback - if (isNaN(date.getTime())) { - return { display: "", full: "" }; - } + if (isNaN(date.getTime())) return { display: "", full: "" }; const diffMs = now.getTime() - date.getTime(); const diffMinutes = Math.floor(diffMs / (1000 * 60)); const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - - // Full timestamp for hover const full = date.toLocaleString(); - // Display format based on age - if (diffMinutes < 1) { - return { display: "just now", full }; - } else if (diffMinutes < 60) { - return { display: `${diffMinutes}m ago`, full }; - } else if (diffHours < 24) { - return { display: `${diffHours}h ago`, full }; - } else if (diffDays === 1) { - return { display: "yesterday", full }; - } else if (diffDays < 7) { - return { display: `${diffDays}d ago`, full }; - } else { - // For older dates, show month/day - const monthDay = date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - }); - return { display: monthDay, full }; - } + if (diffMinutes < 1) return { display: "just now", full }; + if (diffMinutes < 60) return { display: `${diffMinutes}m ago`, full }; + if (diffHours < 24) return { display: `${diffHours}h ago`, full }; + if (diffDays === 1) return { display: "yesterday", full }; + if (diffDays < 7) return { display: `${diffDays}d ago`, full }; + return { display: date.toLocaleDateString("en-US", { month: "short", day: "numeric" }), full }; }; -const UserAvatar = ({ username, email, size = "small" }: UserAvatar) => { - const sizeMap = { - small: { width: "24px", height: "24px", fontSize: "12px" }, - medium: { width: "32px", height: "32px", fontSize: "14px" }, - large: { width: "40px", height: "40px", fontSize: "16px" }, - }; - - const userColor = getUserColor(username); - - return ( -
-
- {username[0].toUpperCase()} -
- {/* Hide username text on narrow viewports (VSCode sidebar) */} -
- {username} -
-
- ); -}; +// Author name with color +const AuthorName = ({ username, size = "sm" }: { username: string; size?: "sm" | "base" }) => ( + + {username} + +); function App() { - const [cellId, setCellId] = useState({ cellId: "", uri: "" }); - const [uri, setUri] = useState(); + const [cellId, setCellId] = useState({ cellId: "", uri: "", globalReferences: [] }); const [commentThreadArray, setCommentThread] = useState([]); - const [replyText, setReplyText] = useState>({}); - const [collapsedThreads, setCollapsedThreads] = useState>({}); - const [searchQuery, setSearchQuery] = useState(""); - const [showNewCommentForm, setShowNewCommentForm] = useState(false); - const [newCommentText, setNewCommentText] = useState(""); - const [pendingResolveThreads, setPendingResolveThreads] = useState>(new Set()); + const [messageText, setMessageText] = useState(""); const [viewMode, setViewMode] = useState<"all" | "cell">("cell"); - const [showResolvedThreads, setShowResolvedThreads] = useState(false); + const [selectedThread, setSelectedThread] = useState(null); + const [pendingResolveThreads, setPendingResolveThreads] = useState>(new Set()); + const [showNewThreadForm, setShowNewThreadForm] = useState(false); + const [newThreadText, setNewThreadText] = useState(""); + const [replyingTo, setReplyingTo] = useState(null); + const messagesEndRef = useRef(null); + const textareaRef = useRef(null); + const commentRefs = useRef>(new Map()); + const MAX_MESSAGE_LENGTH = 8000; + const REPLY_PREVIEW_MAX_WORDS = 12; const [currentUser, setCurrentUser] = useState<{ username: string; email: string; @@ -173,1032 +91,633 @@ function App() { isAuthenticated: false, }); - // Force re-render for timestamp updates - const [timestampUpdateTrigger, setTimestampUpdateTrigger] = useState(0); - - // Update timestamps every minute + // Scroll to bottom when messages change useEffect(() => { - const interval = setInterval(() => { - setTimestampUpdateTrigger((prev) => prev + 1); - }, 60000); // Update every minute - - return () => clearInterval(interval); + if (selectedThread) { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + } + }, [selectedThread, commentThreadArray]); + + // Auto-resize textarea + const autoResizeTextarea = useCallback(() => { + const textarea = textareaRef.current; + if (textarea) { + textarea.style.height = "auto"; + textarea.style.height = Math.min(textarea.scrollHeight, 200) + "px"; + } }, []); - // Track current user state changes useEffect(() => { - // User state updated - }, [currentUser]); - - const [expandedThreads, setExpandedThreads] = useState>(new Set()); - const [replyingTo, setReplyingTo] = useState<{ threadId: string; username?: string } | null>( - null - ); - const [editingTitle, setEditingTitle] = useState(null); - const [threadTitleEdit, setThreadTitleEdit] = useState(""); + autoResizeTextarea(); + }, [messageText, autoResizeTextarea]); - // Helper function to determine if thread is currently resolved based on latest event const isThreadResolved = useCallback((thread: NotebookCommentThread): boolean => { const resolvedEvents = thread.resolvedEvent || []; - const latestResolvedEvent = - resolvedEvents.length > 0 - ? resolvedEvents.reduce((latest, event) => - event.timestamp > latest.timestamp ? event : latest - ) - : null; - return latestResolvedEvent?.resolved || false; + if (resolvedEvents.length === 0) return false; + const latest = resolvedEvents.reduce((a, b) => (a.timestamp > b.timestamp ? a : b)); + return latest.resolved || false; }, []); - // Helper function to determine if thread is currently deleted based on latest event const isThreadDeleted = useCallback((thread: NotebookCommentThread): boolean => { const deletionEvents = thread.deletionEvent || []; - const latestDeletionEvent = - deletionEvents.length > 0 - ? deletionEvents.reduce((latest, event) => - event.timestamp > latest.timestamp ? event : latest - ) - : null; - return latestDeletionEvent?.deleted || false; + if (deletionEvents.length === 0) return false; + const latest = deletionEvents.reduce((a, b) => (a.timestamp > b.timestamp ? a : b)); + return latest.deleted || false; }, []); const handleMessage = useCallback( (event: MessageEvent) => { const message: CommentPostMessages = event.data; - switch (message.command) { - case "commentsFromWorkspace": { + case "commentsFromWorkspace": if (message.content) { try { - const comments = JSON.parse(message.content); - setCommentThread(comments); + setCommentThread(JSON.parse(message.content)); setPendingResolveThreads(new Set()); } catch (error) { console.error("[CommentsWebview] Error parsing comments:", error); } } break; - } - case "reload": { + case "reload": if (message.data?.cellId) { - setCellId({ cellId: message.data.cellId, uri: message.data.uri || "" }); - if (viewMode === "cell") { - setSearchQuery(message.data.cellId); - } - } - if (message.data?.uri) { - setUri(message.data.uri); + setCellId({ + cellId: message.data.cellId, + uri: message.data.uri || "", + globalReferences: message.data.globalReferences || [], + }); } break; - } - case "updateUserInfo": { - if (message.userInfo) { - const newUser = { - username: message.userInfo.username, - email: message.userInfo.email, - isAuthenticated: true, - }; - setCurrentUser(newUser); - } else { - const newUser = { - username: "vscode", - email: "", - isAuthenticated: false, - }; - setCurrentUser(newUser); - } + case "updateUserInfo": + setCurrentUser( + message.userInfo + ? { ...message.userInfo, isAuthenticated: true } + : { username: "vscode", email: "", isAuthenticated: false } + ); break; - } - default: - // Unknown message command } }, - [viewMode] + [] ); useEffect(() => { window.addEventListener("message", handleMessage); + vscode.postMessage({ command: "fetchComments" }); + vscode.postMessage({ command: "getCurrentCellId" }); + return () => window.removeEventListener("message", handleMessage); + }, [handleMessage]); - // Request initial data - vscode.postMessage({ - command: "fetchComments", - } as CommentPostMessages); + // Parse reply reference from message body + const parseReplyInfo = (body: string): { replyToId: string | null; content: string } => { + const match = body.match(/^@reply:([^\n]+)\n([\s\S]*)$/); + if (match) { + return { replyToId: match[1], content: match[2] }; + } + // Legacy: check for markdown quote style + const lines = body.split("\n"); + const quoteLines: string[] = []; + let contentStart = 0; + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith("> ")) { + quoteLines.push(lines[i].slice(2)); + } else if (lines[i].trim() === "" && quoteLines.length > 0) { + contentStart = i + 1; + break; + } else { + break; + } + } + if (quoteLines.length > 0) { + return { replyToId: null, content: lines.slice(contentStart).join("\n") }; + } + return { replyToId: null, content: body }; + }; - vscode.postMessage({ - command: "getCurrentCellId", - } as CommentPostMessages); + // Find comment by ID in current thread + const findCommentById = (commentId: string): Comment | null => { + if (!currentThread) return null; + return currentThread.comments.find((c) => c.id === commentId) || null; + }; + + // Scroll to a comment + const scrollToComment = (commentId: string) => { + const element = commentRefs.current.get(commentId); + if (element) { + element.scrollIntoView({ behavior: "smooth", block: "center" }); + element.classList.add("bg-primary/10"); + setTimeout(() => element.classList.remove("bg-primary/10"), 1500); + } + }; + + // Truncate text to max words + const truncateToWords = (text: string, maxWords: number): string => { + const words = text.split(/\s+/); + if (words.length <= maxWords) return text; + return words.slice(0, maxWords).join(" ") + "..."; + }; + + const getCellLabel = (cellIdState: CellIdGlobalState | string): string => { + if (typeof cellIdState === "string") { + return cellIdState.length > 20 ? cellIdState.slice(-12) : cellIdState; + } + if (cellIdState.globalReferences?.length > 0) { + return cellIdState.globalReferences[0]; + } + const id = cellIdState.cellId; + return id.length > 20 ? id.slice(-12) : id; + }; + + const getThreadPreview = (thread: NotebookCommentThread): string => { + const firstComment = thread.comments[0]; + if (!firstComment) return "Empty thread"; + const plainText = firstComment.body + .split("\n") + .filter((line) => !line.startsWith("> ")) + .join(" ") + .trim(); + return plainText.length > 50 ? plainText.slice(0, 47) + "..." : plainText || "Empty thread"; + }; + + // Filter and sort threads: unresolved first, then resolved at bottom + const filteredThreads = useMemo(() => { + const nonDeleted = commentThreadArray.filter((t) => !isThreadDeleted(t)); + const filtered = nonDeleted.filter((t) => { + if (viewMode === "cell" && cellId.cellId) { + return t.cellId.cellId === cellId.cellId; + } + return true; + }); - return () => { - window.removeEventListener("message", handleMessage); + // Sort: unresolved first (by latest activity), then resolved + const unresolved = filtered.filter((t) => !isThreadResolved(t)); + const resolved = filtered.filter((t) => isThreadResolved(t)); + + const byLatestActivity = (a: NotebookCommentThread, b: NotebookCommentThread) => { + const aTime = Math.max(...a.comments.map((c) => c.timestamp)); + const bTime = Math.max(...b.comments.map((c) => c.timestamp)); + return bTime - aTime; }; - }, [handleMessage]); - const handleReply = (threadId: string) => { - if (!replyText[threadId]?.trim() || !currentUser.isAuthenticated) return; + return [...unresolved.sort(byLatestActivity), ...resolved.sort(byLatestActivity)]; + }, [commentThreadArray, viewMode, cellId.cellId, isThreadDeleted, isThreadResolved]); + + const currentThread = selectedThread + ? commentThreadArray.find((t) => t.id === selectedThread) + : null; + + const handleSendMessage = () => { + if (!messageText.trim() || !currentThread || !currentUser.isAuthenticated) return; + if (isThreadResolved(currentThread)) return; - const existingThread = commentThreadArray.find((thread) => thread.id === threadId); const timestamp = Date.now(); - const newCommentId = `${timestamp}-${Math.random().toString(36).substr(2, 9)}`; - const comment: Comment = { - id: newCommentId, - timestamp: timestamp, - body: replyText[threadId], + // Build message body with optional reply reference + let body = messageText.trim(); + if (replyingTo) { + body = `@reply:${replyingTo.id}\n${body}`; + } + + const newComment: Comment = { + id: `${timestamp}-${Math.random().toString(36).substr(2, 9)}`, + timestamp, + body, mode: 1, author: { name: currentUser.username }, deleted: false, }; - const updatedThread: NotebookCommentThread = { - ...(existingThread || { - id: threadId, - canReply: true, - cellId: cellId, - collapsibleState: 0, - threadTitle: "", - deleted: false, - resolved: false, - }), - comments: existingThread ? [...existingThread.comments, comment] : [comment], - }; - vscode.postMessage({ command: "updateCommentThread", - commentThread: updatedThread, - } as CommentPostMessages); - - setReplyText((prev) => ({ ...prev, [threadId]: "" })); + commentThread: { ...currentThread, comments: [...currentThread.comments, newComment] }, + }); + setMessageText(""); setReplyingTo(null); }; - const handleThreadDeletion = (commentThreadId: string) => { - vscode.postMessage({ - command: "deleteCommentThread", - commentThreadId, - } as CommentPostMessages); - }; - - const handleCommentDeletion = (commentId: string, commentThreadId: string) => { - vscode.postMessage({ - command: "deleteComment", - args: { commentId, commentThreadId }, - } as CommentPostMessages); - }; + const handleCreateThread = () => { + if (!newThreadText.trim() || !cellId.cellId || !currentUser.isAuthenticated) return; - const handleUndoCommentDeletion = (commentId: string, commentThreadId: string) => { - vscode.postMessage({ - command: "undoCommentDeletion", - args: { commentId, commentThreadId }, - } as CommentPostMessages); - }; - - const handleNewComment = () => { - if (!newCommentText.trim() || !cellId.cellId || !currentUser.isAuthenticated) return; - - // Generate a timestamp for the default title - const now = new Date(); - const defaultTitle = now.toLocaleString(); const timestamp = Date.now(); - const commentId = `${timestamp}-${Math.random().toString(36).substr(2, 9)}`; - const newThread: NotebookCommentThread = { id: uuidv4(), canReply: true, cellId: cellId, collapsibleState: 0, - threadTitle: defaultTitle, + threadTitle: new Date().toLocaleString(), deletionEvent: [], resolvedEvent: [], - comments: [ - { - id: commentId, - timestamp: timestamp, - body: newCommentText.trim(), - mode: 1, - author: { name: currentUser.username }, - deleted: false, - }, - ], - }; - - vscode.postMessage({ - command: "updateCommentThread", - commentThread: newThread, - } as CommentPostMessages); - - setNewCommentText(""); - setShowNewCommentForm(false); - }; - - const handleEditThreadTitle = (threadId: string) => { - if (!threadTitleEdit.trim()) return; - - const existingThread = commentThreadArray.find((thread) => thread.id === threadId); - if (!existingThread) return; - - const updatedThread = { - ...existingThread, - threadTitle: threadTitleEdit.trim(), + comments: [{ + id: `${timestamp}-${Math.random().toString(36).substr(2, 9)}`, + timestamp, + body: newThreadText.trim(), + mode: 1, + author: { name: currentUser.username }, + deleted: false, + }], }; - vscode.postMessage({ - command: "updateCommentThread", - commentThread: updatedThread, - } as CommentPostMessages); - - setEditingTitle(null); - setThreadTitleEdit(""); + vscode.postMessage({ command: "updateCommentThread", commentThread: newThread }); + setNewThreadText(""); + setShowNewThreadForm(false); + setSelectedThread(newThread.id); }; const toggleResolved = (thread: NotebookCommentThread) => { - setPendingResolveThreads((prev) => { - const next = new Set(prev); - next.add(thread.id); - return next; - }); - - // Determine if thread is currently resolved (latest event determines state) + setPendingResolveThreads((prev) => new Set(prev).add(thread.id)); const isCurrentlyResolved = isThreadResolved(thread); - // Add new event with opposite state and current timestamp - const updatedThread = { - ...thread, - resolvedEvent: [ - ...(thread.resolvedEvent || []), - { - timestamp: Date.now(), - author: { name: currentUser?.username || "Unknown" }, - resolved: !isCurrentlyResolved, - }, - ], - comments: [...thread.comments], - }; - vscode.postMessage({ command: "updateCommentThread", - commentThread: updatedThread, - } as CommentPostMessages); - }; - - const toggleCollapsed = (threadId: string) => { - setCollapsedThreads((prev) => ({ - ...prev, - [threadId]: !prev[threadId], - })); - }; - - const toggleAllThreads = (collapse: boolean) => { - const newState: Record = {}; - filteredCommentThreads.forEach((thread) => { - newState[thread.id] = collapse; + commentThread: { + ...thread, + resolvedEvent: [ + ...(thread.resolvedEvent || []), + { timestamp: Date.now(), author: { name: currentUser.username }, resolved: !isCurrentlyResolved }, + ], + }, }); - setCollapsedThreads(newState); }; - const getCellId = (cellId: string) => { - const parts = cellId.split(":"); - const finalPart = parts[parts.length - 1] || cellId; - // Show full cell ID if it's less than 10 characters - return cellId.length < 10 ? cellId : finalPart; + const handleDeleteComment = (commentId: string, threadId: string) => { + vscode.postMessage({ command: "deleteComment", args: { commentId, commentThreadId: threadId } }); }; - const filteredCommentThreads = useMemo(() => { - // First, get all non-deleted threads - const nonDeletedThreads = commentThreadArray.filter((thread) => !isThreadDeleted(thread)); - - // Then, apply additional filtering based on view mode, search, and resolved status - const filtered = nonDeletedThreads.filter((commentThread) => { - // Skip resolved threads if they're hidden - if (!showResolvedThreads && isThreadResolved(commentThread)) return false; - - // If in cell view mode, only show comments for the current cell - if (viewMode === "cell" && cellId.cellId) { - return commentThread.cellId.cellId === cellId.cellId; - } - - // If searching, filter by search query - if (searchQuery) { - return ( - commentThread.threadTitle?.toLowerCase().includes(searchQuery.toLowerCase()) || - commentThread.comments.some((comment) => - comment.body.toLowerCase().includes(searchQuery.toLowerCase()) - ) || - commentThread.cellId.cellId.toLowerCase().includes(searchQuery.toLowerCase()) - ); - } - - // In all view mode with no search, show all comments (except resolved ones if hidden) - return true; - }); - - // Sort threads by newest first (based on latest comment timestamp) - return filtered.sort((a, b) => { - const getLatestTimestamp = (thread: NotebookCommentThread) => { - const timestamps = thread.comments.map((c) => c.timestamp); - return Math.max(...timestamps); - }; - return getLatestTimestamp(b) - getLatestTimestamp(a); - }); - }, [commentThreadArray, searchQuery, viewMode, cellId.cellId, showResolvedThreads]); - - // Count of hidden resolved threads - const hiddenResolvedThreadsCount = useMemo(() => { - if (showResolvedThreads) return 0; - - const nonDeletedThreads = commentThreadArray.filter((thread) => !isThreadDeleted(thread)); - - return nonDeletedThreads.filter((thread) => { - const isResolved = isThreadResolved(thread); - const matchesCurrentCell = - viewMode !== "cell" || thread.cellId.cellId === cellId.cellId; - const matchesSearch = - !searchQuery || - thread.threadTitle?.toLowerCase().includes(searchQuery.toLowerCase()) || - thread.comments.some((comment) => - comment.body.toLowerCase().includes(searchQuery.toLowerCase()) - ) || - thread.cellId.cellId.toLowerCase().includes(searchQuery.toLowerCase()); - - return isResolved && matchesCurrentCell && matchesSearch; - }).length; - }, [commentThreadArray, viewMode, cellId.cellId, searchQuery, showResolvedThreads]); - - // Whether a user can start a new top-level comment thread (requires auth and active cell) - const canStartNewComment = currentUser.isAuthenticated && Boolean(cellId.cellId); - - // Helper function to render comment body with blockquotes - const renderCommentBody = (body: string) => { - if (!body) return null; - - const lines = body.split("\n"); - const elements: JSX.Element[] = []; - let currentQuoteLines: string[] = []; - - const flushQuote = () => { - if (currentQuoteLines.length > 0) { - elements.push( -
- {currentQuoteLines.join("\n")} -
- ); - currentQuoteLines = []; - } - }; - - lines.forEach((line, index) => { - if (line.startsWith("> ")) { - currentQuoteLines.push(line.substring(2)); - } else { - flushQuote(); - if (line.trim() || index < lines.length - 1) { - elements.push( - - {line} - {index < lines.length - 1 &&
} -
- ); - } - } - }); - - flushQuote(); - return elements; + const handleUndoDelete = (commentId: string, threadId: string) => { + vscode.postMessage({ command: "undoCommentDeletion", args: { commentId, commentThreadId: threadId } }); }; - const handleReplyToComment = (comment: Comment, threadId: string) => { - const quotedText = `> ${comment.body.replace(/\n/g, "\n> ")}\n\n`; - setReplyText((prev) => ({ - ...prev, - [threadId]: quotedText, - })); - setReplyingTo({ threadId, username: comment.author.name }); + // Render message content (without reply prefix) + const renderMessageContent = (content: string) => { + return content.split("\n").map((line, i, arr) => ( + + {line} + {i < arr.length - 1 &&
} +
+ )); }; - const CommentCard = ({ - thread, - comment, - }: { - thread: NotebookCommentThread; - comment: Comment; - }) => { - const formattedTime = formatTimestamp(comment.timestamp); - const [isHovered, setIsHovered] = useState(false); - - return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > -
- - - - - {comment.author.name} - - - {/* Comment content */} -
-
- - - - {formattedTime.display} - - - {formattedTime.full} - -
-
- {comment.deleted - ? "This comment has been deleted" - : renderCommentBody(comment.body)} -
-
+ // Thread list view + const ThreadList = () => ( +
+ {/* Header */} +
+
+ +
- {/* Action buttons - positioned at bottom right */} - {isHovered && currentUser.isAuthenticated && !comment.deleted && ( -
- - - {comment.author.name === currentUser.username && ( - - )} -
- )} - - {/* Undo deletion button for deleted comments - only show on hover */} - {comment.deleted && comment.author.name === currentUser.username && isHovered && ( -
- -
+ {currentUser.isAuthenticated && cellId.cellId && ( + )}
- ); - }; - return ( - -
- - - {/* Header */} -
- {/* {currentUser.isAuthenticated && ( -
- -
- )} */} - - {/* View mode selector */} -
-