diff --git a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx index 4509a44a7..1a00873a5 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/new-chat/[[...chat_id]]/page.tsx @@ -13,7 +13,11 @@ import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { z } from "zod"; -import { currentThreadAtom } from "@/atoms/chat/current-thread.atom"; +import { + clearTargetCommentIdAtom, + currentThreadAtom, + setTargetCommentIdAtom, +} from "@/atoms/chat/current-thread.atom"; import { type MentionedDocumentInfo, mentionedDocumentIdsAtom, @@ -261,6 +265,8 @@ export default function NewChatPage() { const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom); const hydratePlanState = useSetAtom(hydratePlanStateAtom); const setCurrentThreadState = useSetAtom(currentThreadAtom); + const setTargetCommentId = useSetAtom(setTargetCommentIdAtom); + const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom); // Get current user for author info in shared chats const { data: currentUser } = useAtomValue(currentUserAtom); @@ -424,44 +430,31 @@ export default function NewChatPage() { // Handle scroll to comment from URL query params (e.g., from inbox item click) const searchParams = useSearchParams(); - const targetCommentId = searchParams.get("commentId"); + const targetCommentIdParam = searchParams.get("commentId"); + // Set target comment ID from URL param - the AssistantMessage and CommentItem + // components will handle scrolling and highlighting once comments are loaded useEffect(() => { - if (!targetCommentId || isInitializing || messages.length === 0) return; - - const tryScroll = () => { - const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`); - if (el) { - el.scrollIntoView({ behavior: "smooth", block: "center" }); - return true; + if (targetCommentIdParam && !isInitializing) { + const commentId = Number.parseInt(targetCommentIdParam, 10); + if (!Number.isNaN(commentId)) { + setTargetCommentId(commentId); } - return false; - }; + } - // Try immediately - if (tryScroll()) return; - - // Retry every 200ms for up to 10 seconds - const intervalId = setInterval(() => { - if (tryScroll()) clearInterval(intervalId); - }, 200); - - const timeoutId = setTimeout(() => clearInterval(intervalId), 10000); - - return () => { - clearInterval(intervalId); - clearTimeout(timeoutId); - }; - }, [targetCommentId, isInitializing, messages.length]); + // Cleanup on unmount or when navigating away + return () => clearTargetCommentId(); + }, [targetCommentIdParam, isInitializing, setTargetCommentId, clearTargetCommentId]); // Sync current thread state to atom useEffect(() => { - setCurrentThreadState({ + setCurrentThreadState((prev) => ({ + ...prev, id: currentThread?.id ?? null, visibility: currentThread?.visibility ?? null, hasComments: currentThread?.has_comments ?? false, addingCommentToMessageId: null, - }); + })); }, [currentThread, setCurrentThreadState]); // Cancel ongoing request diff --git a/surfsense_web/atoms/chat/current-thread.atom.ts b/surfsense_web/atoms/chat/current-thread.atom.ts index dea926633..18afb8ff3 100644 --- a/surfsense_web/atoms/chat/current-thread.atom.ts +++ b/surfsense_web/atoms/chat/current-thread.atom.ts @@ -76,3 +76,20 @@ export const toggleCommentsCollapsedAtom = atom(null, (get, set) => { export const setCommentsCollapsedAtom = atom(null, (get, set, collapsed: boolean) => { set(currentThreadAtom, { ...get(currentThreadAtom), commentsCollapsed: collapsed }); }); + +/** Target comment ID to scroll to (from URL navigation or inbox click) */ +export const targetCommentIdAtom = atom(null); + +/** Setter for target comment ID - also ensures comments are not collapsed */ +export const setTargetCommentIdAtom = atom(null, (get, set, commentId: number | null) => { + // Ensure comments are not collapsed when navigating to a comment + if (commentId !== null) { + set(currentThreadAtom, { ...get(currentThreadAtom), commentsCollapsed: false }); + } + set(targetCommentIdAtom, commentId); +}); + +/** Clear target after navigation completes */ +export const clearTargetCommentIdAtom = atom(null, (_, set) => { + set(targetCommentIdAtom, null); +}); diff --git a/surfsense_web/components/assistant-ui/assistant-message.tsx b/surfsense_web/components/assistant-ui/assistant-message.tsx index 4fb8d8393..4fd2446c3 100644 --- a/surfsense_web/components/assistant-ui/assistant-message.tsx +++ b/surfsense_web/components/assistant-ui/assistant-message.tsx @@ -5,14 +5,16 @@ import { MessagePrimitive, useAssistantState, } from "@assistant-ui/react"; -import { useAtom, useAtomValue } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react"; import type { FC } from "react"; -import { useContext, useEffect, useRef, useState } from "react"; +import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { addingCommentToMessageIdAtom, + clearTargetCommentIdAtom, commentsCollapsedAtom, commentsEnabledAtom, + targetCommentIdAtom, } from "@/atoms/chat/current-thread.atom"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { BranchPicker } from "@/components/assistant-ui/branch-picker"; @@ -117,11 +119,23 @@ export const AssistantMessage: FC = () => { const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false); const isMessageStreaming = isThreadRunning && isLastMessage; - const { data: commentsData } = useComments({ + const { data: commentsData, isSuccess: commentsLoaded } = useComments({ messageId: dbMessageId ?? 0, enabled: !!dbMessageId, }); + // Target comment navigation - read target from global atom + const targetCommentId = useAtomValue(targetCommentIdAtom); + const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom); + + // Check if target comment belongs to this message (including replies) + const hasTargetComment = useMemo(() => { + if (!targetCommentId || !commentsData?.comments) return false; + return commentsData.comments.some( + (c) => c.id === targetCommentId || c.replies?.some((r) => r.id === targetCommentId) + ); + }, [targetCommentId, commentsData]); + const commentCount = commentsData?.total_count ?? 0; const hasComments = commentCount > 0; const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId; @@ -146,6 +160,24 @@ export const AssistantMessage: FC = () => { return () => observer.disconnect(); }, []); + // Auto-open sheet on mobile/tablet when this message has the target comment + useEffect(() => { + if (hasTargetComment && !isDesktop && commentsLoaded) { + setIsSheetOpen(true); + } + }, [hasTargetComment, isDesktop, commentsLoaded]); + + // Scroll message into view when it contains target comment (desktop) + useEffect(() => { + if (hasTargetComment && isDesktop && commentsLoaded && messageRef.current) { + // Small delay to ensure DOM is ready after comments render + const timeoutId = setTimeout(() => { + messageRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, 100); + return () => clearTimeout(timeoutId); + } + }, [hasTargetComment, isDesktop, commentsLoaded]); + const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId; // Determine sheet side based on screen size diff --git a/surfsense_web/components/chat-comments/comment-item/comment-item.tsx b/surfsense_web/components/chat-comments/comment-item/comment-item.tsx index 8b2503e8c..847886fc8 100644 --- a/surfsense_web/components/chat-comments/comment-item/comment-item.tsx +++ b/surfsense_web/components/chat-comments/comment-item/comment-item.tsx @@ -1,6 +1,12 @@ "use client"; +import { useAtomValue, useSetAtom } from "jotai"; import { MessageSquare } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { + clearTargetCommentIdAtom, + targetCommentIdAtom, +} from "@/atoms/chat/current-thread.atom"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -113,6 +119,37 @@ export function CommentItem({ members = [], membersLoading = false, }: CommentItemProps) { + const commentRef = useRef(null); + const [isHighlighted, setIsHighlighted] = useState(false); + + // Target comment navigation + const targetCommentId = useAtomValue(targetCommentIdAtom); + const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom); + + const isTarget = targetCommentId === comment.id; + + // Scroll into view and highlight when this is the target comment + useEffect(() => { + if (isTarget && commentRef.current) { + // Small delay to ensure DOM is ready + const scrollTimeoutId = setTimeout(() => { + commentRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + setIsHighlighted(true); + }, 150); + + // Remove highlight and clear target after delay + const clearTimeoutId = setTimeout(() => { + setIsHighlighted(false); + clearTargetCommentId(); + }, 3000); + + return () => { + clearTimeout(scrollTimeoutId); + clearTimeout(clearTimeoutId); + }; + } + }, [isTarget, clearTargetCommentId]); + const displayName = comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown"; const email = comment.author?.email || ""; @@ -122,7 +159,14 @@ export function CommentItem({ }; return ( -
+
{comment.author?.avatarUrl && ( diff --git a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx index 69ab714d8..1a488c6b0 100644 --- a/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/InboxSidebar.tsx @@ -21,7 +21,7 @@ import { AnimatePresence, motion } from "motion/react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { setCommentsCollapsedAtom } from "@/atoms/chat/current-thread.atom"; +import { setCommentsCollapsedAtom, setTargetCommentIdAtom } from "@/atoms/chat/current-thread.atom"; import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; @@ -175,6 +175,8 @@ export function InboxSidebar({ // Comments collapsed state (desktop only, when docked) const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom); + // Target comment for navigation - also ensures comments panel is visible + const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom); const [searchQuery, setSearchQuery] = useState(""); const [activeTab, setActiveTab] = useState("mentions"); @@ -346,6 +348,12 @@ export function InboxSidebar({ const commentId = item.metadata.comment_id; if (searchSpaceId && threadId) { + // Pre-set target comment ID before navigation + // This also ensures comments panel is not collapsed + if (commentId) { + setTargetCommentId(commentId); + } + const url = commentId ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}` : `/dashboard/${searchSpaceId}/new-chat/${threadId}`; @@ -356,7 +364,7 @@ export function InboxSidebar({ } } }, - [markAsRead, router, onOpenChange, onCloseMobileSidebar] + [markAsRead, router, onOpenChange, onCloseMobileSidebar, setTargetCommentId] ); const handleMarkAllAsRead = useCallback(async () => {