;
+ 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/contracts/types/chat-comments.types.ts b/surfsense_web/contracts/types/chat-comments.types.ts
new file mode 100644
index 000000000..92b3ff060
--- /dev/null
+++ b/surfsense_web/contracts/types/chat-comments.types.ts
@@ -0,0 +1,142 @@
+import { z } from "zod";
+
+export const author = z.object({
+ id: z.string().uuid(),
+ display_name: z.string().nullable(),
+ avatar_url: z.string().nullable(),
+ email: z.string(),
+});
+
+export const commentReply = z.object({
+ id: z.number(),
+ content: z.string(),
+ content_rendered: z.string(),
+ author: author.nullable(),
+ created_at: z.string(),
+ updated_at: z.string(),
+ is_edited: z.boolean(),
+ can_edit: z.boolean(),
+ can_delete: z.boolean(),
+});
+
+export const comment = z.object({
+ id: z.number(),
+ message_id: z.number(),
+ content: z.string(),
+ content_rendered: z.string(),
+ author: author.nullable(),
+ created_at: z.string(),
+ updated_at: z.string(),
+ is_edited: z.boolean(),
+ can_edit: z.boolean(),
+ can_delete: z.boolean(),
+ reply_count: z.number(),
+ replies: z.array(commentReply),
+});
+
+export const mentionContext = z.object({
+ thread_id: z.number(),
+ thread_title: z.string(),
+ message_id: z.number(),
+ search_space_id: z.number(),
+ search_space_name: z.string(),
+});
+
+export const mentionComment = z.object({
+ id: z.number(),
+ content_preview: z.string(),
+ author: author.nullable(),
+ created_at: z.string(),
+});
+
+export const mention = z.object({
+ id: z.number(),
+ created_at: z.string(),
+ comment: mentionComment,
+ context: mentionContext,
+});
+
+/**
+ * Get comments for a message
+ */
+export const getCommentsRequest = z.object({
+ message_id: z.number(),
+});
+
+export const getCommentsResponse = z.object({
+ comments: z.array(comment),
+ total_count: z.number(),
+});
+
+/**
+ * Create comment
+ */
+export const createCommentRequest = z.object({
+ message_id: z.number(),
+ content: z.string().min(1).max(5000),
+});
+
+export const createCommentResponse = comment;
+
+/**
+ * Create reply
+ */
+export const createReplyRequest = z.object({
+ comment_id: z.number(),
+ content: z.string().min(1).max(5000),
+});
+
+export const createReplyResponse = commentReply;
+
+/**
+ * Update comment
+ */
+export const updateCommentRequest = z.object({
+ comment_id: z.number(),
+ content: z.string().min(1).max(5000),
+});
+
+export const updateCommentResponse = commentReply;
+
+/**
+ * Delete comment
+ */
+export const deleteCommentRequest = z.object({
+ comment_id: z.number(),
+});
+
+export const deleteCommentResponse = z.object({
+ message: z.string(),
+ comment_id: z.number(),
+});
+
+/**
+ * Get mentions
+ */
+export const getMentionsRequest = z.object({
+ search_space_id: z.number().optional(),
+});
+
+export const getMentionsResponse = z.object({
+ mentions: z.array(mention),
+ total_count: z.number(),
+});
+
+export type Author = z.infer;
+export type CommentReply = z.infer;
+export type Comment = z.infer;
+export type MentionContext = z.infer;
+export type MentionComment = z.infer;
+export type Mention = z.infer;
+export type GetCommentsRequest = z.infer;
+export type GetCommentsResponse = z.infer;
+export type CreateCommentRequest = z.infer;
+export type CreateCommentResponse = z.infer;
+export type CreateReplyRequest = z.infer;
+export type CreateReplyResponse = z.infer;
+export type UpdateCommentRequest = z.infer;
+export type UpdateCommentResponse = z.infer;
+export type DeleteCommentRequest = z.infer;
+export type DeleteCommentResponse = z.infer;
+export type GetMentionsRequest = z.infer;
+export type GetMentionsResponse = z.infer;
diff --git a/surfsense_web/contracts/types/mcp.types.ts b/surfsense_web/contracts/types/mcp.types.ts
index e25ffe3c5..4a0a3c31a 100644
--- a/surfsense_web/contracts/types/mcp.types.ts
+++ b/surfsense_web/contracts/types/mcp.types.ts
@@ -1,15 +1,24 @@
import { z } from "zod";
/**
- * MCP Server Configuration Schema (similar to Cursor's config)
+ * MCP Server Configuration Schema
+ * Supports both stdio (local process) and HTTP (remote server) transports
*/
-export const mcpServerConfig = z.object({
+const stdioConfigSchema = z.object({
command: z.string().min(1, "Command is required"),
args: z.array(z.string()).default([]),
env: z.record(z.string(), z.string()).default({}),
- transport: z.enum(["stdio", "sse", "http"]).default("stdio"),
+ transport: z.enum(["stdio"]).default("stdio"),
});
+const httpConfigSchema = z.object({
+ url: z.string().url("URL must be a valid URL"),
+ headers: z.record(z.string(), z.string()).default({}),
+ transport: z.enum(["streamable-http", "http", "sse"]),
+});
+
+export const mcpServerConfig = z.union([stdioConfigSchema, httpConfigSchema]);
+
/**
* MCP Connector Schemas
*/
diff --git a/surfsense_web/contracts/types/members.types.ts b/surfsense_web/contracts/types/members.types.ts
index d20109b96..9e0665c65 100644
--- a/surfsense_web/contracts/types/members.types.ts
+++ b/surfsense_web/contracts/types/members.types.ts
@@ -11,6 +11,8 @@ export const membership = z.object({
created_at: z.string(),
role: role.nullable().optional(),
user_email: z.string().nullable().optional(),
+ user_display_name: z.string().nullable().optional(),
+ user_avatar_url: z.string().nullable().optional(),
user_is_active: z.boolean().nullable().optional(),
});
diff --git a/surfsense_web/contracts/types/notification.types.ts b/surfsense_web/contracts/types/notification.types.ts
index 6a11e81b4..afd4f1232 100644
--- a/surfsense_web/contracts/types/notification.types.ts
+++ b/surfsense_web/contracts/types/notification.types.ts
@@ -5,7 +5,11 @@ import { documentTypeEnum } from "./document.types";
/**
* Notification type enum - matches backend notification types
*/
-export const notificationTypeEnum = z.enum(["connector_indexing", "document_processing"]);
+export const notificationTypeEnum = z.enum([
+ "connector_indexing",
+ "document_processing",
+ "new_mention",
+]);
/**
* Notification status enum - used in metadata
@@ -68,6 +72,20 @@ export const documentProcessingMetadata = baseNotificationMetadata.extend({
error_message: z.string().nullable().optional(),
});
+/**
+ * New mention metadata schema
+ */
+export const newMentionMetadata = z.object({
+ mention_id: z.number(),
+ comment_id: z.number(),
+ message_id: z.number(),
+ thread_id: z.number(),
+ thread_title: z.string(),
+ author_id: z.string(),
+ author_name: z.string(),
+ content_preview: z.string(),
+});
+
/**
* Union of all notification metadata types
* Use this when the notification type is unknown
@@ -75,6 +93,7 @@ export const documentProcessingMetadata = baseNotificationMetadata.extend({
export const notificationMetadata = z.union([
connectorIndexingMetadata,
documentProcessingMetadata,
+ newMentionMetadata,
baseNotificationMetadata,
]);
@@ -107,6 +126,11 @@ export const documentProcessingNotification = notification.extend({
metadata: documentProcessingMetadata,
});
+export const newMentionNotification = notification.extend({
+ type: z.literal("new_mention"),
+ metadata: newMentionMetadata,
+});
+
// Inferred types
export type NotificationTypeEnum = z.infer;
export type NotificationStatusEnum = z.infer;
@@ -114,7 +138,9 @@ export type DocumentProcessingStageEnum = z.infer;
export type ConnectorIndexingMetadata = z.infer;
export type DocumentProcessingMetadata = z.infer;
+export type NewMentionMetadata = z.infer;
export type NotificationMetadata = z.infer;
export type Notification = z.infer;
export type ConnectorIndexingNotification = z.infer;
export type DocumentProcessingNotification = z.infer;
+export type NewMentionNotification = z.infer;
diff --git a/surfsense_web/hooks/use-comments.ts b/surfsense_web/hooks/use-comments.ts
new file mode 100644
index 000000000..4f027d67c
--- /dev/null
+++ b/surfsense_web/hooks/use-comments.ts
@@ -0,0 +1,18 @@
+import { useQuery } from "@tanstack/react-query";
+import { chatCommentsApiService } from "@/lib/apis/chat-comments-api.service";
+import { cacheKeys } from "@/lib/query-client/cache-keys";
+
+interface UseCommentsOptions {
+ messageId: number;
+ enabled?: boolean;
+}
+
+export function useComments({ messageId, enabled = true }: UseCommentsOptions) {
+ return useQuery({
+ queryKey: cacheKeys.comments.byMessage(messageId),
+ queryFn: async () => {
+ return chatCommentsApiService.getComments({ message_id: messageId });
+ },
+ enabled: enabled && !!messageId,
+ });
+}
diff --git a/surfsense_web/lib/apis/chat-comments-api.service.ts b/surfsense_web/lib/apis/chat-comments-api.service.ts
new file mode 100644
index 000000000..952de7a25
--- /dev/null
+++ b/surfsense_web/lib/apis/chat-comments-api.service.ts
@@ -0,0 +1,134 @@
+import {
+ type CreateCommentRequest,
+ type CreateReplyRequest,
+ createCommentRequest,
+ createCommentResponse,
+ createReplyRequest,
+ createReplyResponse,
+ type DeleteCommentRequest,
+ deleteCommentRequest,
+ deleteCommentResponse,
+ type GetCommentsRequest,
+ type GetMentionsRequest,
+ getCommentsRequest,
+ getCommentsResponse,
+ getMentionsRequest,
+ getMentionsResponse,
+ type UpdateCommentRequest,
+ updateCommentRequest,
+ updateCommentResponse,
+} from "@/contracts/types/chat-comments.types";
+import { ValidationError } from "@/lib/error";
+import { baseApiService } from "./base-api.service";
+
+class ChatCommentsApiService {
+ /**
+ * Get comments for a message
+ */
+ getComments = async (request: GetCommentsRequest) => {
+ const parsed = getCommentsRequest.safeParse(request);
+
+ if (!parsed.success) {
+ const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ return baseApiService.get(
+ `/api/v1/messages/${parsed.data.message_id}/comments`,
+ getCommentsResponse
+ );
+ };
+
+ /**
+ * Create a top-level comment
+ */
+ createComment = async (request: CreateCommentRequest) => {
+ const parsed = createCommentRequest.safeParse(request);
+
+ if (!parsed.success) {
+ const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ return baseApiService.post(
+ `/api/v1/messages/${parsed.data.message_id}/comments`,
+ createCommentResponse,
+ { body: { content: parsed.data.content } }
+ );
+ };
+
+ /**
+ * Create a reply to a comment
+ */
+ createReply = async (request: CreateReplyRequest) => {
+ const parsed = createReplyRequest.safeParse(request);
+
+ if (!parsed.success) {
+ const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ return baseApiService.post(
+ `/api/v1/comments/${parsed.data.comment_id}/replies`,
+ createReplyResponse,
+ { body: { content: parsed.data.content } }
+ );
+ };
+
+ /**
+ * Update a comment
+ */
+ updateComment = async (request: UpdateCommentRequest) => {
+ const parsed = updateCommentRequest.safeParse(request);
+
+ if (!parsed.success) {
+ const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ return baseApiService.put(`/api/v1/comments/${parsed.data.comment_id}`, updateCommentResponse, {
+ body: { content: parsed.data.content },
+ });
+ };
+
+ /**
+ * Delete a comment
+ */
+ deleteComment = async (request: DeleteCommentRequest) => {
+ const parsed = deleteCommentRequest.safeParse(request);
+
+ if (!parsed.success) {
+ const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ return baseApiService.delete(
+ `/api/v1/comments/${parsed.data.comment_id}`,
+ deleteCommentResponse
+ );
+ };
+
+ /**
+ * Get mentions for current user
+ */
+ getMentions = async (request?: GetMentionsRequest) => {
+ const parsed = getMentionsRequest.safeParse(request ?? {});
+
+ if (!parsed.success) {
+ const errorMessage = parsed.error.issues.map((issue) => issue.message).join(", ");
+ throw new ValidationError(`Invalid request: ${errorMessage}`);
+ }
+
+ const params = new URLSearchParams();
+ if (parsed.data.search_space_id !== undefined) {
+ params.set("search_space_id", String(parsed.data.search_space_id));
+ }
+
+ const queryString = params.toString();
+ const url = queryString ? `/api/v1/mentions?${queryString}` : "/api/v1/mentions";
+
+ return baseApiService.get(url, getMentionsResponse);
+ };
+}
+
+export const chatCommentsApiService = new ChatCommentsApiService();
diff --git a/surfsense_web/lib/chat/thread-persistence.ts b/surfsense_web/lib/chat/thread-persistence.ts
index 23dd35800..738d1062f 100644
--- a/surfsense_web/lib/chat/thread-persistence.ts
+++ b/surfsense_web/lib/chat/thread-persistence.ts
@@ -23,6 +23,7 @@ export interface ThreadRecord {
search_space_id: number;
created_at: string;
updated_at: string;
+ has_comments?: boolean;
}
export interface MessageRecord {
diff --git a/surfsense_web/lib/electric/client.ts b/surfsense_web/lib/electric/client.ts
index 0a881c235..c33969914 100644
--- a/surfsense_web/lib/electric/client.ts
+++ b/surfsense_web/lib/electric/client.ts
@@ -48,6 +48,10 @@ let initPromise: Promise | null = null;
// Cache for sync handles to prevent duplicate subscriptions (memory optimization)
const activeSyncHandles = new Map();
+// Track pending sync operations to prevent race conditions
+// If a sync is in progress, subsequent calls will wait for it instead of starting a new one
+const pendingSyncs = new Map>();
+
// Version for sync state - increment this to force fresh sync when Electric config changes
// Set to v2 for user-specific database architecture
const SYNC_VERSION = 2;
@@ -224,6 +228,19 @@ export async function initElectric(userId: string): Promise {
CREATE INDEX IF NOT EXISTS idx_documents_search_space_type ON documents(search_space_id, document_type);
`);
+ // Create the chat_comment_mentions table schema in PGlite
+ await db.exec(`
+ CREATE TABLE IF NOT EXISTS chat_comment_mentions (
+ id INTEGER PRIMARY KEY,
+ comment_id INTEGER NOT NULL,
+ mentioned_user_id TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_user_id ON chat_comment_mentions(mentioned_user_id);
+ CREATE INDEX IF NOT EXISTS idx_chat_comment_mentions_comment_id ON chat_comment_mentions(comment_id);
+ `);
+
const electricUrl = getElectricUrl();
// STEP 4: Create the client wrapper
@@ -243,7 +260,16 @@ export async function initElectric(userId: string): Promise {
return existingHandle;
}
- // Build params for the shape request
+ // Check if there's already a pending sync for this shape (prevent race condition)
+ const pendingSync = pendingSyncs.get(cacheKey);
+ if (pendingSync) {
+ console.log(`[Electric] Waiting for pending sync to complete: ${cacheKey}`);
+ return pendingSync;
+ }
+
+ // Create and track the sync promise to prevent race conditions
+ const syncPromise = (async (): Promise => {
+ // Build params for the shape request
// Electric SQL expects params as URL query parameters
const params: Record = { table };
@@ -394,7 +420,55 @@ export async function initElectric(userId: string): Promise {
) => Promise<{ unsubscribe: () => void; isUpToDate: boolean; stream: unknown }>;
};
};
- const shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig);
+
+ let shape: { unsubscribe: () => void; isUpToDate: boolean; stream: unknown };
+ try {
+ shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig);
+ } catch (syncError) {
+ // Handle "Already syncing" error - pglite-sync might not have fully cleaned up yet
+ const errorMessage = syncError instanceof Error ? syncError.message : String(syncError);
+ if (errorMessage.includes("Already syncing")) {
+ console.warn(`[Electric] Already syncing ${table}, waiting for existing sync to settle...`);
+
+ // Wait a short time for pglite-sync to settle
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ // Check if an active handle now exists (another sync might have completed)
+ const existingHandle = activeSyncHandles.get(cacheKey);
+ if (existingHandle) {
+ console.log(`[Electric] Found existing handle after waiting: ${cacheKey}`);
+ return existingHandle;
+ }
+
+ // Retry once after waiting
+ console.log(`[Electric] Retrying sync for ${table}...`);
+ try {
+ shape = await pgWithElectric.electric.syncShapeToTable(shapeConfig);
+ } catch (retryError) {
+ const retryMessage = retryError instanceof Error ? retryError.message : String(retryError);
+ if (retryMessage.includes("Already syncing")) {
+ // Still syncing - create a placeholder handle that indicates the table is being synced
+ console.warn(`[Electric] ${table} still syncing, creating placeholder handle`);
+ const placeholderHandle: SyncHandle = {
+ unsubscribe: () => {
+ console.log(`[Electric] Placeholder unsubscribe for: ${cacheKey}`);
+ activeSyncHandles.delete(cacheKey);
+ },
+ get isUpToDate() {
+ return false; // We don't know the real state
+ },
+ stream: undefined,
+ initialSyncPromise: Promise.resolve(), // Already syncing means data should be coming
+ };
+ activeSyncHandles.set(cacheKey, placeholderHandle);
+ return placeholderHandle;
+ }
+ throw retryError;
+ }
+ } else {
+ throw syncError;
+ }
+ }
if (!shape) {
throw new Error("syncShapeToTable returned undefined");
@@ -555,6 +629,18 @@ export async function initElectric(userId: string): Promise {
}
throw error;
}
+ })();
+
+ // Track the sync promise to prevent concurrent syncs for the same shape
+ pendingSyncs.set(cacheKey, syncPromise);
+
+ // Clean up the pending sync when done (whether success or failure)
+ syncPromise.finally(() => {
+ pendingSyncs.delete(cacheKey);
+ console.log(`[Electric] Pending sync removed for: ${cacheKey}`);
+ });
+
+ return syncPromise;
},
};
@@ -600,8 +686,9 @@ export async function cleanupElectric(): Promise {
}
}
}
- // Ensure cache is empty
+ // Ensure caches are empty
activeSyncHandles.clear();
+ pendingSyncs.clear();
try {
// Close the PGlite database connection
diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts
index 54f411ad1..72f2bbd54 100644
--- a/surfsense_web/lib/query-client/cache-keys.ts
+++ b/surfsense_web/lib/query-client/cache-keys.ts
@@ -72,4 +72,7 @@ export const cacheKeys = {
["connectors", "google-drive", connectorId, "folders", parentId] as const,
},
},
+ comments: {
+ byMessage: (messageId: number) => ["comments", "message", messageId] as const,
+ },
};