feat(chat): implement target comment navigation and highlight functionality in chat components

This commit is contained in:
Anish Sarkar 2026-01-27 22:14:02 +05:30
parent 6eedce839a
commit 72c421eeb1
5 changed files with 128 additions and 34 deletions

View file

@ -13,7 +13,11 @@ import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod"; import { z } from "zod";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom"; import {
clearTargetCommentIdAtom,
currentThreadAtom,
setTargetCommentIdAtom,
} from "@/atoms/chat/current-thread.atom";
import { import {
type MentionedDocumentInfo, type MentionedDocumentInfo,
mentionedDocumentIdsAtom, mentionedDocumentIdsAtom,
@ -261,6 +265,8 @@ export default function NewChatPage() {
const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom); const setMessageDocumentsMap = useSetAtom(messageDocumentsMapAtom);
const hydratePlanState = useSetAtom(hydratePlanStateAtom); const hydratePlanState = useSetAtom(hydratePlanStateAtom);
const setCurrentThreadState = useSetAtom(currentThreadAtom); const setCurrentThreadState = useSetAtom(currentThreadAtom);
const setTargetCommentId = useSetAtom(setTargetCommentIdAtom);
const clearTargetCommentId = useSetAtom(clearTargetCommentIdAtom);
// Get current user for author info in shared chats // Get current user for author info in shared chats
const { data: currentUser } = useAtomValue(currentUserAtom); 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) // Handle scroll to comment from URL query params (e.g., from inbox item click)
const searchParams = useSearchParams(); 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(() => { useEffect(() => {
if (!targetCommentId || isInitializing || messages.length === 0) return; if (targetCommentIdParam && !isInitializing) {
const commentId = Number.parseInt(targetCommentIdParam, 10);
const tryScroll = () => { if (!Number.isNaN(commentId)) {
const el = document.querySelector(`[data-comment-id="${targetCommentId}"]`); setTargetCommentId(commentId);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
return true;
} }
return false; }
};
// Try immediately // Cleanup on unmount or when navigating away
if (tryScroll()) return; return () => clearTargetCommentId();
}, [targetCommentIdParam, isInitializing, setTargetCommentId, clearTargetCommentId]);
// 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]);
// Sync current thread state to atom // Sync current thread state to atom
useEffect(() => { useEffect(() => {
setCurrentThreadState({ setCurrentThreadState((prev) => ({
...prev,
id: currentThread?.id ?? null, id: currentThread?.id ?? null,
visibility: currentThread?.visibility ?? null, visibility: currentThread?.visibility ?? null,
hasComments: currentThread?.has_comments ?? false, hasComments: currentThread?.has_comments ?? false,
addingCommentToMessageId: null, addingCommentToMessageId: null,
}); }));
}, [currentThread, setCurrentThreadState]); }, [currentThread, setCurrentThreadState]);
// Cancel ongoing request // Cancel ongoing request

View file

@ -76,3 +76,20 @@ export const toggleCommentsCollapsedAtom = atom(null, (get, set) => {
export const setCommentsCollapsedAtom = atom(null, (get, set, collapsed: boolean) => { export const setCommentsCollapsedAtom = atom(null, (get, set, collapsed: boolean) => {
set(currentThreadAtom, { ...get(currentThreadAtom), commentsCollapsed: collapsed }); set(currentThreadAtom, { ...get(currentThreadAtom), commentsCollapsed: collapsed });
}); });
/** Target comment ID to scroll to (from URL navigation or inbox click) */
export const targetCommentIdAtom = atom<number | null>(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);
});

View file

@ -5,14 +5,16 @@ import {
MessagePrimitive, MessagePrimitive,
useAssistantState, useAssistantState,
} from "@assistant-ui/react"; } 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 { CheckIcon, CopyIcon, DownloadIcon, MessageSquare, RefreshCwIcon } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { useContext, useEffect, useRef, useState } from "react"; import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { import {
addingCommentToMessageIdAtom, addingCommentToMessageIdAtom,
clearTargetCommentIdAtom,
commentsCollapsedAtom, commentsCollapsedAtom,
commentsEnabledAtom, commentsEnabledAtom,
targetCommentIdAtom,
} from "@/atoms/chat/current-thread.atom"; } from "@/atoms/chat/current-thread.atom";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { BranchPicker } from "@/components/assistant-ui/branch-picker"; import { BranchPicker } from "@/components/assistant-ui/branch-picker";
@ -117,11 +119,23 @@ export const AssistantMessage: FC = () => {
const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false); const isLastMessage = useAssistantState(({ message }) => message?.isLast ?? false);
const isMessageStreaming = isThreadRunning && isLastMessage; const isMessageStreaming = isThreadRunning && isLastMessage;
const { data: commentsData } = useComments({ const { data: commentsData, isSuccess: commentsLoaded } = useComments({
messageId: dbMessageId ?? 0, messageId: dbMessageId ?? 0,
enabled: !!dbMessageId, 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 commentCount = commentsData?.total_count ?? 0;
const hasComments = commentCount > 0; const hasComments = commentCount > 0;
const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId; const isAddingComment = dbMessageId !== null && addingCommentToMessageId === dbMessageId;
@ -146,6 +160,24 @@ export const AssistantMessage: FC = () => {
return () => observer.disconnect(); 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; const showCommentTrigger = searchSpaceId && commentsEnabled && !isMessageStreaming && dbMessageId;
// Determine sheet side based on screen size // Determine sheet side based on screen size

View file

@ -1,6 +1,12 @@
"use client"; "use client";
import { useAtomValue, useSetAtom } from "jotai";
import { MessageSquare } from "lucide-react"; 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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -113,6 +119,37 @@ export function CommentItem({
members = [], members = [],
membersLoading = false, membersLoading = false,
}: CommentItemProps) { }: CommentItemProps) {
const commentRef = useRef<HTMLDivElement>(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 = const displayName =
comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown"; comment.author?.displayName || comment.author?.email.split("@")[0] || "Unknown";
const email = comment.author?.email || ""; const email = comment.author?.email || "";
@ -122,7 +159,14 @@ export function CommentItem({
}; };
return ( return (
<div className={cn("group flex gap-3")} data-comment-id={comment.id}> <div
ref={commentRef}
className={cn(
"group flex gap-3 rounded-lg p-1 -m-1 transition-all duration-300",
isHighlighted && "ring-2 ring-primary ring-offset-2 ring-offset-background"
)}
data-comment-id={comment.id}
>
<Avatar className="size-8 shrink-0"> <Avatar className="size-8 shrink-0">
{comment.author?.avatarUrl && ( {comment.author?.avatarUrl && (
<AvatarImage src={comment.author.avatarUrl} alt={displayName} /> <AvatarImage src={comment.author.avatarUrl} alt={displayName} />

View file

@ -21,7 +21,7 @@ import { AnimatePresence, motion } from "motion/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -175,6 +175,8 @@ export function InboxSidebar({
// Comments collapsed state (desktop only, when docked) // Comments collapsed state (desktop only, when docked)
const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom); const [, setCommentsCollapsed] = useAtom(setCommentsCollapsedAtom);
// Target comment for navigation - also ensures comments panel is visible
const [, setTargetCommentId] = useAtom(setTargetCommentIdAtom);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [activeTab, setActiveTab] = useState<InboxTab>("mentions"); const [activeTab, setActiveTab] = useState<InboxTab>("mentions");
@ -346,6 +348,12 @@ export function InboxSidebar({
const commentId = item.metadata.comment_id; const commentId = item.metadata.comment_id;
if (searchSpaceId && threadId) { 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 const url = commentId
? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}` ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
: `/dashboard/${searchSpaceId}/new-chat/${threadId}`; : `/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 () => { const handleMarkAllAsRead = useCallback(async () => {