diff --git a/surfsense_backend/app/services/chat_comments_service.py b/surfsense_backend/app/services/chat_comments_service.py
index 24fa2ec1d..fa26bf6d5 100644
--- a/surfsense_backend/app/services/chat_comments_service.py
+++ b/surfsense_backend/app/services/chat_comments_service.py
@@ -301,6 +301,7 @@ async def create_comment(
# Create notifications for mentioned users (excluding author)
thread = message.thread
author_name = user.display_name or user.email
+ content_preview = render_mentions(content, user_names)
for mentioned_user_id, mention_id in mentions_map.items():
if mentioned_user_id == user.id:
continue # Don't notify yourself
@@ -314,7 +315,7 @@ async def create_comment(
thread_title=thread.title or "Untitled thread",
author_id=str(user.id),
author_name=author_name,
- content_preview=content[:200],
+ content_preview=content_preview[:200],
search_space_id=search_space_id,
)
@@ -411,6 +412,7 @@ async def create_reply(
# Create notifications for mentioned users (excluding author)
thread = parent_comment.message.thread
author_name = user.display_name or user.email
+ content_preview = render_mentions(content, user_names)
for mentioned_user_id, mention_id in mentions_map.items():
if mentioned_user_id == user.id:
continue # Don't notify yourself
@@ -424,7 +426,7 @@ async def create_reply(
thread_title=thread.title or "Untitled thread",
author_id=str(user.id),
author_name=author_name,
- content_preview=content[:200],
+ content_preview=content_preview[:200],
search_space_id=search_space_id,
)
@@ -526,13 +528,16 @@ async def update_comment(
)
)
- # Add new mentions (existing ones keep their read status)
+ # Add new mentions and collect their IDs for notifications
+ new_mentions_map: dict[UUID, int] = {}
for user_id in mentions_to_add:
mention = ChatCommentMention(
comment_id=comment_id,
mentioned_user_id=user_id,
)
session.add(mention)
+ await session.flush()
+ new_mentions_map[user_id] = mention.id
comment.content = content
@@ -542,6 +547,28 @@ async def update_comment(
# Fetch user names for rendering mentions
user_names = await get_user_names_for_mentions(session, valid_new_mentions)
+ # Create notifications for newly added mentions (excluding author)
+ if new_mentions_map:
+ thread = comment.message.thread
+ author_name = user.display_name or user.email
+ content_preview = render_mentions(content, user_names)
+ for mentioned_user_id, mention_id in new_mentions_map.items():
+ if mentioned_user_id == user.id:
+ continue # Don't notify yourself
+ await NotificationService.mention.notify_new_mention(
+ session=session,
+ mentioned_user_id=mentioned_user_id,
+ mention_id=mention_id,
+ comment_id=comment_id,
+ message_id=comment.message_id,
+ thread_id=thread.id,
+ thread_title=thread.title or "Untitled thread",
+ author_id=str(user.id),
+ author_name=author_name,
+ content_preview=content_preview[:200],
+ search_space_id=search_space_id,
+ )
+
author = AuthorResponse(
id=user.id,
display_name=user.display_name,
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 c41dd872c..43c33ba5a 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
@@ -8,7 +8,7 @@ import {
} from "@assistant-ui/react";
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue, useSetAtom } from "jotai";
-import { useParams } from "next/navigation";
+import { useParams, useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
@@ -367,6 +367,38 @@ export default function NewChatPage() {
initializeThread();
}, [initializeThread]);
+ // Handle scroll to comment from URL query params (e.g., from notification click)
+ const searchParams = useSearchParams();
+ const targetCommentId = searchParams.get("commentId");
+
+ 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;
+ }
+ 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]);
+
// Sync current thread state to atom
useEffect(() => {
setCurrentThreadState({
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 362a6079e..8489bdb4a 100644
--- a/surfsense_web/components/chat-comments/comment-item/comment-item.tsx
+++ b/surfsense_web/components/chat-comments/comment-item/comment-item.tsx
@@ -68,7 +68,7 @@ function formatTimestamp(dateString: string): string {
);
}
-function convertRenderedToDisplay(contentRendered: string): string {
+export function convertRenderedToDisplay(contentRendered: string): string {
// Convert @{DisplayName} format to @DisplayName for editing
return contentRendered.replace(/@\{([^}]+)\}/g, "@$1");
}
@@ -128,7 +128,7 @@ export function CommentItem({
};
return (
-
+
{comment.author?.avatarUrl && (
diff --git a/surfsense_web/components/notifications/NotificationButton.tsx b/surfsense_web/components/notifications/NotificationButton.tsx
index 26b4713b5..e9f5db2dc 100644
--- a/surfsense_web/components/notifications/NotificationButton.tsx
+++ b/surfsense_web/components/notifications/NotificationButton.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useState } from "react";
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
@@ -12,6 +13,7 @@ import { cn } from "@/lib/utils";
import { useParams } from "next/navigation";
export function NotificationButton() {
+ const [open, setOpen] = useState(false);
const { data: user } = useAtomValue(currentUserAtom);
const params = useParams();
@@ -25,7 +27,7 @@ export function NotificationButton() {
);
return (
-
+
@@ -54,6 +56,7 @@ export function NotificationButton() {
loading={loading}
markAsRead={markAsRead}
markAllAsRead={markAllAsRead}
+ onClose={() => setOpen(false)}
/>
diff --git a/surfsense_web/components/notifications/NotificationPopup.tsx b/surfsense_web/components/notifications/NotificationPopup.tsx
index 74e2f1e31..9196ceaa4 100644
--- a/surfsense_web/components/notifications/NotificationPopup.tsx
+++ b/surfsense_web/components/notifications/NotificationPopup.tsx
@@ -1,12 +1,14 @@
"use client";
import { Bell, CheckCheck, Loader2, AlertCircle, CheckCircle2 } from "lucide-react";
+import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import type { Notification } from "@/hooks/use-notifications";
import { formatDistanceToNow } from "date-fns";
import { cn } from "@/lib/utils";
+import { convertRenderedToDisplay } from "@/components/chat-comments/comment-item/comment-item";
interface NotificationPopupProps {
notifications: Notification[];
@@ -14,6 +16,7 @@ interface NotificationPopupProps {
loading: boolean;
markAsRead: (id: number) => Promise;
markAllAsRead: () => Promise;
+ onClose?: () => void;
}
export function NotificationPopup({
@@ -22,15 +25,38 @@ export function NotificationPopup({
loading,
markAsRead,
markAllAsRead,
+ onClose,
}: NotificationPopupProps) {
- const handleMarkAsRead = async (id: number) => {
- await markAsRead(id);
- };
+ const router = useRouter();
const handleMarkAllAsRead = async () => {
await markAllAsRead();
};
+ const handleNotificationClick = async (notification: Notification) => {
+ if (!notification.read) {
+ await markAsRead(notification.id);
+ }
+
+ if (notification.type === "new_mention") {
+ const metadata = notification.metadata as {
+ thread_id?: number;
+ comment_id?: number;
+ };
+ const searchSpaceId = notification.search_space_id;
+ const threadId = metadata?.thread_id;
+ const commentId = metadata?.comment_id;
+
+ if (searchSpaceId && threadId) {
+ const url = commentId
+ ? `/dashboard/${searchSpaceId}/new-chat/${threadId}?commentId=${commentId}`
+ : `/dashboard/${searchSpaceId}/new-chat/${threadId}`;
+ onClose?.();
+ router.push(url);
+ }
+ }
+ };
+
const formatTime = (dateString: string) => {
try {
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
@@ -86,7 +112,7 @@ export function NotificationPopup({
- {notification.message}
+ {convertRenderedToDisplay(notification.message)}
diff --git a/surfsense_web/hooks/use-mentions-electric.ts b/surfsense_web/hooks/use-mentions-electric.ts
deleted file mode 100644
index d56891018..000000000
--- a/surfsense_web/hooks/use-mentions-electric.ts
+++ /dev/null
@@ -1,167 +0,0 @@
-"use client";
-
-import { useEffect, useState, useRef } from "react";
-import { useElectricClient } from "@/lib/electric/context";
-import type { SyncHandle } from "@/lib/electric/client";
-
-export interface ElectricMention {
- id: number;
- comment_id: number;
- mentioned_user_id: string;
- created_at: string;
-}
-
-/**
- * Hook for syncing mentions with Electric SQL for real-time updates.
- * Syncs all mentions for the current user.
- * @param userId - The user ID to sync mentions for
- */
-export function useMentionsElectric(userId: string | null) {
- const electricClient = useElectricClient();
-
- const [mentions, setMentions] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const syncHandleRef = useRef(null);
- const liveQueryRef = useRef<{ unsubscribe: () => void } | null>(null);
- const syncKeyRef = useRef(null);
-
- useEffect(() => {
- if (!electricClient) {
- setLoading(false);
- setError(new Error("Electric SQL not configured"));
- return;
- }
-
- if (!userId) {
- setMentions([]);
- setLoading(false);
- return;
- }
-
- const syncKey = `mentions_${userId}`;
- if (syncKeyRef.current === syncKey) {
- return;
- }
-
- let mounted = true;
- syncKeyRef.current = syncKey;
-
- const client = electricClient;
-
- async function startSync() {
- try {
- const handle = await client.syncShape({
- table: "chat_comment_mentions",
- where: `mentioned_user_id = '${userId}'`,
- primaryKey: ["id"],
- });
-
- if (!handle.isUpToDate && handle.initialSyncPromise) {
- try {
- await Promise.race([
- handle.initialSyncPromise,
- new Promise((resolve) => setTimeout(resolve, 2000)),
- ]);
- } catch (syncErr) {
- console.error("[useMentionsElectric] Initial sync failed:", syncErr);
- }
- }
-
- if (!mounted) {
- handle.unsubscribe();
- return;
- }
-
- syncHandleRef.current = handle;
- setLoading(false);
- setError(null);
-
- await fetchMentions();
- await setupLiveQuery();
- } catch (err) {
- if (!mounted) return;
- console.error("[useMentionsElectric] Failed to start sync:", err);
- setError(err instanceof Error ? err : new Error("Failed to sync mentions"));
- setLoading(false);
- }
- }
-
- async function fetchMentions() {
- try {
- const result = await client.db.query(
- `SELECT id, comment_id, mentioned_user_id, created_at
- FROM chat_comment_mentions
- WHERE mentioned_user_id = $1
- ORDER BY created_at DESC`,
- [userId]
- );
- if (mounted) {
- setMentions(result.rows || []);
- }
- } catch (err) {
- console.error("[useMentionsElectric] Failed to fetch:", err);
- }
- }
-
- async function setupLiveQuery() {
- try {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const db = client.db as any;
-
- if (db.live?.query && typeof db.live.query === "function") {
- const liveQuery = await db.live.query(
- `SELECT id, comment_id, mentioned_user_id, created_at
- FROM chat_comment_mentions
- WHERE mentioned_user_id = $1
- ORDER BY created_at DESC`,
- [userId]
- );
-
- if (!mounted) {
- liveQuery.unsubscribe?.();
- return;
- }
-
- if (liveQuery.initialResults?.rows) {
- setMentions(liveQuery.initialResults.rows);
- } else if (liveQuery.rows) {
- setMentions(liveQuery.rows);
- }
-
- if (typeof liveQuery.subscribe === "function") {
- liveQuery.subscribe((result: { rows: ElectricMention[] }) => {
- if (mounted && result.rows) {
- setMentions(result.rows);
- }
- });
- }
-
- if (typeof liveQuery.unsubscribe === "function") {
- liveQueryRef.current = liveQuery;
- }
- }
- } catch (liveErr) {
- console.error("[useMentionsElectric] Failed to set up live query:", liveErr);
- }
- }
-
- startSync();
-
- return () => {
- mounted = false;
- syncKeyRef.current = null;
-
- if (syncHandleRef.current) {
- syncHandleRef.current.unsubscribe();
- syncHandleRef.current = null;
- }
- if (liveQueryRef.current) {
- liveQueryRef.current.unsubscribe();
- liveQueryRef.current = null;
- }
- };
- }, [userId, electricClient]);
-
- return { mentions, loading, error };
-}