mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-12 22:42:13 +02:00
feat(chat): implement target comment navigation and highlight functionality in chat components
This commit is contained in:
parent
6eedce839a
commit
72c421eeb1
5 changed files with 128 additions and 34 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue